Обзор магических методов (dunder) в Python [2.9]
Содержание
- 1. Что такое магические методы (dunder) и как они “включаются”
- 2. SortedArray: идея, инварианты и интерфейс
- 3. Жизненный цикл: __init__, приватность и __del__
- 4. Коллекция: итерация, len(), in, индексация и приведение типов
- 5. Перегрузка операторов: сравнения и арифметика
- 6. Шпаргалка: самые полезные dunder-методы
- 7. Практика: частые ошибки и улучшения SortedArray
- 8. Самопроверка (чек-лист)
1. Что такое магические методы (dunder) и как они “включаются”
Определение
Магические методы — это специальные методы, имена которых начинаются и заканчиваются двойным подчёркиванием: __init__, __len__, __iter__, __add__ и т.д. Python вызывает их автоматически, когда вы используете стандартные конструкции языка.
Например, когда вы пишете len(obj), Python пытается вызвать obj.__len__(). Когда пишете for x in obj, Python ищет obj.__iter__(). Когда пишете a + b — вызывается a.__add__(b) (если он определён).
Почему это “мост” между объектом и синтаксисом
Можно думать о dunder-методах как о “переводчиках”: синтаксис Python (операторы и функции) обращается к вашему объекту через заранее известные имена методов. Поэтому один раз реализовав метод __iter__, вы сразу получаете совместимость с for, list(), tuple(), многими алгоритмами и библиотеками.
Мини-пример: len(), for и оператор +
Ниже — упрощённый пример: класс содержит внутренний список и “прокидывает” к нему поведение. Это очень распространённая техника: оборачивать встроенный тип, добавляя инварианты/правила.
class Box:
def __init__(self, items):
self._items = list(items)
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __add__(self, other):
return Box(self._items + list(other))
2. SortedArray: идея, инварианты и интерфейс
Задача и требования
Построим класс SortedArray, который хранит только целые числа и всегда держит их отсортированными. Это отличный учебный пример, потому что:
- нужно контролировать входные данные (проверка типов);
- нужно поддерживать инвариант (сортировка всегда сохранена);
- хочется, чтобы объект вёл себя “как список”:
for,len(),in,[]; - хочется перегрузить операторы:
+,==,<,>.
Инварианты: что класс обязан гарантировать
Инвариант — это правило, которое должно быть истинным всегда после завершения любой публичной операции. Для SortedArray инварианты такие:
- внутри хранятся только значения типа
int; - внутренний список всегда отсортирован по возрастанию.
Магические методы здесь помогают сделать интерфейс естественным, но важно помнить: именно __init__ и методы изменения данных должны “держать” инвариант.
Как будет выглядеть “удобный” API
Хотим, чтобы пользователь мог писать так:
a = SortedArray([3, 1, 4, 2])
print(list(a)) # [1, 2, 3, 4]
print(len(a)) # 4
print(3 in a) # True
print(a[0], a[-1]) # 1 4
b = a + 10 # прибавить 10 к каждому элементу
c = a + SortedArray([5, -1]) # объединить и отсортировать
3. Жизненный цикл: __init__, приватность и __del__
__init__: проверка типов и сортировка
__init__ — первое место, где мы можем обеспечить корректность. Если пустить “плохие” данные внутрь, дальше придётся защищаться в каждом методе. Поэтому правильно: проверка типов + сортировка в конструкторе.
class SortedArray:
def __init__(self, arr=None):
if arr is None:
arr = []
# 1) Проверяем тип каждого элемента
if not all(isinstance(x, int) for x in arr):
raise ValueError("Список содержит не только целые числа")
# 2) Сохраняем всегда в отсортированном виде
self.__arr = sorted(arr)
Обратите внимание: мы используем arr=None вместо arr=[]. Это лучший стиль: вы избегаете ловушки “изменяемого аргумента по умолчанию”.
Опасность arr=[ ] по умолчанию
Почему arr=[ ] плохая идея? Потому что значение по умолчанию создаётся один раз — в момент определения функции, а не при каждом вызове. Если бы мы изменяли этот список (например, делали arr.append(...)), то изменения могли “протекать” между объектами.
Даже если вы не модифицируете список напрямую, привычка использовать None как дефолт — это стандарт де-факто и снижает риск ошибок при будущих изменениях кода.
__arr и “приватность” (name mangling)
Атрибут __arr не просто “соглашение”, как _arr. Python преобразует имя атрибута, чтобы его нельзя было случайно переопределить в наследниках и чтобы к нему сложнее было обращаться снаружи. Это не безопасность, а механизм дисциплины.
__del__: почему почти всегда не нужен
В Python управление памятью автоматическое (сборщик мусора), поэтому __del__ редко нужен. Более того, наличие __del__ иногда усложняет сборку мусора. Если вы работаете с файлами/сокетами — чаще применяют контекстный менеджер (with), а не __del__.
4. Коллекция: итерация, len(), in, индексация и приведение типов
__iter__: for, list(), tuple()
Если ваш объект — “контейнер”, то первое ожидание пользователя: по нему можно пройти циклом. Реализация __iter__ обычно очень простая: вернуть итератор внутреннего контейнера.
class SortedArray:
# ... __init__
def __iter__(self):
return iter(self.__arr)
После этого автоматически работают: for x in obj, list(obj), tuple(obj) и множество функций, которые принимают “итерируемое”.
__len__ и __contains__
Чтобы работало len(obj), реализуют __len__. Чтобы работало x in obj (оператор принадлежности), реализуют __contains__. Если не реализовать __contains__, Python иногда пытается перебором через итерацию, но лучше сделать явно.
class SortedArray:
# ...
def __len__(self):
return len(self.__arr)
def __contains__(self, item):
return item in self.__arr
__getitem__: индексация и срезы
Метод __getitem__ отвечает за obj[index]. Важно: в Python индекс может быть не только числом, но и срезом slice, например obj[1:4].
class SortedArray:
# ...
def __getitem__(self, index):
return self.__arr[index]
Если вы просто возвращаете self.__arr[index], то:
- для целого индекса вернётся число;
- для среза вернётся список.
SortedArray. Тогда срез сохраняет инвариант “отсортировано” и остаётся тем же типом.
__int__, __float__, __bool__: смысловые преобразования
Приведение типов — это соглашение: вы сами выбираете смысл. Главное — чтобы преобразование выглядело логично и не удивляло пользователя.
__int__: часто “обобщающая характеристика” — например сумма или количество.__float__: может быть среднее значение, медиана и т.д.__bool__: чаще всего “пустой/непустой”.
class SortedArray:
# ...
def __int__(self):
return sum(self.__arr)
def __float__(self):
return sum(self.__arr) / len(self.__arr) if self.__arr else 0.0
def __bool__(self):
return len(self.__arr) > 0
__repr__ и __str__: отладка vs “красиво вывести”
Два метода часто путают:
__str__— человекочитаемый вывод (то, что печатается вprint(obj)).__repr__— отладочное представление (то, что видите в консоли/логах).
Хорошее правило: __repr__ должен быть максимально информативным и по возможности похожим на код, который создаёт объект (не всегда возможно, но это полезная цель).
class SortedArray:
# ...
def __str__(self):
return str(self.__arr)
def __repr__(self):
return f"SortedArray({self.__arr!r})"
5. Перегрузка операторов: сравнения и арифметика
__eq__ и сравнения: как делать корректно
Сравнение — важный элемент интерфейса. Но у него есть тонкость: что считать “равенством”? В нашем примере (как в учебной задаче) равенство зависит от среднего значения, то есть два разных массива могут быть “равны”, если их среднее одинаковое.
В реальных проектах чаще сравнивают либо полный набор элементов (как списки), либо уникальный ключ. Но для демонстрации dunder-методов вариант со средним подходит.
Почему лучше возвращать NotImplemented
Когда оператор встречает неизвестный тип справа, хорошей практикой считается вернуть NotImplemented, а не бросать исключение. Тогда Python попробует выполнить “обратную” операцию (например, вызвать метод другого объекта). Если и там не получится — Python сам выдаст корректный TypeError.
class SortedArray:
# ...
def __eq__(self, other):
if not isinstance(other, SortedArray):
return NotImplemented
return float(self) == float(other)
def __lt__(self, other):
if not isinstance(other, SortedArray):
return NotImplemented
return float(self) < float(other)
def __gt__(self, other):
if not isinstance(other, SortedArray):
return NotImplemented
return float(self) > float(other)
__add__ и __radd__: поддержка obj + x и x + obj
__add__ определяет поведение obj + other. Но если вы хотите, чтобы работало ещё и other + obj (например, 3 + SortedArray([...])), вам нужен __radd__ — “reverse add”.
class SortedArray:
# ...
def __add__(self, other):
if isinstance(other, int):
return SortedArray([x + other for x in self.__arr])
if isinstance(other, SortedArray):
return SortedArray(self.__arr + list(other))
return NotImplemented
def __radd__(self, other):
# Позволяет писать: 3 + SortedArray([1,2,3])
return self.__add__(other)
6. Шпаргалка: самые полезные dunder-методы
Группы методов
- Жизненный цикл:
__init__,__del__ - Строки и отладка:
__str__,__repr__ - Контейнер:
__iter__,__len__,__contains__,__getitem__ - Сравнения:
__eq__,__lt__,__gt__,__le__,__ge__ - Арифметика:
__add__,__sub__,__mul__, ...
Таблица “синтаксис → метод”
| Синтаксис | Что происходит | Метод |
|---|---|---|
len(obj) |
Python вызывает метод длины | __len__ |
for x in obj |
Python получает итератор | __iter__ |
x in obj |
Проверка принадлежности | __contains__ |
obj[i] |
Индексация/срез | __getitem__ |
print(obj) |
Человекочитаемый вывод | __str__ |
obj в REPL/логах |
Отладочное представление | __repr__ |
a == b |
Сравнение на равенство | __eq__ |
a + b |
Сложение | __add__ |
7. Практика: частые ошибки и улучшения SortedArray
Типичные ошибки
| Ошибка | Почему плохо | Как правильно |
|---|---|---|
def __init__(self, arr=[]) |
Изменяемый аргумент по умолчанию может вести к неожиданным эффектам | arr=None, внутри: arr = [] |
| Не проверять типы элементов | Инвариант ломается: внутри могут оказаться строки/float и т.д. | all(isinstance(x, int) for x in arr) |
| Хранить как есть (без сортировки) | Класс перестаёт выполнять обещание “Sorted” | self.__arr = sorted(arr) |
Снаружи менять __arr |
Можно сломать сортировку и типы | Делать безопасные методы (например, add()) |
Как защитить инвариант “всегда отсортировано”
Самая частая проблема: пользователь может захотеть “вставить элемент” или “изменить по индексу”. Если вы добавите __setitem__ без контроля, можно легко разрушить сортировку.
Поэтому часто делают так:
- либо вообще не дают изменять элементы по индексу (нет
__setitem__), - либо разрешают только безопасные операции, которые сохраняют сортировку (например, метод
add(x)).
Идеи расширения
Если захочется сделать класс ещё “взрослее”, можно добавить:
- метод вставки с сохранением сортировки через модуль
bisect; __iadd__для+=;__mul__для умножения (например, повторить элементы или масштабировать значения);- поддержку срезов, возвращающих новый
SortedArray, а не обычный список.
8. Самопроверка (чек-лист)
Таблица с чекбоксами
| ✓ | Проверяю себя |
|---|---|
| Я понимаю, что dunder-методы вызываются Python автоматически при использовании синтаксиса. | |
Я знаю, зачем нужен __iter__ и как он связан с for и list(). |
|
Я понимаю разницу между __str__ и __repr__. |
|
Я могу перегрузить + через __add__ и понимаю, зачем нужен __radd__. |
|
| Я умею поддерживать инвариант класса (типы + сортировка) и не даю пользователю его сломать. |
Примеры обращений к “волшебным” методам (что именно вызывает Python)
Важно понимать: в большинстве случаев вы не вызываете dunder-методы напрямую. Вы пишете привычный синтаксис Python, а интерпретатор “под капотом” обращается к методам вида __...__. Но для обучения полезно увидеть, что синтаксис и прямой вызов — это почти одно и то же.
Нормальная практика — пользоваться синтаксисом (len(obj), obj + x), а не писать obj.__len__(). Прямые вызовы dunder-методов применяют редко: в отладке, экспериментах или при написании библиотек.
Мини-таблица “синтаксис ↔ прямой вызов”
| Выражение в коде | Что пытается сделать Python |
|---|---|
len(obj) |
obj.__len__() |
iter(obj) |
obj.__iter__() |
next(it) |
it.__next__() |
x in obj |
obj.__contains__(x) (или перебор через итерацию, если метода нет) |
obj[i] |
obj.__getitem__(i) |
obj + other |
obj.__add__(other), если вернул NotImplemented → other.__radd__(obj) |
obj == other |
obj.__eq__(other) |
int(obj) |
obj.__int__() |
print(obj) |
obj.__str__() (в интерактивной среде часто виден __repr__) |
Живой пример на Box
b = Box([1, 2, 3])
# len(obj) вызывает __len__
print(len(b)) # 3
print(b.__len__()) # 3 (прямой вызов, чисто для понимания)
# for вызывает __iter__ (а затем __next__ у итератора)
for x in b:
print(x)
it = iter(b) # вызывает b.__iter__()
print(next(it)) # вызывает it.__next__()
print(next(it))
# оператор + вызывает __add__
b2 = b + [4, 5]
b3 = b.__add__([4, 5]) # прямой вызов (обычно так не пишут)
Примеры: как “коллекционные” dunder-методы вызываются на SortedArray
Ниже — один набор примеров, где для каждой операции показаны две формы: обычная (правильная в реальном коде) и прямая (для понимания механики).
a = SortedArray([3, 1, 4, 2]) # внутри станет [1, 2, 3, 4]
# 1) Итерация
print(list(a)) # list() использует итератор: __iter__
print(list(a.__iter__())) # прямой вызов (вернёт тот же результат)
# 2) len()
# Если вы реализовали __len__, то len(a) будет работать так:
# len(a) -> a.__len__()
# (в вашей текущей версии статьи __len__ описан; если его нет — добавьте)
print(len(a))
# print(a.__len__()) # можно вызвать напрямую, если __len__ реализован
# 3) in (принадлежность)
# 3 in a -> a.__contains__(3) (если метод реализован)
print(3 in a)
# print(a.__contains__(3)) # прямой вызов
# 4) Индексация
# a[0] -> a.__getitem__(0)
print(a[0])
print(a.__getitem__(0))
Почему полезно знать “прямые вызовы”? Потому что так легче отлаживать. Например, если for x in a падает, можно проверить: работает ли a.__iter__() и что он возвращает.
Примеры: приведение типов и строковые представления (int/float/bool/str/repr)
Методы __int__, __float__, __bool__, __str__, __repr__ обычно вызываются не напрямую, а через встроенные функции и стандартные механизмы вывода.
a = SortedArray([3, 1, 4, 2])
# int(obj) -> obj.__int__()
print(int(a))
print(a.__int__()) # прямой вызов
# float(obj) -> obj.__float__()
print(float(a))
print(a.__float__()) # прямой вызов
# bool(obj) -> obj.__bool__()
# применяется в if/while, а также в bool(obj)
print(bool(a))
print(a.__bool__()) # прямой вызов
empty = SortedArray([])
print(bool(empty)) # False
# str(obj) -> obj.__str__()
print(str(a))
print(a.__str__()) # прямой вызов
# repr(obj) -> obj.__repr__()
# В интерактивной консоли обычно показывается repr
print(repr(a))
print(a.__repr__()) # прямой вызов
Примеры: как вызываются __add__/__radd__ и __eq__/__lt__/__gt__
Для операторов важно понимать, что Python может “перекинуть” операцию на правый операнд, если левый вернул NotImplemented. Это и есть причина, почему NotImplemented — правильный путь для “не моего типа”.
a = SortedArray([1, 2, 3, 4])
b = SortedArray([-2, 7]) # среднее тоже 2.5 (если сравниваете по float)
# Сравнения
# a == b -> a.__eq__(b)
print(a == b)
print(a.__eq__(b)) # прямой вызов
# a < b -> a.__lt__(b)
print(a < b)
print(a.__lt__(b)) # прямой вызов
# Сложение с int
# a + 10 -> a.__add__(10)
c = a + 10
d = a.__add__(10) # прямой вызов
print(list(c), list(d))
# Сложение с SortedArray
# a + b -> a.__add__(b)
ab1 = a + b
ab2 = a.__add__(b)
print(list(ab1), list(ab2))
# Обратное сложение (если реализован __radd__)
# 10 + a -> int.__add__(a) (скорее всего вернёт NotImplemented) -> a.__radd__(10)
ra1 = 10 + a
ra2 = a.__radd__(10) # прямой вызов
print(list(ra1), list(ra2))
Практический смысл NotImplemented: вы не “ломаете” выражение сразу, а позволяете Python попробовать альтернативу — метод правого операнда (__radd__, __req__ и т.п.).
Мини-практика: как проверить, что метод существует и что он делает
Иногда полезно быстро “прощупать” объект: какие dunder-методы у него определены и что возвращают. Для этого используют dir() и getattr().
a = SortedArray([3, 1, 2])
# Посмотреть все атрибуты/методы (в том числе dunder)
methods = [name for name in dir(a) if name.startswith("__") and name.endswith("__")]
print(methods)
# Аккуратно достать конкретный метод и вызвать
m = getattr(a, "__iter__")
it = m()
print(next(it))