Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 05.02 · 25 мин
Продвинутый
Dunder methodsSlot wrapperstp_inittp_calltp_richcomparetp_hashHashable invariant

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, происходит следующее:

  1. Compiler создаёт function object для __init__ и кладёт в cls.__dict__['__init__'].
  2. CPython type construction (type_new_impl в Objects/typeobject.c) обходит __dict__, ищет dunder-имена.
  3. Для каждого распознанного 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):

DunderC-слотТриггер
__init__tp_initCls(...) после tp_new
__new__tp_newCls(...) allocation phase
__del__tp_finalize (через tp_dealloc)при refcount → 0
__call__tp_callobj(...)
__repr__tp_reprrepr(obj), f"{obj!r}"
__str__tp_strstr(obj), f"{obj}", print(obj)
__hash__tp_hashhash(obj), dict-key, set-element
__eq__, __lt__, __gt__, __le__, __ge__, __ne__tp_richcompare (один слот, dispatch по op)==, <, >, <=, >=, !=
__getattribute__tp_getattroobj.attr (любой attribute access)
__setattr__tp_setattroobj.attr = x
__bool__tp_as_number->nb_boolbool(obj), if obj:
__len__tp_as_sequence->sq_length или tp_as_mapping->mp_lengthlen(obj)
__getitem__tp_as_mapping->mp_subscript или tp_as_sequence->sq_itemobj[k]
__setitem__tp_as_mapping->mp_ass_subscriptobj[k] = v
__iter__tp_iteriter(obj), for x in obj:
__next__tp_iternextnext(it)
__add__tp_as_number->nb_addobj + x
__contains__tp_as_sequence->sq_containsx 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:

  1. Берёт Py_TYPE(lst) = PyList_Type.
  2. Дёргает tp_as_sequence->sq_item.
  3. Это напрямую вызов C-функции list_item(lst, 5) — direct array access ob_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_itemlookup_method('__getitem__') → call user-defined Python function. Extra indirection ~3-5x.

Cite: Objects/typeobject.clookup_method() функция; Objects/object.c_PyObject_LookupAttr.


Самое важное 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.ctype_new_impl() логика “if eq defined and hash not, set tp_hash = NULL”.

WARNING

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 класса:

  1. Если есть __bool__ — вызывается напрямую.
  2. Иначе если есть __len__bool(obj) = len(obj) != 0.
  3. Иначе — True (любой объект truthy by default).

Этот fallback chain объясняет, почему if my_list: работает для всего, что имеет __len__ (list, dict, str, set, tuple, ваш custom container).


Cross-course context

Комбинаторы агрегатных функций ClickHouse

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

  1. Каждый dunder связан с C-слотом PyTypeObject: __init__tp_init, __call__tp_call, __getitem__mp_subscript/sq_item, __hash__tp_hash, __eq__tp_richcompare (один слот для всех 6 comparisons).
  2. Slot wrappers slot_tp_* генерируются автоматически при создании Python-defined класса. Они делают lookup_method('__name__') через cls.__dict__ и вызывают user-defined функцию. C-types имеют слоты заполненные напрямую — без extra indirection.
  3. Slot tables (tp_as_number, tp_as_sequence, tp_as_mapping, …) группируют операции по протоколам. __getitem__ мапится либо в sq_item (sequence), либо в mp_subscript (mapping); CPython smart-dispatches.
  4. __eq__/__hash__ invariant enforced на уровне CPython: переопределение __eq__ без __hash__tp_hash = NULL → unhashable. Cross-link M02 урок 06: hashable требует stable consistent hash.
  5. 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.

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

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

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

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