12
10

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.

Python3.12からPEP692が導入され`**kwargs: **TypedDict`でキーワードごとの詳細な型付けやDRY化ができるようになる

Last updated at Posted at 2023-08-22

はじめに

以前、日本語での解説記事がなかった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 フィールドの指定とは全然関係ないキーがある

その他の機能や仕様について、詳しくは上述の公式ドキュメントを参照して下さい。

キーワード引数に型制約をつける

前述の関数hogefugaは下記のように**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.Requiredtyping.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.dataclasstyping.NamedTupleのようなクラスを使うべき
  • 「キーに抜けがあるかもしれない辞書」を型付けして使うぐらいなら、代わりに構造的部分型を表現できるtyping.Protocolを使うべき

という考えでした。

しかし、今回導入されたPEP692は、**kwargsTypedDictを使ってアノテーションすることで

  • 辞書としての振る舞いを表現する方法が使える
  • 辞書のキーが必須なのか任意なのかということで、各キーワード引数が必須なのか任意なのかということを表現できる
  • キーはPythonの関数に渡す引数名となるので、「TypedDictのフィールド名はPythonの変数名として適切でなければいけない」という制約3がバグの早期発見につながる

ので、初めてその存在意義を感じることができました。

**kwargsについてキーワードごとに型が違うことの表現ができたのだから、*argsについて位置ごとに型が違うことの表現もできるのではないかと、私は期待しています。

型アノテーションで表現できることが増えていくことは、ロバストなコードを書くことに役立っていくことでしょう。

  1. TypeVarTupleのユースケースについては下記に素晴らしい解説記事があります。
    https://qiita.com/kissy24/items/79fd3691bb77ec5ea2cb

  2. *T**Tなどの表現が文法的に許されていない後方バージョンのために、Unpackなどの特別なシンボルも用意することもトレンドのようです。

  3. PEP589にあるようにX = TypedDict('X', {'y': str, 'x-z': int})とすれば、Pythonの変数名として使用できない文字列も辞書のキーへの制約として設定することができます。

12
10
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
12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?