Протоколы

Интерфейс в Python — не декларация, а набор дандеров, которые интерпретатор ищет напрямую в типе. Отсюда и утиная типизация, и len() как функция, и то, почему проверяемые контракты приделывали к языку дважды — в 2007 и в 2017 году.

L1 · 8 минL2 · 10 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
class G:
    def __getattr__(self, n):
        return lambda *a: 0

print(len(G()))
TypeError: object of type 'G' has no len()
Перехватчик синтезирует любой атрибут, включая __len__. И всё равно не работает.
Фрагмент B
class Sized:
    def __init__(self, n): self.n = n
    def __len__(self): return self.n

if Sized(0):
    print("истина")
else:
    print("ложь")
ложь
Объект существует, поле заполнено, а в условии он ложный.
Фрагмент C
from typing import Protocol, runtime_checkable

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

class Raw:
    def close(self): pass

print(isinstance(Raw(), Closeable))
True
Raw ничего не наследует и о Closeable не знает.
Фрагмент D
from abc import ABC, abstractmethod

class Base(ABC):
    @abstractmethod
    def go(self): ...

Base()
TypeError: Can't instantiate abstract class Base
with abstract method go
Ошибка при создании экземпляра, а не при объявлении класса.

2Механизм

В уроке 04 разобрана процедура поиска атрибута. Здесь — важное исключение из неё, на котором стоит вся система интерфейсов языка.

Специальные методы ищутся в типе, минуя экземпляр. Когда интерпретатор сам вызывает __len__, __iter__, __add__, он не идёт по четырёхшаговой процедуре — он читает соответствующий слот в объекте типа.

Слот — поле в структуре типа, заполняемое при создании класса из одноимённого дандера. Поэтому доступ к нему стоит одно чтение, а не поиск по MRO.

Протокол — это набор слотов, которые должны быть заполнены. Никакой декларации, никакой регистрации: тип либо имеет нужные слоты, либо нет.

Следствие 1 · почему len(x), а не x.len()

Функция len читает слот напрямую. Будь это метод, его пришлось бы искать обычной процедурой — с обходом MRO, дескрипторами и созданием связанного метода на каждый вызов. Слот избавляет от всего этого разом.

Отсюда и общий вид языка: len, iter, next, str, операторы, with, in — все они выглядят как функции и синтаксис, а не как методы, потому что все читают слоты. Это не стилистика, а прямое следствие того, где живёт протокол.

Следствие 2 · перехватчики не работают на дандерах

Фрагмент A становится очевидным. __getattr__ — четвёртый шаг процедуры из урока 04, а неявный вызов до этой процедуры вообще не доходит: интерпретатор смотрит в слот типа и находит там пусто.

Это важнее, чем кажется, потому что задевает целый класс приёмов. Прокси-объект, ленивый загрузчик, объект-заглушка — всё, что синтезирует атрибуты на лету, не станет итерируемым, вызываемым или сравнимым таким способом 🔒. Дандеры приходится объявлять явно на классе, и именно поэтому библиотеки-прокси перечисляют их списком в коде.

Следствие 3 · протокол влияет и там, где его не звали

Фрагмент B. Проверка истинности идёт по протоколу: сначала спрашивается __bool__, если его нет — __len__, и только если нет обоих, объект считается истинным. Реализовав длину, ты автоматически изменил поведение в if.

Логика правильная — пустой контейнер должен быть ложным, — но следствие неочевидно: любой объект с __len__, вернувшим ноль, ложен. Отсюда классическая ошибка if not response на объекте, у которого длина означает число элементов, а не наличие самого объекта.

Следствие 4 · два способа проверить контракт, приделанные позже

Раз протокол — это наличие слотов, вопрос «реализует ли класс интерфейс» изначально не имел ответа: узнать можно было только попыткой вызова. За четверть века появились два ответа.

ABC (2007) — номинальный: класс объявляет наследование или явно регистрируется, а @abstractmethod запрещает создавать экземпляр, пока метод не переопределён. Фрагмент D показывает момент проверки: не при объявлении класса, а при попытке создать объект — считаются незакрытые абстрактные методы.

Protocol (2017) — структурный: совпадение по форме, без наследования и без ведома автора класса. Фрагмент C: Raw подходит просто потому, что у него есть close. Основной режим — статическая проверка типизатором; runtime_checkable добавляет isinstance, но проверяет только наличие методов, не сигнатуры.

Выбор выводится из этого различия: ABC — когда нужна общая реализация и явное родство; Protocol — когда описываешь ожидание от чужого кода, который трогать нельзя.

3Границы модели

Где сказанное перестаёт держать

runtime_checkable проверяет только имена. isinstance с Protocol смотрит, есть ли методы, и не смотрит на их сигнатуры и типы. Класс с методом close(self, force, timeout) пройдёт проверку и упадёт при вызове. Настоящая проверка — статическая.

Слоты заполняются при создании класса. Присвоить obj.__len__ = ... экземпляру бесполезно: слот берётся из типа. Добавление дандера классу после создания работает 🔧, потому что CPython обновляет слоты при изменении класса — но это деталь реализации.

Протоколы частично перекрываются. Итерируемость даёт не только __iter__, но и старый протокол через __getitem__ с целыми индексами. Поэтому объект без __iter__ иногда всё равно итерируется — и это не ошибка, а наследие, оставленное ради совместимости.

4Корень

Корень R4: интерфейс задаётся поведением, а не декларацией. Решение принято в самом начале и по совершенно практической причине: типы, написанные на C, и типы, написанные на Python, должны были работать одинаково. Слот в структуре типа — это то, что C-расширение заполняет естественно, указателем на функцию.

То есть система интерфейсов Python выросла не из идеи утиной типизации, а из требования совместимости с C — того же корня R6, который позже даст научный стек и не даст убрать GIL. Утиная типизация оказалась следствием, а не целью.

Цена выяснилась позже: без деклараций нельзя ни проверить контракт, ни выразить его в подписи функции. Дважды это чинили пристройкой — сначала номинальной (ABC), потом структурной (Protocol), — и оба раза не трогая исходный механизм.

5Аналогия

Слот в структуре типа — это vtable, и сравнение точное. Виртуальный вызов в C++ идёт через таблицу указателей на функции, лежащую при типе; вызов len(x) в Python идёт через поле tp_as_sequence->sq_length в структуре типа. Обе конструкции существуют ровно затем, чтобы не искать метод по имени в момент вызова.

Разница в том, кто заполняет таблицу. В C++ это делает компилятор по объявлению класса; в Python — интерпретатор при создании типа, копируя туда одноимённые дандеры. Отсюда и следствие 2: слот заполняется из типа и один раз, а не спрашивается у объекта на каждый вызов — поэтому перехватчик атрибутов до него не дотягивается.

А проверка «подходит ли тип под требование» — это концепты C++20, и здесь Python со своим Protocol пришёл к тому же ответу независимо: описать форму, а не родословную.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему len(x), а не x.len()?

Потому что len обязан быть быстрым и одинаковым для всех, а метод — не обязан ни тем ни другим.

Функция len идёт прямо в слот tp_as_sequence->sq_length типа — это чтение поля структуры и вызов C-функции, минуя всю процедуру поиска атрибута из урока 04. Метод x.len() прошёл бы полный путь: дескрипторы данных, __dict__ инстанса, MRO.

Второй аргумент — единообразие интерфейса. len, iter, str, abs, hash — все встроенные функции с одинаковым синтаксисом для всех типов. Не бывает объекта, у которого «длина» называется size, count или length: реализуешь __len__ — работает len(). Гвидо формулировал это как «префиксная запись для операций, применимых ко многим типам».

class G:
    def __len__(self): return 3
g = G()
print(len(g))                # → 3
g.__len__ = lambda: 99       # подменяем на инстансе
print(len(g), g.__len__())   # → 3 99   len берёт из типа

Последняя строка — главное следствие: дандер читается из типа, а не из объекта. Поэтому __getattr__ не может синтезировать протокол, и библиотеки-прокси вынуждены перечислять дандеры списком в коде класса.

Цена решения: набор протоколов фиксирован. Свою «встроенную функцию» с такой же скоростью написать нельзя — она будет обычным вызовом.

ABC или Protocol — что выбрать?

ABC — когда контракт твой и ты хочешь его навязать. Protocol — когда контракт описывает чужие классы.

Разница в направлении зависимости. ABC требует явного наследования или регистрации: класс должен знать про твой интерфейс. Protocol проверяется структурно: подходит всё, у чего есть нужные методы, и класс про протокол ничего не знает.

from typing import Protocol, runtime_checkable
from abc import ABC, abstractmethod

class Reader(Protocol):                 # структурно
    def read(self, n: int) -> bytes: ...

class Base(ABC):                        # номинально
    @abstractmethod
    def read(self, n: int) -> bytes: ...

class Mine:
    def read(self, n): return b""

print(isinstance(Mine(), Base))         # → False   не наследовался
# Mine подходит под Reader без единой строки про Reader

Практическое правило. Пишешь библиотеку и принимаешь чужой объект — Protocol: пользователю не придётся наследоваться от твоего класса и тянуть зависимость. Строишь иерархию внутри своего кода, где важно, чтобы наследник не забыл метод, — ABC: он не даст создать инстанс с нереализованным @abstractmethod.

Оба, между прочим, пристроены к языку сильно позже: ABC в 2007-м (PEP 3119), Protocol в 2017-м (PEP 544). Пятнадцать лет между ними — это ровно время, за которое стало ясно, что номинальная проверка не ложится на утиную типизацию.

И runtime_checkable проверяет только наличие методов, не сигнатуры — то есть isinstance с протоколом гораздо слабее, чем кажется.

Если типизация утиная, зачем вообще понадобились ABC?

Затем, что «крякает как утка» невозможно проверить, не покрякав.

До ABC единственным способом узнать, поддерживает ли объект интерфейс, был hasattr — и это плохо работало по двум причинам. Во-первых, наличие метода не означает нужную семантику: у строки есть __getitem__ и __len__, но обращаться с ней как со списком обычно ошибка. Во-вторых, интерфейсы составные — «последовательность» это пять методов, и проверять их по одному в каждой функции неудобно.

from collections.abc import Sequence, Iterable, Hashable
print(isinstance("abc", Sequence))     # → True
print(isinstance({1: 2}, Iterable))    # → True
print(isinstance([1], Hashable))       # → False

collections.abc дал этому именам и одну проверку вместо пяти. Плюс механизм виртуальной регистрации: чужой класс можно объявить реализацией интерфейса, не трогая его код и не наследуясь.

from collections.abc import Sized
class Weird:
    def __len__(self): return 0
Sized.register(Weird)
print(isinstance(Weird(), Sized))      # → True

И второе применение, ради которого ABC используют чаще: mixin-методы. Наследуешь Mapping, реализуешь три метода — получаешь keys, values, items, get, __contains__, __eq__ бесплатно и одинаково с остальным языком.

Так что ABC не отменяют утиную типизацию, а дают ей словарь и заготовки. Настоящая замена появилась только с Protocol.

Почему дандер, назначенный инстансу, не работает?

Потому что специальные методы ищутся в типе, минуя __dict__ объекта и __getattr__.

Это не оптимизация «на всякий случай», а необходимость: a + b, len(x), x[i] исполняются миллионы раз, и полный поиск атрибута на каждой операции стоил бы вдвое дороже самой операции. Поэтому интерпретатор читает готовый указатель из слота типа.

class C: pass
c = C()
c.__add__ = lambda other: "из инстанса"
try:
    c + 1
except TypeError as e:
    print(e)          # → unsupported operand type(s) for +: 'C' and 'int'
print(c.__add__(1))   # → из инстанса   явный вызов работает

Два практических следствия.

Первое: прокси и обёртки нельзя написать через __getattr__. Класс, который прозрачно перенаправляет всё на внутренний объект, обязан объявить каждый дандер явно — __len__, __iter__, __getitem__ и так далее. Именно поэтому unittest.mock.MagicMock содержит длинный список дандеров прямо в коде, а обычный Mock их не поддерживает.

Второе: назначать дандеры надо классу, а не объекту — и вот это работает, потому что CPython пересчитывает слоты при изменении класса 🔧:

C.__add__ = lambda self, other: "из класса"
print(c + 1)      # → из класса
Итог. Специальные методы ищутся в слоте объекта-типа, минуя четырёхшаговую процедуру поиска атрибута. Отсюда len() как функция, а не метод: слот читается за одно обращение, тогда как метод потребовал бы обхода MRO и создания связанного метода. Отсюда же то, что __getattr__ не может синтезировать дандер — неявный вызов до этого шага не доходит, и прокси-объекты вынуждены перечислять специальные методы явно. Протокол влияет шире, чем кажется: реализовав __len__, ты изменил поведение объекта в if, потому что истинность проверяется __bool__, а при его отсутствии — длиной. Проверяемых контрактов у языка изначально не было, и их приделали дважды: ABC в 2007 году как номинальный механизм с проверкой при создании экземпляра, Protocol в 2017 как структурный, работающий без наследования и без ведома автора класса.
Дальше — по желанию
контрфактуалC++ концепты, Go, JavaСтруктурная проверка на этапе компиляции — и почему это ровно Protocol, только раньше

C++20 концепты — самая близкая точка. Концепт описывает требования к типу по форме: какие операции над ним допустимы. Проверка структурная и полностью на этапе компиляции, тип ни от чего не наследуется и о концепте не знает. Это буквально Protocol, только на двадцать лет раньше по замыслу и без рантайм-варианта. До концептов та же роль доставалась SFINAE, и диагностика была нечитаемой — прямой аналог Python до 2017 года, где несоответствие протоколу проявлялось AttributeError где-то глубоко внутри.

Go сделал структурную типизацию основным механизмом: тип реализует интерфейс, если у него есть нужные методы, и объявлять это не нужно. То, к чему Python пришёл через Protocol, у Go было с первого дня, зато проверяется всегда и обязательно.

Java — противоположный полюс: только номинально, implements обязателен. Отсюда адаптеры и обёртки там, где Go и Python обходятся совпадением по форме.

Три точки на одной оси: номинально всегда, структурно всегда, структурно по факту с опциональной проверкой.

🐛 багif not response на объекте с длинойРеализовали __len__ — и объект стал ложным при нулевой длине, ветка ушла не туда

Добавили удобный len() в класс ответа. Через неделю поймали обработку успешного пустого ответа как ошибки.

class Response:
    def __init__(self, items, status):
        self.items, self.status = items, status
    def __len__(self):
        return len(self.items)          # добавили ради удобства

resp = api.fetch()
if not resp:                            # написано до того
    raise ApiError("пустой ответ")

Раньше Response не имел ни __bool__, ни __len__, и if not resp означало «объекта нет». После добавления длины оно стало означать «нет элементов» — успешный ответ с пустым списком пошёл в ветку ошибки.

Ревью пропускает дважды: сначала добавление __len__ выглядит безобидным улучшением, а потом проверка if not resp в другом файле никак с ним не связана в диффе. Это следствие 3 в чистом виде — реализация одного протокола меняет поведение в конструкции, о которой не думали.

Два способа починить, и выбор осмысленный. Либо явно объявить истинность и оторвать её от длины:

def __bool__(self):
    return True              # объект всегда истинен, длина — про элементы

Либо сделать проверку однозначной там, где она стоит: if resp is None или if not resp.items. Общее правило выводится из механизма: реализуя __len__ у не-контейнера, реализуй и __bool__ — иначе за тебя это решит протокол.

⚡ приёмОтдавать итератор вместо спискаОдна строка вместо метода get_items() — и объект работает во всём, что умеет обходить

Класс, накапливающий данные, обычно обзаводится методом «отдай список». Протокол делает это лишним и заодно снимает копирование.

class Batch:
    def __init__(self): self._rows = []
    def get_rows(self):            # так пишут по привычке
        return list(self._rows)    # ещё и копия на каждый вызов
class Batch:
    def __init__(self): self._rows = []
    def __iter__(self):  return iter(self._rows)
    def __len__(self):   return len(self._rows)
    def __contains__(self, x): return x in self._rows

После этого объект работает во всём, что читает протоколы, без единой дополнительной строки:

for row in batch: ...
len(batch)
x in batch
sorted(batch)
list(batch)
sum(r.n for r in batch)
first, *rest = batch

Выигрыш не только в удобстве. get_rows() создаёт копию списка при каждом вызове — это память и время по числу элементов, ровно арифметика из урока 03. __iter__ отдаёт итератор, который ничего не копирует.

Когда так не надо: если объект — не коллекция, а сущность с коллекцией внутри. Тогда __iter__ на нём вводит в заблуждение, и честнее выставить сам список свойством. Признак простой: если фраза «обойти этот объект» звучит естественно — протокол уместен; если требует уточнения «обойти его элементы» — нет.

💻 терминалПроверь в REPLЧетыре фрагмента плюс старый протокол итерации и подмена дандера на экземпляре

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

class Old:
    def __getitem__(self, i):
        if i > 2: raise IndexError
        return i
list(Old())
# __iter__ нет. Почему всё равно работает?

class C: pass
c = C()
c.__len__ = lambda: 5
len(c)
# А если присвоить C.__len__ вместо c.__len__?

from collections.abc import Iterable, Sized
isinstance([], Iterable), isinstance([], Sized)
issubclass(list, Iterable)
# list ничего этого не наследует. Как проверка проходит?

import numbers
isinstance(3, numbers.Number), isinstance(3.0, numbers.Number)
# Зачем в стандартной библиотеке иерархия чисел, если есть int и float?

Третий вопрос выводит на __subclasshook__ — механизм, которым ABC умеет признавать своими классы, ничего о нём не знающие. Это мостик от номинального к структурному, сделанный за десять лет до Protocol.

исходникиcollections.abc и ioГде стандартная библиотека сама пользуется обоими механизмами

Lib/_collections_abc.py. Здесь живут Iterable, Sized, Mapping и остальные — и здесь же видно, как ABC умеет быть структурным. Найди __subclasshook__ у Iterable: он проверяет наличие __iter__ в MRO кандидата и на этом основании признаёт его подклассом. Поэтому issubclass(list, Iterable) истинно, хотя list ни от чего такого не наследует.

Заметь ещё одну вещь: часть этих классов даёт готовую реализацию поверх минимума. Реализовав __getitem__ и __len__ и унаследовав Sequence, ты бесплатно получаешь __contains__, __iter__, __reversed__, index и count. Это ответ на вопрос «зачем ABC, если есть Protocol»: Protocol только описывает, ABC ещё и доделывает.

Lib/typing.py, класс Protocol. Посмотри, как устроен runtime_checkable и почему в его проверке нет ни слова про сигнатуры — ограничение из раздела «Границы модели» видно прямо в коде.

L2Слоты типа и что происходит при создании классаКак дандер превращается в поле структуры и сколько это экономит

Из L1 ты знаешь, что протокол — это заполненные слоты. Здесь — как именно они заполняются.

Структура типа в CPython содержит поля вида tp_iter, tp_call, tp_richcompare, а также подструктуры tp_as_number, tp_as_sequence, tp_as_mapping с полями под арифметику, индексацию и длину. При создании класса интерпретатор проходит по таблице соответствий «имя дандера — слот» и заполняет их переходниками, которые вызывают питоновскую функцию.

Обратная операция тоже есть: у типа, написанного на C, дандеры появляются как атрибуты, синтезированные из заполненных слотов. Поэтому list.__len__ существует, хотя никакого def __len__ в исходниках list нет.

print(list.__len__)
# → <slot wrapper '__len__' of 'list' objects>
print(type(list.__len__))
# → <class 'wrapper_descriptor'>

Само слово slot wrapper в выводе — прямое указание на механизм.

Что это экономит: обычное обращение obj.method проходит четыре шага, создаёт связанный метод и потому аллоцирует объект. Чтение слота — разыменование поля структуры. Для операций, выполняющихся в каждом цикле — сравнение, сложение, взятие длины, шаг итератора, — разница принципиальна, и именно поэтому протоколы сделаны слотами, а не соглашением об именах методов.

L3Где это в исходниках CPythontypeobject.c: slotdefs, fixup_slot_dispatchers, update_one_slot

Тег v3.11.15.

  • Objects/typeobject.c, таблица slotdefs — соответствие «имя дандера ↔ поле структуры типа», несколько сотен строк. Самый прямой ответ на вопрос, что вообще считается протоколом.
  • Там же fixup_slot_dispatchers и update_one_slot — заполнение слотов при создании класса и их обновление при изменении класса задним числом.
  • Include/cpython/object.h — сама структура PyTypeObject. Стоит один раз посмотреть глазами: половина урока становится очевидной от одного взгляда на список полей.
  • PEP 544 — Protocol; в мотивации прямо сравнивается с интерфейсами Go.
связиКуда это ведётL04, L12, L13 · контраст с концептами C++, Go и Java
корень R4 · Протоколы вместо интерфейсов
← основа L04 · obj.x насквозь — процедура поиска, из которой дандеры сделаны исключением
← основа L06 · Классы — слоты заполняются в момент создания класса
↔ контраст Концепты C++20: та же структурная проверка, но на этапе компиляции и обязательно
↔ контраст Go — структурная типизация как основа; Java — номинальная как единственный вариант
↔ контраст ABC против Protocol: родство с готовой реализацией против совпадения по форме
→ дальше L12 · Контекстные менеджеры — протокол with и что умеет __exit__
→ дальше L13 · Итератор и генератор — самый нагруженный протокол языка
источникиЧто почитатьData model 🟥 · PEP 544 🟧 · collections.abc 🟦