Vous pouvez trouver cela utile - Python internes: ajout d'une nouvelle instruction à Python , citée ici:
Cet article tente de mieux comprendre le fonctionnement du front-end de Python. La simple lecture de la documentation et du code source peut être un peu ennuyeuse, alors je prends une approche pratique ici: je vais ajouter une until
déclaration à Python.
Tout le codage de cet article a été effectué sur la branche de pointe Py3k dans le miroir du référentiel Python Mercurial .
La until
déclaration
Certains langages, comme Ruby, ont une until
instruction, qui est le complément de while
( until num == 0
équivaut à while num != 0
). En Ruby, je peux écrire:
num = 3
until num == 0 do
puts num
num -= 1
end
Et il imprimera:
3
2
1
Donc, je veux ajouter une capacité similaire à Python. Autrement dit, être capable d'écrire:
num = 3
until num == 0:
print(num)
num -= 1
Une digression linguistique
Cet article n'essaie pas de suggérer l'ajout d'un until
instruction à Python. Bien que je pense qu'une telle déclaration clarifierait le code et que cet article montre à quel point il est facile à ajouter, je respecte complètement la philosophie du minimalisme de Python. Tout ce que j'essaie de faire ici, en réalité, c'est d'avoir un aperçu du fonctionnement interne de Python.
Modifier la grammaire
Python utilise un générateur d'analyseur personnalisé nommé pgen
. Il s'agit d'un analyseur LL (1) qui convertit le code source Python en un arbre d'analyse. L'entrée du générateur d'analyseur est le fichier Grammar/Grammar
[1] . Il s'agit d'un simple fichier texte qui spécifie la grammaire de Python.
[1] : À partir de là, les références aux fichiers dans la source Python sont données relativement à la racine de l'arborescence des sources, qui est le répertoire dans lequel vous exécutez configure et make pour construire Python.
Deux modifications doivent être apportées au fichier de grammaire. La première consiste à ajouter une définition pour l' until
instruction. J'ai trouvé où l' while
instruction a été définie ( while_stmt
), et ajouté until_stmt
ci-dessous [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] : Cela démontre une technique courante que j'utilise lors de la modification du code source que je ne connais pas: le travail par similarité . Ce principe ne résoudra pas tous vos problèmes, mais il peut certainement faciliter le processus. Étant donné que tout ce qui doit être fait while
doit également l'être until
, cela constitue une très bonne ligne directrice.
Notez que j'ai décidé d'exclure la else
clause de ma définition de until
, juste pour la rendre un peu différente (et parce que franchement je n'aime pas leelse
clause de boucles et ne pense pas qu'elle s'accorde bien avec le Zen de Python).
La deuxième modification consiste à modifier la règle pour compound_stmt
inclure until_stmt
, comme vous pouvez le voir dans l'extrait de code ci-dessus. C'est juste après while_stmt
, encore.
Lorsque vous exécutez make
après la modification Grammar/Grammar
, notez que le pgen
programme est exécuté pour regénérer Include/graminit.h
et Python/graminit.c
, puis plusieurs fichiers sont recompilés.
Modifier le code de génération AST
Une fois que l'analyseur Python a créé un arbre d'analyse, cet arbre est converti en AST, car les AST sont beaucoup plus simples à utiliser dans les étapes suivantes du processus de compilation.
Nous allons donc visiter Parser/Python.asdl
ce qui définit la structure des AST de Python et ajouter un nœud AST pour notre nouvelle until
déclaration, à nouveau juste en dessous de while
:
| While(expr test, stmt* body, stmt* orelse)
| Until(expr test, stmt* body)
Si vous exécutez maintenant make
, notez qu'avant de compiler un tas de fichiers, Parser/asdl_c.py
est exécuté pour générer du code C à partir du fichier de définition AST. Ceci (comme Grammar/Grammar
) est un autre exemple du code source Python utilisant un mini-langage (en d'autres termes, un DSL) pour simplifier la programmation. Notez également que puisqu'il Parser/asdl_c.py
s'agit d'un script Python, il s'agit d'une sorte de bootstrapping - pour construire Python à partir de zéro, Python doit déjà être disponible.
Lors de la Parser/asdl_c.py
génération du code pour gérer notre nœud AST nouvellement défini (dans les fichiers Include/Python-ast.h
et Python/Python-ast.c
), nous devons encore écrire le code qui convertit manuellement un nœud d'arbre d'analyse pertinent. Ceci est fait dans le fichier Python/ast.c
. Là, une fonction nommée ast_for_stmt
convertit les nœuds d'arbre d'analyse pour les instructions en nœuds AST. Encore une fois, guidés par notre vieil ami while
, nous sautons directement dans le grand switch
pour gérer les instructions composées et ajoutons une clause pour until_stmt
:
case while_stmt:
return ast_for_while_stmt(c, ch);
case until_stmt:
return ast_for_until_stmt(c, ch);
Maintenant, nous devons mettre en œuvre ast_for_until_stmt
. C'est ici:
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;
}
Encore une fois, cela a été codé en regardant de près l'équivalent ast_for_while_stmt
, à la différence que until
j'ai décidé de ne pas appuyer l' else
article. Comme prévu, l'AST est créé de manière récursive, en utilisant d'autres fonctions de création AST comme ast_for_expr
pour l'expression de condition et ast_for_suite
pour le corps de l' until
instruction. Enfin, un nouveau nœud nommé Until
est renvoyé.
Notez que nous accédons au nœud de l'arbre d'analyse en n
utilisant des macros comme NCH
et CHILD
. Celles-ci méritent d'être comprises - leur code est en place Include/node.h
.
Digression: composition AST
J'ai choisi de créer un nouveau type d'AST pour l' until
instruction, mais en fait ce n'est pas nécessaire. J'aurais pu économiser du travail et implémenter la nouvelle fonctionnalité en utilisant la composition des nœuds AST existants, depuis:
until condition:
# do stuff
Est fonctionnellement équivalent à:
while not condition:
# do stuff
Au lieu de créer le Until
nœud dans ast_for_until_stmt
, j'aurais pu créer un Not
nœud avec un While
nœud en tant qu'enfant. Étant donné que le compilateur AST sait déjà comment gérer ces nœuds, les étapes suivantes du processus peuvent être ignorées.
Compilation des AST en bytecode
L'étape suivante consiste à compiler l'AST en bytecode Python. La compilation a un résultat intermédiaire qui est un CFG (Control Flow Graph), mais comme le même code le gère, je vais ignorer ce détail pour le moment et le laisser pour un autre article.
Le code que nous examinerons ensuite est Python/compile.c
. En suivant l'exemple de while
, nous trouvons la fonction compiler_visit_stmt
, qui est responsable de la compilation des instructions en bytecode. Nous ajoutons une clause pour Until
:
case While_kind:
return compiler_while(c, s);
case Until_kind:
return compiler_until(c, s);
Si vous vous demandez ce que Until_kind
c'est, c'est une constante (en fait une valeur de l' _stmt_kind
énumération) générée automatiquement à partir du fichier de définition AST dans Include/Python-ast.h
. Quoi qu'il en soit, nous appelons compiler_until
ce qui, bien sûr, n'existe toujours pas. J'y reviendrai un instant.
Si vous êtes curieux comme moi, vous remarquerez que c'est étrange compiler_visit_stmt
. Aucune partie de grep
l'arborescence source ne révèle où il est appelé. Lorsque c'est le cas, il ne reste qu'une seule option - C macro-fu. En effet, une brève enquête nous conduit à la VISIT
macro définie dans Python/compile.c
:
#define VISIT(C, TYPE, V) {\
if (!compiler_visit_ ## TYPE((C), (V))) \
return 0; \
Il est utilisé pour invoquer compiler_visit_stmt
dans compiler_body
. Mais revenons à nos affaires ...
Comme promis, voici 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;
}
J'ai une confession à faire: ce code n'a pas été écrit sur la base d'une compréhension approfondie du bytecode Python. Comme le reste de l'article, cela a été fait à l'imitation de la compiler_while
fonction parentale . En le lisant attentivement, cependant, en gardant à l'esprit que la machine virtuelle Python est basée sur la pile et en jetant un coup d'œil à la documentation du dis
module, qui contient une liste de bytecodes Python avec des descriptions, il est possible de comprendre ce qui se passe.
Ça y est, nous avons fini ... N'est-ce pas?
Après avoir effectué toutes les modifications et exécuté make
, nous pouvons exécuter le Python nouvellement compilé et essayer notre nouvelle until
instruction:
>>> until num == 0:
... print(num)
... num -= 1
...
3
2
1
Voila, ça marche! Voyons le bytecode créé pour la nouvelle instruction en utilisant le dis
module comme suit:
import dis
def myfoo(num):
until num == 0:
print(num)
num -= 1
dis.dis(myfoo)
Voici le résultat:
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
L'opération la plus intéressante est le numéro 12: si la condition est vraie, on saute après la boucle. C'est une sémantique correcte pour until
. Si le saut n'est pas exécuté, le corps de la boucle continue de fonctionner jusqu'à ce qu'il revienne à la condition de l'opération 35.
Me sentant bien dans mon changement, j'ai ensuite essayé d'exécuter la fonction (exécution myfoo(3)
) au lieu d'afficher son bytecode. Le résultat était loin d'être encourageant:
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 ... ça ne peut pas être bon. Alors qu'est-ce qui ne va pas?
Le cas de la table des symboles manquante
L'une des étapes que le compilateur Python effectue lors de la compilation de l'AST est de créer une table de symboles pour le code qu'il compile. L'appel à PySymtable_Build
in PyAST_Compile
appelle le module de table de symboles ( Python/symtable.c
), qui parcourt l'AST d'une manière similaire aux fonctions de génération de code. Le fait d'avoir une table de symboles pour chaque étendue aide le compilateur à trouver certaines informations clés, telles que les variables globales et locales à une étendue.
Pour résoudre le problème, nous devons modifier la symtable_visit_stmt
fonction dans Python/symtable.c
, en ajoutant du code pour la gestion des until
instructions, après le code similaire pour les while
instructions [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] : Au fait, sans ce code, il y a un avertissement du compilateur pour Python/symtable.c
. Le compilateur remarque que la Until_kind
valeur d'énumération n'est pas gérée dans l'instruction switch de symtable_visit_stmt
et se plaint. Il est toujours important de vérifier les avertissements du compilateur!
Et maintenant, nous avons vraiment terminé. La compilation de la source après cette modification rend l'exécution du myfoo(3)
travail comme prévu.
Conclusion
Dans cet article, j'ai montré comment ajouter une nouvelle instruction à Python. Bien que nécessitant un peu de bricolage dans le code du compilateur Python, le changement n'a pas été difficile à implémenter, car j'ai utilisé une instruction similaire et existante comme guide.
Le compilateur Python est un logiciel sophistiqué et je ne prétends pas en être un expert. Cependant, je suis vraiment intéressé par les internes de Python, et en particulier son front-end. Par conséquent, j'ai trouvé cet exercice un compagnon très utile pour l'étude théorique des principes et du code source du compilateur. Il servira de base pour de futurs articles qui approfondiront le compilateur.
Références
J'ai utilisé quelques excellentes références pour la construction de cet article. Les voici, sans ordre particulier:
- PEP 339: Conception du compilateur CPython - probablement la documentation officielle la plus importante et la plus complète pour le compilateur Python. Étant très court, il affiche douloureusement la rareté de la bonne documentation des internes de Python.
- "Python Compiler Internals" - un article de Thomas Lee
- "Python: Design and Implementation" - une présentation de Guido van Rossum
- Machine virtuelle Python (2.5), une visite guidée - une présentation de Peter Tröger
source primaire