Learning Platform
Глоссарий Troubleshooting
Урок 05.05 · 22 мин
Продвинутый
__slots__member_descriptor@dataclassPEP 557exec codegenMemory optimization

__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.htp_dictoffset = 0 для __slots__-класса (нет __dict__); Objects/typeobject.ctype_new_slots() создаёт member_descriptor’ы для каждого slot имени.

__slots__ vs __dict__ — memory layout
WithoutSlots48 + 296 = 344 bytesPyObject header (16 bytes) + tp_dictoffset slot (8 bytes) + lazily аллоцированный PyDictObject (296 bytes на cpython-3.12.7). __dict__ это hash table (M02 урок 03) - overkill для 2 атрибутов.
vs
WithSlots48 bytes (no __dict__)Один блок памяти: PyObject header + два fixed offsets для x, y. Атрибутный доступ через member_descriptor (data descriptor, C-implemented) - читает offset напрямую, без hash + probe. ~6-7x меньше памяти, ~20-30% быстрее access.

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.ctype_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:

  1. Обходит class body, ищет аннотации (x: int, y: int).
  2. Собирает их в fields.
  3. Для каждого нужного метода (__init__, __repr__, __eq__, …) генерирует source string (текст функции на Python).
  4. Вызывает exec() для парсинга и компиляции этого текста в function object.
  5. 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 caseChooseWhy
Record-like класс с few attrs@dataclasszero 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 на setmanual class with property/descriptordataclass не делает per-attr validation; нужны descriptors
Inheritance с custom __init__ logicmanual class@dataclass __init__ не очень flexible (может только default values + __post_init__)
Memory-critical production recordsclass WithSlots: __slots__ = (...) или @dataclass(slots=True)без __slots__ 296 байт overhead на instance
Quick prototyperegular class или @dataclassoverhead irrelevant для прототипов

@dataclass ≠ replacement для всех классов. Это specifically для record-like data containers (data + минимальное behavior). Если у класса много логики — обычный class + manual __init__.


Cross-course context

Arrow Memory Layout: packed layout без overhead заголовков MergeTree: фундамент хранилища ClickHouse

Ключевые выводы

  1. __slots__ заменяет per-instance __dict__ на fixed set member_descriptor’ов (data descriptors с C-implemented __get__/__set__, читающие fixed offsets). Verified: WithSlots() = 48 bytes (no __dict__), WithoutSlots() = 48 + 296 = 344 bytes. Экономия ~6-7x для small classes.
  2. Subclass rule: каждый класс в иерархии должен иметь __slots__, иначе __dict__ возвращается (CPython устанавливает tp_dictoffset = 0 только если все базовые классы тоже имеют 0). Child пишет только дополнительные slots (не повторяет parent).
  3. __slots__ блокирует динамические attrsobj.unknown = ... raises AttributeError. Feature, не bug: декларативная schema, опечатки ловятся at runtime.
  4. @dataclass (PEP 557, Python 3.7) генерирует __init__/__repr__/__eq__ через codegen: собирает source string → exec() → attaches function to class. См. Lib/dataclasses.py функция _create_fn() (~30 строк).
  5. @dataclass(slots=True) (Python 3.10+) — связка двух механизмов: __slots__ + generated methods. Создаёт новый класс с slots (потому что __slots__ нельзя добавить in-place — влияет на memory layout). Idiomatic для mass-allocated records (events, log entries).
  6. 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.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Сколько байт занимает instance с `__slots__ = ('a', 'b')` на cpython-3.12.7 (через sys.getsizeof)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6