Kodokon kodokon.com

Протоколы и специальные методы: __repr__, __eq__, менеджеры контекста

Освой объектную модель Python: контракт __eq__/__hash__, значение NotImplemented, менеджеры контекста и структурную типизацию через Protocol.

9 мин · 3 вопросов

Открыть этот урок в Kodokon

В Python каждый оператор, каждое преобразование, каждая конструкция with проходит через специальный метод: a == b вызывает a.__eq__(b), len(x) вызывает x.__len__(). Важно различать __repr__ - однозначное представление для разработчика, которое видно в отладчике и внутри списков - и __str__, предназначенное для конечного пользователя. Если __str__ не определён, Python откатывается к __repr__: поэтому всегда реализуй сначала __repr__, в идеале в такой форме, по которой объект можно воссоздать.

PYTHON
class Money:
    def __init__(self, amount: int, currency: str):
        self.amount = amount
        self.currency = currency

    def __repr__(self) -> str:
        return f"Money({self.amount!r}, {self.currency!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return (self.amount, self.currency) == (
            other.amount, other.currency)

print(Money(10, "EUR") == Money(10, "EUR"))
print(Money(10, "EUR") == "10 EUR")
Сравнение с незнакомым типом возвращает NotImplemented, а не False.

Возврат NotImplemented (это значение, его не надо путать с исключением NotImplementedError) запускает протокол согласования операторов: Python пробует зеркальную операцию other.__eq__(self) и только в крайнем случае приходит к False. Если сразу вернуть False, это согласование обрывается и ломаются сравнения с подклассами или сторонними типами, которые на самом деле знали бы, что ответить.

PYTHON
import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc, tb):
        stop = time.perf_counter()
        self.elapsed = stop - self.start
        return False

with Timer() as timer:
    sum(range(1_000_000))
print(f"{timer.elapsed:.4f} s")
Возврат False позволяет исключению распространяться как обычно.

Если __exit__ возвращает True, текущее исключение подавляется - именно так и работает contextlib.suppress. Чтобы не писать целый класс, используй @contextlib.contextmanager: он превращает генератор в менеджер контекста, где код до yield играет роль __enter__, а код после (в идеале в блоке finally) - роль __exit__. Ещё одна опора объектной модели: типы Protocol из модуля typing формализуют утиную типизацию - класс совместим, если предоставляет нужные методы, безо всякого объявленного наследования.

PYTHON
from typing import Protocol, runtime_checkable

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

class Session:
    def close(self) -> None:
        print("session closed")

def shutdown(resource: Closable) -> None:
    resource.close()

shutdown(Session())
print(isinstance(Session(), Closable))
Session ни от чего не наследуется: соответствие определяется структурой.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Что нужно вернуть из __eq__, если попался тип, с которым сравнение невозможно?
    • False, чтобы явно показать неравенство
    • Возбудить NotImplementedError
    • NotImplemented, чтобы Python попробовал зеркальную операцию второго операнда
    • None, который Python преобразует в False
  2. Ты добавил __eq__ в класс, не трогая __hash__. Каково последствие?
    • Никаких: __hash__ остаётся унаследованным от object
    • __hash__ становится None: экземпляры больше нельзя использовать как ключи словаря или элементы множества
    • Хеш автоматически становится хешем кортежа атрибутов
  3. Что означает метод __exit__, возвращающий True?
    • Блок with завершился без ошибок
    • Менеджер контекста можно использовать повторно
    • Python выполнит блок with ещё раз
    • Текущее исключение подавляется, и выполнение продолжается после блока with