__add__ и арифметические операторы
Что если написать деньги1 + деньги2 — и это просто сработает? Без лишних методов, без громоздкого кода. В этом уроке мы научим наши объекты складываться, вычитаться, умножаться — с помощью привычных математических операторов. Разберём тонкости: что делать, если наш объект стоит справа от оператора, как работает +=, и почему нужно возвращать именно NotImplemented, а не NotImplementedError.
🤔 Зачем вообще перегружать арифметику?
Представьте: у вас есть класс Money для работы с деньгами. Вы хотите посчитать сдачу или общую стоимость покупок. Без магических методов вам придётся писать что-то вроде:
total = Money(money1.amount + money2.amount)
Это работает, но выглядит громоздко. А с арифметическими dunder-методами можно написать просто:
total = money1 + money2
💡 Это называется перегрузкой операторов — вы «перегружаете» (переопределяете) поведение стандартного оператора для своего класса. Python не запрещает складывать объекты — он просто вызывает нужный метод. Числа умеют это делать через int.__add__, строки — через str.__add__. Теперь и ваши классы смогут!
📋 Арифметические dunder-методы: полная таблица
У каждого математического оператора есть свой метод. Вот основные:
➕ Сложение
a + b → a.__add__(b)
➖ Вычитание
a - b → a.__sub__(b)
✖️ Умножение
a * b → a.__mul__(b)
➗ Деление
a / b → a.__truediv__(b)
🔢 Целочисленное деление
a // b → a.__floordiv__(b)
🔣 Остаток от деления
a % b → a.__mod__(b)
🔋 Возведение в степень
a ** b → a.__pow__(b)
🧠 Важно понять: когда Python видит a + b, он сначала вызывает a.__add__(b). Если тот вернёт NotImplemented — Python попробует b.__radd__(a). Если и это не сработает — вы получите TypeError. Про __radd__ подробно поговорим ниже.
➕ Пишем __add__ — складываем два объекта Money
Метод __add__ принимает два аргумента: self — это левый операнд (наш объект), и other — правый операнд (то, с чем складываем). Метод должен вернуть новый объект с результатом — исходные объекты обычно не меняются.
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
# Проверяем: other тоже является Money?
if not isinstance(other, Money):
return NotImplemented # не знаем как складывать с этим типом
# Проверяем совместимость валют
if self.currency != other.currency:
raise ValueError(
f"Нельзя складывать {self.currency} и {other.currency}"
)
# Возвращаем НОВЫЙ объект — исходные не трогаем
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
# Теперь работает!
wallet = Money(500, "RUB")
price = Money(300, "RUB")
total = wallet + price
print(total) # Money(800 RUB)
print(wallet) # Money(500 RUB) ← исходный не изменился
print(price) # Money(300 RUB) ← и этот тоже
# Складываем несколько подряд:
a = Money(100, "RUB")
b = Money(200, "RUB")
c = Money(300, "RUB")
print(a + b + c) # Money(600 RUB)
🔑 Ключевое правило: возвращай новый объект
В математике 5 + 3 не изменяет 5 — оно создаёт новое число 8. Точно так же __add__ должен создавать новый объект, а не изменять self. Если хотите изменять объект на месте — для этого есть __iadd__ (оператор +=), который рассмотрим позже.
⚠️ Проблема: что если наш объект стоит СПРАВА?
Отлично, Money(100) + Money(200) работает. Но что если мы захотим написать 5 + Money(100)? Казалось бы — та же операция сложения. Но Python вызовет сначала метод левого операнда: (5).__add__(Money(100)).
Целое число 5 понятия не имеет, как складываться с Money. Его __add__ вернёт NotImplemented. И что дальше?
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Нельзя складывать {self.currency} и {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
m = Money(100)
# Это работает (наш объект СЛЕВА):
print(m + Money(50)) # Money(150 RUB) ✅
# А это вызывает ошибку (наш объект СПРАВА):
print(5 + m) # TypeError: unsupported operand type(s) for +: 'int' and 'Money' ❌
❌ Почему возникает ошибка?
При выполнении 5 + m Python делает следующее: сначала вызывает (5).__add__(m). Встроенный тип int не знает ничего о нашем Money, поэтому возвращает NotImplemented. Затем Python хочет попробовать «отражённый» метод — m.__radd__(5). Но мы его не определили! Вот и TypeError.
🔄 __radd__ — «отражённые» методы спасают ситуацию
r в названии означает right (правый). Метод __radd__ вызывается, когда наш объект стоит справа, а левый операнд вернул NotImplemented. В __radd__ аргументы переставлены: self — это правый операнд (наш объект), other — левый.
Цепочка вызовов для 5 + Money(100):
Шаг 1
(5).__add__(Money(100))
→ NotImplemented
Шаг 2
Money(100).__radd__(5)
→ Python пробует это!
Результат
Наш __radd__ возвращает
новый объект ✅
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
# Наш объект СЛЕВА: Money(100) + ?
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Нельзя складывать {self.currency} и {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __radd__(self, other):
# Наш объект СПРАВА: ? + Money(100)
# В этот метод попадаем, когда left.__add__(self) вернул NotImplemented
# other — это ЛЕВЫЙ операнд (например, число 5)
if not isinstance(other, (int, float)):
return NotImplemented
# Складываем: число прибавляем к нашей сумме
return Money(self.amount + other, self.currency)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
m = Money(100)
print(m + Money(50)) # Money(150 RUB) ✅ — через __add__
print(5 + m) # Money(105 RUB) ✅ — через __radd__
print(10.5 + m) # Money(110.5 RUB) ✅ — тоже через __radd__
# Суммирование списка с sum() — тоже через __radd__!
# sum() начинает с 0, т.е. делает: 0 + Money(100) + Money(200)
prices = [Money(100), Money(200), Money(300)]
total = sum(prices) # sum() вызывает: 0 + prices[0] → __radd__!
print(total) # Money(600 RUB) ✅
🎁 Бонус: sum() начинает с нуля!
Встроенная функция sum([a, b, c]) внутри делает примерно так: 0 + a + b + c. Первая операция — это 0 + a, то есть (0).__add__(a). Целое число 0 не знает про Money, вернёт NotImplemented, и Python позвонит нашему a.__radd__(0). Поэтому sum() работает с нашим классом!
🪞 Отражённые методы: полный список
У каждого арифметического оператора есть своя «отражённая» версия. Правило одно: если a + b → сначала a.__add__(b), потом b.__radd__(a).
| Оператор | Прямой (объект слева) | Отражённый (объект справа) |
|---|---|---|
+ |
__add__ |
__radd__ |
- |
__sub__ |
__rsub__ |
* |
__mul__ |
__rmul__ |
/ |
__truediv__ |
__rtruediv__ |
// |
__floordiv__ |
__rfloordiv__ |
% |
__mod__ |
__rmod__ |
** |
__pow__ |
__rpow__ |
➖ Осторожно с __rsub__: порядок важен!
С вычитанием нужно быть аккуратнее, чем со сложением: a - b ≠ b - a. Когда вызывается __rsub__(self, other), помните: self — это правый операнд, а other — левый. То есть нужно вычислить other - self, а не self - other!
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __sub__(self, other):
# self - other (наш объект слева)
if not isinstance(other, (Money, int, float)):
return NotImplemented
other_amount = other.amount if isinstance(other, Money) else other
return Money(self.amount - other_amount, self.currency)
def __rsub__(self, other):
# other - self (наш объект СПРАВА!)
# Нужно вычислить: other - self.amount
if not isinstance(other, (int, float)):
return NotImplemented
return Money(other - self.amount, self.currency)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
m = Money(100)
print(m - 30) # Money(70 RUB) — через __sub__: 100 - 30
print(200 - m) # Money(100 RUB) — через __rsub__: 200 - 100
⚠️ Частая ошибка в __rsub__:
Если случайно написать return Money(self.amount - other, ...) вместо return Money(other - self.amount, ...) — ответ будет неверным! При 200 - m получите Money(-100) вместо Money(100). Всегда помните: в __rsub__ нужно вычислять other - self.
🔀 __iadd__ — оператор += (in-place операции)
Оператор += — это «сложение на месте» (in-place, то есть изменение самого объекта). Для него отвечает метод __iadd__. Но здесь есть нюанс, который часто путает новичков.
🤔 Что делает a += b под капотом?
Python выполняет: a = a.__iadd__(b). То есть результат __iadd__ присваивается обратно переменной a. Если метод не определён, Python автоматически использует __add__: делает a = a + b. Это и объясняет два разных варианта реализации.
Вариант 1: создаём новый объект (как в __add__) — проще и безопаснее для неизменяемых объектов:
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Нельзя складывать {self.currency} и {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __iadd__(self, other):
# Вариант 1: создаём новый объект (делегируем __add__)
return self.__add__(other)
# После этого Python сделает: self = результат
# Старый объект будет удалён сборщиком мусора
def __repr__(self):
return f"Money({self.amount} {self.currency})"
wallet = Money(500)
print(id(wallet)) # какой-то id, например 140234567890
wallet += Money(100)
print(wallet) # Money(600 RUB)
print(id(wallet)) # ДРУГОЙ id — это новый объект!
Вариант 2: изменяем существующий объект (мутабельный подход) — возвращаем self:
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Нельзя складывать {self.currency} и {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __iadd__(self, other):
# Вариант 2: изменяем ТЕКУЩИЙ объект на месте
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Нельзя складывать {self.currency} и {other.currency}")
self.amount += other.amount # изменяем атрибут
return self # ВАЖНО: возвращаем себя (self)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
wallet = Money(500)
alias = wallet # alias и wallet ссылаются на один объект
print(id(wallet)) # например 140234567890
wallet += Money(100)
print(wallet) # Money(600 RUB)
print(alias) # Money(600 RUB) ← alias тоже изменился! Тот же объект.
print(id(wallet)) # ТОТ ЖЕ id — объект не пересоздавался
⚡ Вариант 2 vs вариант 1 — что выбрать?
- Вариант 1 (новый объект): объект ведёт себя как встроенные типы — числа, строки. Безопасно, никаких сюрпризов с алиасами.
- Вариант 2 (изменить self): эффективнее по памяти для больших объектов (списки, массивы). Используется в
list.__iadd__— вот почемуlist += [1,2]не создаёт новый список. - Для большинства классов выбирайте вариант 1 — он проще и предсказуемее.
🚦 NotImplemented vs NotImplementedError — в чём разница?
Это один из самых часто встречаемых вопросов у новичков. Два похожих имени — совершенно разный смысл.
✅ NotImplemented
- Это специальное значение (синглтон), не исключение
- Возвращается из метода как сигнал Python: «я не умею работать с этим типом, попробуй другой способ»
- Python видит
NotImplementedи пробует следующий вариант (например,__radd__) - Программа продолжает работу!
❌ NotImplementedError
- Это исключение, подкласс
RuntimeError - Выбрасывается через
raise NotImplementedErrorв абстрактных методах, которые обязаны быть переопределены в подклассе - Немедленно прерывает выполнение программы
- Python не пытается ничего другого — сразу ошибка!
class Money:
def __init__(self, amount, currency="RUB"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
# ПРАВИЛЬНО: возвращаем NotImplemented (значение)
# Python сможет попробовать other.__radd__(self)
return NotImplemented
# НЕПРАВИЛЬНО: raise NotImplementedError
# Это сразу сломает программу — исключение, а не сигнал!
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"Money({self.amount} {self.currency})"
m = Money(100)
# Что происходит при m + "строка":
# 1. m.__add__("строка") → возвращает NotImplemented
# 2. Python пробует: "строка".__radd__(m) → тоже NotImplemented (str не знает про Money)
# 3. Только теперь Python выбрасывает TypeError
# Программа даёт понятную ошибку, а не непонятный NotImplementedError
try:
result = m + "100 рублей"
except TypeError as e:
print(e) # unsupported operand type(s) for +: 'Money' and 'str'
📌 Правило большого пальца:
- В арифметических методах (
__add__,__sub__и т.д.) при несовместимом типе — возвращайтеNotImplemented - В абстрактных методах базового класса, которые подкласс обязан реализовать — используйте
raise NotImplementedError
🧮 Полный пример: класс Vector со всеми операторами
Класс Vector — двумерный вектор — отличный пример для демонстрации арифметических операций. Он поддерживает: сложение двух векторов, вычитание, умножение на число (и число * вектор), деление на число.
class Vector:
"""Двумерный вектор с поддержкой арифметики."""
def __init__(self, x, y):
self.x = x
self.y = y
# ── Сложение ─────────────────────────────────────────────
def __add__(self, other):
"""Vector + Vector или Vector + число"""
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
if isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
return NotImplemented
def __radd__(self, other):
"""число + Vector (вызывается когда наш объект справа)"""
# Сложение коммутативно: a + v == v + a
return self.__add__(other)
# ── Вычитание ────────────────────────────────────────────
def __sub__(self, other):
"""Vector - Vector или Vector - число"""
if isinstance(other, Vector):
return Vector(self.x - other.x, self.y - other.y)
if isinstance(other, (int, float)):
return Vector(self.x - other, self.y - other)
return NotImplemented
def __rsub__(self, other):
"""число - Vector (наш объект справа!)"""
# ВАЖНО: это other - self, не self - other!
if isinstance(other, (int, float)):
return Vector(other - self.x, other - self.y)
return NotImplemented
# ── Умножение ────────────────────────────────────────────
def __mul__(self, scalar):
"""Vector * число"""
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
return NotImplemented
def __rmul__(self, scalar):
"""число * Vector (наш объект справа)"""
# Умножение коммутативно, делегируем __mul__
return self.__mul__(scalar)
# ── Деление ──────────────────────────────────────────────
def __truediv__(self, scalar):
"""Vector / число"""
if isinstance(scalar, (int, float)):
if scalar == 0:
raise ZeroDivisionError("Нельзя делить вектор на ноль")
return Vector(self.x / scalar, self.y / scalar)
return NotImplemented
# ── In-place операции ─────────────────────────────────────
def __iadd__(self, other):
"""Vector += Vector (изменяем текущий объект)"""
if isinstance(other, Vector):
self.x += other.x
self.y += other.y
return self
if isinstance(other, (int, float)):
self.x += other
self.y += other
return self
return NotImplemented
def __repr__(self):
return f"Vector({self.x}, {self.y})"
# === Демонстрация ===
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6) — __add__
print(v1 - v2) # Vector(-2, -2) — __sub__
print(v1 * 3) # Vector(3, 6) — __mul__
print(3 * v1) # Vector(3, 6) — __rmul__ ← объект СПРАВА
print(v1 / 2) # Vector(0.5, 1.0) — __truediv__
print(10 - v1) # Vector(9, 8) — __rsub__ ← объект СПРАВА
print(5 + v1) # Vector(6, 7) — __radd__ ← объект СПРАВА
# In-place: изменяем v1 на месте
v1 += Vector(10, 10)
print(v1) # Vector(11, 12) — __iadd__, v1 изменён
# sum() работает благодаря __radd__:
vectors = [Vector(1, 0), Vector(0, 1), Vector(2, 3)]
total = sum(vectors)
print(total) # Vector(3, 4)
⚠️ Типичные ошибки и как их избежать
❌ Ошибка 1: raise NotImplementedError вместо return NotImplemented
# Плохо: прерывает выполнение программой сразу
def __add__(self, other):
if not isinstance(other, Money):
raise NotImplementedError # ← неверно! это исключение
# Хорошо: сигнал Python попробовать другой метод
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented # ← верно! Python продолжит поиск
❌ Ошибка 2: Неверный порядок в __rsub__
# Плохо: при 200 - Money(100) вернёт Money(-100) вместо Money(100)
def __rsub__(self, other):
return Money(self.amount - other) # ← self - other, но нужно other - self!
# Хорошо: other - self
def __rsub__(self, other):
return Money(other - self.amount) # ← правильно
❌ Ошибка 3: __iadd__ не возвращает self при изменении объекта
# Плохо: забыли return self → переменная становится None!
def __iadd__(self, other):
self.amount += other.amount
# return self ← забыли!
# Python выполнит: a = a.__iadd__(b) → a = None ← катастрофа
# Хорошо: всегда возвращаем self при in-place изменении
def __iadd__(self, other):
self.amount += other.amount
return self # ← обязательно!
✅ Итоги урока
__add__отвечает за+,__sub__за-,__mul__за*,__truediv__за/,__floordiv__за//,__mod__за%,__pow__за**.- Отражённые методы (
__radd__,__rsub__и т.д.) вызываются когда наш объект стоит справа от оператора, а левый вернулNotImplemented. - В
__rsub__нужно вычислятьother - self, а неself - other— порядок операндов важен! __iadd__отвечает за+=. Если изменяете объект на месте — обязательно вернитеself. Если создаёте новый — верните его.- При несовместимом типе возвращайте
NotImplemented(значение), а неraise NotImplementedError(исключение). Это сигнал Python попробовать другой вариант. - Встроенная функция
sum()работает с вашим классом, если определить__radd__— она начинает с0 + первый_элемент. - Обычно
__add__должен возвращать новый объект, не изменяя исходные.
👉 В следующем уроке мы изучим __str__ и __repr__ — как сделать так, чтобы ваши объекты красиво выводились в консоли, логах и при отладке. Это то, что вы уже использовали в примерах этого урока — теперь разберём их устройство детально!