きっかけ
チームメンバーから「VSCode上でPythonを書いていて、型判定をコード上でしているのにPylanceが型絞り込みからのガード節をしてくれない」という相談を受けたときに行った回答や例をまとめておこうと思ったため、記事を書きました。
型絞り込みについて
一般的なPythonの静的型チェッカー(mypy
やpyright
など)は、プログラムのコードフローの中でより正確な式の型を決定するtype-narrowing(型の絞り込み)を行なっています。
- これは
typing.TypeGuard
に関するPEPであるPEP647にも記載があります。
ざっくばらんに言うと、「直和型(typing.Union
)の変数に対して型判定の分岐を書いたら、その分岐に入る/入らない場合で変数に対して異なる型推論をしてくれるよ」というものです。
関数の要件
「整数インスタンスまたは文字列インスタンスが渡される。0以上の整数または数字であればTrue
、それ以外ならFalse
を返す」 ことが要件である関数を実装します。
引数と返り値はこのようになります。
def foo(a: int | str) -> bool: # 3.10以降で有効な書き方です
...
この関数に型チェッカーがエラーを出さないようにして適切な実装をするにはどうすればよいでしょうか。
型絞り込みできないケース
Pythonで型を判定する手段の一つとして、type(a) is B
で判定する方法があります。
def foo(a: int | str) -> bool:
if type(a) is int:
return a >= 0
return a.isnumeric()
これで実装してみます。
すると、VSCode上でmypy
やpylance
による型チェッカーがエラーを表示します。
※ ツールチップがコードの上にかぶらないように、大きな改行幅を取っています。
ここで、型判定を行った分岐後の引数a
にマウスカーソルを当てると、型情報はint
となっています。
int
である分岐に入らなかったならstr
に絞り込めていそうなのに、なぜか型チェッカーは変数a
がstr | int
の可能性がまだあると言い張っています。
型絞り込みできるケース
もう一つ、Pythonにはisinstance(a, B)
という型判定用の組み込み関数があります。こちらを使ってみましょう。
def foo(a: int | str) -> bool:
if isinstance(a, int):
return a >= 0
return a.isnumeric()
ご覧のように、エラーは出ていません。
マウスカーソルを当てても、型がちゃんと絞り込めていることを確認できます。
なぜこのようになるのか
結論をいうと
- Pythonの型アノテーションは、指定されたクラスだけでなく、その派生クラスであれば受け入れる
-
type(a) is B
:B
だけで、B
の派生型は考慮されていない -
isinstance(a, B)
:B
だけでなく、B
の派生型も考慮されている
ことが理由です。
つまり、
type(a) is B
がFalse
を返してもisinstance(a, B)
がTrue
を返す場合であれば、func(arg: B)
にa
を渡しても型チェッカーがエラーになることはありません。func(arg: B | C)
関数内の分岐でtype(arg) is B
を分岐に使うとB
の派生型は分岐に入らないため、型が絞り込めず分岐を抜けてもarg
の型はB | C
と推測され、C
にあってB
にないメソッドを使おうとすると型チェッカーがエラーになります。
ランタイムでエラーにならない/なる例
int
の派生型を実際に渡そうとした場合を考えてみます。
例えばenum.IntEnum
はint
のサブクラスです。
これを関数に渡したとき、
-
isinstance(a, int)
であれば、関数の2行目にある分岐に入って3行目にある演算結果をreturn
します。 -
type(a) is int
であれば、関数の2行目にある分岐に入らず4行目のメソッド参照でAttributeError
がraiseされます。
終わりに
型アノテーションは指定したクラスだけではなく、その派生型も意識するべきです。
逆を言えば、基底クラスで使えるメソッドが派生クラスでは使えなくなる、SOLID原則の1つであるリスコフの置換原則に違反するようなクラスを作ってはいけないということです。
# 下記のクラスはものすごい極端な例です
# ここまででなくても、派生クラスの同じメソッドが違う型を返すようなケースは多々あることでしょう
# ともかく、これは`int`のサブクラスなので`isinstance(..., int)`をすり抜けてしまいます
# このような型が紛れ込んでしまうとバグ原因の発見は難しくなります
class CannotAddInt(int):
"""+演算子がサポートされておらず使おうとするとエラーになる`int`のサブクラス"""
def __add__(self, other):
return NotImplemented
def __radd__(self, other):
return NotImplemented
「プロジェクトで引き算や割り算や掛け算はできても足し算できない整数型を導入したいけど、a: int
となっている引数を受け取って足し算をする関数に渡るかもしれない」というような状況になったら、そのようになってしまった理由をブレークダウンして全体の設計を考え直したほうがいいでしょう。