Магические методы Python - dunder-методы
Методы с двойным подчёркиванием - это не магия. Это способ научить свои классы вести себя как встроенные типы
Содержание
Коротко и чётко
Магические методы (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__