Python sous le capot — Chapitre 5 : L’implémentation des variables en CPython

Avant-propos (du traducteur)

Ceci est la traduction du cinquième article de Victor Skvortsov du 14 novembre 2020 : Python behind the scenes #5: how variables are implemented in CPython.

Avant-propos

Prenons l’assignation Python suivante :

a = b

Cette déclaration peut sembler triviale. Nous prenons la valeur du nom b et l’assignons au nom a, mais le faisons-nous vraiment ? Cette explication, simpliste, soulève de nombreuses questions :

Aujourd’hui, nous allons voir comment les variables, aspect si crucial d’un langage de programmation, sont implémentées dans CPython.

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.

Début de l’enquête

Par quoi faut-il commencer ? Dans les parties précédentes, nous avons vu que pour exécuter du code Python, CPython doit le compiler en bytecode. Commençons donc par regarder le bytecode de a = b :

$ echo 'a = b' | python -m dis

  1           0 LOAD_NAME                0 (b)
              2 STORE_NAME               1 (a)
...

La dernière fois, nous avons vu que la VM CPython fonctionne via une pile de valeurs. La plupart des instructions de bytecode extraient (pop) les valeurs de la pile, en font quelque chose et poussent (push) le résultat sur la pile. Les instructions LOAD_NAME et STORE_NAME sont dédiées à cette tache. Voici ce qu’elles font dans notre exemple :

La dernière fois, nous avons vu que les opcodes sont implémentés via une instruction switch géante dans Python/ceval.c. On peut ainsi retrouver les blocs de case de LOAD_NAME et STORE_NAME, et voir comment ils fonctionnent. Commençons logiquement par STORE_NAME, car nous devons associer un nom à une valeur avant de pouvoir obtenir une valeur depuis ce nom :

case TARGET(STORE_NAME): {
    PyObject *name = GETITEM(names, oparg);
    PyObject *v = POP();
    PyObject *ns = f->f_locals;
    int err;
    if (ns == NULL) {
        _PyErr_Format(tstate, PyExc_SystemError,
                      "no locals found when storing %R", name);
        Py_DECREF(v);
        goto error;
    }
    if (PyDict_CheckExact(ns))
        err = PyDict_SetItem(ns, name, v);
    else
        err = PyObject_SetItem(ns, name, v);
    Py_DECREF(v);
    if (err != 0)
        goto error;
    DISPATCH();
}

Analysons ce qu’il fait :

  1. Les noms sont des strings. Elles sont stockées dans un code object, dans un tuple appelé co_names. La variable names est un juste raccourci vers co_names. L’argument de l’instruction STORE_NAME n’est pas un nom, mais un index utilisé pour rechercher le nom dans co_names. La première chose que fait la VM est d’aller chercher, dans co_names, le nom auquel elle va assigner une valeur.
  2. La VM extrait (pop) la valeur de la pile.
  3. Les valeurs des variables sont stockées dans un frame object. Le champ f_locals d’un frame object est un tableau de relation entre les noms des variables locales et leur valeur. La VM associe un nom name à une valeur v en définissant f_locals[name] = v.

Nous venons de comprendre deux choses importantes :

L’exécution de l’opcode LOAD_NAME est un peu plus compliquée, car la VM cherche la valeur d’un nom, non seulement dans f_locals, mais également à d’autres endroits :

case TARGET(LOAD_NAME): {
    PyObject *name = GETITEM(names, oparg);
    PyObject *locals = f->f_locals;
    PyObject *v;

    if (locals == NULL) {
        _PyErr_Format(tstate, PyExc_SystemError,
                        "no locals when loading %R", name);
        goto error;
    }

    // cherche la valeur dans `f->f_locals`
    if (PyDict_CheckExact(locals)) {
        v = PyDict_GetItemWithError(locals, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
    }
    else {
        v = PyObject_GetItem(locals, name);
        if (v == NULL) {
            if (!_PyErr_ExceptionMatches(tstate, PyExc_KeyError))
                goto error;
            _PyErr_Clear(tstate);
        }
    }

    // cherche la valeur dans `f->f_globals` et `f->f_builtins`
    if (v == NULL) {
        v = PyDict_GetItemWithError(f->f_globals, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
        else {
            if (PyDict_CheckExact(f->f_builtins)) {
                v = PyDict_GetItemWithError(f->f_builtins, name);
                if (v == NULL) {
                    if (!_PyErr_Occurred(tstate)) {
                        format_exc_check_arg(
                                tstate, PyExc_NameError,
                                NAME_ERROR_MSG, name);
                    }
                    goto error;
                }
                Py_INCREF(v);
            }
            else {
                v = PyObject_GetItem(f->f_builtins, name);
                if (v == NULL) {
                    if (_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) {
                        format_exc_check_arg(
                                    tstate, PyExc_NameError,
                                    NAME_ERROR_MSG, name);
                    }
                    goto error;
                }
            }
        }
    }
    PUSH(v);
    DISPATCH();
}

On peut traduire ce code de la façon suivante :

  1. Comme pour STORE_NAME, la VM récupère d’abord le nom de la variable.
  2. La VM cherche ensuite la valeur de ce nom dans le tableau de relations des variables locales : v = f_locals[name].
  3. Si le nom n’est pas dans f_locals, la VM tente alors dans le dictionnaire des variables globales f_globals. Et si le nom n’y est pas non plus, elle tente dans f_builtins. Le champ f_builtins d’un frame object pointe vers le dictionnaire du module builtins, qui contient les types, fonctions, exceptions et constantes standards. Si le nom n’y est pas, la VM abandonne et définie l’exception NameError.
  4. Si la VM trouve la valeur, elle la pousse sur la pile.

La façon dont la machine virtuelle cherche la valeur a les effets suivants :

Dans la mesure où

La seule chose qu’on souhaite faire avec ces variables étant de les associer à des valeurs et de pouvoir récupérer ces valeurs, on pourrait penser que STORE_NAME et LOAD_NAME sont suffisants pour implémenter toutes les variables en Python. Ce n’est pas le cas. Examinons l’exemple ci-dessous :

x = 1

def f(y, z):
    def _():
        return z

    return x + y + z

La fonction f doit charger la valeur des variables x, y et z pour les additionner et renvoyer le résultat. Notez quels sont les opcodes générés par le compilateur pour faire cela :

$ python -m dis global_fast_deref.py
...
  7          12 LOAD_GLOBAL              0 (x)
             14 LOAD_FAST                0 (y)
             16 BINARY_ADD
             18 LOAD_DEREF               0 (z)
             20 BINARY_ADD
             22 RETURN_VALUE
...

Il n’y a aucun opcode LOAD_NAME. Le compilateur génère l’opcode LOAD_GLOBAL pour charger la valeur de x, l’opcode LOAD_FAST pour charger la valeur de y et l’opcode LOAD_DEREF pour charger la valeur de z. Pour comprendre la raison qui pousse le compilateur à faire ce choix, nous devons cerner deux concepts importants : Les namespaces (espaces de noms) et les portées (scope).

Espaces de noms et portées

Un programme Python est composé de code blocks. Un code blocks est un morceau de code que la VM exécute comme une unité seule. CPython distingue trois types de code block :

Le compilateur créé un code object pour chaque code block d’un programme. Un code object est une structure décrivant ce que fait le code block. Il contient également le bytecode du code block. Pour exécuter un code object, CPython cré un état d’exécution appelé frame object. Un frame object contient, entre autres, des tableaux de relations nom/valeur, tel que f_locals, f_globals et f_builtins. L’ensemble de ces tableaux forme un namespace. Chaque code block amène un namespace : Sont namespace local. Un même nom dans un programme peut faire référence à différentes variables dans différents namespaces :

x = y = "I'm a variable in a global namespace"

def f():
    x = "I'm a local variable"
    print(x)
    print(y)

print(x)
print(y)
f()
$ python namespaces.py
I'm a variable in a global namespace
I'm a variable in a global namespace
I'm a local variable
I'm a variable in a global namespace

Une autre notion importante et la portée. Voici ce que la documentation en dit :

Une portée est une région textuelle d’un programme Python ou un namespace est directement accessible. Ici, « directement accessible » veut dire qu’une référence non qualifiée à un nom tentera de trouver ce nom dans le namespace.

On peut voir la portée comme la propriété d’un nom qui dit la valeur de ce nom est stockée. Un exemple est la portée locale. La portée d’un nom est relative à un code block. L’exemple suivant illustre ce point :

a = 1

def f():
    b = 3
    return a + b

Ici, le nom a fait référence à une unique variable dans les deux cas. Du point de vue de la fonction, c’est une variable globale, mais du point de vue du module, elle est à la fois globale et locale. La variable b est local à la fonction f, mais n’existe pas au niveau du module.

La variable est considérée comme local à un code block si elle est liée à ce code block. Une assignation tel que a = 1 lie le nom a à 1. L’assignation n’est en revanche pas la seule façon de lier un nom. La documentation de Python en liste quelques autres :

Les constructions suivantes lient des noms : Les paramètres formels des fonctions, les instructions import, les définitions de classes et de fonctions (ces dernières lient le nom de la classe ou de la fonction dans le bloc de définition), et les cibles qui sont des identificateurs s’ils se produisent dans une assignation, un en-tête de boucle for, ou après as lors d’une instruction with ou une clause except. L’instruction import sous la forme from ... import * lie tous les noms définis dans le module importé à l’exception de ceux commençant pas un underscore. Cette forme ne peut être utilisée qu’au niveau du module.

Le compilateur interprète toute liaison comme une liaison locale. C’est la raison pour laquelle le code suivant lève une exception :

a = 1

def f():
    a += 1
    return a

print(f())
$ python unbound_local.py
...
    a += 1
UnboundLocalError: local variable 'a' referenced before assignment

La déclaration a += 1 est une forme d’assignation, elle est donc interprétée comme locale par le compilateur. Pour effectuer cette opération, la VM essai de charger la valeur de a, échoue et défini une exception. Pour dire au compilateur que a est global malgré l’assignation, on peut utiliser l’instruction global :

a = 1

def f():
    global a
    a += 1
    print(a)

f()
$ python global_stmt.py
2

De façon similaire, on peut utiliser l’instruction nonlocal pour dire au compilateur qu’un nom lié dans une fonction imbriquée fait référence à une variable de la fonction parent :

a = "I'm not used"

def f():
    def g():
        nonlocal a
        a += 1
        print(a)
    a = 2
    g()

f()
$ python nonlocal_stmt.py
3

C’est le travail du compilateur d’analyser l’utilisation des noms à l’intérieur d’un code block et de prendre en compte les déclarations de global et nonlocal pour produire les bons opcodes pour charger et stocker les valeurs. En général, l’opcode choisi par le compilateur pour un nom donné dépend de la portée de ce nom et du type de code block en cours de compilation. La VM exécute chaque opcode différemment. Tout cela est fait pour que les variables Python fonctionnent comme elles le doivent.

Au total, CPython utilise quatre paires d’opcode de chargement/stockage (load/store) et un opcode de chargement supplémentaire :

Regardons ce qu’ils font et pourquoi ils sont tous nécessaires à CPython.

LOAD_FAST et STORE_FAST

Le compilateur génère les opcodes LOAD_FAST et STORE_FAST pour les variables locales d’une fonction. Voici un exemple :

def f(x):
    y = x
    return y
$ python -m dis fast_variables.py
...
  2           0 LOAD_FAST                0 (x)
              2 STORE_FAST               1 (y)

  3           4 LOAD_FAST                1 (y)
              6 RETURN_VALUE

La variable y est locale à f, car elle est liée dans f par son assignation. La variable x est local à f, car elle est liée dans f en tant que paramètre.

Regardons le code exécuté par STORE_FAST :

case TARGET(STORE_FAST): {
    PREDICTED(STORE_FAST);
    PyObject *value = POP();
    SETLOCAL(oparg, value);
    FAST_DISPATCH();
}

La macro SETLOCAL() se développe essentiellement en fastlocals[oparg] = value. La variable fastlocals est un raccourci vers le champ f_localsplus du frame object. Ce champs est un tableau de pointeurs vers des objets Python. Il stocke les valeurs des variables locales, des cellules, des variables libres et la pile de valeurs. La dernière fois, nous avons vu que le tableau f_localsplus est utilisé pour stocker la pile de valeurs. Dans la section suivante, nous allons voir comment il est utilisé pour stocker les valeurs des cellules et des variables libres. Pour l’instant, nous allons nous intéresser à la première partie du tableau, qui est utilisé pour les variables locales.

Nous avons vu qu’avec STORE_NAME, la VM récupère d’abord le nom depuis co_names, puis mappe ce nom à la valeur du dessus de la pile. Elle utilise f_locals comme tableau de relation nom/valeur, qui est généralement un dictionnaire. Avec STORE_FAST, la VM n’a pas besoin de récupérer le nom. Le nombre de variables locales peut être calculé statiquement par le compilateur, afin que la VM puisse utiliser un tableau pour stocker leurs valeurs. Chaque variable locale peut ensuite être associée à un index de ce tableau. Pour mapper un nom à une valeur, la VM stocke simplement la valeur à l’index correspondant.

La VM n’a pas besoin de récupérer le nom des variables locales d’une fonction pour charger et stocker leur valeur. Elle stock néanmoins ces noms dans le code object de la fonction, dans le tuple co_varnames. Pourquoi ça ? Les noms des variables sont nécessaires au débogage et aux messages d’erreurs. Elles sont également utilisées par des outils comme dis, qui lit le contenu de co_varnames pour afficher les noms entre parentheses :

              2 STORE_FAST               1 (y)

CPython dispose de la fonction standards locals() qui renvoie le namespace local du code block courant sous forme de dictionnaire. La VM ne conserve pas un tel dictionnaire pour les fonctions, mais elle peut en créer un à la volée, en mappant les clés de co_varnames aux valeurs de f_localsplus.

LOAD_FAST pousse simplement f_localsplus[oparg] sur la pile :

case TARGET(LOAD_FAST): {
    PyObject *value = GETLOCAL(oparg);
    if (value == NULL) {
        format_exc_check_arg(tstate, PyExc_UnboundLocalError,
                             UNBOUNDLOCAL_ERROR_MSG,
                             PyTuple_GetItem(co->co_varnames, oparg));
        goto error;
    }
    Py_INCREF(value);
    PUSH(value);
    FAST_DISPATCH();
}

Les opcodes LOAD_FAST et STORE_FAST n’existent que pour des raisons de performance. Ils sont appelés *_FAST, car la VM utilise un tableau de relation, ce qui est plus rapide qu’un dictionnaire. De quel gain de vitesse parle-t-on ? Mesurons la différence entre STORE_FAST et STORE_NAME. Le morceau de code suivant stock la valeur de la variable i 100 millions de fois :

for i in range(10**8):
    pass

Si nous le plaçons dans un module, le compilateur génère un opcode STORE_NAME. Si nous le plaçons dans une fonction, le compilateur génère un STORE_FAST. Faisons les deux et comparons les temps d’exécution :

import time


# mesure STORE_NAME
times = []
for _ in range(5):
    start = time.time()
    for i in range(10**8):
        pass
    times.append(time.time() - start)

print('STORE_NAME: ' + ' '.join(f'{elapsed:.3f}s' for elapsed in sorted(times)))


# mesure STORE_FAST
def f():
    times = []
    for _ in range(5):
        start = time.time()
        for i in range(10**8):
            pass
        times.append(time.time() - start)

    print('STORE_FAST: ' + ' '.join(f'{elapsed:.3f}s' for elapsed in sorted(times)))

f()
$ python fast_vs_name.py
STORE_NAME: 4.536s 4.572s 4.650s 4.742s 4.855s
STORE_FAST: 2.597s 2.608s 2.625s 2.628s 2.645s

Une autre différence dans l’implémentation de STORE_NAME et STORE_FAST peut théoriquement influencer ces résultats. Le case de l’opcode STORE_FAST se termine par la macro FAST_DISPATCH(), ce qui veut dire qu’après avoir exécuté l’instruction STORE_FAST, la VM saute immédiatement à l’instruction suivante. Le case de l’opcode STORE_NAME se termine lui par la macro DISPATCH(), ce qui veut dire que la VM peut éventuellement retourner au début de la boucle d’évaluation. Au début de cette boucle d’évaluation, la VM vérifie si elle doit suspendre l’exécution du bytecode pour, par exemple, relâcher le GIL ou pour gérer les signaux. J’ai toutefois remplacé la macro DISPATCH() par FAST_DISPATCH() dans le case de STORE_NAME, recompilé CPython et obtenu des résultats similaires. Ainsi, la différence de temps s’explique peut-être plus par :

LOAD_DEREF et STORE_DEREF

Il y a un cas où le compilateur ne génère pas l’opcode LOAD_FAST et STORE_FAST pour des variables locales d’une fonction, c’est quand une variable est utilisée dans une fonction imbriquée.

def f():
    b = 1
    def g():
        return b
$ python -m dis nested.py
...
Disassembly of <code object f at 0x1027c72f0, file "nested.py", line 1>:
  2           0 LOAD_CONST               1 (1)
              2 STORE_DEREF              0 (b)

  3           4 LOAD_CLOSURE             0 (b)
              6 BUILD_TUPLE              1
              8 LOAD_CONST               2 (<code object g at 0x1027c7240, file "nested.py", line 3>)
             10 LOAD_CONST               3 ('f.<locals>.g')
             12 MAKE_FUNCTION            8 (closure)
             14 STORE_FAST               0 (g)
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

Disassembly of <code object g at 0x1027c7240, file "nested.py", line 3>:
  4           0 LOAD_DEREF               0 (b)
              2 RETURN_VALUE

Le compilateur génère l’opcode LOAD_DEREF et STORE_DEREF pour les cellules et les variables libres. Une cellule est une variable locale référencée dans une fonction imbriquée. Dans notre exemple, b est une cellule de la fonction f, car elle est référencée par g. Du point de vue d’une fonction imbriquée, une variable libre est une cellule. C’est une variable qui n’est pas lié à la fonction imbriquée, mais à la fonction englobante (ou parent), ou une variable déclarée nonlocal. Dans notre exemple, b est une variable libre de la fonction g, car elle n’est pas liée à g, mais à f.

Les valeurs des variables libres et des cellules sont stockées dans le tableau f_localsplus après les valeurs des variables locales « normales ». La seule différence étant que f_localsplus[index_of_cell_or_free_variable] ne pointe pas directement sur la valeur, mais sur un objet « cellule » (PyCellObject) contenant la valeur :

typedef struct {
    PyObject_HEAD
    PyObject *ob_ref;       /* Contenu de la cellule, ou NULL si vide */
} PyCellObject;

STORE_DEREF récupère (pop) la valeur de la pile, prends la cellule de la variable spécifiée par oparg et assigne le ob_ref de cette cellule à la valeur récupérée de la pile :

case TARGET(STORE_DEREF): {
    PyObject *v = POP();
    PyObject *cell = freevars[oparg]; // freevars = f->f_localsplus + co->co_nlocals
    PyObject *oldobj = PyCell_GET(cell);
    PyCell_SET(cell, v); // expands to ((PyCellObject *)(cell))->ob_ref = v
    Py_XDECREF(oldobj);
    DISPATCH();
}

LOAD_DEREF fonctionne en poussant le contenu d’une cellule sure la pile.

case TARGET(LOAD_DEREF): {
    PyObject *cell = freevars[oparg];
    PyObject *value = PyCell_GET(cell);
    if (value == NULL) {
      format_exc_unbound(tstate, co, oparg);
      goto error;
    }
    Py_INCREF(value);
    PUSH(value);
    DISPATCH();
}

Pourquoi stocke-t-on des valeurs dans des cellules ? On le fait pour connecter une variable libre à la cellule correspondante. Leurs valeurs sont stockées dans différents namespaces dans différents frame objects, mais dans la même cellule. La VM passes les cellules d’une fonction englobante vers la fonction imbriquée quand elle crée la fonction englobante. LOAD_CLOSURE pousse une cellule sur la pile et MAKE_FUNCTION créé un fonction object avec cette cellule pour la variable libre correspondante. Du fait du mécanisme de cellules, quand une fonction englobante réassigne la variable d’une cellule, la fonction imbriquée prend en compte ce réassignement :

def f():
    def g():
        print(a)
    a = 'assigned'
    g()
    a = 'reassigned'
    g()

f()
$ python cell_reassign.py
assigned
reassigned

Et vice versa :

def f():
    def g():
        nonlocal a
        a = 'reassigned'
    a = 'assigned'
    print(a)
    g()
    print(a)

f()
$ python free_reassign.py
assigned
reassigned

A-t-on réellement besoin de ce mécanisme de variables cellulaires pour implémenter un tel comportement ? Ne peut-on pas simplement utiliser le namespace englobant pour charger et stocker les valeurs des variables libres ? Oui, on pourrait, mais examinons l’exemple suivant :

def get_counter(start=0):
    def count():
        nonlocal c
        c += 1
        return c

    c = start - 1
    return count

count = get_counter()
print(count())
print(count())
$ python counter.py
0
1

Rappelez-vous que quand on appelle une fonction, CPython créé un frame object pour l’exécuter. Cet exemple montre qu’une fonction imbriquée peut survivre au frame object de la fonction englobante. L’avantage du mécanisme de cellule est qu’il permet d’éviter de garder en mémoire le frame object d’une fonction englobante ainsi que toutes ses références.

LOAD_GLOBAL et STORE_GLOBAL

Le compilateur génère les opcodes LOAD_GLOBAL et STORE_GLOBAL pour les variables globales, dans les fonctions. La variable est considérée comme globale dans une fonction si elle est déclarée global ou si elle n’est pas liée à la fonction ou n’importe quelle fonction englobante (c.à.d qu’elle n’est ni local ou libre). Voici un exemple :

a = 1
d = 1

def f():
    b = 1
    def g():
        global d
        c = 1
        d = 1
        return a + b + c + d

Voici l’implémentation de l’opcode STORE_GLOBAL :

case TARGET(STORE_GLOBAL): {
    PyObject *name = GETITEM(names, oparg);
    PyObject *v = POP();
    int err;
    err = PyDict_SetItem(f->f_globals, name, v);
    Py_DECREF(v);
    if (err != 0)
        goto error;
    DISPATCH();
}

Le champ f_globals du frame object est un dictionnaire de correspondance entre les noms globaux et leur valeur. Quand CPython créé un frame object pour un module, il assigne f_globals au dictionnaire du module. On peut facilement le vérifier :

$ python -q
>>> import sys
>>> globals() is sys.modules['__main__'].__dict__
True

Quand la VM exécute l’opcode MAKE_FUNCTION pour créer un nouveau function object, elle assigne le champ func_globals de cet objet au f_globals du frame object courant. Quand la fonction est appelée, la VM lui créé un nouveau frame object avec f_globals mis dans func_globals.

L’implémentation de LOAD_GLOBAL est similaire à celle de LOAD_NAME à deux exceptions près :

* Elle ne récupère pas les valeurs dans `f_locals`.
* Elle utilise le cache pour accélérer la récupération.

CPython cache les résultats dans un code object du tableau co_opcache. Le tableau stock des pointeurs vers des structures _PyOpcache :

typedef struct {
    PyObject *ptr;  /* Pointeurs cachés (référence empruntée) */
    uint64_t globals_ver;  /* ma_version du dict global */
    uint64_t builtins_ver; /* ma_version du dict builtin */
} _PyOpcache_LoadGlobal;

struct _PyOpcache {
    union {
        _PyOpcache_LoadGlobal lg;
    } u;
    char optimized;
};

Le champ ptr de la structure _PyOpcache_LoadGlobal pointe en fait vers le résultat de LOAD_GLOBAL. Le cache est maintenu par nombre d’instructions. Un autre tableau du code object appelé co_opcache_map mappe chaque instruction du bytecode vers son indexe (moins un) dans co_opcache. Si une instruction n’est pas LOAD_GLOBAL, il mappe l’instruction sur 0, ce qui veut dire que l’instruction n’est jamais cachée. La taille du cache ne dépasse pas 254. Si le bytecode contient plus de 254 instructions LOAD_GLOBAL, co_opcache_map mappe les instructions supplémentaires également à 0.

Si la VM trouve une valeur dans le cache quand elle exécute LOAD_GLOBAL, elle s’assure que les dictionnaires f_globals et f_builtins n’ont pas été modifiés depuis la dernière fois que la valeur a été récupérée. Cela est fait en comparant globals_ver et builtins_ver avec ma_version_tag des dictionnaires. Le champ ma_version_tag d’un dictionnaire change chaque fois que le dictionnaire est modifié. Voir PEP 509 pour plus de détails.

Si la VM ne trouve pas de valeur dans le cache, elle fait une récupération normale, d’abord dans f_globals puis dans f_builtins. Si éventuellement elle trouve une valeur, elle enregistre le ma_version_tag courant des deux dictionnaires et pousse la valeur sur la pile.

LOAD_NAME et STORE_NAME (et LOAD_CLASSDEREF)

À cette étape, vous vous demandez peut-être pourquoi CPython utilise LOAD_NAME et STORE_NAME. Le compilateur ne générant pas ces opcodes quand il compile des fonctions. Mais au-delà des fonctions, CPython a deux autres types de code blocks : Les définitions de module et de classe. Nous n’avons pas abordé les définitions de classe, et c’est ce que je vous propose de faire maintenant.

Tout d’abord, il est important de comprendre que lorsque nous définissons une classe, la VM exécute son corps. Voici ce que j’entends par là :

class A:
    print('This code is executed')
$ python create_class.py
This code is executed

Le compilateur créé des code objects pour les définitions de classe au même titre qu’il le fait pour les modules et les fonctions. Ce qui est intéressant, c’est que le compilateur génère presque toujours les opcodes LOAD_NAME et STORE_NAME pour les variables comprises dans le corps d’une classe, à deux rares exceptions près : Les variables libres et celles explicitement déclarées global.

La VM exécute les opcodes *_NAME et *_FAST différemment. Par conséquent, les variables fonctionnent différemment dans le corps d’une classe que dans une fonction :

x = 'global'

class C:
    print(x)
    x = 'local'
    print(x)
$ python class_local.py
global
local

Au premier print(), la VM charge la valeur de la variable x depuis f_globals. Puis elle stocke la nouvelle valeur ('local') dans f_locals, dont elle va ensuite charger la valeur au second print(). Si C était une fonction, nous aurions un UnboundLocalError: local variable 'x' referenced before assignment lors de son appel, car le compilateur penserait que la variable x est locale à C.

Comment interagissent les namespaces des classes et de leurs fonctions ? Quand on place une fonction dans une classe, une pratique courante pour implémenter des méthodes, la fonction ne voit pas les noms liés dans au namespace de la classe :

class D:
    x = 1
    def method(self):
        print(x)

D().method()
$ python func_in_class.py
...
NameError: name 'x' is not defined

Cela est dû au fait que la VM stocke la valeur de x avec STORE_NAME lorsqu’elle exécute la définition de classe et tente de la charger avec LOAD_GLOBAL lorsqu’elle exécute la fonction. Cependant, lorsque nous plaçons une définition de classe à l’intérieur d’une fonction, le mécanisme de cellule fonctionne comme si nous placions une fonction à l’intérieur d’une fonction :

def f():
    x = "I'm a cell variable"
    class B:
        print(x)

f()
$ python class_in_func.py
I'm a cell variable

Il y a cependant une différence. Le compilateur génère l’opcode LOAD_CLASSDEREF au lieu de LOAD_DEREF pour charger la valeur de x. La documentation du module dis explique ce que fait LOAD_CLASSDEREF :

Pratiquement comme LOAD_DEREF, mais regarde d’abord dans le dictionnaire local avant de consulter la cellule. Ceci est utilisé pour charger des variables libres du corps des classes.

Pourquoi regarder d’abord le dictionnaire local ? Dans le cas d’une fonction, le compilateur sait avec certitude si une variable est locale ou non. Dans le cas d’une classe, le compilateur ne peut pas être sûr. Cela est dû au fait que CPython a des métaclasses et qu’une métaclasse peut préparer un dictionnaire local non vide pour une classe en implémentant la méthode __prepare__.

Nous savons maintenant pourquoi le compilateur génère les opcodes LOAD_NAME et STORE_NAME pour les définitions de classe, mais nous avons également vu qu’il les génère pour les variables compris dans le namespace du module, comme dans l’exemple a = b. Ils fonctionnent comme prévu, car le f_locals du module et le f_globals pointe sur le même objet :

$ python -q
>>> locals() is globals()
True

Vous vous demandez peut-être pourquoi CPython n’utilise pas LOAD_GLOBAL et STORE_GLOBAL dans ce cas. Honnêtement, je ne connais pas la raison exacte, s’il y en a une, mais j’ai une hypothèse. CPython dispose des fonctions standards compile(), eval() et exec() qui peuvent être utilisées pour compiler et exécuter dynamiquement du code Python. Ces fonctions utilisent les opcodes LOAD_NAME et STORE_NAME dans le namespace courant. C’est parfaitement logique car cela permet d’exécuter du code dynamiquement dans un corps de classe et d’obtenir le même effet que si ce code y était directement écrit :

a = 1

class A:
    b = 2
    exec('print(a + b)', globals(), locals())
$ python exec.py
3

CPython choisi de toujours utiliser les opcodes LOAD_NAME et STORE_NAME pour les modules. En ce sens, le bytecode généré par le compilateur lorsque nous exécutons un module de manière normale est le même que lorsque nous exécutons le module avec exec().

Comment le compilateur choisi l’opcode à générer

Dans la partie 2 de cette série, nous avons vu qu’avant de créer le code object d’un code block, le compilateur génère une table de symboles pour ce bloc. Une table de symboles contient des informations sur les symboles (c.à.d. les noms) utilisés à l’intérieur d’un code block, y compris leurs portées. Le compilateur décide quel opcode de load/store générer pour un nom donné, suivant la portée et le type de code block qu’il compile. L’algorithme peut être résumé de la sorte :

  1. Détermine la portée de la variable :
  2. Si la variable est déclarée global, c’est une variable explicitement globale.
  3. Si la variable est déclarée nonlocal, c’est une variable libre.
  4. Si la variable est liée au code block courant, c’est une variable locale.
  5. Si la variable est liée au code block englobant qui n’est pas une définition de class, c’est une variable libre.
  6. Sinon, c’est une variable globale implicite.
  7. Met la portée à jour :
  8. Si la variable est locale et qu’elle est libre dans le code block imbriquée, c’est une variable de cellule.
  9. Décide de l’opcode à générer :
  10. Si la variable est une variable de cellule ou une variable libre, génère l’opcode *_DEREF ; génère l’opcode LOAD_CLASSDEREF pour charger la valeur si le code block courant est une définition de classe.
  11. Si la variable est une variable locale et que le code block courant est une fonction, génère l’opcode *_FAST.
  12. Si la variable est explicitement ou implicitement globale et que le code block courant est une fonction, génère l’opcode *_GLOBAL.
  13. Sinon, génère l’opcode *_NAME.

Vous n’avez pas besoin de vous souvenir de ces règles. Vous pouvez toujours lire le code source. Consultez Python/symtable.c pour savoir comment le compilateur détermine la portée d’une variable et Python/compile.c pour savoir comment il décide quel opcode générer.

Conclusion

Le sujet des variables Python est beaucoup plus compliqué qu’il n’y paraît. Une bonne partie de la documentation Python est liée aux variables, comme la section sur la dénomination et la liaison et celle sur les portées et les namespaces. Les principales questions de la FAQ Python concernent les variables. Et je ne parle pas des questions sur Stack Overflow. Bien que les ressources officielles donnent une idée des raisons pour lesquelles les variables Python fonctionnent ainsi, il est toujours difficile de comprendre et de se souvenir de toutes les règles. Heureusement, il est plus facile de comprendre le fonctionnement des variables en lisant directement le code source de l’implémentation Python. Et c’est ce que nous avons fait aujourd’hui.

Nous avons vu un groupe d’opcodes que CPython utilise pour charger et stocker les valeurs des variables. Pour comprendre comment la VM exécute les autres opcodes, qui calculent réellement quelque chose, nous devons entrer dans le cœur de Python : Le système d’objets. C’est le but de la prochaine partie.

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