__eq__, __lt__ и операторы сравнения
Как Python решает, что один объект «равен» другому или «меньше»? По умолчанию — по адресу в памяти. Но мы можем это изменить. В этом уроке научимся сравнивать объекты так, как нам нужно: по значению, по сумме, по дате — по чему угодно. Плюс разберём коварный подводный камень с хешем.
🤔 Что происходит без наших методов: сравнение по 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__ и другие. Научим объекты складываться, вычитаться и умножаться с помощью привычных математических операторов — как будто они числа!