🐍

Pythonの型ヒントと共に進化するコード(#16: TypeVar で実現する Generics)

に公開
これまでの連載記事


前回は SelfReadOnly を使って整形ロジックを分離しました。メソッドチェーンが型安全に書けるようになり、完成後のデータは変更しないという契約を型レベルで明示することができました。

また、前回の最後でデコレータを型安全に書きたいという話をしました。
ただ、その実装へ入る前に基礎となる概念を押さえておく必要があります。Genericsです。

今回の課題:ヘルパー関数で型情報が消える

開発中、API レスポンスの中身を確認したくなることがあります。
値を受け取って print で表示し、そのまま返す debug 関数を作ってみましょう。

def debug(value):
    print(f"DEBUG: {value}")
    return value

この関数は str でも int でも dict でも、なんでも受け取れるようにしたい。そして受け取った値をそのまま返すので 戻り値の型も入力と同じであってほしい。

この「なんでも受け取れて、入力と同じ型を返す」という要求を型で表現するにはどうすればいいでしょうか。

ひとまず型注釈を付けてみます。
どんな値でも受け取れるようにしたいので、まずは Any を使ってみましょう。

from typing import Any

def debug(value: Any) -> Any:
    print(f"DEBUG: {value}")
    return value

これで mypy は満足しますが落とし穴があります。

result: int = debug(42)       # OK
result2: str = debug(42)      # これもOK... え?

debug(42) は明らかに整数を返すはずなのに str 型の変数に代入しても mypy は型エラーを報告しません。
Any を返す関数はここ以降の型チェックを諦めるという意味になるので、どんな型への代入も許可されてしまうのです。

Any を返す関数を挟むと、その値は「何でもあり」として扱われ、型チェッカーの保護対象から外れます。

上記の例では debug を通した値だけの問題ですが、便利なヘルパー的な関数ほどあちこちで使われがちなので影響範囲は広がりやすいものです。

処方箋:型変数(TypeVar)で実現する Generics

この問題を解決するために使うのが TypeVar(型変数) です。
TypeVar を用いることで Python でも Generics 的な振る舞いを型として表現できます。

ここで欲しい保証は入力と出力の型が同じであることです。int を渡したら int が返る、str を渡したら str が返る。

この同一性を型チェッカーに伝えるための仕掛けが TypeVar(型変数)です。

用語を整理しておきます。

  • Generics

    • 型をパラメータ化する仕組み全体の概念
    • 「入力の型に応じて出力の型も変わる」といった型の関係性を表現する考え方
  • TypeVar(型変数)

    • Python において Generics を実現するための具体的な仕組み
    • 関数やクラスの中で「まだ決まっていない型」を表すプレースホルダー
    • 呼び出し時に与えられた型がそのまま戻り値や属性の型として伝播する

つまり、Generics は概念であり、TypeVar はそれを Python で実装するための道具です。

では TypeVar(型変数)を実際に使ってみましょう。
Python 3.12 以降では PEP 695 で導入された新構文により、関数定義に [] を付けて型変数を宣言できます。

def debug[T](value: T) -> T:
    print(f"DEBUG: {value}")
    return value

[T] の部分が型変数の宣言です。この構文によって T という型変数がこの関数のスコープ内で使えるようになります。

ここで、[T] を省略して def debug(value: T) -> T: と書けないか。結論から言うとそれだけではエラーになります。

# これはエラー: T が未定義
def debug(value: T) -> T:  # error: Name "T" is not defined
    return value

型変数は使う前に宣言が必要です。Python 3.12 の [T] 構文はその宣言を関数定義と一緒に書ける便利な書き方というわけです。

従来の Python 3.11 以前では、関数の外で TypeVar を使って明示的に宣言する必要がありました。

from typing import TypeVar

T = TypeVar("T")  # モジュールスコープで型変数を宣言

def debug(value: T) -> T:  # 宣言済みの T を使う
    print(f"DEBUG: {value}")
    return value

両者の違いをまとめるとこうなります。

構文 バージョン 型変数の宣言 スコープ
def debug[T](...) 3.12+ [T] で暗黙的に宣言 関数スコープ(その関数内でのみ有効)
T = TypeVar("T") + def debug(value: T) 3.5+ 事前に明示的に宣言 モジュールスコープ(どこからでも参照可能)

Python 3.12 の新構文は TypeVar を import して定義する手間が省けるだけでなく、型変数のスコープが関数に閉じるので名前の衝突も起きにくくなるのが素晴らしいところです。

この関数を使うと型情報が正しく保持されます。

result: int = debug(42)       # OK: T=int と推論される
result2: str = debug(42)      # error: expression has type "int", variable has type "str"

今度は mypy がちゃんとエラーを報告してくれます。型変数 T のおかげで入力の型と出力の型の関係が保証されるようになりました。

型変数がどう動くのか

型変数がどのように型をキャプチャ(捕捉)するのか、もう少し詳しく見てみます。

def debug[T](value: T) -> T:
    print(f"DEBUG: {value}")
    return value

# 呼び出しごとに T は異なる型に束縛される
n = debug(42)          # T=int、n の型は int
s = debug("hello")     # T=str、s の型は str
f = debug(3.14)        # T=float、f の型は float

型チェッカーは呼び出し時の引数から T の具体的な型を推論します。debug(42) なら T=intdebug("hello") なら T=str といった具合です。

ポイントは T が関数のシグネチャ内で同じ型を指すという点です。引数が T で戻り値も T なら両者は必ず同じ型になります。

複数の型変数を使う

型変数は複数宣言できます。2 つの値を入れ替える関数を考えてみましょう。

def swap[T, U](first: T, second: U) -> tuple[U, T]:
    return (second, first)

result = swap(1, "hello")  # result の型は tuple[str, int]

TU は独立した型変数なので、それぞれ異なる型を受け取れます。

実用例:コレクションを扱うヘルパー関数

もう少し実用的な例として、コレクションを扱う小さな関数を考えてみます。

シーケンスの先頭要素を取り出す

まず、シーケンスの最初の要素を安全に取り出す関数です。

from collections.abc import Sequence

def first[T](items: Sequence[T]) -> T | None:
    """シーケンスの最初の要素を返す。空なら None を返す。"""
    return items[0] if items else None

この関数は list[int] を渡せば int | None を、list[str] を渡せば str | None を返します。

numbers = [1, 2, 3]
n = first(numbers)     # n の型は int | None

names = ["Alice", "Bob"]
name = first(names)    # name の型は str | None

マッピングから安全に値を取得する

次に、マッピング(辞書など)から値を取得する関数です。

from collections.abc import Mapping

def get_or[K, V](d: Mapping[K, V], key: K, default: V) -> V:
    """マッピングから値を取得する。キーがなければデフォルト値を返す。"""
    return d.get(key, default)

この関数は辞書の値の型を保持したまま、キーが存在しない場合のデフォルト値を安全に返せます。

config = {"timeout": 30, "retries": 3}
timeout = get_or(config, "timeout", 10)    # timeout の型は int
max_size = get_or(config, "max_size", 100)  # キーがなくてもデフォルト値が返る

Generics の利点

Any を使った場合と比較すると Generics のありがたみが見えてきます。

Any を使った場合
from collections.abc import Sequence, Mapping
from typing import Any

def first_any(items: Sequence[Any]) -> Any | None:
    return items[0] if items else None

def get_or_any(d: Mapping[Any, Any], key: Any, default: Any) -> Any:
    return d.get(key, default)

n = first_any([1, 2, 3])
# n の型は Any | None
# Any 部分は型チェックが効かず、int を返しているのに str にも代入できてしまう
n_str: str | None = n  # エラーにならない(本当は int | None のはず)

config = {"timeout": 30}
timeout = get_or_any(config, "timeout", 10)
# timeout の型は Any になり、型チェックが効かない
timeout_str: str = timeout  # エラーにならない(本当は int のはず)
Genericsを使った場合
from collections.abc import Sequence, Mapping

def first[T](items: Sequence[T]) -> T | None:
    return items[0] if items else None

def get_or[K, V](d: Mapping[K, V], key: K, default: V) -> V:
    return d.get(key, default)

m = first([1, 2, 3])
# m の型は int | None になり、正確に追跡される
m_str: str | None = m  # error: Incompatible types

config = {"timeout": 30}
timeout = get_or(config, "timeout", 10)
# timeout の型は int として正確に保持される
timeout_str: str = timeout  # error: Incompatible types

Generics を使うことで、コレクション(listdict のような値を格納する型)の要素型が正確に追跡されます。
また、少し脱線しますが MappingSequence という抽象型を使うことで具体的な実装(dictlist)に縛られない柔軟性を持ちながら型情報を正しく保持できます。

発展:クラスも Generics にできる

関数の Generics を理解したところで、同じ概念がクラスにも適用できることを確認しておきます。次回以降で Generics クラスを扱う場面で役立つ知識です。

値を包むだけのシンプルな Box クラスを見てみます。

class Box[T]:
    def __init__(self, value: T) -> None:
        self._value = value

    def get(self) -> T:
        return self._value

int_box = Box(42)          # Box[int]
name_box = Box("Alice")    # Box[str]

Box 自体はただ値を包むだけのクラスですが、このシンプルな例から型を保ったまま扱うという Generics の本質が見えてきます。
int_box.get()int を返し、name_box.get()str を返すことが型レベルで保証されます。これは Box[T] がコンストラクタで受け取った型 Tget() の戻り値へそのまま伝播させているためです。

発展:型変数に上界を設ける

ここまでの T は「任意の型」を表していました。しかし実際には特定の基底クラスを継承した型だけに制限したいケースがよくあります。

たとえばメッセージを送信する関数を考えてみましょう。

from abc import ABC, abstractmethod

class Message(ABC):
    @abstractmethod
    def serialize(self) -> str: ...

class EmailMessage(Message):
    def __init__(self, to: str, body: str) -> None:
        self.to = to
        self.body = body

    def serialize(self) -> str:
        return f"To: {self.to}\n{self.body}"

この Message を受け取って送信し、同じ型を返す関数を書きたいとします。

使い方のイメージはこうです。

email = EmailMessage("alice@example.com", "Hello!")
sent = send(email)  # sent の型は EmailMessage であってほしい

send 関数は Message のサブタイプなら何でも受け取れて、渡した型をそのまま返してほしい。EmailMessage を渡したら EmailMessage が返るという具合です。

bound なしだと何も呼べない

では send 関数を定義します。
ここで単純に T を使うとどうなるでしょうか。

def send[T](msg: T) -> T:
    print(msg.serialize())  # error: "T" has no attribute "serialize"
    return msg

T は任意の型なので serialize() メソッドを持つ保証がありません。mypy は当然エラーを出します。

bound で型変数を制限する

ここで登場するのが 上界(bound) です。

[M: Message] と書くと MMessage またはそのサブタイプ という制約を表現できます。

def send[M: Message](msg: M) -> M:
    serialized = msg.serialize()  # OK: M は Message のサブタイプなので serialize() を呼べる
    print(f"Sending: {serialized}")
    # ... 実際の送信処理 ...
    return msg

これで serialize() を型安全に呼べるようになりました。

実際に使ってみます。

email = EmailMessage("alice@example.com", "Hello!")
result = send(email)
# 出力: Sending: To: alice@example.com
#       Hello!
# result の型は EmailMessage として保持される

戻り値の型も EmailMessage として保持されます。Message に丸められることはありません。

一方で Message のサブタイプでない型を渡すとエラーになります。

send("hello")  # error: Value of type variable "M" of "send" cannot be "str"
send(123)      # error: Value of type variable "M" of "send" cannot be "int"

bound を使えば特定のインターフェースを持つ型だけを受け取り、その具体的な型を保持して返すという関数を型安全に書けます。

コードの進化:汎用関数を追加する

Generics を理解したところで、先ほど紹介した型安全な汎用関数を実際にコードベースに追加します。

新しく typings.py を作成し、プロジェクト全体で使える関数を置く場所にします。

# typings.py
from __future__ import annotations

from collections.abc import Mapping, Sequence


def first[T](items: Sequence[T]) -> T | None:
    """シーケンスの最初の要素を返す。空なら None を返す。"""
    return items[0] if items else None


def get_or[K, V](d: Mapping[K, V], key: K, default: V) -> V:
    """マッピングから値を取得する。キーがなければデフォルト値を返す。"""
    return d.get(key, default)

こうした小さな便利関数も型安全に書く習慣をつけておくとコードベース全体の型安全性が維持しやすくなると思います。

default が別の型でもよい場合

次回予告

Generics の基礎をおさえたところで次回はデコレータの話に戻ります。

デコレータは関数を受け取って関数を返す高階関数です。型安全なデコレータを書くには元の関数と同じシグネチャを持つ関数を返すことを型で表現しなければなりません。

今回紹介した TypeVar戻り値の型をキャプチャできますが、それだけでは不十分です。
引数の型情報もキャプチャする必要があります。

次回は引数の型情報をキャプチャする ParamSpec と、関数の型を表す Callable を導入して型安全なデコレータを実装します。

👉 17 日目: ParamSpec と Callable

処方後のコードはこちら

typings.py(新規作成)

# typings.py
from __future__ import annotations

from collections.abc import Mapping, Sequence


def first[T](items: Sequence[T]) -> T | None:
    """シーケンスの最初の要素を返す。空なら None を返す。"""
    return items[0] if items else None


def get_or[K, V](d: Mapping[K, V], key: K, default: V) -> V:
    """マッピングから値を取得する。キーがなければデフォルト値を返す。"""
    return d.get(key, default)

models.py

# models.py
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any, ClassVar, Final, NewType, ReadOnly, Self, TypedDict

ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]

@dataclass(frozen=True, slots=True)
class Address:
    API_PATH: ClassVar[Final[str]] = "/v1/address"

    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

    def full_address(self) -> str:
        """都道府県・市区町村・町域を結合したフル住所を返す"""
        return self.prefecture + self.city + self.town

    def full_address_kana(self) -> str:
        """フル住所のカナ表記を返す"""
        return self.prefecture_kana + self.city_kana + self.town_kana

    @classmethod
    def unmarshal_payload(cls, payload: Mapping[str, Any]) -> Address:
        """APIレスポンスからAddressオブジェクトを生成する"""
        return cls(
            zipcode=str(payload["zipcode"]),
            prefecture=str(payload["prefecture"]),
            prefecture_kana=str(payload["prefecture_kana"]),
            city=str(payload["city"]),
            city_kana=str(payload["city_kana"]),
            town=str(payload["town"]),
            town_kana=str(payload["town_kana"]),
        )

class FormattedAddressDict(TypedDict):
    zipcode: ReadOnly[str]
    full_address: ReadOnly[str]
    prefecture: ReadOnly[str]
    city: ReadOnly[str]
    town: ReadOnly[str]

class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: ReadOnly[str]

@dataclass(frozen=True, slots=True)
class AddressFormatter:
    _address: Address | None = None
    _include_kana: bool = False

    def with_address(self, address: Address) -> Self:
        return replace(self, _address=address)

    def with_kana(self, include: bool = True) -> Self:
        return replace(self, _include_kana=include)

    def build(self) -> FormattedAddressDict | FormattedAddressWithKanaDict:
        if self._address is None:
            raise ValueError("Address must be set before building.")

        base: FormattedAddressDict = {
            "zipcode": self._address.zipcode,
            "full_address": self._address.full_address(),
            "prefecture": self._address.prefecture,
            "city": self._address.city,
            "town": self._address.town,
        }

        if self._include_kana:
            with_kana: FormattedAddressWithKanaDict = {
                **base,
                "full_address_kana": self._address.full_address_kana(),
            }
            return with_kana

        return base

http_client.py

# http_client.py
from __future__ import annotations

import requests as requests_lib
from typing import Protocol

from models import Headers

type JsonObject = dict[str, object]


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response) -> None:
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self) -> None:
        self._session = requests_lib.Session()

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)

main.py

# main.py
from __future__ import annotations

import json
from collections.abc import Mapping
from typing import Any, Final, cast

from models import (
    ZipCode,
    Headers,
    Address,
    AddressFormatter,
)
from http_client import HttpClient, RequestsHttpClient

# 定数
BASE_URL: Final[str] = "https://api.zipcode-jp.example"
HTTP_OK: Final[int] = 200

def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    """郵便番号から住所を取得し、整形して返す"""

    api_url = f"{BASE_URL}{Address.API_PATH}"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != HTTP_OK:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        payload = cast(Mapping[str, Any], response.json())
        address = Address.unmarshal_payload(payload)

        formatter = AddressFormatter()
        result = formatter.with_address(address).with_kana(include_kana).build()

        return json.dumps(result, indent=2, ensure_ascii=False)

    except Exception as e:
        print(f"An error occurred: {e}")
        return None

# 実行例
if __name__ == "__main__":
    http_client = RequestsHttpClient()
    zipcode = ZipCode("1000001")
    result = fetch_and_format_address(
        zipcode, include_kana=True, http_client=http_client
    )
    if result is not None:
        print(result)
おまけ: type ステートメントについて

Python 3.12 以降では type ステートメントで型エイリアスを書けます。
ここでも同じ [T] 構文で Generics が使えます。

(例:type Result[T] = tuple[T, str]

type ステートメントは 6 日目 で紹介した TypeAlias の後継です。

Discussion