__slots__ и @dataclass: память и удобство
Когда у вас 1 миллион instance’ов, каждый несущий __dict__ (296 байт на cpython-3.12.7), вы теряете ~300MB только на пустые dict’ы. Это мотивация __slots__ — старого механизма Python (с версии 2.2), который заменяет per-instance __dict__ на fixed set of member_descriptor’ов, экономя ~6x памяти. С другой стороны, ручное написание __init__/__repr__/__eq__ для record-like классов — boilerplate, который @dataclass (PEP 557, Python 3.7) автоматизирует через codegen — генерацию методов через exec(). Связка @dataclass(slots=True) (Python 3.10+) даёт оба преимущества.
В этом уроке откроем Lib/dataclasses.py и проследим, как @dataclass через _create_fn() собирает source string и exec()’ает его в __init__. Verified empirical: WithSlots() = 48 bytes (no __dict__), WithoutSlots() = 48 + 296 = 344 bytes.
slots — list/tuple имён → member_descriptor для каждого attr
Стандартный класс лениво аллоцирует __dict__ при первом setattr (см. M04 урок 01: tp_dictoffset → PyDictObject). __slots__ запрещает __dict__ и вместо этого создаёт fixed set атрибутов, каждый — отдельный member_descriptor:
class WithoutSlots:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ('x', 'y') # фиксированный набор атрибутов
def __init__(self, x, y):
self.x = x
self.y = y
Что меняется на уровне CPython:
- Без
__slots__:WithoutSlots.__dict__['x']отсутствует (instance attr), приobj.x = ...создаётсяobj.__dict__lazily.obj.__dict__['x'] = 10. - С
__slots__:WithSlots.__dict__['x']это member_descriptor (data descriptor с C-implemented__get__/__set__, читает/пишет fixed offset в instance memory).obj.__dict__не существует (атрибутный доступ к нему raise’ит AttributeError).
import sys
ws = WithSlots(1, 2)
wo = WithoutSlots(1, 2)
print(sys.getsizeof(ws)) # 48 - только PyObject header + 2 slots для x, y
print(hasattr(ws, '__dict__')) # False - нет __dict__!
print(sys.getsizeof(wo)) # 48 - PyObject header + dict pointer
print(hasattr(wo, '__dict__')) # True
print(sys.getsizeof(wo.__dict__)) # 296 - отдельно-аллоцированный PyDictObject
# Total:
# WithSlots: 48 bytes (один блок памяти)
# WithoutSlots: 48 + 296 = 344 bytes (instance + __dict__)
# Speed-up bonus: slot lookup быстрее dict lookup
# (member_descriptor читает fixed offset, без hash + probe)
Verified empirical (Python 3.12.7 локально): WithSlots() — 48 байт, no __dict__. WithoutSlots() — 48 байт + 296 байт __dict__ = 344 байт total. Экономия 7.2x для small classes.
Cite: Include/cpython/object.h — tp_dictoffset = 0 для __slots__-класса (нет __dict__); Objects/typeobject.c — type_new_slots() создаёт member_descriptor’ы для каждого slot имени.
Subclass slots rule — savings потеряны если child без slots
Каждый класс в иерархии должен иметь __slots__, иначе экономия теряется:
class Parent:
__slots__ = ('x',)
class Child(Parent): # НЕТ __slots__!
pass
c = Child()
print(hasattr(c, '__dict__')) # True - вернулся!
c.y = 10 # OK - идёт в c.__dict__
print(sys.getsizeof(c.__dict__)) # 296 - savings потеряны
Почему? CPython устанавливает tp_dictoffset = 0 только если во всех базовых классах его 0. Если хоть один родитель не имеет __slots__, у него tp_dictoffset != 0 → child наследует это.
Корректный pattern:
class Parent:
__slots__ = ('x',)
class Child(Parent):
__slots__ = ('y',) # extension slots, не повторяем 'x'
c = Child()
print(hasattr(c, '__dict__')) # False
c.x = 1
c.y = 2
c.z = 3 # AttributeError: 'Child' object has no attribute 'z'
Child.__slots__ = ('y',) означает: дополнительно к slots’ам родителя ещё y. Не повторяем ('x', 'y') — это создаёт duplicate descriptors и предупреждение от CPython.
Также: __slots__ блокирует динамическое добавление атрибутов:
ws = WithSlots(1, 2)
ws.z = 3 # AttributeError: 'WithSlots' object has no attribute 'z'
Это feature, не bug: вы декларируете полный набор атрибутов, опечатки ловятся at runtime, schema понятна из class body.
Cite: Objects/typeobject.c — type_new_descriptors() обработка __slots__; Python data model § __slots__.
@dataclass codegen: PEP 557 — генерация init/repr/eq
Каждый раз, когда вы пишете record-like класс:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
Это boilerplate. @dataclass (PEP 557, Python 3.7) генерирует всё это автоматически:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
# Generated:
# - __init__(self, x: int, y: int)
# - __repr__(self) → "Point(x=1, y=2)"
# - __eq__(self, other) → (self.x, self.y) == (other.x, other.y)
Как это работает? @dataclass — функция, которая на этапе class definition:
- Обходит class body, ищет аннотации (
x: int,y: int). - Собирает их в fields.
- Для каждого нужного метода (
__init__,__repr__,__eq__, …) генерирует source string (текст функции на Python). - Вызывает
exec()для парсинга и компиляции этого текста в function object. - Setattr этой функции в class.
# Lib/dataclasses.py - упрощённая _create_fn (~30 строк):
def _create_fn(name, args, body, *, globals=None, locals=None):
if locals is None:
locals = {}
if 'BUILTINS' not in locals:
locals['BUILTINS'] = builtins
return_annotation = ''
args = ','.join(args)
body = '\n'.join(f' {b}' for b in body)
# Compute the text of the entire function:
txt = f' def {name}({args}){return_annotation}:\n{body}'
# Free variables (closures) for the generated function:
local_vars = ', '.join(locals.keys())
txt = f"def __create_fn__({local_vars}):\n{txt}\n return {name}"
ns = {}
exec(txt, globals, ns)
return ns['__create_fn__'](**locals)
Сгенерированный текст для Point.__init__:
def __init__(self, x: 'int', y: 'int') -> None:
self.x = x
self.y = y
Этот текст компилируется через exec() и attached к Point как настоящий method. На уровне tp_init это обычный method, indistinguishable от написанного руками.
import dataclasses
print(dataclasses.fields(Point)) # tuple of Field objects
print(Point.__init__.__qualname__) # 'Point.__init__' - normal method
print(Point.__init__.__module__) # модуль, где dataclass определён
Cite: Lib/dataclasses.py — функция _create_fn() (~30 lines), функция _init_fn() (~80 lines генерирует init source); PEP 557 — Data Classes (Python 3.7).
@dataclass(slots=True) — Python 3.10+, dataclass + slots
Связка двух механизмов появилась в Python 3.10:
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: int
y: int
# Equivalent to:
# class Point:
# __slots__ = ('x', 'y')
# def __init__(self, x: int, y: int): ...
# def __repr__(self): ...
# def __eq__(self, other): ...
p = Point(1, 2)
print(hasattr(p, '__dict__')) # False - slots работают
print(sys.getsizeof(p)) # 48 - compact
print(p) # Point(x=1, y=2) - generated __repr__
Под капотом @dataclass(slots=True) создаёт новый класс (потому что __slots__ нельзя добавить после class definition — он влияет на memory layout, который определяется при type_new_impl()). Новый класс копирует методы старого + добавляет __slots__ = (имена fields). Это требование PEP 487 — работает корректно, но __slots__-добавление не in-place.
Use case: production data records, которые создаются миллионами (events, log entries, market data). Слитные slots=True дают memory savings + zero-boilerplate. Idiomatic Python 3.10+.
# Real-world: лог-events
@dataclass(slots=True, frozen=True) # frozen → immutable + hashable
class LogEntry:
timestamp: float
level: str
message: str
events = [LogEntry(time.time(), 'INFO', f'event {i}') for i in range(1_000_000)]
# 1M instances × 48 bytes = ~48MB
# Без slots было бы 1M × 344 = ~344MB - 7x разница на event-stream
Cite: Lib/dataclasses.py — _add_slots() функция (Python 3.10+); What’s New in Python 3.10 § dataclasses.
When to use what — decision matrix
| Use case | Choose | Why |
|---|---|---|
| Record-like класс с few attrs | @dataclass | zero boilerplate, generated __init__/__repr__/__eq__ |
| Mass-allocated record (1M+ instances) | @dataclass(slots=True) (Python 3.10+) | memory-efficient + zero boilerplate |
| Immutable record (key in dict, hashable) | @dataclass(frozen=True) | hashable, immutable, generated __hash__ |
| Custom validation на set | manual class with property/descriptor | dataclass не делает per-attr validation; нужны descriptors |
Inheritance с custom __init__ logic | manual class | @dataclass __init__ не очень flexible (может только default values + __post_init__) |
| Memory-critical production records | class WithSlots: __slots__ = (...) или @dataclass(slots=True) | без __slots__ 296 байт overhead на instance |
| Quick prototype | regular class или @dataclass | overhead irrelevant для прототипов |
@dataclass ≠ replacement для всех классов. Это specifically для record-like data containers (data + минимальное behavior). Если у класса много логики — обычный class + manual __init__.
Cross-course context
Arrow Memory Layout: packed layout без overhead заголовков MergeTree: фундамент хранилища ClickHouseКлючевые выводы
__slots__заменяет per-instance__dict__на fixed setmember_descriptor’ов (data descriptors с C-implemented__get__/__set__, читающие fixed offsets). Verified: WithSlots() = 48 bytes (no__dict__), WithoutSlots() = 48 + 296 = 344 bytes. Экономия ~6-7x для small classes.- Subclass rule: каждый класс в иерархии должен иметь
__slots__, иначе__dict__возвращается (CPython устанавливаетtp_dictoffset = 0только если все базовые классы тоже имеют 0). Child пишет только дополнительные slots (не повторяет parent). __slots__блокирует динамические attrs —obj.unknown = ...raises AttributeError. Feature, не bug: декларативная schema, опечатки ловятся at runtime.@dataclass(PEP 557, Python 3.7) генерирует__init__/__repr__/__eq__через codegen: собирает source string →exec()→ attaches function to class. См.Lib/dataclasses.pyфункция_create_fn()(~30 строк).@dataclass(slots=True)(Python 3.10+) — связка двух механизмов:__slots__+ generated methods. Создаёт новый класс с slots (потому что__slots__нельзя добавить in-place — влияет на memory layout). Idiomatic для mass-allocated records (events, log entries).- Decision matrix:
@dataclassдля record-like data containers; manual class для logic-heavy;slots=Trueдля mass-allocated production records (1M+ instances);frozen=Trueдля hashable immutable records (dict key).
В уроке M04-06 — финальная сводка модуля: synthesis всех концепций (PyTypeObject + MRO + descriptors + __slots__/dataclass), cross-link M02 (hashable contract, instance __dict__) и M03 (lru_cache требует hashable args), pre-exam consolidation.