$ sudo teach IT
МОДУЛЬ 7 · УРОК 5

__add__ и арифметические операторы

Что если написать деньги1 + деньги2 — и это просто сработает? Без лишних методов, без громоздкого кода. В этом уроке мы научим наши объекты складываться, вычитаться, умножаться — с помощью привычных математических операторов. Разберём тонкости: что делать, если наш объект стоит справа от оператора, как работает +=, и почему нужно возвращать именно NotImplemented, а не NotImplementedError.

⏱ ~45 минут 🎓 Для новичков 🐍 Python 3

🤔 Зачем вообще перегружать арифметику?

Представьте: у вас есть класс 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__ — как сделать так, чтобы ваши объекты красиво выводились в консоли, логах и при отладке. Это то, что вы уже использовали в примерах этого урока — теперь разберём их устройство детально!