2023年5月: 型チェッカーでロジックの間違いを検出できるtyping.assert_never関数とtyping.Never型の紹介(ryu22e)

筒井@ryu22eです。2023年5月の『Python Monthly Topics』のテーマは、Python 3.11からtypingモジュールに追加された「assert_never関数、Never型」です。

みなさんは「この行には仕様上絶対に到達しないはず」というコードを書いたことはありますか? そして、バグが原因で到達しないはずの行に到達してしまった経験はありませんか? assert_never関数、Never型にはこのようなミスを型チェッカー(MypyPyrightなど)で検出してくれる便利な機能があります。

本記事では、サンプルコードを交えて実際にassert_never関数、Never型がどう役立つのか解説します。

なお、型チェッカーはMypy 1.2.0を使用します。

assert_never関数、Never型についての公式ドキュメントは以下を参照してください。

「到達しないはずなのにバグのため到達してしまう行」を検出してくれるassert_never関数

まず最初にassert_never関数について解説します。

以下のサンプルコードには、色を表す列挙型Colorとそれを色名に変換する関数get_color_nameが定義されています。

colors.py
from enum import Enum, auto


class Color(Enum):
    RED = auto()
    BLUE = auto()
    YELLOW = auto()


def get_color_name(color: Color) -> str:
    match color:
        case Color.RED:
            return "赤"
        case Color.BLUE:
            return "青"
        # Color.YELLOWがないのはバグ
        case _ as unreachable:
            raise AssertionError(unreachable)


if __name__ == "__main__":
    print(get_color_name(Color.RED))
    print(get_color_name(Color.BLUE))
    print(get_color_name(Color.YELLOW))

get_color_name関数の中ではColor型の引数colorを構造的パターンマッチ[1]で検証しています。 コメントにも書いているように、Color.YELLOWの場合が書かれていません。これは、うっかり書き忘れてバグを埋め込んでしまった前提で読んでください。

このコードを実行してみましょう。print(get_color_name(Color.YELLOW))を呼び出した時点で、get_color_name関数の中でどのパターンにも該当しない場合のコードcase _ as unreachable:に到達してAssertionErrorを送出します。

$ python colors.py
赤
青
Traceback (most recent call last):
  File "/****/colors.py", line 24, in <module>
    print(get_color_name(Color.YELLOW))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/****/colors.py", line 18, in get_color_name
    raise AssertionError(unreachable)
AssertionError: Color.YELLOW

このバグをリリース前に見つけるには、Python 3.11以前ならテストコードを書くしかありませんでした。しかし、テストデータにColor.YELLOWが抜けていた場合はバグを検出することはできません。

こんな時に活躍するのがassert_never関数です。 前述のコードのget_color_name関数でAssertionErrorを送出している部分を書き換えて、assert_never関数を使うようにしてみます。

colors2.py
from enum import Enum, auto
from typing import assert_never


class Color(Enum):
    RED = auto()
    BLUE = auto()
    YELLOW = auto()


def get_color_name(color: Color) -> str:
    match color:
        case Color.RED:
            return "赤"
        case Color.BLUE:
            return "青"
        # Color.YELLOWがないのはバグ
        case _ as unreachable:
            assert_never(unreachable)  # ここを変更


if __name__ == "__main__":
    print(get_color_name(Color.RED))
    print(get_color_name(Color.BLUE))
    print(get_color_name(Color.YELLOW))

このコードをMypyで型チェックしてみましょう。

$ mypy colors2.py
colors2.py:19: error: Argument 1 to "assert_never" has incompatible type "Literal[Color.YELLOW]"; expected "NoReturn"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

assert_never関数を呼んでいる行がエラーになりました。このように、型チェッカーはassert_never関数が呼ばれている行に到達できるケースがあることを検出すると、その行をエラーとしてくれます。

なお、assert_never関数は実際に実行するとAssertionErrorを送出します。

$ python colors2.py
赤
青
Traceback (most recent call last):
  File "/****/colors2.py", line 25, in <module>
    print(get_color_name(Color.YELLOW))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/****/colors2.py", line 19, in get_color_name
    assert_never(unreachable)  # ここを変更
    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/****/python3.11/typing.py", line 2462, in assert_never
    raise AssertionError(f"Expected code to be unreachable, but got: {value}")
AssertionError: Expected code to be unreachable, but got: <Color.YELLOW: 3>

Never型を使った自作関数はassert_never関数を代替できる

前述の通りassert_never関数は実行するとAssertionErrorを送出しますが、別の例外にしたり、例外を送出する以外のコードを書きたい場合もあるでしょう。

そういう時は、Never型を使った自作の関数を定義してassert_never関数の代わりに使うことができます。

前述のcolors2.pyを以下のように編集して、assert_never関数の代わりに自作のassert_unreachable関数を使うようにします。

colors3.py
from enum import Enum, auto
from typing import Never


class Color(Enum):
    RED = auto()
    BLUE = auto()
    YELLOW = auto()


class UnreachableError(Exception):
    pass


def assert_unreachable(arg: Never) -> Never:
    raise UnreachableError(arg)


def get_color_name(color: Color) -> str:
    match color:
        case Color.RED:
            return "赤"
        case Color.BLUE:
            return "青"
        # Color.YELLOWがないのはバグ
        case _ as unreachable:
            assert_unreachable(unreachable)  # ここを変更


if __name__ == "__main__":
    print(get_color_name(Color.RED))
    print(get_color_name(Color.BLUE))
    print(get_color_name(Color.YELLOW))

このコードをMypyで型チェックすると、変更前のcolors3.pyと同じ箇所でエラーを検出します。

$ mypy colors3.py
colors3.py:27: error: Argument 1 to "assert_unreachable" has incompatible type "Literal[Color.YELLOW]"; expected "NoReturn"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

実行してみると、assert_unreachable関数に書かれた自作の例外UnreachableErrorを送出します。

$ python colors3.py
赤
青
Traceback (most recent call last):
  File "/****/colors3.py", line 33, in <module>
    print(get_color_name(Color.YELLOW))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/****/colors3.py", line 27, in get_color_name
    assert_unreachable(unreachable)  # ここを変更
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/****/colors3.py", line 16, in assert_unreachable
    raise UnreachableError(arg)
UnreachableError: Color.YELLOW

assert_never関数、Never型を使えないケース

assert_never関数、Never型は型チェッカーと組み合わせることでプログラマーのミスを防いでくれる便利なものですが、無闇に使うと問題が起こることがあります。

以下のコードはFizz Buzzを出力する関数infinite_fizzbuzzを定義しています。

fizzbuzz.py
import itertools
from typing import Generator, assert_never


def infinite_fizzbuzz() -> Generator[str, None, None]:
    # 無限にループを繰り返す
    for i in itertools.count(1):
        if i % 3 == 0 and i % 5 == 0:
            yield "FizzBuzz"
        elif i % 3 == 0:
            yield "Fizz"
        elif i % 5 == 0:
            yield "Buzz"
        else:
            yield str(i)
    # ループを抜けないので絶対ここには到達しないはず
    assert_never("unreachable")

上記のコードはfor文にitertools.count(1)を使っているので、無限にループを繰り返します。 そのため、最後の行のassert_never("unreachable")には到達しないはずです。

ところが、型チェッカーを使うと以下のようにエラーを検出してしまいます(assert_never関数でもNever型を使った自作関数でも結果は同じです)。

$ mypy fizzbuzz.py
fizzbuzz.py:17: error: Argument 1 to "assert_never" has incompatible type "str"; expected "NoReturn"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

型チェッカーは到達可能・不可能を判別できない行をエラーとして扱います。

前述のcolors2.pycolors3.pyでは列挙型Colorの定義により構造的パターンマッチで取り得るパターンが3つ(Color.REDColor.BLUEColor.YELLOW)であることは明らかです。よって、型チェッカーは到達可能・不可能を判別できます。

この問題は以下公式ドキュメントにも記載されています。

https://typing.readthedocs.io/en/latest/source/unreachable.html#marking-code-as-unreachable

【コラム】 「到達するはずなのにバグのため到達しない行」を検出するには

前述したとおり、assert_never関数、Never型は「到達しないはずなのにバグのため到達してしまう行」を検出するために使います。

逆のパターンである「到達するはずなのにバグのため到達しない行」を検出するには、Mypyの--warn-unreachableオプションを使うと便利です。

以下のコードは引数がint型なのにも関わらず、引数の型がint型以外の場合にprint("x is not int type")を呼び出すコードが書かれていますが、この行に到達することはできないはずです。

example_warn_unreachable.py
def process(x: int) -> None:
    if isinstance(x, int):
        print("x is int type")
    else:
        # 引数xはint型なので、このブロックは実行されない
        print("x is not int type")

上記のコードはMypyでオプションなしで型チェックしてもエラーにはなりません。

$ mypy example_warn_unreachable.py
Success: no issues found in 1 source file

--warn-unreachableオプションを付けた場合はprint("x is not int type")の部分が到達不可能と判断されてエラーになります(エラー部分のコードを表示させるために--prettyオプションも併用しています)。

$ mypy --warn-unreachable --pretty example_warn_unreachable.py
example_warn_unreachable.py:6: error: Statement is unreachable  [unreachable]
            print("x is not int type")
            ^~~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in 1 file (checked 1 source file)

NoReturn型とNever型の関係性

これまでに見てきたMypyのエラーメッセージには"NoReturn"という文言が含まれています。 エラーメッセージを和訳すると「○○関数の引数に互換性のない××型が指定されています。期待する型はNoReturnです」という意味になります。 NoReturnはtypingモジュールに属する型で、Never型が登場するよりも前に存在しています。型チェッカーではNoReturn型とNever型は同じ意味として扱います。

以下でNoReturn型とは何なのか、なぜ同じ意味のNever型があるのかについて説明します。

NoReturn型は2つの役割を持ちます。

まず1つ目は、「戻り値が存在しない関数やメソッドを示す型」としての役割です。 以下のコードのraise_assertionerror関数は中で例外を送出するだけなので戻り値がありません。こういう関数では戻り値の型としてNoReturnを指定します。

example_noreturn1.py
from typing import NoReturn


def raise_assertionerror() -> NoReturn:
    # この関数は必ず例外を送出するので戻り値が存在しない
    raise AssertionError("Example")

2つ目はボトム型としての役割です。 ボトム型は値を持たない型です。 逆に言うと、ボトム型以外の型は値を持ちます。たとえば、関数の引数がstr型なら"example"int型なら1のように具体的な値を指定できます。 ところが、引数がNoReturn型の場合はどんな値も渡すことができません。

以下のコードは引数の型がNoReturn型であるため、どんな値を渡しても型チェッカーではエラーになります。

example_noreturn2.py
from typing import NoReturn


def raise_assertionerror(arg: NoReturn) -> NoReturn:
    raise AssertionError(arg)


raise_assertionerror("example")
raise_assertionerror(1)
raise_assertionerror(None)

Mypyで上記のコードを型チェックするとraise_assertionerror関数の呼び出し部分ですべてエラーになります。

$ mypy example_noreturn2.py
example_noreturn2.py:8: error: Argument 1 to "raise_assertionerror" has incompatible type "str"; expected "NoReturn"  [arg-type]
example_noreturn2.py:9: error: Argument 1 to "raise_assertionerror" has incompatible type "int"; expected "NoReturn"  [arg-type]
example_noreturn2.py:10: error: Argument 1 to "raise_assertionerror" has incompatible type "None"; expected "NoReturn"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

引数の型がNoReturn(何も返さない)というのは奇妙な感じがしませんか? CPythonコミッターの間でもNoReturnという名前からはボトム型の特徴をイメージしにくいという意見が挙がっていました。 そこで、Python 3.11からは別の名前のボトム型Neverが追加されました。

NoReturn型がボトム型であることはPython 3.11以降でも変わりませんが、Never型が使用可能な場合はNever型を使うようにしてください。

NoReturn型に関する公式ドキュメントにもボトム型として利用する場合はNever型をつかうべきとの記述があります。

https://docs.python.org/ja/3/library/typing.html#typing.NoReturn

NoReturn型とNever型の関係性については以下ドキュメントも参照してください。

https://typing.readthedocs.io/en/latest/source/unreachable.html#never-and-noreturn

また、Never型が採用されるに至った議論の内容は以下のサイトで確認できます。

https://bugs.python.org/issue46475

型チェッカーの対応状況

主要な型チェッカーでのassert_never関数、Never型への対応状況を以下に掲載します。

型チェッカー名

2023年4月7日時点の最新バージョン

対応状況

Pyright

1.1.302

対応済み

Mypy

1.2.0

対応済み

Pyre

0.9.18

対応していない(該当するIssueは見当たらなかった)

pytype

2023.3.31

未対応(Python 3.11自体に未対応。Python 3.11はissues/1308で対応予定)

最後に

タイプヒントや型チェッカーは関数やメソッドの引数指定の間違いを指摘してくれるだけでも嬉しいですが、 ロジックの誤りを教えてくれるのは嬉しいですね。 この記事をきっかけにタイプヒントの導入を検討する人が増えてくれると幸いです。