Python: как переменные работают на самом деле? Погружаемся в байткод и C

от автора

Привет! Меня зовут Никита Соболев, я 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: создание результирующего байткода из промежуточного представления

Исходники:

Далее, пользуясь данными из 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. Подробнее: https://t.me/opensource_findings/852

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

Заключение

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

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

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

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

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

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

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

  • При чем тут __type_params__

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

А если нравится такой контект, забегайте ко мне в телеграм канал: https://t.me/opensource_findings

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


ссылка на оригинал статьи https://habr.com/ru/articles/845314/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *