Contenido

El compilador de Python desde dentro

Los viernes hay formación en la oficina. El viernes pasado le tocó dar una charla a Goran, y nos estuvo contando cómo funciona PHP por dentro.

No he podido quitarme la charla de la cabeza en todo el fin de semana, y he investigado cómo funciona Python por dentro. Goran se quejaba de que hay muy poca información sobre PHP, y tampoco hay tanta sobre Python. Pero encontré este artículo, me gustó y he tenido que traducirlo. Lo mejor es que todos los lenguajes que están de moda (PHP, Python, Ruby, Java, …) se basan en los mismos principios, aunque algunos de ellos tienen mayor acierto que otros, y la implementación, que es completamente diferente.

El artículo original, ‘Python Compiler Internals’ lo escribió Thomas Lee (@tglee ) en el 2008, pero es perfectamente válido hoy en día. Me gustó por lo sencillo y por lo acertado del ejemplo.

Espero que también os guste:

Python

Resumen

En este artículo se introduce una nueva instrucción del lenguaje para demostrar cómo se puede comenzar a modificar el compilador del lenguaje Python. El lenguaje Python en sí mismo es un lenguaje potente, dinámicamente tipado que corre en gran variedad de sistemas operativos y plataformas. Los entresijos del propio compilador siguen un patrón común en las implementaciones de muchos lenguajes, con fases de análisis léxico, sintáctico y generación de código. Esto hace del código fuente de Python un buen lugar para aprender cómo deberían implementarse los lenguajes en su sentido más general. Tras leer el artículo, se espera que el lector vea que contribuir al núcleo del lenguaje Python no es tan complejo como puede parecer.

1. Vista general

Aquéllos que ven a Python como un lenguaje de scripting pueden sorprenderse al descubrir que el núcleo del intérprete Python realmente tiene la estructura de un compilador clásico. Cuando se invoca la orden “python”, el código fuente se escanea buscando tokens, estos tokens se procesan en una representación arbórea de la estructura lógica del programa, que finalmente se transforma en bytecode. Finalmente, este bytecode se procesa por la máquina virtual.

Con el fin de demostrar cómo encajan entre sí los distintos componentes del compilador de Python, usaremos como base el código de Python 2.6 caminando hacia la agregación de una nueva estructura del lenguaje: la instrucción “unless”, mostrada en el listado siguiente:

1
2
3
4
5
6
import sys

passwd = sys.stdin.readline().strip()
unless passwd == 'tehsecret':
       print 'Password do not match. Exiting!'
       sys.exit(1)

Todo el código de este artículo se ha escrito y probado con Python 2.6 beta 3, cuyo repositorio Subversion puede encontrarse aquí: https://svn.python.org/projects/python/tags/r26b3/ . La única cosa que debería cambiar en la versión 2.6 final son los números de línea referenciados en los listados de fuentes aquí presentes.

2. Análisis léxico

En lugar de tratar de procesar un flujo de texto directamente, a menudo es más sencillo – y rápido – desglosar la entrada de texto en una serie de “tokens”, que deberían incluir palabras reservadas, literales, operadores y/o identificadores. Estos tokens pueden inspeccionarse por el procesador de forma más eficiente que un flujo de texto plano. El proceso de transformar la entra de texto en tokens se conoce como “análisis léxico”, “tokenizado” (tokenizing) o, simplemente, “escaneo” (scanning).

El punto de entrada del analizador léxico es la función PyTokenizer_Get en Parser/tokenizer.c . Esta función se llama repetidamente desde la función principal de procesado, parsetok en Parser/parsetok.c , que a su vez se utiliza por por alguna de las distintas funciones de proceso de más alto nivel.

Para implementar nuestra instrucción “unless”, no es necesario realizar nada explícito en el código del tokenizer – sólo es necesario modificar la descripción de la gramática, como se verá en la siguiente sección.

3. Análisis sintáctico

Para que el compilador obtenga algún significado del programa fuente, el flujo de tokens emitido por el analizador léxico debe estar organizado en algún tipo de estructura. Éste es el trabajo del analizador sintáctico de Python, que toma como entrada un flujo de tokens y – basado en las reglas declaradas en la gramática de Python – produce un Árbol Sintáctico Abstracto (AST, “Abstract Syntax Tree”).

Python utiliza un generador de análisis sintáctico a medida que genera automáticamente su analizador sintáctico a partir de una descripción gramatical. La salida del análisis sintáctico es el árbol sintáctico, que puede verse como una representación de bajo nivel del programa analizado en la estructura definida por la descripción gramatical.

En Python 2.5, se añade un paso adicional a la fase de análisis sintáctico: la construcción de un Árbol Sintáctico Abstracto (AST) del árbol analizado. El AST, como el árbol analizado, es una representación en memoria del programa que se está procesando, aunque de una forma de más alto nivel y por tanto más sencilla de manipular.

Ahora, para añadir nuestra nueva instrucción unless al lenguaje Python, primero tenemos que modificar el archivo de gramática (Grammar/Grammar) para describir la sintaxis de esta característica, como en el listado siguiente:

1
2
3
4
5
/* Grammar/Grammar:78 */
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
while_stmt: 'while' test ':' suite ['else' ':' suite]
unless_stmt: 'unless' test ':' suite
for_stmt: 'for' exprlist 'in' testlist ':' suite ['else' ':' suite]

Dado que nuesta instrucción unless es realmente una instrucción “if” con la lógica invertida, para implementarlo podríamos utilizar los nodos AST del “if” y el “Not” existentes. De hecho, así es como se introdujo la sintaxis el try…except…finally en Python 2.5. De todas formas, para los propósitos de este artículo se modificará la estructura AST para poder demostrar cómo generar bytecode para nuestra nueva construcción. Con tal fin, se modifica Parser/Python.asdl – que contiene la definición del AST – como en el listado siguiente:

1
2
3
4
5
/* Parser/Python.asdl:25 */
    | For(expr target, expr iter, stmt* body, stmt* orelse)
    | While(expr test, stmt* body, stmt* orelse)
    | Unless(expr test, stmt* body)
    | If(expr test, stmt* body, stmt* orelse)

Finalmente, necesitamos añadir algo de código para manejar la transformación de la gramática al AST en Python/ast.c . Aquí añadimos ast_for_unless_stmt, como se muestra a continuación:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Python/ast.c:2805 */
static stmt_ty
ast_for_unless_stmt(struct compiling* c, const node *n)
{
    expr_ty test_expr;
    asdl_seq *suite_seq;
    /* unless_stmt: 'unless' test ':' suite */
    REQ(n, unless_stmt);
    test_expr = ast_for_expr(c, CHILD(n, 1));
    if (test_expr == NULL)
        return NULL;
    suite_seq = ast_for_suite(c, CHILD(n, 3));
    if (suite_seq == NULL)
        return NULL;
    return Unless(test_expr, suite_seq, LINENO(n), n->n_col_offset, c->c_arena);
}
/* ... <snip> ... */
/* Python/ast.c:3125 */
    case while_stmt:
        return ast_for_while_stmt(c, ch);
    case unless_stmt:
        return ast_for_unless_stmt(c, ch);

El código de transformación del árbol sintáctico al AST sigue un popular patrón Visitor. En la línea 3123, se añade un hook para llamar al código de transformación apropiado si se encuentra un nodo unless_stmt en el árbol sintáctico. Una vez dentro de la función de transformación, se construye un nodo Unless analizando recursivamente la condición y el cuerpo.

Note que puede necesitar regenerar explícitamente algunos ficheros tras realizar estos cambios:

1
2
$ rm -f Python/graminit.c && make Python/graminit.c
$ rm -f Python/Python-ast.c && make Python/Python-ast.c

En este punto, el compilador podrá escanear y analizar nuestra nueva instrucción “unless”. Lo único que falta es añadir código para generar el bytecode para nuestro nuevo nodo AST.

4. Generación de código

La siguiente fase de compilación – generación de código – toma el AST construído en la fase anterior y produce un PyCodeObject como salida. Un PyCodeObject es una unidad independiente de código ejecutable, que contiene toda la información y código para la ejecución independiente por parte del intérprete de bytecode de Python.

Antes de ver más código fuente, es importante comprender que el intérprete de bytecode de Python es una máquina virtual basada en pila. Esto significa que el proceso de ejecutar bytecode manipula una pila de datos, con instrucciones que añaden, eliminan y operan sobre el par de elementos de la cima de la pila. Con esto en mente, veamos cómo generar bytecode desde nuestro nuevo nodo AST Unless en el listado siguiente (veremos lo que hace realmente el bytecode en la sección siguiente):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* Python/compile.c:1623 */
static int
compiler_unless(struct compiler *c, stmt_ty s)
{
    basicblock *end;
    basicblock *next;
    assert(s->kind == Unless_kind);
    end = compiler_new_block(c);
    if (end == NULL)
        return 0;
    next = compiler_new_block(c);
    if (next == NULL)
        return 0;
    VISIT(c, expr, s->v.Unless.test);
    ADDOP_JREL(c, JUMP_IF_TRUE, next);
    ADDOP(c, POP_TOP);
    VISIT_SEQ(c, stmt, s->v.Unless.body);
    ADDOP_JREL(c, JUMP_FORWARD, end);
    compiler_use_next_block(c, next);
    ADDOP(c, POP_TOP);
    compiler_use_next_block(c, end);
    return 1;
}
/* ... <snip> ... */
/* Python/compile.c:2167 */
case If_kind:
    return compiler_if(c, s);
case Unless_kind:
    return compiler_unless(c, s);

En este punto, nuestra implementación de la instrucción “Unless” está terminada. Recompile Python y pruébelo:

1
2
3
4
5
6
7
8
$ ./configure && make clean && make
$ ./python <<EOF
> dv = False
> unless v:
>
print 'test'
> EOF
test

Vea Python/compile.c para más detalles sobre el generador de bytecode de Python.

5. Ejecución de código

La ejecución del bytecode de Python se maneja por el intérprete de bytecode. Como se mencionó en la sección anterior, el intérprete es una máquina virtual basada en pila que ejecuta las instrucciones bytecode de Python que actúan sobre una única pila de datos. No se requiere realizar ningún cambio adicional al propio intérprete de bytecode para que nuestra instrucción “Unless” funcione, pero veamos con más detenimiento cómo interpreta el bytecode que generamos en la sección anterior.

Primero se ejecuta la expresión de la condición, y se añade el resultado de la misma a la pila. A continuación, el opcode JUMP_IF_TRUE inspeccionará el valor en la cima de la pila y determinará si contiene un valor que representa la verdad. Si es así, se salta todo el cuerpo y continúa la ejecución a partir del final de la instrucción “unless”. Si la expresión se evalúa como un valor falso, el compilador ejecutará el cuerpo de la instrucción unless.

Se aprecia que la primera instrucción a ejecutar en cualquiera de las ramas es desapilar el elemento de encima de la pila de datos (POP_TOP). Es así porque la expresión JUMP_IF_TRUE deja la expresión comprobada en la pila. En nuestro caso ya no se necesita el valor de la expresión de comprobación, por lo que simplemente se descarta con la ejecución de la instrucción POP_TOP.

Si está interesado en los detalles de lo que hacen los bytecodes individuales, puede encontrar información sobre todos los bytecodes en https://docs.python.org/lib/bytecodes.html . También puede ver PyEval_CodeEvalEx y PyEval_EvalFrame_ex en Python/ceval.c si está interesado en investigar con mayor detalle el intérprete de bytecode.

6. Conclusión

Debería quedar claro que no es necesario ser un científico de la Nasa para contribuir en el núcleo del lenguaje Python. Si le interesan realmente los entresijos del trabajo de un intérprete del mundo real, Python es un buen lugar para comenzar. Si no está seguro de por dónde debe continuar, aquí tiene un par de ideas tanto alcanzables como educativas para el nivel iniciado del hacker de Python:

  1. Reescriba la instrucción “unless” usando sólo nodos AST ya existentes.
  2. Añada un nuevo operador al lenguaje modificando el tokenizer.
  3. Investigue cómo funcionan por dentro las funciones embebidas en el lenguaje.
  4. Investigue el uso de la tabla de símbolos (symtable).

Hay mucho que aprender jugando con el código, y aún más realizando contribuciones activas al proyecto mediante el arreglo de errores, contribuyendo a la documentación o respondiendo las preguntas de novatos en la lista de correo de usuarios. Anímese – y puede que se sorprenda usted mismo.


Artículo original: Python Compiler Internals Autor original: Thomas Lee / (@tglee ) Traducción: Miguel Ángel García (@magmax_en )