PythonMiddleTechnical

Что такое Protocol в typing?

Protocol (typing) — structural typing: type checker считает класс совместимым с Protocol, если он реализует те же методы и атрибуты, без наследования. Это статический duck-typing. @runtime_checkable добавляет isinstance-проверку, но проверяет только наличие имён, не сигнатуры.

Что это и зачем

typing.Protocol (PEP 544, Python 3.8+) реализует structural subtyping: тип совместим с Protocol, если у него есть все нужные атрибуты и методы, независимо от того, наследуется ли он от Protocol-класса.

from typing import Protocol

class Sender(Protocol):
    def send(self, message: str) -> None: ...

class ConsoleSender:        # не наследуется от Sender
    def send(self, message: str) -> None:
        print(message)

class EmailSender:
    def send(self, message: str) -> None:
        smtp.sendmail(...)

def notify(sender: Sender, msg: str) -> None:
    sender.send(msg)

notify(ConsoleSender(), "hi")     # ok для mypy/pyright
notify(EmailSender(), "hi")       # тоже ok

Protocol vs ABC

  • ABC (abc.ABC) — nominal subtyping: класс должен наследоваться от ABC. Подходит, когда вы владеете кодом и хотите явный контракт.
  • Protocol — structural: подходит любой класс с нужными методами. Идеально для интеграции со сторонним кодом и dependency injection, когда нельзя/неудобно править иерархию.
  • Protocol можно использовать как тип параметра, ABC — и как тип, и как базу для миксинов.

@runtime_checkable

По умолчанию Protocol — статический. Чтобы работал isinstance(obj, Sender), добавьте декоратор:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Sender(Protocol):
    def send(self, message: str) -> None: ...

isinstance(ConsoleSender(), Sender)   # True

Важно: runtime-проверка смотрит только на наличие методов с правильными именами, не на их сигнатуры. Объект с send(self) без аргументов пройдёт.

Generic Protocol

from typing import Protocol, TypeVar

T = TypeVar("T", covariant=True)

class Reader(Protocol[T]):
    def read(self) -> T: ...

class IntReader:
    def read(self) -> int:
        return 42

def consume(r: Reader[int]) -> int:
    return r.read() + 1

consume(IntReader())    # ok

Атрибуты в Protocol

class Named(Protocol):
    name: str         # обязателен как атрибут

class User:
    def __init__(self):
        self.name = "Ada"

def greet(x: Named) -> str:
    return f"Hello, {x.name}"

greet(User())     # ok

Если атрибут пишется через __init__ — runtime isinstance с @runtime_checkable может не увидеть его до создания инстанса.

Где Protocol полезен в реальной жизни

  • Dependency injection в FastAPI/тестах: зависимость объявлена как Protocol, в production подставляется реальный клиент, в тестах — fake.
  • Plugin-системы: сторонние модули реализуют контракт без импорта вашей библиотеки.
  • Совместимость со сторонним кодом: sqlalchemy, requests — нельзя поменять иерархию, но можно описать нужную "форму".
  • file-like / db-like объекты: вместо IO опишите свой SupportsRead.

Подводные камни

  • isinstance(obj, Protocol) без @runtime_checkableTypeError.
  • Runtime-проверка игнорирует сигнатуры и аннотации параметров — pyright/mypy здесь строже.
  • Protocol с большим количеством методов — нарушение ISP, лучше разбить.
  • Унаследовать конкретный класс от Protocol — он становится «обязательным» (методы должны быть реализованы), и теряется смысл «structural».
  • В pydantic v1 поля + Protocol работали плохо; v2 лучше.
  • Не все методы Protocol должны быть ... — можно дать default implementation, но осторожно с диамондом.
  • На 3.7 и ниже Protocol требовал typing_extensions; на 3.8+ — в stdlib.

Common mistakes

  • Не объяснять structural typing.
  • Использовать ABC там, где Protocol проще.
  • Писать огромные Protocol с десятками методов.

What the interviewer is testing

  • Понимает static duck typing.
  • Может показать dependency injection.
  • Отличает Protocol от ABC.

Sources

Related topics