nikkie-ftnextの日記

イベントレポートや読書メモを発信

Pythonの型ヒントのうち、typing.TypeGuardの使い所を完全理解しました

はじめに

ともりるありがとうーーーー!!!1 nikkieです。

ロバストPython』でtyping.TypedDictを知り2、便利なことに気づき、書く型ヒントをちょっとずつ増やしています。
mypyを使った型チェックで少し理解が進んだ点があったのでアウトプットします。

目次

小さな壁:辞書のgetが返した値が絡む型ヒント

Python 3.10.93、mypy 1.1.1で動かしています。
以下のようなスクリプトの型チェックを扱います。

def shout_love(food: str) -> None:
    print(f"{food} 好きです!!")


favorites: dict[str, str] = {
    "satomi": "抹茶アイス",
    "toma": "干物全般",
    "thunder": "きな粉餅",
}

character = "satomi"
favorite_food = favorites.get(character)
shout_love(favorite_food)

実行すると、大好きを叫びます。

% python dict_get.py
抹茶アイス 好きです!!

型チェックしてみましょう。

% mypy dict_get.py
dict_get.py:13: error: Argument 1 to "shout_love" has incompatible type "Optional[str]"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

エラーが見つかりました。
13行目のshout_love関数の呼び出しで、型チェックのエラーが送出されています。

  • favorites.get(character)の返り値の型はOptional[str]4
    • characterfavoritesのキーのいずれかと一致したらstr
    • 一致しなかったらNone
  • 型がOptional[str]の値をshout_love関数に渡している
  • 一方、関数定義よりshout_love関数の引数の型はstr
  • つまり、str型の値しか受け取れないshout_love関数にOptional[str]型の値を渡しているためincompatible(エラー)となっている

getの返り値がNoneかどうかの分岐を追加すると型チェックは通る

getの返り値がNoneでない(=str型の値)のときだけ、shout_love関数を呼び出しましょう(差分を示します)。

def shout_love(food: str) -> None:
    print(f"{food} 好きです!!")


favorites: dict[str, str] = {
    "satomi": "抹茶アイス",
    "toma": "干物全般",
    "thunder": "きな粉餅",
}

character = "satomi"
favorite_food = favorites.get(character)
-shout_love(favorite_food)
+if favorite_food is not None:
+    shout_love(favorite_food)

このとき(省略しますがスクリプトは大好きを叫びますし)型チェックは通ります🙌

% mypy dict_get.py
Success: no issues found in 1 source file

getの返り値がNoneかどうかの判定を関数に切り出したら型チェックが通らない!

favorite_food is not Noneという式の意味をコードに語らせるため、関数is_foodに切り出してみました。

def shout_love(food: str) -> None:
    print(f"{food} 好きです!!")


def is_food(food: str | None) -> bool:
    """
    >>> is_food(None)
    False
    >>> is_food("きな粉餅")
    True
    """
    return food is not None


favorites: dict[str, str] = {
    "satomi": "抹茶アイス",
    "toma": "干物全般",
    "thunder": "きな粉餅",
}

character = "satomi"
favorite_food = favorites.get(character)
if is_food(favorite_food):
    shout_love(favorite_food)

このとき型チェックはエラーを送出します

% mypy dict_get.py
dict_get.py:24: error: Argument 1 to "shout_love" has incompatible type "Optional[str]"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

TypeGuardの出番と気づく

is_food関数の実装はNoneかどうかの判定です。
関数にしただけで式自体は変わっていません。
ですが、if文のスイートにあるshout_love関数の呼び出しでは、型チェックはエラーです。
shout_love関数はstr型の値が渡されるべきですが、辞書のgetメソッドの返り値のOptional[str]型の値が渡されているため、エラーが送出されます。

この事象の解決方法を調べる中では適切な言葉が見つからず苦労しました5
ただ調べる中で、聞いたことのあったtyping.TypeGuardが偶然結びついたのです!

+from typing import TypeGuard
+
+
def shout_love(food: str) -> None:
    print(f"{food} 好きです!!")


-def is_food(food: str | None) -> bool:
+def is_food(food: str | None) -> TypeGuard[str]:
    """
    >>> is_food(None)
    False
    >>> is_food("きな粉餅")
    True
    """
    return food is not None


favorites: dict[str, str] = {
    "satomi": "抹茶アイス",
    "toma": "干物全般",
    "thunder": "きな粉餅",
}

character = "satomi"
favorite_food = favorites.get(character)
if is_food(favorite_food):
    shout_love(favorite_food)

変えたのはis_food関数の型ヒント、返り値の型だけです。

関数の返り値をTypeGuardで型ヒント

https://docs.python.org/ja/3/library/typing.html#typing.TypeGuard

In short, the form def foo(arg: TypeA) -> TypeGuard[TypeB]: ..., means that if foo(arg) returns True, then arg narrows from TypeA to TypeB.

nikkie訳: 手短に言うと、def foo(arg: TypeA) -> TypeGuard[TypeB]: ...という形式は次を意味する: foo(arg)という呼び出しがTrueを返すならば、引数argの型はTypeAからTypeBに狭められる

def is_food(food: str | None) -> TypeGuard[str]:で言うと、is_food(favorite_food)という呼び出しがTrueを返すとき、実引数favorite_foodの型はstr | None(すなわちOptional[str]6)からstrに狭められているわけですね。
shout_love関数はstr型の値で呼び出されていたため、型チェッカはエラーを送出しなかった、と。

関数の返り値の型ヒントを-> TypeGuardと書くことについて、ドキュメントには以下のようにありました7

  1. 関数の返り値の型はbool(原文:The return value is a boolean.
  2. 関数の返り値がTrueならば、引数の型はTypeGuardの内側の型となる(原文:If the return value is True, the type of its argument is the type inside TypeGuard.

TypeGuard落ち穂拾い

Python 3.10で追加されました。
python.jpに分かりやすい解説があります(「python typing TypeGuard」のような検索語で発見)。

プログラムの処理から型の情報を取得することを、型の絞り込み(type narrowing) といいます。

また、(略) 型チェッカが型の絞り込みに利用する条件のことを、型ガード(type guard) といいます。

type narrowingを調べると、mypyのドキュメントが見つかりました。

type narrowingのいくつかの例が挙がっています。
上で示した例は、favorite_food is not Noneという式も、それを関数に抽出してTypeGuardで型ヒントしたのも、どちらも型を狭めていたのですね!

typing.TypeGuardはユーザが定義できる型ガードなので、ユーザ定義型ガードと呼ばれるという理解です8

castというものも知る

上で挙げたpython.jpの記事で知ったcastで書き直します。

+from typing import cast
+
+
def shout_love(food: str) -> None:
    print(f"{food} 好きです!!")


def is_food(food: str | None) -> bool:
    """
    >>> is_food(None)
    False
    >>> is_food("きな粉餅")
    True
    """
    return food is not None


favorites: dict[str, str] = {
    "satomi": "抹茶アイス",
    "toma": "干物全般",
    "thunder": "きな粉餅",
}

character = "satomi"
favorite_food = favorites.get(character)
if is_food(favorite_food):
-    shout_love(favorite_food)
+    shout_love(cast(str, favorite_food))

shout_love関数の実引数をcastしています。

% mypy dict_get.py
Success: no issues found in 1 source file

TypeGuardを使わずにtyping.castでも型チェックが通ります!

https://docs.python.org/ja/3/library/typing.html#typing.cast

型検査器に対して、返り値が指定された型を持っていることを通知しますが、実行時には意図的に何も検査しません。

ただ毎回castを書くのは大変そうで、ユーザが楽をするためにTypeGuardが導入されたのかなと想像します。

終わりに

辞書のgetメソッドの返り値を扱う中で見かけた型チェッカのエラーからTypeGuardの使い所が分かりました。
ポイントは型を狭めているということですね。
Optional型は導入は簡単です(いくつも記事が見つかります)が、Optional型の値を狭める方法を知るのに苦労し、TypeGuardcastが必要ということを今回学びました。
1つ壁を超えたので、型ヒントで書く範囲をもう少し増やせそうに感じています(ただしちょっとずつです)。