Привет! Меня зовут Никита Соболев, я 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 = 1ndef 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 = 1ndef 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__
Но большинство данных вопросов я осветил в видео. Надеюсь, что будет полезно и интересно.
А если нравится такой контект, забегайте ко мне в телеграм канал.
Там я регулярно пишу подобное!
Автор: sobolevn