はじめに
ともりるありがとうーーーー!!!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]
4character
がfavorites
のキーのいずれかと一致したら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 iffoo(arg)
returnsTrue
, thenarg
narrows fromTypeA
toTypeB
.
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:
- 関数の返り値の型は
bool
(原文:The return value is a boolean.) - 関数の返り値が
True
ならば、引数の型はTypeGuard
の内側の型となる(原文:If the return value isTrue
, the type of its argument is the type insideTypeGuard
.)
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型の値を狭める方法を知るのに苦労し、TypeGuard
やcast
が必要ということを今回学びました。
1つ壁を超えたので、型ヒントで書く範囲をもう少し増やせそうに感じています(ただしちょっとずつです)。
- 😭 楠木ともりさん、優木せつ菜ちゃんを演じてくださってありがとう🌟(まだその日じゃないですが) - nikkie-ftnextの日記↩
- 読書ログ | 『ロバストPython』5章「コレクション型」を読んで、コレクション(listやdictなどなど)への型ヒントの書き方や、振る舞いの拡張の仕方を完全に理解しました - nikkie-ftnextの日記↩
- (2023/04/01追加) Python 3.9以前ではtyping-extensionsをインストールすることで、ここで紹介したコードが動かせると思います↩
- https://docs.python.org/ja/3/library/stdtypes.html#dict.get↩
- ChatGPTに聞くのは試していません↩
- ref: PythonのUnionまわりの型ヒントの書き方を整理する(Python 3.9と3.10が|が使える境目です) - nikkie-ftnextの日記↩
-
Using
-> TypeGuard
tells the static type checker that for a given function: の部分です↩ - もっと知るにはPEPが待っていますね! PEP 647 – User-Defined Type Guards | peps.python.org↩