Магические методы в Python: как научить свой класс вести себя как встроенный тип

Магические методы, или dunder-методы (от double underscore), - это специальные методы с двумя подчеркиваниями по краям имени: __init__, __str__, __len__. Python вызывает их сам, когда вы пишете print(obj), a + b, len(x) или with obj:. Реализовав нужные методы, вы заставляете свой класс работать со встроенными функциями и операторами так же естественно, как list или int.
Ниже разобраны методы, которые реально пригождаются в прикладном коде, с рабочими примерами и типичными ошибками. Если вы только начинаете знакомиться с классами, сначала загляните в материал про классы и объекты в Python.
Как Python вызывает магические методы
Любой оператор или встроенная функция в Python транслируется в вызов метода на типе объекта. Выражение a + b превращается в type(a).__add__(a, b), а len(x) - в type(x).__len__(x). Важная деталь: поиск идет по классу, а не по экземпляру. Если присвоить obj.__len__ = lambda: 5, функция len(obj) это проигнорирует.
class Box:
pass
b = Box()
b.__len__ = lambda: 5
print(b.__len__()) # -> 5
print(len(b)) # TypeError: object of type 'Box' has no len()
Вызывать dunder-методы напрямую почти никогда не нужно. Пишите len(x), а не x.__len__(): встроенная функция дополнительно проверяет результат (например, что длина неотрицательная) и работает быстрее для встроенных типов.
Строковое представление: __str__ и __repr__
Это два самых популярных метода. Разница между ними проста:
__repr__- однозначное представление для разработчика. Его показывает интерактивная консоль, отладчик, а такжеrepr()и контейнеры при печати. В идеале строка выглядит как код, которым можно воссоздать объект.__str__- читаемое представление для пользователя. Его вызываютprint(),str()и f-строки.
class Money:
def __init__(self, amount: int, currency: str = "RUB"):
self.amount = amount
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self) -> str:
return f"{self.amount} {self.currency}"
m = Money(1500)
print(m) # -> 1500 RUB
print(repr(m)) # -> Money(1500, 'RUB')
print([m, m]) # -> [Money(1500, 'RUB'), Money(1500, 'RUB')]
print(f"{m!r}") # -> Money(1500, 'RUB')
Обратите внимание на последнюю строку со списком: контейнеры всегда печатают элементы через repr, даже если у объекта есть __str__. Поэтому, если реализуете только один метод, выбирайте __repr__. Когда __str__ не определен, Python использует __repr__ и для print(). Обратного запасного пути нет.
В f-строке модификатор !r внутри __repr__ аккуратно добавляет кавычки к строкам. Подробнее о модификаторах и форматах читайте в материале про f-строки.
Сравнение объектов: __eq__, __lt__ и total_ordering
По умолчанию == для пользовательских объектов сравнивает идентичность, то есть работает как is. Два объекта с одинаковыми полями будут неравны, пока вы не определите __eq__.
class Version:
def __init__(self, major: int, minor: int):
self.major = major
self.minor = minor
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor) == (other.major, other.minor)
print(Version(1, 2) == Version(1, 2)) # -> True
print(Version(1, 2) == "1.2") # -> False
Возврат NotImplemented (это специальная константа, а не исключение) сообщает интерпретатору: "я не умею сравнивать с этим типом". Тогда Python попробует отраженную операцию у второго операнда, и только если оба вернут NotImplemented, для == вернется False, а для < будет выброшено TypeError. Возвращать False вместо NotImplemented - частая ошибка: она ломает сравнение с типами, которые знают о вашем классе.
Методы сравнения и операторы
| Оператор | Метод | Отраженный метод |
|---|---|---|
a == b | __eq__ | __eq__ |
a != b | __ne__ | __ne__ |
a < b | __lt__ | __gt__ |
a <= b | __le__ | __ge__ |
a > b | __gt__ | __lt__ |
a >= b | __ge__ | __le__ |
__ne__ писать не нужно: по умолчанию он инвертирует результат __eq__. А вот остальные четыре метода сравнения сами не выводятся.
Декоратор functools.total_ordering
Чтобы не писать четыре почти одинаковых метода, используйте functools.total_ordering. Достаточно определить __eq__ и один из методов порядка, остальные декоратор достроит сам.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major: int, minor: int):
self.major = major
self.minor = minor
def _key(self):
return (self.major, self.minor)
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._key() == other._key()
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._key() < other._key()
def __repr__(self):
return f"{self.major}.{self.minor}"
versions = [Version(2, 0), Version(1, 10), Version(1, 2)]
print(sorted(versions)) # -> [1.2, 1.10, 2.0]
print(Version(1, 2) >= Version(1, 2)) # -> True
print(max(versions)) # -> 2.0
Функции sorted(), min() и max() используют __lt__, поэтому после этого объекты сортируются без параметра key. Сравнение через кортеж - удобный прием: кортежи сравниваются поэлементно, и логика укладывается в одну строку. Если класс в основном хранит данные, посмотрите на dataclasses: параметр order=True генерирует все методы сравнения автоматически.
Хеширование: __hash__ и его связь с __eq__
Объект можно положить в множество или использовать как ключ словаря, только если он хешируемый. Правило из документации: равные объекты обязаны иметь равные хеши. Отсюда поведение, которое удивляет новичков: как только вы определяете __eq__, Python автоматически ставит __hash__ = None, и объект перестает быть хешируемым.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
{Point(1, 2)} # TypeError: unhashable type: 'Point'
Исправление - определить __hash__ на основе тех же полей, что участвуют в сравнении:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
points = {Point(1, 2), Point(1, 2), Point(3, 4)}
print(len(points)) # -> 2
Хешировать стоит только неизменяемые по смыслу объекты. Если поменять x у точки, которая уже лежит в множестве, ее хеш изменится, и множество перестанет ее находить. Для изменяемых объектов честнее оставить __hash__ = None.
Объект как контейнер: __len__, __getitem__, __iter__, __contains__
Эта группа методов превращает класс в коллекцию. Возьмем плейлист, который хранит треки во внутреннем списке:
class Playlist:
def __init__(self, name, tracks=None):
self.name = name
self._tracks = list(tracks or [])
def __len__(self):
return len(self._tracks)
def __getitem__(self, index):
if isinstance(index, slice):
return Playlist(self.name, self._tracks[index])
return self._tracks[index]
def __repr__(self):
return f"Playlist({self.name!r}, {self._tracks!r})"
p = Playlist("rock", ["Song A", "Song B", "Song C"])
print(len(p)) # -> 3
print(p[0]) # -> Song A
print(p[-1]) # -> Song C
print(p[:2]) # -> Playlist('rock', ['Song A', 'Song B'])
print("Song B" in p) # -> True
for track in p:
print(track) # Song A, Song B, Song C
Обратите внимание: __iter__ и __contains__ здесь не определены, но цикл for и оператор in все равно работают. Python откатывается к старому протоколу последовательностей: вызывает __getitem__ с индексами 0, 1, 2... пока не получит IndexError. Это удобно, но для неиндексируемых коллекций и ради скорости лучше определить __iter__ явно. Срезы приходят в __getitem__ как объект slice, их стоит обработать отдельно, как в примере. Про срезы подробно рассказано в статье срезы в Python.
Метод __iter__ и генераторы
Самый короткий способ реализовать __iter__ - сделать его генератором через yield. Тогда не нужно писать отдельный класс итератора с __next__.
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
n = self.start
while n > 0:
yield n
n -= 1
def __contains__(self, value):
return isinstance(value, int) and 0 < value <= self.start
c = Countdown(3)
print(list(c)) # -> [3, 2, 1]
print(list(c)) # -> [3, 2, 1] объект можно обходить повторно
print(2 in c) # -> True, без перебора
Здесь __contains__ отвечает за in за константное время вместо полного перебора. Объект остается итерируемым много раз, потому что каждый вызов __iter__ создает новый генератор.
Арифметика: __add__, __radd__ и __iadd__
Перегрузка операторов в Python - это реализация арифметических dunder-методов. Для сложения их три:
__add__(self, other)- дляa + b, когдаa- ваш объект;__radd__(self, other)- дляb + a, когда левый операнд не справился и вернулNotImplemented;__iadd__(self, other)- дляa += b; если его нет, Python использует__add__и присваивает новый объект.
class Money:
def __init__(self, amount: int, currency: str = "RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if isinstance(other, Money):
if other.currency != self.currency:
raise ValueError("разные валюты")
return Money(self.amount + other.amount, self.currency)
if isinstance(other, int):
return Money(self.amount + other, self.currency)
return NotImplemented
def __radd__(self, other):
return self.__add__(other)
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
print(Money(100) + Money(50)) # -> Money(150, 'RUB')
print(10 + Money(5)) # -> Money(15, 'RUB')
print(sum([Money(1), Money(2), Money(3)])) # -> Money(6, 'RUB')
Без __radd__ не заработал бы sum(): он начинает с 0 + первый_элемент, а int не умеет складываться с Money. Аналогичные тройки есть для всех операторов: __sub__, __mul__, __truediv__, __floordiv__, __mod__, __pow__, __matmul__ для @.
Часто используемые арифметические методы
| Выражение | Метод | Пример использования |
|---|---|---|
a + b | __add__ | деньги, векторы, конкатенация |
a - b | __sub__ | разница дат, векторов |
a * b | __mul__ | умножение на скаляр |
a / b | __truediv__ | деление величин |
-a | __neg__ | смена знака |
abs(a) | __abs__ | длина вектора |
bool(a) | __bool__ | проверка if a: |
Про __bool__ стоит помнить отдельно: если его нет, Python использует __len__, и пустой контейнер считается ложным. Это ожидаемо для коллекций, но иногда неожиданно для доменных объектов.
Вызываемые объекты: __call__
Метод __call__ позволяет вызывать экземпляр как функцию. Это удобно, когда функции нужно хранить состояние или настройки между вызовами.
class RateLimiter:
def __init__(self, limit: int):
self.limit = limit
self.calls = 0
def __call__(self, user: str) -> bool:
self.calls += 1
return self.calls <= self.limit
allow = RateLimiter(limit=2)
print(allow("ivan")) # -> True
print(allow("ivan")) # -> True
print(allow("ivan")) # -> False
print(callable(allow)) # -> True
Объекты с __call__ часто используют как декораторы с параметрами и как стратегии, которые передают в другие функции. Альтернатива - замыкание; оно хранит состояние в переменных внешней функции. Класс выигрывает, когда состояние нужно смотреть или сбрасывать снаружи: allow.calls = 0.
Контекстные менеджеры: __enter__ и __exit__
Конструкция with гарантирует, что ресурс будет освобожден даже при исключении. Любой объект с методами __enter__ и __exit__ становится контекстным менеджером.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # это значение попадет в "as"
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
print(f"прошло {self.elapsed:.3f} с")
return False # не подавлять исключения
with Timer() as t:
total = sum(range(1_000_000))
# -> прошло 0.015 с
Метод __exit__ получает три аргумента: тип исключения, само исключение и трейсбек. Если блок завершился нормально, все три равны None. Если __exit__ вернет истинное значение, исключение будет подавлено. Делайте так только осознанно, например, для менеджера, который игнорирует конкретный тип ошибки:
class Ignore:
def __init__(self, *exc_types):
self.exc_types = exc_types
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is not None and issubclass(exc_type, self.exc_types)
with Ignore(ZeroDivisionError):
1 / 0
print("выполнение продолжается") # -> выполнение продолжается
Для простых случаев в стандартной библиотеке есть готовые contextlib.suppress и декоратор contextlib.contextmanager, превращающий генератор в менеджер. Писать класс имеет смысл, когда у менеджера есть состояние или он используется многократно. Как это связано с обработкой ошибок, разобрано в статье обработка исключений в Python.
Истинность и форматирование: __bool__ и __format__
Метод __bool__ определяет, как объект ведет себя в if, while, and и or. Он должен вернуть именно bool, иначе Python выбросит TypeError. Метод __format__ получает строку спецификатора из f-строки и позволяет придумать собственные форматы для доменного объекта.
class Balance:
def __init__(self, kopecks: int):
self.kopecks = kopecks
def __bool__(self):
return self.kopecks != 0
def __format__(self, spec):
rub = self.kopecks / 100
if spec == "short":
return f"{rub:.0f} руб."
return format(rub, spec or ".2f")
b = Balance(150050)
if b:
print(f"{b}") # -> 1500.50
print(f"{b:short}") # -> 1500 руб.
print(f"{b:,.1f}") # -> 1,500.5
print(bool(Balance(0))) # -> False
Пустой спецификатор приходит как пустая строка, поэтому в примере для него выбран формат по умолчанию. Все незнакомые спецификаторы передаются встроенной функции format, и объект поддерживает стандартные форматы чисел без лишнего кода. Обратите внимание, что "short" дает 1500 руб., а не 1501: при форматировании действует банковское округление половины к четному.
Другие полезные dunder-методы
Кроме разобранных, в повседневной работе встречаются:
__init__и__new__- инициализация и создание экземпляра;__new__нужен редко, в основном при наследовании от неизменяемых типов вродеintилиtuple.__setitem__и__delitem__- запись и удаление по ключу:obj[k] = v,del obj[k].__getattr__- вызывается, только если обычный поиск атрибута не нашел имя; удобно для прокси-объектов.__init_subclass__- вызывается при создании подкласса; удобен для регистрации плагинов без метаклассов.__slots__- не метод, а атрибут класса, который экономит память и запрещает создавать новые атрибуты.
Типичные ошибки при реализации dunder-методов
- __repr__ или __str__ возвращает не строку. Если метод вернет число или
None, вызовprint(obj)упадет сTypeError: __str__ returned non-string. Всегда возвращайтеstr. - Рекурсия в __repr__. Запись
f"{self}"внутри__str__снова вызывает__str__и заканчиваетсяRecursionError. Обращайтесь к полям, а не к самому объекту. - __len__ возвращает float или отрицательное число. Функция
len()принимает только неотрицательное целое, иначе выбрасываетTypeErrorилиValueError. - __add__ изменяет self. Оператор
a + bпо смыслу создает новый объект. Если метод меняет левый операнд, код видаc = a + bнеожиданно портитa. Изменение на месте - работа__iadd__, и он обязан вернутьself. - Хеш от изменяемых полей. Объект, чьи поля меняются после добавления в множество или словарь, теряется в нем. Хешируйте только то, что не меняется за время жизни объекта.
- __exit__ всегда возвращает True. Такой менеджер молча проглатывает любые исключения, включая опечатки в коде внутри блока
with. Ошибки исчезают бесследно, и отладка превращается в мучение. - Сравнение с объектами другого класса. Без проверки
isinstanceметод__eq__обращается к атрибутам, которых у чужого объекта нет, и вместоFalseполучаетсяAttributeError, например при поиске объекта в списке смешанных элементов.
Большинство этих ошибок ловятся простыми тестами: проверьте repr, равенство с объектом другого типа, работу в множестве и поведение with при исключении внутри блока.
Практический чек-лист
- Всегда определяйте
__repr__, это бесплатно упрощает отладку. - В бинарных методах проверяйте тип и возвращайте
NotImplemented, а неFalseи не исключение. - Определили
__eq__- решите, нужен ли__hash__, и стройте его из тех же полей. - Для порядка используйте
@total_orderingили@dataclass(order=True). - Добавляйте
__radd__, если объект должен работать сsum(). - Не вызывайте dunder-методы напрямую, используйте операторы и встроенные функции.
Частые вопросы
Чем __str__ отличается от __repr__?
__repr__ дает техническое однозначное представление для разработчика и используется в консоли, логах и при печати контейнеров. __str__ дает понятный текст для пользователя и вызывается в print() и str(). Если определен только __repr__, он используется в обоих случаях.
Почему после добавления __eq__ объект стал unhashable?
Python автоматически обнуляет __hash__, когда класс переопределяет __eq__, чтобы не нарушить правило "равные объекты имеют равные хеши". Определите __hash__ явно через hash() от кортежа полей, участвующих в сравнении.
Что вернуть, если операция с таким типом не поддерживается?
Константу NotImplemented. Тогда Python попробует отраженный метод второго операнда и выбросит понятный TypeError, если ни один не справился. Выбрасывать исключение самому не нужно.
Можно ли не писать эти методы вручную?
Для классов-хранилищ данных да: @dataclass генерирует __init__, __repr__ и __eq__, а с параметрами order=True и frozen=True также методы сравнения и __hash__. Вручную писать стоит поведение, которое dataclass не покрывает: арифметику, контейнерные методы, __call__.
Можно ли придумать свой метод с двумя подчеркиваниями?
Технически можно, но не стоит. Имена вида __name__ зарезервированы за языком, и будущая версия Python может придать вашему имени особый смысл. Для внутренних методов используйте одно подчеркивание в начале: _helper.