Python sous le capot — Chapitre 4 : Comment le bytecode Python est exécuté

Avant-propos (du traducteur)

Ceci est la traduction du quatrième article de Victor Skvortsov du 30 octobre 2020 : Python behind the scenes #4: how Python bytecode is executed.

Avant-propos

Nous avons commencé cette série par un aperçu de la VM CPython. Nous avons vu que pour exécuter un programme Python, CPython le compile d’abord en bytecode, puis nous avons étudié le fonctionnement du compilateur dans la seconde partie. La dernière fois, nous avons parcouru le code source de CPython en partant de la fonction main() jusqu’à la boucle d’évaluation, l’endroit où le bytecode Python est exécuté. Nous avons étudié tout ça afin de nous préparer à la discussion que nous entamons aujourd’hui. Notre objectif est de comprendre comment CPython exécute le bytecode compilé depuis notre code.

Remarque : Dans cet article, je fais référence à CPython 3.9. Certains détails d’implémentation changeront certainement à mesure que CPython évolue. J’essayerais de suivre les changements importants et d’ajouter des notes de mise à jour.

Le point de départ

Rappelons brièvement ce que nous avons vu dans les parties précédentes. Nous commandons CPython en écrivant du code Python. Cependant, la VM CPython ne comprend que le bytecode. C’est au compilateur de traduire le code Python en bytecode. Le compilateur stocke le bytecode dans un code object, qui est une structure décrivant ce que fait un code block tel qu’un module ou une fonction. Pour exécuter un code object, CPython lui crée un état d’exécution appelé frame object. Il passe ensuite ce frame object à une fonction d’évaluation de frame qui effectue l’exécution attendu. La fonction d’évaluation de frame par défaut est _PyEval_EvalFrameDefault(), définie dans Python/ceval.c. Cette fonction implémente le noyau de la VM CPython. À savoir, la logique de l’exécution du bytecode Python. C’est fonction que nous allons étudier aujourd’hui.

Pour comprendre comment fonctionne _PyEval_EvalFrameDefault(), il est crucial de comprendre ce qu’il reçoit en entré, à savoir un frame object. Un frame object est un objet Python défini par la structure C suivante :

// Le typedef struct _frame PyFrameObject; est placé ailleurs
struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* frame précédente, ou NULL */
    PyCodeObject *f_code;       /* segment de code */
    PyObject *f_builtins;       /* table des symboles standards (PyDictObject) */
    PyObject *f_globals;        /* table des symboles globaux (PyDictObject) */
    PyObject *f_locals;         /* table des symboles locaux */
    PyObject **f_valuestack;    /* pointe derrière la dernière variable locale */
    /* Next free slot in `f_valuestack`.  Frame creation sets to `f_valuestack`.
       Frame evaluation usually NULLs it, but a frame that yields sets it
       to the current stack top. */
    PyObject **f_stacktop;
    PyObject *f_trace;          /* Fonction de traçage */
    char f_trace_lines;         /* Émettre les événements de traçage par ligne ? */
    char f_trace_opcodes;       /* Émettre les événements de traçage par opcode ? */

    /* Référence empruntée à un générateur, ou NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    int f_lineno;               /* Numéro de la ligne courante */
    int f_iblock;               /* Index dans `f_blockstack` */
    char f_executing;           /* Est-ce que la frame est toujours en cours d’exécution */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* pour les blocs de `try` et de boucles */
    PyObject *f_localsplus[1];  /* variables locals et sur la pile, dimensionnés dynamiquement */
};

Le champ f_code du frame object pointe vers un code object. Un code object est également un objet Python. Voici sa définition :

struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* Nombre d’arguments, à l’exception de *args */
    int co_posonlyargcount;     /* Nombre d’arguments positionnels uniquement */
    int co_kwonlyargcount;      /* Nombre d’arguments mot-clés uniquement */
    int co_nlocals;             /* Nombre de variables locales */
    int co_stacksize;           /* Nombre d’entrées nécessaires à la pile d’évaluation */
    int co_flags;               /* CO_..., voir ci-dessous */
    int co_firstlineno;         /* Premier numéro de ligne de la source */
    PyObject *co_code;          /* Opcodes d’instruction */
    PyObject *co_consts;        /* list (constantes utilisées) */
    PyObject *co_names;         /* list de strings (noms utilisés) */
    PyObject *co_varnames;      /* tuple de strings (noms des variables locales) */
    PyObject *co_freevars;      /* tuple de strings (noms des variables libres) */
    PyObject *co_cellvars;      /* tuple de strings (noms des cellules) */
    /* Le reste n’est utilisé ni par le hachage ni par les comparaisons, à l’exception de `co_name`,
       utilisé par les deux. Ceci est fait pour conserver le nom et le numéro de ligne
       pour les trackebacks et les débogueurs ; sinon, la dé-duplication constante
       ferait s’effondrer les fonctions/lambdas identiques définies sur des lignes différentes.
    */
    Py_ssize_t *co_cell2arg;    /* Maps les cellules qui sont des arguments. */
    PyObject *co_filename;      /* unicode (fichier d’où il a été chargé) */
    PyObject *co_name;          /* unicode (nom, pour référence) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */
    void *co_zombieframe;       /* Pour l’optimisation uniquement (voir `frameobject.c`) */
    PyObject *co_weakreflist;   /* Pour gérer les `weakrefs` vers les code objects */
    /* Espace des données supplémentaires relatives au *code object*.
       Le type `void*` permet de garder le format privé dans codeobject.c
       pour forcer les gens à passer par les API appropriées. */
    void *co_extra;

    /* Cache just-in-time par opcodes
     *
     * Pour réduire la taille du cache, on utilise un mappage indirect partant
     * de l’index de l’opcode vers le cache object :
     *   cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
     */

    // `co_opcache_map` est indexé par (`next_instr - first_instr`).
    //  * `0` veut dire qu’il n’y a pas de cache pour cet opcode.
    //  * `n > 0` veut dire qu’il y a un cache dans `co_opcache[n-1]`.
    unsigned char *co_opcache_map;
    _PyOpcache *co_opcache;
    int co_opcache_flag;  // utilisé pour déterminer quand créer un cache.
    unsigned char co_opcache_size;  // longueur de `co_opcache`.
};

Le champ le plus important d’un code object est co_code. C’est un pointeur vers un objet Python bytes représentant le bytecode. Le bytecode est une séquence d’instructions à deux octets : Un octet pour un opcode et un octet pour un argument.

Ne vous inquiétez pas si certains membres des structures ci-dessus vous semble mystérieux. Nous verrons à quoi ils servent au fur et à mesure de notre avancé dans la compréhension de l’exécution du bytecode par la VM CPython.

Vue d’ensemble de la boucle d’évaluation

L’exécution du bytecode Python peut vous sembler une évidence. Tout ce que la VM a à faire est d’itérer sur les instructions et d’agir en fonction. Et c’est essentiellement ce que fait _PyEval_EvalFrameDefault(). Il contient une boucle infinie for (;;) que nous appelons la boucle d’évaluation. À l’intérieur de cette boucle, il y a un switch géant, gérant tous les opcodes possibles. Chaque opcode a un case dédié contenant son code d’exécution. Le bytecode est représenté par un tableau d’entiers non signés en 16 bits, un entier par instruction. La VM garde la trace de la prochaine instruction à exécuter à l’aide de la variable next_instr, qui est un pointeur vers le tableau d’instructions. Au début de chaque itération de la boucle d’évaluation, la VM calcule l’opcode suivant et son argument en prenant respectivement l’octet de poids faible et l’octet de poids fort de l’instruction suivante1 et incrémente next_instr. La fonction _PyEval_EvalFrameDefault() fait près de 3000 lignes, mais la version simplifiée suivante en capture l’essentiel :

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // ... déclarations et initialisation des variables locales
    // ... définitions des macros
    // ... gestion de la profondeur des appels
    // ... code de traçage et de profilage

    for (;;) {
        // ... vérifier si l’exécution du bytecode doit être suspendue,
        // e.g. un thread a demandé le GIL

        // macro `NEXTOPARG()`
        _Py_CODEUNIT word = *next_instr; // `_Py_CODEUNIT` est un `typedef` de `uint16_t`
        opcode = _Py_OPCODE(word);
        oparg = _Py_OPARG(word);
        next_instr++;

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // plus d’information plus loin
            }

            case TARGET(LOAD_FAST) {
                // ... code du chargement des variables locales
            }

            // ... 117 `case` de plus, un pour chaque opcode possible
        }

        // ... gestion des erreurs
    }

    // ... fin
}

Pour une meilleure vision d’ensemble, voyons plus en détail les morceaux qui ont été masqués.

Les raisons de suspendre la boucle

De temps à autre, le thread en cours cesse d’exécuter son bytecode pour faire autre chose, ou rien du tout. Cela peut se produire dans quatre cas :

CPython dispose d’indicateurs pour chacun de ces événements. La variable indiquant qu’il y a des handlers à appeler est un membre de runtime->ceval, qui est une structure _ceval_runtime_state :

struct _ceval_runtime_state {
    /* Demande la vérification des signaux. C’est partagé par tous les interpréteurs (voir
       bpo-40513). N’importe quel thread de n’importe quel interpréteur peu recevoir un signal, mais seul
       le thread principal de l’interpréteur principal peut gérer les signaux : Voir
       _Py_ThreadCanHandleSignals(). */
    _Py_atomic_int signals_pending;
    struct _gil_runtime_state gil;
};

Les autres indicateurs sont des membres de interp->ceval, qui est une structure _ceval_state :

struct _ceval_state {
    int recursion_limit;
    /* Enregistre si le suivi est activé sur au moins un thread. Compte le nombre
       de threads pour lesquels `tstate->c_tracefunc` est non-NULL, donc si la
       valeur est 0, nous savons que nous n’avons pas à vérifier le champ
       `c_tracefunc` du thread. Cela accélère l’instruction if dans
       _PyEval_EvalFrameDefault() après `fast_next_opcode`. */
    int tracing_possible;
    /* Cette variable agrège toutes les requêtes de sortie du *fast path*
       dans la boucle d’évaluation. */
    _Py_atomic_int eval_breaker;
    /* Invitation à lâcher le GIL. */
    _Py_atomic_int gil_drop_request;
    struct _pending_calls pending;
}

Le résultat (en « ou ») de tous les indicateurs est stocké dans la variable eval_breaker. Elle indique s’il y a une raison pour que le thread en cours d’exécution arrête son exécution normale de bytecode. Chaque itération de la boucle d’évaluation commence par vérifier si eval_breaker est vrai. S’il est vrai, le thread regarde les indicateurs pour déterminer exactement ce qui lui est demandé de faire, le fait et continue d’exécuter le bytecode.

GOTO calculés

Le code de la boucle d’évaluation est plein de macros, tel que TARGET() et DISPATCH(). Elles ne servent pas à rendre le code plus compact. Elles développent un code différent suivant qu’une optimisation, appelée « GOTO calculés » (alias « threaded code »), est utilisée. Le but de cette optimisation est d’accélérer l’exécution du bytecode en écrivant le code de manière à ce le CPU puisse utiliser son mécanisme de prédiction de branche pour prédire le prochain opcode.

Après avoir exécuté une instruction, la VM effectue une action parmi les trois suivantes :

Pour voir le problème que pose l’instruction continue, nous devons comprendre en quoi switch se compile. Un opcode est un entier compris entre 0 et 255. Comme la plage est dense, le compilateur peut créer une table de sauts qui stocke les adresses des blocs de case et utiliser des opcodes comme index dans cette table. Les compilateurs modernes font en effet cela, de sorte que l’expédition des cas est implémenté via un seul saut indirect. C’est un moyen efficace d’implémenter un switch. Cependant, placer le commutateur à l’intérieur de la boucle et ajouter des instructions continues crée deux problèmes :

L’optimisation peut être activée ou désactivée. Cela dépend du support de l’extension GCC C « labels as values » par le compilateur. Avec l’optimisation activée, certaines macros, telles que TARGET(), DISPATCH() et FAST_DISPATCH(), se développent de façon différente. Ces macros sont énormément utilisées dans le code de la boucle d’évaluation. Chaque expression de case a une forme TARGET(op), où op est une macro définie sur un littéral entier représentant un opcode. Enfin, chaque case non retourné se termine par la macro DISPATCH() ou FAST_DISPATCH(). Voyons d’abord en quoi ces macros se développent lorsque l’optimisation est désactivée :

for (;;) {
    // ... vérifie si l’exécution du bytecode doit être interrompu.

fast_next_opcode:
    // Macro `NEXTOPARG()`.
    _Py_CODEUNIT word = *next_instr;
    opcode = _Py_OPCODE(word);
    oparg = _Py_OPARG(word);
    next_instr++;

    switch (opcode) {
        // `TARGET(NOP)` se développe en `NOP`.
        case NOP: {
            goto fast_next_opcode; // Macro `FAST_DISPATCH()`.
        }

        // ...

        case BINARY_MULTIPLY: {
            // ... code de multiplication binaire.
            continue; // Macro `DISPATCH()`.
        }

        // ...
    }

    // ... gestion des erreurs.
}

La macro FAST_DISPATCH() est utilisée lorsqu’on ne veut pas suspendre la boucle d’évaluation après avoir exécuté certains opcodes. Pour le reste, l’implémentation est très simple.

Si le compilateur supporte l’extension « labels as values », nous pouvons utiliser l’opérateur unaire && sur un label pour obtenir son adresse. Elle est de type void*, nous pouvons donc la stocker dans un pointeur :

void *ptr = &&my_label;

On peut ensuite accéder au label en déréférençant le pointeur :

goto *ptr;

Cette extension permet d’implémenter une table de sauts en C sous la forme d’un tableau de pointeurs de labels. Et c’est ce que fait CPython :

static void *opcode_targets[256] = {
    &&_unknown_opcode,
    &&TARGET_POP_TOP,
    &&TARGET_ROT_TWO,
    &&TARGET_ROT_THREE,
    &&TARGET_DUP_TOP,
    &&TARGET_DUP_TOP_TWO,
    &&TARGET_ROT_FOUR,
    &&_unknown_opcode,
    &&_unknown_opcode,
    &&TARGET_NOP,
    &&TARGET_UNARY_POSITIVE,
    &&TARGET_UNARY_NEGATIVE,
    &&TARGET_UNARY_NOT,
    // ... et quelques autres.
};

Voici à quoi ressemble la version optimisée de la boucle d’évaluation :

for (;;) {
    // ... vérifie si l’exécution du bytecode doit être interrompu.

fast_next_opcode:
    // Macro `NEXTOPARG()`
    _Py_CODEUNIT word = *next_instr;
    opcode = _Py_OPCODE(word);
    oparg = _Py_OPARG(word);
    next_instr++;

    switch (opcode) {
        // `TARGET(NOP)` se développe en `NOP: TARGET_NOP`.
        // Où `TARGET_NOP` est un label.
        case NOP: TARGET_NOP: {
            // Macro `FAST_DISPATCH()`
            // Quand le traçage est désactivé.
            f->f_lasti = INSTR_OFFSET();
            NEXTOPARG();
            goto *opcode_targets[opcode];
        }

        // ...

        case BINARY_MULTIPLY: TARGET_BINARY_MULTIPLY: {
            // ... code de multiplication binaire.
            // DISPATCH() macro
            if (!_Py_atomic_load_relaxed(eval_breaker)) {
              FAST_DISPATCH();
            }
            continue;
        }

        // ...
    }

    // ... gestion des erreurs.
}

L’extension est prise en charge par les compilateurs GCC et Clang. Il est ainsi for probable que l’optimisation soit activée lorsque vous exécutez python. La question, bien sûr, est de savoir comment cela affecte la performance. Pour cela, je vais m’appuyer sur le commentaire du code source :

Au moment d’écrire ces lignes, la version « threaded code » est jusqu’à 15-20% plus rapide que la version « switch » normale, suivant le compilateur et l’architecture du processeur.

Cette section devrait vous éclairer sur la façon dont la VM CPython passe d’une instruction à l’autre et de ce qu’elle peut être amenée à faire entre les deux. L’étape logique suivante consiste à étudier plus en profondeur comment la VM exécute une seule instruction. CPython 3.9 a 119 opcodes différents. Bien sûr, nous n’allons pas voir l’implémentation de chaque opcode dans cet article. Nous nous concentrerons plutôt sur les principes généraux que la VM utilise pour les exécuter.

Pile de valeurs

La chose la plus importante, et aussi la plus simple à comprendre, concernant la VM CPython est qu’elle s’appuie sur une pile (« stack-based »). Cela signifie que pour effectuer les calculs, la VM extrait (ou regarde) les valeurs de la pile, effectue le calcul sur celles-ces dernières et pousse le résultat. Voici quelques exemples :

La pile de valeurs réside dans un frame object. Il est implémenté sous la forme d’une entrée dans le tableau appelé f_localsplus. Le tableau est divisé en plusieurs parties pour stocker différentes choses, mais seule la dernière partie est utilisée pour la pile de valeurs. Le début de cette partie est le bas de la pile. Le champ f_valuestack d’un frame object pointe dessus. Pour localiser le haut de la pile, CPython conserve la variable locale stack_pointer, qui pointe vers l’emplacement suivant, après le haut de la pile. Les éléments du tableau f_localsplus sont des pointeurs vers des objets Python (PyObject*), et la VM CPython utilise ce type de pointeurs pour fonctionner.

Gestion des erreurs et pile de blocs

Not all computations performed by the VM are successful. Suppose we try to add a number to a string like 1 + '41'. The compiler produces the BINARY_ADD opcode to add two objects. When the VM executes this opcode, it calls PyNumber_Add() to calculate the result:

Les calculs effectués par la VM ne sont pas tous couronnés de succès. Supposons que nous essayions d’ajouter un nombre à une chaîne comme 1 + '41'. Le compilateur produit l’opcode BINARY_ADD pour ajouter deux objets. Lorsque la VM exécute cet opcode, elle appelle PyNumber_Add() pour calculer le résultat :

case TARGET(BINARY_ADD): {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    // ... cas particulier d’addition de string.
    sum = PyNumber_Add(left, right);
    Py_DECREF(left);
    Py_DECREF(right);
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}

Ici, l’important pour nous n’est pas la manière dont PyNumber_Add() est implémenté, mais que son appel entraîne une erreur. L’erreur signifie deux choses :

NULL est l’indicateur d’une erreur. La VM le remarque et accède au label de l’erreur à la fin de la boucle d’évaluation. La suite dépend de si un gestionnaire d’exceptions a été défini ou non. Si ce n’est pas le cas, la VM saute sur l’instruction break et la fonction d’évaluation renvoie NULL avec l’exception définie sur le thread state. CPython print les détails de l’exception et quitte. On obtient ce résultat :

$ python -c "1 + '42'"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Mais supposons que nous ayons le meme code dans la clause try de la déclaration try-finally. Le code dans finally sera également exécuté :

$ python -q
>>> try:
...     1 + '41'
... finally:
...     print('Hey!')
...
Hey!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Comment la VM peut-elle continuer son exécution après qu’une erreur se soit produite ? Regardons le bytecode produit par le compilateur pour la déclaration try-finally :

$ python -m dis try-finally.py

  1           0 SETUP_FINALLY           20 (to 22)

  2           2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 ('41')
              6 BINARY_ADD
              8 POP_TOP
             10 POP_BLOCK

  4          12 LOAD_NAME                0 (print)
             14 LOAD_CONST               2 ('Hey!')
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 JUMP_FORWARD            10 (to 32)
        >>   22 LOAD_NAME                0 (print)
             24 LOAD_CONST               2 ('Hey!')
             26 CALL_FUNCTION            1
             28 POP_TOP
             30 RERAISE
        >>   32 LOAD_CONST               3 (None)
             34 RETURN_VALUE

Notez la présence des opcodes SETUP_FINALLY et POP_BLOCK. Le premier met en place le gestionnaire d’exception et le second le supprime. Si une erreur se produit pendant que la VM exécute les instructions entre eux, l’exécution se poursuit en utilisant l’instruction à l’offset 22, qui est le début de la clause finally. Sinon, la clause finally est exécutée après la clause try (offset 12). Dans les deux cas, le bytecode de la clause finally est presque identique. La seule différence étant que le gestionnaire relance (RERAISE) l’exception définie dans la clause try.

Un gestionnaire d’exceptions est implémenté sous la forme d’une simple struct C, appelé « bloc » :

typedef struct {
    int b_type;     /* le type du bloc */
    int b_handler;  /* où effectuer le saut pour trouver le gestionnaire */
    int b_level;    /* niveau de la pile de valeurs qu’on souhaite éventuellement extraire */
} PyTryBlock;

La VM conserve les blocs dans la pile de blocs. Mettre en place un gestionnaire d’exceptions signifie qu’on pousse un nouveau bloc sur la pile de blocs. C’est ce que font des opcodes tel que SETUP_FINALLY. Le label d’erreur pointe vers un morceau de code qui tente de gérer une erreur en utilisant les blocs sur la pile de blocs. La VM déroule la pile de blocs jusqu’à trouver le bloc le plus haut de type SETUP_FINALLY. Elle restaure le niveau de la pile de valeurs au niveau spécifié par le champ b_level du bloc, et continue d’exécuter le bytecode avec l’instruction à l’offset b_handler. C’est essentiellement ainsi que CPython implémente des instructions telles que try-except, try-finally et with.

Il y a une dernière chose à dire sur la gestion des exceptions. Voyons ce qui se passe lorsqu’une erreur se produit pendant que la VM gère une exception :

$ python -q
>>> try:
...     1 + '41'
... except:
...     1/0
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ZeroDivisionError: division by zero

Comme prévu, CPython print l’exception d’origine. Un tel comportement est implémenté de la sorte : Lorsque CPython gère une exception à l’aide d’un bloc SETUP_FINALLY, il définit un autre bloc de type EXCEPT_HANDLER. Si une erreur se produit lorsqu’un bloc de ce type se trouve sur la pile de blocs, la VM obtient l’exception d’origine de la pile de valeurs et la définit comme étant celle actuelle. CPython avait autrefois différents types de blocs, mais maintenant il ne s’agit que de SETUP_FINALLY et EXCEPT_HANDLER.

La pile de blocs est implémentée via le tableau f_blockstack dans frame object. La taille du tableau est définie statiquement à 20. Si vous imbriquez plus de 20 clauses try, vous obtiendrez SyntaxError: too many statically nested blocks.

Résumé

Aujourd’hui, nous avons vu que la VM CPython exécute les instructions de bytecode une par une, dans une boucle infinie. La boucle contient une instruction switch gérant tous les opcodes possibles. Chaque opcode est exécuté dans le bloc case correspondant. La fonction d’évaluation s’exécute dans un thread, et parfois ce thread suspend la boucle pour faire autre chose. Par exemple, un thread peut avoir besoin de libérer le GIL, afin qu’un autre thread puisse le prendre et continuer à exécuter son bytecode. Pour accélérer l’exécution du bytecode, CPython effectue une optimisation permettant d’utiliser le mécanisme de prédiction de branche du CPU. Un commentaire affirme que cela rend CPython 15 à 20 % plus rapide.

Nous avons également vu deux structures de données cruciales pour l’exécution du bytecode :

La conclusion la plus importante de cet article est la suivante : Si vous souhaitez étudier l’implémentation de certains aspects de Python, la boucle d’évaluation est le point de départ idéal. Vous voulez savoir ce qui se passe lorsque vous écrivez x + y ? Jetez un œil au code de l’opcode BINARY_ADD. Vous voulez savoir comment l’instruction with est implémentée ? Cherchez SETUP_WITH. Intéressé par la sémantique exacte d’un appel de fonction ? C’est l’opcode CALL_FUNCTION que vous cherchez. Nous appliquerons cette méthode la prochaine fois, quand on se concentrera sur l’implémentation des variables dans CPython.

Mise à jour du 10 novembre 2020 : Dans les commentaires sur HN, on m’a rapporté que les opcodes UNARY_NEGATIVE et GET_ITER prennent (peek) la valeur du haut de la pile et la remplacent avec le résultat plutôt que d’opérer à un pop/push. Il va sans dire que les deux approches sont sémantiquement équivalentes. La seule différence est que l’approche pop/push décrémente, puis incrémente stack_pointer. CPython évite ces opérations redondantes.


  1. NdT : Une façon précise de dire « les deux octets suivants », voir la page Wikipédia 

Dernière mise à jour : jeu. 17 décembre 2020