8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

株式会社ディーバ PD部Advent Calendar 2022

Day 16

【Python】【上級者向け】型絞り込み(type-narrowing)を使ったガード節は`isinstance(a, B)`ではできるけど`type(a) is B`ではできない

Posted at

きっかけ

チームメンバーから「VSCode上でPythonを書いていて、型判定をコード上でしているのにPylanceが型絞り込みからのガード節をしてくれない」という相談を受けたときに行った回答や例をまとめておこうと思ったため、記事を書きました。

型絞り込みについて

一般的なPythonの静的型チェッカー(mypypyrightなど)は、プログラムのコードフローの中でより正確な式の型を決定する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上でmypypylanceによる型チェッカーがエラーを表示します。
※ ツールチップがコードの上にかぶらないように、大きな改行幅を取っています。

image.png

image.png

ここで、型判定を行った分岐後の引数aにマウスカーソルを当てると、型情報はintとなっています。

image.png

intである分岐に入らなかったならstrに絞り込めていそうなのに、なぜか型チェッカーは変数astr | intの可能性がまだあると言い張っています。

型絞り込みできるケース

もう一つ、Pythonにはisinstance(a, B)という型判定用の組み込み関数があります。こちらを使ってみましょう。

def foo(a: int | str) -> bool:
	if isinstance(a, int):
		return a >= 0
	return a.isnumeric()

ご覧のように、エラーは出ていません。

image.png

マウスカーソルを当てても、型がちゃんと絞り込めていることを確認できます。

image.png

image.png

なぜこのようになるのか

結論をいうと

  • Pythonの型アノテーションは、指定されたクラスだけでなく、その派生クラスであれば受け入れる
  • type(a) is B: Bだけで、Bの派生型は考慮されていない
  • isinstance(a, B): Bだけでなく、Bの派生型も考慮されている

ことが理由です。

つまり、

  • type(a) is BFalseを返してもisinstance(a, B)Trueを返す場合であれば、func(arg: B)aを渡しても型チェッカーがエラーになることはありません。
  • func(arg: B | C)関数内の分岐でtype(arg) is Bを分岐に使うとBの派生型は分岐に入らないため、型が絞り込めず分岐を抜けてもargの型はB | Cと推測され、CにあってBにないメソッドを使おうとすると型チェッカーがエラーになります。

ランタイムでエラーにならない/なる例

intの派生型を実際に渡そうとした場合を考えてみます。
例えばenum.IntEnumintのサブクラスです。
これを関数に渡したとき、

  • isinstance(a, int)であれば、関数の2行目にある分岐に入って3行目にある演算結果をreturnします。
  • type(a) is intであれば、関数の2行目にある分岐に入らず4行目のメソッド参照でAttributeErrorがraiseされます。

image.png

終わりに

型アノテーションは指定したクラスだけではなく、その派生型も意識するべきです。

逆を言えば、基底クラスで使えるメソッドが派生クラスでは使えなくなる、SOLID原則の1つであるリスコフの置換原則に違反するようなクラスを作ってはいけないということです。

# 下記のクラスはものすごい極端な例です
# ここまででなくても、派生クラスの同じメソッドが違う型を返すようなケースは多々あることでしょう
# ともかく、これは`int`のサブクラスなので`isinstance(..., int)`をすり抜けてしまいます
# このような型が紛れ込んでしまうとバグ原因の発見は難しくなります
class CannotAddInt(int):
    """+演算子がサポートされておらず使おうとするとエラーになる`int`のサブクラス"""
    def __add__(self, other):
        return NotImplemented

    def __radd__(self, other):
        return NotImplemented

「プロジェクトで引き算や割り算や掛け算はできても足し算できない整数型を導入したいけど、a: intとなっている引数を受け取って足し算をする関数に渡るかもしれない」というような状況になったら、そのようになってしまった理由をブレークダウンして全体の設計を考え直したほうがいいでしょう。

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?