¿Cómo funcionan realmente las variables? Profundizando en el código de bytes y C/Sudo Null IT News

¡Hola! Mi nombre es Nikita Sobolev, soy desarrollador principal del lenguaje de programación CPython y autor de una serie de videos sobre su dispositivo.

Hoy quiero hablar sobre cómo funcionan realmente las variables en CPython.

Debajo del corte hay un montón de tripas de pitón y un video de 46 minutos con tripas de pitón adicionales (ninguna pitón real resultó dañada durante la redacción de este artículo).


Comencemos con el video y luego describamos los puntos principales en formato de texto.

¿Cuál es el plan?

Echemos un vistazo de alto nivel a lo que sucede en CPython cuando se trata de nombres:

  • El analizador crea un AST con todos los nodos.

  • symtable.c genera una tabla de símbolos a partir de AST

  • compile.c y codegen.c usan AST y una tabla de símbolos para generar instrucciones de código de bytes correctas

  • Que luego son ejecutados por la máquina virtual.

¡Veamos todos los pasos con más detalle! Veamos un ejemplo como:

z = 1

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

En este ejemplo hay varios tipos de “variables”:

  • Nombre global en un módulo

  • Parámetro de función (lo consideramos un caso especial de la capacidad de crear nombres)

tabla simbólica.c

Empecemos con symtable.c! Fuente.

symtable genera una tabla de símbolos (nombres) antes de que el compilador la procese. Para tener más información sobre lo que haremos al compilar.

Primero, repasamos en profundidad todas las declaraciones y todas las expresiones:

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;
    }
    // ...
 }

Es importante ver dos macros aquí. VISIT y VISIT_SEQque pasan por alto otros nodos AST o secuencias de nodos AST, respectivamente. Tenga en cuenta que esta lógica se implementa para todas las declaraciones en Python.

Por ejemplo para try repasaremos todas sus subpartes: el cuerpo mismo tryel cuerpo de todos except manipuladores, cuerpo else y cuerpo finally.

A continuación nos fijamos en la lógica de las expresiones:

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;
    // ...
}

Lo mismo ocurre aquí: la lógica transversal debe definirse para todo tipo de expresiones. Lo que nos permite encontrar todos los nombres dentro del AST.

Para x + y + z Se crearán dos BinOpque repasamos aquí: miramos tanto la parte izquierda como la derecha.

Y un ejemplo para def first(x, y): Cuando encontramos definiciones de parámetros dentro de una función, las agregamos a symtable para usarlas más adelante en compile.c y 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;
}

Aquí symtable_add_def hace algo bastante simple: agrega nombres de parámetros al diccionario de los símbolos (nombres) actuales. Simplifiqué enormemente esta función, eliminé el manejo de errores y varias comprobaciones lógicas para dejar la esencia:

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;
}

Es especialmente importante ver aquí. PyDict_SetItem(ste->ste_symbols, mangled, o); Dónde o es el valor de las banderas. Nombres como se agregarán aquí x y y de nuestro ejemplo.

I PyDict_SetItem(st->st_global, mangled, o); Para agregar nombres globales como z. El resto es procesamiento de casos extremos.

¡Ahora tenemos una tabla completa de diferentes símbolos con diferentes banderas! Echemos un vistazo:

» 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

Tenga en cuenta la diferencia:

  • x y y tener tipo local symboly banderas: use (usado), def_param (parámetro de función)

  • z dentro del espacio de nombres global tiene el tipo local symbol y bandera def_local

  • z dentro de un espacio de nombres first (ya que se usa desde un ámbito externo) tiene tipo global_implicitbanderas: use

Necesitaremos este conocimiento en el siguiente bloque.

compilar.c y codegen.c

¿Qué son compilar.c y codegen.c?

Son responsables de:

  • compilar.c: crear una representación de código de bytes intermedia a partir de un AST

  • codegen.c: generar el código de bytes resultante a partir de una representación intermedia

Fuentes:

A continuación, utilizando los datos de symtable, podemos crear el código de bytes necesario para nuestro ejemplo:

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;
}

Aquí la compilación creará:

  • _PyCompile_optype la especie COMPILE_LOAD_FAST para variables x y y. Porque son locales y están dentro de una función.

  • _PyCompile_optype la especie COMPILE_OP_GLOBAL para variables zporque como vimos en symtable, había una entrada global_implicit junto al nombre de pila

A partir del cual ya podemos generar bytecode en 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;
}

Y ahora ya hemos generado las instrucciones de código de bytes necesarias:

Veámoslo en su totalidad:

» 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

Tenga en cuenta que las dos instrucciones de código de bytes LOAD_FAST pegados en uno LOAD_FAST_LOAD_FAST gracias a la optimización, que no cambia su esencia.

Otra cosa interesante a la que vale la pena prestar atención son dos instrucciones: STORE_NAME. El primero creará un nombre. z con el valor de la pila, que se pondrá allí LOAD_CONST (1). Así es como la variable obtiene su valor.

Segundo desafío STORE_NAME ya creará un nombre firstque recibirá el valor de la pila que la instrucción creará allí MAKE_FUNCTION. Lo cual es lógico.

¡Todo lo que queda es ejecutar el código de bytes para llegar hasta el final!

ceval.c y bytecodes.c

Estos dos archivos ejecutan el código de bytes de la máquina virtual.

Fuentes:

Primero veamos cómo crear una variable en el área de nombres globales: STORE_NAME para variables 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);
}

¡Hay muchos detalles sutiles e interesantes aquí!

  • Resulta que en algunas situaciones es posible que no tengamos locals() dentro del marco. Entonces debemos caer con un error. SystemError. Esto sólo es realmente posible si hacemos algún tipo de magia oscura. Pero es posible.

  • Además, resulta locals() puede ser no solo un diccionario, sino también un objeto (de hecho PyFrameLocalsProxy pasa muy seguido, es que él también MutableMappingpor lo que parece casi un diccionario).

Alternativa directa 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);
}

Dónde _PyEval_LoadName solo busco nombres uno por uno 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;
}

De ahora en adelante puedes explicar completamente el comportamiento de código como z = 1; print(z). ¡Fresco!

Ahora veamos el uso de nombres dentro. def first(x, y). Necesitamos encontrar LOAD_FAST_LOAD_FAST y 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;
}

¿Por qué en LOAD_NAME usado _PyEval_LoadNamey en LOAD_GLOBAL usado _PyEval_LoadGlobalStackRef?

Porque a nivel de módulo f_locals y f_globals son un dictado general:

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);

Por lo tanto, a nivel de módulo z Estará en globals() y en locals(). Y por lo tanto de la función first() ya recibiremos el valor z desde el campo f_globals. Más detalles.

¡Parece que hemos cubierto todos los conceptos básicos de cómo funcionan los nombres en Python!

Conclusión

Así que hemos llegado hasta el final hasta el uso de nombres.

En la práctica, esto no es muy útil, pero para aquellos a quienes les gusta profundizar en la tecnología, ¡aquí está! Ármate con este conocimiento para el trabajo social más difícil Cuando te pregunten qué es una variable en Python, asegúrate de contarnos todos los pasos del proceso (es broma).

Por supuesto, no tuvimos tiempo de discutir muchas cosas:

  • Cómo se optimiza el código de bytes para usar variables

  • Cómo funcionan AST y el analizador

  • ¿Cuáles son las características y comprobaciones de diferentes nombres en diferentes contextos?

  • Cómo funciona el cierre

  • ¿Qué tiene que ver con eso? __type_params__

Pero cubrí la mayoría de estos problemas en el video. Espero que sea útil e interesante.

Y si te gusta este tipo de contexto, ven a visitarme. canal de telegramas.

¡Escribo cosas como esta allí regularmente!

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *