LoginSignup
134
100
お題は不問!Qiita Engineer Festa 2023で記事投稿!

Python3.12からPEP695-Type Parameter Syntax(型引数構文)が導入され、型変数を使ったクラスや関数の定義が大きく変わる

Last updated at Posted at 2023-07-04

目次

はじめに

Python3.12からPEP695で提案されたType Parameter Syntax(型引数構文)が導入され、これまでtypingモジュールからGenericTypeVarなどをインポートしてやるほかなかったジェネリクスや型変数の定義方法が大きく変わります。

これは実装にあたり新たな制御文を増やしているほどの大きな変更であり、3.12の目玉機能だと思うのですが、日本語で書かれている記事は(2023/07/04時点で)ありませんでした。

なので、ここに記事を書いてみることにしました。

PEP全文

詳しくは上記の元ドキュメントを読んでください。
本記事では、上記PEPの要約を説明します。

このPEPが提案された経緯

Python3.5でtypingなど型ヒントのための機能が導入(PEP484)されて以来、型ヒントはPythonの機能の中でも人気があるものの一つになりました。
型ヒント機能はジェネリクスを用いることで表現力を豊かにすることができます。
しかし、それを行うにはtypingからGenericなどをインポートしてそれを継承してジェネリクスを定義する必要がありました。
さらに、それらに渡す型引数のためにtypingからTypeVar/ParamSpec/TypeVarTupleをインポートして型変数を定義する必要もありました。
Pythonの開発者たちはこれらの機能をPythonの組み込み(bulit-in)機能ではなくネジ止め(bolt-on)機能であるように感じていて、混乱のもとになっていました。
そこで、他の現代的なプログラミング言語にあるのと同等の構文を用いて、ジェネリクスやパラメータ型を表現できるようにすべくこのPEPが提案されました。

「混乱のもと」とは何か

型変数のスコープルールがわかりにくい

型変数は通常グローバルスコープ内でアサインされます。

_T = TypeVar("_T")

しかし、それを型引数として使う時、意味付けはジェネリッククラス、関数、型エイリアスの文脈内で使用される場合にのみ有効です。
そして型変数のインスタンスは複数の文脈で再利用される可能性があり、それぞれのコンテキストで異なる意味を持つことができてしまいます。

# インスタンス属性`hoge`の型がなんであるかはコンストラクタ引数に
# 渡された`hoge`引数の型で決まることを型チェッカーに伝える
class Foo(Generic[_T]):
    hoge: _T

    def __init__(self, hoge: _T) -> None:
        ...


# この関数が返す型がなんであるかは引数に渡されたシーケンスの型引数が
# 何型かで決まることを型チェッカーに伝える
def get_first(seq: Sequence[_T]) -> _T:
    return seq[0]

上記のコードでは、グローバルに宣言した_Tをまったく違う意味として使えてしまっています。
つまり、ひとつの型変数に過剰な責任が載せられてしまっています。
もちろん、別々の名前で宣言することで、意味が違うことを強調し、責任を分散させることもできます。
しかし、ジェネリッククラス、関数、型エイリアスを定義しようとするたびに新しい名前を付けていくとそれはそれで混乱を招くし、コーダーが思いつく名前はいずれ枯渇するでしょう。

いつcovariantcontravariantを使う(使わない)べきかわかりにくい

PEP483PEP484では型変数の「変性」という概念が導入されました。
型変数のコンストラクタ引数で

  • covariant=Trueならば「共変」
  • contravariant=Trueならば「反変」
  • それらでなければ「不変」(invariance)

であることが指定できます。
しかし、これは非常に高度な概念1 2で、ほとんどのPython開発者に理解されていません。

型引数の順番ルールがわかりにくい

ジェネリッククラスや型エイリアスで複数の型引数を使用する場合、Generic(やProtocol)を多重継承することで上書きすることができます。例えばclass ClassA(Mapping[K, V])では型引数はKVの順に並びますが、class ClassB(Mapping[K, V], Generic[V, K])ではVKの順に並びます。
この並び順の違いにどのような意味を持たせるかはクラス定義の詳細によりますが、後々になってコードを読んだ人が「入れ替えていることに何か意味があるのか?」と混乱するということは十分にありえるでしょう。

グローバルに宣言しているので「すべての参照」に引っかかる

最近のIDEは、言語サーバーが解釈した意味レベルで同一の変数を操作する「すべての参照を検索」や「すべての参照の名前を変更」といった機能を提供しています。
しかし前述のように、グローバルに宣言された型変数をまったく違う文脈で使用していることも多いです。
IDEの機能でまったく違う文脈のシンボルを操作して、意味が通らない名前になってしまうかもしれません。

型変数の命名にはプライベート性と冗長さを意識しなければならない

型変数はたいてい、モジュールのプライベート変数(外部から参照されない)として定義されます。
プライベートであるため、PEP8で推奨される「アンダースコアで始まる名前を付ける」3ルールが適用されます。
なので、_T_contra_KT_coのような冗長な名前になってしまいます。また、現在の型変数はコンストラクタ引数で型変数名と等しい文字列を指定(ex. _T = TypeVar("_T"))しなければならず、これもまた冗長です。

TypeVarライクやGenericライクなシンボルをtypingからインポートする必要がある

ジェネリクスや型引数の概念はtypingに関連して導入され、使用するにはtypingをインポートする必要がありました。
静的型付け機能が大人気になったので、Pythonの最近のリリースではPEP585のような特別なシンボルをインポートする必要性をなくす努力がなされていましたが、TypeVarGenericについては後回しにされてきました。
同じ種類の概念を扱っていたのに、片方はインポートが必要で片方は不要なのは、「どっちがどっちだっけ」問題を引き起こして混乱のもとになります。

PEP695導入後にPythonの書き方はどう変わるか

TypeVarシンボルの宣言を新たな構文に置き換えることができる

下記に紹介する新しい構文では、TypeVarを使って型引数や型アノテーションに使う型変数をグローバルに宣言する必要がなくなります。
そして、型変数のスコープは宣言された文脈に限定されるようになり、冗長な命名や使いまわし問題を回避することが可能になります。

関数の場合

def 関数名[型引数](引数: 引数アノテーション) -> 返り値型アノテーション:と書くことができるようになります。

from typing import TypeVar

_T = TypeVar("_T")

def func(a: _T, b: _T) -> _T:
    ...

def func[T](a: T, b: T) -> T:
    ...

クラスの場合

class クラス名[型引数](基底クラス):と書くことができるようになります。

_T = TypeVar("_T")

class ClassA(Generic[_T]):
    def method1(self) -> _T:
        ...

class ClassB(Iterator[_T]):
    def method1(self) -> _T:
        ...

class ClassA[T]:
    def method1(self) -> T:
        ...

class ClassB[T](Iterator):
    def method1(self) -> T:
        ...

旧来のGenericを使う方法と新しい構文を使ったジェネリッククラスの定義を併用することはできません。

class ClassA[T](Generic[T]): ...  # 実行時エラー

Protocolを定義するための構文

構造的部分型でダックタイピングを行う時に便利なProtocolですが、Genericとは違ってこれは新しい構文でも個別にインポートして宣言する必要があります。

from typing import Protocol

_S = TypeVar("_S")
_T = TypeVar("_T")

class ClassA(Protocol[_S, _T]): ...

from typing import Protocol

class ClassA[S, T](Protocol): ... # OK

TypeVarTupleParamSpecの宣言を代替する構文

型引数シンボルの前に*をつけるとTypeVarTuple**をつけるとParamSpecとして解釈します。

_T = TypeVar("_T")
_Ts = TypeVarTuple("_Ts")
_P = ParamSpec("_P")
class Foo(Generic[_T, _Ts, _P]): ...

# シンボル`T`を`TypeVar`とするなら`T`と宣言する
# シンボル`Ts`を`TypeVarTuple`とするなら`*Ts`と宣言する
# シンボル`P`を`ParamSpec`とするなら`**P`と宣言する
class Foo[T, *Ts, **P]: ...

同じシンボルの型変数を同時にTypeVar/TypeVarTuple/ParamSpecとして扱おうとするとSyntaxErrorとなります。

class ClassA[T, *T]: ... # SyntaxError

def func1[T, **T](): ... # SyntaxError

ジェネリック型エイリアスを表現するためのtype文の登場

ジェネリック型エイリアスを定義するためには、型変数を定義するために必要なTypeVarに加えて(PEP613で導入された)TypeAliasを使うことが推奨されていました。

from typing import TypeAlias

_T = TypeVar("_T")

ListOrSet: TypeAlias = list[_T] | set[_T]

TypeVarTypeAliasを使わずに型エイリアスを明示的に宣言できるように、新たにtype文が導入され、上記のコードは下記のように書き直すことができるようになります。

type ListOrSet[T] = list[T] | set[T]

型の束縛(TypeVarbound引数)相当をやりたい場合

_T_D = TypeVar("_T_D", bound=dict[str, int])

class ClassA(Generic[_T_D]): ...  # OK

_T_FR = TypeVar("_T_FR", bound="ForwardReference")

class ClassB(Generic[_T_FR]): ...  # OK

class ClassA[T: dict[str, int]]: ...  # OK

class ClassB[T: "ForwardReference"]: ...  # OK

下記のような書き方はエラーになります。

class ClassC[V]:
    class ClassD[T: dict[str, V]]: ...  # 型チェッカーエラー、このやり方でジェネリックを指定できない

class ClassE[T: [str, int]]: ...  # 型チェッカーエラー、この書き方は許されない

型の制約(TypeVarの可変長引数相当)をやりたい場合

_T_A = TypeVar("_T_A", str, bytes)

class ClassA(Generic[_T_A]): ...

_T_B = TypeVar("_T_B", "ForwardReference", bytes)

class ClassB(Generic[_T_B]): ...

class ClassA[T: (str, bytes)]: ...  # OK

class ClassB[T: ("ForwardReference", bytes)]: ...  # OK

下記のような書き方はエラーになります。

class ClassC[T: ()]: ...  # 型チェッカーエラー、長さ2以上のタプルとして指定必須

class ClassD[T: (str, )]: ...  # 型チェッカーエラー、長さ2以上のタプルとして指定必須

t1 = (bytes, str)
class ClassE[T: t1]: ...  # 型チェッカーエラー、リテラルタプルとして指定必須

TypeVarのコンストラクタ引数にinfer_varianceが追加

infer_varianceTypeVarのオプショナルなコンストラクタ引数として追加され、Trueを指定すると変性は型チェッカーが推論するようになります。
従来のTypeVarinfer_variance=Falseがデフォルトであるため、(他のオプショナル引数を指定しなければ)不変として解釈されることは変わりません。

以前に不変である型変数を使いたくない場面がある場合、変性について熟知していなければなりませんでした。

from typing import Generic, TypeVar

_T_co = TypeVar("_T_co", covariant=True, bound=str)

class ClassA(Generic[_T_co]):
    def method1(self) -> _T_co:
        ...

上記のコードは下記のように書くことができるようになり、変性の指定について悩む必要がなくなります。

from typing import Generic, TypeVar

_T = TypeVar("_T", infer_variance=True, bound=str)

class ClassA(Generic[_T]):
    def method1(self) -> _T:
        ...

実は、これまで紹介した新しい構文で型変数を宣言した際には、変性を(指定できず)型チェッカーの推論に任せることになります。
つまり、TypeVar(..., infer_variance=True, ...)である型変数を用いるのと同じことになっています。

上記のコードは下記のように書き直すことができます。

class ClassA[T: str]:
    def method1(self) -> T:
        ...

TypeVarと新しい構文との互換性

新しい構文とは、旧来のTypeVarと併用/互換して使える実装がされています。

K = TypeVar("K")

class ClassA[V](dict[K, V]): ...  # NG、`ClassA(dict[K, V], Generic[V])`ができないのと同じ理由

class ClassB[K, V](dict[K, V]): ...  # OK、`ClassA(dict[K, V], Generic[K, V])`と同じ

class ClassC[V]:
    # `method1`で使用されている`K`と`V`は型引数が「暗黙的」で「伝統的」なジェネリック関数の
    # メカニズムを使用しているため問題ない。この場合、
    # - `V`は`ClassC`で宣言されている型引数から
    # - `K`はグローバルで宣言されている`TypeVar`から
    # 「暗黙的」に導入されている。
    def method1(self, a: V, b: K) -> V | K: ...  # OK

    # `method2`で`M`と`K`は使えない。メソッドの型引数を新しい構文で宣言する場合は、
    # すべての型引数を新しい構文で「明示的」に宣言する必要がある。
    def method2[M](self, a: M, b: K) -> M | K: ...  # NG

その他

実行時にクラスやインスタンスに付与されるdunder属性/メソッドやデコレータ、nonlocalとの相互作用などがPEP695には記載されています。

付録: ソフトキーワードの変化

Pythonではmatchのように「特定の文脈だけ制御文」「そうでなければ普通の変数名としてその単語で宣言可能」「よって、制御構文の単語と変数名の単語が一緒でも問題ない」という「特定の文脈でのみ予約される識別子」を「ソフトキーワード」という概念にしています。
Python3.9でkeywordモジュールには空リストsoftkwlist及びFalseのみを返す関数issoftkeywordが導入されました。
match文が導入されたPython3.10ではソフトキーワードリストにmatchcase_が追加されました。
Python3.12では型エイリアスをtyping.TypeAliasなしで明示することができるように、ソフトキーワードリストにtypeが追加されました。

まとめ

これまでもPythonの静的型付けシステムには様々な機能が付け加えられました。

  • typingからコレクション型エイリアスをインポートしなくてもビルトイン型で型アノテーションができるようになる(PEP585)
  • ParamSpecを使うことでコールバック関数の引数に制約を持たせることが可能になる(PEP614)
  • TypeVarTupleを使うことで可変長ジェネリクスが可能になる(PEP646)

型アノテーションの重要性について論じた『ロバストPython』も刊行され、従来のPythonエンジニアにとって縁遠いものだった型アノテーションについて認知が高まっています。
PEP695はこれまでの静的型付け機能の中でも、クラスや関数の定義のための新たな構文が追加され、新たな文(type)が増えるという大きな変更です。
今後も、コミュニティの拡大によって、新たな機能が静的型付けシステムに付け加えられていくことでしょう。
個人的には、動的ミックスインやファクトリ関数のアノテーションが簡単にできるようになる交差型Intersectionの導入を楽しみにしています。

  1. 筆者も「説明しろ」と言われるとかなり言葉につまります。『ロバストPython』でもTypeVarのオプショナル引数の意味について説明していません。もしやるとすればTypeScriptで同等の概念を扱うので、それを引用しながら説明することになると思います。

  2. 反変や共変であることを指定するべきかどうかは型チェッカーによって異なります。筆者はpylanceが「ここは反変にすべき」という警告を発してmypyが警告を発しない場面を目にしたことがあります。

  3. Pythonの型スタブリポジトリtypeshedCONTRIBUTING.mdには型変数名をアンダースコアはじまりにすべきと明記されています。

134
100
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
134
100