2023年5月: 型チェッカーでロジックの間違いを検出できるtyping.assert_never関数とtyping.Never型の紹介(ryu22e)¶
筒井@ryu22eです。2023年5月の『Python Monthly Topics』のテーマは、Python 3.11からtypingモジュールに追加された「assert_never
関数、Never
型」です。
みなさんは「この行には仕様上絶対に到達しないはず」というコードを書いたことはありますか?
そして、バグが原因で到達しないはずの行に到達してしまった経験はありませんか?
assert_never
関数、Never
型にはこのようなミスを型チェッカー(Mypy、Pyrightなど)で検出してくれる便利な機能があります。
本記事では、サンプルコードを交えて実際にassert_never
関数、Never
型がどう役立つのか解説します。
なお、型チェッカーはMypy 1.2.0を使用します。
assert_never
関数、Never
型についての公式ドキュメントは以下を参照してください。
assert_never
関数: https://docs.python.org/ja/3/library/typing.html#typing.assert_neverNever
型: https://docs.python.org/ja/3/library/typing.html#typing.Never
「到達しないはずなのにバグのため到達してしまう行」を検出してくれるassert_never
関数¶
まず最初にassert_never
関数について解説します。
以下のサンプルコードには、色を表す列挙型Color
とそれを色名に変換する関数get_color_name
が定義されています。
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
関数を使うようにしてみます。
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
関数を使うようにします。
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
を定義しています。
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.py
、colors3.py
では列挙型Color
の定義により構造的パターンマッチで取り得るパターンが3つ(Color.RED
、Color.BLUE
、Color.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")
を呼び出すコードが書かれていますが、この行に到達することはできないはずです。
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
を指定します。
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_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
型が採用されるに至った議論の内容は以下のサイトで確認できます。
型チェッカーの対応状況¶
主要な型チェッカーでのassert_never
関数、Never
型への対応状況を以下に掲載します。
型チェッカー名 |
2023年4月7日時点の最新バージョン |
対応状況 |
---|---|---|
1.1.302 |
対応済み |
|
1.2.0 |
対応済み |
|
0.9.18 |
対応していない(該当するIssueは見当たらなかった) |
|
2023.3.31 |
未対応(Python 3.11自体に未対応。Python 3.11はissues/1308で対応予定) |
最後に¶
タイプヒントや型チェッカーは関数やメソッドの引数指定の間違いを指摘してくれるだけでも嬉しいですが、 ロジックの誤りを教えてくれるのは嬉しいですね。 この記事をきっかけにタイプヒントの導入を検討する人が増えてくれると幸いです。