Deep Engineering

ЗАМЕР

bench/slots/contract.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/memory/slots
Как запустить
Записи прогонов — в `runs/`: `memory.txt` и `layout.txt` для основной сборки
3.13, остальные с суффиксом версии. У `access.py` запись одна: время между
версиями не сравнивается (см. корневой `bench/README.md`), и второй файл
провоцировал бы ровно такое сравнение.

## Почему память меряется партией, а не объектом

`sys.getsizeof` — это `tp_basicsize + tp_itemsize*ob_size`: размер самого
объекта, без того, на что он ссылается. Словарь экземпляра лежит по ссылке, и
в это число не входит. Для `__slots__` результат получается не просто
неточным, а бесполезным, причём по-разному в разных версиях:

| `sys.getsizeof` | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
| --- | --- | --- | --- | --- |
| без `__slots__` | 56 | 48 | 48 | 48 |
| со `__slots__` | 56 | 56 | 56 | 56 |
| что из этого следует | «разницы нет» | «слоты дороже» | «слоты дороже» | «слоты дороже» |
| на самом деле (замер) | слоты дешевле на 40 | на 32 | на 40 | на 40 |

На 3.12 и дальше число **перевёрнуто**: по нему слоты выходят дороже на 8
байт. На 3.11 оно просто молчит — одинаковое в обоих случаях. Ни в одной
версии оно не даёт правильного ответа, и ни в одной не даёт его с одной и той
же ошибкой.

Честный ответ даёт `tracemalloc` вокруг создания партии в 200 000 экземпляров:
он считает все запрошенные аллокации. Список-держатель отводится целиком
**до** старта `tracemalloc`, поэтому в измерение не попадает и вычитать из
суммы нечего — проверка 3 в блоке 5 `memory.py` меряет партию из уже
созданного объекта и обязана дать ноль.

Первый черновик скрипта вычитал «8 байт на ячейку списка»: так методика
описана в плане статьи, и там она верна для варианта с `append`, где список
растёт под измерением. Здесь он не растёт. Проверка и существует затем, чтобы
такие расхождения не оставались на слово.

## Что измерено

Класс с тремя атрибутами, байт на экземпляр:

| | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
| --- | --- | --- | --- | --- |
| без `__slots__` | 96 | 88 | 96 | 96 |
| со `__slots__` | 56 | 56 | 56 | 56 |
| без слотов, после `vars(obj)` | 160 | 152 | 160 | 160 |
| подкласс класса со слотами, без своих | 96 | 88 | **72** | 96 |

Экономия — 40 байт (42 %) на трёх из четырёх сборок и 32 байта (36 %) на 3.12.

**«Экономия выросла в 3.13» — формулировка, которая держится только на
сравнении с 3.12.** Ряд не монотонный: 3.11 уже давал 96 байт, 3.12 сделал
обычный экземпляр на 8 байт дешевле, 3.13 их вернул. В плане статьи это было
записано как рост в 3.13, потому что 3.11 в таблице не было.

## Подкласс без своих слотов: 72 байта на 3.13 и 96 на 3.14

Самая известная ловушка темы ведёт себя в разных версиях по-разному, и разница
не мелкая. Причина читается по одному флагу, `layout.py` печатает его:

| | 3.13.13 | 3.14.7 |
| --- | --- | --- |
| обычный класс, `INLINE_VALUES` | да | да |
| подкласс без своих слотов, `INLINE_VALUES` | **нет** | **да** |
| байт на экземпляр | 72 | 96 |

Флага `Py_TPFLAGS_INLINE_VALUES` до 3.13 не существует вовсе — в `object.h`
версий 3.11 и 3.12 бит 2 не определён, и спрашивать его там бессмысленно.
Первый черновик `layout.py` спрашивал и печатал «False», то есть выдавал за
измеренный факт ответ про несуществующий флаг.

Почему подкласс его не получает на 3.13 — написано в заголовке этой машины,
`/usr/include/python3.13/internal/pycore_object.h`:

Запись прогона

Замеры: __slots__ — сколько экономит и чем это правильно мерить

Тема, где почти каждый привычный индикатор отвечает неправильно, причём по-разному. Здесь три скрипта, и каждый ловит свой промах.

скрипт что показывает
memory.py честная память на экземпляр через tracemalloc на партии; рядом — sys.getsizeof, который даёт ответ с ОБРАТНЫМ знаком; цена __weakref__, которую не видит basicsize; четыре версии подряд
layout.py из чего эти байты складываются: флаги типа, basicsize, dictoffset, weaklistoffset, плюс дословные фрагменты заголовков CPython этой машины
access.py ускоряет ли __slots__ доступ к атрибуту (нет) и во что обходится обращение к vars(obj) (в запись — почти вчетверо)

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/slots/memory.py
done
for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/slots/layout.py
done
python3.13 bench/slots/access.py

Записи прогонов — в runs/: memory.txt и layout.txt для основной сборки 3.13, остальные с суффиксом версии. У access.py запись одна: время между версиями не сравнивается (см. корневой bench/README.md), и второй файл провоцировал бы ровно такое сравнение.

Почему память меряется партией, а не объектом

sys.getsizeof — это tp_basicsize + tp_itemsize*ob_size: размер самого объекта, без того, на что он ссылается. Словарь экземпляра лежит по ссылке, и в это число не входит. Для __slots__ результат получается не просто неточным, а бесполезным, причём по-разному в разных версиях:

sys.getsizeof 3.11.15 3.12.3 3.13.13 3.14.7
без __slots__ 56 48 48 48
со __slots__ 56 56 56 56
что из этого следует «разницы нет» «слоты дороже» «слоты дороже» «слоты дороже»
на самом деле (замер) слоты дешевле на 40 на 32 на 40 на 40

На 3.12 и дальше число перевёрнуто: по нему слоты выходят дороже на 8 байт. На 3.11 оно просто молчит — одинаковое в обоих случаях. Ни в одной версии оно не даёт правильного ответа, и ни в одной не даёт его с одной и той же ошибкой.

Честный ответ даёт tracemalloc вокруг создания партии в 200 000 экземпляров: он считает все запрошенные аллокации. Список-держатель отводится целиком до старта tracemalloc, поэтому в измерение не попадает и вычитать из суммы нечего — проверка 3 в блоке 5 memory.py меряет партию из уже созданного объекта и обязана дать ноль.

Первый черновик скрипта вычитал «8 байт на ячейку списка»: так методика описана в плане статьи, и там она верна для варианта с append, где список растёт под измерением. Здесь он не растёт. Проверка и существует затем, чтобы такие расхождения не оставались на слово.

Что измерено

Класс с тремя атрибутами, байт на экземпляр:

3.11.15 3.12.3 3.13.13 3.14.7
без __slots__ 96 88 96 96
со __slots__ 56 56 56 56
без слотов, после vars(obj) 160 152 160 160
подкласс класса со слотами, без своих 96 88 72 96

Экономия — 40 байт (42 %) на трёх из четырёх сборок и 32 байта (36 %) на 3.12.

«Экономия выросла в 3.13» — формулировка, которая держится только на сравнении с 3.12. Ряд не монотонный: 3.11 уже давал 96 байт, 3.12 сделал обычный экземпляр на 8 байт дешевле, 3.13 их вернул. В плане статьи это было записано как рост в 3.13, потому что 3.11 в таблице не было.

Подкласс без своих слотов: 72 байта на 3.13 и 96 на 3.14

Самая известная ловушка темы ведёт себя в разных версиях по-разному, и разница не мелкая. Причина читается по одному флагу, layout.py печатает его:

3.13.13 3.14.7
обычный класс, INLINE_VALUES да да
подкласс без своих слотов, INLINE_VALUES нет да
байт на экземпляр 72 96

Флага Py_TPFLAGS_INLINE_VALUES до 3.13 не существует вовсе — в object.h версий 3.11 и 3.12 бит 2 не определён, и спрашивать его там бессмысленно. Первый черновик layout.py спрашивал и печатал «False», то есть выдавал за измеренный факт ответ про несуществующий флаг.

Почему подкласс его не получает на 3.13 — написано в заголовке этой машины, /usr/include/python3.13/internal/pycore_object.h:

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 в этой машине нет. Поэтому про неё утверждается только то, что прочитано флагом в рантайме и измерено; на её исходник здесь не ссылаются, и layout.py говорит об этом в выводе.

Для сравнения — как то же место выглядело в 3.12, /usr/include/python3.12/internal/pycore_object.h:

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

Одно слово на «или словарь, или значения» — против отдельного указателя на словарь и отдельных inline values в 3.13.

__weakref__: тот же промах на другой величине

Слабая ссылка на экземпляр со слотами не создаётся, пока '__weakref__' не назван в слотах явно. Сколько это стоит и что показывает basicsize:

3.11.15 3.12.3 3.13.13 3.14.7
со слотами, байт 56 56 56 56
и с '__weakref__', байт 64 72 72 72
цена 8 16 16 16
разница в basicsize 8 0 0 0
weaklistoffset 40 −32 −32 −32

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

Время: что меряется и почему повторяется

access.py проверяет второе ходовое утверждение темы — «слоты ускоряют доступ». На 3.13.13, лучшее из 7 раундов:

со слотами без слотов после vars(obj)
чтение o.a 8,78 8,51 8,53
запись o.a = 5 8,33 8,36 32,37

Разница между слотами и inline values — около трёх процентов, и при чтении она направлена не в пользу слотов. Утверждение «__slots__ ускоряет доступ» замером не подтверждается. Зато подтверждается другое: обращение к vars(obj) удорожает запись почти вчетверо (+287 %), а чтение не трогает.

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

Числа сравниваются только между собой: они получены одним процессом на одной сборке. Между версиями время не сравнивается вовсе (корневой bench/README.md).

Операция повторяется 50 раз внутри одного витка timeit, и это не украшение. Проверка 2 печатает оба режима рядом. Без амортизации: pass 12,36 нс, o.a со слотами 19,44, без слотов 19,56 — то есть на сигнал приходится 36 % числа, остальное цикл, и «разница» между двумя последними строками лежит внутри его шума. После амортизации дно метода — 1,43 нс против измеряемых 8, и числа стали означать то, что должны.

Чем это меряли

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

Скрипт

340 строк
"""Что `__slots__` делает на уровне модели данных — до всяких байтов.

ЗАЧЕМ ЭТОТ СКРИПТ. `memory.py`, `layout.py` и `access.py` отвечают на вопрос
«сколько это стоит в CPython такой-то версии». Здесь другой вопрос и другой
уровень: что `__slots__` МЕНЯЕТ В ОБЪЕКТНОЙ МОДЕЛИ, независимо от того, как
конкретная реализация раскладывает объект в памяти.

Разделение не формальное. Всё, что печатает этот файл, одинаково на 3.11,
3.12, 3.13 и 3.14 — и должно остаться одинаковым в следующих версиях, потому
что это контракт языка. Всё, что печатают три соседних файла, между этими же
версиями РАСХОДИТСЯ, потому что это детали реализации. Читатель, у которого эти
два уровня склеены, делает из «на 3.13 подкласс стоит 72 байта» вывод про
правила наследования — а это разные этажи.

ЧТО ЗДЕСЬ ПОКАЗАНО:

  1. слот — это дескриптор данных НА КЛАССЕ, а не «поле объекта»;
  2. перечисленные имена разрешены, остальные запрещены;
  3. `__dict__` и `__weakref__` автоматически не создаются;
  4. `'__dict__'` можно вернуть, назвав его в слотах, — запрет не абсолютен;
  5. наследование: подкласс без своих слотов получает словарь обратно;
  6. повторное объявление имени слота в подклассе — то, чего делать нельзя;
  7. непустые слоты запрещены ровно тем, у кого __itemsize__ != 0;
  8. словарная форма слотов задаёт докстринги атрибутов.

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

ЗАПУСК:

    for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v bench/slots/contract.py; done
"""

import inspect
import sys
import weakref


def rule(title):
    print(f"\n=== {title} ===\n")


def show(label, fn, with_message=False):
    """Печатает результат или ТИП исключения.

    Текст сообщения намеренно НЕ печатается по умолчанию: он между версиями
    меняется (в 3.12 к AttributeError про запись добавили «and no __dict__ for
    setting new attributes», а в 3.13 в текст попал квалифицированный путь
    класса). Контракт — какое исключение возбуждается, а не какими словами оно
    об этом сообщает. Первый черновик печатал текст, и вывод файла разъехался
    между сборками ровно в тех местах, где ничего не менялось по существу.
    """
    try:
        print(f"    {label:<38} {fn()}")
    except Exception as exc:  # noqa: BLE001
        tail = f": {exc}" if with_message else ""
        print(f"    {label:<38} {type(exc).__name__}{tail}")


def block_descriptor():
    rule("БЛОК 1. СЛОТ — ЭТО ДЕСКРИПТОР ДАННЫХ НА КЛАССЕ")

    class User:
        __slots__ = ("name",)

    d = User.__dict__["name"]
    print("  Объявление __slots__ = ('name',) кладёт В СЛОВАРЬ КЛАССА объект:")
    print()
    show("User.__dict__['name']", lambda: repr(d))
    show("его тип", lambda: type(d).__name__)
    show("есть __get__", lambda: hasattr(d, "__get__"))
    show("есть __set__", lambda: hasattr(d, "__set__"))
    print()
    print("  Оба метода на месте — значит, это дескриптор ДАННЫХ, и по правилу")
    print("  приоритета он сильнее словаря экземпляра. Отсюда и берётся всё")
    print("  остальное поведение: не «объект хранит поле», а «класс хранит")
    print("  дескриптор, который знает смещение внутри экземпляра».")
    print()
    u = User()
    show("до присваивания u.name", lambda: u.name)
    u.name = "ann"
    show("после u.name = 'ann'", lambda: u.name)
    show("значение через дескриптор", lambda: d.__get__(u, User))
    print()
    print("  Последняя строка — то же самое значение, добытое в обход точки.")
    print("  Точка ничего своего не делает: она вызывает этот же дескриптор.")


def block_allowed():
    rule("БЛОК 2. КАКИЕ ИМЕНА РАЗРЕШЕНЫ")

    class Point:
        __slots__ = ("x", "y")

    p = Point()
    p.x, p.y = 1, 2
    show("p.x, p.y", lambda: (p.x, p.y))
    show("p.z = 3", lambda: setattr(p, "z", 3))
    print()
    print("  Причина отказа — не «запрет на новые атрибуты» как правило, а")
    print("  отсутствие места: под z смещения не отведено, а словаря, куда")
    print("  можно было бы положить что угодно, нет.")
    print()
    show("hasattr(p, '__dict__')", lambda: hasattr(p, "__dict__"))
    show("Point.__dictoffset__ == 0", lambda: Point.__dictoffset__ == 0)
    print()
    print("  Нулевой __dictoffset__ и означает «словаря у экземпляра нет вовсе».")
    print("  Само число здесь не печатается: оно из этажа реализации и между")
    print("  версиями разное (layout.py), а ноль против не-нуля — из контракта.")


def block_weakref():
    rule("БЛОК 3. `__dict__` И `__weakref__` НЕ СОЗДАЮТСЯ САМИ")

    class Bare:
        __slots__ = ("a",)

    class WithWeak:
        __slots__ = ("a", "__weakref__")

    show("weakref.ref(Bare())", lambda: weakref.ref(Bare()))
    show("weakref.ref(WithWeak())", lambda: type(weakref.ref(WithWeak())).__name__)
    print()
    print("  Это тот же механизм, что и с атрибутами: слот под слабую ссылку")
    print("  тоже надо объявить. Оба имени — служебные, но перечисляются так же,")
    print("  как обычные.")


def block_dict_in_slots():
    rule("БЛОК 4. `'__dict__'` В СЛОТАХ: ЗАПРЕТ НЕ АБСОЛЮТЕН")

    class Flexible:
        __slots__ = ("a", "b", "__dict__")

    f = Flexible()
    f.a = 1
    f.whatever = 2
    show("f.a (слот)", lambda: f.a)
    show("f.whatever = 2 (динамический)", lambda: f.whatever)
    show("vars(f)", lambda: vars(f))
    show("Flexible.__dictoffset__ != 0", lambda: Flexible.__dictoffset__ != 0)
    print()
    print("  Обратите внимание на vars(f): в словаре только whatever. Значения")
    print("  слотов там НЕ появляются — они лежат по своим смещениям, и словарь")
    print("  про них ничего не знает. Отсюда практическое следствие: код,")
    print("  который сериализует объект через vars() или __dict__, у такого")
    print("  класса увидит не все атрибуты.")
    print()
    print("  Зачем это нужно: слоты дают предсказуемую раскладку и экономию для")
    print("  объявленных имён, а словарь оставляет дверь для динамических. Это")
    print("  третий вариант между «обычный класс» и «только слоты», и в")
    print("  библиотечном коде он встречается чаще, чем кажется.")


def block_inheritance():
    rule("БЛОК 5. НАСЛЕДОВАНИЕ")

    class Base:
        __slots__ = ("x",)

    class ChildBare(Base):
        pass

    class ChildEmpty(Base):
        __slots__ = ()

    class ChildOwn(Base):
        __slots__ = ("y",)

    print("  Правило одно: словарь заводится тому, кто НЕ объявил __slots__.")
    print()
    for name, cls in (("Base", Base), ("ChildBare (нет строки)", ChildBare),
                      ("ChildEmpty (__slots__ = ())", ChildEmpty),
                      ("ChildOwn (__slots__ = ('y',))", ChildOwn)):
        has = cls.__dictoffset__ != 0
        print(f"    {name:<30} __dict__ {'есть' if has else 'нет'}")
    print()
    print("  Пустой кортеж — не «слотов нет», а «своих слотов нет, и словарь")
    print("  заводить не надо». Разница между второй и третьей строкой — вся")
    print("  ловушка наследования целиком, и она НЕ зависит от версии.")
    print()
    c = ChildOwn()
    c.x, c.y = 1, 2
    show("унаследованный слот c.x", lambda: c.x)
    show("свой слот c.y", lambda: c.y)


def block_duplicate():
    rule("БЛОК 6. ЧЕГО ДЕЛАТЬ НЕЛЬЗЯ: ПОВТОР ИМЕНИ СЛОТА В ПОДКЛАССЕ")

    class A:
        __slots__ = ("x",)

    class B(A):
        __slots__ = ("x",)          # то же имя, что у базы

    print("  Объявление проходит без ошибки — и это худшее, что может быть:")
    print("  ошибки нет, а поведение испорчено.")
    print()
    show("A.__dict__['x'] is B.__dict__['x']", lambda: A.__dict__["x"] is B.__dict__["x"])
    show("у B появился свой слот", lambda: B.__basicsize__ > A.__basicsize__)
    print()
    print("  У B ДВА слота под одним именем — под каждый отведено своё место,")
    print("  и место базы тратится впустую (сколько именно байт — в layout.py,")
    print("  это уже реализация). Дескриптор подкласса перекрывает базовый, и")
    print("  слот базы становится недоступен обычным обращением по имени —")
    print("  добраться до него можно только через дескриптор базы напрямую:")
    print()
    b = B()
    b.x = "значение подкласса"
    A.__dict__["x"].__set__(b, "значение базы")
    show("b.x", lambda: b.x)
    show("A.__dict__['x'].__get__(b, B)", lambda: A.__dict__["x"].__get__(b, B))
    print()
    print("  Два разных значения под одним именем в одном объекте. Правило:")
    print("  имя слота в подклассе не повторять.")


def block_varlength():
    rule("БЛОК 7. КОМУ НЕПУСТЫЕ СЛОТЫ ЗАПРЕЩЕНЫ")

    print("  Правило обычно пересказывают списком: «нельзя подклассам int,")
    print("  bytes, tuple». Список запоминать не нужно — есть критерий, и его")
    print("  видно в одну строку у любого типа:")
    print()
    print("    непустые __slots__ запрещены тогда и только тогда,")
    print("    когда у базового типа __itemsize__ != 0")
    print()
    print("  Такой тип хранит переменное число элементов прямо в объекте, его")
    print("  размер зависит от содержимого, и фиксированное смещение под слот")
    print("  назначить некуда.")
    print()
    print(f"    {'тип':<12}{'itemsize':>9}   непустые слоты")
    for base in (tuple, bytes, int, str, float, list, dict, set,
                 frozenset, bytearray, complex, object):
        try:
            type("X", (base,), {"__slots__": ("extra",)})
            verdict = "разрешены"
        except TypeError:
            verdict = "TypeError"
        print(f"    {base.__name__:<12}{base.__itemsize__:>9}   {verdict}")
    print()
    print("  Критерий сходится на всех двенадцати: у tuple, bytes и int")
    print("  itemsize не ноль — им нельзя; у остальных ноль — им можно.")
    print()
    print("  Отдельно про str: он переменной длины по смыслу, но itemsize у него")
    print("  ноль — строка держит свои данные сама, а не как массив элементов")
    print("  объекта. Поэтому подклассу str непустые слоты РАЗРЕШЕНЫ. Это тот")
    print("  случай, где список «int, bytes, tuple» дал бы неверный ответ, а")
    print("  критерий даёт верный.")
    print()
    print("  Пустые слоты разрешены всем — они ничего не требуют:")
    print()
    for base in (tuple, int, bytes):
        try:
            cls = type("Y", (base,), {"__slots__": ()})
            print(f"    class Y({base.__name__}): __slots__ = ()"
                  f"{'':<{max(0, 20 - len(base.__name__))}} объявлен, "
                  f"__dict__ {'есть' if cls.__dictoffset__ else 'нет'}")
        except TypeError as exc:
            print(f"    class Y({base.__name__}): __slots__ = ()   TypeError: {exc}")


def block_dictform():
    rule("БЛОК 8. СЛОВАРНАЯ ФОРМА: ДОКСТРИНГИ АТРИБУТОВ")

    class User:
        __slots__ = {
            "name": "имя пользователя",
            "age": "возраст в полных годах",
        }

    print("  __slots__ принимает не только кортеж. Словарь задаёт те же слоты,")
    print("  а значения становятся документацией атрибутов:")
    print()
    show("сами слоты", lambda: sorted(User.__slots__))
    show("inspect.getdoc(User.name)", lambda: inspect.getdoc(User.name))
    show("inspect.getdoc(User.age)", lambda: inspect.getdoc(User.age))
    print()
    u = User()
    u.name, u.age = "ann", 30
    show("работает как обычные слоты", lambda: (u.name, u.age))
    show("hasattr(u, '__dict__')", lambda: hasattr(u, "__dict__"))
    print()
    print("  Редкая форма, но именно она даёт слотам то, чего у них иначе нет:")
    print("  help() и инструменты документации видят описание каждого атрибута.")


def block_summary():
    rule("БЛОК 9. ЧТО ИЗ ЭТОГО КОНТРАКТ, А ЧТО РЕАЛИЗАЦИЯ")

    print("  Всё, что напечатано выше, — контракт: одинаково на 3.11, 3.12,")
    print("  3.13 и 3.14 и не зависит от раскладки объекта в памяти.")
    print()
    print("    объявление имён и запрет остальных")
    print("    дескриптор данных на классе")
    print("    отсутствие __dict__ и __weakref__ по умолчанию")
    print("    '__dict__' в слотах возвращает динамические атрибуты")
    print("    подкласс без своих слотов получает словарь обратно")
    print("    повтор имени слота портит поведение молча")
    print("    непустые слоты запрещены ровно при __itemsize__ != 0")
    print("    словарная форма задаёт докстринги")
    print()
    print("  А это — реализация, и она между теми же версиями расходится:")
    print()
    print("    сколько байт занимает экземпляр      memory.py")
    print("    где лежит словарь и лежит ли вообще  layout.py")
    print("    что показывает sys.getsizeof         memory.py")
    print("    во что специализируется обращение    access.py")
    print()
    print("  Первый список — то, на что можно опираться в коде. Второй — то,")
    print("  что надо проверять на своей версии.")


def main():
    print("__SLOTS__: КОНТРАКТ ЯЗЫКА")
    print()
    print(f"Python {sys.version.split()[0]}")
    build = sys.version.split("[", 1)[1].rstrip("]") if "[" in sys.version else "?"
    print(f"Сборка: {build}")
    print()
    print("Ни байтов, ни времени: печатаются значения, типы и исключения.")
    print("Вывод этого файла обязан совпадать на всех поддерживаемых версиях.")

    block_descriptor()
    block_allowed()
    block_weakref()
    block_dict_in_slots()
    block_inheritance()
    block_duplicate()
    block_varlength()
    block_dictform()
    block_summary()

    print()
    print("Все строки выше напечатаны этой программой.")


if __name__ == "__main__":
    main()