モダンPython中級 #2 typing 本格 — Generic、Protocol、TypedDict、Literal

読了 7分

#1 dataclass と __slots__ でデータの形を短く書くツールを学んだところで、今回は その形をより正確に、表現力豊かに書く ツールを扱います。typing モジュールの本格的な武器 4 つ — Generic、Protocol、TypedDict、Literal を扱います。

出発点 — どこまでが基礎だったか #

基礎 #2 で:

  • intstrboolNone
  • list[int]dict[str, int]
  • int | None (union 短縮)
  • type Alias = int (型エイリアス)
  • Callable[[int, int], int]

ここまで押さえました。その次のステップ — ユーザーがパラメータを埋められる型 から始めます。

Generic — 型パラメータ #

同じ形なのに中に入る型だけ違う関数 / クラスを書きたいとき。

関数 — 入力の型をそのまま返す #

🚫 型が広すぎる
def first(items: list) -> object:
    return items[0]

x = first([1, 2, 3])    # x: object  ← int という情報を失う

first([1,2,3]) は明らかに int を返しますが、シグネチャが object なのでエディタがその情報を失います。パラメータ化が必要です。

✅ 型パラメータ (3.12+)
def first[T](items: list[T]) -> T:
    return items[0]

x = first([1, 2, 3])      # x: int
y = first(["a", "b"])     # y: str

def first[T] の形が Python 3.12 の新文法です。PEP 695。以前は次のように書きました。

旧方式 — 新コードでは使わない
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

新コードは メソッド / 関数の横の [T] の位置に直接書く方式 で統一すればよいです。

複数の型パラメータ #

2 つ以上
def pair[K, V](key: K, value: V) -> tuple[K, V]:
    return (key, value)

p = pair("name", 42)    # tuple[str, int]

型制約 — bound / 明示的な制限 #

bound — 上限の型
class HasLength:
    def __len__(self) -> int: ...

def shortest[T: HasLength](items: list[T]) -> T:
    return min(items, key=len)

[T: HasLength] は「T は HasLength のサブタイプでなければならない」という制約です。実は上のケースには Protocol の方が合います。 (下で扱う)

明示的な union 制約
def add[T: (int, float)](a: T, b: T) -> T:
    return a + b

add(1, 2)      # int
add(1.0, 2.0)  # float
add(1, 2.0)    # ✗ T が一つの型に決まらない

[T: (int, float)] の形は「T は int または float」を意味します。ジェネリクスの制約 (constraint) と呼びます。

クラス — Generic class #

ジェネリッククラス (3.12+)
class Stack[T]:
    def __init__(self):
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T | None:
        if not self._items:
            return None
        return self._items.pop()

s: Stack[int] = Stack()
s.push(1)
s.push(2)
x = s.pop()    # x: int | None

Stack[int] のように 使用時に T を埋めて 使うのがジェネリッククラスの出番です。

Protocol — ダックタイピングを型に #

Python の慣用句は 「アヒルのように歩いてガーガー鳴けばアヒル」 です。オブジェクトがどのクラスのインスタンスかではなく、どのメソッド / 属性を持つかが重要です。これを静的型で表現するのが Protocol です。

Java / C# のインターフェースではない #

Java のインターフェースは 明示的に implements を書かないと そのインターフェースになりません。Python の Protocol はそれが必要ありません。形さえ合えば その Protocol を満たします (structural typing)。

Protocol の定義
from typing import Protocol

class Closable(Protocol):
    def close(self) -> None: ...

# 何が Closable か?
def safe_close(resource: Closable) -> None:
    resource.close()

# 上の関数は .close() を持つすべてのオブジェクトを受け取る。
# 明示的な継承関係は不要。
class File:
    def close(self) -> None:
        print("file closed")

class Connection:
    def close(self) -> None:
        print("connection closed")

safe_close(File())         # OK — File は Closable を満たす
safe_close(Connection())   # OK — Connection も満たす

runtime_checkable — ランタイムで isinstance 可能 #

デフォルトの Protocol は静的検査用です。isinstance で検査するにはデコレータを付けます。

ランタイムチェック
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

print(isinstance(File(), Closable))   # True

ただし isinstance はメソッド名のみ検査 し、シグネチャは見ません。正確な検査は静的型チェッカーの方が得意です。

Protocol の真価 — 「必要な分だけ約束」 #

実用例 — sized と iterable
from typing import Protocol

class Sized(Protocol):
    def __len__(self) -> int: ...

def first_n[T](items: Sized, n: int) -> ...:
    if len(items) < n:
        raise ValueError("足りない")
    ...

関数が必要なのは __len__ メソッドのみ。だから引数の型を「リスト」ではなく「len 可能な何か」で表現する方が正確で柔軟です。呼び出し側が list / tuple / dict / ユーザー定義クラスのどれを渡しても通ります。

collections.abc がすでに持つ Protocol #

標準ライブラリにはよく使う Protocol がすでにあります。

よく使う ABC たち
from collections.abc import (
    Iterable, Iterator, Sized, Container,
    Mapping, Sequence, Callable, Hashable
)

def process(items: Iterable[int]) -> None:
    for x in items:
        ...

def lookup(m: Mapping[str, int], key: str) -> int | None:
    return m.get(key)

collections.abc は abstract base class ですが Protocol のように動作することもあり (@runtime_checkable 可能)、標準にあるものはここから持ってきて使えばよいです。 自分で Protocol を定義するのは標準にない形のときです。

TypedDict — dict の形を型に #

JSON のようなデータを dict で扱うとき、どのキーとどの値の型 が入るかを書きたいとき。

TypedDict の基本
from typing import TypedDict

class User(TypedDict):
    id: int
    name: str
    age: int

u: User = {"id": 1, "name": "カーティス", "age": 30}
print(u["name"])   # name: str — 型が絞り込まれる

TypedDictランタイムでは普通の dict です。インスタンス検査をしても dict として見えます。型チェッカーだけが認識します。

dataclass との違い #

dataclassTypedDict
ランタイムの形ユーザー定義クラスのインスタンス通常の dict
メソッド__init____repr____eq__ 自動なし (dict のメソッドのみ)
アクセスu.nameu["name"]
合う場面内部のドメインモデルJSON、外部 API のレスポンス

API のレスポンスを受け取って dict で扱うケースに TypedDict が合います。変換コストがありません — 入ってきた dict をその形だと宣言するだけです。

オプションのキー — total=False / NotRequired #

オプションのキー
from typing import TypedDict, NotRequired

class User(TypedDict):
    id: int                       # 必須
    name: str                      # 必須
    nickname: NotRequired[str]     # 任意

u1: User = {"id": 1, "name": "カーティス"}                       # OK
u2: User = {"id": 1, "name": "カーティス", "nickname": "C"}     # OK

旧方式はクラス単位の total=False ですが、全キーをオプションにしてしまうので ほとんど使いません。新コードは NotRequired が標準です。

逆に、すべてオプションの dict で一部だけ必須にしたいときは Required:

Required の使用
from typing import TypedDict, Required

class Config(TypedDict, total=False):
    timeout: int
    retries: int
    base_url: Required[str]    # これだけ必須

継承もできる #

継承
class BaseUser(TypedDict):
    id: int
    name: str

class AdminUser(BaseUser):
    permissions: list[str]

AdminUseridnamepermissions の 3 キーを持ちます。

Literal — 狭い union #

値そのものを型に。特定の文字列 / 数字だけ許可したいとき。

Literal の基本
from typing import Literal

def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    ...

set_log_level("info")     # OK
set_log_level("trace")    # ✗ 許可された 4 つにない

呼び出し側の自動補完が正確に出て、タイポがコンパイル時に捕まります。

Literal と union #

複数の Literal
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]

def render(color: Color, mode: Mode) -> str:
    ...

定数と組み合わせ — Final #

Final
from typing import Final, Literal

DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL の型が Literal["info"] に絞られる

DEFAULT_LEVEL = "warn"   # ✗ Final — 再代入不可

Final は「この変数は一度だけ代入」を表します。モジュール定数によく使います。

Discriminated union — 決定的な分岐 #

異なる形の dict が一つのところに入ってくるとき、「どの形か」を一つのキーで識別するパターン。

discriminated union
from typing import Literal, TypedDict

class ClickEvent(TypedDict):
    type: Literal["click"]
    x: int
    y: int

class KeyEvent(TypedDict):
    type: Literal["key"]
    code: str

type Event = ClickEvent | KeyEvent

def handle(event: Event) -> None:
    if event["type"] == "click":
        # ここで event は ClickEvent に絞られる
        print(event["x"], event["y"])
    else:
        # こちらは KeyEvent
        print(event["code"])

type キーの Literal 値を見て型チェッカーが 自動で絞り込んでくれます。 基礎 #3match-case とよく合うパターンです。

他によく使うツール #

Optional はもう使わない #

✗ 旧 / ✅ 新
# 旧
from typing import Optional
def find(id: int) -> Optional[str]: ...

# 新 — 常にこちら
def find(id: int) -> str | None: ...

Any は最後の手段 #

Any は「型チェックをオフにする」という宣言です。本当にわからないときだけ使い、object / Unknown (pyright) / unknown のような狭い型 に行けるかをまず検討してください。

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

Self (3.11+)
from typing import Self

class Builder:
    def __init__(self):
        self.items: list[str] = []

    def add(self, item: str) -> Self:
        self.items.append(item)
        return self

-> Self は「このメソッドは呼ばれたクラスのインスタンスを返す」を意味します。ビルダーパターンによく合います。旧方式は -> "Builder" (forward reference) でしたが、サブクラスから呼ばれると正確ではありませんでした。Self がそれを解いてくれます。

まとめ #

今回押さえたツール:

  • Genericdef fn[T](...)class Stack[T]: (3.12+) — 型パラメータ
  • 制約: [T: HasLength] (bound)、[T: (int, float)] (constraints)
  • Protocol — ダックタイピングを型に、明示的な implements 不要
  • collections.abc が標準 Protocol の集まり — IterableSizedMapping など
  • @runtime_checkable で isinstance 可能 (ただしシグネチャは未検証)
  • TypedDict — dict の形を宣言、JSON / 外部データに合う
  • オプションのキーは NotRequired、必須化は Required
  • Literal — 値を型に、discriminated union のかなめ
  • Final — 一度だけ代入、定数の明示
  • Optional[X]X | None で、Any は最後の手段、Self でビルダーパターン

次回 (#3 コンテキストマネージャー) では、リソース管理の標準ツール — with 文と contextlib のあらゆるパターンを扱います。

X