Learning Platform
Глоссарий Troubleshooting
Урок 08.03 · 25 мин
Средний
ProtocolStructural subtypingPEP 544TypedDictPEP 589PEP 655RequiredNotRequired@runtime_checkableDuck typing

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 типизацию для словарей.

В этом уроке:

  1. Protocol structural subtypingclass Drawable(Protocol): + duck-typing с типами.
  2. @runtime_checkable — enables isinstance(obj, Drawable). Pitfall 15 — без runtime_checkable isinstance raises.
  3. TypedDictclass User(TypedDict): id: int; name: str для API responses.
  4. Required/NotRequired (PEP 655, Python 3.11+) — partial dicts.
  5. 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:

  1. Никакого inheritanceCircle и Square не наследуют Drawable. Они structurally compatible — у них есть .draw() -> str.
  2. mypy проверяетrender(Circle(5.0)) OK потому что Circle.draw() matches Drawable.draw() signature.
  3. Если метода нет — 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.pyProtocol 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 выглядит безобидно, но имеет тонкости:

  1. Только method existence проверяется, не signature. isinstance(obj, Drawable) returns True если у obj есть атрибут draw (callable или нет — не проверяется).
  2. Performance costisinstance(x, Drawable) итерирует по all required attributes — медленнее чем nominal isinstance.
  3. Не для data attributesclass 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”.


В M04 урок 04 (descriptors / @property) мы упоминали ABC (abc.ABCMeta + @abstractmethod) — nominal subtyping. Protocol — structural subtyping. Это два разных механизма:

AspectABC (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 caseFramework контракт (“все Stream’ы наследуют этот класс”)Duck-typing с типами (“любой объект с методом X”)
Cross-class compatManual 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:

  1. Это всё ещё dict в runtime — isinstance(alice, dict) == True, type(alice) == dict. Никаких custom classes.
  2. Type checker проверяет ключи и типы values статически.
  3. Runtime overhead — zero — TypedDict только описание для mypy.
  4. Совместим с JSONjson.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.pyTypedDict 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

Three subtyping flavours
Protocol (PEP 544)structural — duck-typing с типамиОбъект совместим если у него есть нужные методы. Никакого inheritance. mypy проверяет structurally. @runtime_checkable enables isinstance — Pitfall 15: только method existence, не signature accuracy. Cite: PEP 544, Lib/typing.py Protocol class
TypedDict (PEP 589)structural — typed dict-as-recorddict с known keys + types. Runtime — обычный dict. mypy проверяет ключи + значения статически. PEP 655 Required/NotRequired для per-key optionality. Совместим с json.loads (но не runtime-validated). Cite: PEP 589, PEP 655
ABC (M04 урок 04)nominal — forced inheritanceabc.ABCMeta + @abstractmethod. User должен наследовать — иначе __init__ raises. isinstance работает по nominal hierarchy (всегда, без декоратора). Use case: framework контракт. Cite: Lib/abc.py, M04 урок 04 descriptors / property

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 ↔ optional field в Avro / optional keyword в Protobuf 3 — одинаковая семантика «поле может отсутствовать», что критично для backward compatibility при добавлении новых полей в running pipeline.


Ключевые выводы

  1. Protocol (PEP 544) — structural subtyping (typed duck-typing). Класс совместим если методы matches — никакого inheritance не нужно.
  2. @runtime_checkable — enables isinstance(obj, MyProtocol). Pitfall 15: только method existence проверяется, не signature accuracy; не для data attributes (Python ≤ 3.12).
  3. TypedDict (PEP 589) — typed dict-as-record. Runtime — обычный dict, type-checker — known keys + types. Zero overhead.
  4. Required/NotRequired (PEP 655, Python 3.11+) — per-key optionality control. Combinable с total=False.
  5. Cross-link M04 урок 04 — Protocol (structural) vs ABC (nominal): два разных механизма subtyping. ABC = forced inheritance; Protocol = duck-typing с типами. 90% production-кода — Protocol достаточно.
  6. collections.abc.* — гибрид: formal ABCs but with structural-like behavior через __subclasshook__. Iterable, Iterator, Sized, Container — predefined Protocols.
  7. TypedDict совместим с JSONjson.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).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём ключевое отличие `Protocol` (PEP 544) от ABC (M04 урок 04)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7