はじめに
以前、日本語での解説記事がなかったPEP695について記事を書き、自分のQiita記事で初めて100いいねを超えました。
PEP695の他にもPython3.12からはPEP692が採用され、キーワード引数アノテーションに対する新文法として**kwargs: **TypedDict
という書き方ができるようになります。
それも現時点(2023/8/22現在)で日本語記事がなく、それなりにバズる需要があるのではないかと思い、この記事を書きました。
PEP全文
このPEPが提案された経緯
これまでも関数の引数について**kwargs: T
のようにアノテーションすれば、
- 各キーワード引数が
T
型以外を受け付けないように型制約がつく -
kwargs
が関数内部でdict[str, T]
として型推論される
ようになっていました。
def foo(**kwargs: str): # 各キーワード引数に`str`以外を渡すと型チェッカーエラー
kwargs # `dict[str, str]`に型推論される
しかし、**kwargs
がキーワードごとに異なる型を持つことはよくあります。
このような場合、**kwargs
にそれらの型の違いについてアノテーションを付けることはできませんでした。
# 引数の名前も型もわかる
class hoge(
*,
a: int = 0,
b: str = "",
c: float = 0.,
d: tuple[int, str, float] = (0, "", 0.)
) -> None:
...
# `**kwargs`は`hoge`に渡すことができる引数名と型に限定したいが、方法がなかった
# `Any`でアノテーションするしかないが、引数の名前も型も制約も失われてしまっている
class fuga(criteria: int, **kwargs: Any) -> None:
hoge(**kwargs) # 辞書をアンパックすればいいのでヘルパー関数を呼ぶのは楽
デフォルト値を持たないキーワード引数に対応しなければならない場合についても考えます。
**kwargs
を使うとどの名前が必須引数であるかの情報も失われてしまいます。
# `piyo(lettuce="egg")`のように必ず名前付きで呼ばなければならない
def piyo(*, lettuce: str) -> None:
...
# `hoge`の場合と同じく引数の名前も型も制約も失われてしまっている
def moge(**kwargs: Any) -> None:
piyo(**kwargs) # 辞書をアンパックすればいいのでヘルパー関数を呼ぶのは楽
**kwargs
は関数が内部でヘルパー関数を呼び出す場合に、必要なコード量を減らすために使用できます。
しかし、上記のように本来渡すべき適切な引数名と型を秘匿してしまいます。
そのような事態を避けるため、より正確な**kwargs
の型付けをサポートすることについて多くの議論があり、その結果PEP692が提案されました。
キーワードごとに違う型付けであることを表現する
PEP589で提案されPython3.8から追加されたtyping.TypedDict
、そして**
を用いることで、**kwargs
に渡されるキーワードごとに違う型が付けられていることを表現可能になります。
TypedDict
について
「辞書のどのキーに何の型がつけられているか」を型ヒントで表現する(ことがいくつかあるうちのひとつの)機能です。
from typing import TypedDict
# 各フィールドで辞書のキーとその型を指定できる
class _Td1(TypedDict):
foo: int
bar: str
a: _Td1 = {"foo": 0, "bar": ""} # OK
b: _Td1 = {"bar": "", "foo": 0} # OK 並び順はフィールドと違っても可
c: _Td1 = {"foo": "", "bar": 0} # NG キーごとの型が違う
d: _Td1 = {"foo": 0, "bar": "", "baz": 3.14} # NG 余分なキーがある
e: _Td1 = {"foo": 0} # NG フィールドで指定されたキーが不足している
# クラス定義時の引数として`total: bool`を指定できる
# デフォルトは`True`で上記`_Td1`と同じ振る舞いをする
class _Td2(TypedDict, total=False):
foo: int
bar: str
o: _Td2 = {"foo": 0, "bar": ""} # OK
p: _Td2 = {"foo": 0} # OK フィールドで指定されたキーがなくても可
q: _Td2 = {} # OK まったく空でも可
r: _Td2 = {"foo": "", "bar": 0} # NG キーごとの型が違う
s: _Td2 = {"foo": 0, "bar": "", "baz": 3.14} # NG 余分なキーがある
t: _Td2 = {"baz": 3.14} # NG フィールドの指定とは全然関係ないキーがある
その他の機能や仕様について、詳しくは上述の公式ドキュメントを参照して下さい。
キーワード引数に型制約をつける
前述の関数hoge
やfuga
は下記のように**kwargs: **TypedDict
を使って書き換えても、個別の引数名に型付けすることと同様の型制約をつけることができます。
class _HogeKwargs(TypedDict, total=False):
a: int
b: str
c: float
d: tuple[int, str, float]
def hoge(**kwargs: **_HogeKwargs) -> None:
...
# `TypedDict`はデフォルト値をサポートしていません。
# ヘルパー関数にアンパックして渡さず、内部の要素を使う場合、
# デフォルト値を補いつつ取得するには下記のような冗長なプロセスが必要となります。
# a = kwargs.get("a", 0)
# b = kwargs.get("b", "")
# c = kwargs.get("c", 0.)
# d = kwargs.get("d", (0, "", 0.))
# なので、現実には`hoge`は従来の個別の引数に型付けした定義のままで、
# `fuga`への型ヒントとしてだけ`_HogeKwargs`で使うことになると考えています。
# 今回は機能の説明のため、極端な例を用いました。
# `hoge`に渡すべきキーワード引数の名前、型、制約と同じであることが表現できる
class fuga(criteria: int, **kwargs: **_HogeKwargs) -> None:
hoge(**kwargs)
必須/任意であるキーワード引数の表現
PEP655で導入されたtyping.Required
とtyping.NotRequired
を使って、必須または任意であるキーワード引数についても型アノテーションで表現が可能です。
def mogera(a: int, *, b: int, c: int = 0, d: str = "") -> None:
...
↓
class _MogeraKwargs(TypedDict):
b: int
c: NotRequired[int]
d: NotRequired[str]
def mogera(a: int, *, **kwargs: **_MogeraKwargs) -> None:
...
or
class _MogeraKwargs(TypedDict, total=False):
b: Required[int]
c: int
d: str
def mogera(a: int, *, **kwargs: **_MogeraKwargs) -> None:
...
引数名と型のDRY化
文字列を渡して、それに呼応する型が返るという関数を想定します。
class Foo: ...
class Bar: ...
class Baz: ...
def find(
kind: str,
*,
criteria1: int = 0,
criteria2: str = "",
criteria3: float = 0.,
criteria4: tuple[int, str, float] = (0, "", 0.),
) -> Foo | Bar | Baz:
...
if kind in ("Foo", "Spam", "Hoge"):
return Foo()
if kind in ("Bar", "Ham", "Fuga"):
return Bar()
if kind in ("Baz", "Bacon", "Piyo"):
return Baz()
raise TypeError
上記のような関数を呼び出したときには、下記のような型推論がされて欲しいです。
x = find("Hoge") # `Foo`に型推論される
y = find("Ham") # `Bar`に型推論される
z = find("Baz") # `Baz`に型推論される
kind
に渡されるLiteral
ごとにoverload
を設定して返す型を推論させる場合、これまでは返す型には影響しない引数もすべてWETに明示しなければ、元の関数にある引数の型制約を表現することはできませんでした。
@overload
def find(
kind: Literal["Foo", "Spam", "Hoge"],
*,
criteria1: int = ...,
criteria2: str = ...,
criteria3: float = ...,
criteria4: tuple[int, str, float] = ...,
) -> Foo: ...
@overload
def find(
kind: Literal["Bar", "Ham", "Fuga"],
*,
criteria1: int = ...,
criteria2: str = ...,
criteria3: float = ....,
criteria4: tuple[int, str, float] = ...,
) -> Bar: ...
@overload
def find(
kind: Literal["Baz", "Bacon", "Piyo"],
*,
criteria1: int = ...,
criteria2: str = ...,
criteria3: float = ...,
criteria4: tuple[int, str, float] = ...,
) -> Baz: ...
TypedDict
を使ってキーワード引数にアノテーションができるようになったことで、DRYな表現をすることができるようになりました。
class _FindOptKw(TypedDict, total=False):
criteria1: int
criteria2: str
criteria3: float
criteria4: tuple[int, str, float]
@overload
def find(kind: Literal["Foo", "Spam", "Hoge"], **kwargs: **_FindOptKw) -> Foo: ...
@overload
def find(kind: Literal["Bar", "Ham", "Fuga"], **kwargs: **_FindOptKw) -> Bar: ...
@overload
def find(kind: Literal["Baz", "Bacon", "Piyo"], **kwargs: **_FindOptKw) -> Baz: ...
後方互換性
Python3.11以前でも、TypedDict
を使って**kwargs
へアノテーションすることができます。
しかし**kwargs: **T
という文法は許されていないので、typing.Unpack
を使わなければなりません。
import sys
if sys.version_info >= (3, 11):
from typing import Unpack
else:
# Python3.11以前は標準ライブラリに`Unpack`がないので
# `typing_extensions`からimportする
from typing_extensions import Unpack
def hoge(**kwargs: Unpack[_HogeKwargs]) -> None:
fuga(**kwargs)
これは、Python3.11より前ではTypeVarTuple
を使っても*args: *Ts
とは書けないため、*args: Unpack[Ts]
と書く(PEP646)1ということに相似しています。
PEP692原文はUnpack
を使うユースケースが先に紹介され、**
を型ヒントシンボルに対して使えるようになることへの言及は最後のセクションに限られています。
しかしPEP646など静的型付けに関するPEPの傾向を見ると、新しいPythonのバージョンでは型アノテーション表現のために新文法を積極的に導入する2ことがトレンドのようです。
そして、Pythonコア開発者はPEP585導入後の対応に見られるように(置き換えることに問題がないなら)古い記法を使わず新しい記法を使うように推奨しています。
なので、この記事ではPEP692原文のユースケースとは違ってPython3.12の新文法の方をメインにおいています。
その他
PEP692原文には他にも位置引数との衝突を避ける方法、型付けされていないkwargs
との共存、TypedDict
の派生型をさらに派生させた際の振る舞いなどが記載されています。
まとめ
私は以前からTypedDict
の存在を知ってはいました。
しかし、
- 「キーごとに型が違う辞書」を型付けして使うぐらいなら、代わりにドットアクセスが可能でユーティリティが充実している
dataclasses.dataclass
やtyping.NamedTuple
のようなクラスを使うべき - 「キーに抜けがあるかもしれない辞書」を型付けして使うぐらいなら、代わりに構造的部分型を表現できる
typing.Protocol
を使うべき
という考えでした。
しかし、今回導入されたPEP692は、**kwargs
をTypedDict
を使ってアノテーションすることで
- 辞書としての振る舞いを表現する方法が使える
- 辞書のキーが必須なのか任意なのかということで、各キーワード引数が必須なのか任意なのかということを表現できる
- キーはPythonの関数に渡す引数名となるので、「
TypedDict
のフィールド名はPythonの変数名として適切でなければいけない」という制約3がバグの早期発見につながる
ので、初めてその存在意義を感じることができました。
**kwargs
についてキーワードごとに型が違うことの表現ができたのだから、*args
について位置ごとに型が違うことの表現もできるのではないかと、私は期待しています。
型アノテーションで表現できることが増えていくことは、ロバストなコードを書くことに役立っていくことでしょう。
-
TypeVarTuple
のユースケースについては下記に素晴らしい解説記事があります。
https://qiita.com/kissy24/items/79fd3691bb77ec5ea2cb ↩ -
*T
や**T
などの表現が文法的に許されていない後方バージョンのために、Unpack
などの特別なシンボルも用意することもトレンドのようです。 ↩ -
PEP589にあるように
X = TypedDict('X', {'y': str, 'x-z': int})
とすれば、Pythonの変数名として使用できない文字列も辞書のキーへの制約として設定することができます。 ↩