Перейти к содержанию
Educora
Продвинутый25 мин19 / 42

Продвинутое ООП: специальные методы, свойства и MRO

Сделай свои классы похожими на встроенные типы: `__repr__`/`__str__`, `__eq__`/`__lt__`/`__hash__`, перегрузка операторов, протокол контейнеров, `@property`, методы класса и статические методы, MRO, `super()` и `abc.ABC`.

Проверь себя
В этом уроке ты узнаешь
  • Правильно реализовывать __repr__, __str__, __eq__, __lt__, __hash__ и методы операторов
  • Добавлять проверки через @property и уместно использовать методы класса и статические методы
  • Объяснять MRO и работу super() при множественном наследовании
  • Создавать абстрактный базовый класс с abc.ABC

Почему + складывает числа, соединяет строки и склеивает списки? Почему len(), in, sorted() и print() работают с объектами совершенно разных типов? Потому что Python превращает каждую такую операцию в вызов специального метода: a + b становится a.__add__(b), а len(x) — x.__len__(). Их ещё называют dunder-методами (от англ. double underscore — двойное подчёркивание). Реализуй их в своём классе, и его объекты будут вести себя как встроенные типы. Этот набор правил называется моделью данных Python.

__repr__ и __str__: как объект представляется

Каждый класс наследует __repr__, который печатает что-то вроде <__main__.Money object at 0x7f…> — для отладки это бесполезно. __repr__ должен быть однозначным и адресован разработчикам; в идеале он выглядит как код, создающий объект. __str__ — читаемый вариант для пользователей. print() и f-строки используют __str__, а интерактивная консоль, списки и преобразование !r — __repr__. Если определён только __repr__, str() тоже использует его.

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

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

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

price = Money(12.5)
print(price)
print(repr(price))
print([price, Money(3, 'USD')])
print(f'{price} | {price!r}')
▸ Ожидаемый результат
12.50 AZN
Money(12.5, 'AZN')
[Money(12.5, 'AZN'), Money(3, 'USD')]
12.50 AZN | Money(12.5, 'AZN')

Равенство, порядок и хеширование

Без __eq__ оператор == сравнивает идентичность: два объекта с одинаковыми данными не равны. Если тип другого операнда не поддерживается, __eq__ должен не возбуждать ошибку, а вернуть особое значение **NotImplemented: тогда Python попробует отражённую операцию у другого объекта, а если и она не сработает, сравнит идентичность. Декоратор functools.total_ordering** выводит <=, > и >= из __eq__ и __lt__:

Python
from functools import total_ordering

@total_ordering
class Money:
    def __init__(self, amount, currency='AZN'):
        self.amount, self.currency = amount, currency

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

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

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

prices = [Money(5), Money(2.5), Money(10)]
print([m.amount for m in sorted(prices)])
print(Money(5) == Money(5), Money(5) >= Money(2.5), Money(5) == 5)
print(len({Money(1), Money(1)}))
▸ Ожидаемый результат
[2.5, 5, 10]
True True False
1

Перегрузка операторов и протокол контейнера

Арифметика работает так же. Для a + b Python сначала вызывает a.__add__(b); если тот вернул NotImplemented, пробуется b.__radd__(a) — отражённый метод. Поэтому для 2 * v ниже нужен __rmul__: int ничего не знает о векторах. __bool__ определяет смысл if v:, а __abs__ обслуживает встроенную abs():

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

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

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

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

    __rmul__ = __mul__

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self):
        return bool(self.x or self.y)

v = Vector(3, 4)
print(v + Vector(1, 1), v * 2, 2 * v)
print(abs(v), bool(Vector(0, 0)))
▸ Ожидаемый результат
Vector(4, 5) Vector(6, 8) Vector(6, 8)
5.0 False
ЗаписьВызываемый метод
a + b, a - b, a * b__add__, __sub__, __mul__ (__radd__, …)
a == b, a < b__eq__, __lt__
len(a), a[i], x in a__len__, __getitem__, __contains__
for x in a__iter__ (__getitem__)
abs(a), bool(a), hash(a)__abs__, __bool__, __hash__
str(a), repr(a), a()__str__, __repr__, __call__

Для a += b Python сначала ищет __iadd__ (вариант, который может изменить объект на месте), а если его нет, использует __add__. Поэтому += меняет список на месте, а для кортежей и строк создаёт новый объект. Метод __call__ позволяет вызывать объект как функцию: obj().

Класс может вести себя и как коллекция. __len__ обслуживает len(), __getitem__ — индексы и срезы (объект среза просто передаётся списку), а __contains__ — оператор in. Работает даже for: если у класса нет __iter__, Python вызывает __getitem__ с 0, 1, 2…, пока не появится IndexError:

Python
class Playlist:
    def __init__(self, *songs):
        self._songs = list(songs)

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

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

    def __contains__(self, song):
        return song.lower() in (s.lower() for s in self._songs)

p = Playlist('Morning', 'Rain', 'Finale')
print(len(p), p[0], p[-1], p[1:])
print('rain' in p)
for song in p:
    print('>', song)
▸ Ожидаемый результат
3 Morning Finale ['Rain', 'Finale']
True
> Morning
> Rain
> Finale

@property, @classmethod и @staticmethod

Свойство (property) снаружи выглядит как обычный атрибут, но при чтении или присваивании выполняется метод. Это позволяет позже добавить проверку, не меняя код, который использует класс: t.celsius = -300 — по-прежнему обычное присваивание. Заметь, что __init__ тоже проходит через сеттер, поэтому даже новый объект не может начать жизнь в неверном состоянии. Свойство без сеттера, как fahrenheit, доступно только для чтения и вычисляется на лету:

Python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError('below absolute zero')
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius, t.fahrenheit)
try:
    t.celsius = -300
except ValueError as e:
    print('error:', e)
▸ Ожидаемый результат
25 77.0
error: below absolute zero

Метод класса (@classmethod) получает не экземпляр, а сам класс в параметре cls. Главное его применение — альтернативные конструкторы вроде from_csv: поскольку метод вызывает cls(...), подкласс автоматически получает объекты своего типа. Статический метод (@staticmethod) не получает ни self, ни cls — это обычная функция, живущая в пространстве имён класса, потому что логически к нему относится. Обрати внимание и на атрибут класса count, общий для всех экземпляров:

Python
class Student:
    count = 0

    def __init__(self, name, grade):
        self.name, self.grade = name, grade
        Student.count += 1

    @classmethod
    def from_csv(cls, line):
        name, grade = line.split(',')
        return cls(name, int(grade))

    @staticmethod
    def is_valid_grade(grade):
        return 1 <= grade <= 11

    def __repr__(self):
        return f'{type(self).__name__}({self.name!r}, {self.grade})'

class Graduate(Student):
    pass

print(Student.from_csv('Aysel,9'), Graduate.from_csv('Murad,11'))
print(Student.count, Student.is_valid_grade(12))
▸ Ожидаемый результат
Student('Aysel', 9) Graduate('Murad', 11)
2 False

Наследование, MRO и super()

При множественном наследовании Python нужно правило поиска методов. Он вычисляет MRO (method resolution order — порядок разрешения методов) алгоритмом C3: потомок идёт раньше родителей, родители сохраняют порядок, в котором перечислены, и каждый класс встречается один раз. Его видно в __mro__. Главное: **super() означает не «мой родитель», а «следующий класс в MRO настоящего объекта»**:

Python
class Base:
    def save(self):
        print('Base.save')

class Logged(Base):
    def save(self):
        print('Logged: before')
        super().save()

class Validated(Base):
    def save(self):
        print('Validated: checking')
        super().save()

class Order(Logged, Validated):
    pass

print([c.__name__ for c in Order.__mro__])
Order().save()
▸ Ожидаемый результат
['Order', 'Logged', 'Validated', 'Base', 'object']
Logged: before
Validated: checking
Base.save

Logged напрямую наследуется от Base, но его super().save() вызвал Validated.save(), потому что именно он следующий в MRO класса Order. Благодаря этому Base.save() выполнился ровно один раз. Такие «сотрудничающие» классы, называемые миксинами (mixin), работают, только если каждый метод вызывает super() и ни один не вызывает родителя напрямую как Base.save(self).

Абстрактный базовый класс задаёт контракт, который обязаны выполнить подклассы. Экземпляр класса, унаследованного от abc.ABC и имеющего методы с пометкой @abstractmethod, нельзя создать, пока подкласс не реализует их все, — ошибка возникает при создании объекта, а не посреди работы:

Python
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self): ...

    def describe(self):
        return f'{type(self).__name__} with area {self.area():.2f}'

class Rect(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h

    def area(self):
        return self.w * self.h

print(Rect(3, 4.5).describe())
try:
    Shape()
except TypeError as e:
    print(e)
▸ Ожидаемый результат
Rect with area 13.50
Can't instantiate abstract class Shape without an implementation for abstract method 'area'
Задание

Добавь в класс Money методы __add__, __radd__ и __eq__, чтобы можно было складывать суммы, суммировать список через sum() и сравнивать результат. Для чужих типов __add__ должен возвращать NotImplemented.

Задание · Python
class Money:
    def __init__(self, amount):
        self.amount = amount

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

    # add __add__, __radd__ and __eq__ here

wallet = [Money(5), Money(10), Money(2)]
print(Money(1) + Money(2))
print(sum(wallet))
print(sum(wallet) == Money(17))
▸ Ожидаемый результат
Money(3)
Money(17)
True
Задание

Добавь в класс Stack методы __len__ и __repr__. Отдельный метод для bool(s) не пиши — пустой стек сам должен считаться False.

Задание · Python
class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

    # add __len__ and __repr__ here

s = Stack()
print(bool(s))
s.push('a')
s.push('b')
print(s, len(s), bool(s))
print(s.pop(), s)
▸ Ожидаемый результат
False
Stack(['a', 'b']) 2 True
b Stack(['a'])

Главное

  • Операторы и встроенные функции вызывают специальные методы: a + b → __add__, len(x) → __len__, x in c → __contains__.
  • __repr__ — для разработчиков, __str__ — для пользователей; контейнеры показывают __repr__.
  • __eq__ для чужих типов должен возвращать NotImplemented; определение __eq__ убирает __hash__, если не добавить его явно.
  • @property прячет проверку за синтаксисом атрибута; @classmethod получает cls (альтернативные конструкторы), @staticmethod — ничего.
  • super() следует MRO настоящего объекта; абстрактные методы ABC должны быть реализованы до создания экземпляра.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Какой метод использует print([obj]), чтобы показать объект внутри списка?