Python のジェネリック型から実行時に型パラメータを取得する方法
Python の型ヒントには、ジェネリック型も存在します。しかし、現在の Python の実装では、ジェネリック型のインスタンスやクラスには、実行時に型パラメータの情報は一切残っていない(型消去的動作)です。
本記事では、Python のジェネリック型の型注釈で実行時に型パラメータを取れるようにする方法を紹介します。
少し前に下書きをした関係で、Python 3.12 以前のジェネリック記法が登場します。
Python のジェネリック型と型注釈
Python のジェネリック型は以下のように定義できます。
from typing import Generic, TypeVar
T = TypeVar("T")
class MyList(Generic[T]):
...
このクラス MyList
は以下のようにインスタンス化できます。
ints = MyList[int]()
ここでは、具体的な型パラメータ int
を与えてそのインスタンスを作成し、型チェッカによってその使用方法を検査することができます。
この記事では、この ints
オブジェクトから、型パラメータ int
を取得できない(つまり、型パラメータが int
であることを実行時に特定できない)問題を解決するための方法を紹介します。
実行時に型パラメータを保持する方法
型ヒントに基づく型チェックを実現するために、新しいジェネリック型 Reified[T]
を定義し、その型パラメータを保持するようにします1。__class_getitem__
メソッドは、list[int]
のような型パラメータを指定したときに呼び出される特殊メソッドであり、その実装を変更することにより実行時に型パラメータを確保します。
実装は以下のようになります。
import types
from typing import Generic, TypeVar, Any
T = TypeVar("T")
class Reified(Generic[T]):
type_arg: Any = Any
def __class_getitem__(cls, key):
reified = types.new_class(
name=cls.__name__,
bases=(cls,),
exec_body=(lambda ns: ns)
)
reified.type_arg = key
return reified
話を簡単にするため型パラメタは 1 つに限定して考えています(TypeVarTuple
のことは一旦忘れます)。
__class_getitem__
の標準実装だと、__new__
には、型消去された元のクラスしか渡ってこないため、型パラメータを保持することができなくなっています。したがって、基本的な方針として、型パラメータごとに新しい type
を派生作成すれば良いという考え方が生まれます。上記実装では、types.new_class
を使って動的に新しいクラスを作成することで、これを達成しています。
同じ型パラメータのクラスはキャッシュしておくと ==
, is
演算子によるクラスの同一性も調べられるようになります(同一型パラメタの同一クラスは同一になる)が、上の実装ではサボっています。実装の細かい部分はまだ考慮が必要そうですが、上記の定義でだいたい欲しいものはできました。
使ってみる
前に Mypy で遊んだときのスタック実装に実行時型情報をつけてみました。値をプッシュする際に、型パラメータの指定と異なる場合に実行時エラーを発生させます。
class MyStack(Reified[T]):
def __init__(self) -> None:
super().__init__()
self.items: list[T] = []
def push(self, item: T) -> None:
# 実行時に型を調べて、非許容な値の場合はエラーにする
if isinstance(item, self.type_arg):
self.items.append(item)
else:
raise TypeError()
def pop(self) -> T:
if self.items:
return self.items.pop()
else:
raise IndexError("pop from empty stack")
実行時に型パラメータを取得することが可能になったため、このように実行時エラーを発生させることができています。
>>> MyStack[int]().push(100)
>>> MyStack[int]().push(0.1)
TypeError
>>> MyStack[int]().push("aaa")
TypeError
実装の妥当性
おおむね期待通りの性質になっているかなと思います。
>>> s = MyStack[int]()
>>> isinstance(s, Generic)
True
>>> isinstance(s, Reified)
True
>>> isinstance(s, MyStack)
True
>>> isinstance(s, MyStack[int]) # 上記で述べたクラスの同一性を直せば True になる
False
>>> isinstance(s, MyStack[str])
False
>>> issubclass(MyStack, Generic)
True
>>> issubclass(MyStack, Reified)
True
>>> issubclass(MyStack, MyStack[int])
False
>>> issubclass(MyStack[int], MyStack)
True
2024/04/16 追記
これをやってくれるライブラリを作りました。
型消去の反対で Reification というらしいです。 ↩︎