Python3.11の新機能 Python 3.11の新機能(その4)PEP 654 例外グループとTaskGroup

(その1) CPython高速化計画(その2) 特殊化適応的インタープリタ(その3) 関数呼び出しのインライン化(その4) 例外グループとTaskGroup

Python 3.11では、新しい構文として、例外処理に try ~ except* が追加されました

PEP 654 – Exception Groups and except*

ちょっと用途がわかりにくい機能ですが、try ~ except* は、主に asyncio による非同期処理で利用することを想定した機能で、asyncio を使っていない場合はさしあたってあまり気にしなくても良いかもしれません。

ExceptionGroup例外

Python3.11では、組み込みの例外型に ExceptionGroup が追加されました。新しく追加された try ~ except* は、主に ExceptionGroup 例外と組み合わせて使用します。

ExceptionGroup 例外は、複数の例外が発生したとき、一つの例外にまとめるためのオブジェクトです。たとえば、 2つの例外 ValueError 例外と RuntimeError 例外が発生した場合、一つの ExceptionGroup 例外として送出できます。

次の例は、ValueError 例外と RuntimeError 例外をまとめて、一つの ExceptionGroup 例外として raise しています。

>>> excepions = [ValueError("例外1"), RuntimeError("例外2")]
>>> raise ExceptionGroup("2つの例外が発生した!", excepions)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: 2つの例外が発生した (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: 例外1
    +---------------- 2 ----------------
    | RuntimeError: 例外2
    +------------------------------------

ExceptionGroup 例外は、ValueError などの例外と同じ、単なる例外オブジェクトです。try ~ except ブロックを使うと、次のように except 節に指定して捕捉できます。

>>> try:
...     excepions = [ValueError("例外1"), RuntimeError("例外2")]
...     raise ExceptionGroup("2つの例外が発生した!", excepions)
...
... except ExceptionGroup as e:
...     print("ExceptionGroup:", repr(e))
...
ExceptionGroup: ExceptionGroup('2つの例外が発生した!', 
                               [ValueError('例外1'), RuntimeError('例外2')])

try ~ except*ブロック

では、こんどは Python3.11で導入された try ~ except* ブロックで、 ExceptionGroup 例外を捕捉してみましょう。

>>> try:
...     excepions = [ValueError("例外1"), RuntimeError("例外2")]
...     raise ExceptionGroup("2つの例外が発生した!", excepions)
...
... except* ValueError as e:
...     print("ValueError:", repr(e))
...
... except* RuntimeError as e:
...     print("RuntimeError :", repr(e))
...
ValueError: ExceptionGroup('2つの例外が発生した!', [ValueError('例外1')])
RuntimeError : ExceptionGroup('2つの例外が発生した!', [RuntimeError('例外2')])

try ~ except とは大きく違う点が二つ、目に付きます。

複数の except* ブロックが実行される

まず第一に、通常の try ~ except ブロックであれば、複数の except ブロックが実行されることはありません。

たとえば、次のコードで ValueError 例外が発生すると、except ValueError のブロックだけが実行されます。RuntimeError 例外が発生すると、 except RuntimeError のブロックだけが実行されます。

>>> try:
...     raise ValueError("例外のテスト")
...
... except ValueError as e:
...     print("ValueError:", repr(e))
...
... except RuntimeError as e:
...     print("RuntimeError:", repr(e))
...
ValueError: ValueError('例外のテスト')

except ValueError ブロックと except RuntimeError ブロックのうち、実行されるのはどちらか一方だけで、両方が実行されることは決してありません。

しかし、try ~ except* ブロックでは、ExceptionGroup 例外が発生した場合、ExceptionGroup に登録されている例外のブロックが すべて 実行されます。先程の例では、 ValueErrorRuntimeError が登録されていますので、except ValueError ブロックと except RuntimeError ブロックの 両方 が実行されます。

例外オブジェクトはExceptionGroup.exceptions属性で参照する

第二に、try ~ exept ブロックで except ValueError as e と指定した場合、変数 e には必ず except節に指定した ValueError 型のオブジェクトが代入されます。

一方、except* ValueError as e の場合、except* 節の指定に関わらず、変数 e には必ず ExceptionGroup 型のオブジェクトとなります。

上の例で、 except* ValueError as e で指定した例外ハンドラを実行するとき、例外オブジェクト e にはExceptionGroup 型のオブジェクトが代入されます。ValueError 型のオブジェクトではありません。

実際に発生した例外オブジェクトは、ExceptionGroupオブジェクトの exceptions フィールド に格納されます。exceptions は例外オブジェクトのシーケンスで、except* 節に指定した例外型にマッチする例外オブジェクトだけが格納されます。

次の例では、ValueError が二つ、RuntimeError が一つ、KeyError が一つ発生しています。

>>> try:
...     excepions = [ValueError("例外1"), ValueError("例外2"),
...                  RuntimeError("例外3"), KeyError("例外4")]
...     raise ExceptionGroup("4つの例外が発生した!", excepions)
...
... except* (ValueError, RuntimeError) as e:
...     print("ValueErrorとRuntimeError:", repr(e.exceptions))
...
... except* KeyError as e:
...     print("KeyError:", repr(e.exceptions))
... 
ValueErrorとRuntimeError: (ValueError('例外1'), ValueError('例外2'),
                           RuntimeError('例外3'))
KeyError: (KeyError('例外4'),)

except* (ValueError, RuntimeError) as e のブロックが実行するとき、e.exceptionsには二つの ValueError と一つの RuntimeError が格納されます。

同様に、except* KeyError as e のブロックを実行するときには、KeyError 一つだけが格納されます。

未処理例外

通常の try ~ except ブロックの場合、try ブロックで発生して except で指定されていない例外は、親のブロックに送出されます。

try ~ except* の場合も同様ですが、try ~ except* では、except* で指定されて いない 例外だけが送出されます。次の例では、ValueErrorRuntimeError, KeyError が発生していますが、ValueErrorRuntimeError は内部ブロックで捕捉しています。

親ブロックには内部のブロックで捕捉していない KeyError だけが送出されます。

>>> try:
...     try:
...         excepions = [ValueError("例外1"), RuntimeError("例外2"),KeyError(" 例外3")]
...         raise ExceptionGroup("3つの例外が発生した!", excepions)
...     except* ValueError as e:
...         print("内部 ValueError:", repr(e))
...     except* RuntimeError as e:
...         print("内部 RuntimeError:", repr(e))
... except* KeyError as e:
...     print("外部 KeyError:", repr(e))
...
内部 ValueError: ExceptionGroup('3つの例外が発生した!', [ValueError('例外1')])
内部 RuntimeError: ExceptionGroup('3つの例外が発生した!', [RuntimeError('例外2')])
外部 KeyError: ExceptionGroup('3つの例外が発生した!', [KeyError('例外3')])

Naked例外

try ~ except* ブロックで except* ValueError as e と指定して例外を捕捉するとき、変数 e には必ず ExceptionGroup 型のオブジェクトが代入されます。ValueError 型のオブジェクトではありません。

これは、try ~ except* ブロックの try ブロックで ExceptionGroup 以外の例外が送出された場合も同様です。例えば ValueError などの例外が発生した場合でも、except* 節には自動的に ExceptionGroup 例外が渡されます。

>>> try:
...     raise ValueError("ValueErrorです!")
... except* ValueError as e:
...     print("ValueError:", repr(e))
...
ValueError: ExceptionGroup('', (ValueError('ValueErrorです!'),))

このように、try ~ except*try ブロックで ExceptionGroup 以外の例外が発生した場合、このような例外を Naked例外 と呼びます。Naked例外が発生した場合、自動的に新しく生成した ExceptionGroup でラップされます。

この例では、try ブロックで ValueError 例外が送出されていますが、この ValueError をラップする ExceptionGroup 例外が生成されて、変数 e に代入されています。

asyncio.TaskGroup

これまで説明してきた try ~ except* ブロックと ExceptionGroup は、主に asyncio による非同期処理で使うことを想定して開発されました。

通常の同期的な処理の場合、実行している処理は常にひとつだけです。例外が発生するとそこで処理は中断しますので、「複数の例外が発生する」ということはありません。

しかし、複数の非同期タスクを起動すると、それぞれの非同期タスクで別々に例外が発生する可能性があります。

Python 3.10までの複数タスク

Python 3.10では、複数の非同期タスクを同時に実行する場合、asyncio.gather()asyncio.wait() などを利用します。

次の非同期関数 get_sum() は、asyncio.gather() を使ってWeb APIから複数のデータを取得し、結果を集計しています。

import asyncio
import aiohttp

async def fetch(session, params):
    async with session.post(url="/api", json=params) as resp:
        resp.raise_for_status()
        data = await resp.json()
        return data['value']

async def get_sum():
    async with aiohttp.ClientSession("https://www.example.com") as session:
        results = await asyncio.gather(
            fetch(session, {"id":1}),
            fetch(session, {"id":2}),
            fetch(session, {"id":3}),
            )
        return sum(results)

async def main():
    while True:
        try:
            await get_sum()
            break
        except aiohttp.ClientConnectorError:
            # 最初のエラーがClientConnectorErrorの場合はリトライする
            pass
        except aiohttp.ClientResponseError:
            # 最初のエラーがClientResponseErrorの場合は一秒まってからリトライする
            await asyncio.sleep(1)

asyncio.run(main())

この処理は、ほぼ同時にWeb APIサーバにリクエストを3回送出します。どれか一つでもエラーが発生した場合、asyncio.gather() は最初のエラーを例外として送出して、呼び出し元に復帰してします。

エラーが発生したのが一つだけならこれでも構いません。しかし、2番目と3番目のリクエストでもエラーが発生していている場合、この二つのエラー情報はそのまま捨てられてしまいます。

発生したすべてのエラーを取得する方法もありますが、取得したエラー情報やトレースバックをすべて呼び出し元に伝えるのは、通常の例外処理に比べるとかなり面倒です。

Python 3.11の複数タスク

そこで、Python 3.11で複数のタスク実行を管理する asyncio.TaskGroup が追加されました。TaskGroup は、発生した例外をすべて ExceptionGroup例外 にまとめて送出します。

先程の処理は、TaskGroup を使って次のように書けます。

import asyncio
import aiohttp

async def fetch(session, params):
    # 3.10版と同じ
    async with session.post(url="/api", json=params) as resp:
        resp.raise_for_status()
        data = await resp.json()
        return data['value']

async def get_sum():
    async with aiohttp.ClientSession("https://www.example.com") as session:
        async with asyncio.TaskGroup() as tg:
            task1 = tg.create_task(fetch(session, {"id":1}))
            task2 = tg.create_task(fetch(session, {"id":2}))
            task3 = tg.create_task(fetch(session, {"id":3}))

        return task1.result() + task2.result() + task3.result()

async def main():
    while True:
        try:
            await get_sum()
            break
        except* aiohttp.ClientConnectorError:
            # ClientConnectorErrorの場合はリトライする
            pass
        except* aiohttp.ClientResponseError:
            # ClientResponseErrorが一つでも発生していたら一秒まってからリトライする
            await asyncio.sleep(1)

asyncio.run(main())

TaskGroup は非同期コンテキストマネージャとして使用し、TaskGroup.create_task() で登録したタスクがすべて終了するか、例外が発生すると終了します。例外が発生した場合、他の実行中のタスクは自動的にキャンセルします。

登録したタスクで例外が発生した場合、TaskGroup はすべての例外を ExceptionGroup例外 にまとめて送出します。ですので、呼び出し元では try ~ except* で発生したすべての例外を捕捉し、より適切な例外処理を実行できるようになります。

この例の場合、try ~ except の場合には最初に受け取ったエラーに応じた例外処理しかできていませんが、TaskGrouptry ~ except* を使ったバージョンでは、発生しているすべてのエラーを参照して処理を決定しています。

Copyright © 2001-2022 python.jp Privacy Policy python_japan
Amazon.co.jpアソシエイト
Amazonで他のPython書籍を検索