Protocol и TypedDict — structural subtyping и typed dict-as-record
В уроках 01-02 мы научились писать nominal типы — list[int], Stack[T]. Но Python — duck-typed язык: важны не имена классов, а методы, которые объект имеет. До PEP 544 (Python 3.8, 2018) duck-typing был untyped — type checker не мог проверить “у этого объекта есть метод .read()”. Protocol дал structural subtyping — typed duck-typing.
Параллельно для dict-as-record pattern ({'id': 42, 'name': 'alice'}) до PEP 589 типизация была размытой — dict[str, int | str] не различает обязательные ключи от опциональных. TypedDict дал structural типизацию для словарей.
В этом уроке:
- Protocol structural subtyping —
class Drawable(Protocol):+ duck-typing с типами. @runtime_checkable— enablesisinstance(obj, Drawable). Pitfall 15 — без runtime_checkable isinstance raises.- TypedDict —
class User(TypedDict): id: int; name: strдля API responses. Required/NotRequired(PEP 655, Python 3.11+) — partial dicts.- Cross-link M04 урок 04 — Protocol vs ABC: structural vs nominal subtyping.
Protocol — structural subtyping
Простой пример — функция работающая с любым объектом, имеющим метод .draw() возвращающий str:
from typing import Protocol
class Drawable(Protocol):
"""Structural type: любой объект с методом draw() -> str."""
def draw(self) -> str: ...
def render(item: Drawable) -> str:
"""Принимает любой Drawable. Не важен тип — важна метода draw()."""
return f'<rendered>{item.draw()}</rendered>'
# Класс, который НЕ наследует Drawable — но имеет .draw()
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def draw(self) -> str:
return f'circle r={self.radius}'
class Square:
def __init__(self, side: float) -> None:
self.side = side
def draw(self) -> str:
return f'square s={self.side}'
# Оба работают — оба имеют .draw() -> str
print(render(Circle(5.0))) # <rendered>circle r=5.0</rendered>
print(render(Square(3.0))) # <rendered>square s=3.0</rendered>
Что делает Protocol special:
- Никакого inheritance —
CircleиSquareне наследуютDrawable. Они structurally compatible — у них есть.draw() -> str. - mypy проверяет —
render(Circle(5.0))OK потому чтоCircle.draw()matchesDrawable.draw()signature. - Если метода нет — error:
class Triangle:
def __init__(self, base: float, height: float) -> None:
self.base = base
self.height = height
# NO draw() method!
# render(Triangle(3, 4)) # mypy error: Triangle missing 'draw' method
Cite: PEP 544 — Protocols: Structural subtyping; Lib/typing.py — Protocol class.
@runtime_checkable — Pitfall 15
По default, isinstance(obj, Drawable) поднимет TypeError — Protocol классы не runtime-checked:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
circle = Circle(5.0)
# isinstance(circle, Drawable)
# TypeError: Instance and class checks can only be used with @runtime_checkable protocols
Чтобы isinstance заработал — добавить декоратор @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
circle = Circle(5.0)
print(isinstance(circle, Drawable)) # True
class NoDraw: pass
nd = NoDraw()
print(isinstance(nd, Drawable)) # False
Pitfall 15 — runtime_checkable выглядит безобидно, но имеет тонкости:
- Только method existence проверяется, не signature.
isinstance(obj, Drawable)returns True если уobjесть атрибутdraw(callable или нет — не проверяется). - Performance cost —
isinstance(x, Drawable)итерирует по all required attributes — медленнее чем nominal isinstance. - Не для data attributes —
class P(Protocol): x: int+@runtime_checkable+isinstance(obj, P)поднимет error в Python ≤ 3.12 (3.13+ relaxed).
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float: ...
class FakeArea:
area = 'not a method' # это атрибут, не метод!
fa = FakeArea()
print(isinstance(fa, HasArea)) # True — runtime_checkable проверяет только existence атрибута 'area'
# fa.area() # TypeError — 'str' не callable
Pragmatic rule: используйте @runtime_checkable только для capability-checks; никогда не полагайтесь на signature accuracy. Если нужна полная type-safety — mypy / pyright (static analysis).
Cite: PEP 544 Section “@runtime_checkable decorator”.
Cross-link M04 урок 04 — Protocol vs ABC
В M04 урок 04 (descriptors / @property) мы упоминали ABC (abc.ABCMeta + @abstractmethod) — nominal subtyping. Protocol — structural subtyping. Это два разных механизма:
| Aspect | ABC (nominal) | Protocol (structural) |
|---|---|---|
| Inheritance required? | Да — class Foo(MyABC): | Нет — duck-typing |
| Verified когда? | Runtime — MyABC() raises если abstractmethod не реализован | Static — mypy проверяет structural compat |
| isinstance работает? | Yes — nominal hierarchy | Только если @runtime_checkable |
| Use case | Framework контракт (“все Stream’ы наследуют этот класс”) | Duck-typing с типами (“любой объект с методом X”) |
| Cross-class compat | Manual MyABC.register(Foo) | Automatic — если методы matches, OK |
Когда использовать какой:
- ABC — если вы пишете library / framework и хотите навязать inheritance hierarchy. Производите ABC + abstractmethod, force users наследовать. Example:
collections.abc.Iterator,io.IOBase. - Protocol — если вы принимаете arbitrary objects от users и нужны только некоторые методы. Example:
def process(reader: SupportsRead): ...— accept anything readable, не force inheritance.
Concrete example — Comparable:
# ABC variant — nominal
from abc import ABC, abstractmethod
class ComparableABC(ABC):
@abstractmethod
def __lt__(self, other: object) -> bool: ...
# User MUST inherit:
class IntWrapper(ComparableABC):
def __init__(self, n: int) -> None:
self.n = n
def __lt__(self, other: object) -> bool:
return isinstance(other, IntWrapper) and self.n < other.n
# Protocol variant — structural
from typing import Protocol
class ComparableProto(Protocol):
def __lt__(self, other: object) -> bool: ...
# User just has __lt__ — no inheritance:
class FloatWrapper:
def __init__(self, f: float) -> None:
self.f = f
def __lt__(self, other: object) -> bool:
return isinstance(other, FloatWrapper) and self.f < other.f
def smallest[T: ComparableProto](items: list[T]) -> T:
return min(items)
print(smallest([FloatWrapper(3.0), FloatWrapper(1.0), FloatWrapper(2.0)]).f) # 1.0
int, float, str, tuple — все structurally Comparable (имеют __lt__), но не наследуют ComparableABC. Protocol — the механизм для cross-class compat без forced inheritance.
Cite: M04 урок 04 — descriptors / @property treats nominal subtyping; M07 урок 03 — Protocol fills structural side. Standard library collections.abc.* — гибрид (formal ABCs but with @runtime_checkable-like semantics through __subclasshook__).
TypedDict — typed dict-as-record
Pattern “JSON-как-record” повсеместен — REST API responses, configs, message envelopes. dict[str, int | str] слишком слаб (не знает какие ключи обязательны), full custom class — overkill. TypedDict (PEP 589, Python 3.8) — middle ground:
from typing import TypedDict
class User(TypedDict):
"""API response: user record."""
id: int
name: str
email: str
def format_user(user: User) -> str:
return f"#{user['id']} {user['name']} <{user['email']}>"
# Construct:
alice: User = {'id': 1, 'name': 'alice', 'email': '[email protected]'}
print(format_user(alice)) # #1 alice <[email protected]>
# mypy errors:
# wrong: User = {'id': 1, 'name': 'alice'} # missing 'email'
# wrong: User = {'id': 'not-int', 'name': 'a', 'email': '[email protected]'} # 'id' wrong type
# wrong: User = {'id': 1, 'name': 'alice', 'email': 'a', 'extra': 0} # extra key not in TypedDict
Что special про TypedDict:
- Это всё ещё
dictв runtime —isinstance(alice, dict) == True,type(alice) == dict. Никаких custom classes. - Type checker проверяет ключи и типы values статически.
- Runtime overhead — zero — TypedDict только описание для mypy.
- Совместим с JSON —
json.loadsвозвращаетdict, который mypy примет заUserесли структура matches.
import json
raw = '{"id": 42, "name": "bob", "email": "[email protected]"}'
parsed: User = json.loads(raw) # mypy needs type: ignore без validation
print(format_user(parsed)) # #42 bob <[email protected]>
Pragmatic note: в production-коде поверх json.loads обычно ставят runtime validation (Pydantic, marshmallow, attrs), потому что mypy не проверяет что raw JSON соответствует TypedDict — только assumption. TypedDict — для internal dict-as-record, где источник доверенный.
Cite: PEP 589 — TypedDict; Lib/typing.py — TypedDict class.
Required / NotRequired — partial dicts (PEP 655)
До Python 3.11 TypedDict имел total=False — все ключи становились optional. С PEP 655 (Python 3.11) можно per-key mark optionality:
from typing import TypedDict, Required, NotRequired
class APIResponse(TypedDict):
"""Mixed required + optional ключи."""
status: int # required (default)
data: Required[dict[str, str]] # explicit Required
error: NotRequired[str] # optional — может отсутствовать
cached: NotRequired[bool] # optional
# Все валидные:
ok: APIResponse = {'status': 200, 'data': {'user': 'alice'}}
ok_with_cache: APIResponse = {'status': 200, 'data': {}, 'cached': True}
err: APIResponse = {'status': 500, 'data': {}, 'error': 'internal error'}
# mypy errors:
# missing_data: APIResponse = {'status': 200} # missing 'data'
# extra_key: APIResponse = {'status': 200, 'data': {}, 'foo': 0} # 'foo' not in TypedDict
Combos:
total=True(default) + no Required/NotRequired = все ключи required.total=False= все ключи optional.- Per-key Required/NotRequired = mixed, fine-grained control.
class Config(TypedDict, total=False):
"""Все ключи optional by default."""
host: str # optional
port: int # optional
debug: Required[bool] # explicit override — required
cfg1: Config = {'debug': True} # OK
cfg2: Config = {'debug': False, 'host': 'localhost'} # OK
# cfg3: Config = {'host': 'localhost'} # mypy error: missing 'debug'
Cite: PEP 655 — Marking individual TypedDict items as required or potentially-missing.
Diagram: Protocol vs TypedDict vs ABC
Recipe: typed message router
Production pattern — message router принимает любой объект с .handle(msg) -> Response:
from typing import Protocol, TypedDict
class Message(TypedDict):
"""API message envelope."""
id: int
type: str
payload: dict[str, str]
class Response(TypedDict):
"""API response envelope."""
status: int
body: str
class Handler(Protocol):
"""Anyone with handle(msg) -> Response works."""
def handle(self, msg: Message) -> Response: ...
# Concrete handlers — no inheritance, just structural compat
class EchoHandler:
def handle(self, msg: Message) -> Response:
return {'status': 200, 'body': f"echo: {msg['payload']}"}
class LoggerHandler:
def __init__(self, log: list[str]) -> None:
self._log = log
def handle(self, msg: Message) -> Response:
self._log.append(f"msg #{msg['id']} type={msg['type']}")
return {'status': 200, 'body': 'logged'}
def route(handlers: list[Handler], msg: Message) -> list[Response]:
"""Dispatch msg к каждому handler."""
return [h.handle(msg) for h in handlers]
# Use:
log: list[str] = []
handlers: list[Handler] = [EchoHandler(), LoggerHandler(log)]
test_msg: Message = {'id': 1, 'type': 'ping', 'payload': {'data': 'hello'}}
responses = route(handlers, test_msg)
print(responses) # [{'status': 200, 'body': "echo: {'data': 'hello'}"}, {'status': 200, 'body': 'logged'}]
print(log) # ['msg #1 type=ping']
Handler — Protocol, Message/Response — TypedDicts. Никаких classes для users — они просто пишут “обычный” Python код, mypy проверяет structural compat.
Cross-course context
Cross-course → Storage Formats: 10/01 evolution-fundamentals — TypedDict
Message = {'id': int, 'payload': Payload}декларативно описывает shape JSON-сообщения; Avro / Protobuf / Parquet schemas делают то же самое для бинарных форматов. Параллель углубляется в schema evolution:total=False/NotRequiredв TypedDict ↔optionalfield в Avro /optionalkeyword в Protobuf 3 — одинаковая семантика «поле может отсутствовать», что критично для backward compatibility при добавлении новых полей в running pipeline.
Ключевые выводы
- Protocol (PEP 544) — structural subtyping (typed duck-typing). Класс совместим если методы matches — никакого inheritance не нужно.
@runtime_checkable— enablesisinstance(obj, MyProtocol). Pitfall 15: только method existence проверяется, не signature accuracy; не для data attributes (Python ≤ 3.12).- TypedDict (PEP 589) — typed dict-as-record. Runtime — обычный
dict, type-checker — known keys + types. Zero overhead. Required/NotRequired(PEP 655, Python 3.11+) — per-key optionality control. Combinable сtotal=False.- Cross-link M04 урок 04 — Protocol (structural) vs ABC (nominal): два разных механизма subtyping. ABC = forced inheritance; Protocol = duck-typing с типами. 90% production-кода — Protocol достаточно.
collections.abc.*— гибрид: formal ABCs but with structural-like behavior через__subclasshook__.Iterable,Iterator,Sized,Container— predefined Protocols.- TypedDict совместим с JSON —
json.loads(raw)возвращаетdict, type-cast’ится в TypedDict без runtime validation. Production: combine с Pydantic/marshmallow для runtime validation.
Дальше — урок 04 — runtime introspection: typing.get_type_hints, inspect.signature, dataclasses.fields — как извлечь typed metadata в runtime для frameworks (validators, ORMs, serializers).