モダンPython上級 #6 typing 上級 — Variance、ParamSpec、Self、overload

読了 8分

中級 #2 typing 本格 で Generic、Protocol、TypedDict、Literal までつかみました。今回はその次の段階 — typing システムの難しい部分 です。variance、ParamSpec/Concatenate、Self、TypeGuard/TypeIs、@overload といった道具です。

これはほぼ ライブラリ作者向けの領域 で、一般のコードには出てこなくても構いません。しかしライブラリコードを読んだり、正確な型を作りたければ、知っておく必要があります。

Variance — 共変性 / 反共変性 #

最も混乱しやすい部分から。list[Cat]list[Animal] のサブタイプか?

直感的には「Cat は Animal だから list[Cat] も list[Animal] だ」と思いがちですが — 間違いです。 なぜか見ていきましょう。

🚫 安全ではない
def add_dog(animals: list[Animal]) -> None:
    animals.append(Dog())

cats: list[Cat] = [Cat(), Cat()]
add_dog(cats)    # ✗ もし許されると、cats の中に Dog が入ってしまう

list[Cat]list[Animal] では ありません。 受け取る側と渡す側がどちらも可能 (mutable) なため、どちら向きでも安全を保証しにくいのです。

3 種類の variance #

種類意味
不変 (invariant)Box[T]Box[U] は無関係list[T]
共変 (covariant)TU のサブタイプなら Box[T]Box[U] のサブタイプtuple[T, ...]Iterable[T] (read-only)
反共変 (contravariant)TU のサブタイプなら Box[U]Box[T] のサブタイプCallable[[T], R]引数の位置

直感 #

  • 読むだけ → 共変。Iterable[Cat]Iterable[Animal] として使用可能 (読めば Animal である Cat)
  • 書き / 読み両方 → 不変。list[Cat]dict[K, V]
  • 引数の位置 → 反共変。「Animal を受け取る関数」の位置に「Cat を受け取る関数」を入れると危険 (Dog が来ても Animal 関数は受けるべき)

関数型から見ると #

Callable の variance
def feed_animal(a: Animal): ...
def feed_cat(c: Cat): ...

# 'Animal を受け取る関数' の位置に 'Cat だけ受け取る関数' を入れられるか?
fn: Callable[[Animal], None] = feed_cat   # ✗ — Dog が来ると壊れる

# 逆は?
fn2: Callable[[Cat], None] = feed_animal  # ✅ — Cat も Animal の一部なので OK

関数の 引数の位置は反共変 です。戻り値の位置は共変です。

どう変位を明示するか #

中級 #2 で見た PEP 695 の新文法 def fn[T]:自動で推論 します。古い方式 (TypeVar) では直接書きました。

古い方式 — 明示的
from typing import TypeVar

T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)

class Producer(Generic[T_co]):
    def get(self) -> T_co: ...

class Consumer(Generic[T_contra]):
    def put(self, item: T_contra) -> None: ...

新しいコードでは普通、自分で明示しなくてもツールがうまく処理してくれます。ライブラリ作者なら IterableCallable などの標準 ABC の variance を意識して使う — その程度が日常の場面です。

ParamSpecConcatenate #

中級 #5 でデコレータのシグネチャを保持する道具として短く見ました。もっと深く。

ParamSpec の正体 #

ParamSpec ふたたび
def log[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        ...
    return wrapper

P型パラメータではなく、パラメータシグネチャそのもの です。一束の (位置引数の型たち + キーワード引数の型たち) を表します。

Concatenate — 引数を追加 #

デコレータが 引数を 1 つ追加 したいとき。

先頭引数を追加
from typing import Concatenate, Callable

def with_logger[**P, R](
    fn: Callable[Concatenate[Logger, P], R]
) -> Callable[P, R]:
    logger = make_logger()
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(logger, *args, **kwargs)
    return wrapper

@with_logger
def do_work(logger: Logger, x: int, y: int) -> int:
    logger.info(f"{x} + {y}")
    return x + y

do_work(2, 3)   # logger は自動で注入される

Concatenate[Logger, P] の意味: 「先頭の引数が Logger、その後ろは P そのまま」。デコレートした後 logger 引数が抜けた シグネチャになります。

デコレータが 引数を抜いて 呼び出し側が書かなくてよくするパターン — 依存性注入の 1 つの形です。

Self — メソッドの戻り値で自身のクラス #

中級 #2 で短く見た部分。

Self
from typing import Self

class Builder:
    def add(self, item: str) -> Self:
        ...
        return self

class SubBuilder(Builder):
    def special(self) -> Self:
        ...
        return self

b = SubBuilder().add("x").special()
# b の型は正確に SubBuilder と推論される

-> "Builder" (forward reference) を使うと SubBuilder で呼び出しても結果型が Builder になり、.special() の呼び出しがブロックされます。Self がそれを解決します。

クラスメソッドにも #

代替コンストラクタ
class Item:
    @classmethod
    def from_dict(cls, data: dict) -> Self:
        return cls(**data)

サブクラスで呼び出すと、結果型がサブクラスに正確に推論されます。

@overload — 同じ関数、異なるシグネチャ #

呼び出し引数によって 戻り値の型が変わる 関数に正確な型を付ける道具。

overload
from typing import overload

@overload
def parse(value: str) -> str: ...
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: list[str]) -> list[str]: ...

def parse(value):
    # 実際の実装 — overload シグネチャの下
    return value

a = parse("hello")        # str
b = parse(42)              # int
c = parse(["a", "b"])      # list[str]

@overload デコレータが付いた定義は 型チェック専用 です。本体は通常 ...。その下に 実際の本体 1 つ が来ます。

いつ使うか? #

  • 引数の型によって戻り値の型が変わるとき
  • Literal 引数によって分岐するとき
Literal による分岐
@overload
def get(key: Literal["count"]) -> int: ...
@overload
def get(key: Literal["name"]) -> str: ...
@overload
def get(key: str) -> object: ...

def get(key): ...

呼び出し側が get("count") なら正確に int を受け取ると推論されます。

dict.get のような標準関数がこのように定義されている #

標準ライブラリのパターン
@overload
def get(self, key: K) -> V | None: ...
@overload
def get(self, key: K, default: V) -> V: ...
@overload
def get(self, key: K, default: T) -> V | T: ...

d.get("k")V | Noned.get("k", 0)int (0 の型) のように、同じ呼び出しでも正確な推論ができるようになるケース。

TypeGuardTypeIs — ユーザー定義の型の絞り込み関数 #

isinstance 以外に ユーザー定義の型の絞り込み関数 を作る道具。

TypeGuard (3.10+) #

TypeGuard
from typing import TypeGuard

def is_str_list(items: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in items)

def process(data: list[object]) -> None:
    if is_str_list(data):
        # ここで data は list[str] に絞り込まれる
        print(", ".join(data))

TypeGuard[T] を返す関数は True のとき引数が T である という約束になります。

TypeIs (3.13+) — TypeGuard の改良版 #

TypeIs
from typing import TypeIs

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def handle(x: int | str) -> None:
    if is_str(x):
        print(len(x))     # str
    else:
        print(x + 1)      # int  ← ここが違う!

違い: TypeIs は False のときも 型を絞り込みます。TypeGuard は True のときだけ。TypeIs はより直感的で安全な場面で使われます。

TypeGuard は互換性維持のために残されており、新しいコードは TypeIs が推奨されます (3.13+)。

Annotated — 型にメタデータ #

Annotated
from typing import Annotated

UserId = Annotated[int, "UserID — DB の users.id"]
Email = Annotated[str, "メール形式"]

def find(id: UserId, email: Email): ...

Annotated[T, ...] の追加メタデータは型チェッカーが 無視 します。ただし、ライブラリが ランタイムにそれを読んで動作に活用 することはできます。FastAPIDependsQuery がこのパターンを使う最も有名な例です。

FastAPI が使うパターン
from typing import Annotated
from fastapi import Depends, Query

def get_db(): ...

def search(
    q: Annotated[str, Query(min_length=3)],
    db: Annotated[Database, Depends(get_db)],
): ...

型自体は strDatabase のまま読まれつつ、追加の動作仕様が一緒に束ねられます。

LiteralString — SQL インジェクション防御 (3.11+) #

LiteralString
from typing import LiteralString

def execute(query: LiteralString) -> list[Row]:
    ...

execute("SELECT * FROM users WHERE id = 1")    # OK
execute(f"SELECT * FROM users WHERE id = {user_input}")  # ✗ — f-string の結果は LiteralString ではない

LiteralStringコンパイル時に既知の文字列 だけを受け取る型です。ユーザー入力で作った文字列 (f"..."+ user_id など) は拒否されます。SQL インジェクション防御を静的に保証する道具。

NewType — 同じ形、異なる型 #

NewType
from typing import NewType

UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)

def get_user(id: UserId): ...

uid = UserId(123)
pid = ProductId(456)

get_user(uid)    # OK
get_user(pid)    # ✗ — 別の型として扱われる
get_user(123)    # ✗ — int のままでは入らない

NewTypeランタイムにはただの int です。型チェッカーだけが別の型として扱います。同じ int でも意味が異なる場面 (UserId vs ProductId、USD vs KRW) で強力です。

type エイリアスとの違い #

基礎 #2type エイリアス (type UserId = int) は ただのエイリアスint の位置に UserId を入れられ、その逆も可能。NewType は片方向だけ: int → UserId は明示的なキャストが必要、自動ではダメ。

type エイリアスは 名前だけ違うNewType別の型として分離

Generic class — もう一段の深さ #

中級 #2 で見た class Stack[T]:。さらに踏み込むと:

複数パラメータ #

複数のパラメータ
class Cache[K, V]:
    def __init__(self):
        self._data: dict[K, V] = {}

    def get(self, key: K) -> V | None: ...
    def put(self, key: K, value: V) -> None: ...

cache: Cache[str, int] = Cache()

制約と bound #

制約
class SortedList[T: (int, str)]:    # int または str だけ
    ...

class Container[T: Comparable]:     # Comparable のサブタイプだけ
    ...

可変長 — TypeVarTuple (3.11+) #

TypeVarTuple
def stack[*Ts](*args: *Ts) -> tuple[*Ts]:
    return args

t = stack(1, "hello", 3.14)
# t の型: tuple[int, str, float]

可変個数の型パラメータ — numpy のような多次元配列ライブラリで次元を型として表現するときに活用。

castassert_typeassert_never #

cast — 型の強制 #

cast
from typing import cast

raw: object = get_data()
data = cast(dict[str, int], raw)
# ランタイムにはただの raw、型チェッカーだけが dict[str, int] として見る

確実に分かっているのに チェッカーが推論できないとき に使う最後の手段。誤って使うとランタイム事故になり得るので、理由のコメント を一緒に置くのが良いです。

assert_type — 推論の検証 #

assert_type
from typing import assert_type

x = some_function()
assert_type(x, int)    # チェッカーが x を int と推論しなければエラー

型チェッカーに「ここはちょうどこの型でなければならない」という断言。ライブラリのテストでよく使われます。

assert_never — すべてのケース処理を保証 #

exhaustiveness
from typing import assert_never

def handle(event: Literal["click", "key"]):
    if event == "click": ...
    elif event == "key": ...
    else:
        assert_never(event)    # 新しいケースが増えたらここが壊れる

union のすべてのケースを処理したかを チェッカーが検証 してくれます。discriminated union の分岐で本当に役立ちます。

まとめ #

今回見たもの:

  • Variance — invariant (list)、covariant (Iterable、読み)、contravariant (Callable の引数)
  • ParamSpec + Concatenate — デコレータのシグネチャ保存、引数注入
  • Self — メソッド戻り値で自身のクラス、ビルダー / 代替コンストラクタ
  • @overload — 引数によって戻り値の型が変わる関数の正確なシグネチャ
  • TypeGuard vs TypeIs (3.13+) — ユーザー定義の型の絞り込み、TypeIs が新しい標準
  • Annotated — 型にメタデータ、FastAPI/Pydantic の核心
  • LiteralString (3.11+) — コンパイル時の文字列を強制、SQL インジェクション防御
  • NewType — 同じ形で別の型 (UserId vs int)
  • 可変パラメータ TypeVarTuple (3.11+)
  • cast / assert_type / assert_never — 明示的な断言

次回(#7 性能 — プロファイリング)では上級シリーズの最後 — 遅いコードを見つけて直す道具たち、cProfile、py-spy、line_profiler、メモリプロファイリングまでを扱います。

X