名古屋出身ソフトウェアエンジニアのブログ

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 追記

これをやってくれるライブラリを作りました。

Python でジェネリック型の型パラメータを実行時に取れるようにする Mixin 作った
Python でジェネリック型の型パラメータをランタイム時に取得できる抽象を提供するライブラリ Reification を作りました。前回記事「Python のジェネリック型から実行時に型パラメータを取得する方法」の内容を綺麗に実装した感じです。 このライブラリを使用すると、ジェネリックなクラスにランタイムでその型パラメータを認識させ、それに基づいた動作をさせることができます。具体的なユースケースとしては、コードの動的型検査の強化や、型パラメータを動作上の意味付けとして利用することなどが考えられます。

  1. 型消去の反対で Reification というらしいです。 ↩︎