筒井@ryu22eです。2023年5月のassert_関数、Never型」
みなさんはassert_関数、Never型にはこのようなミスを型チェッカー
本記事では、サンプルコードを交えて実際にassert_関数、Never型がどう役立つのか解説します。
なお、型チェッカーはMypy 1.
assert_関数、Never型についての公式ドキュメントは以下を参照してください。
「到達しないはずなのにバグのため到達してしまう行」を検出してくれるassert_never 関数
assert_never 関数まず最初にassert_関数について解説します。
以下のサンプルコードには、色を表す列挙型Colorと、それを色名に変換する関数get_が定義されています。
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型の引数colorを構造的パターンマッチ[1]で検証しています。コメントにも書いているように、Color.の場合が書かれていません。これは、うっかり書き忘れてバグを埋め込んでしまった前提で読んでください。
このコードを実行してみましょう。print(get_を呼び出した時点で、get_関数の中でどのパターンにも該当しない場合のコード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.Color.が抜けていた場合はバグを検出することはできません。
こんな時に活躍するのがassert_関数です。前述のコードのget_関数でAssertionErrorを送出している部分を書き換えて、assert_関数を使うようにしてみます。
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_関数を呼んでいる行がエラーになりました。このように、型チェッカーはassert_関数が呼ばれている行に到達できるケースがあることを検出すると、その行をエラーとしてくれます。
なお、assert_関数は実際に実行すると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_関数は実行するとAssertionErrorを送出しますが、別の例外にしたり、例外を送出する以外のコードを書きたい場合もあるでしょう。そういう時は、Never型を使った自作の関数を定義してassert_関数の代わりに使うことができます。
前述のcolors2.を以下のように編集して、assert_関数の代わりに自作のassert_関数を使うようにします。
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.と同じ箇所でエラーを検出します。
$ 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_関数に書かれた自作の例外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型は型チェッカーと組み合わせることでプログラマーのミスを防いでくれる便利なものですが、無闇に使うと問題が起こることがあります。
以下のコードはFizz Buzzを出力する関数infinite_を定義しています。
import itertools
from collections.abc import Iterator
from typing import assert_never
def infinite_fizzbuzz() -> Iterator[str]:
# 無限にループを繰り返す
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.を使っているので、無限にループを繰り返します。そのため、最後の行のassert_には到達しないはずです。
ところが、型チェッカーを使うと以下のようにエラーを検出してしまいますassert_関数でもNever型を使った自作関数でも結果は同じです)。
$ mypy fizzbuzz.py fizzbuzz.py:18: 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.、colors3.では列挙型Colorの定義により構造的パターンマッチで取り得るパターンが3つColor.、Color.、Color.)
この問題は以下の公式ドキュメントにも記載されています。
NoReturn型とNever型の関係性
これまでに見てきたMypyのエラーメッセージには"NoReturn"という文言が含まれています。エラーメッセージを和訳するとNoReturnです」
NoReturnはtypingモジュールに属する型で、Never型が登場するよりも前に存在しています。型チェッカーではNoReturn型とNever型は同じ意味として扱います。
以下でNoReturn型とは何なのか、なぜ同じ意味のNever型があるのかについて説明します。
NoReturn型は2つの役割を持ちます。
まず1つ目は、raise_関数は中で例外を送出するだけなので戻り値がありません。こういう関数では戻り値の型としてNoReturnを指定します。
from typing import NoReturn
def raise_assertionerror() -> NoReturn:
# この関数は必ず例外を送出するので戻り値が存在しない
raise AssertionError("Example")
2つ目はボトム型としての役割です。ボトム型は値を持たない型です。逆に言うと、ボトム型以外の型は値を持ちます。たとえば、関数の引数がstr型なら"example"、int型なら1のように具体的な値を指定できます。ところが、引数がNoReturn型の場合はどんな値も渡すことができません。
以下のコードは引数の型がNoReturn型であるため、どんな値を渡しても型チェッカーではエラーになります。
from typing import NoReturn
def raise_assertionerror(arg: NoReturn) -> NoReturn:
raise AssertionError(arg)
raise_assertionerror("example")
raise_assertionerror(1)
raise_assertionerror(None)
Mypyで上記のコードを型チェックするとraise_関数の呼び出し部分ですべてエラーになります。
$ 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)
引数の型がNoReturnNoReturnという名前からはボトム型の特徴をイメージしにくいという意見が挙がっていました。そこで、Python 3.Neverが追加されました。
NoReturn型がボトム型であることはPython 3.Never型が使用可能な場合はNever型を使うようにしてください。
NoReturn型に関する公式ドキュメントにもボトム型として利用する場合はNever型をつかうべきとの記述があります。
NoReturn型とNever型の関係性については以下ドキュメントも参照してください。
また、Never型が採用されるに至った議論の内容は以下のサイトで確認できます。
型チェッカーの対応状況
主要な型チェッカーでのassert_関数、Never型への対応状況を以下に掲載します。
| 型チェッカー名 | 2023年4月7日時点の最新バージョン | 対応状況 |
|---|---|---|
| Pyright | 1. |
対応済み |
| Mypy | 1. |
対応済み |
| Pyre | 0. |
対応していない |
| pytype | 2023. |
未対応 |
最後に
タイプヒントや型チェッカーは関数やメソッドの引数指定の間違いを指摘してくれるだけでも嬉しいですが、ロジックの誤りを教えてくれるのは嬉しいですね。この記事をきっかけにタイプヒントの導入を検討する人が増えてくれると幸いです。
