モダンPython中級 #2 typing 本格 — Generic、Protocol、TypedDict、Literal
#1 dataclass と __slots__ でデータの形を短く書くツールを学んだところで、今回は その形をより正確に、表現力豊かに書く ツールを扱います。typing モジュールの本格的な武器 4 つ — Generic、Protocol、TypedDict、Literal を扱います。
出発点 — どこまでが基礎だったか #
基礎 #2 で:
int、str、bool、Nonelist[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 なのでエディタがその情報を失います。パラメータ化が必要です。
def first[T](items: list[T]) -> T:
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: strdef first[T] の形が Python 3.12 の新文法です。PEP 695。以前は次のように書きました。
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]新コードは メソッド / 関数の横の [T] の位置に直接書く方式 で統一すればよいです。
複数の型パラメータ #
def pair[K, V](key: K, value: V) -> tuple[K, V]:
return (key, value)
p = pair("name", 42) # tuple[str, int]型制約 — 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 の方が合います。 (下で扱う)
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 #
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 | NoneStack[int] のように 使用時に T を埋めて 使うのがジェネリッククラスの出番です。
Protocol — ダックタイピングを型に #
Python の慣用句は 「アヒルのように歩いてガーガー鳴けばアヒル」 です。オブジェクトがどのクラスのインスタンスかではなく、どのメソッド / 属性を持つかが重要です。これを静的型で表現するのが Protocol です。
Java / C# のインターフェースではない #
Java のインターフェースは 明示的に implements を書かないと そのインターフェースになりません。Python の Protocol はそれが必要ありません。形さえ合えば その Protocol を満たします (structural typing)。
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 の真価 — 「必要な分だけ約束」 #
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 がすでにあります。
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 で扱うとき、どのキーとどの値の型 が入るかを書きたいとき。
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 との違い #
| dataclass | TypedDict | |
|---|---|---|
| ランタイムの形 | ユーザー定義クラスのインスタンス | 通常の dict |
| メソッド | __init__、__repr__、__eq__ 自動 | なし (dict のメソッドのみ) |
| アクセス | u.name | u["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:
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]AdminUser は id、name、permissions の 3 キーを持ちます。
Literal — 狭い union #
値そのものを型に。特定の文字列 / 数字だけ許可したいとき。
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 #
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]
def render(color: Color, mode: Mode) -> str:
...定数と組み合わせ — Final
#
from typing import Final, Literal
DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL の型が Literal["info"] に絞られる
DEFAULT_LEVEL = "warn" # ✗ Final — 再代入不可Final は「この変数は一度だけ代入」を表します。モジュール定数によく使います。
Discriminated union — 決定的な分岐 #
異なる形の dict が一つのところに入ってくるとき、「どの形か」を一つのキーで識別するパターン。
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 値を見て型チェッカーが 自動で絞り込んでくれます。 基礎 #3 の match-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 — メソッドの戻り値で自身のクラス
#
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 がそれを解いてくれます。
まとめ #
今回押さえたツール:
- Generic —
def fn[T](...)、class Stack[T]:(3.12+) — 型パラメータ - 制約:
[T: HasLength](bound)、[T: (int, float)](constraints) - Protocol — ダックタイピングを型に、明示的な implements 不要
collections.abcが標準 Protocol の集まり —Iterable、Sized、Mappingなど@runtime_checkableで isinstance 可能 (ただしシグネチャは未検証)- TypedDict — dict の形を宣言、JSON / 外部データに合う
- オプションのキーは
NotRequired、必須化はRequired - Literal — 値を型に、discriminated union のかなめ
Final— 一度だけ代入、定数の明示Optional[X]はX | Noneで、Anyは最後の手段、Selfでビルダーパターン
次回 (#3 コンテキストマネージャー) では、リソース管理の標準ツール — with 文と contextlib のあらゆるパターンを扱います。