Deep Engineering
Продвинутый·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·35 МИН

__slots__: что меняет в объектной модели и сколько это стоит в байтах

Сначала механизм: перечисленные имена получают дескрипторы данных на классе и места по фиксированным смещениям, а __dict__ и __weakref__ перестают создаваться сами. Всё это — контракт языка, одинаковый на 3.11–3.14. И только потом цена, которая контрактом не является: 40 байт на экземпляр, 42 %, причём sys.getsizeof показывает обратное, basicsize молчит про __weakref__, а сумма двух getsizeof отвечает по-разному в начале программы и потом.

Полное техническое изложение

У этой темы два этажа, и почти вся путаница вокруг неё — от того, что их складывают в один.

Первый этаж — контракт языка. __slots__ перечисляет имена; каждое имя получает дескриптор данных на классе и своё место в экземпляре; __dict__ и __weakref__ перестают создаваться сами. Это не меняется от версии к версии, и на это можно опираться в коде.

Второй этаж — реализация. Сколько при этом экономится байт, что показывает sys.getsizeof, где физически лежит словарь. Здесь не гарантировано ничего, и между 3.11 и 3.14 оно менялось трижды.

Статья идёт снизу вверх: сначала механизм, потом обещания языка, потом устройство CPython и только затем числа. Числа стоят последними не потому, что они неважны — ради них статья и написана, — а потому, что без первых трёх частей они читаются как набор неожиданностей.

Часть I. Что такое __slots__

Зачем обычному экземпляру словарь

Атрибуты в Python можно добавлять когда угодно и какие угодно:

PYTHON
class Plain:
    pass
 
p = Plain()
p.x = 1
p.later = "что угодно, в любой момент"

Чтобы это работало, у экземпляра должно быть место, где имена появляются во время работы, — динамическое пространство имён. Им и служит __dict__.

__slots__ меняет ровно эту предпосылку: имена объявляются заранее, и место под них отводится заранее.

PYTHON
class Slotted:
    __slots__ = ("x", "y")
 
s = Slotted()
s.x = 1
s.y = 2
s.z = 3          # AttributeError

Отказ в последней строке — не отдельное правило «слоты запрещают новые атрибуты», а следствие: под z не отведено места, а словаря, куда можно положить что угодно, нет.

Слот — это дескриптор данных на классе

Здесь легко остановиться на образе «значение лежит прямо в объекте». Он верен, но неполон, и из него не выводится половина поведения. Точнее так: объявление слота кладёт объект в словарь КЛАССА.

PYTHON
class User:
    __slots__ = ("name",)
 
User.__dict__["name"]        # <member 'name' of 'User' objects>

У этого объекта есть и __get__, и __set__ — значит, это дескриптор данных, и по правилу приоритета из статьи про поиск атрибута он сильнее словаря экземпляра. Отсюда всё остальное:

PYTHON
u = User()
u.name = "ann"
 
u.name                                  # 'ann'
User.__dict__["name"].__get__(u, User)  # 'ann' — то же самое, в обход точки

Точка ничего своего не делает: она вызывает этот дескриптор. Класс хранит знание о том, по какому смещению в экземпляре лежит значение; экземпляр хранит само значение.

Из этой же картины сразу следует, почему cached_property со слотами не работает: он дескриптор не данных и рассчитывает, что при следующем обращении его перекроет словарь экземпляра. Перекрывать нечем.

'__dict__' в слотах: запрет не абсолютен

Между «обычный класс» и «только слоты» есть третий вариант, о котором обычно не говорят:

PYTHON
class Flexible:
    __slots__ = ("a", "b", "__dict__")
 
f = Flexible()
f.a = 1              # слот
f.whatever = 2       # динамический атрибут — прошёл
vars(f)              # {'whatever': 2}

Слоты дают предсказуемую раскладку объявленным именам, а словарь оставляет дверь открытой для остальных.

Обратите внимание на последнюю строку: в vars(f) значений слотов нет. Они лежат по своим смещениям, и словарь про них не знает. Отсюда практическое следствие, которое дороже самой возможности: любой код, сериализующий объект через vars() или __dict__ — логгер, отладчик, наивный asdict — у такого класса увидит не все атрибуты.

Наследование: одно правило

Правило одно, и версии его не меняют: словарь заводится тому, кто не объявил __slots__.

PYTHON
class Base:
    __slots__ = ("x",)
 
class ChildBare(Base):      # словарь есть — строки нет
    pass
 
class ChildEmpty(Base):     # словаря нет
    __slots__ = ()
 
class ChildOwn(Base):       # словаря нет
    __slots__ = ("y",)
__dict__
Baseнет
ChildBare — строки нетесть
ChildEmpty__slots__ = ()нет
ChildOwn__slots__ = ("y",)нет

Вторая и третья строка — вся ловушка наследования целиком. __slots__ = () — это не «слотов нет», это «своих слотов нет, и словарь заводить не надо».

Часть II. Что обещает язык

Три конфигурации и что каждая даёт

фиксированные слоты__dict__динамические атрибуты
обычный класснетдада
__slots__ = (...)данетнет
__slots__ = (..., "__dict__")дадада

Краевые случаи, о которые спотыкаются

Слабые ссылки не работают, пока не объявлены. weakref.ref на экземпляре со слотами даёт TypeError, пока в кортеже нет '__weakref__'. Имя служебное, но перечисляется как обычное.

Множественное наследование ломается на двух непустых наборах слотов. TypeError: multiple bases have instance lay-out conflict. Слот — фиксированное смещение, и двух несовместимых раскладок не бывает. Если у одного из родителей слоты пустые, конфликта нет.

Повтор имени слота в подклассе не даёт ошибки — и это худший вариант.

PYTHON
class A:
    __slots__ = ("x",)
 
class B(A):
    __slots__ = ("x",)      # то же имя — объявление проходит

У B появляется свой дескриптор x, и место базы тратится впустую. Слот базы при этом становится недоступен обычным обращением по имени: дескриптор подкласса его перекрывает. Добраться до него можно только через дескриптор базы напрямую — и тогда видно, что в одном объекте под одним именем лежат два разных значения. Правило простое: имя слота в подклассе не повторять.

Непустые слоты запрещены не «встроенным типам», а типам с ненулевым __itemsize__. Это не список для запоминания, а критерий, проверяемый одной строкой:

тип__itemsize__непустые слоты
tuple8TypeError
bytes1TypeError
int4TypeError
str0разрешены
list, dict, set, float, object0разрешены

Такой тип хранит переменное число элементов прямо в объекте, и фиксированное смещение под слот назначить некуда. Строка str — тот случай, где привычный список из int, bytes и tuple даёт неверный ответ: она переменной длины по смыслу, но __itemsize__ у неё ноль, и подклассу str непустые слоты разрешены. Пустые слоты разрешены всем.

Словарная форма задаёт документацию атрибутов. Редкая, но полезная:

PYTHON
class User:
    __slots__ = {
        "name": "имя пользователя",
        "age": "возраст в полных годах",
    }
 
inspect.getdoc(User.name)    # 'имя пользователя'

Это единственный способ дать слотам то, чего у них иначе нет: help() и инструменты документации видят описание каждого атрибута.

Всё перечисленное в этой части одинаково на 3.11, 3.12, 3.13 и 3.14 — прогон bench/slots/contract.py на четырёх сборках даёт побайтово совпадающий вывод. Дальше начинается то, что не совпадает.

Часть III. Что делает CPython

Что показывает getsizeof и что показывает замер

Класс с тремя атрибутами, 3.13.13. Слева — то, что отвечает sys.getsizeof, справа — байты, реально запрошенные у аллокатора при создании 200 000 таких экземпляров:

getsizeofзамер
без __slots__48 Б96 Б
со __slots__56 Б56 Б
выводслоты дороже на 8слоты дешевле на 40

Причина расхождения записана в документации самой функции:

Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to.

перевод

Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается.

sys.getsizeof

Словарь экземпляра — как раз «объект, на который ссылаются». Он лежит по указателю, и в число не входит. У экземпляра со слотами словаря нет вовсе, зато три значения лежат в самом объекте — и попадают в basicsize. Отсюда и переворот: у того, кто платит меньше, число больше.

На 3.11 та же функция не переворачивает ответ, а молчит: 56 против 56. Правильного ответа она не даёт ни в одной версии.

Почему сумма двух getsizeof — тоже не ответ

Напрашивается поправка: сложить getsizeof(obj) и getsizeof(obj.__dict__). Она не работает, и разбор этого места объясняет заодно, что такое key-sharing.

Разделяется не словарь, а таблица ключей. Словари у экземпляров разные:

PYTHON
x, y = Plain(), Plain()
x.__dict__ is y.__dict__      # False
x.a = 999
y.__dict__["a"]               # 1 — запись в один в другом не видна

Общее у них другое: имена атрибутов у всех экземпляров класса одни и те же, и хранить их по разу на экземпляр незачем. Поэтому таблица ключей одна на класс, а значения — свои у каждого.

Отсюда следствие, из-за которого сумма и не работает: размер словаря экземпляра зависит от истории программы, а не от класса.

getsizeof(obj.__dict__)3.11 / 3.12 / 3.13 / 3.14
у самого первого экземпляра класса296 Б
у него же после тысячи других96 Б

Один и тот же класс, одни и те же три атрибута. Наивная сумма даёт 48 + 296 = 344 Б в начале программы и 48 + 96 = 144 Б потом, а замер партией — 160 Б. То есть она завышает на 184 байта в одном случае и занижает на 16 в другом, и какой ответ вы получите, зависит от того, когда спросили.

Поэтому мерить приходится партией: в ней ключи уже разделены — как и в настоящей программе, где экземпляров много.

Раскладка от версии к версии

Байты между версиями сравнивать можно: это раскладка объекта, а не время.

байт на экземпляр3.11.153.12.33.13.133.14.7
без __slots__96889696
со __slots__56565656
экономия40324040

Ряд не монотонный, и это важно: обычно его описывают как «в 3.13 экономия выросла». Выросла она только относительно 3.12: 3.11 уже давал 40 байт, 3.12 сделал обычный экземпляр на восемь байт дешевле, а 3.13 эти восемь байт вернул.

Что изменилось. До 3.13 на «словарь или значения» отводилось одно слово, размеченное объединением:

C
typedef union {
    PyObject *dict;
    /* Use a char* to generate a warning if directly assigning a PyDictValues */
    char *values;
} PyDictOrValues;

В 3.13 указатель на словарь и inline values разъехались: указатель получил своё слово в предзаголовке, а значения переехали внутрь объекта. Обычный экземпляр подорожал на восемь байт, экземпляр со слотами не изменился — у него ни того, ни другого нет.

Ловушка наследования в байтах

Правило из части I версии не меняют. А вот его цена — меняет:

байт на экземпляр3.11.153.12.33.13.133.14.7
обычный класс96889696
подкласс без своих слотов96887296

На 3.13 такой подкласс дешевле обычного класса на 24 байта. Разница читается по одному флагу: INLINE_VALUES у него снят, тогда как у обычного класса стоит. А почему снят — написано в заголовке:

C
static inline PyDictValues *
_PyObject_InlineValues(PyObject *obj)
{
    assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_INLINE_VALUES);
    assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_MANAGED_DICT);
    assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));
    return (PyDictValues *)((char *)obj + sizeof(PyObject));
}

Третья строка и есть ответ. Значения кладутся ровно по адресу obj + sizeof(PyObject) — сразу за заголовком. У подкласса там уже лежат слоты базы, места нет, и массив значений не заводится вовсе: указатель на словарь остаётся пустым, пока в словарь не напишут.

На 3.14 флаг у такого подкласса стоит, и цена возвращается к 96. Заголовков 3.14 в этой машине нет, поэтому здесь утверждается только прочитанное флагом в рантайме и измеренное: её исходник не читался.

Практический вывод от версии не зависит и совпадает с правилом из части I: подклассу нужен свой __slots__, хотя бы пустой. Уносить из этого раздела стоит не числа, а форму: цена этой ловушки не постоянна от версии к версии, и проверять её надо на той, на которой работает код.

Цена __weakref__ и молчащий basicsize

3.11.153.12.33.13.133.14.7
со слотами56 Б56 Б56 Б56 Б
и с '__weakref__'64 Б72 Б72 Б72 Б
цена8 Б16 Б16 Б16 Б
разница в basicsize8000
weaklistoffset40−32−32−32

С 3.12 цена вдвое выше, а basicsize показывает ноль. Ссылка уехала в предзаголовок — область перед началом объекта, — и в basicsize она не входит по определению. Отрицательный weaklistoffset это и говорит: смещение считается назад от начала объекта.

Промах ровно тот же, что у getsizeof: привычное число молчит, а байты тратятся. Величина другая, механизм тот же — считается только то, что лежит внутри объекта.

Часть IV. Измерения

Как получены числа

tracemalloc вокруг создания партии в 200 000 экземпляров: он считает все запрошенные аллокации, а не размер одного заголовка. Деление на размер партии возвращает цену экземпляра, а шум аллокатора — арены, пулы, округления — на такой партии усредняется.

Три вещи сделаны нарочно, и без них числам верить нельзя.

Значения атрибутов общие. Всем экземплярам присваиваются одни и те же заранее созданные объекты, поэтому на экземпляр не приходится ни одной аллокации под значение. Если дать каждому свои три строки, собранные в рантайме, та же партия показывает 254,4 байта вместо 96 — разница в 158,4 байта и есть память под строки.

Список-держатель отводится целиком до старта tracemalloc. Поэтому в измерение он не попадает и вычитать из суммы нечего. Проверка меряет партию из уже созданного объекта и обязана дать ноль — она и даёт.

Величина не должна зависеть от размера партии. 200 000 и 400 000 дают одно и то же число: расхождение 0,00 байта.

Ускоряет ли __slots__ доступ

Проверяется в одном запуске одного интерпретатора — время между версиями не сравнивается вовсе, у сборок разные компиляторы и разные флаги.

3.13.13, наносекунд на операцию, лучшее из семи раундов:

со слотамибез слотовпосле vars(obj)
чтение o.a8,788,518,53
запись o.a = 58,338,3632,37

Разница между слотами и обычным классом — 3,1 % при чтении и 0,4 % при записи, и при чтении она направлена не в пользу слотов. На этой сборке и на этом классе замер преимущества слотов в скорости доступа не показывает.

Формулировка узкая намеренно. Проверены: одна сборка, класс с тремя атрибутами, прогретые чтение и запись одного атрибута. Утверждать из этого «__slots__ не ускоряет доступ» вообще — значит повторить ту же ошибку, за которую эта статья критикует getsizeof: принять результат частного измерения за свойство языка.

Во что специализируется прогретое чтение

Вот почему времена и не могли разойтись. Прогретую операцию в современном CPython исполняет не общий опкод, а специализированный, и адаптивный дизассемблер показывает какой:

3.123.133.14
со слотамиLOAD_ATTR_SLOTLOAD_ATTR_SLOTLOAD_ATTR_SLOT
без слотовLOAD_ATTR_INSTANCE_VALUELOAD_ATTR_INSTANCE_VALUELOAD_ATTR_INSTANCE_VALUE
после vars(obj)LOAD_ATTR_WITH_HINTLOAD_ATTR_INSTANCE_VALUELOAD_ATTR

Первые две строки — разные опкоды, но одинаковая работа: чтение по фиксированному смещению внутри объекта. Разными путями пришли к одному, поэтому и времена равны. Замер сравнивал не «дескриптор против словаря», а два специализированных опкода.

Третья строка объясняет третий столбец таблицы времён и заодно показывает, до какой степени это свойство сборки: на 3.14 специализации после vars(obj) не остаётся вовсе — работает общий LOAD_ATTR со всем протоколом поиска атрибута.

Отсюда точная формулировка того, что вообще измерено: не «какой механизм короче в принципе», а «сколько стоит прогретая операция в этой сборке после оптимизаций интерпретатора».

Почему в этом замере операция повторяется

Первый черновик мерил одно o.a за виток timeit. Прогон печатает оба режима рядом, и вот что даёт неамортизированный:

одна операция за витокнс
pass12,36
o.a со слотами19,44
o.a без слотов19,56

Полезного сигнала здесь 36 % от числа: всё остальное — цикл. Разница между двумя последними строками лежит внутри его шума, и вывод из неё не стоит ничего.

После того как операция стала повторяться 50 раз внутри витка, дно метода упало до 1,43 нс против измеряемых восьми — и числа стали означать то, что должны. Вывод при этом не перевернулся, а укрепился.

Это третий случай одного сюжета. getsizeof меряет не то, что нужно; basicsize не видит предзаголовка; однооперационный timeit меряет себя. Каждый раз инструмент отвечает честно на вопрос, который ему задали, — просто это не тот вопрос.

Что стоит vars(obj)

Обращение к vars(obj) разворачивает inline values в настоящий словарь. На измеренной сборке это дороже и по памяти, и по времени: 96 → 160 байт и запись атрибута дороже почти вчетверо (+287 %) при неизменном чтении.

Кратность здесь важнее числа. В другую сессию на этой же машине то же место дало +136 %: подорожание записи никуда не делось, но во сколько именно раз — величина, привязанная к прогону. Три последовательных прогона подряд дали +296, +298 и +302 %, и в записи лежит один из них.

Формулировать это следует так же узко, как и предыдущий вывод: в CPython с inline-атрибутами обращение к vars(obj) или obj.__dict__ может материализовать словарное представление, и это способно увеличить память экземпляра и изменить стоимость последующих записей. Насколько — зависит от версии, формы класса и нагрузки.

Часть V. Практика

Когда __slots__ оправдан

Экономия в 40 байт имеет смысл там, где экземпляров много: миллион объектов — это 40 мегабайт, сто штук — четыре килобайта, о которых и говорить нечего.

Порядок вопросов, по которому решение принимается:

  1. Экземпляров одного небольшого класса действительно много? Нет — слоты, скорее всего, не нужны.
  2. Нужны динамические атрибуты или инструменты, работающие через __dict__? Да — обычный класс либо __slots__ вместе с '__dict__'.
  3. Нужны слабые ссылки? Да — добавить '__weakref__' и учесть 16 байт.
  4. Наследование? У каждого подкласса должен быть свой __slots__, хотя бы пустой.
  5. Проверить на своей версии. Числа этой статьи — про четыре конкретные сборки.

Что слоты забирают взамен: новых атрибутов не добавить; множественное наследование от двух родителей с непустыми слотами не собирается; cached_property не работает; weakref требует явной строки.

И главное, ради чего их чаще всего и берут: экономия не исчезает от одного обращения к vars(obj) в чужом коде — логгере, сериализаторе, отладчике. У класса со слотами словаря нет вовсе, материализовывать нечего. Обычный класс от этого не защищён никак.

История версий

ВерсияИзменениеЧто это значит для кода
3.11Обычный экземпляр стоит 96 байт, со слотами — 56. getsizeof здесь ещё не переворачивает ответ, а просто молчит: 56 против 56. '__weakref__' в слотах стоит 8 байт, и basicsize их показывает.
3.12Обычный экземпляр дешевеет до 88 байт, экономия падает до 32. С этой версии getsizeof отвечает с обратным знаком: 48 против 56. '__weakref__' дорожает вдвое, до 16 байт, и уезжает в предзаголовок — basicsize перестаёт его показывать. Чтение после vars(obj) специализируется в LOAD_ATTR_WITH_HINT.
3.13Появляется флаг Py_TPFLAGS_INLINE_VALUES; указатель на словарь и inline values разъезжаются. Обычный экземпляр возвращается к 96 байтам, экономия — к 40. Побочный эффект: подкласс класса со слотами, у которого нет своих, inline values не получает и стоит 72 байта — дешевле обычного класса.
3.14Ограничение на inline values снято: подкласс без своих слотов снова стоит 96 байт. Зато чтение после vars(obj) перестаёт специализироваться вовсе — остаётся общий LOAD_ATTR.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.

Контракт языка, без байтов и времени — вывод совпадает на всех четырёх сборках:

Байты, флаги и смещения:

Время и специализация:

Python 3.11.15, 3.12.3 и 3.13.13 (GCC 13.3.0), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU. Байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги.

Фрагменты заголовков читались в файлах установленных сборок, /usr/include/python3.12/internal/pycore_object.h и /usr/include/python3.13/internal/pycore_object.h, и печатаются прогоном дословно. Заголовков 3.14 в этой машине нет: про неё здесь утверждается только то, что прочитано флагом в рантайме и измерено.

Частые заблуждения

Утверждение

sys.getsizeof покажет, сколько экономит __slots__

На самом деле

С 3.12 он показывает обратное: 48 байт у экземпляра без слотов против 56 со слотами, то есть по этому числу слоты выходят дороже на 8 байт. Настоящая экономия — 40 байт. Причина названа в документации самой функции: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Словарь экземпляра — как раз объект, на который ссылаются. На 3.11 функция не переворачивает ответ, а просто молчит: 56 против 56. Правильного ответа она не даёт ни в одной версии.

Утверждение

__slots__ ускоряет доступ к атрибутам

На самом деле

На измеренной сборке — нет. 3.13.13, чтение o.a: 8,78 нс со слотами против 8,51 без; запись: 8,33 против 8,36. Разница около трёх процентов, и при чтении она направлена не в пользу слотов. Причина видна в дизассемблере: прогретое обращение специализируется в LOAD_ATTR_SLOT у слотов и в LOAD_ATTR_INSTANCE_VALUE у обычного класса — два разных опкода, но одинаковая работа, чтение по фиксированному смещению. То есть замер сравнивал не «дескриптор против словаря», а две специализации, и отвечает он на вопрос «сколько стоит прогретая операция в этой сборке», а не «какой механизм короче в принципе». Выигрыш слотов — в памяти.

Утверждение

Экономия от слотов росла от версии к версии

На самом деле

Ряд не монотонный: 96 / 88 / 96 / 96 байт на 3.11.15, 3.12.3, 3.13.13 и 3.14.7 для обычного экземпляра, то есть экономия 40 / 32 / 40 / 40. 3.11 уже давал 40 байт; 3.12 сделал обычный экземпляр на восемь байт дешевле, а 3.13 их вернул, разведя указатель на словарь и inline values по разным местам. «В 3.13 экономия выросла» верно только относительно 3.12.

Утверждение

Подкласс класса со слотами наследует экономию

На самом деле

Не наследует: без своего __slots__ он снова получает словарь. Причём цена этой ловушки различается по версиям — 96 / 88 / 72 / 96 байт, — и на 3.13 такой подкласс неожиданно дешевле обычного класса на 24 байта. Дело в ограничении, записанном в pycore_object.h прямо: assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject)); — inline values кладутся сразу за заголовком объекта, а там уже лежат слоты базы, поэтому массив значений не заводится вовсе. На 3.14 флаг у такого подкласса стоит и цена возвращается к 96 — это видно по флагу в рантайме и по замеру, исходник 3.14 не читался. Лечится одинаково во всех версиях: __slots__ = () у подкласса.

Утверждение

Добавить '__weakref__' в слоты ничего не стоит: basicsize не меняется

На самом деле

Меняется память, а не basicsize. С 3.12 слабая ссылка стоит 16 байт на экземпляр (56 → 72), при этом basicsize показывает ноль разницы: ссылка уехала в предзаголовок, до начала объекта, а basicsize считает только то, что внутри. Отрицательный weaklistoffset (−32) — то место, где это видно. На 3.11 она стоила 8 байт, и basicsize их показывал.

Утверждение

Официальная документация говорит, что слоты экономят 10–20 %

На самом деле

Не говорит. Числа 10–20 % — из PEP 412 и относятся к key-sharing словарям, а не к __slots__: measurements show a memory saving of 10% to 20% for object-oriented programs (замеры показывают экономию памяти 10–20 % для объектно-ориентированных программ). Официальных чисел экономии от слотов не существует вовсе, поэтому все цифры здесь — собственные замеры с указанной методикой.

Утверждение

При key-sharing экземпляры разделяют общий словарь

На самом деле

Разделяется не словарь, а таблица ключей. Словари у экземпляров разные объекты: x.__dict__ is y.__dict__ даёт False, и запись в один в другом не видна. Общее у них другое — имена атрибутов одинаковы у всех экземпляров класса, и хранить их по разу на экземпляр незачем; значения при этом остаются свои. Отсюда следствие, из-за которого нельзя просто сложить два getsizeof: размер словаря экземпляра зависит от того, разделены ключи или ещё нет. У самого первого экземпляра класса он 296 байт, у него же после тысячи других — 96. Один класс, три атрибута, ответ зависит от момента вопроса.

Утверждение

Непустые __slots__ нельзя объявить подклассу встроенного типа

На самом деле

Можно почти всем. Запрет действует ровно при __itemsize__ != 0 — то есть у типов, хранящих переменное число элементов прямо в объекте: tuple (8), bytes (1), int (4). У str, list, dict, set, float и object itemsize нулевой, и непустые слоты им разрешены. Ходовой список из int, bytes и tuple на str даёт неверный ответ: строка переменной длины по смыслу, но itemsize у неё ноль. Пустые слоты разрешены всем.

Утверждение

Повторить имя слота в подклассе — безобидно, просто переопределение

На самом деле

Объявление проходит без ошибки, и это худший вариант: ошибки нет, а поведение испорчено. У подкласса появляется СВОЙ дескриптор с тем же именем, место базы тратится впустую, а слот базы становится недоступен обычным обращением. Через дескриптор базы напрямую видно, что в одном объекте под одним именем лежат два разных значения. Правило: имя слота в подклассе не повторять.

Утверждение

__slots__ = () — то же самое, что не писать __slots__ вовсе

На самом деле

Прямо противоположное. Пустой кортеж говорит «своих слотов нет и словарь заводить не надо» — экземпляр остаётся без __dict__. Отсутствие строки говорит «заведи словарь». Разница видна по __dictoffset__: у подкласса с __slots__ = () он равен нулю, у подкласса без строки — отличен от нуля.

Проверка знаний

Вопрос 1 из 7

У класса три атрибута. sys.getsizeof показывает 48 байт для экземпляра без __slots__ и 56 со слотами. Что из этого следует?

Источники и что читать дальше

5 ИСТОЧНИКОВ

  1. internal/pycore_object.h — inline values в 3.13Исходный код CPython. Место, из которого следует поведение подкласса без своих слотов. Функция `_PyObject_InlineValues` начинается с трёх утверждений, и третье — `assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));`, после чего значения адресуются как `(PyDictValues *)((char *)obj + sizeof(PyObject))`. То есть массив значений кладётся сразу за заголовком объекта, и типу, у которого за заголовком уже лежат слоты базы, он не достаётся. Читалось в заголовке установленной сборки, `/usr/include/python3.13/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.13/Include/internal/pycore_object.h
  2. internal/pycore_object.h — то же место в 3.12Исходный код CPython. Для сравнения: до 3.13 на «словарь или значения» отводилось одно слово, размеченное объединением — `typedef union { PyObject *dict; char *values; } PyDictOrValues;`, с комментарием `/* Use a char* to generate a warning if directly assigning a PyDictValues */`. В 3.13 указатель на словарь и inline values разъехались, и обычный экземпляр подорожал на 8 байт. Читалось в заголовке установленной сборки, `/usr/include/python3.12/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.12/Include/internal/pycore_object.h
  3. object.h — биты флагов типаИсходный код CPython. `Py_TPFLAGS_INLINE_VALUES (1 << 2)` определён только начиная с 3.13; в `object.h` версий 3.11 и 3.12 этого бита нет вовсе. Рядом `Py_TPFLAGS_MANAGED_WEAKREF (1 << 3)` (с 3.12) и `Py_TPFLAGS_MANAGED_DICT (1 << 4)`. Именно поэтому спрашивать «стоит ли INLINE_VALUES» на 3.11 бессмысленно: там это другой, никак не названный бит.https://github.com/python/cpython/blob/3.13/Include/object.h
  4. sys.getsizeof — что именно возвращаетсяОфициальная документация. Официальная формулировка того, из-за чего вся путаница: «Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to» (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Словарь экземпляра — как раз объект, на который ссылаются, и в число он не входит.https://docs.python.org/3/library/sys.html#sys.getsizeof
  5. PEP 412 — Key-Sharing DictionaryPEP. Источник механизма, который делает измерение памяти экземпляра нетривиальным: экземпляры одного класса разделяют ТАБЛИЦУ КЛЮЧЕЙ, а значения остаются у каждого свои. Отсюда же единственное официальное число про экономию памяти, которое обычно приводят рядом со слотами, — и относится оно к key-sharing словарям, а не к `__slots__`: «measurements show a memory saving of 10% to 20% for object-oriented programs» (замеры показывают экономию памяти 10–20 % для объектно-ориентированных программ). Официальных чисел экономии от `__slots__` не существует вовсе.https://peps.python.org/pep-0412/