ООП в 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__) выглядит так:
Этот порядок объясняет 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__: когда словарь на экземпляр слишком дорог
Если объектов миллионы (парсинг логов, узлы графа, тики котировок), словарь на каждый экземпляр становится заметной статьёй расходов.
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 того объекта, который сейчас обрабатывается»,
а не «мой базовый класс». Это и есть кооперативное множественное наследование:
каждый класс делает свою часть работы и передаёт эстафету дальше.
а не в базы класса A A->>B: super().hello() B->>Base: super().hello() Base-->>B: return B-->>A: return A-->>Код: return
Как считается 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 — внутри домена,
чтобы ядро не зависело от библиотеки валидации. Про раскладку по слоям — в статье об
архитектуре.
и инварианты?"} B -- "нет, это просто запись" --> C{"Данные приходят
извне системы?"} C -- "да" --> D["pydantic.BaseModel
валидация на границе"] C -- "нет" --> E{"Нужна изменяемость?"} E -- "нет" --> F["frozen dataclass
или NamedTuple"] E -- "да" --> G["@dataclass(slots=True)"] B -- "да" --> H{"Состояние меняется
по правилам?"} H -- "да" --> I["обычный класс
методы охраняют инварианты"] H -- "нет, только вычисления" --> J{"Нужен ли объект вообще?"} J -- "нет" --> K["функция в модуле
+ замыкание при необходимости"] J -- "да" --> I
Хуки создания класса: __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 Data Model — первоисточник
по атрибутам,
__new__, слотам и специальным методам. - Descriptor HowTo Guide — дескрипторы
с реализациями
propertyиclassmethodна чистом Python. - dataclasses и PEP 557 — полный список параметров и их семантика.
- PEP 487 —
__init_subclass__и__set_name__. - The Python 2.3 Method Resolution Order — каноническое описание C3.
- Raymond Hettinger, Python’s super() considered super!.
- abc и
functools — ABC,
singledispatch,cached_property. - Luciano Ramalho, Fluent Python, 2nd ed. — главы о протоколах, наследовании и метапрограммировании.
- David Beazley, Brian K. Jones, Python Cookbook, 3rd ed. — рецепты по классам и дескрипторам.
- attrs и pydantic —
альтернативы и дополнения к
dataclasses. - Harry Percival, Bob Gregory, Architecture Patterns with Python — почему в Python композиция и функции чаще уместнее иерархий.
Что дальше
Идиоматичный Python: генераторы, итераторы, контекстные менеджеры, comprehensions — разберём протоколы, на которых держится язык: как писать код, который читается как описание задачи, и как ленивые вычисления экономят память в реальных пайплайнах.