Магические методы Python - dunder-методы

Методы с двойным подчёркиванием - это не магия. Это способ научить свои классы вести себя как встроенные типы

Магические методы Python - dunder-методы.md

Содержание


Коротко и чётко

Магические методы (dunder-методы) - это специальные методы класса с двойным подчёркиванием с обеих сторон: __init__, __str__, __len__ и так далее. Dunder - сокращение от double underscore.

Python вызывает их автоматически в определённых ситуациях - не ты, а сам язык.

Основные группы:

  • Создание и удаление - __new__, __init__, __del__
  • Строковое представление - __str__, __repr__
  • Сравнение - __eq__, __lt__, __gt__, __le__, __ge__
  • Арифметика - __add__, __sub__, __mul__, __truediv__
  • Контейнеры - __len__, __getitem__, __setitem__, __contains__
  • Контекстный менеджер - __enter__, __exit__
  • Вызываемые объекты - __call__

Магические методы - это интерфейс между пользовательскими классами и встроенными механизмами Python. Когда ты пишешь len(obj) - Python вызывает obj.__len__(). Когда obj1 + obj2 - вызывает obj1.__add__(obj2). Когда print(obj) - вызывает obj.__str__(). Реализуя эти методы в своём классе, ты делаешь его «родным» для языка - он начинает работать с операторами, встроенными функциями и конструкциями как будто это встроенный тип.


Простыми словами

Вот где у многих возникает путаница - давай разберём один раз и навсегда.


Откуда вообще берётся «магия»

Ты уже пользуешься магическими методами каждый день - просто не замечаешь этого.

Когда ты пишешь:

numbers = [1, 2, 3]
print(len(numbers))   # 3

Ты вызываешь функцию len(). Но как len() знает, что делать со списком? Она не знает. Она просто спрашивает у самого объекта: «эй, у тебя есть метод __len__?». Список отвечает: «да, вот он» - и возвращает 3.

То же самое происходит с операторами. Когда ты пишешь 1 + 2 - Python не знает заранее, как складывать именно эти объекты. Он спрашивает у числа 1: «у тебя есть __add__?». Число отвечает: «да, вот результат».

Вся «магия» - это просто соглашение. Python видит определённую операцию и ищет определённый метод. Если метод есть - вызывает его. Если нет - падает с ошибкой.


Зачем это нужно тебе как разработчику

Представь, что ты написал класс Money - деньги. У денег есть сумма и валюта.

Без магических методов твой класс - это просто коробка с данными. Ты не можешь написать money1 + money2, не можешь сделать print(money) и получить что-то читаемое, не можешь сравнить money1 > money2.

С магическими методами - можешь. Ты один раз описываешь поведение, и дальше твой класс работает как родной питоновский объект.


Как это работает - шаг за шагом

Возьмём самый простой пример. Ты создаёшь объект:

dog = Dog("Шарик", 3)

Что происходит в этот момент:

1. Python видит Dog("Шарик", 3)
2. Создаёт пустой объект в памяти — это делает __new__
3. Передаёт этот объект в __init__ вместе с аргументами
4. __init__ заполняет объект данными
5. Возвращает готовый объект — это и есть dog

Ты никогда не вызываешь dog.__init__() напрямую. Python делает это сам, когда видит скобки после имени класса.


Таблица: что ты пишешь и что Python вызывает

Вот в чём вся суть - за каждым привычным действием стоит магический метод:

Что ты пишешь          Что Python вызывает
-------------------------------------------------
Dog("Шарик", 3)     -> Dog.__init__(self, "Шарик", 3)
print(dog)          -> dog.__str__()
len(dog)            -> dog.__len__()
dog1 + dog2         -> dog1.__add__(dog2)
dog1 == dog2        -> dog1.__eq__(dog2)
dog1 > dog2         -> dog1.__gt__(dog2)
dog[0]              -> dog.__getitem__(0)
3 in dog            -> dog.__contains__(3)
with dog:           -> dog.__enter__() и dog.__exit__()
dog()               -> dog.__call__()
del dog             -> dog.__del__()

Всё что кажется встроенным и «само работает» - это просто Python, который ищет нужный метод у объекта.


Разница между __str__ и __repr__

Это самая частая путаница - запомни раз и навсегда.

__str__ - это для людей. Красивое, читаемое сообщение. Вызывается через print().

__repr__ - это для разработчиков. Техническое представление, по которому можно воссоздать объект. Вызывается в консоли и в отладчике.

Правило: __repr__ должен возвращать строку, из которой можно воссоздать объект через eval().

# Пользователь видит это через print()
# "Шарик, 3 года"

# Разработчик видит это в консоли
# "Dog(name='Шарик', age=3)"

Если определён только __repr__ - он используется и там, и там. Если определён только __str__ - в консоли будет некрасивый адрес памяти. Поэтому хорошая практика - определять оба.


Код

Базовый пример - класс Money

class Money:
    def __init__(self, amount, currency="RUB"):
        self.amount = amount
        self.currency = currency

    # Для людей - print(money)
    def __str__(self):
        return f"{self.amount} {self.currency}"

    # Для разработчиков - в консоли, в логах
    def __repr__(self):
        return f"Money(amount={self.amount}, currency='{self.currency}')"

    # Сложение: money1 + money2
    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Нельзя складывать разные валюты")
        return Money(self.amount + other.amount, self.currency)

    # Сравнение: money1 == money2
    def __eq__(self, other):
        return self.amount == other.amount and self.currency == other.currency

    # Сравнение: money1 > money2
    def __gt__(self, other):
        if self.currency != other.currency:
            raise ValueError("Нельзя сравнивать разные валюты")
        return self.amount > other.amount

    # len(money) - количество цифр в сумме (просто для примера)
    def __len__(self):
        return len(str(self.amount))


wallet = Money(1000)
salary = Money(50000)

print(wallet)            # 1000 RUB        <- __str__
print(repr(wallet))      # Money(amount=1000, currency='RUB')  <- __repr__

total = wallet + salary
print(total)             # 51000 RUB       <- __add__ + __str__

print(salary > wallet)   # True            <- __gt__
print(wallet == Money(1000))  # True       <- __eq__

__len__ и __getitem__ - делаем класс похожим на список

class Playlist:
    def __init__(self, name):
        self.name = name
        self._tracks = []

    def add(self, track):
        self._tracks.append(track)

    # len(playlist)
    def __len__(self):
        return len(self._tracks)

    # playlist[0]
    def __getitem__(self, index):
        return self._tracks[index]

    # "Bohemian Rhapsody" in playlist
    def __contains__(self, track):
        return track in self._tracks

    def __str__(self):
        return f"Плейлист '{self.name}' - {len(self)} треков"


playlist = Playlist("Рок 90-х")
playlist.add("Nirvana - Smells Like Teen Spirit")
playlist.add("Radiohead - Creep")
playlist.add("Oasis - Wonderwall")

print(len(playlist))          # 3
print(playlist[0])            # Nirvana - Smells Like Teen Spirit
print("Radiohead - Creep" in playlist)  # True
print(playlist)               # Плейлист 'Рок 90-х' - 3 треков

# После __getitem__ работает даже цикл for - бесплатно!
for track in playlist:
    print(track)

__call__ - объект, который можно вызвать как функцию

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    # multiplier(10) - объект вызывается как функция
    def __call__(self, value):
        return value * self.factor


double = Multiplier(2)
triple = Multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15

# Полезно для декораторов и middleware
numbers = [1, 2, 3, 4, 5]
print(list(map(double, numbers)))  # [2, 4, 6, 8, 10]

__enter__ и __exit__ - контекстный менеджер

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None

    # Вызывается при входе в блок with
    def __enter__(self):
        print(f"Открываю соединение с {self.db_name}")
        self.connection = f"connection_to_{self.db_name}"
        return self  # то, что попадает в переменную as

    # Вызывается при выходе из блока with - даже при ошибке
    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Закрываю соединение с {self.db_name}")
        self.connection = None
        return False  # False = не подавлять исключения


# Соединение откроется и гарантированно закроется
with DatabaseConnection("mydb") as db:
    print(f"Работаю с {db.connection}")
    # даже если здесь упадёт ошибка - __exit__ всё равно вызовется

# Открываю соединение с mydb
# Работаю с connection_to_mydb
# Закрываю соединение с mydb

__eq__ без __hash__ - частая ловушка

class User:
    def __init__(self, user_id, name):
        self.user_id = user_id
        self.name = name

    def __eq__(self, other):
        return self.user_id == other.user_id

    # Если определяешь __eq__ - нужно определить и __hash__
    # Иначе объект нельзя положить в set или использовать как ключ словаря
    def __hash__(self):
        return hash(self.user_id)


user1 = User(1, "Ники")
user2 = User(1, "Николай")  # тот же id

print(user1 == user2)   # True - одинаковый user_id

# Работает в set и dict только если есть __hash__
users = {user1, user2}
print(len(users))       # 1 - это один и тот же пользователь

Глубже

Почему нельзя вызывать dunder-методы напрямую. Технически можно - obj.__str__() сработает. Но правильно использовать встроенные функции: str(obj), len(obj), repr(obj). Причина: встроенные функции делают дополнительные проверки и оптимизации перед вызовом метода. Например, len() проверяет, что результат - неотрицательное целое число.

__new__ против __init__. __new__ - настоящий конструктор, создаёт объект в памяти и возвращает его. __init__ - инициализатор, получает уже созданный объект и заполняет его данными. В 99% случаев переопределять __new__ не нужно. Он нужен в специфических случаях: создание синглтона, наследование от неизменяемых типов (str, int, tuple), метаклассы.

Reflected-методы - когда левый операнд не знает что делать. Если a + b вызывает a.__add__(b) и получает NotImplemented - Python попробует b.__radd__(a). Это позволяет писать 2 + Money(100) - число не знает как складываться с Money, но Money знает как складываться с числом через __radd__.

class Money:
    def __add__(self, other):
        if isinstance(other, (int, float)):
            return Money(self.amount + other, self.currency)
        return NotImplemented

    def __radd__(self, other):  # вызовется при other + money
        return self.__add__(other)

print(Money(100) + 50)  # 150 RUB  <- __add__
print(50 + Money(100))  # 150 RUB  <- __radd__

__slots__ - экономия памяти. Магический атрибут (не метод), который запрещает создание __dict__ у объектов класса. Объект с __slots__ занимает значительно меньше памяти - важно когда создаются миллионы объектов.

class Point:
    __slots__ = ['x', 'y']  # только эти атрибуты

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.z = 3  # AttributeError - нельзя добавить атрибут не из __slots__

FAQ

Почему их называют «магическими»? Потому что они вызываются автоматически - ты не видишь явного вызова в коде, но он происходит. Пишешь `a + b` - магически вызывается `__add__`. Пишешь `with obj` - магически вызываются `__enter__` и `__exit__`. Название неформальное, в документации Python они называются «специальными методами». Dunder - просто сокращение от double underscore.
Чем __str__ отличается от __repr__? `__str__` - для конечного пользователя, читаемое представление. Вызывается через `print()` и `str()`. `__repr__` - для разработчика, техническое представление. Вызывается в интерактивной консоли, в отладчике, через `repr()`. Идеальный `__repr__` возвращает строку, из которой можно воссоздать объект: `Money(amount=100, currency='RUB')`. Если определить только `__repr__` - он используется везде. Если только `__str__` - в консоли будет некрасивый адрес памяти.
Можно ли вызывать dunder-методы напрямую? Технически - да, `obj.__len__()` сработает. Но принято использовать встроенные функции: `len(obj)`, `str(obj)`, `repr(obj)`. Они выполняют дополнительные проверки перед вызовом метода. Единственное исключение - `super().__init__()` при наследовании, здесь прямой вызов нормальная практика.
Зачем нужен __hash__ если есть __eq__? Если ты определяешь `__eq__` - Python автоматически убирает `__hash__` у класса, потому что объект с кастомным равенством не может быть хэширован по умолчанию. Без `__hash__` объект нельзя положить в `set` или использовать как ключ словаря. Поэтому если определяешь `__eq__` - определяй и `__hash__`, основывая его на тех же полях, что используются в `__eq__`.
Что такое __call__ и зачем он нужен? `__call__` позволяет вызывать экземпляр класса как обычную функцию: `obj()`. Это полезно для декораторов, функторов и объектов, которые хранят состояние между вызовами - в отличие от обычных функций. Проверить, вызываем ли объект, можно через `callable(obj)` - он вернёт `True` если у объекта есть `__call__`.
Можно ли случайно сломать встроенные типы через dunder-методы? Встроенные типы (`int`, `str`, `list`) сломать нельзя - они реализованы на уровне C и защищены. Но в своём классе можно создать неожиданное поведение - например, `__eq__` который возвращает не `bool` а строку. Python это позволит, но код станет непредсказуемым. Dunder-методы должны соблюдать контракт: `__len__` всегда возвращает неотрицательный `int`, `__eq__` всегда возвращает `bool`, `__hash__` всегда возвращает `int`.