Dunder-методы и slot wrappers: как Python вызывает C
__init__, __call__, __getitem__, __eq__, __hash__ — методы с двумя подчёркиваниями (dunder = double underscore). Они выглядят как обычные методы, но за ними стоит специальный механизм: каждый dunder связан с конкретным C-слотом в PyTypeObject, и Python не вызывает их напрямую через атрибут lookup, а через slot wrapper — C-функцию, которая знает, какой слот в PyTypeObject дёрнуть.
В этом уроке мы откроем Objects/typeobject.c и проследим, как __init__ становится tp_init, как __eq__/__hash__ invariant fundamental на уровне CPython (а не «контракт на бумаге»), и почему slot lookup быстрее обычного __dict__ lookup для C-типов.
Что такое dunder и зачем double underscore
__double_under__ — соглашение об именовании, указывающее: этот метод имеет специальное значение для Python runtime. Не «protected» (как в Java); не «name-mangled» (это _single или __double без хвостового __). Dunder-методы вызываются через синтаксические триггеры: obj() → __call__, obj[k] → __getitem__, obj + x → __add__, if obj: → __bool__ или __len__, и так далее.
class C:
def __init__(self, x): # вызывается C(10)
self.x = x
def __repr__(self): # вызывается repr(c) или print(c)
return f"C(x={self.x})"
def __call__(self, y): # вызывается c(20)
return self.x + y
def __getitem__(self, k): # вызывается c[k]
return self.x * k
c = C(10)
print(c) # repr(c) → C(x=10)
print(c(5)) # c.__call__(5) → 15
print(c[3]) # c.__getitem__(3) → 30
Список dunder-методов фиксирован: их около 100, перечислены в Python data model. Создавать собственные __double__ имена — нельзя (зарезервированы для будущих версий runtime).
Slot wrapper machinery (Objects/typeobject.c)
Когда вы определяете __init__ в классе на Python, происходит следующее:
- Compiler создаёт
function objectдля__init__и кладёт вcls.__dict__['__init__']. - CPython type construction (
type_new_implвObjects/typeobject.c) обходит__dict__, ищет dunder-имена. - Для каждого распознанного dunder выставляется соответствующий C-слот в
PyTypeObject. Например,__init__→tp_init = slot_tp_init.
slot_tp_init — generic wrapper, который:
- Лезет обратно в
cls.__dict__['__init__']черезlookup_method(). - Вызывает Python-функцию с правильными arg unpacking.
- Возвращает результат.
// Objects/typeobject.c - упрощённый паттерн SLOT_FUNCTION
static int
slot_tp_init(PyObject *self, PyObject *args, PyObject *kwds)
{
PyObject *meth = lookup_method(self, &_Py_ID(__init__), &unbound);
if (meth == NULL) return -1;
PyObject *res = call_method(meth, args, kwds);
if (res == NULL) return -1;
// __init__ должен возвращать None
if (res != Py_None) {
PyErr_Format(PyExc_TypeError, "__init__() should return None");
return -1;
}
Py_DECREF(res);
return 0;
}
Когда CPython внутренне вызывает tp_init (например, при type.__call__ после tp_new), он уже знает, что для Python-defined класса tp_init = slot_tp_init. Никакого dynamic dispatch на уровне dunder lookup’а каждый раз — связь установлена один раз при создании класса.
Cite: Objects/typeobject.c — функция fixup_slot_dispatchers() (вызывается при type_new), которая по tp_dict выставляет нужные tp_* слоты; макрос SLOT0/SLOT1/SLOT2 для генерации wrapper’ов.
Mapping table: dunder → C slot
Вот ключевая таблица соответствий (см. Python data model + Include/cpython/object.h):
| Dunder | C-слот | Триггер |
|---|---|---|
__init__ | tp_init | Cls(...) после tp_new |
__new__ | tp_new | Cls(...) allocation phase |
__del__ | tp_finalize (через tp_dealloc) | при refcount → 0 |
__call__ | tp_call | obj(...) |
__repr__ | tp_repr | repr(obj), f"{obj!r}" |
__str__ | tp_str | str(obj), f"{obj}", print(obj) |
__hash__ | tp_hash | hash(obj), dict-key, set-element |
__eq__, __lt__, __gt__, __le__, __ge__, __ne__ | tp_richcompare (один слот, dispatch по op) | ==, <, >, <=, >=, != |
__getattribute__ | tp_getattro | obj.attr (любой attribute access) |
__setattr__ | tp_setattro | obj.attr = x |
__bool__ | tp_as_number->nb_bool | bool(obj), if obj: |
__len__ | tp_as_sequence->sq_length или tp_as_mapping->mp_length | len(obj) |
__getitem__ | tp_as_mapping->mp_subscript или tp_as_sequence->sq_item | obj[k] |
__setitem__ | tp_as_mapping->mp_ass_subscript | obj[k] = v |
__iter__ | tp_iter | iter(obj), for x in obj: |
__next__ | tp_iternext | next(it) |
__add__ | tp_as_number->nb_add | obj + x |
__contains__ | tp_as_sequence->sq_contains | x in obj |
Важно: tp_as_number, tp_as_sequence, tp_as_mapping, tp_as_async, tp_as_buffer — это группированные slot tables внутри PyTypeObject. CPython группирует операции по протоколам: number protocol (арифметика), sequence protocol (__getitem__/__len__/__contains__), mapping protocol (__getitem__/__setitem__/__delitem__), iteration protocol, async protocol, buffer protocol. Каждая группа — отдельная struct, prtoколы хранятся через указатель.
// Include/cpython/object.h - PyTypeObject (продолжение)
struct _typeobject {
// ... поля выше ...
PyNumberMethods *tp_as_number; // арифметика: nb_add/nb_sub/nb_mul/...
PySequenceMethods *tp_as_sequence; // sq_length, sq_item, sq_contains, ...
PyMappingMethods *tp_as_mapping; // mp_length, mp_subscript, mp_ass_subscript
PyAsyncMethods *tp_as_async; // am_await, am_aiter, am_anext
PyBufferProcs *tp_as_buffer; // bf_getbuffer, bf_releasebuffer
// ...
};
__getitem__ особенно интересен — он мапится либо в sq_item (sequence: integer index), либо в mp_subscript (mapping: any key). CPython smart-dispatches на основе того, что вы передали: obj[5] пробует sq_item сначала, obj['key'] или obj[slice] — mp_subscript.
Slot lookup vs __dict__ lookup — почему slots быстрее
Для C-implemented типов (list, dict, str, int, ваш C-extension) tp_* слоты заполнены прямо C-функциями — никакого dunder lookup’а вообще нет:
// Например, list.__getitem__:
PyTypeObject PyList_Type = {
...
.tp_as_sequence = &list_as_sequence,
};
PySequenceMethods list_as_sequence = {
.sq_length = list_length, // напрямую C-функция
.sq_item = list_item, // напрямую C-функция
...
};
Когда вы пишете lst[5], CPython:
- Берёт
Py_TYPE(lst) = PyList_Type. - Дёргает
tp_as_sequence->sq_item. - Это напрямую вызов C-функции
list_item(lst, 5)— direct array accessob_item[5](M02 урок 01).
Для Python-defined класса tp_as_sequence->sq_item = slot_sq_item, который делает lookup_method(self, '__getitem__') через tp_dict — это extra indirection, но всё ещё O(1) average (PyDictObject lookup).
# C-type: zero-indirection
import time
lst = list(range(1000))
start = time.perf_counter()
for _ in range(1_000_000):
x = lst[500]
elapsed_c = time.perf_counter() - start
# Python-defined class with __getitem__:
class MyList:
def __init__(self, data): self.data = data
def __getitem__(self, i): return self.data[i]
ml = MyList(list(range(1000)))
start = time.perf_counter()
for _ in range(1_000_000):
x = ml[500]
elapsed_py = time.perf_counter() - start
print(f"C list: {elapsed_c:.3f}s")
print(f"Python class: {elapsed_py:.3f}s") # ~3-5x slower
C-list: tp_as_sequence->sq_item = list_item — direct C call.
Python-class: tp_as_sequence->sq_item = slot_sq_item → lookup_method('__getitem__') → call user-defined Python function. Extra indirection ~3-5x.
Cite: Objects/typeobject.c — lookup_method() функция; Objects/object.c — _PyObject_LookupAttr.
Hashable invariant: eq/hash consistency (cross-link M02)
Самое важное dunder-правило: если __eq__ определён, __hash__ обязан быть consistent. Контракт:
Если
a == b(т.е.a.__eq__(b)возвращаетTrue), тоhash(a) == hash(b)обязательно.
Это fundamental для hash table: dict/set использует hash(key) для нахождения slot’а, потом key == stored_key для финального match. Если hash(a) != hash(b), но a == b — то после d[a] = 1; d[b], вы попадёте в разные probe sequences и не найдёте записанное.
Python enforces этот invariant конкретным механизмом: переопределяя __eq__ без __hash__, вы автоматически делаете класс unhashable. CPython сам устанавливает tp_hash = None, что вызывает TypeError при hash(obj).
class Bad:
def __init__(self, v):
self.v = v
def __eq__(self, other):
return isinstance(other, Bad) and self.v == other.v
# ВНИМАНИЕ: __hash__ не определён → автоматически None
b = Bad(1)
print(b == Bad(1)) # True
hash(b) # TypeError: unhashable type: 'Bad'
{b: "value"} # TypeError тоже
Почему так? CPython logic такой:
- Default
__hash__ = id(obj) // 16(object identity). - Default
__eq__ = (self is other)(identity comparison). - Если вы переопределили
__eq__(на content-based equality), то default__hash__(id-based) уже не consistent:Bad(1) == Bad(1)(True, content), ноid(Bad(1)) != id(Bad(1))(False, разные allocation). - Python предотвращает скрытый bug:
tp_hash = None→ unhashable.
Fix: явно определите __hash__, consistent с __eq__:
class Good:
def __init__(self, v):
self.v = v
def __eq__(self, other):
return isinstance(other, Good) and self.v == other.v
def __hash__(self):
return hash(self.v) # consistent с __eq__
g1 = Good(1)
g2 = Good(1)
print(g1 == g2) # True
print(hash(g1) == hash(g2)) # True - invariant соблюдён
{g1: "ok", g2: "still ok"} # один entry, потому что g1 == g2
tp_hash и tp_richcompare — разные C-слоты, но связаны invariant’ом. Cite: Objects/typeobject.c — type_new_impl() логика “if eq defined and hash not, set tp_hash = NULL”.
Cross-link M02 урок 06 (mutability/cache) — там мы установили: hashable требует stable identity. Mutable объекты не hashable, потому что hash может смещаться. Здесь мы видим механизм enforcement: __eq__/__hash__ invariant проверяется на уровне tp_* слотов CPython. Это не «контракт на бумаге» — это жёсткое requirement memory model. Тот же invariant — у tuple (см. M02 урок 02): tuple hashable, только если все элементы hashable.
Practical: repr vs str, len vs bool
__repr__ vs __str__ — оба возвращают строку, но смысл разный:
__repr__— для разработчика: должен быть «однозначный», в идеале eval’абельный (eval(repr(x)) == x). Используется в REPL, отладчиках, error messages,f"{x!r}".__str__— для пользователя: human-friendly. Используетсяprint(x),str(x),f"{x}".
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})" # eval-friendly
def __str__(self):
return f"({self.x}, {self.y})" # user-friendly
p = Point(1, 2)
print(p) # (1, 2) - вызов __str__
print(repr(p)) # Point(x=1, y=2) - вызов __repr__
print([p]) # [Point(x=1, y=2)] - list вызывает __repr__ для элементов
Если определён только __repr__, str(obj) fallback’ит на него (это default tp_str). Если только __str__ — repr(obj) всё равно использует default tp_repr = <ClassName object at 0xHHHH>. Best practice: всегда определяйте __repr__; __str__ опционально, если нужен другой формат.
__len__ vs __bool__ — оба определяют truthiness:
class Inbox:
def __init__(self, msgs):
self.msgs = msgs
def __len__(self):
return len(self.msgs)
# __bool__ не определён
ib = Inbox([])
print(len(ib)) # 0
print(bool(ib)) # False - fallback на __len__ (False if 0)
ib = Inbox(['msg'])
print(bool(ib)) # True - len > 0
Default tp_as_number->nb_bool для python-defined класса:
- Если есть
__bool__— вызывается напрямую. - Иначе если есть
__len__—bool(obj) = len(obj) != 0. - Иначе —
True(любой объект truthy by default).
Этот fallback chain объясняет, почему if my_list: работает для всего, что имеет __len__ (list, dict, str, set, tuple, ваш custom container).
Cross-course context
Комбинаторы агрегатных функций ClickHouseКлючевые выводы
- Каждый dunder связан с C-слотом
PyTypeObject:__init__→tp_init,__call__→tp_call,__getitem__→mp_subscript/sq_item,__hash__→tp_hash,__eq__→tp_richcompare(один слот для всех 6 comparisons). - Slot wrappers
slot_tp_*генерируются автоматически при создании Python-defined класса. Они делаютlookup_method('__name__')черезcls.__dict__и вызывают user-defined функцию. C-types имеют слоты заполненные напрямую — без extra indirection. - Slot tables (
tp_as_number,tp_as_sequence,tp_as_mapping, …) группируют операции по протоколам.__getitem__мапится либо вsq_item(sequence), либо вmp_subscript(mapping); CPython smart-dispatches. __eq__/__hash__invariant enforced на уровне CPython: переопределение__eq__без__hash__→tp_hash = NULL→ unhashable. Cross-link M02 урок 06: hashable требует stable consistent hash.- Performance: C-types skip dunder lookup (
tp_*слоты вызываются напрямую); Python-defined classes имеют ~3-5x extra overhead наlookup_method()черезtp_dict. Это та цена, которую вы платите за ability to override.
В уроке M04-03 разберём MRO и C3 linearization: как CPython решает, в каком порядке искать атрибуты в иерархии множественного наследования, и почему diamond problem не ломает Python.