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 :
- Que signifie associer un nom à une valeur ? Qu’est-ce qu’une valeur ?
- Que fait CPython pour attribuer une valeur à un nom ? Et pour récupérer cette valeur ?
- Toutes les variables sont-elles implémentées de la même manière ?
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 :
LOAD_NAME
récupère la valeur du nomb
et la pousse sur la pile.STORE_NAME
fait sortir (pop) la valeur de la pile et associe le noma
à cette valeur.
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 :
- Les noms sont des strings. Elles sont stockées dans un code object, dans un tuple appelé
co_names
. La variablenames
est un juste raccourci versco_names
. L’argument de l’instructionSTORE_NAME
n’est pas un nom, mais un index utilisé pour rechercher le nom dansco_names
. La première chose que fait la VM est d’aller chercher, dansco_names
, le nom auquel elle va assigner une valeur. - La VM extrait (pop) la valeur de la pile.
- 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 nomname
à une valeurv
en définissantf_locals[name] = v
.
Nous venons de comprendre deux choses importantes :
- Les variables Python sont des noms mappés à des valeurs.
- Les valeurs des noms sont des références vers des
PyObject
.
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 :
- Comme pour
STORE_NAME
, la VM récupère d’abord le nom de la variable. - La VM cherche ensuite la valeur de ce nom dans le tableau de relations des variables locales :
v = f_locals[name]
. - Si le nom n’est pas dans
f_locals
, la VM tente alors dans le dictionnaire des variables globalesf_globals
. Et si le nom n’y est pas non plus, elle tente dansf_builtins
. Le champf_builtins
d’un frame object pointe vers le dictionnaire du modulebuiltins
, qui contient les types, fonctions, exceptions et constantes standards. Si le nom n’y est pas, la VM abandonne et définie l’exceptionNameError
. - 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 :
- Nous avons toujours les noms du dictionnaire
builtins
à notre disposition, tel queint
,next
,ValueError
etNone
. - Si nous utilisons un nom standard pour une variable locale ou globale, cette dernière viendra prendre le dessus sur la variable standard (shadowing).
- Une variable locale prend le dessus sur une variable globale du même nom.
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 module.
- La fonction (les compréhensions et les lambdas sont aussi des fonctions).
- La définition de classe.
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 où 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 bouclefor
, ou aprèsas
lors d’une instructionwith
ou une clauseexcept
. L’instructionimport
sous la formefrom ... 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 :
LOAD_FAST
etSTORE_FAST
.LOAD_DEREF
etSTORE_DEREF
.LOAD_GLOBAL
etSTORE_GLOBAL
.LOAD_NAME
etSTORE_NAME
.LOAD_CLASSDEREF
.
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 :
- L’étape supplémentaire de récupération du nom.
- Le fait qu’un dictionnaire est plus lent qu’un tableau.
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
- La variable
c
n’est pas globale àg
, car elle est locale àg
. - La variable
b
n’est pas globale àg
, car c’est une variable libre. - La variable
a
est globale àg
, car elle n’est ni locale, ni libre. - La variable
d
est globale àg
, car elle est explicitement déclaréeglobal
.
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 :
- Détermine la portée de la variable :
- Si la variable est déclarée
global
, c’est une variable explicitement globale. - Si la variable est déclarée
nonlocal
, c’est une variable libre. - Si la variable est liée au code block courant, c’est une variable locale.
- Si la variable est liée au code block englobant qui n’est pas une définition de class, c’est une variable libre.
- Sinon, c’est une variable globale implicite.
- Met la portée à jour :
- Si la variable est locale et qu’elle est libre dans le code block imbriquée, c’est une variable de cellule.
- Décide de l’opcode à générer :
- Si la variable est une variable de cellule ou une variable libre, génère l’opcode
*_DEREF
; génère l’opcodeLOAD_CLASSDEREF
pour charger la valeur si le code block courant est une définition de classe. - Si la variable est une variable locale et que le code block courant est une fonction, génère l’opcode
*_FAST
. - Si la variable est explicitement ou implicitement globale et que le code block courant est une fonction, génère l’opcode
*_GLOBAL
. - 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