はじめに
この記事は静岡大学情報学部ITソルーション室アドベントカレンダーの2日目の記事として執筆されました。他の記事もぜひ御覧ください。
本文
Pythonで条件分岐を書くとき、全てのケースを網羅したつもりが漏れていた——そんな経験はありませんか?RustやHaskellならコンパイラが網羅性を検証してくれますが、Pythonでは実行時まで気づけません。
この記事では assert_never を使って、分岐の網羅性を静的解析で担保する方法を紹介します。
問題例:Union型の変更がコードを壊す
以下のようなコードを考えてみましょう。
class Pending:
...
class Approved:
...
OrderStatus = Pending | Approved
def to_response(status: OrderStatus) -> dict:
if isinstance(status, Pending):
return {"status": "pending"}
elif isinstance(status, Approved):
return {"status": "approved"}
else:
raise AssertionError(f"Unknown status: {status}")
この else 節、「ここには来ないはず」という意図で書いていますよね。Union型を網羅しているので、論理的には到達しないはずです。
ここで OrderStatus に新しい状態 Rejected を追加してみましょう。
class Rejected:
reason: str
OrderStatus = Pending | Approved | Rejected # ← Rejected を追加
to_response 関数は Rejected を処理していませんが、型チェッカーは何も言いません。else 節があるので、戻り値の型としては問題ないからです。
しかし、実行時に Rejected が渡されると AssertionError が飛びます。
なぜこのコードは変更に弱いのか
問題は else 節です。else は「それ以外の全て」を受け入れるので、Union型に新しいバリアントが追加されても、型チェッカーは「全ての場合を処理している」と判断してしまいます。本来なら検出すべき漏れが else によって隠蔽されているわけです。
さらに厄介なのは、Union型にバリアントを追加するたびに、それを使っている全ての分岐処理を探し出して修正しなければならない点です。どこを直すべきかを機械的に特定する手段がありません。
RustやHaskellでは、パターンマッチの網羅性(exhaustiveness)をコンパイラが検証してくれます。Union型に新しいバリアントを追加すると、それを処理していない箇所が全てコンパイルエラーになる。Pythonでも同じことができないでしょうか?
解決策:assert_never
この問題を解決するのが assert_never です。これを使えば、Union型の分岐で漏れがあったときに型チェッカーがエラーを出してくれます。
from typing import assert_never
def to_response(status: OrderStatus) -> dict:
if isinstance(status, Pending):
return {"status": "pending"}
elif isinstance(status, Approved):
return {"status": "approved"}
elif isinstance(status, Rejected):
return {"status": "rejected", "reason": status.reason}
else:
assert_never(status)
もし Rejected のケースを書き忘れると、型チェッカーが以下のようなエラーを出します。
ERROR Argument `Rejected` is not assignable to parameter `arg`
with type `Never` in function `typing.assert_never`
漏れている型をピンポイントで教えてくれます。これで、Union型を変更したときに修正すべき箇所が静的解析で全て洗い出せるようになります。
ここからは「なぜ assert_never で検出できるのか」という仕組みの話です。使い方だけ知りたい方は「まとめ」まで飛ばしてもらって大丈夫です。
なぜ assert_never は動くのか
ボトム型 Never
型理論には「ボトム型(bottom type)」という概念があります(参考:typing documentation)。これは 値を持たない型、つまり「空集合」を表す型です。
Pythonでは PEP 484 で NoReturn が「絶対に返らない関数」の戻り値型として導入されました。
from typing import NoReturn
def stop() -> NoReturn:
raise RuntimeError("no way")
しかし実際には、NoReturn は戻り値以外の場面でもボトム型として使われるようになりました。この用途を明確にするため、Python 3.11で Never が追加されました(cpython#90633)。Never と NoReturn は型チェッカーからは同等に扱われますが、以下のように使い分けるのが慣習となっています。
-
NoReturn→ 関数の戻り値型として(「この関数は返らない」) -
Never→ それ以外の場面でのボトム型として
型ナローイング
assert_never が機能するのは、型チェッカーの 型ナローイング(type narrowing) のおかげです。型チェッカーは制御フローに応じて変数の型を絞り込みます。
-
isinstance(status, Pending)→statusからPendingを除外 -
isinstance(status, Approved)→statusからApprovedを除外 -
isinstance(status, Rejected)→statusからRejectedを除外 -
else節ではstatusの型はNever(空)に絞り込まれる
全てを網羅していれば else 節の status は Never 型になり、assert_never(status) は型チェックを通ります。漏れがあれば、その型が Never に代入できないとしてエラーになります。
歴史的背景
この「ボトム型を使った網羅性チェック」というイディオムは、2018年頃からmypyユーザーの間で知られていました(参考:mypy#5818)。
当時は標準ライブラリになく、各プロジェクトで自前実装する必要がありました。
# 2020年頃の自前実装
from typing import NoReturn
def assert_never(x: NoReturn) -> NoReturn:
raise AssertionError(f"Invalid value: {x!r}")
2022年、Jelle Zijlstraによって typing.Never と typing.assert_never がPython 3.11に追加されました(cpython#90633)。これにより、このパターンが公式にサポートされることになりました。
Python 3.10以前でも typing_extensions パッケージ(v4.1以降)から使えます。
from typing_extensions import assert_never, Never
まとめ
- Union型やEnumの分岐には
else: assert_never(x)を付ける - 型を追加したときの更新漏れをCIで検出できる
- Python 3.11以降は
from typing import assert_never、それ以前はfrom typing_extensions import assert_never
網羅性チェックを静的解析に任せて、変更に強いコードを書きましょう。
参考
- Unreachable Code and Exhaustiveness Checking — typing documentation
- Special types in annotations — typing documentation
- PEP 484 – Type Hints
- cpython#90633: typing.Never and typing.assert_never
- mypy#5818: Preferred idiom for exhaustiveness checking of unions(2018年)
- Exhaustiveness Checking with Mypy | Haki Benita(2020年)