Dunder-методы: repr, eq, hash, len, iter

Dunder-методы (double underscore - __method__) это специальные методы, которые Python вызывает автоматически в определённых контекстах. Они позволяют твоему классу интегрироваться с языком: работать с операторами, встроенными функциями, циклами и многим другим. В этом уроке - самые важные dunder-методы.

Общая идея

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

m1 = Money(100)
m2 = Money(50)
m1 + m2          # TypeError - + не определён для Money

# Добавляем __add__
class Money:
    def __init__(self, amount):
        self.amount = amount
    def __add__(self, other):
        return Money(self.amount + other.amount)

m1 + m2          # Money(150)

+ вызывает __add__. Аналогично == вызывает __eq__, len() вызывает __len__, for x in obj использует __iter__. Реализуя dunder-методы, ты «учишь» класс работать со стандартными конструкциями Python.

repr и str

__repr__ - официальное представление, для отладки:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    def __repr__(self):
        return f"User(name={self.name!r}, age={self.age})"

u = User("Alice", 30)
print(repr(u))   # User(name='Alice', age=30)
print(u)         # User(name='Alice', age=30) - использует __repr__ если __str__ нет

__str__ - человекочитаемое представление, для конечного пользователя:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    def __repr__(self):
        return f"User(name={self.name!r}, age={self.age})"
    def __str__(self):
        return f"{self.name} ({self.age})"

u = User("Alice", 30)
print(str(u))    # Alice (30)
print(repr(u))   # User(name='Alice', age=30)
print(u)         # Alice (30) - print использует __str__

Конвенция: __repr__ должен (по возможности) возвращать строку которая в eval() даст эквивалентный объект. __str__ для humans.

Минимум: всегда определяй __repr__, __str__ опционально.

eq и hash

По умолчанию == сравнивает по identity (адресу в памяти):

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

Point(1, 2) == Point(1, 2)   # False - разные объекты

С __eq__ - по значению:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

Point(1, 2) == Point(1, 2)   # True

Важно: определяя __eq__, ты автоматически отключаешь __hash__ - объект становится unhashable:

{Point(1, 2)}   # TypeError - unhashable

Чтобы вернуть hashable, реализуй __hash__:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y
    def __hash__(self):
        return hash((self.x, self.y))

{Point(1, 2), Point(1, 2), Point(3, 4)}   # {Point(1,2), Point(3,4)}
Если `a == b`, то `hash(a) == hash(b)`. Это invariant.

Обратное не обязательно: hash(a) == hash(b) не значит a == b (могут быть hash collisions).

Если меняется ключевое для eq поле, нужно либо избегать использования объекта в set и dict, либо делать класс immutable.

lt, le, gt, ge - сравнение

Для сортировки:

class Version:
    def __init__(self, major, minor, patch):
        self.major, self.minor, self.patch = major, minor, patch

    def _key(self):
        return (self.major, self.minor, self.patch)

    def __lt__(self, other):
        return self._key() < other._key()
    def __eq__(self, other):
        return self._key() == other._key()

    def __repr__(self):
        return f"v{self.major}.{self.minor}.{self.patch}"

versions = [Version(1, 2, 3), Version(2, 0, 0), Version(1, 10, 0)]
print(sorted(versions))   # [v1.2.3, v1.10.0, v2.0.0]

sorted и min/max используют __lt__. Для полного набора операторов используется @functools.total_ordering:

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor, patch):
        self.major, self.minor, self.patch = major, minor, patch

    def __eq__(self, other):
        return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
    def __lt__(self, other):
        return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

# total_ordering генерирует __le__, __gt__, __ge__, __ne__ из __eq__ и __lt__

len

Поддержка len():

class Inbox:
    def __init__(self):
        self.messages = []

    def __len__(self):
        return len(self.messages)

inbox = Inbox()
inbox.messages = ["msg1", "msg2", "msg3"]
print(len(inbox))   # 3

__len__ также делает объект truthy/falsy: пустой считается False:

if not inbox:
    print("Пусто")

Если хочется явно управлять truthiness без __len__, используется __bool__:

class Connection:
    def __init__(self):
        self.is_open = False
    def __bool__(self):
        return self.is_open

getitem, setitem, delitem, contains

Доступ как к коллекции:

class WordList:
    def __init__(self, words):
        self.words = words

    def __getitem__(self, index):
        return self.words[index]

    def __setitem__(self, index, value):
        self.words[index] = value

    def __delitem__(self, index):
        del self.words[index]

    def __contains__(self, value):
        return value in self.words

    def __len__(self):
        return len(self.words)

wl = WordList(["apple", "banana", "cherry"])
print(wl[0])         # apple - __getitem__
wl[0] = "avocado"    # __setitem__
del wl[1]            # __delitem__
print("cherry" in wl) # True - __contains__
print(len(wl))       # 2

С __getitem__ и __len__ объект становится sequence-подобным. Можно делать for item in wl - даже без __iter__.

iter и next

Полноценная итерация:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        result = self.current
        self.current -= 1
        return result

for x in Countdown(3):
    print(x)   # 3, 2, 1

__iter__ возвращает iterator (часто self). __next__ возвращает следующее значение или бросает StopIteration для остановки.

Часто проще через генератор:

class Countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        n = self.start
        while n > 0:
            yield n
            n -= 1

for x in Countdown(3):
    print(x)

Генераторы создают итератор автоматически. Подробно - в уроках про итераторы и генераторы.

call - объект как функция

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, x):
        return x * self.factor

double = Multiplier(2)
print(double(5))    # 10
print(double(7))    # 14

После __call__ инстанс ведёт себя как функция. Полезно для:

enter и exit - context manager

class Timer:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        import time
        self.elapsed = time.perf_counter() - self.start
        print(f"Прошло: {self.elapsed:.4f}s")

with Timer() as t:
    sum(x ** 2 for x in range(1_000_000))
# Прошло: 0.05s

Подробно про context managers - в уроке 16.

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

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):   # scalar * vector
        return self.__mul__(scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)    # Vector(4, 6)
print(v1 * 3)     # Vector(3, 6)
print(3 * v1)     # Vector(3, 6) - через __rmul__

Полный список арифметических dunder:

ОператорМетод
+__add__, __radd__
-__sub__, __rsub__
*__mul__, __rmul__
/__truediv__, __rtruediv__
//__floordiv__
%__mod__
**__pow__
+=__iadd__ (или fallback на __add__)

__r<op>__ (reflected) вызывается когда левый операнд не реализует операцию. __i<op>__ (inplace) для +=, -= и т.д.

NotImplemented

Когда операция не определена для конкретных типов:

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

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            return NotImplemented
        return Money(self.amount + other.amount, self.currency)

Возврат NotImplemented (не NotImplementedError) говорит Python попробовать reflected-метод другого операнда. Если оба вернули - получишь TypeError.

Распространённые ошибки

1. eq без hash

class User:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return self.name == other.name

users = {User("Alice"), User("Bob")}   # TypeError - unhashable

Реши - либо тоже определи __hash__, либо явно __hash__ = None (явная неhashability).

2. Mutable объект как ключ dict

class MutableUser:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return self.name == other.name
    def __hash__(self):
        return hash(self.name)

u = MutableUser("Alice")
d = {u: "value"}
u.name = "Bob"      # ОПАСНО - hash изменился!
print(u in d)       # False - ключ "потерян" в hash-таблице

Hashable объекты должны быть immutable (или хотя бы не меняться по hash-зависимым полям).

3. init возвращает значение

class A:
    def __init__(self):
        return 5    # TypeError: __init__() should return None

__init__ всегда возвращает None.

Полный список dunder-методов

Часто используемые:

МетодКонтекст
__init__конструктор
__repr__repr(), отладка
__str__str(), print()
__eq__, __hash__сравнение и hash
__lt__, __le__, __gt__, __ge__сравнение
__len__len(), truthiness
__bool__bool()
__getitem__, __setitem__, __delitem__индексация
__iter__, __next__итерация
__contains__оператор in
__call__вызов как функция
__enter__, __exit__context manager
__add__, __sub__, __mul__ etcарифметика
__getattr__, __setattr__доступ к атрибутам

Менее частые: __new__, __del__, __copy__, __deepcopy__, __reduce__ (pickle).

Мини-задание

  1. Класс Money с операторами:
class Money:
    def __init__(self, amount, currency="RUB"):
        self.amount = amount
        self.currency = currency

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

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

    def __hash__(self):
        return hash((self.amount, self.currency))

    def __add__(self, other):
        if not isinstance(other, Money) or self.currency != other.currency:
            return NotImplemented
        return Money(self.amount + other.amount, self.currency)

a = Money(100)
b = Money(50)
c = Money(100, "USD")
print(a + b)     # Money(150, 'RUB')
print(a == Money(100))   # True
# print(a + c)   # TypeError - разные валюты
  1. Итерируемая коллекция:
class FibonacciSequence:
    def __init__(self, count):
        self.count = count

    def __iter__(self):
        a, b = 0, 1
        for _ in range(self.count):
            yield a
            a, b = b, a + b

for n in FibonacciSequence(10):
    print(n, end=" ")
# 0 1 1 2 3 5 8 13 21 34
  1. Объект как функция:
class Pipeline:
    def __init__(self, *functions):
        self.functions = functions

    def __call__(self, value):
        for fn in self.functions:
            value = fn(value)
        return value

pipeline = Pipeline(
    lambda x: x.strip(),
    lambda x: x.lower(),
    lambda x: x.replace(" ", "-"),
)
print(pipeline("  Hello World  "))   # hello-world

Что дальше

Освоили dunder-методы - класс теперь интегрирован с языком. В следующем уроке - наследование и MRO: как один класс наследует другой, что такое multiple inheritance и алгоритм C3 для разрешения порядка методов.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.