Python: уроки, справочник и задачи

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

Раздел: ООП в Python · Обновлено 25.10.2026
Ноутбук с открытым редактором кода на рабочем столе
Фото: неизвестен, CC0

Магические методы, или 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__

Это два самых популярных метода. Разница между ними проста:

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-методов. Для сложения их три:

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-методы

Кроме разобранных, в повседневной работе встречаются:

Типичные ошибки при реализации dunder-методов

Большинство этих ошибок ловятся простыми тестами: проверьте repr, равенство с объектом другого типа, работу в множестве и поведение with при исключении внутри блока.

Практический чек-лист

  1. Всегда определяйте __repr__, это бесплатно упрощает отладку.
  2. В бинарных методах проверяйте тип и возвращайте NotImplemented, а не False и не исключение.
  3. Определили __eq__ - решите, нужен ли __hash__, и стройте его из тех же полей.
  4. Для порядка используйте @total_ordering или @dataclass(order=True).
  5. Добавляйте __radd__, если объект должен работать с sum().
  6. Не вызывайте 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.