Привет! Меня зовут Никита Соболев, я 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_localzвнутри пространства именfirst(так как она используется из внешнего скоупа) имеет типglobal_implicit, флаги:use
Данное знание нам понадобится в следующем блоке.
compile.c и codegen.c
Что такое compile.c и codegen.c?
Они отвечают за:
compile.c: создание промежуточного представления байткода из AST
codegen.c: создание результирующего байткода из промежуточного представления
Исходники:
https://github.com/python/cpython/blob/main/Python/compile.c
https://github.com/python/cpython/blob/main/Python/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иyLOAD_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_NAME – LOAD_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__
Но большинство данных вопросов я осветил в видео. Надеюсь, что будет полезно и интересно.
А если нравится такой контект, забегайте ко мне в телеграм канал.
Там я регулярно пишу подобное!
