open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 03 · 03

Классы, датаклассы и __slots__: сгенерированный бойлерплейт и словарь экземпляра

@dataclass генерирует __init__/__repr__/__eq__ из аннотаций; eq без frozen обнуляет __hash__; изменяемым дефолтам нужен default_factory. __slots__ убирает __dict__ экземпляра: ~100 байт экономии на объект, быстрее доступ, но без динамических атрибутов и со строгим наследованием.

PY Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

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 — обычный класс. Почему нет экономии?

Вспомните перед уходом
  1. 01
    Объясните матрицу eq/frozen/hash датакласса: что генерирует дефолт, почему экземпляры нехешируемы и что меняют frozen=True и unsafe_hash?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.