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.
Cite: Include/object.h — PyObject struct; Include/cpython/object.h — PyTypeObject (full ~600 lines).
Layout PyTypeObject — самые важные слоты
Из ~70 полей PyTypeObject нам важны эти (остальные — для C extension authors, generic GC, buffer protocol):
| Слот | Что хранит | Python-equivalent |
|---|---|---|
tp_name | C-строка имени класса | cls.__name__ |
tp_basicsize | размер instance в байтах | instance.__sizeof__() baseline |
tp_dict | PyDictObject со всеми атрибутами класса | cls.__dict__ |
tp_mro | PyTupleObject — линеаризованный порядок поиска | cls.__mro__ |
tp_bases | tuple прямых баз | cls.__bases__ |
tp_init | C-функция, вызываемая при инициализации | cls.__init__ |
tp_new | C-функция, выделяющая memory + создающая instance | cls.__new__ |
tp_dealloc | C-функция уничтожения, при refcount → 0 | cls.__del__ (примерно) |
tp_dictoffset | offset внутри instance до его __dict__ | (нет напрямую) |
tp_call | если задано — instance callable | cls.__call__ |
tp_repr | формирование repr | cls.__repr__ |
tp_hash | хэш instance | cls.__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.x → tp_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.h — tp_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:
- Находит
tp_dictoffsetуtype(obj). - Берёт указатель
instance + tp_dictoffset→ этоPyObject **dict_ptr. - Если
*dict_ptr == NULL— lazily аллоцирует пустойPyDictObject(см.Objects/object.cфункция_PyObject_GenericSetAttrWithDict). - Делает
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).
Cite: Objects/object.c — _PyObject_GenericGetAttrWithDict / _PyObject_GenericSetAttrWithDict.
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.
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 attribute trap (cross-link M03 mutable default)
Прямое следствие того, что 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:
- Look up
'items'вtype(a).__mro__(C → object). Нашли вC.__dict__. 'items'— не data descriptor (нет__set__), значит Python сначала проверяет instance dict.a.__dict__пустой → fallback на найденный class attribute.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: исполняемые операторы DataFusionCross-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.
Ключевые выводы
- Класс — это runtime-инстанс
PyTypeObject, не abstract концепт. Слотыtp_dict(атрибуты),tp_mro(linearization),tp_init/tp_new(lifecycle),tp_basicsize(memory) — конкретные C-поля. - Instance — это
PyObjectсob_typepointer’ом наPyTypeObject.tp_dictoffsetуказывает, где внутри instance лежит pointer на__dict__. - Instance
__dict__— этоPyDictObject(M02 урок 03), создающийся lazy при первом setattr. Каждый instance с__dict__несёт ~296 байт overhead (мотивация__slots__— урок 5). type(obj)иobj.__class__оба возвращаютPy_TYPE(obj). Predictable и stable — class identity всегда определена черезob_type.type— its own metaclass:type(type) is type. Все обычные классы созданы черезtypeкак metaclass. Полное metaclass coverage отложено (99% разработчиков не пишут metaclass’ы); ключевая идея — metaclass это простоtypeили его subclass.- 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.