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

__eq__, __lt__ и операторы сравнения

Как Python решает, что один объект «равен» другому или «меньше»? По умолчанию — по адресу в памяти. Но мы можем это изменить. В этом уроке научимся сравнивать объекты так, как нам нужно: по значению, по сумме, по дате — по чему угодно. Плюс разберём коварный подводный камень с хешем.

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

🤔 Что происходит без наших методов: сравнение по id

Представьте: вы написали класс Money — деньги. Создали два объекта с одинаковой суммой: a = Money(100) и b = Money(100). Интуитивно кажется, что a == b должно быть True — ведь обе переменные хранят «100 рублей». Но вот что происходит на самом деле:

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

a = Money(100)
b = Money(100)

print(a == b)   # False  ← !!!
print(a is b)   # False  (это разные объекты в памяти)
print(a == a)   # True   (один и тот же объект)

⚠️ Почему False? Потому что Python по умолчанию сравнивает адреса объектов в памяти (id). a и b — два разных объекта, живущие в разных местах памяти, даже если их содержимое одинаково. Оператор == без __eq__ ведёт себя как is.

Это поведение редко бывает полезным. Нам нужно сравнивать по значению, а не по адресу. Именно для этого существуют специальные методы сравнения.

📋 Шесть операторов — шесть методов

В Python ровно шесть операторов сравнения, и у каждого есть свой dunder-метод:

⚖️ Равенство

a == b → a.__eq__(b)

🚫 Неравенство

a != b → a.__ne__(b)

⬇️ Меньше

a < b → a.__lt__(b)

⬆️ Больше

a > b → a.__gt__(b)

⬇️≤ Меньше или равно

a <= b → a.__le__(b)

⬆️≥ Больше или равно

a >= b → a.__ge__(b)

💡 Про __ne__: В Python 3 вам почти никогда не нужно определять __ne__ вручную. По умолчанию a != b автоматически возвращает отрицание результата a.__eq__(b). То есть если вы написали __eq__ — != уже работает правильно.

⚖️ Определяем __eq__ — оператор ==

Метод __eq__ принимает два аргумента: self (левый операнд) и other (правый операнд). Он должен вернуть True, False, или специальное значение NotImplemented (если не знаем, как сравниваться с таким типом).

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

    def __eq__(self, other):
        # Сначала проверяем: other — тоже Money?
        # Если нет — говорим Python "не знаю как сравнивать"
        if not isinstance(other, Money):
            return NotImplemented
        # Равны, если совпадают и сумма, и валюта
        return self.amount == other.amount and self.currency == other.currency

    def __repr__(self):
        return f"Money({self.amount}, {self.currency!r})"


a = Money(100, "RUB")
b = Money(100, "RUB")
c = Money(200, "RUB")
d = Money(100, "USD")

print(a == b)   # True  ← теперь работает!
print(a == c)   # False (разные суммы)
print(a == d)   # False (разные валюты)
print(a != b)   # False (__ne__ автоматически!)
print(a != c)   # True

🤔 Зачем возвращать NotImplemented?

Если написать a == 42, ваш __eq__ получит other = 42 — число, а не Money. Что вернуть? Мы не знаем, как их сравнивать. Возвращаем NotImplemented — это сигнал Python: «попробуй спросить правую сторону». Python тогда вызовет (42).__eq__(a). Если и оно вернёт NotImplemented — Python вернёт False. Никаких исключений, всё чисто.

⬇️ Определяем __lt__, __gt__, __le__, __ge__

По аналогии с __eq__ определяем методы для «меньше», «больше», «меньше или равно», «больше или равно». Каждый принимает self и other, возвращает True, False или NotImplemented.

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

    def _check_same_currency(self, other):
        """Вспомогательный метод: проверяем совместимость."""
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise ValueError(
                f"Нельзя сравнивать {self.currency} и {other.currency}"
            )
        return None  # всё ок

    def __eq__(self, other):
        result = self._check_same_currency(other)
        if result is NotImplemented:
            return NotImplemented
        return self.amount == other.amount

    def __lt__(self, other):
        result = self._check_same_currency(other)
        if result is NotImplemented:
            return NotImplemented
        return self.amount < other.amount

    def __gt__(self, other):
        result = self._check_same_currency(other)
        if result is NotImplemented:
            return NotImplemented
        return self.amount > other.amount

    def __le__(self, other):
        result = self._check_same_currency(other)
        if result is NotImplemented:
            return NotImplemented
        return self.amount <= other.amount

    def __ge__(self, other):
        result = self._check_same_currency(other)
        if result is NotImplemented:
            return NotImplemented
        return self.amount >= other.amount

    def __repr__(self):
        return f"Money({self.amount} {self.currency})"


price = Money(500, "RUB")
sale  = Money(300, "RUB")

print(price > sale)    # True
print(price < sale)    # False
print(price >= sale)   # True
print(sale <= price)   # True

# Сортировка работает!
prices = [Money(300), Money(100), Money(200), Money(50)]
print(sorted(prices))
# [Money(50 RUB), Money(100 RUB), Money(200 RUB), Money(300 RUB)]

🎯 Бонус — sorted() и min()/max()! Как только вы определили __lt__, встроенные функции sorted(), min() и max() начинают работать с вашими объектами. Они используют именно < для сравнения и сортировки.

⚡ functools.total_ordering — пишем два метода, получаем шесть

Писать шесть методов сравнения — утомительно и повторяемо. Python предлагает элегантное решение: декоратор @total_ordering из модуля functools. Вы определяете только __eq__ и один из методов порядка (__lt__, __gt__, __le__ или __ge__), а Python автоматически выводит остальные пять.

🧠 Декоратор — это специальная «обёртка» над классом или функцией. Когда вы пишете @total_ordering перед class, Python передаёт ваш класс в функцию total_ordering, которая дополняет его недостающими методами и возвращает обратно. Магия!

from functools import total_ordering

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

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency

    def __lt__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise ValueError("Нельзя сравнивать разные валюты")
        return self.amount < other.amount

    def __repr__(self):
        return f"Money({self.amount} {self.currency})"


a = Money(100)
b = Money(200)
c = Money(100)

# Определили только __eq__ и __lt__,
# но все шесть операторов работают:
print(a == c)   # True
print(a != b)   # True
print(a < b)    # True
print(a > b)    # False  ← сгенерировано автоматически!
print(a <= c)   # True   ← сгенерировано автоматически!
print(b >= a)   # True   ← сгенерировано автоматически!

⚡ Что генерирует total_ordering из __eq__ + __lt__:

  • __gt__(a, b) — через not (a < b) and a != b
  • __le__(a, b) — через a < b or a == b
  • __ge__(a, b) — через not (a < b)

Небольшой нюанс: сгенерированные методы чуть медленнее вручную написанных (они делают несколько вызовов), но для большинства задач это незаметно.

💣 Подводный камень: __eq__ ломает __hash__

Вот самая неочевидная вещь в этом уроке — и одна из самых важных. Запомните её хорошо.

Когда вы кладёте объект в set или используете его как ключ в dict, Python должен вычислить его хеш — специальное целое число, которое используется для быстрого поиска. Хеш вычисляется методом __hash__. У всех объектов он есть по умолчанию.

Но! Как только вы определяете __eq__, Python автоматически устанавливает __hash__ = None. Ваш объект перестаёт быть «хешируемым» (hashable). Попытка положить его в set или сделать ключом dict вызовет ошибку:

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

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount


a = Money(100)
b = Money(200)

# Сравнение работает:
print(a == Money(100))   # True

# Но попытка положить в set — ошибка!
try:
    s = {a, b}
except TypeError as e:
    print(e)   # unhashable type: 'Money'

# И в dict тоже нельзя использовать как ключ:
try:
    d = {a: "сто рублей"}
except TypeError as e:
    print(e)   # unhashable type: 'Money'

⚠️ Почему Python так делает?

Это сделано специально для защиты от противоречия. Представьте: у вас два объекта a == b, но у них разные хеши. Python поместит их в set как два разных элемента — хотя они «равны». Это нарушило бы фундаментальный контракт: если a == b, то hash(a) == hash(b). Чтобы вы не нарушили это правило случайно, Python отключает хеш при переопределении __eq__.

✅ Решение: определяем __hash__ вместе с __eq__

Правило простое: если вы определили __eq__ — всегда определяйте и __hash__. Хеш должен быть вычислен на основе тех же полей, что используются в __eq__. Проще всего использовать встроенную функцию hash() с кортежем из нужных полей:

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

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency

    def __hash__(self):
        # Хешируем кортеж из тех же полей, что используем в __eq__
        # hash() для кортежа — стандартный безопасный способ
        return hash((self.amount, self.currency))

    def __repr__(self):
        return f"Money({self.amount} {self.currency})"


a = Money(100, "RUB")
b = Money(100, "RUB")   # равен a по __eq__
c = Money(200, "RUB")

# Равные объекты имеют одинаковый хеш:
print(hash(a) == hash(b))   # True  ← контракт соблюдён!

# Теперь можно класть в set:
s = {a, b, c}
print(s)     # {Money(100 RUB), Money(200 RUB)}  ← a и b — одно значение!
print(len(s))  # 2

# И использовать как ключ dict:
prices = {
    Money(100, "RUB"): "Сто рублей",
    Money(50, "RUB"):  "Пятьдесят рублей",
}
print(prices[Money(100, "RUB")])   # "Сто рублей" ← работает!

📐 Главный контракт хеширования:

  • Если a == b, то обязательно hash(a) == hash(b)
  • Если hash(a) == hash(b), объекты не обязаны быть равными (это нормально — называется «коллизией»)
  • Хеш объекта должен не меняться в течение его жизни — поэтому хешировать стоит только неизменяемые поля

💰 Полный пример: класс Money с правильным сравнением и хешем

Соберём всё в один класс с использованием @total_ordering и корректным __hash__:

from functools import total_ordering

@total_ordering
class Money:
    """Денежная сумма с поддержкой сравнений, сортировки и хеширования."""

    def __init__(self, amount, currency="RUB"):
        if amount < 0:
            raise ValueError("Сумма не может быть отрицательной")
        self.amount = amount
        self.currency = currency.upper()

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency

    def __lt__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise ValueError(
                f"Нельзя сравнивать {self.currency} и {other.currency}"
            )
        return self.amount < other.amount

    def __hash__(self):
        # Важно: хешируем те же поля, что и в __eq__
        return hash((self.amount, self.currency))

    def __repr__(self):
        return f"Money({self.amount} {self.currency})"

    def __str__(self):
        return f"{self.amount} {self.currency}"


# === Демонстрация ===

# 1. Равенство
a = Money(100)
b = Money(100)
c = Money(100, "USD")

print(a == b)   # True  (одинаковые сумма и валюта)
print(a == c)   # False (разные валюты)
print(a != b)   # False (автоматически из __eq__)

# 2. Порядок
prices = [Money(500), Money(100), Money(300), Money(200)]
print(sorted(prices))
# [Money(100 RUB), Money(200 RUB), Money(300 RUB), Money(500 RUB)]

print(min(prices))   # Money(100 RUB)
print(max(prices))   # Money(500 RUB)

# 3. Хеш и set
wallet = {Money(100), Money(200), Money(100), Money(300)}
print(wallet)    # {Money(100 RUB), Money(200 RUB), Money(300 RUB)} — дубликат убран!
print(len(wallet))  # 3

# 4. Ключи dict
exchange = {
    Money(1, "USD"): Money(90, "RUB"),
    Money(1, "EUR"): Money(100, "RUB"),
}
print(exchange[Money(1, "USD")])   # Money(90 RUB)

🎯 Обратите внимание на строку self.currency = currency.upper() в конструкторе. Мы сразу приводим валюту к верхнему регистру. Это гарантирует, что Money(100, "rub") и Money(100, "RUB") будут равны. Мелкая деталь, но важная — так объекты ведут себя предсказуемо.

📊 Сводная таблица: операторы сравнения

Оператор Метод По умолчанию Заметки
== __eq__ Сравнение по id (адресу) При переопределении — сбрасывается __hash__!
!= __ne__ Автоматически — не __eq__ Почти никогда не нужно определять вручную
< __lt__ TypeError Используй вместе с @total_ordering
> __gt__ TypeError Генерируется @total_ordering
<= __le__ TypeError Генерируется @total_ordering
>= __ge__ TypeError Генерируется @total_ordering

⚠️ Типичные ошибки и как их избежать

❌ Ошибка 1: Забыть про __hash__ после __eq__

# Плохо: определили __eq__, но не __hash__
class Tag:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return self.name == other.name
    # __hash__ не определён → Python сделал его None!

tags = {Tag("python")}   # TypeError: unhashable type: 'Tag'

# Хорошо: всегда добавляем __hash__
class Tag:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return self.name == other.name
    def __hash__(self):
        return hash(self.name)   # хешируем то же поле, что и в __eq__

❌ Ошибка 2: __eq__ и __hash__ используют разные поля

# Плохо: __eq__ сравнивает по (name, version),
# а __hash__ хеширует только name → контракт нарушен!
def __eq__(self, other):
    return self.name == other.name and self.version == other.version
def __hash__(self):
    return hash(self.name)  # ❌ разные поля!

# Хорошо: одинаковые поля в обоих методах
def __eq__(self, other):
    return self.name == other.name and self.version == other.version
def __hash__(self):
    return hash((self.name, self.version))  # ✅

❌ Ошибка 3: Применять @total_ordering без __eq__

# Плохо: забыли __eq__ — @total_ordering требует его обязательно
from functools import total_ordering

@total_ordering
class Product:
    def __init__(self, price):
        self.price = price
    def __lt__(self, other):
        return self.price < other.price
    # __eq__ не определён → TypeError при использовании

# Хорошо: обязательно оба метода
@total_ordering
class Product:
    def __init__(self, price):
        self.price = price
    def __eq__(self, other):          # ← обязателен
        return self.price == other.price
    def __lt__(self, other):          # ← хотя бы один из четырёх
        return self.price < other.price

✅ Итоги урока

  • По умолчанию == сравнивает объекты по адресу в памяти (id), а не по значению.
  • __eq__ переопределяет ==; __ne__ (!=) автоматически строится на его основе.
  • __lt__, __gt__, __le__, __ge__ — методы для <, >, <=, >=.
  • Декоратор @total_ordering из functools позволяет определить только __eq__ + один метод порядка, а остальные сгенерирует сам.
  • Критично: при переопределении __eq__ Python обнуляет __hash__ → объект нельзя положить в set или использовать ключом dict.
  • Правило: если определил __eq__ — всегда определяй __hash__ на основе тех же полей.
  • Контракт хеша: a == b → hash(a) == hash(b) (нарушать нельзя!).

👉 В следующем уроке мы изучим арифметические dunder-методы: __add__, __sub__, __mul__ и другие. Научим объекты складываться, вычитаться и умножаться с помощью привычных математических операторов — как будто они числа!