Интерфейс в Python — не декларация, а набор дандеров, которые интерпретатор ищет напрямую в типе. Отсюда и утиная типизация, и len() как функция, и то, почему проверяемые контракты приделывали к языку дважды — в 2007 и в 2017 году.
Зафиксируй вывод и причину до того, как откроешь ответ.
class G:
def __getattr__(self, n):
return lambda *a: 0
print(len(G()))TypeError: object of type 'G' has no len()Перехватчик синтезирует любой атрибут, включая __len__. И всё равно не работает.class Sized:
def __init__(self, n): self.n = n
def __len__(self): return self.n
if Sized(0):
print("истина")
else:
print("ложь")ложьОбъект существует, поле заполнено, а в условии он ложный.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))TrueRaw ничего не наследует и о Closeable не знает.from abc import ABC, abstractmethod
class Base(ABC):
@abstractmethod
def go(self): ...
Base()TypeError: Can't instantiate abstract class Base
with abstract method goОшибка при создании экземпляра, а не при объявлении класса.В уроке 04 разобрана процедура поиска атрибута. Здесь — важное исключение из неё, на котором стоит вся система интерфейсов языка.
Специальные методы ищутся в типе, минуя экземпляр. Когда интерпретатор сам вызывает __len__, __iter__, __add__, он не идёт по четырёхшаговой процедуре — он читает соответствующий слот в объекте типа.
Слот — поле в структуре типа, заполняемое при создании класса из одноимённого дандера. Поэтому доступ к нему стоит одно чтение, а не поиск по MRO.
Протокол — это набор слотов, которые должны быть заполнены. Никакой декларации, никакой регистрации: тип либо имеет нужные слоты, либо нет.
len(x), а не x.len()Функция len читает слот напрямую. Будь это метод, его пришлось бы искать обычной процедурой — с обходом MRO, дескрипторами и созданием связанного метода на каждый вызов. Слот избавляет от всего этого разом.
Отсюда и общий вид языка: len, iter, next, str, операторы, with, in — все они выглядят как функции и синтаксис, а не как методы, потому что все читают слоты. Это не стилистика, а прямое следствие того, где живёт протокол.
Фрагмент A становится очевидным. __getattr__ — четвёртый шаг процедуры из урока 04, а неявный вызов до этой процедуры вообще не доходит: интерпретатор смотрит в слот типа и находит там пусто.
Это важнее, чем кажется, потому что задевает целый класс приёмов. Прокси-объект, ленивый загрузчик, объект-заглушка — всё, что синтезирует атрибуты на лету, не станет итерируемым, вызываемым или сравнимым таким способом 🔒. Дандеры приходится объявлять явно на классе, и именно поэтому библиотеки-прокси перечисляют их списком в коде.
Фрагмент B. Проверка истинности идёт по протоколу: сначала спрашивается __bool__, если его нет — __len__, и только если нет обоих, объект считается истинным. Реализовав длину, ты автоматически изменил поведение в if.
Логика правильная — пустой контейнер должен быть ложным, — но следствие неочевидно: любой объект с __len__, вернувшим ноль, ложен. Отсюда классическая ошибка if not response на объекте, у которого длина означает число элементов, а не наличие самого объекта.
Раз протокол — это наличие слотов, вопрос «реализует ли класс интерфейс» изначально не имел ответа: узнать можно было только попыткой вызова. За четверть века появились два ответа.
ABC (2007) — номинальный: класс объявляет наследование или явно регистрируется, а @abstractmethod запрещает создавать экземпляр, пока метод не переопределён. Фрагмент D показывает момент проверки: не при объявлении класса, а при попытке создать объект — считаются незакрытые абстрактные методы.
Protocol (2017) — структурный: совпадение по форме, без наследования и без ведома автора класса. Фрагмент C: Raw подходит просто потому, что у него есть close. Основной режим — статическая проверка типизатором; runtime_checkable добавляет isinstance, но проверяет только наличие методов, не сигнатуры.
Выбор выводится из этого различия: ABC — когда нужна общая реализация и явное родство; Protocol — когда описываешь ожидание от чужого кода, который трогать нельзя.
runtime_checkable проверяет только имена. isinstance с Protocol смотрит, есть ли методы, и не смотрит на их сигнатуры и типы. Класс с методом close(self, force, timeout) пройдёт проверку и упадёт при вызове. Настоящая проверка — статическая.
Слоты заполняются при создании класса. Присвоить obj.__len__ = ... экземпляру бесполезно: слот берётся из типа. Добавление дандера классу после создания работает 🔧, потому что CPython обновляет слоты при изменении класса — но это деталь реализации.
Протоколы частично перекрываются. Итерируемость даёт не только __iter__, но и старый протокол через __getitem__ с целыми индексами. Поэтому объект без __iter__ иногда всё равно итерируется — и это не ошибка, а наследие, оставленное ради совместимости.
Корень R4: интерфейс задаётся поведением, а не декларацией. Решение принято в самом начале и по совершенно практической причине: типы, написанные на C, и типы, написанные на Python, должны были работать одинаково. Слот в структуре типа — это то, что C-расширение заполняет естественно, указателем на функцию.
То есть система интерфейсов Python выросла не из идеи утиной типизации, а из требования совместимости с C — того же корня R6, который позже даст научный стек и не даст убрать GIL. Утиная типизация оказалась следствием, а не целью.
Цена выяснилась позже: без деклараций нельзя ни проверить контракт, ни выразить его в подписи функции. Дважды это чинили пристройкой — сначала номинальной (ABC), потом структурной (Protocol), — и оба раза не трогая исходный механизм.
Слот в структуре типа — это vtable, и сравнение точное. Виртуальный вызов в C++ идёт через таблицу указателей на функции, лежащую при типе; вызов len(x) в Python идёт через поле tp_as_sequence->sq_length в структуре типа. Обе конструкции существуют ровно затем, чтобы не искать метод по имени в момент вызова.
Разница в том, кто заполняет таблицу. В C++ это делает компилятор по объявлению класса; в Python — интерпретатор при создании типа, копируя туда одноимённые дандеры. Отсюда и следствие 2: слот заполняется из типа и один раз, а не спрашивается у объекта на каждый вызов — поэтому перехватчик атрибутов до него не дотягивается.
А проверка «подходит ли тип под требование» — это концепты C++20, и здесь Python со своим Protocol пришёл к тому же ответу независимо: описать форму, а не родословную.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
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 проверяется структурно: подходит всё, у чего есть нужные методы, и класс про протокол ничего не знает.
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 единственным способом узнать, поддерживает ли объект интерфейс, был 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++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__ на нём вводит в заблуждение, и честнее выставить сам список свойством. Признак простой: если фраза «обойти этот объект» звучит естественно — протокол уместен; если требует уточнения «обойти его элементы» — нет.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
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.
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 и почему в его проверке нет ни слова про сигнатуры — ограничение из раздела «Границы модели» видно прямо в коде.
Из 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 проходит четыре шага, создаёт связанный метод и потому аллоцирует объект. Чтение слота — разыменование поля структуры. Для операций, выполняющихся в каждом цикле — сравнение, сложение, взятие длины, шаг итератора, — разница принципиальна, и именно поэтому протоколы сделаны слотами, а не соглашением об именах методов.
Тег v3.11.15.
Objects/typeobject.c, таблица slotdefs — соответствие «имя дандера ↔ поле структуры типа», несколько сотен строк. Самый прямой ответ на вопрос, что вообще считается протоколом.fixup_slot_dispatchers и update_one_slot — заполнение слотов при создании класса и их обновление при изменении класса задним числом.Include/cpython/object.h — сама структура PyTypeObject. Стоит один раз посмотреть глазами: половина урока становится очевидной от одного взгляда на список полей.Protocol; в мотивации прямо сравнивается с интерфейсами Go.obj.x насквозь — процедура поиска, из которой дандеры сделаны исключениемwith и что умеет __exit__runtime_checkable с его ограничениями.collections.abc — хватит разбора выше; полезна таблица «какие методы даёшь — какие получаешь».