Python ООП в Python: классы, наследование, MRO, дескрипторы, dataclasses
0%

ООП в Python: классы, наследование, MRO, дескрипторы, dataclasses

ООП в Python: классы, наследование, MRO, дескрипторы, dataclasses

Если вы пришли из Java или C#, у вас есть модель «класс — это чертёж, компилятор раскладывает поля по смещениям, интерфейс — контракт, проверяемый на этапе сборки». В Python почти ничего из этого не верно. Здесь класс — это обычный объект, живущий в памяти рядом с остальными, атрибуты — записи в словаре, а «интерфейс» по умолчанию существует только в голове разработчика. Поэтому синтаксис class выучивается за десять минут, а грабли собираются годами.

Эта статья — про модель исполнения. Мы разберём, что происходит в момент выполнения class, по какому алгоритму интерпретатор ищет obj.name, почему super() — не «вызов родителя», как из одного протокола дескрипторов вырастают property, методы, __slots__ и cached_property, и когда вместо всей этой машинерии достаточно @dataclass и функции.

Азы («что такое класс и объект») здесь не повторяются — они разобраны в курсе Программирование с нуля. Понимание того, что переменная хранит ссылку, а не значение, берём из модели данных; замыкания, декораторы и области видимости — из функций и областей видимости.

Класс — это выполняемый код и объект в памяти

Инструкция class не «объявляет тип». Она делает три вещи: выполняет тело как обычный блок кода в отдельном пространстве имён, собирает получившийся словарь и передаёт его метаклассу (по умолчанию type), который создаёт объект класса и связывает его с именем.

class Dog:
    """Собака. Тело класса — обычный код, выполняется один раз при импорте модуля."""
    kind = "млекопитающее"          # атрибут класса: один объект на всех экземпляров

    def __init__(self, name: str) -> None:
        self.name = name            # атрибут экземпляра: свой у каждого объекта

print(type(Dog))            # <class 'type'> — класс сам является объектом
print(Dog.__mro__)          # (<class '__main__.Dog'>, <class 'object'>)
print(Dog.__dict__["kind"]) # млекопитающее

То же самое руками, без синтаксического сахара:

def _init(self, name: str) -> None:
    self.name = name

Dog2 = type("Dog2", (object,), {"kind": "млекопитающее", "__init__": _init})
print(Dog2("Рекс").name)    # Рекс

Из «тело класса — это код» следуют неочевидные вещи. Например, внутри тела можно считать значения, ветвиться, и даже случайно напороться на область видимости:

class Broken:
    SIZES = [1, 2, 3]
    DOUBLED = [s * 2 for s in SIZES]        # работает: SIZES — первый итерируемый объект
    FACTOR = 10
    SCALED = [s * FACTOR for s in SIZES]    # NameError: name 'FACTOR' is not defined

Comprehension создаёт собственную функцию-область, а область видимости класса в цепочку поиска имён не входит (кроме первого выражения-итератора, которое вычисляется снаружи). Это классическая ловушка при описании конфигов и enum-подобных классов; лечится вынесением констант на уровень модуля.

Где живут данные: __dict__ экземпляра и __dict__ класса

Экземпляр хранит только свои данные. Всё остальное — методы, константы, свойства — лежит в классе и его предках. Присваивание obj.x = ... не «меняет поле», а создаёт запись в словаре экземпляра:

class A:
    x = "из класса"

a = A()
print(a.x)              # из класса — своего x нет, взяли из класса
a.x = "из экземпляра"   # создали запись в a.__dict__, класс не тронут
print(a.x, A.x)         # из экземпляра из класса
print(a.__dict__)       # {'x': 'из экземпляра'}
del a.x
print(a.x)              # снова «из класса»

Полный алгоритм поиска (то, что делает object.__getattribute__) выглядит так:

Поиск атрибута: экземпляр, класс и MRO

Этот порядок объясняет 90% «магии»: почему property перекрывает данные экземпляра, почему cached_property считает значение один раз, почему __getattr__ не вызывается для существующих атрибутов и почему дескриптор, положенный в экземпляр, не работает.

Грабли №1: изменяемый атрибут класса

Самая частая ошибка новичка в промышленном коде:

class Basket:
    items = []                        # ГРАБЛИ: список один на все корзины

    def add(self, item: str) -> None:
        self.items.append(item)       # мутируем общий объект класса!

b1, b2 = Basket(), Basket()
b1.add("яблоко")
print(b2.items)                       # ['яблоко'] — «чужой» товар в чужой корзине

self.items.append(...) не создаёт запись в b1.__dict__: имя ищется по правилам выше, находится в классе, и мутируется общий список. Правило: изменяемое состояние инициализируется в __init__, атрибуты класса оставляем неизменяемым константам.

class Basket:
    def __init__(self) -> None:
        self.items: list[str] = []    # свой список у каждого экземпляра

Та же природа у знаменитой ловушки с изменяемым значением по умолчанию у аргумента функции — она разбирается в функциях и областях видимости.

__slots__: когда словарь на экземпляр слишком дорог

Если объектов миллионы (парсинг логов, узлы графа, тики котировок), словарь на каждый экземпляр становится заметной статьёй расходов.

Раскладка экземпляра: обычный класс против slots

class Vec:
    __slots__ = ("x", "y")            # фиксированный набор атрибутов

    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y

v = Vec(1.0, 2.0)
v.z = 3.0        # AttributeError: 'Vec' object has no attribute 'z'

Что важно знать про __slots__ до того, как вы его добавите:

  • каждый слот — это data-дескриптор в классе, отсюда и запрет на новые атрибуты, и небольшой выигрыш в скорости доступа;
  • наследник, не объявивший __slots__, снова получает __dict__ — экономия исчезает;
  • множественное наследование от двух классов с непустыми __slots__ запрещено (TypeError: multiple bases have instance lay-out conflict);
  • пропадает weakref, если не добавить "__weakref__" в слоты; ломается functools.cached_property (ей нужен __dict__);
  • выигрыш имеет смысл мерить, а не предполагать: см. производительность.

Методы, classmethod, staticmethod

Метод — это просто функция в теле класса. Магия «откуда берётся self» — не в синтаксисе, а в том, что функции реализуют протокол дескрипторов:

class Counter:
    def inc(self, by: int = 1) -> int:
        return by

c = Counter()
print(Counter.inc)                      # <function Counter.inc at 0x...>
print(c.inc)                            # <bound method Counter.inc of <__main__.Counter ...>>
print(c.inc.__self__ is c)              # True
print(Counter.__dict__["inc"].__get__(c, Counter))  # тот же bound method

c.inc — не хранимое поле, а объект, создаваемый на лету: function.__get__ возвращает «связанный метод», который при вызове подставит c первым аргументом. Отсюда же дешёвая, но реальная стоимость: каждое обращение к методу в горячем цикле создаёт временный объект — поэтому в тесных циклах метод часто «вытаскивают» в локальную переменную.

classmethod получает класс, staticmethod — ничего. Практический критерий: если метод создаёт экземпляр — это classmethod (и внутри обязательно cls, а не имя класса, иначе наследники сломаются):

from decimal import Decimal
from typing import Self          # Python 3.11+; раньше — строковая аннотация

class Money:
    __slots__ = ("amount", "currency")

    def __init__(self, amount: Decimal, currency: str) -> None:
        self.amount, self.currency = amount, currency

    @classmethod
    def parse(cls, text: str) -> Self:
        raw, currency = text.split()
        return cls(Decimal(raw), currency)   # cls, а не Money!

    @staticmethod
    def is_supported(currency: str) -> bool:
        return currency in {"RUB", "USD", "EUR"}

class Salary(Money):
    pass

print(type(Salary.parse("1000 RUB")).__name__)   # Salary

Если бы в parse стояло return Money(...), Salary.parse возвращала бы Money — тихий баг, который всплывает через полгода.

__new__ против __init__

__new__ создаёт объект, __init__ его настраивает. Разделять их нужно редко, но для неизменяемых типов иначе нельзя:

class UpperStr(str):
    """Наследник str: значение задаётся при создании, в __init__ менять уже нечего."""
    def __new__(cls, value: str) -> "UpperStr":
        return super().__new__(cls, value.upper())

print(UpperStr("привет"))       # ПРИВЕТ

Из диаграммы виден нюанс: если __new__ вернул объект не того класса, __init__ вообще не вызовется. На этом основан классический «синглтон через __new__» — и на этом же он регулярно ломается, потому что __init__ при каждом вызове класса всё-таки отработает заново, затирая состояние.

Наследование и MRO: почему super() — это не «родитель»

Python допускает множественное наследование, поэтому «позвать метод родителя» — недостаточно определённая операция. Вместо этого у каждого класса есть MRO (method resolution order) — линейный порядок обхода, вычисляемый алгоритмом C3-линеаризации.

class Base:
    def hello(self) -> None:
        print("Base")

class A(Base):
    def hello(self) -> None:
        print("A"); super().hello()

class B(Base):
    def hello(self) -> None:
        print("B"); super().hello()

class C(A, B):
    pass

C().hello()
# A
# B
# Base
print([k.__name__ for k in C.__mro__])   # ['C', 'A', 'B', 'Base', 'object']

Обратите внимание: super().hello() внутри A позвал B, хотя B вообще не предок A. super() означает «следующий класс в MRO того объекта, который сейчас обрабатывается», а не «мой базовый класс». Это и есть кооперативное множественное наследование: каждый класс делает свою часть работы и передаёт эстафету дальше.

Как считается C3 вручную

Формула: L[C] = C + merge(L[B1], ..., L[Bn], [B1, ..., Bn]), где merge на каждом шаге берёт голову первого списка, которая не встречается в хвосте ни одного из оставшихся списков.

Для примера выше:

L[A] = [A, Base, object]
L[B] = [B, Base, object]
L[C] = C + merge([A, Base, object], [B, Base, object], [A, B])
     шаг 1: голова A — в хвостах не встречается → берём A
     шаг 2: голова Base — есть в хвосте [B, Base, object] → пропускаем,
            берём голову следующего списка: B → берём B
     шаг 3: остаётся Base, затем object
L[C] = [C, A, B, Base, object]

Два свойства, ради которых всё затевалось: потомок всегда идёт перед предком, и порядок базовых классов в объявлении сохраняется. Если совместить их невозможно, класс просто не создастся:

class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class C(A, B): pass
# TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B

Каноническое описание алгоритма — The Python 2.3 Method Resolution Order, а лучшее объяснение кооперативного super() — статья Рэймонда Хеттингера Python’s super() considered super!.

Кооперативные миксины: рецепт, который работает

Чтобы множественное наследование не превращалось в лотерею, соблюдайте дисциплину: все участники цепочки принимают **kwargs, пробрасывают их в super().__init__() и объявляют собственные параметры как keyword-only.

from datetime import datetime, timezone

class Base:
    def __init__(self, **kwargs) -> None:
        # конец цепочки: object.__init__ лишних аргументов не принимает
        super().__init__(**kwargs)

class Timestamped:
    def __init__(self, *, created_at: datetime | None = None, **kwargs) -> None:
        super().__init__(**kwargs)      # ВСЕГДА пробрасываем дальше по MRO
        self.created_at = created_at or datetime.now(timezone.utc)

class Named:
    def __init__(self, *, name: str, **kwargs) -> None:
        super().__init__(**kwargs)
        self.name = name

class Document(Timestamped, Named, Base):
    pass

d = Document(name="отчёт")
print(d.name, d.created_at.tzinfo)               # отчёт UTC
print([k.__name__ for k in Document.__mro__])
# ['Document', 'Timestamped', 'Named', 'Base', 'object']

Если один миксин «забудет» вызвать super().__init__(), цепочка молча оборвётся и часть атрибутов не появится — а падать будет в другом месте и через час. Это главный аргумент против глубокого множественного наследования в продуктовом коде.

Правило, проверенное практикой: наследование — для «является», композиция — для «использует». Миксин допустим, когда он не хранит состояния и не претендует на __init__. Подробнее о критериях — в треке принципов разработки и паттернов проектирования.

ABC, протоколы и утиная типизация

В Python есть три разных способа задать контракт, и они решают разные задачи.

1. Утиная типизация — ничего не объявляем, просто вызываем нужные методы. Быстро, гибко, ошибки — в рантайме.

2. ABC (abc.ABC) — номинальный контракт с проверкой при создании экземпляра:

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send(self, to: str, text: str) -> None:
        """Отправить сообщение. Наследник обязан реализовать."""

    def send_all(self, recipients: list[str], text: str) -> None:
        # шаблонный метод: общий алгоритм поверх абстрактной операции
        for r in recipients:
            self.send(r, text)

class EmailNotifier(Notifier):
    def send(self, to: str, text: str) -> None:
        print(f"[email] {to}: {text}")

Notifier()        # TypeError: нельзя создать экземпляр абстрактного класса
EmailNotifier().send_all(["a@b.c"], "привет")

Важно: @abstractmethod проверяется только в момент инстанцирования и только метаклассом ABCMeta. Абстрактность — не статическая гарантия, а рантайм-ассерт. Если совмещаете с property, порядок декораторов такой: сверху @property, снизу @abstractmethod.

3. typing.Protocol — структурный контракт, проверяемый статически (mypy/pyright) и не требующий наследования. Это самый «питонический» способ описать порт в архитектуре: реализация ничего не знает про интерфейс. Детали, включая runtime_checkable и вариантность, — в статье про аннотации типов.

Практический выбор: Protocol — для границ между слоями (Repository, Clock, Sender), ABC — когда нужен общий код и шаблонные методы, isinstance — только там, где действительно нужно ветвление по типу.

Дескрипторы: один протокол, из которого выросло всё

Дескриптор — объект, который умеет перехватывать доступ к атрибуту, будучи положенным в класс. Протокол: __get__, __set__, __delete__, а также __set_name__ (вызывается автоматически при создании класса-владельца, PEP 487).

  • data-дескриптор — есть __set__ или __delete__. Приоритет выше словаря экземпляра. Так работают property и слоты.
  • non-data дескриптор — только __get__. Приоритет ниже словаря экземпляра. Так работают функции (отсюда bound methods) и functools.cached_property.

Разницу видно на пятнадцати строках:

class NonData:
    def __get__(self, obj, objtype=None):
        return "из дескриптора"

class WithSet(NonData):
    def __set__(self, obj, value):        # наличие __set__ делает его data-дескриптором
        raise AttributeError("только чтение")

class C:
    plain = NonData()
    guarded = WithSet()

c = C()
print(c.plain)                     # из дескриптора
c.__dict__["plain"] = "из словаря"
print(c.plain)                     # из словаря — non-data проигрывает __dict__
c.__dict__["guarded"] = "из словаря"
print(c.guarded)                   # из дескриптора — data-дескриптор выигрывает

Практическое применение — переиспользуемая валидация полей без копипасты:

class Positive:
    """Data-дескриптор: следит, чтобы значение было положительным."""

    def __set_name__(self, owner: type, name: str) -> None:
        # вызывается один раз при создании класса-владельца
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self                       # обращение через класс: Order.qty
        return getattr(obj, self.private_name)

    def __set__(self, obj, value: int) -> None:
        if value <= 0:
            raise ValueError(f"{self.private_name[1:]} должно быть > 0, получено {value!r}")
        setattr(obj, self.private_name, value)

class Order:
    qty = Positive()
    price = Positive()

    def __init__(self, qty: int, price: int) -> None:
        self.qty = qty        # проходит через Positive.__set__
        self.price = price

o = Order(2, 100)
print(o.qty)                  # 2
o.qty = -1                    # ValueError: qty должно быть > 0, получено -1

Главные грабли дескрипторов: они работают, только когда лежат в классе. Положите Positive() в self.qty внутри __init__ — и получите обычный объект без всякой валидации, потому что object.__getattribute__ ищет дескрипторы в типе.

Обязательное чтение по теме — официальный Descriptor HowTo Guide: там же показана реализация property, classmethod и staticmethod на чистом Python.

property: вычисляемые атрибуты без Java-геттеров

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

import math

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius            # публичный атрибут — и это нормально

    @property
    def area(self) -> float:
        """Дешёвое вычисляемое свойство без побочных эффектов."""
        return math.pi * self.radius ** 2

    @property
    def diameter(self) -> float:
        return self.radius * 2

    @diameter.setter
    def diameter(self, value: float) -> None:
        if value <= 0:
            raise ValueError("диаметр должен быть > 0")
        self.radius = value / 2

c = Circle(1.0)
print(round(c.area, 4))     # 3.1416
c.diameter = 10
print(c.radius)             # 5.0

Правило хорошего тона: property должна быть дешёвой и предсказуемой. Если за точкой прячется запрос в базу или сетевой вызов — делайте явный метод load_x(), иначе отладка превращается в археологию: невинное repr(obj) внезапно ходит в сеть.

Для дорогих, но неизменных вычислений есть cached_property:

from functools import cached_property

class Report:
    def __init__(self, rows: list[int]) -> None:
        self.rows = rows

    @cached_property
    def total(self) -> int:
        print("считаем...")            # выполнится ровно один раз
        return sum(self.rows)

r = Report([1, 2, 3])
print(r.total)     # считаем...  →  6
print(r.total)     # 6 — значение уже лежит в r.__dict__["total"]
del r.total        # сброс кэша

Работает это ровно потому, что cached_property — non-data дескриптор: он один раз пишет результат в __dict__ экземпляра, а дальше словарь экземпляра выигрывает поиск. Следствие: с __slots__ она несовместима, а если объект мутирует — кэш протухнет молча.

dataclasses: 90% классов в проде выглядят так

Большинство классов в реальном коде — это «данные плюс немного поведения». Писать для них __init__, __repr__ и __eq__ руками — потеря времени и источник рассинхрона. dataclasses (PEP 557, Python 3.7+) генерирует их из аннотаций.

from dataclasses import dataclass, field, replace
from decimal import Decimal

@dataclass(frozen=True, slots=True, kw_only=True)
class OrderLine:
    sku: str
    qty: int = 1
    price: Decimal = Decimal("0")
    tags: tuple[str, ...] = ()        # неизменяемый дефолт — так можно

    def __post_init__(self) -> None:
        # проверка инвариантов сразу после инициализации
        if self.qty <= 0:
            raise ValueError("qty должно быть > 0")

    @property
    def total(self) -> Decimal:
        return self.price * self.qty

line = OrderLine(sku="A-1", qty=2, price=Decimal("199.90"))
print(line)          # OrderLine(sku='A-1', qty=2, price=Decimal('199.90'), tags=())
print(line.total)    # 399.80
new = replace(line, qty=3)   # «изменение» неизменяемого = новый объект
line.qty = 5                 # dataclasses.FrozenInstanceError

Что означают параметры, которые стоит ставить осознанно:

Параметр Что делает Когда включать
frozen=True запрещает присваивание, генерирует __hash__ value-объекты, ключи словарей, конфиги
slots=True (3.10+) добавляет __slots__, убирает __dict__ много экземпляров, важна память
kw_only=True (3.10+) все поля только по имени 4+ полей, защита от перепутанных аргументов
order=True генерирует <, <=, >, >= по кортежу полей сортировка, приоритетные очереди
eq=False оставляет сравнение по идентичности entity с собственным id

Тонкая настройка отдельных полей — через field():

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass(order=True)
class Task:
    priority: int
    title: str = field(compare=False)                       # не участвует в сравнении
    tags: list[str] = field(default_factory=list)           # изменяемый дефолт — только так
    _cache: dict = field(default_factory=dict, repr=False, compare=False)
    created_by: str = field(default="system", metadata={"doc": "кто создал"})
    total_created: ClassVar[int] = 0                        # атрибут класса, НЕ поле

Грабли dataclasses

@dataclass
class Cart:
    items: list[str] = []
# ValueError: mutable default <class 'list'> for field items is not allowed:
#             use default_factory

Здесь Python спасает вас явной ошибкой — но только для известных ему изменяемых типов (list, dict, set). Свой изменяемый класс он пропустит, и вы получите общий объект на все экземпляры, как в примере с корзиной выше.

Остальное, о чём стоит помнить:

  • frozen=Trueповерхностная заморозка: line.tags менять нельзя, но если внутри окажется list, его содержимое изменяемо. Для value-объектов используйте кортежи.
  • eq=True без frozen=True обнуляет __hash__ (объект становится нехешируемым) — это правильно, но неожиданно для тех, кто клал объекты в set.
  • slots=True создаёт новый объект класса: декораторы-регистраторы, захватившие класс до @dataclass, будут ссылаться на старый; исторически ломался и super() без аргументов внутри методов. Проверяйте на своей версии.
  • asdict() рекурсивно копирует всё дерево — на горячем пути это дорого; для сериализации часто быстрее написать явный метод или взять pydantic.
  • Наследование dataclass’ов складывает поля родителя перед полями потомка: добавить поле без значения по умолчанию после унаследованного поля с дефолтом невозможно (спасает kw_only=True).

Чем это отличается от NamedTuple, attrs и pydantic

Инструмент Валидация в рантайме Изменяемость Когда брать
dict нет да «сырые» данные на границе, короткоживущие структуры
NamedTuple нет нет лёгкие кортежи с именами, распаковка, совместимость с tuple-API
TypedDict нет (только статически) да описание формы JSON без создания объектов
@dataclass только своя в __post_init__ настраивается дефолт для доменных типов и конфигов
attrs валидаторы, конвертеры настраивается нужны валидаторы/__slots__/старые версии Python
pydantic v2 полная, с приведением типов да границы системы: HTTP-запросы, конфиги из env, JSON

Практический рецепт для продакшена: pydantic — на границе (парсинг входных данных, см. веб и API), dataclassвнутри домена, чтобы ядро не зависело от библиотеки валидации. Про раскладку по слоям — в статье об архитектуре.

Хуки создания класса: __init_subclass__ и метаклассы

До PEP 487 регистрация плагинов требовала метакласса. Сегодня в большинстве случаев хватает __init_subclass__ — метода, который вызывается при создании каждого наследника:

class Handler:
    registry: dict[str, type["Handler"]] = {}

    def __init_subclass__(cls, /, key: str, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if key in Handler.registry:
            raise ValueError(f"дубликат обработчика: {key}")
        Handler.registry[key] = cls

    def handle(self, payload: bytes) -> None:
        raise NotImplementedError

class JsonHandler(Handler, key="json"):
    def handle(self, payload: bytes) -> None: ...

class CsvHandler(Handler, key="csv"):
    def handle(self, payload: bytes) -> None: ...

print(sorted(Handler.registry))     # ['csv', 'json']

Метакласс нужен, когда требуется изменить сам процесс создания класса или поведение класса как объекта (например, перехватить его вызов):

class Singleton(type):
    _instances: dict[type, object] = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Settings(metaclass=Singleton):
    def __init__(self) -> None:
        self.debug = False

print(Settings() is Settings())      # True

Честная оценка: за пределами фреймворков (Django ORM, SQLAlchemy declarative, enum, ABCMeta) метаклассы почти всегда — переусложнение. Они конфликтуют при множественном наследовании, путают IDE и mypy, и почти всё, что делают, достигается декоратором класса или __init_subclass__. Синглтон из примера в реальном коде обычно заменяется модульным объектом или functools.lru_cache над фабрикой — и легко подменяется в тестах.

Инкапсуляция: соглашения вместо запретов

Приватности в Python нет — есть договорённости.

  • _name — «внутреннее, не трогайте». Полностью на совести читателя, но линтеры и IDE это уважают.
  • __name (два подчёркивания в начале) — name mangling: внутри тела класса имя превращается в _ClassName__name. Это не защита от доступа, а защита от случайного пересечения имён в иерархии.
class A:
    def __init__(self) -> None:
        self.__x = 1          # реально сохранится как _A__x

class B(A):
    def __init__(self) -> None:
        super().__init__()
        self.__x = 2          # реально _B__x — НЕ перезаписывает атрибут родителя

b = B()
print(b.__dict__)             # {'_A__x': 1, '_B__x': 2}
print(b._A__x)                # 1 — «приватность» обходится тривиально

Использовать __x стоит только в базовых классах библиотек, где вы реально боитесь коллизий с наследниками. В прикладном коде достаточно одного подчёркивания.

Типичные ошибки, которые ловят на ревью

__eq__ без __hash__. Определив __eq__, вы получаете __hash__ = None:

class P:
    def __init__(self, x: int) -> None:
        self.x = x
    def __eq__(self, other: object) -> bool:
        return isinstance(other, P) and self.x == other.x

{P(1)}          # TypeError: unhashable type: 'P'

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

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, P):
            return NotImplemented        # даём шанс правому операнду
        return self.x == other.x

    def __hash__(self) -> int:
        return hash((type(self), self.x))

Специальные методы ищутся на типе, а не на экземпляре.

class Weird:
    pass

w = Weird()
w.__len__ = lambda: 5
len(w)          # TypeError: object of type 'Weird' has no len()

Патчить dunder-методы поэкземплярно нельзя — интерпретатор ищет их в типе (это называется implicit special method lookup и описано в Data model).

Наследование от dict/list вместо UserDict/UserList.

class MyDict(dict):
    def __setitem__(self, k, v):
        super().__setitem__(k, v.upper())

d = MyDict()
d["a"] = "x"
d.update(b="y")     # update реализован на C и НЕ зовёт наш __setitem__
print(d)            # {'a': 'X', 'b': 'y'} — половина значений не преобразована

Встроенные типы вызывают свои методы напрямую, минуя переопределения. Для расширения берите collections.UserDict, UserList, UserString или композицию.

Бесконечная рекурсия в __getattr__.

class Proxy:
    def __init__(self, target: object) -> None:
        self._target = target
    def __getattr__(self, name: str):
        return getattr(self._target, name)   # если _target ещё не установлен — рекурсия

Пока self._target не появился в __dict__, обращение к нему снова уходит в __getattr__. Лечится записью через object.__setattr__ в __init__ или проверкой if name.startswith("_"): raise AttributeError(name).

Забытый super().__init__() в наследнике — половина атрибутов не инициализирована. Изменяемые значения по умолчанию. Проверка type(x) == Foo вместо isinstance. except без типа при работе с абстрактными классами — см. исключения.

Где ООП в Python выигрывает, а где мешает

Честная картина, без евангелизма.

Выигрывает там, где есть состояние с инвариантами (соединение, сессия, машина состояний), где нужен полиморфизм на границах (репозитории, транспорты, нотификаторы), где протоколы языка дают бесплатную интеграцию (__iter__, __enter__, __getitem__ — см. идиоматичный Python), и там, где важна подменяемость в тестах.

Мешает в следующих случаях:

  • Классы-обёртки над одной функцией. class ReportGenerator: def generate(self) — это функция с лишним слоем. В Python модуль уже является пространством имён, «класс ради группировки» не нужен.
  • Глубокие иерархии. Три и более уровней наследования в прикладном коде почти всегда означают, что композицию заменили на is-a. Каждый уровень удлиняет MRO и делает поведение неочевидным.
  • Симуляция приватности и «настоящих» интерфейсов. Динамика всё равно пробьёт любую защиту; полагайтесь на mypy и код-ревью, а не на подчёркивания.
  • Множественная диспетчеризация. Полиморфизм в Python — по первому аргументу. Если ветвление нужно по типу данных, а не по объекту, честнее взять functools.singledispatch, чем городить иерархию:
from functools import singledispatch

@singledispatch
def render(node: object) -> str:
    raise TypeError(f"нет рендера для {type(node).__name__}")

@render.register
def _(node: int) -> str:
    return f"<num>{node}</num>"

@render.register
def _(node: str) -> str:
    return f"<text>{node}</text>"

print(render(42), render("привет"))     # <num>42</num> <text>привет</text>
  • Горячие циклы. Каждый доступ к атрибуту — это поиск по описанному алгоритму, каждый вызов метода — создание bound method. Для численного кода объектная модель проигрывает массивам: см. работу с данными и производительность.

Ориентир: если класс не имеет состояния — это модуль с функциями; если у него один метод — это функция; если наследование используется ради переиспользования кода, а не ради подстановки — это композиция.

Собираем всё вместе: кусок доменной модели

Так выглядит типичный «взрослый» код: неизменяемые value-объекты, entity с инвариантами, порт как Protocol и адаптер без наследования.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID, uuid4


@dataclass(frozen=True, slots=True)
class Sku:
    """Value object: сравнивается по значению, хешируем, годится в ключи словаря."""
    code: str

    def __post_init__(self) -> None:
        if not self.code.strip():
            raise ValueError("пустой SKU")


@dataclass(slots=True)
class Order:
    """Entity: имеет идентичность, состояние меняется только через методы."""
    id: UUID = field(default_factory=uuid4)
    lines: dict[Sku, int] = field(default_factory=dict)

    def add(self, sku: Sku, qty: int) -> None:
        if qty <= 0:
            raise ValueError("qty должно быть > 0")
        self.lines[sku] = self.lines.get(sku, 0) + qty

    @property
    def total_items(self) -> int:
        return sum(self.lines.values())


class OrderRepository(Protocol):
    """Порт: контракт без наследования и без знания о БД."""
    def get(self, order_id: UUID) -> Order | None: ...
    def save(self, order: Order) -> None: ...


class InMemoryOrderRepository:
    """Адаптер для тестов. Ничего не наследует — достаточно совпадения сигнатур."""

    def __init__(self) -> None:
        self._items: dict[UUID, Order] = {}

    def get(self, order_id: UUID) -> Order | None:
        return self._items.get(order_id)

    def save(self, order: Order) -> None:
        self._items[order.id] = order


def add_item(repo: OrderRepository, order_id: UUID, sku: str, qty: int) -> Order:
    """Сценарий приложения зависит только от протокола, а не от реализации."""
    order = repo.get(order_id) or Order(id=order_id)
    order.add(Sku(sku), qty)
    repo.save(order)
    return order


repo = InMemoryOrderRepository()
oid = uuid4()
add_item(repo, oid, "A-1", 2)
print(add_item(repo, oid, "A-1", 3).total_items)   # 5

Здесь нет ни одного class X(Y) в прикладном коде — и это норма. Подменяемость даёт Protocol, переиспользование — функции и композиция, а dataclass убирает бойлерплейт. Как такой репозиторий подменяется в тестах — в статье о тестировании.

Чек-лист перед мержем

  • Изменяемое состояние создаётся в __init__, а не в теле класса.
  • Альтернативные конструкторы — @classmethod с cls, а не хардкод имени класса.
  • __repr__ есть у каждого доменного класса (это экономит часы отладки).
  • __eq__ и __hash__ определены парой, объект-ключ неизменяем.
  • Наследование глубже двух уровней обосновано; миксины не хранят состояния и зовут super().
  • Контракты между слоями описаны Protocol/ABC, а не «просто договорились».
  • Для «данных с поведением» взят dataclass, а не рукописный __init__.
  • __slots__ и метаклассы применены по измеренной необходимости, а не «для красоты».

Мини-итог

Классы в Python — это объекты, атрибуты — записи в словарях, а вся «магия» сводится к одному алгоритму поиска имени и одному протоколу дескрипторов. Наследование линеаризуется алгоритмом C3, и super() идёт по MRO конкретного экземпляра, а не к «родителю». Из-за этого множественное наследование требует дисциплины и почти всегда проигрывает композиции. Повседневный инструмент — @dataclass (плюс frozen, slots, kw_only), а Protocol заменяет тяжёлые иерархии интерфейсов. Дескрипторы, __slots__ и метаклассы — мощная, но узкоспециальная техника: они нужны авторам библиотек чаще, чем авторам сервисов.

Источники

Что дальше

Идиоматичный Python: генераторы, итераторы, контекстные менеджеры, comprehensions — разберём протоколы, на которых держится язык: как писать код, который читается как описание задачи, и как ленивые вычисления экономят память в реальных пайплайнах.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов