Привет! Меня зовут Никита Соболев, я core-разработчик языка программирования CPython, а так же автор серии видео про его устройство.

Сегодня я хочу рассказать, как на самом деле работают переменные в CPython.

Под катом куча кишков питона и видео на 46 минут с дополнительными кишками питона (ни один настоящий питон не пострадал при написании данной статьи).


Начнем с видео, а далее в текстовом формате опишем основные моменты.

Какой план?

Давайте посмотрим на высоком уровне, что происходит в CPython, когда он работает с именами:

  • Парсер создает AST со всеми нодами

  • symtable.c генерирует таблицу символов из AST

  • compile.c и codegen.c используют AST и таблицу символов, чтобы генерировать правильные инструкции байткода

  • Которые потом выполняет виртуальная машина

Давайте посмотрим на все шаги детальнее! Будем рассматривать пример вида:

z = 1

def first(x, y):
    return x + y + z

В данном примере есть сразу несколько видов "переменных":

  • Глобальное имя в модуле

  • Параметр функции (мы его считаем частным случаем возможности создавать имена)

symtable.c

Давайте начнем с symtable.c! Исходник.

symtable генерирует таблицу символов (имен) перед тем как отрабатывает компилятор. Чтобы иметь больше информации о том, что мы будем делать при компиляции.

Сначала мы обходим все statement’ы и все expression’ы вглубь:

static int
symtable_visit_stmt(struct symtable *st, stmt_ty s)
{
    ENTER_RECURSIVE(st);
    switch (s->kind) {
    case Delete_kind:
        VISIT_SEQ(st, expr, s->v.Delete.targets);
        break;
    case Assign_kind:
        VISIT_SEQ(st, expr, s->v.Assign.targets);
        VISIT(st, expr, s->v.Assign.value);
        break;
    case Try_kind:
        VISIT_SEQ(st, stmt, s->v.Try.body);
        VISIT_SEQ(st, excepthandler, s->v.Try.handlers);
        VISIT_SEQ(st, stmt, s->v.Try.orelse);
        VISIT_SEQ(st, stmt, s->v.Try.finalbody);
        break;
    case Import_kind:
        VISIT_SEQ(st, alias, s->v.Import.names);
        break;
    }
    // ...
 }

Здесь важно увидеть два макроса VISIT и VISIT_SEQ, которые обходят другие ноды AST или последовательности AST нод соответственно. Обратите внимание, что данная логика реализова для всех statement’ов в питоне.

Например для try мы обойдем все его подчасти: само тело try, тело всех except хендлеров, тело else и тело finally.

Далее смотрим на логику для expression’ов:

static int
symtable_visit_expr(struct symtable *st, expr_ty e)
{
    ENTER_RECURSIVE(st);
    switch (e->kind) {
    case NamedExpr_kind:
        if (!symtable_raise_if_annotation_block(st, "named expression", e)) {
            return 0;
        }
        break;
    case BoolOp_kind:
        VISIT_SEQ(st, expr, e->v.BoolOp.values);
        break;
    case BinOp_kind:
        VISIT(st, expr, e->v.BinOp.left);
        VISIT(st, expr, e->v.BinOp.right);
        break;
    case UnaryOp_kind:
        VISIT(st, expr, e->v.UnaryOp.operand);
        break;
    // ...
}

Аналогично и здесь: логика обхода должна быть определена для всех видов expression’ов. Что позволяет нам нам найти все имена внутри AST.

Для x + y + z будет создано два BinOp, которые мы обходим здесь: смотрим и на левую, и на правую части.

И пример для def first(x, y): когда мы встречаем дефиницию параметров внутри функции, мы добавляем их в symtable для дальнейшего использования в compile.c и codegen.c

static int
symtable_visit_arguments(struct symtable *st, arguments_ty a)
{
    if (a->posonlyargs && !symtable_visit_params(st, a->posonlyargs))
        return 0;
    if (a->args && !symtable_visit_params(st, a->args))
        return 0;
    if (a->kwonlyargs && !symtable_visit_params(st, a->kwonlyargs))
        return 0;
    if (a->vararg) {
        if (!symtable_add_def(st, a->vararg->arg, DEF_PARAM, LOCATION(a->vararg)))
            return 0;
        st->st_cur->ste_varargs = 1;
    }
    if (a->kwarg) {
        if (!symtable_add_def(st, a->kwarg->arg, DEF_PARAM, LOCATION(a->kwarg)))
            return 0;
        st->st_cur->ste_varkeywords = 1;
    }
    return 1;
}

Здесь symtable_add_def делает довольно простую штуку, добавляя имена параметров в словарь текущих символов (имен). Я очень сильно упростил данную функцию, убрал обработку ошибок и разные логические проверки, чтобы оставить саму суть:

static int
symtable_add_def(
    struct symtable *st, 
    PyObject *name, 
    int flag, 
    struct _symtable_entry *ste,
    _Py_SourceLocation loc)
{
    // Превращение `__attr` в `__SomeClass_attr` случается тут:
    PyObject *mangled = _Py_MaybeMangle(st->st_private, st->st_cur, name);
    PyObject *o = PyLong_FromLong(flag);
    PyDict_SetItem(ste->ste_symbols, mangled, o);

    if (flag & DEF_PARAM) {
        PyList_Append(ste->ste_varnames, mangled);
    } else if (flag & DEF_GLOBAL) {
        PyDict_SetItem(st->st_global, mangled, o);
    }
    Py_DECREF(mangled);
    return 1;
}

Особо важно тут увидеть PyDict_SetItem(ste->ste_symbols, mangled, o); Где o является значением флагов. Здесь будут добавлены такие имена как x и y из нашего примера.

И PyDict_SetItem(st->st_global, mangled, o); Для добавления глобальных имен, таких как z. Остальное – обработка краевых случаев.

Теперь у нас есть полная таблица разных символов с разными флагами! Давайте посмотрим на нее:

» echo 'z = 1\ndef first(x, y): return x + y + z' | python -m symtable  
symbol table for module from file '<stdin>':
    local symbol 'z': def_local
    local symbol 'first': def_local

    symbol table for annotation '__annotate__':
        local symbol '.format': use, def_param

    symbol table for function 'first':
        local symbol 'x': use, def_param
        local symbol 'y': use, def_param
        global_implicit symbol 'z': use

Обратите внимание на разницу:

  • x и y имеют тип local symbol, и флаги: use (использован), def_param (параметр функции)

  • z внутри глобального пространства имен имеет тип local symbol и флаг def_local

  • z внутри пространства имен first (так как она используется из внешнего скоупа) имеет тип global_implicit, флаги: use 

Данное знание нам понадобится в следующем блоке.

compile.c и codegen.c

Что такое compile.c и codegen.c?

Они отвечают за:

  • compile.c: создание промежуточного представления байткода из AST

  • codegen.c: создание результирующего байткода из промежуточного представления

Исходники:

Далее, пользуясь данными из symtable, мы можем сделать нужный байткод для нашего примера:

int
_PyCompile_ResolveNameop(
    compiler *c, PyObject *mangled, int scope,
    _PyCompile_optype *optype, Py_ssize_t *arg)
{
    PyObject *dict = c->u->u_metadata.u_names;
    *optype = COMPILE_OP_NAME;

    assert(scope >= 0);
    switch (scope) {
    // case FREE: ...
    // case CELL: ...
    case LOCAL:
        if (_PyST_IsFunctionLike(c->u->u_ste)) {
            *optype = COMPILE_OP_FAST;
        }
        // ...
        break;
    case GLOBAL_IMPLICIT:
        if (_PyST_IsFunctionLike(c->u->u_ste)) {
            *optype = COMPILE_OP_GLOBAL;
        }
        break;
    // case GLOBAL_EXPLICIT: ...
    }

    return SUCCESS;
}

Здесь compile создаст:

  • _PyCompile_optype вида COMPILE_LOAD_FAST для переменных x и y. Потому что они локальные и внутри функции

  • _PyCompile_optype вида COMPILE_OP_GLOBAL для переменной z, потому что как мы видели в symtable, там была запись global_implicit рядом с данным именем

Из которых мы уже сможем сгененрировать байткод в codegen.c:

static int
codegen_nameop(
    compiler *c, location loc,
    identifier name, expr_context_ty ctx)
{
    PyObject *mangled = _PyCompile_MaybeMangle(c, name);

    int scope = _PyST_GetScope(SYMTABLE_ENTRY(c), mangled);
    // Вот тут мы вызываем compile.c:
    if (_PyCompile_ResolveNameop(c, mangled, scope, &optype, &arg) < 0) {
        return ERROR;
    }

    int op = 0;
    switch (optype) {
    // case COMPILE_OP_DEREF: ...
    case COMPILE_OP_FAST:
        switch (ctx) {
        case Load: op = LOAD_FAST; break;
        case Store: op = STORE_FAST; break;
        case Del: op = DELETE_FAST; break;
        }
        ADDOP_N(c, loc, op, mangled, varnames);
        return SUCCESS;
    case COMPILE_OP_GLOBAL:
        switch (ctx) {
        case Load: op = LOAD_GLOBAL; break;
        case Store: op = STORE_GLOBAL; break;
        case Del: op = DELETE_GLOBAL; break;
        }
        break;
    // case COMPILE_OP_NAME: ...
    }
    ADDOP_I(c, loc, op, arg);
    return SUCCESS;
}

И вот мы уже сгенерировали нужные инструкции байткода:

  • LOAD_FAST для параметров x и y

  • LOAD_GLOBAL для имени z

Просмотрим его целиком:

» echo 'z = 1\ndef first(x, y): return x + y + z' | python -m dis     
  0           RESUME                   0

  1           LOAD_CONST               0 (1)
              STORE_NAME               0 (z)

  2           LOAD_CONST               1 (<code object first at 0x102e86340, file "<stdin>", line 2>)
              MAKE_FUNCTION
              STORE_NAME               1 (first)
              RETURN_CONST             2 (None)

Disassembly of <code object first at 0x102e86340, file "<stdin>", line 2>:
  2           RESUME                   0
              LOAD_FAST_LOAD_FAST      1 (x, y)
              BINARY_OP                0 (+)
              LOAD_GLOBAL              0 (z)
              BINARY_OP                0 (+)
              RETURN_VALUE

Обратите внимание, что две инструкции байткода LOAD_FAST склеились в одну LOAD_FAST_LOAD_FAST благодаря оптимизации, что не меняет их суть.

Еще из интересного стоит обратить внимание на две инструкции STORE_NAME. Первая создаст имя z со значением со стека, которое положит туда LOAD_CONST (1). Вот таким образом переменная получает свое значение.

Второй вызов STORE_NAME создаст уже имя first, которое получит значение со стека, которое создаст там инструкция MAKE_FUNCTION. Что логично.

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

ceval.c и bytecodes.c

Данные два файла выполняют байткод виртуальной машины.

Исходники:

Сначала посмотрим на создание переменной в области глобальных имен: STORE_NAME для переменной z

inst(STORE_NAME, (v -- )) {
    PyObject *name = GETITEM(FRAME_CO_NAMES, oparg);
    PyObject *ns = frame->f_locals;
    int err;
    if (ns == NULL) {
        _PyErr_Format(tstate, PyExc_SystemError,
                        "no locals found when storing %R", name);
        DECREF_INPUTS();
        ERROR_IF(true, error);
    }
    if (PyDict_CheckExact(ns))
        err = PyDict_SetItem(ns, name, PyStackRef_AsPyObjectBorrow(v));
    else
        err = PyObject_SetItem(ns, name, PyStackRef_AsPyObjectBorrow(v));
    DECREF_INPUTS();
    ERROR_IF(err, error);
}

Здесь много тонких и интересных деталей!

  • Оказывается, что в некоторых ситуациях у нас может не оказаться locals() внутри фрейма. Тогда мы должны упасть с ошибкой SystemError. Такое реально возможно только если мы делаем какую-то темную магию. Но возможно.

  • Далее, оказывается locals() может быть не только словарем, но и объектом (на самом деле PyFrameLocalsProxy встречается очень часто, просто он тоже MutableMapping, так что выглядит он почти как словарь).

Прямая альтернатива STORE_NAMELOAD_NAME

inst(LOAD_NAME, (-- v)) {
    PyObject *name = GETITEM(FRAME_CO_NAMES, oparg);
    PyObject *v_o = _PyEval_LoadName(tstate, frame, name);
    ERROR_IF(v_o == NULL, error);
    v = PyStackRef_FromPyObjectSteal(v_o);
}

Где _PyEval_LoadName просто по-очереди ищет имена в locals() / globals() / __builtins__:

PyObject *
_PyEval_LoadName(
    PyThreadState *tstate, 
    _PyInterpreterFrame *frame, 
    PyObject *name)
{
    PyObject *value;
    // Ищем в locals()
    PyMapping_GetOptionalItem(frame->f_locals, name, &value);
    if (value != NULL) {
        return value;
    }
    // Ищем в globals()
    PyDict_GetItemRef(frame->f_globals, name, &value);
    if (value != NULL) {
        return value;
    }
    // Ищем в __builtins__
    PyMapping_GetOptionalItem(frame->f_builtins, name, &value);
    if (value == NULL) { // Или вызываем NameError, если имени нет
        _PyEval_FormatExcCheckArg(PyExc_NameError, name);
    }
    return value;
}

С данного момента вы можете полностью объяснить поведение кода вида z = 1; print(z). Круто!

Теперь посмотрим на использование имен внутри def first(x, y). Надо найти LOAD_FAST_LOAD_FAST и LOAD_GLOBAL:

inst(LOAD_FAST_LOAD_FAST, ( -- value1, value2)) {
  uint32_t oparg1 = oparg >> 4;
  uint32_t oparg2 = oparg & 15;
  value1 = PyStackRef_DUP(GETLOCAL(oparg1));
  value2 = PyStackRef_DUP(GETLOCAL(oparg2));
}

op(_LOAD_GLOBAL, ( -- res[1], null if (oparg & 1))) {
  PyObject *name = GETITEM(FRAME_CO_NAMES, oparg>>1);
  _PyEval_LoadGlobalStackRef(frame->f_globals, frame->f_builtins, name, res);
  ERROR_IF(PyStackRef_IsNull(*res), error);
  null = PyStackRef_NULL;
}

Почему в LOAD_NAME используется _PyEval_LoadName, а в LOAD_GLOBAL используется _PyEval_LoadGlobalStackRef?

Потому что на уровне модуля f_locals и f_globals являются одним общим диктом:

PyObject *main_module = PyImport_AddModuleRef("__main__");
PyObject *main_dict = PyModule_GetDict(main_module);  // borrowed ref

PyObject *res = run_mod(mod, filename, main_dict, 
                        main_dict, flags, arena, 
                        interactive_src, 1);

Потому на уровне модуля z будет и в globals() и в locals(). А потому из функции first() мы уже будем получать значение z из поля f_globals. Подробнее.

Кажется, что мы рассмотрели все основные моменты работы имен в Python!

Заключение

Вот мы и прошли полный путь для использования имен.

На практике такое не очень полезно, но вот для любителей поковырять технологии глубже — самое оно! Вооружитесь данным знанием для самого сложного собеса ? Когда вас спросят, что такое переменная в питоне — обязательно расскажите про все шаги процесса (шутка).

Конечно, мы много чего не успели обсудить:

  • Как оптимизируется байткод для использования переменных

  • Как работает AST и парсер

  • Какие есть особенности и проверки для разных имен в разных контекстах

  • Как работает замыкание

  • При чем тут __type_params__

Но большинство данных вопросов я осветил в видео. Надеюсь, что будет полезно и интересно.

А если нравится такой контект, забегайте ко мне в телеграм канал.

Там я регулярно пишу подобное!

Комментарии (5)


  1. ovalsky
    23.09.2024 11:16

    Начнем с видео, а далее в текстовом формате опишем основные моменты.

    страшную весть расскажу я тебе - ютуб блочат. Гудбайдипиай работает только на винде, а на винде сидят не только лишь все. Да и впн ставить не всегда есть возможность, тем более для чтения статьи


    1. DMY
      23.09.2024 11:16
      +7

      Вы ещё не доросли до хабра значит. Начните с дтф и пикабу, узнайте всё про борьбу с блокировками, и назад сюда тогда уже


  1. Mcublog
    23.09.2024 11:16
    +1

    Спасибо интересную статью, очень понравилась.

    А как считаете упростило ли разработку использование cpp?

    Я без подвоха спрашиваю, сам часто стою перед таким выбором)


    1. sobolevn Автор
      23.09.2024 11:16
      +1

      Нет, потому что уже большая кодовая база на С, многие авторы не знают C++. Сейчас код очень простой и понятный, введение C++ его усложнит, но не очень понимаю, какие плюсы (!) добавит.


      1. Mcublog
        23.09.2024 11:16
        +1

        Ясно, спасибо большое за ответ. Удачи вам!