Esto puede resultarle útil: aspectos internos de Python: agregar una nueva declaración a Python , citada aquí:
Este artículo es un intento de comprender mejor cómo funciona el front-end de Python. Solo leer la documentación y el código fuente puede ser un poco aburrido, así que estoy adoptando un enfoque práctico aquí: voy a agregar una until
declaración a Python.
Toda la codificación de este artículo se realizó contra la rama Py3k de última generación en el espejo del repositorio Python Mercurial .
La until
declaración
Algunos idiomas, como Ruby, tienen una until
declaración, que es el complemento de while
( until num == 0
es equivalente a while num != 0
). En Ruby, puedo escribir:
num = 3
until num == 0 do
puts num
num -= 1
end
Y se imprimirá:
3
2
1
Entonces, quiero agregar una capacidad similar a Python. Es decir, poder escribir:
num = 3
until num == 0:
print(num)
num -= 1
Una digresión de defensa del lenguaje
Este artículo no intenta sugerir la adición de una until
declaración a Python. Aunque creo que tal afirmación aclararía un poco el código, y este artículo muestra lo fácil que es agregarlo, respeto completamente la filosofía de minimalismo de Python. Todo lo que estoy tratando de hacer aquí, realmente, es obtener una idea del funcionamiento interno de Python.
Modificando la gramática
Python usa un generador de analizador personalizado llamado pgen
. Este es un analizador LL (1) que convierte el código fuente de Python en un árbol de análisis. La entrada al generador de analizadores es el archivo Grammar/Grammar
[1] . Este es un archivo de texto simple que especifica la gramática de Python.
[1] : De aquí en adelante, las referencias a los archivos en la fuente de Python se dan relativamente a la raíz del árbol de fuentes, que es el directorio donde ejecuta configure y crea para construir Python.
Deben realizarse dos modificaciones al archivo de gramática. El primero es agregar una definición para la until
declaración. Encontré dónde while
se definió la declaración ( while_stmt
), y agregué a until_stmt
continuación [2] :
compound_stmt: if_stmt | while_stmt | until_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef | decorated
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
while_stmt: 'while' test ':' suite ['else' ':' suite]
until_stmt: 'until' test ':' suite
[2] : Esto demuestra una técnica común que uso al modificar el código fuente con el que no estoy familiarizado: trabajar por similitud . Este principio no resolverá todos sus problemas, pero definitivamente puede facilitar el proceso. Dado que todo lo que debe hacerse while
también debe hacerse until
, sirve como una guía bastante buena.
Tenga en cuenta que he decidido excluir la else
cláusula de mi definición de until
, solo para que sea un poco diferente (y porque, francamente, no me gusta la else
cláusula de bucles y no creo que encaje bien con el Zen de Python).
El segundo cambio es modificar la regla para compound_stmt
incluir until_stmt
, como puede ver en el fragmento de arriba. Es justo después while_stmt
, de nuevo.
Cuando se ejecuta make
después de modificar Grammar/Grammar
, observe que el pgen
programa se ejecuta para volver a generar Include/graminit.h
y Python/graminit.c
, a continuación, varios archivos se vuelven a compilar.
Modificando el código de generación AST
Después de que el analizador de Python haya creado un árbol de análisis, este árbol se convierte en un AST, ya que los AST son mucho más simples para trabajar en las etapas posteriores del proceso de compilación.
Entonces, vamos a visitar Parser/Python.asdl
qué define la estructura de los AST de Python y agregaremos un nodo AST para nuestra nueva until
declaración, nuevamente justo debajo de while
:
| While(expr test, stmt* body, stmt* orelse)
| Until(expr test, stmt* body)
Si ahora ejecuta make
, tenga en cuenta que antes de compilar un montón de archivos, Parser/asdl_c.py
se ejecuta para generar código C a partir del archivo de definición AST. Este (como Grammar/Grammar
) es otro ejemplo del código fuente de Python usando un mini-lenguaje (en otras palabras, un DSL) para simplificar la programación. También tenga en cuenta que, dado que Parser/asdl_c.py
es un script de Python, este es un tipo de arranque : para construir Python desde cero, Python ya tiene que estar disponible.
Si bien Parser/asdl_c.py
generamos el código para administrar nuestro nodo AST recién definido (en los archivos Include/Python-ast.h
y Python/Python-ast.c
), todavía tenemos que escribir el código que convierte a mano un nodo de árbol de análisis relevante. Esto se hace en el archivo Python/ast.c
. Allí, una función llamada ast_for_stmt
convierte los nodos del árbol de análisis para las declaraciones en nodos AST. Nuevamente, guiados por nuestro viejo amigo while
, saltamos directamente a lo grande switch
para manejar declaraciones compuestas y agregamos una cláusula para until_stmt
:
case while_stmt:
return ast_for_while_stmt(c, ch);
case until_stmt:
return ast_for_until_stmt(c, ch);
Ahora deberíamos implementar ast_for_until_stmt
. Aquí está:
static stmt_ty
ast_for_until_stmt(struct compiling *c, const node *n)
{
/* until_stmt: 'until' test ':' suite */
REQ(n, until_stmt);
if (NCH(n) == 4) {
expr_ty expression;
asdl_seq *suite_seq;
expression = ast_for_expr(c, CHILD(n, 1));
if (!expression)
return NULL;
suite_seq = ast_for_suite(c, CHILD(n, 3));
if (!suite_seq)
return NULL;
return Until(expression, suite_seq, LINENO(n), n->n_col_offset, c->c_arena);
}
PyErr_Format(PyExc_SystemError,
"wrong number of tokens for 'until' statement: %d",
NCH(n));
return NULL;
}
Una vez más, esto fue codificado mientras miraba de cerca el equivalente ast_for_while_stmt
, con la diferencia de que until
he decidido no apoyar la else
cláusula. Como se esperaba, el AST se crea de forma recursiva, utilizando otras funciones de creación de AST, como ast_for_expr
la expresión de condición y ast_for_suite
el cuerpo de la until
declaración. Finalmente, Until
se devuelve un nuevo nodo llamado .
Tenga en cuenta que accedemos al nodo parse-tree n
usando algunas macros como NCH
y CHILD
. Vale la pena entenderlos: su código está en Include/node.h
.
Digresión: composición AST
Elegí crear un nuevo tipo de AST para la until
declaración, pero en realidad esto no es necesario. Podría haber ahorrado algo de trabajo e implementado la nueva funcionalidad usando la composición de los nodos AST existentes, ya que:
until condition:
# do stuff
Es funcionalmente equivalente a:
while not condition:
# do stuff
En lugar de crear el Until
nodo ast_for_until_stmt
, podría haber creado un Not
nodo con un While
nodo cuando era niño. Como el compilador AST ya sabe cómo manejar estos nodos, se pueden omitir los siguientes pasos del proceso.
Compilación de AST en bytecode
El siguiente paso es compilar el AST en Python bytecode. La compilación tiene un resultado intermedio que es un CFG (Control Flow Graph), pero como el mismo código lo maneja, ignoraré este detalle por ahora y lo dejaré para otro artículo.
El código que veremos a continuación es Python/compile.c
. Siguiendo el ejemplo de while
, encontramos la función compiler_visit_stmt
, que es responsable de compilar las declaraciones en bytecode. Agregamos una cláusula para Until
:
case While_kind:
return compiler_while(c, s);
case Until_kind:
return compiler_until(c, s);
Si se pregunta qué Until_kind
es, es una constante (en realidad un valor de la _stmt_kind
enumeración) generada automáticamente desde el archivo de definición AST Include/Python-ast.h
. De todos modos, llamamos compiler_until
que, por supuesto, todavía no existe. Llegaré a eso un momento.
Si tienes curiosidad como yo, notarás que compiler_visit_stmt
es peculiar. Ninguna cantidad de grep
-ping del árbol fuente revela dónde se llama. Cuando este es el caso, solo queda una opción: C macro-fu. De hecho, una breve investigación nos lleva a la VISIT
macro definida en Python/compile.c
:
#define VISIT(C, TYPE, V) {\
if (!compiler_visit_ ## TYPE((C), (V))) \
return 0; \
Se utiliza para invocar compiler_visit_stmt
en compiler_body
. Volviendo a nuestro negocio, sin embargo ...
Según lo prometido, aquí está compiler_until
:
static int
compiler_until(struct compiler *c, stmt_ty s)
{
basicblock *loop, *end, *anchor = NULL;
int constant = expr_constant(s->v.Until.test);
if (constant == 1) {
return 1;
}
loop = compiler_new_block(c);
end = compiler_new_block(c);
if (constant == -1) {
anchor = compiler_new_block(c);
if (anchor == NULL)
return 0;
}
if (loop == NULL || end == NULL)
return 0;
ADDOP_JREL(c, SETUP_LOOP, end);
compiler_use_next_block(c, loop);
if (!compiler_push_fblock(c, LOOP, loop))
return 0;
if (constant == -1) {
VISIT(c, expr, s->v.Until.test);
ADDOP_JABS(c, POP_JUMP_IF_TRUE, anchor);
}
VISIT_SEQ(c, stmt, s->v.Until.body);
ADDOP_JABS(c, JUMP_ABSOLUTE, loop);
if (constant == -1) {
compiler_use_next_block(c, anchor);
ADDOP(c, POP_BLOCK);
}
compiler_pop_fblock(c, LOOP, loop);
compiler_use_next_block(c, end);
return 1;
}
Tengo una confesión que hacer: este código no fue escrito en base a un profundo conocimiento del código de bytes Python. Al igual que el resto del artículo, se realizó imitando la compiler_while
función de parentesco . Sin embargo, al leerlo detenidamente, teniendo en cuenta que la máquina virtual de Python está basada en la pila, y mirando la documentación del dis
módulo, que tiene una lista de códigos de bytes de Python con descripciones, es posible comprender lo que está sucediendo.
Eso es todo, hemos terminado ... ¿no?
Después de hacer todos los cambios y ejecutar make
, podemos ejecutar el Python recién compilado y probar nuestra nueva until
declaración:
>>> until num == 0:
... print(num)
... num -= 1
...
3
2
1
¡Voila, funciona! Veamos el bytecode creado para la nueva declaración usando el dis
módulo de la siguiente manera:
import dis
def myfoo(num):
until num == 0:
print(num)
num -= 1
dis.dis(myfoo)
Aquí está el resultado:
4 0 SETUP_LOOP 36 (to 39)
>> 3 LOAD_FAST 0 (num)
6 LOAD_CONST 1 (0)
9 COMPARE_OP 2 (==)
12 POP_JUMP_IF_TRUE 38
5 15 LOAD_NAME 0 (print)
18 LOAD_FAST 0 (num)
21 CALL_FUNCTION 1
24 POP_TOP
6 25 LOAD_FAST 0 (num)
28 LOAD_CONST 2 (1)
31 INPLACE_SUBTRACT
32 STORE_FAST 0 (num)
35 JUMP_ABSOLUTE 3
>> 38 POP_BLOCK
>> 39 LOAD_CONST 0 (None)
42 RETURN_VALUE
La operación más interesante es la número 12: si la condición es verdadera, saltamos a después del ciclo. Esta es la semántica correcta para until
. Si no se ejecuta el salto, el cuerpo del bucle sigue corriendo hasta que vuelve a la condición en la operación 35.
Sintiéndome bien con mi cambio, intenté ejecutar la función (ejecutar myfoo(3)
) en lugar de mostrar su código de bytes. El resultado fue menos que alentador:
Traceback (most recent call last):
File "zy.py", line 9, in
myfoo(3)
File "zy.py", line 5, in myfoo
print(num)
SystemError: no locals when loading 'print'
Whoa ... esto no puede ser bueno. Entonces, ¿qué salió mal?
El caso de la tabla de símbolos que falta
Uno de los pasos que realiza el compilador de Python al compilar el AST es crear una tabla de símbolos para el código que compila. La llamada a PySymtable_Build
in PyAST_Compile
llama al módulo de tabla de símbolos ( Python/symtable.c
), que recorre el AST de manera similar a las funciones de generación de código. Tener una tabla de símbolos para cada ámbito ayuda al compilador a encontrar información clave, como qué variables son globales y cuáles son locales para un ámbito.
Para solucionar el problema, tenemos que modificar la symtable_visit_stmt
función Python/symtable.c
, agregando código para manejar las until
declaraciones, después del código similar para las while
declaraciones [3] :
case While_kind:
VISIT(st, expr, s->v.While.test);
VISIT_SEQ(st, stmt, s->v.While.body);
if (s->v.While.orelse)
VISIT_SEQ(st, stmt, s->v.While.orelse);
break;
case Until_kind:
VISIT(st, expr, s->v.Until.test);
VISIT_SEQ(st, stmt, s->v.Until.body);
break;
[3] : Por cierto, sin este código hay una advertencia del compilador Python/symtable.c
. El compilador nota que el Until_kind
valor de enumeración no se maneja en la declaración de cambio symtable_visit_stmt
y se queja. ¡Siempre es importante verificar las advertencias del compilador!
Y ahora realmente hemos terminado. Compilar la fuente después de este cambio hace que la ejecución del myfoo(3)
trabajo sea la esperada.
Conclusión
En este artículo, he demostrado cómo agregar una nueva declaración a Python. Aunque requirió un poco de retoques en el código del compilador de Python, el cambio no fue difícil de implementar, porque utilicé una declaración similar y existente como guía.
El compilador de Python es una parte sofisticada de software, y no pretendo ser un experto en él. Sin embargo, estoy realmente interesado en los aspectos internos de Python, y particularmente en su front-end. Por lo tanto, este ejercicio me pareció un compañero muy útil para el estudio teórico de los principios y el código fuente del compilador. Servirá como base para futuros artículos que profundizarán en el compilador.
Referencias
Utilicé algunas referencias excelentes para la construcción de este artículo. Aquí están, sin ningún orden en particular:
- PEP 339: Diseño del compilador CPython : probablemente la pieza de documentación oficial más importante y completa para el compilador Python. Al ser muy corto, muestra dolorosamente la escasez de buena documentación de los componentes internos de Python.
- "Compilación interna de Python" - un artículo de Thomas Lee
- "Python: diseño e implementación" - una presentación de Guido van Rossum
- Python (2.5) Virtual Machine, una visita guiada - una presentación de Peter Tröger
fuente original