目次
はじめに
Python3.12からPEP695で提案されたType Parameter Syntax(型引数構文)が導入され、これまでtyping
モジュールからGeneric
やTypeVar
などをインポートしてやるほかなかったジェネリクスや型変数の定義方法が大きく変わります。
これは実装にあたり新たな制御文を増やしているほどの大きな変更であり、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
をまったく違う意味として使えてしまっています。
つまり、ひとつの型変数に過剰な責任が載せられてしまっています。
もちろん、別々の名前で宣言することで、意味が違うことを強調し、責任を分散させることもできます。
しかし、ジェネリッククラス、関数、型エイリアスを定義しようとするたびに新しい名前を付けていくとそれはそれで混乱を招くし、コーダーが思いつく名前はいずれ枯渇するでしょう。
いつcovariant
とcontravariant
を使う(使わない)べきかわかりにくい
PEP483とPEP484では型変数の「変性」という概念が導入されました。
型変数のコンストラクタ引数で
-
covariant=True
ならば「共変」 -
contravariant=True
ならば「反変」 - それらでなければ「不変」(invariance)
であることが指定できます。
しかし、これは非常に高度な概念1 2で、ほとんどのPython開発者に理解されていません。
型引数の順番ルールがわかりにくい
ジェネリッククラスや型エイリアスで複数の型引数を使用する場合、Generic
(やProtocol
)を多重継承することで上書きすることができます。例えばclass ClassA(Mapping[K, V])
では型引数はK
、V
の順に並びますが、class ClassB(Mapping[K, V], Generic[V, K])
ではV
、K
の順に並びます。
この並び順の違いにどのような意味を持たせるかはクラス定義の詳細によりますが、後々になってコードを読んだ人が「入れ替えていることに何か意味があるのか?」と混乱するということは十分にありえるでしょう。
グローバルに宣言しているので「すべての参照」に引っかかる
最近のIDEは、言語サーバーが解釈した意味レベルで同一の変数を操作する「すべての参照を検索」や「すべての参照の名前を変更」といった機能を提供しています。
しかし前述のように、グローバルに宣言された型変数をまったく違う文脈で使用していることも多いです。
IDEの機能でまったく違う文脈のシンボルを操作して、意味が通らない名前になってしまうかもしれません。
型変数の命名にはプライベート性と冗長さを意識しなければならない
型変数はたいてい、モジュールのプライベート変数(外部から参照されない)として定義されます。
プライベートであるため、PEP8で推奨される「アンダースコアで始まる名前を付ける」3ルールが適用されます。
なので、_T_contra
や_KT_co
のような冗長な名前になってしまいます。また、現在の型変数はコンストラクタ引数で型変数名と等しい文字列を指定(ex. _T = TypeVar("_T")
)しなければならず、これもまた冗長です。
TypeVar
ライクやGeneric
ライクなシンボルをtyping
からインポートする必要がある
ジェネリクスや型引数の概念はtyping
に関連して導入され、使用するにはtyping
をインポートする必要がありました。
静的型付け機能が大人気になったので、Pythonの最近のリリースではPEP585のような特別なシンボルをインポートする必要性をなくす努力がなされていましたが、TypeVar
やGeneric
については後回しにされてきました。
同じ種類の概念を扱っていたのに、片方はインポートが必要で片方は不要なのは、「どっちがどっちだっけ」問題を引き起こして混乱のもとになります。
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
TypeVarTuple
やParamSpec
の宣言を代替する構文
型引数シンボルの前に*
をつけると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]
TypeVar
、TypeAlias
を使わずに型エイリアスを明示的に宣言できるように、新たにtype
文が導入され、上記のコードは下記のように書き直すことができるようになります。
type ListOrSet[T] = list[T] | set[T]
型の束縛(TypeVar
のbound
引数)相当をやりたい場合
_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_variance
がTypeVar
のオプショナルなコンストラクタ引数として追加され、True
を指定すると変性は型チェッカーが推論するようになります。
従来のTypeVar
はinfer_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ではソフトキーワードリストにmatch
、case
、_
が追加されました。
Python3.12では型エイリアスをtyping.TypeAlias
なしで明示することができるように、ソフトキーワードリストにtype
が追加されました。
まとめ
これまでもPythonの静的型付けシステムには様々な機能が付け加えられました。
-
typing
からコレクション型エイリアスをインポートしなくてもビルトイン型で型アノテーションができるようになる(PEP585) -
ParamSpec
を使うことでコールバック関数の引数に制約を持たせることが可能になる(PEP614) -
TypeVarTuple
を使うことで可変長ジェネリクスが可能になる(PEP646)
型アノテーションの重要性について論じた『ロバストPython』も刊行され、従来のPythonエンジニアにとって縁遠いものだった型アノテーションについて認知が高まっています。
PEP695はこれまでの静的型付け機能の中でも、クラスや関数の定義のための新たな構文が追加され、新たな文(type
)が増えるという大きな変更です。
今後も、コミュニティの拡大によって、新たな機能が静的型付けシステムに付け加えられていくことでしょう。
個人的には、動的ミックスインやファクトリ関数のアノテーションが簡単にできるようになる交差型Intersection
の導入を楽しみにしています。
-
筆者も「説明しろ」と言われるとかなり言葉につまります。『ロバストPython』でも
TypeVar
のオプショナル引数の意味について説明していません。もしやるとすればTypeScriptで同等の概念を扱うので、それを引用しながら説明することになると思います。 ↩ -
反変や共変であることを指定するべきかどうかは型チェッカーによって異なります。筆者は
pylance
が「ここは反変にすべき」という警告を発してmypy
が警告を発しない場面を目にしたことがあります。 ↩ -
Pythonの型スタブリポジトリ
typeshed
のCONTRIBUTING.md
には型変数名をアンダースコアはじまりにすべきと明記されています。 ↩