到達不能コードと網羅性チェック

時々、実行されるべきではないコードを書く必要があり、時々、実行されることを期待して書いたコードが実際には到達不能であることがあります。 型チェッカーはどちらの場合にも役立ちます。

このガイドでは、次の内容をカバーします。

  • 到達不能コードに使用されるプリミティブ型 Never

  • 網羅性チェックのためのヘルパー assert_never()

  • コードを直接到達不能としてマークする方法

  • 予期せず到達不能なコードの検出

NeverNoReturn

型理論には、値を持たない型である ボトム型 という概念があります。 具体的には、戻り値を持たない関数の戻り値の型や、呼び出されることのない関数の引数の型を表すために使用できます。 ボトム型はメンバーを持たない共用体としても考えることができます。

Python の型システムは長い間 NoReturn という型を提供してきました。 これは元々、戻り値を持たない関数のためだけに意図されていましたが、この概念は一般的なボトム型に自然に拡張され、すべての型チェッカーは NoReturn を一般的なボトム型として扱います。

この型の意味をより明確にするために、Python 3.11 と typing-extensions 4.1 では、新しいプリミティブ Never が追加されました。 型チェッカーにとって、これは NoReturn と同じ意味を持ちます。

このガイドでは、ボトム型として Never を使用しますが、まだ使用できない場合は、代わりに typing.NoReturn を使用できます。

assert_never() と網羅性チェック

Never 型は静的網羅性チェックを実行するために活用できます。 これにより、すべての可能なケースをカバーしたことを型チェッカーで確認できます。 たとえば、コードが列挙型の各メンバーや共用体の各型に対して個別のアクションを実行する場合に役立ちます。

型チェッカーに網羅性チェックを行わせるためには、Never 型として型付けされたパラメーターを持つ関数を呼び出します。 型チェッカーは、このコードが到達不能であることを証明できる場合にのみ、この呼び出しを許可します。

例として、このシンプルな計算機を考えてみましょう。

import enum
from typing_extensions import Never

def assert_never(arg: Never) -> Never:
    raise AssertionError("Expected code to be unreachable")

class Op(enum.Enum):
    ADD = 1
    SUBTRACT = 2

def calculate(left: int, op: Op, right: int) -> int:
    match op:
        case Op.ADD:
            return left + right
        case Op.SUBTRACT:
            return left - right
        case _:
            assert_never(op)

match 文は Op 列挙型のすべてのメンバーをカバーしているため、assert_never() 呼び出しは到達不能であり、型チェッカーはこのコードを受け入れます。 しかし、列挙型に別のメンバー (たとえば MULTIPLY) を追加しても match 文を更新しない場合、型チェッカーは MULTIPLY ケースを処理していないというエラーを出します。

assert_never() ヘルパー関数は頻繁に役立つため、標準ライブラリでは Python 3.11 以降で typing.assert_never として提供されており、typing_extensions ではバージョン 4.1 以降で提供されています。 ただし、ランタイムエラーメッセージをカスタマイズしたい場合など、自分のコードで同様の関数を定義することも可能です。

assert_never() を一連の if 文と一緒に使用することもできます。

def calculate(left: int, op: Op, right: int) -> int:
    if op is Op.ADD:
        return left + right
    elif op is Op.SUBTRACT:
        return left - right
    else:
        assert_never(op)

コードを到達不能としてマークする

時々、コードの一部が到達不能であるが、型システムがそれを認識するのに十分な力を持っていないことがあります。 たとえば、通りの中で最も低い未使用の番地を見つける関数を考えてみましょう。

import itertools

def is_used(street: str, number: int) -> bool:
    ...

def lowest_unused(street: str) -> int:
    for i in itertools.count(1):
        if not is_used(street, i):
            return i
    assert False, "unreachable"

itertools.count() は無限イテレータであるため、この関数は assert False 文に到達することはありません。 しかし、型チェッカーがそれを知る方法はないため、assert False がなければ、型チェッカーは関数が戻り値を持たないと文句を言います。

これが assert_never() とは異なる点に注意してください。

  • lowest_unused() 関数で assert_never() を使用した場合、型チェッカーはその行が到達不能であることを証明できないため、エラーを出します。

  • calculate() の例で assert_never() の代わりに assert False を使用した場合、網羅性チェックの利点を得ることはできません。 コードが実際に到達可能である場合、型チェッカーは警告を出さず、ランタイムでアサーションにヒットする可能性があります。

assert False はこのパターンを表現する最も慣用的な方法ですが、実行を終了する任意の文でも構いません。 たとえば、例外を発生させるか、Never を返す関数を呼び出すことができます。

予期せず到達不能なコードの検出

もう一つの問題は、実行されることを期待しているコードが実際には静的に到達不能であると判断できる場合です。 一部の型チェッカーには、到達不能と検出されたコードに対して警告を出すオプションがあります (例: mypy の --warn-unreachable)。