Классы, датаклассы и __slots__: сгенерированный бойлерплейт и словарь экземпляра
@dataclass генерирует __init__/__repr__/__eq__ из аннотаций; eq без frozen обнуляет __hash__; изменяемым дефолтам нужен default_factory. __slots__ убирает __dict__ экземпляра: ~100 байт экономии на объект, быстрее доступ, но без динамических атрибутов и со строгим наследованием.
ETL-сервис при старте загружал в память справочную таблицу на 40 миллионов строк — по маленькому объекту Candle на строку, пять float-полей в каждом. На бумаге: 40 млн × 5 × 8 байт ≈ 1,6 ГБ полезной нагрузки. В продакшене поду требовалось 8 ГБ, и на пике его убивал OOM-киллер. Разрыв давали не флоаты, а объектная обвязка. Каждый обычный Python-экземпляр таскал за собой __dict__ — словарь, делающий возможным obj.anything = whatever, — около сотни байт накладных расходов на объект, даже если динамические атрибуты никто не добавлял. Одна строка, __slots__ = ("ts", "o", "h", "l", "c"), заменила словарь фиксированными слотами уровня C: память упала ниже 3 ГБ, чтение атрибутов измеримо ускорилось, под перестал умирать. Следующий инцидент, два спринта спустя, был второй половиной урока: отладочный хак candle.is_synthetic = True теперь поднимал AttributeError — слоты отобрали ту самую гибкость, на которую все молча полагались.
К концу урока ты будешь знать, какая одна строка снизила под с 8 ГБ до 3 ГБ — и какой сюрприз два спринта спустя доказывает, что компромисс настоящий.
Что на самом деле делает оператор class
class Candle: — это исполняемый код, а не декларация: Python выполняет тело в свежем пространстве имён, затем вызывает type(name, bases, namespace), чтобы изготовить объект класса. Методы — просто функции, попавшие в это пространство имён; Candle.__dict__ — то место, где их находит поиск атрибутов из урока 2. Два следствия важны ежедневно. Первое: присваивания в теле класса выполняются один раз — изменяемый атрибут класса (cache = {}) общий для всех экземпляров, классический баг разделяемого состояния. Второе: раз класс строится вызовом type, этот вызов можно перехватить — декораторы вроде @dataclass пост-обрабатывают готовый класс, именно так он добавляет методы без магии метаклассов.
Механика @dataclass: что генерируется и связка eq/hash
@dataclass читает аннотации класса и синтезирует бойлерплейт: __init__ (параметры в порядке аннотаций), __repr__ и __eq__, сравнивающий кортеж полей. Дефолты, которые обязательно знать:
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
tags: list[str] = field(default_factory=list) # НЕ tags: list = []
note: str = "" # простые дефолты допустимы для неизменяемых
@dataclass(frozen=True) # __setattr__ поднимает исключение; __hash__ генерируется
class Money:
amount: int
currency: str- Изменяемые дефолты:
tags: list = []поднимаетValueErrorещё при создании класса — датаклассы требуютdefault_factory, потому что общий список-дефолт — тот же баг однократно исполненного тела класса. (Обычные классы позволяют эту ошибку молча; датаклассы превратили её в ошибку времени определения.) - Связка eq/hash — контракт из урока 1, теперь в форме генератора: дефолтный
eq=Trueдаёт__eq__и, следовательно,__hash__ = None; экземпляры нехешируемы.frozen=Trueделает экземпляры неизменяемыми (присваивание поднимаетFrozenInstanceError) и вместе сeq=Trueгенерирует__hash__по полям — замороженные датаклассы безопасны как ключи словаря. Лазейкаeq=True, unsafe_hash=Trueхеширует изменяемый класс и называется «unsafe» по причине из урока 1: измените хешированное поле — и объект потерян в своей корзине. order=Trueдобавляет__lt__/__le__/…, сравнивающие кортежи полей, — удобно дляsorted(), неверно, если порядок полей не совпадает с вашим смысловым порядком.
Вы объявили `@dataclass class Point: x: int; y: int` и используете экземпляры Point как ключи словаря в пространственном кеше. Что произойдёт?
slots: словарь в обмен на память и скорость
Если ты загружаешь миллионы однородных объектов, а RSS (Resident Set Size — объём физической памяти, занятой процессом) заметно выше, чем говорит математика полезной нагрузки, — первый вопрос, который стоит задать себе: несёт ли каждый экземпляр __dict__, который ему никогда не понадобится?
Объявление __slots__ = ("ts", "o", "h", "l", "c") говорит созданию класса: не давай экземплярам __dict__; вместо этого выдели фиксированные смещения в C-структуре и создай по одному слот-дескриптору на имя (data-дескриптор — машинерия урока 2, — читающий/пишущий смещение). Честные числа: пустой обычный объект — ~56 байт плюс его __dict__ на ~64–100+ байт после заполнения (словари с разделяемыми ключами в Python ≥3.3 смягчают это для однородных классов, но на маленьких объектах словарь всё равно доминирует); слотированный экземпляр хранит только ссылки — для пятиполевого Candle примерно 104 байта против ~250+ со словарём; так 8 ГБ из Хука стали 3 ГБ. Доступ к атрибутам тоже ускоряется на ~10–25%: чтение по фиксированному смещению быстрее зондирования словаря.
Издержки структурные, а не косметические. Нет __dict__ — значит нет динамических атрибутов (candle.debug_flag = 1 поднимает AttributeError), нет vars(obj), и часть инструментов, полагающихся на словарь (наивные сериализаторы, functools.cached_property, манкипатчинг в тестах), ломается. Наследование — место, где слоты тихо отказывают: подкласс без собственных __slots__ снова вводит __dict__, и экономия испаряется; множественное наследование от двух классов с непустыми __slots__ поднимает TypeError: multiple bases have instance lay-out conflict. И слоты задаются на класс: каждый подкласс объявляет только свои новые поля. С датаклассами интеграция прямая начиная с Python 3.10: @dataclass(slots=True) пересоздаёт класс с __slots__ из полей — эргономичный дефолт для массовых объектов-значений.
Когда вместо этого NamedTuple или attrs
typing.NamedTuple даёт настоящий кортеж: неизменяемый, хешируемый, итерируемый, распаковываемый, по памяти ~как слоты — правильный выбор для маленьких записей-значений, текущих через код, ожидающий кортежи; неправильный, когда нужна мутация или методы, меняющие состояние (а равенство с обычными кортежами умеет удивлять: Point(1, 2) == (1, 2) — True). attrs старше датаклассов и оправдывает зависимость, когда нужны валидаторы/конвертеры полей без рукописных дескрипторов. Эмпирическое правило: датакласс по умолчанию, slots=True на объёме, frozen для ключей словаря, NamedTuple на границах API, говорящих кортежами, attrs — когда повалидационная работа с полями того стоит.
Вы добавили __slots__ в класс на горячем пути, а память почти не упала. Иерархия — `class Candle(Base)`, где Base — обычный класс. Почему нет экономии?
- 01Объясните матрицу eq/frozen/hash датакласса: что генерирует дефолт, почему экземпляры нехешируемы и что меняют frozen=True и unsafe_hash?
- 02Дайте честный баланс затрат и выгод __slots__: числа, режимы отказа и правила наследования.
Оператор class исполняем: тело выполняется в пространстве имён, type() строит класс, и всё присвоенное в теле — включая невинный cache = {} — общее состояние класса; именно этот баг @dataclass институционально починил, отвергнув изменяемые дефолты в пользу default_factory. Декоратор читает аннотации и синтезирует __init__, __repr__ и __eq__ по кортежу полей, механически соблюдая контракт урока 1: дефолтный eq обнуляет __hash__ (нехешируемые экземпляры, TypeError в роли ключей словаря), frozen=True возвращает хеш по полям на неизменяемых экземплярах, unsafe_hash хеширует изменяемое состояние и оправдывает своё имя, order=True сравнивает в порядке полей независимо от того, осмыслен ли этот порядок. __slots__ — рычаг памяти: замена пообъектного __dict__ фиксированными смещениями за послассовыми слот-дескрипторами ужимает маленький объект примерно в 2–3 раза — около сотни байт на экземпляр, гигабайты на 40 млн строк — и ускоряет доступ на ~10–25%, в обмен на отсутствие динамических атрибутов, отсутствие vars(), трения с инструментами, предполагающими словарь, и правила наследования, тихо возвращающие издержки, если хоть одна база неслотирована, и жёстко падающие при встрече двух слотированных баз. @dataclass(slots=True) упаковывает весь компромисс в один флаг; NamedTuple обслуживает кортежные границы; attrs добавляет валидаторы полей, когда это стоит зависимости. Теперь, когда увидишь под, умирающий от OOM значительно выше расчётной нагрузки, или замороженный датакласс с TypeError в роли ключа словаря, — ты знаешь, за какой рычаг тянуть и какой неявный контракт тебе предстоит подписать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.