Learning Platform
Глоссарий Troubleshooting
Урок 05.01 · 25 мин
Продвинутый
PyTypeObjectPyObjecttp_dicttp_mrotp_dictoffsetMetaclass

Class как PyTypeObject: всё, чем является объект Python

«Class — это шаблон для создания объектов» — стандартное OOP-определение, которое ничего не объясняет о том, где этот шаблон живёт, как он связан с конкретным instance, и почему type(obj) и obj.__class__ всегда совпадают. На уровне CPython ответ конкретный: класс — это runtime-инстанс C-структуры PyTypeObject, а instance — это PyObject с указателем на этот type. Никакой магии: data + behavior, упакованные в один struct.

В этом уроке мы откроем Include/cpython/object.h и прочтём PyTypeObject слот за слотом — tp_dict, tp_mro, tp_init, tp_new, tp_basicsize, tp_dictoffset. После этого вы сможете объяснить, почему class C: items = [] шарит список между всеми instances (M03 cross-link), почему type(type) is type (its-own-metaclass), и где физически живёт instance.__dict__ (spoiler: в PyDictObject — той самой структуре из M02 урок 03).


PyObject и PyTypeObject — двойная природа объектов

Каждый Python-объект в CPython — это указатель на структуру PyObject (header: refcount + type pointer):

// Include/object.h - упрощённо
typedef struct _object {
    Py_ssize_t ob_refcnt;       // reference count (refcount tracking, M01 урок 01)
    PyTypeObject *ob_type;      // указатель на класс (= тип объекта)
} PyObject;

Вот и всё, что есть у любого объекта: refcount + ob_type. Сам тип (class) — отдельная структура PyTypeObject, которая хранит всё про этот класс: имя, размер instance, словарь атрибутов, MRO, slot wrappers.

// Include/cpython/object.h - PyTypeObject (упрощённо, реальная struct ~70 полей)
struct _typeobject {
    PyObject_VAR_HEAD                   // ob_refcnt + ob_type + ob_size
    const char *tp_name;                // имя класса для repr
    Py_ssize_t tp_basicsize;            // размер instance в байтах
    Py_ssize_t tp_itemsize;             // size per element (для var-types: list, tuple)

    // Slot wrappers (object protocol):
    destructor tp_dealloc;              // <- __del__
    reprfunc tp_repr;                   // <- __repr__
    hashfunc tp_hash;                   // <- __hash__
    ternaryfunc tp_call;                // <- __call__
    richcmpfunc tp_richcompare;         // <- __eq__/__lt__/...

    // Access protocol:
    getattrofunc tp_getattro;           // <- __getattribute__
    setattrofunc tp_setattro;           // <- __setattr__

    // Allocation/init protocol:
    initproc tp_init;                   // <- __init__
    newfunc tp_new;                     // <- __new__
    allocfunc tp_alloc;                 // <- __alloc__ (rarely overridden)

    // Class metadata:
    PyObject *tp_dict;                  // словарь атрибутов класса (методы + class-vars)
    PyObject *tp_mro;                   // tuple - method resolution order
    PyObject *tp_bases;                 // tuple базовых классов
    PyTypeObject *tp_base;              // первый базовый класс (для simple inheritance)

    Py_ssize_t tp_dictoffset;           // offset до instance->__dict__ внутри instance
    Py_ssize_t tp_weaklistoffset;       // offset до weakref списка
    // ... ещё ~50 полей
};

PyTypeObject сам по себе тоже PyObject: PyObject_VAR_HEAD — это ob_refcnt + ob_type + ob_size. То есть класс — это тоже объект с собственным refcount и собственным ob_type. И вот тут возникает рекурсия: если C — это PyTypeObject, то у C есть ob_type. Что туда указывает?

class C: pass

print(type(C))         # <class 'type'>
print(type(type))      # <class 'type'>  - type своего же типа
print(type is type.__class__)  # True

type — это сам PyTypeObject под именем type. Его ob_type указывает на самого себя — это терминирующее условие рекурсии. type — это its own metaclass. Все обычные классы (которые вы пишете через class C: ...) имеют type как metaclass; metaclass’ы — пользовательские subclass’ы type.

PyObject + PyTypeObject — двойная природа
instancePyObjectЛюбой объект: list, dict, ваш custom class instance. Имеет ob_refcnt (для refcount GC) + ob_type (указывает на class).
ob_type
classPyTypeObjectСам класс - тоже PyObject (с PyObject_VAR_HEAD), но содержит дополнительные поля: tp_dict (методы), tp_mro (resolution order), tp_init/new/dealloc (allocation lifecycle).
ob_type
metaclasstypeКласс класса = metaclass. Для всех обычных классов это type. Сам type имеет ob_type указывающий на type - терминирующая рекурсия (its-own-metaclass).

Cite: Include/object.hPyObject struct; Include/cpython/object.hPyTypeObject (full ~600 lines).


Layout PyTypeObject — самые важные слоты

Из ~70 полей PyTypeObject нам важны эти (остальные — для C extension authors, generic GC, buffer protocol):

СлотЧто хранитPython-equivalent
tp_nameC-строка имени классаcls.__name__
tp_basicsizeразмер instance в байтахinstance.__sizeof__() baseline
tp_dictPyDictObject со всеми атрибутами классаcls.__dict__
tp_mroPyTupleObject — линеаризованный порядок поискаcls.__mro__
tp_basestuple прямых базcls.__bases__
tp_initC-функция, вызываемая при инициализацииcls.__init__
tp_newC-функция, выделяющая memory + создающая instancecls.__new__
tp_deallocC-функция уничтожения, при refcount → 0cls.__del__ (примерно)
tp_dictoffsetoffset внутри instance до его __dict__(нет напрямую)
tp_callесли задано — instance callablecls.__call__
tp_reprформирование reprcls.__repr__
tp_hashхэш instancecls.__hash__
tp_richcompareсравнения (__eq__/__lt__/__gt__/…)cls.__eq__ etc.

tp_dict — самое важное. Это PyDictObject (тот самый из M02 урок 03 — open addressing, perturbation probe, USABLE_FRACTION 2/3). Когда вы пишете class C: x = 10; def f(self): pass, компилятор создаёт tp_dict со entries {'x': 10, 'f': <function f>, '__init__': ...}. Lookup C.xtp_dict['x'] через lookdict() — за O(1) average.

class C:
    x = 10
    def f(self): return self.x

print(type(C.__dict__))   # <class 'mappingproxy'> - read-only wrapper над PyDictObject
print(dict(C.__dict__))   # {'__module__': '__main__', 'x': 10, 'f': <function ...>, ...}
print(type(C.__mro__))    # <class 'tuple'>
print(C.__mro__)          # (<class 'C'>, <class 'object'>)

Note: cls.__dict__ оборачивается в mappingproxy — read-only view, чтобы предотвратить случайные модификации tp_dict извне (которые сломали бы attribute caches PEP 509 ma_version_tag, см. M02 урок 03).

Cite: Include/cpython/object.htp_dict/tp_mro/tp_dictoffset определения; Objects/typeobject.c — реализация type construction (type_new_impl).


Instance: PyObject_HEAD + tp_dictoffset → dict

Instance — это просто блок памяти размера tp_basicsize. Layout:

[ ob_refcnt | ob_type | ... attrs ... | __dict__ pointer | __weakref__ pointer ]
^                                       ^
|                                       tp_dictoffset (если > 0)
PyObject_HEAD

tp_dictoffset — это offset в байтах от начала instance до места, где лежит pointer на __dict__. По соглашению offset положительный → fixed offset (для plain Python classes); отрицательный → calculated relative to end (для var-types).

Когда вы пишете obj.x = 10, CPython:

  1. Находит tp_dictoffset у type(obj).
  2. Берёт указатель instance + tp_dictoffset → это PyObject **dict_ptr.
  3. Если *dict_ptr == NULL — lazily аллоцирует пустой PyDictObject (см. Objects/object.c функция _PyObject_GenericSetAttrWithDict).
  4. Делает PyDict_SetItemString(*dict_ptr, "x", value) — стандартная dict insertion (M02 урок 03).
class C:
    pass

c = C()
print(c.__dict__)       # {} - lazy: пустой dict создаётся при первом setattr
c.x = 10
print(c.__dict__)       # {'x': 10}
print(type(c.__dict__)) # <class 'dict'>  (НЕ mappingproxy — instance dict mutable)

Это тот же PyDictObject из M02 урок 03 — open addressing с perturbation probe. Каждый instance с __dict__ несёт свою dict-таблицу (на cpython-3.12.7 пустой __dict__ весит 296 байт). Поэтому 1M instances → ~300MB только под dicts — мотивация для __slots__ (урок 5).

Instance layout: PyObject_HEAD + tp_dictoffset → __dict__
instance startob_refcnt + ob_typePyObject_HEAD: 16 байт на 64-bit (8 байт refcount + 8 байт type pointer). Этот header есть у любого Python-объекта.
+ tp_dictoffset bytes
instance.__dict__ slotPyObject *8 байт - pointer на отдельно-аллоцированный PyDictObject. NULL до первого setattr (lazy allocation).
dereference + lookdict
PyDictObjectM02 урок 03Тот же hash table, что dict {} - open addressing, perturbation probe, USABLE_FRACTION 2/3. На cpython-3.12.7 пустой __dict__ занимает 296 байт - значимый overhead.

Cite: Objects/object.c_PyObject_GenericGetAttrWithDict / _PyObject_GenericSetAttrWithDict.

NOTE

Cross-link M02 урок 03 — instance __dict__ физически реализован как PyDictObject. Это одна и та же структура, что и обычный {}-литерал: open addressing, perturbation probe (5*i + 1 + perturb) & mask, load factor ≤ 2/3 (USABLE_FRACTION). Когда вы пишете obj.attr, под капотом дёргается lookdict() — те же 200 строк CPython, что мы изучили в Module 02.


type(obj) и obj.class — две стороны медали

В Python есть два способа спросить «какой тип у объекта»:

class C: pass
c = C()

print(type(c))           # <class 'C'>
print(c.__class__)       # <class 'C'>
print(type(c) is c.__class__)  # True

Откуда они берутся?

  • type(obj) — вызывает C-функцию Py_TYPE(obj), которая просто читает поле obj->ob_type (см. Include/object.h макрос Py_TYPE). Это самый прямой путь.
  • obj.__class__ — обычный attribute lookup через tp_getattro. Для object’а __class__ — это property (data descriptor), которая возвращает Py_TYPE(obj). То есть результат идентичен.

Различие: obj.__class__ можно (теоретически) переопределить (некоторые старые библиотеки делают это, но это плохая практика — PEP 8 рекомендует type() для сравнения типов). type(obj) всегда возвращает реальный ob_type.

# Best practice: type() для сравнения, isinstance() для иерархии
isinstance(c, C)      # True - проверяет MRO
type(c) is C          # True - точное совпадение типа
type(c) == C          # True - но __eq__ может быть переопределён

type — its own metaclass (краткое упоминание)

Каждый класс имеет metaclass — класс класса. По умолчанию metaclass = type. Это значит:

class C: pass

print(type(C))           # <class 'type'>      - C создан через type
print(type(type))        # <class 'type'>      - type создан через type (its-own)
print(type.__bases__)    # (<class 'object'>,) - type наследует от object
print(object.__class__)  # <class 'type'>      - object создан через type

type — единственный класс, чьи ob_type указывает на самого себя (терминатор рекурсии). Когда вы пишете class C(metaclass=Meta): ..., под капотом Python вызывает Meta('C', (object,), {...attrs...}) — это та самая trinity (name, bases, dict), что вы видите в type.__init__ signature.

TIP

Metaclass — отложенная тема (per Phase 64 D-05). 99% Python-разработчиков не пишут metaclass’ы. Это инструмент авторов фреймворков (Django ORM, SQLAlchemy declarative, Pydantic): они используют metaclass для перехвата создания класса и регистрации его в реестре. Полное покрытие отложено в Advanced Python milestone. Ключевая идея для текущего урока: type — сам metaclass, и любой обычный класс имеет type как metaclass. Если хотите глубже — почитайте Python data model § Metaclasses и type.__call__ в Objects/typeobject.c.


Прямое следствие того, что class C: items = [] записывает [] в tp_dict['items'] (один раз, при создании класса):

class C:
    items = []          # этот [] - ОДИН объект, живёт в C.__dict__['items']

a = C()
b = C()
a.items.append(1)
print(b.items)          # [1] - тот же list! (a.items и b.items - оба читают C.__dict__['items'])

a.items = [99]          # rebinding на instance level - создаёт a.__dict__['items']
print(a.items)          # [99]
print(b.items)          # [1]   - всё ещё shared class-level
print(C.items)          # [1]   - class-level не изменился

Где живёт items после класс-создания? В C.__dict__['items'] — то есть в tp_dict PyTypeObject’е C. Lookup a.items:

  1. Look up 'items' в type(a).__mro__ (C → object). Нашли в C.__dict__.
  2. 'items' — не data descriptor (нет __set__), значит Python сначала проверяет instance dict.
  3. a.__dict__ пустой → fallback на найденный class attribute.
  4. a.items → тот же list, что и C.items. Mutation видна всем.

(Lookup precedence — это descriptor protocol, который мы рассмотрим в уроке 4.)

Параллель с M03 урок 01 (mutable default trap): там def f(x, lst=[])[] evaluated один раз и хранится в func.__defaults__. Тут — то же самое, но [] хранится в cls.__dict__. Разные места, одна и та же ошибка: mutable default evaluated once, shared across all callers.

Каноничное решение: instance attributes через __init__ (создаются per-instance):

class C:
    def __init__(self):
        self.items = []     # создаётся НОВЫЙ list для каждого instance

a = C()
b = C()
a.items.append(1)
print(b.items)              # [] - не shared

Cross-course context

Семейство движков MergeTree: иерархия и выбор PhysicalPlan: исполняемые операторы DataFusion

Cross-course → ClickHouse: 03/01 mergetree-base — иерархия Python-классов (PyTypeObject + tp_base/tp_bases/tp_mro) параллельна семейству MergeTree-engines: MergeTree — base; ReplacingMergeTree, SummingMergeTree, AggregatingMergeTree, CollapsingMergeTree наследуют merge-логику и переопределяют только specifc слоты (как dunder-методы в Python __eq__/__hash__). Та же модель «base struct + slot overrides», только в storage engine domain.


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

  1. Класс — это runtime-инстанс PyTypeObject, не abstract концепт. Слоты tp_dict (атрибуты), tp_mro (linearization), tp_init/tp_new (lifecycle), tp_basicsize (memory) — конкретные C-поля.
  2. Instance — это PyObject с ob_type pointer’ом на PyTypeObject. tp_dictoffset указывает, где внутри instance лежит pointer на __dict__.
  3. Instance __dict__ — это PyDictObject (M02 урок 03), создающийся lazy при первом setattr. Каждый instance с __dict__ несёт ~296 байт overhead (мотивация __slots__ — урок 5).
  4. type(obj) и obj.__class__ оба возвращают Py_TYPE(obj). Predictable и stable — class identity всегда определена через ob_type.
  5. type — its own metaclass: type(type) is type. Все обычные классы созданы через type как metaclass. Полное metaclass coverage отложено (99% разработчиков не пишут metaclass’ы); ключевая идея — metaclass это просто type или его subclass.
  6. Class attribute trap: class C: items = [] шарит один list между instances (тот же mechanism, что mutable-default-trap в M03 урок 01). Решение: инициализация в __init__ (instance-level).

В уроке M04-02 разберём dunder-методы и slot wrappers: как Python вызывает C-функции из tp_* слотов, и почему __eq__/__hash__ invariant fundamental на уровне CPython.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое `PyTypeObject` в CPython?

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

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

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

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