検索
連載

「Python 3.11」で追加されたExceptionGroup例外とexcept*節、TaskGroupとはPython最新情報キャッチアップ

複数のタスクから独立に例外が発生したらどうすればよいだろう。これをスマートに処理するためにPython 3.11に追加されたExceptionGroupやTaskGroupなどを紹介する。

PC用表示 関連情報
Share
Tweet
LINE
Hatena
「Python最新情報キャッチアップ」のインデックス

連載目次

 前回はPython 3.11に組み込まれたFaster CPythonプロジェクトの成果について見た。今回はPython 3.11で新たに追加された例外クラス「ExceptionGroup」とこれを取り扱うためのexcept*節、ExceptionGroup例外と組で使うことになるであろうTaskGroupクラスについて見ていく。

ExceptionGroup例外とは

 ExceptionGroup例外とexcept*節はPEP 654で提案されたものだ。簡単にまとめるとExceptionGroup例外とは、無関係な例外を複数まとめて処理するための機構だ。そして、まとめられた複数の例外を個別に処理するために追加されたのがexcept*節となる。

 簡単な例を以下に示す。

raise ExceptionGroup(
    'exception group',
    [
        TypeError('TypeError in ExceptionGroup'),
        ValueError('ValueError in ExceptionGroup')
    ]
)

ExceptionGroup例外

 TypeError例外とValueError例外をExceptionGroup例外にまとめて送出している。このように、ExceptionGroup例外のインスタンスを生成する際には、例外についてのメッセージとそこに含める例外を要素とするシーケンス(リストなど)を与える。これを実行すると、その結果は次のようになる。

>>> raise ExceptionGroup(
...     'exception group',
...     [
...         TypeError('TypeError in ExceptionGroup'),
...         ValueError('ValueError in ExceptionGroup')
...     ]
... )
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: exception group (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: TypeError in ExceptionGroup
    +---------------- 2 ----------------
    | ValueError: ValueError in ExceptionGroup
    +------------------------------------


発生したExceptionGroup例外についてのトレースバックが表示されたところ

 先のコードは例外を送出するだけだったので、その結果は例外についてのトレースバック情報の表示となる。最初に概要が、その下にインデント付きでExceptionGroupでラップした2つの例外が並んでいることが分かる。

 なお、シーケンスに格納する例外の中にさらにExceptionGroup例外を含めることも可能だ。

raise ExceptionGroup(
    'exception group',
    [
        TypeError('TypeError in ExceptionGroup'),
        ValueError('ValueError in ExceptionGroup'),
        ExceptionGroup(
            'another exception group',
            [RuntimeError('RuntimeError in another ExceptionGroup')]
        )
    ]
)

ネストしたExceptionGroup例外

 これを実行すると次のようになる。

>>> raise ExceptionGroup(
...     'exception group',
...     [
...         TypeError('TypeError in ExceptionGroup'),
...         ValueError('ValueError in ExceptionGroup'),
...         ExceptionGroup(
...             'another exception group',
...             [RuntimeError('RuntimeError in another ExceptionGroup')]
...         )
...     ]
... )
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: exception group (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | TypeError: TypeError in ExceptionGroup
    +---------------- 2 ----------------
    | ValueError: ValueError in ExceptionGroup
    +---------------- 3 ----------------
    | ExceptionGroup: another exception group (1 sub-exception)
    +-+---------------- 1 ----------------
      | RuntimeError: RuntimeError in another ExceptionGroup
      +------------------------------------


ネストしたExceptionGroup例外のトレースバック情報

 ネストしたExceptionGroup例外の内容がさらにインデントを付けられて表示されていることに注意しよう。

ExceptionGroup例外の処理

 ExceptionGroup例外を処理するには大きく分けて2つのやり方がある。

 1つはExceptionGroup例外にまとめられている個々の例外をexcept*節で処理する方法だ。基本的には以下のような形になる。

try:
    # ExceptionGroup例外を送出するコード
except* TypeError as e:
    # ExceptionGroup例外に格納されているTypeError例外を処理
except* ValueError as e:
    # ExceptionGroup例外に格納されているValueError例外を処理

except*節を使って個々の例外を処理

 もう1つはExceptionGroup例外全体をexcept節でまとめて捕捉する方法だ。こちらは通常のtry〜except文と同様だ。

try:
    # ExceptionGroup例外を送出するコード
except ExceptionGroup as eg:
    # ExceptionGroup例外に格納されている例外を個別に取り出して処理する

except節でExceptionGroup例外の全体を捕捉して処理

 以下ではこれらについて簡単に見ていく。

except*節での処理

 以下は本稿の冒頭でお目にかけたExceptionGroup例外を送出するコードをtry〜except*節で処理するコードだ。

try:
    raise ExceptionGroup(
        'exception group',
        [
            TypeError('TypeError in ExceptionGroup'),
            ValueError('ValueError in ExceptionGroup')
        ]
    )
except* TypeError as e:
    print(type(e))
    print(repr(e))
except* ValueError as e:
    print(repr(e))

except*節での例外処理

 ここではTypeErrorとValueErrorの2つの例外がExceptionGroup例外にまとめて送出されており、それらを2つのexcept*節で処理する。通常のtry〜except文では複数のexcept節があっても何かの例外を処理するときにはいずれかのexcept節のみが実行される。しかし、except*節ではExceptionGroupに格納されている例外を(それにマッチするexcept*節があれば)全て処理できるのが大きな違いである。

 実際に実行すると次のようになる。

>>> try:
...     raise ExceptionGroup(
...         'exception group',
...         [
...             TypeError('TypeError in ExceptionGroup'),
...             ValueError('ValueError in ExceptionGroup')
...         ]
...     )
... except* TypeError as e:
...     print(type(e))
...     print(repr(e))
... except* ValueError as e:
...     print(repr(e))
...     print(e.exceptions)
...
<class 'ExceptionGroup'>
ExceptionGroup('exception group', [TypeError('TypeError in ExceptionGroup')])
ExceptionGroup('exception group', [ValueError('ValueError in ExceptionGroup')])
(ValueError('ValueError in ExceptionGroup'),)


例外処理の結果

 print関数による4つの出力(最後の4行)に注目してほしい。最初の行は、捕捉したTypeErrorを代入した変数eの型を表示したものだが、TypeErrorではなくExceptionGroupになっている。ExceptionGroup例外をexcept*節で捕捉したときには、それを代入した変数の型は常にExceptionGroupになる。実際の例外を取り出すには、そのexceptions属性にアクセスする(後述)。

 その下の2行は変数eをrepr関数で文字列化した結果だ。これを見ると、最初のexcept*節でTypeError例外が、次のexcept*節でValueError例外が捕捉されているのが分かる。

 最後の行が上述したExceptionGroupオブジェクトのexceptions属性を表示したものだ。ここではExceptionGroup例外に格納されていたTypeError例外を表すオブジェクトがタプルの要素となっていることが分かる。例外発生時の情報を取り出して、何らかの処理をするのであれば、ここからそれらにアクセスできるだろう。

 なお、ExceptionGroup例外をexcept*節で処理することは許されていない。ExceptionGroup例外を捕捉して処理するにはexcept節を使用する必要がある。

except節での処理

 次にexcept節でExceptionGroup例外の全体を捕捉して処理する例を見てみよう。ここでも、例外を送出するコードは同じものとする。

try:
    raise ExceptionGroup(
        'exception group',
        [
            TypeError('TypeError in ExceptionGroup'),
            ValueError('ValueError in ExceptionGroup')
        ]
    )
except ExceptionGroup as eg:
    print(eg.exceptions)

except節でExceptionGroup例外を処理

 実行結果を以下に示す。

>>> try:
...     raise ExceptionGroup(
...         'exception group',
...         [
...             TypeError('TypeError in ExceptionGroup'),
...             ValueError('ValueError in ExceptionGroup')
...         ]
...     )
... except ExceptionGroup as eg:
...     print(eg.exceptions)
...
(TypeError('TypeError in ExceptionGroup'), ValueError('ValueError in ExceptionGroup'))


except節でExceptionGroup例外を処理した結果

 先ほどと同じく、exceptions属性の内容を表示してみると、ExceptionGroup例外に格納されている2つの例外がタプルに格納されていることが分かる。

 ではこれらの例外を個別に処理するにはどうするかというと、例えば、以下のようなコードが考えられる。

try:
    raise ExceptionGroup(
        'exception group',
        [
            TypeError('TypeError in ExceptionGroup'),
            ValueError('ValueError in ExceptionGroup')
        ]
    )
except ExceptionGroup as eg:
    print(eg.exceptions)
    typeerr_group, rest = eg.split(lambda e: isinstance(e, TypeError))
    valerr_group, rest = rest.split(lambda e: isinstance(e, ValueError))

    if typeerr_group:
        print(f'matched: {typeerr_group.exceptions}')

    if valerr_group:
        print(f'matched: {valerr_group.exceptions}')

    if rest:
        print('re-raise rest exception group')
        raise rest

splitメソッドでTypeError例外とその他の例外を切り分けて、TypeError例外とValueError例外だけを処理し、他の例外は再送出する

 ExceptionGroupクラスにはsplitメソッドがある。これはExceptionGroup例外がラップしている例外を、引数に指定した条件にマッチするものとマッチしないものに分けて返送する。上のコードはこのメソッドを使ってTypeError例外、ValueError例外、その他の例外に分けて、TypeError例外とValueError例外は処理をして、その他の例外は再送出するようにしている。

 実行結果を以下に示す(try節は省略)。

... except ExceptionGroup as eg:
...     print(eg.exceptions)
...     typeerr_group, rest = eg.split(lambda e: isinstance(e, TypeError))
...     valerr_group, rest = rest.split(lambda e: isinstance(e, ValueError))
...     if typeerr_group:
...         print(f'matched: {typeerr_group.exceptions}')
...     if valerr_group:
...         print(f'matched: {valerr_group.exceptions}')
...     if rest:
...         print('re-raise rest exception group')
...         raise rest
...
(TypeError('TypeError in ExceptionGroup'), ValueError('ValueError in ExceptionGroup'))
matched: (TypeError('TypeError in ExceptionGroup'),)
matched: (ValueError('ValueError in ExceptionGroup'),)


ExceptionGroup例外から例外を個別に取り出して処理

 最初の出力はexceptions属性に格納されている例外の一覧だ(直前の例と同じ)。その後の2行はTypeError例外とValueError例外の処理が行われたことを示している。

 この他にも、exceptions属性を走査して、if文やmatch文で例外の型をチェックして、個別に処理していくといった方法も考えられるが、ここではsplitメソッドを使ってみた。

未処理の例外

 次にexcept*節で処理をしなかった例外があったらどうなるかを見てみよう。

try:
    raise ExceptionGroup(
        'exception group',
        [
            TypeError('TypeError in ExceptionGroup'),
            ValueError('ValueError in ExceptionGroup'),
            RuntimeError('RuntimeError in ExceptionGroup')
        ]
    )
except* TypeError as e:
    print(e.exceptions)
except* ValueError as e:
    print(e.exceptions)

RuntimeError例外をexcept*節で処理していないコード

 この例ではこれまでのTypeError例外とValueError例外に加えてRuntimeError例外も送出するようになっている。これを実行すると、次のような結果になる。

>>> try:
...     raise ExceptionGroup(
...         'exception group',
...         [
...             TypeError('TypeError in ExceptionGroup'),
...             ValueError('ValueError in ExceptionGroup'),
...             RuntimeError('RuntimeError in ExceptionGroup')
...         ]
...     )
... except* TypeError as e:
...     print(e.exceptions)
... except* ValueError as e:
...     print(e.exceptions)
...
(TypeError('TypeError in ExceptionGroup'),)
(ValueError('ValueError in ExceptionGroup'),)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  | ExceptionGroup: exception group (1 sub-exception)
  +-+---------------- 1 ----------------
    | RuntimeError: RuntimeError in ExceptionGroup
    +------------------------------------


未処理の例外はExceptionGroup例外として上位に伝播する

 RuntimeError例外は処理していないので、これが上位に伝播され、これを処理する部分がなかったのでトレースバックが表示された。try〜except*節が想定していなかった例外が発生した場合にそれを処理するコードは一般にはtry文をネストさせて次のようになる。

try:
    try:
        raise ExceptionGroup(
            'exception group',
            [
                TypeError('TypeError in ExceptionGroup'),
                ValueError('ValueError in ExceptionGroup'),
                RuntimeError('RuntimeError in ExceptionGroup')
            ]
        )
    except* TypeError as e:
        print(e.exceptions)
    except* ValueError as e:
        print(e.exceptions)
except ExceptionGroup as eg:
    print(eg.exceptions)

未処理のExceptionGroup例外を処理

 先ほど見たexcept*節で個々の例外を処理するコードの外側にtry〜except文を置き、そこでExceptionGroup例外を捕捉するようにしている(内側で未処理の例外があれば、それもExceptionGroup例外にラップされて上位に伝播することに注意)。

 ここまでに見てきたのが、ExceptionGroup例外を処理する基本的な方法といえるだろう。次に、実際に並行的に幾つかの処理を行うコードから1つ以上の例外が発生した場合のコードについて考えてみよう。

並行的に何かの処理を行うコード

 以下はPython 3.10ベースでasyncioモジュールを使って並行的に何らかの処理を行うコードの例だ。

import asyncio
from random import randint

async def do_work(num):
    print(f'work #{num} start')
    delay = randint(1, 5)
    await asyncio.sleep(delay)
    print(f'work #{num} end')
    return num

async def do_some_works():
    works = [asyncio.create_task(do_work(n)) for n in range(3)]
    # works = [do_work(n) for n in range(3)]
    result = await asyncio.gather(*works)
    return result

async def main():
    try:
        result = await do_some_works()
        return result
    except TypeError as e:
        print(f'TypeError: {e}')
    except ValueError as e:
        print(f'ValueError: {e}')
    except Exception as e:
        print(f'{e.__class__.__name__} {e}')

並行的に3つの処理を行うコード

 詳しい説明は省略するが、do_work関数(コルーチン。async付きで定義された関数)が実際に何らかの処理を行う。ここではランダムな秒数を待機した後、パラメーターnumに受け取った値を返すだけだ。

 次のdo_some_works関数(コルーチン)はdo_workコルーチンから3つのタスクを作成して、並行に実行し、それらの実行が終わるまで待機をしてから、結果を返す。このときには3つのコルーチンに引数として0、1、2を渡している。そのため、3つのdo_workコルーチンの戻り値も0、1、2となり、それらを格納したリストである[0, 1, 2]がgather関数の戻り値となる。

 最後のmain関数(コルーチン)はdo_some_worksコルーチンを実行し、それが実行を終えるまで待機してから結果を受け取り、呼び出し元に返送するか、例外が発生したときにはそれらを処理するようになっている。この時点では例外を発生するコードは含まれていない点に注意。

 最後に以下のコードで実際にmainコルーチンを実行し、その結果を表示してみよう。

result = asyncio.run(main())
print(result)

mainコルーチンを実行し、結果を表示

 実行結果の例を以下に示す。3つのdo_workコルーチンはそれぞれがランダムな秒数だけスリープすることを思い出してほしい。

>>> result = asyncio.run(main())
work #0 start
work #1 start
work #2 start
work #1 end
work #2 end
work #0 end
>>> print(result)
[0, 1, 2]


実行結果

 コルーチンの実行開始は0→1→2となっているが、終了は1→2→0となっていることからも3つのコルーチンが並行に実行され、コルーチン0のスリープ時間が一番長かったことが分かる。

Python 3.10で並行処理時に発生した例外を処理

 do_workコルーチンとdo_some_worksコルーチンを以下のように修正して、例外クラスを受け取り、それを送出するようにした。

async def do_work(klass, num):
    print(f'work #{num} start')
    delay = randint(1, 5)
    await asyncio.sleep(delay)
    if randint(0, 1) == 0:
        raise klass(f'exception from task #{num}')
    print(f'work #{num} end'# not reach
    return num

async def do_some_works():
    print('start')
    works = [
        asyncio.create_task(do_work(TypeError, 0)),
        asyncio.create_task(do_work(ValueError, 1)),
        asyncio.create_task(do_work(RuntimeError, 2))
    ]
    result = await asyncio.gather(*works)
    print('end')
    return result

async def main():
    try:
        result = await do_some_works()
        return result
    except TypeError as e:
        print(f'TypeError: {e}')
    except ValueError as e:
        print(f'ValueError: {e}')
    except Exception as e:
        print(f'{e.__class__.__name__} {e}')

result = asyncio.run(main())
print(result)

修正後のコード

 do_workコルーチンは何らかの(戻り値となる)値に加えて例外クラスを受け取るようにし、1/2の確率で受け取ったら例外クラスのインスタンスを生成して送出するようにしてある。do_some_worksコルーチンはasyncio.gather関数に渡すタスクの作成時に0、1、2の値と共にTypeError、ValueError、RuntimeErrorの各例外クラスをdo_workコルーチンに渡す。mainコルーチンに変更はない。

 この実行結果の例を以下に示す。

>>> result = asyncio.run(main())
start
work #0 start
work #1 start
work #2 start
work #2 end
TypeError: exception from task #0
>>> print(result)
None


実行結果の例

 この例ではタスク0でTypeError例外が発生し、それを処理したところで実行は中断し、各タスクの実行結果は得られない(例外が発生したタスクを再実行するのはありかもしれないが、果たして、そのタスクを実行するために必要なコンテキストがそろっているかは不明だ)。何より他のタスクでも例外が発生したとしたら、その処理が行われない。

 Python 3.11では新たにasyncioモジュールにTaskGroupクラスが追加されている。これはタスクをひとまとめにして、タスク実行時に例外が発生したときにはそれらをExceptionGroup例外として呼び出し元に伝播する。以下ではこれを使ってみよう。

Python 3.11で並行処理時に発生した例外を処理

 上記のコードをPython 3.11のTaskGroupとExceptionGroupを使って書き直したものが以下だ。

import asyncio
from random import randint

async def do_work(klass, num):
    print(f'work #{num} start')
    delay = randint(1, 5)
    await asyncio.sleep(delay)
    if randint(0, 1) == 0:
        raise klass(f'exception from task #{num}')
    print(f'work #{num} end')
    return num

async def do_some_works():
    print('start')
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(do_work(TypeError, 0))
        task2 = tg.create_task(do_work(ValueError, 1))
        task3 = tg.create_task(do_work(RuntimeError, 2))
    print('end')
    return [task1.result(), task2.result(), task3.result()]

async def main():
    try:
        try:
            result = await do_some_works()
            return result
        except* TypeError:
            print('TypeError')
        except* ValueError:
            print('ValueError')
    except ExceptionGroup as eg:
        for ex in eg.exceptions:
            print(f'propagated: {ex.__class__.__name__}, {ex}')

result = asyncio.run(main())

TaskGroupとExceptionGroupを使って書き直したコード

 do_workコルーチンは共通だ。Python 3.10版のdo_some_worksコルーチンは次のようなコードになっていた。

async def do_some_works():
    print('start')
    works = [
        asyncio.create_task(do_work(TypeError, 0)),
        asyncio.create_task(do_work(ValueError, 1)),
        asyncio.create_task(do_work(RuntimeError, 2))
    ]
    result = await asyncio.gather(*works)
    print('end')
    return result

do_some_worksコルーチン(Python 3.10)

 asyncio.create_task関数でdo_workコルーチンからタスクを作成して、それをasyncio.gather関数に渡して、それらの処理が完了するのを待機している。

 これがPython 3.11では次のようになる。

async def do_some_works():
    print('start')
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(do_work(TypeError, 0))
        task2 = tg.create_task(do_work(ValueError, 1))
        task3 = tg.create_task(do_work(RuntimeError, 2))
    print('end')
    return [task1.result(), task2.result(), task3.result()]

do_some_worksコルーチン(Python 3.11)

 asyncio.TaskGroupはコンテキストマネジャーとしても機能する。そのブロックでは、TaskGroup.create_taskメソッドを用いてタスクを作成している。そして、このasync with文では全てのタスクが完了するのを待機するようになっている。TaskGroupコンテキストマネジャーの内部におけるタスク実行時に発生した例外はExceptionGroupにまとめられて送出される。

 これを処理するmainコルーチンは次のようにした。

async def main():
    try:
        try:
            result = await do_some_works()
            return result
        except* TypeError:
            print('TypeError')
        except* ValueError:
            print('ValueError')
    except ExceptionGroup as eg:
        for ex in eg.exceptions:
            print(f'propagated: {ex.__class__.__name__}, {ex}')

mainコルーチン(Python 3.11)

 「未処理の例外」でも触れたようにtry文をネストさせ、内側ではTypeError例外とValueError例外を処理し、RuntimeError例外は内側のtry文では処理をしていない。そのため、これは外側のtry文へと伝播し、そこで処理される。

 実行結果の例を以下に示す。

>>> result = asyncio.run(main())
start
work #0 start
work #1 start
work #2 start
TypeError
propagated: RuntimeError, exception from task #2


実行結果の例

 この例ではTypeError例外とRuntimeError例外が発生し、それらが処理されたことが分かる。これは単一の例外(とそれにチェーンする例外)しか処理できないPython 3.10のコードとは大きく異なる点となる。

 なお、ExceptionGroupとTaskGroupについてはpython.jpの「Python 3.11の新機能(その4)PEP 654 例外グループとTaskGroup」や、Pythonの公式ドキュメント「Exception groups」「Task Groups」なども参考にされたい。

「Python最新情報キャッチアップ」のインデックス

Python最新情報キャッチアップ

Copyright© Digital Advantage Corp. All Rights Reserved.

[an error occurred while processing this directive]
ページトップに戻る