Протоколы дандеров: __repr__, контракт eq/hash, истинность и NotImplemented
Дандеры подключают объект к синтаксису Python: __repr__ для отладки, контракт __eq__/__hash__ (определил eq — потерял hash), цепочка истинности __bool__/__len__ и операторные дандеры с NotImplemented, чтобы ход получил второй операнд. Протоколы сильнее наследования.
Ночной джоб дедупликации работал год без сбоев. Потом кто-то добавил в класс Order вполне разумный __eq__, чтобы тесты сравнивали заказы по id, — и следующей ночью джоб упал с TypeError: unhashable type: 'Order'. Код дедупликации никто не трогал: там по-прежнему было seen = set() и if order in seen. Сломавшее его изменение лежало в трёх строках в другом файле: если определить __eq__ без __hash__, Python молча выставляет __hash__ = None, потому что равные объекты обязаны иметь равный хеш, а гарантировать это для вашего кастомного равенства интерпретатор не может. Автор прогнал тесты сравнения — все зелёные — и зарелизил. Вывод из разбора инцидента попал на вики команды: дандер-методы — не изолированные удобства, а система контрактов. Тронул один пункт модели данных — интерпретатор спросит с тебя за остальные, иногда в джобе, который запускается в три часа ночи.
Через десять минут ты будешь точно знать, какой дандер сломал ночной джоб — и почему починить это значит тронуть сразу два метода, а не один.
repr и str: контракт для отладки
repr(x) вызывает x.__repr__() и адресован разработчику: однозначный, в идеале пригодный для eval или хотя бы идентифицирующий (Order(id=42, total=Decimal('19.90'))). str(x) вызывает __str__ и адресован пользователю; если его нет, str падает обратно на __repr__ — поэтому сначала определяйте __repr__, а __str__ лишь когда нужна дружелюбная форма. Дефолтный __repr__ — это <Order object at 0x7f...>, и именно его вы увидите в логах и трейсбеках в три часа ночи, если пропустили этот шаг. Контейнеры всегда используют repr элементов — print([order]) покажет repr, хотя вы звали print, — и это ровно то поведение, которое нужно при чтении выгрузки из 200 заказов.
from decimal import Decimal
class Order:
def __init__(self, id: int, total: Decimal):
self.id, self.total = id, total
def __repr__(self) -> str:
# !r рекурсивно берёт repr полей — Decimal('19.90'), а не 19.90
return f"Order(id={self.id!r}, total={self.total!r})"
print(repr(Order(42, Decimal("19.90")))) # Order(id=42, total=Decimal('19.90'))Контракт eq/hash
Правило, которое интерпретатор навязывает: если a == b, то hash(a) == hash(b), потому что словари и множества сначала находят объект по хеш-корзине и только потом подтверждают равенством. Дефолтный __eq__ — это идентичность (is), дефолтный __hash__ выводится из id() — они согласованы. В момент, когда вы определяете __eq__, Python ставит классу __hash__ = None: объекты становятся нехешируемыми, пока вы не дадите __hash__, согласованный с вашим равенством (хешируйте тот же кортеж полей, который сравниваете). Ловушка из Хука — в тихой половине этого правила: на этапе определения класса ничего не падает, только при первом использовании set/dict. И ловушка второго порядка: хешируемые объекты должны быть фактически неизменяемыми в хешируемых полях — измените order.id после вставки в множество, и объект окажется не в той корзине: он внутри, но ненаходим. x in s вернёт False, пока x лежит в s.
class Order:
def __init__(self, id: int):
self.id = id
def __eq__(self, other):
if not isinstance(other, Order):
return NotImplemented # не False — см. ниже
return self.id == other.id
def __hash__(self):
return hash(self.id) # те же поля, что в __eq__, иначе контракт сломанВы добавили в класс __eq__ со сравнением по id, прогнали тесты равенства (зелёные) и зарелизили. Ночной джоб с `if obj in seen_set` теперь падает с TypeError. Почему?
Истинность и длина: цепочка bool/len
Когда пишешь if response: на своём классе-коллекции, что вызывает Python — __eq__, __bool__ или что-то ещё? Ответ определяет, будет ли пустая коллекция ложной или каждый экземпляр молча пройдёт проверку.
if x: никогда не зовёт __eq__. Он вызывает bool(x), который сначала пробует __bool__; если его нет — падает на __len__ (ненулевая длина истинна); если нет обоих — любой экземпляр истинен. Эта цепочка — причина, по которой if response: на кастомной коллекции «просто работает», стоит определить __len__, — и причина, по которой она бьёт в спину на объектах, где пустота не означает ложь. Классический продакшен-укус: объект запроса ORM или datetime.time полуночи попадает в if value: и трактуется как «значения нет». Если у класса есть длина, но «пусто» — всё ещё осмысленные данные, либо определите __bool__, явно возвращающий True, либо заставьте вызывающий код писать if value is not None:.
Операторные дандеры и NotImplemented
a + b запускает type(a).__add__(a, b); если тот вернул сентинел NotImplemented, Python пробует отражённый type(b).__radd__(b, a); и лишь когда отказались оба, поднимает TypeError. (Уточнение: если type(b) — подкласс type(a), отражённый метод получает первый ход, чтобы подклассы могли переопределять поведение в смешанных выражениях.) NotImplemented — это возвращаемое значение со смыслом «я не знаю этот тип операнда, спросите вторую сторону» — не путать с исключением NotImplementedError, которое поднимают абстрактные методы со смыслом «подкласс забыл меня реализовать». Возврат False из __eq__ для незнакомых типов — тонкая версия того же бага: вы делаете Order(1) == 1 окончательно ложным, не дав int слова, и ломаете симметрию для чьего-то типа-обёртки. Отсюда же и то, почему в Python протоколы сильнее наследования: len(), in, итерация, арифметика — всё диспетчеризуется по дандерам, так что участвует любой класс с нужными методами, без базового класса. Утиная типизация — это диспетчеризация по дандерам.
Ваш Money.__add__ поднимает NotImplementedError для незнакомых типов операндов. Коллега определил в классе Discount метод __radd__ для Money + Discount. Что произойдёт с `money + discount`?
- 01Сформулируйте контракт __eq__/__hash__, что делает Python, когда вы определяете только __eq__, и ловушку с мутацией, которая переживает даже корректную реализацию.
- 02Опишите, что происходит при `a + b` с разными типами, и почему возврат False и поднятие NotImplementedError из дандера — оба баги.
Модель данных — это система протоколов Python: синтаксис ==, in, if x:, len() и + диспетчеризуется в дандер-методы, поэтому участвует любой класс с нужными методами — утиная типизация и есть диспетчеризация по дандерам, наследование не требуется. __repr__ служит разработчикам (однозначный, раскрывающий поля, используется контейнерами и трейсбеками); __str__ служит пользователям и откатывается на __repr__. Контракт eq/hash — самое острое ребро юнита: равные объекты обязаны иметь равный хеш, поэтому определение __eq__ заставляет Python молча выставить __hash__ = None, и сбой всплывает только при первом использовании set или dict — возможно, в ночном джобе вдали от вашего диффа. Восстановите __hash__ по тем же полям, что сравниваете, и считайте эти поля неизменяемыми, иначе объекты теряются внутри собственных контейнеров. Истинность идёт по цепочке — __bool__, затем __len__, затем «истинен по умолчанию», — что удобно для коллекций и опасно для объектов, где пустота — валидные данные. Бинарные операторы выполняют левый __add__, по сентинелу NotImplemented передают ход отражённому __radd__ (правые операнды-подклассы получают приоритет) и поднимают TypeError, лишь когда отказались обе стороны. Возвращайте NotImplemented для незнакомых типов; поднятый NotImplementedError обрывает диспетчеризацию и закрывает совместимость с типами, которых вы ещё не встречали. Теперь, когда увидите TypeError: unhashable type вдали от своего диффа или ночной джоб, упавший на in set после зелёных тестов, — вы точно знаете, какой контракт задели и какой пункт остался без реализации.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.