中学英語808712 views
いろは2986023 views
高校日本史189857 views
ヒストリア284143 views
教育148875 views
中学社会667106 views
高校物理158224 views
高校国語785655 views
数学講師2852771 views
MathPython491378 views
Help
Tools

English

Generic な dataclass - 型変数を使った汎用コンテナ|Python

list[int]dict[str, float] のように、Python の組み込み型は型引数を取れる。同じことを自作の dataclass でもやりたい場面がある。「中身の型は使う側が決める」という汎用的なコンテナやラッパーを dataclass で表現するには、Generic と型変数を組み合わせる。

型変数の基本

まず TypeVar で型変数を定義する。型変数は「まだ決まっていない型」を表す記号のようなものだ。

from typing import TypeVar

T = TypeVar("T")

この T は、使う側が intstr などの具体的な型を当てはめるまで未確定のままになる。関数のシグネチャやクラス定義で使うことで、「入力と出力の型が一致する」「コンテナの中身と取り出す値の型が同じ」といった関係性を表現できる。

Python 3.12 以降では新しい構文が導入され、TypeVar を明示的に作らなくても型変数を宣言できるようになった。

Python 3.11 以前

TypeVar を明示的に作成し、Generic[T] を継承して型変数を導入する。

Python 3.12 以降

class ClassName[T]: の構文で型変数を直接宣言できる。TypeVar のインポートが不要になる。

この記事では広く使える 3.11 以前の書き方を基本としつつ、3.12 の新構文にも触れる。

Generic な dataclass を作る

Generic[T] を継承した dataclass を定義すると、型引数を受け取るクラスになる。

from dataclasses import dataclass
from typing import TypeVar, Generic

T = TypeVar("T")

@dataclass
class Box(Generic[T]):
    value: T

box_int = Box[int](value=42)
box_str = Box[str](value="hello")

print(box_int)  # Box(value=42)
print(box_str)  # Box(value='hello')

Box[int] と書くことで「中身が int の Box」という型情報を表現している。ただし、これはあくまで型チェッカー向けの情報であり、実行時に型の検証が走るわけではない。Box[int](value="hello") と書いても実行時エラーにはならないが、mypy や pyright が警告を出す。

複数の型変数

型変数は複数使える。キーと値のペア、入力と出力の型が異なるコンテナなどを表現するときに必要になる。

from dataclasses import dataclass
from typing import TypeVar, Generic

K = TypeVar("K")
V = TypeVar("V")

@dataclass
class Pair(Generic[K, V]):
    key: K
    value: V

    def swap(self) -> "Pair[V, K]":
        return Pair(key=self.value, value=self.key)

p = Pair[str, int](key="age", value=30)
print(p)          # Pair(key='age', value=30)

swapped = p.swap()
print(swapped)    # Pair(key=30, value='age')

Pair[str, int] は「キーが str、値が int」のペアを表し、swap メソッドの戻り値は Pair[int, str] になる。型チェッカーはこの関係を追跡し、swapped.keyint であることを推論できる。

境界付き型変数

TypeVarbound 引数で、型変数が取りうる型の上界を制限できる。

from dataclasses import dataclass
from typing import TypeVar, Generic

class Comparable:
    def __lt__(self, other):
        return NotImplemented

C = TypeVar("C", bound=Comparable)

@dataclass
class MinMax(Generic[C]):
    low: C
    high: C

    def contains(self, value: C) -> bool:
        return self.low <= value <= self.high

CComparable のサブクラスに限定される。型チェッカーは C が比較演算をサポートしていることを保証でき、contains メソッド内の <= 演算が型安全であると判断する。

実用的には bound を使う場面はそれほど多くないが、ライブラリやフレームワークの内部で「この型変数は特定のプロトコルを満たす型だけを受け入れる」と明示したいときに有効だ。

実用例: Result 型

関数の成功・失敗を型安全に表現する Result パターンは、Generic な dataclass の代表的な応用だ。

from dataclasses import dataclass
from typing import TypeVar, Generic

T = TypeVar("T")
E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    value: T

@dataclass(frozen=True)
class Err(Generic[E]):
    error: E

Result = Ok[T] | Err[E]

def divide(a: float, b: float) -> Ok[float] | Err[str]:
    if b == 0:
        return Err("division by zero")
    return Ok(a / b)

result = divide(10, 3)
match result:
    case Ok(value):
        print(f"成功: {value:.4f}")
    case Err(error):
        print(f"失敗: {error}")

Ok[float]Err[str] という異なる型を返し、match 文でパターンマッチする。例外を投げる代わりに戻り値で成否を表現するアプローチで、Rust の Result<T, E> に着想を得た設計だ。frozen にしているのは、結果が後から変更されるべきでないためである。

実用例: キャッシュ付きローダー

取得コストの高いデータをキャッシュする汎用的なローダーも、Generic な dataclass で表現できる。

from dataclasses import dataclass, field
from typing import TypeVar, Generic, Callable, Optional

T = TypeVar("T")

@dataclass
class CachedLoader(Generic[T]):
    loader: Callable[[], T]
    _cache: Optional[T] = field(default=None, init=False, repr=False)
    _loaded: bool = field(default=False, init=False, repr=False)

    def get(self) -> T:
        if not self._loaded:
            self._cache = self.loader()
            self._loaded = True
        return self._cache  # type: ignore

config = CachedLoader[dict](loader=lambda: {"db": "postgres", "port": 5432})

print(config.get())  # {'db': 'postgres', 'port': 5432}
print(config.get())  # キャッシュから返る(loader は再実行されない)

CachedLoader[dict] は「dict を返すローダー」を意味する。get の戻り値が dict であることを型チェッカーが認識するため、呼び出し側で安全にアクセスできる。

_cache_loadedinit=Falserepr=False を指定し、外部からの初期化や表示に含めないようにしている。これにより、利用者は loader だけを渡せばよく、内部状態が隠蔽される。

dataclass の field オプションで可視性を制御する設計パターン。

Python 3.12 の新構文

Python 3.12 では型変数の宣言構文が簡潔になった。TypeVar を明示的に作る必要がなくなる。

# Python 3.12 以降
from dataclasses import dataclass

@dataclass
class Box[T]:
    value: T

    def map[U](self, func: "Callable[[T], U]") -> "Box[U]":
        return Box(value=func(self.value))

b = Box[int](value=42)
doubled = b.map(lambda x: x * 2)
print(doubled)  # Box(value=84)

class Box[T]: のようにクラス名の直後に角括弧で型変数を宣言する。メソッドレベルでも def map[U] のように新しい型変数を導入できる。typing モジュールからのインポートが減り、コードの見通しがよくなる。

Generic な dataclass で Boxint と書いた場合、何が起きるか?

  • TypeError が発生する
  • value が自動的に int に変換される
  • 実行時エラーにはならないが型チェッカーが警告する
  • Box のインスタンスが生成されない
__RESULT__

型引数は型チェッカー向けの情報であり、実行時の型検証は行われません。mypy や pyright は型の不一致を検出して警告しますが、Python 自体はそのまま実行します。

Generic dataclass の設計指針

型変数を導入すると柔軟性が増すが、何でも Generic にすればよいわけではない。

Generic にすべきケース

コンテナ、ラッパー、Result 型など「中身の型が利用者によって変わる」クラス。同じ構造で異なる型を扱う再利用性の高い部品に適している。

Generic にすべきでないケース

フィールドの型が最初から確定しているドメインオブジェクト。User や Order のようなビジネスロジックのクラスを無理に Generic にすると、可読性が下がるだけで利点がない。

型変数の数も抑えるべきだ。Generic[A, B, C, D] のように型変数が増えると、利用側のアノテーションが冗長になり、かえって理解しづらくなる。型変数は 1 つか 2 つに収めるのが実用的な目安で、それ以上必要なら設計を見直したほうがよい場合が多い。