Привет! Меня зовут Никита Соболев, я 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
! Исходник: https://github.com/python/cpython/blob/main/Python/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: создание результирующего байткода из промежуточного представления
Исходники:
-
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
и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_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
. Подробнее: https://t.me/opensource_findings/852
Кажется, что мы рассмотрели все основные моменты работы имен в Python!
Заключение
Вот мы и прошли полный путь для использования имен.
На практике такое не очень полезно, но вот для любителей поковырять технологии глубже – самое оно! Вооружитесь данным знанием для самого сложного собеса 😂 Когда вас спросят, что такое переменная в питоне – обязательно расскажите про все шаги процесса (шутка).
Конечно, мы много чего не успели обсудить:
-
Как оптимизируется байткод для использования переменных
-
Как работает AST и парсер
-
Какие есть особенности и проверки для разных имен в разных контекстах
-
Как работает замыкание
-
При чем тут
__type_params__
Но большинство данных вопросов я осветил в видео. Надеюсь, что будет полезно и интересно.
А если нравится такой контект, забегайте ко мне в телеграм канал: https://t.me/opensource_findings
Там я регулярно пишу подобное!
ссылка на оригинал статьи https://habr.com/ru/articles/845314/
Добавить комментарий