Autour du codeDevelopper toujours mieux
Posté le

Le bytecode python

En Python, les programmes avant d'être interprétés sont pré-compillé en bytecode. Dans cet article, nous allons voir comment on peut lire le bytecode d'une fonction et le modifier. Il est parfois intéressant de visualiser le bytecode d'une fonction, mais avant de se lancer dans une telle opération, il faut savoir comment est fait un callable python.

Les objets de type code

Toutes méthodes ou fonctions contiennent un objet de type code. Que l'on peut récupérer via l'attribut __code__. Cet objet contient plusieurs choses.

co_flags
Entier qui indique si la fonction utilise *args ou **kwargs ou s'il y a des imports avec __future__
0x04: vous utilisez *args
0x08: vous utilisez **kwargs
co_codeLe bytecode de la fonction
co_nameLe nom de la fonction
co_filenameLe nom du module dans lequel est déclaré la fonction
co_firstlinenoLe numéro de la première ligne de la fonction
co_lnotab
Tableau compressé de correspondance entre l'instruction en bytecode et le numéro de ligne de l'instruction Il se forme par une suite de couple le premier membre du couple et l'indice de l'instruction du bytecode la deuxième partie est la ligne du code source correspondante. Par contre seule les incréments entre les indices sont stockés dans les couples.
Ainsi pour coder le tableau de correspondance suivant:
Indice de l'instruction dans le bytecodeIncrémentNuméro de ligne dans le code sourceIncrément
0011
3310
631010
4239122

lnotab sera:

lnotab = byte(zip((0, 3, 3, 39), (1,0,10,2)))

L'autre chose à savoir est que l'on prend pour indice de l'instruction en bytecode l'indice de la première instruction de chaque ligne du code source.

co_varnamesUn tuple avec le nom des variables utilisées dans la fonction y compris le nom des arguments de la fonction
co_nlocalsLe nombre de variables locales utilisé par la fonction
co_constsTuple des constantes utilisées dans la fonction
co_namesLe nom des variables définies à l'extérieur de la portée de la fonction
co_argcountLe nombre d'arguments positionnels que prend la fonction
co_kwonlyargcountLe nombre d'arguments uniquement utilisables par mot-clé que prend la fonction
co_freevarsPour les closures, tuple avec les noms des variables définies dans la fonction parent
co_cellvarsTuple du nom des variables définies dans la fonction est référencées dans une closure
co_stacksizeLa taille de la pile

Lire le bytecode d'une fonction

Pour exemple, on va prendre une petite fonction qui traduit les chiffres en lettre en entier

def translate(number):
word_to_digit = {"one": 1,
                 "two": 2}
return word_to_digit[number]

print(translate.__code__.co_consts) # (None, 1, 'one', 2, 'two')
print(translate.__code__.co_varnames) # ('number', 'word_to_digit')
print(list(translate.__code__.co_code)) # [105, 2, 0, 100, 1, 0, 100, 2, 0, 54, 100, 3, 0, 100, 4, 0, 54, 125, 1, 0, 124, 1, 0, 124, 0, 0, 25, 83]

La fonction translate possède cinq constantes "one", "two", 1 et 2 ainsi que None, cela peut sembler étrange, mais None est toujours ajouté à la liste des constantes utilisées par les fonctions. Cela est dû au fait qu'une fonction retourne None par défaut et que l'interpréteur pour une raison d'efficacité ne teste pas si la fonction à des instructions return à chaque sorti de la fonction. La fonction utilise deux variables 'number' et 'word_to_digit'. Si l'on affiche le bytecode, on se retrouve avec un objet byte illisible que l'on peut changer en liste d'entiers, mais ce n'est pas forcément mieux.

Le mieux pour voir le bytecode d'une fonction est d'utiliser le module dis qui va désassembler la fonction.

import dis
dis.dis(translate)

Ainsi la fonction translate désassemblée se présente comme ceci:

16           0 BUILD_MAP                2
             3 LOAD_CONST               1 (1)
             6 LOAD_CONST               2 ('one')
             9 STORE_MAP

17          10 LOAD_CONST               3 (2)
            13 LOAD_CONST               4 ('two')
            16 STORE_MAP
            17 STORE_FAST               1 (word_to_digit)

18          20 LOAD_FAST                1 (word_to_digit)
            23 LOAD_FAST                0 (number)
            26 BINARY_SUBSCR
            27 RETURN_VALUE

La première colonne est le numéro de la ligne de l'instruction comme vous avez pu le deviner, chez moi translate.__code__.co_firstlineno == 15. La deuxième colonne indique la position de l'instruction dans le bytecode. La troisième colonne contient les instructions en anglais plus pratique qu'un simple nombre la 4 éme, Les paramètres que prennent les instructions.

BUILD_MAP créé un dictionnaire puis le mets dans la pile, la pile étant une liste que va utiliser la fonction pour travailler. LOAD_CONST met la constante numéro 1 sur la pile, LOAD_CONST est appelée une deuxième fois pour ajouter à la pile la constante numéro 2 puis STORE_MAP va prendre ce qui a été chargé sur la pile pour en faire un couple clé valeur à ajouter au dictionnaire.

Plus précisément

  • BUILD_MAP 2 fait: PILE.append({})
  • LOAD_CONST 1 fait: PILE.append(__code__.co_const[1])
  • LOAD_CONST 2 fait: PILE.append(__code__.co_const[2])
  • STORE_MAP fait: k = PILE.pop(); v = PILE.pop(); PILE[-1][k] = v

Pour les instructions ligne 17 c'est la même chose, jusqu'au STORE_FAST qui stocke le haut de la pile dans la variable 1 (word_to_digit) Le contenue de la variable est mis sur la pile et on utilise BINARY_SUBSCR qui enlève de la pile le dernier et avant-dernier élément pour appliquer l'opérateur [] et mettre le résultat sur la PILE. ça ressemble à ça:

i = PILE.pop(); o = PILE.pop(); PILE.append(o[i])

RETURN_VALUE retourne la valeur du haut de la pile.

Ecrire soi même du bytecode

On va réécrire la fonction translate mais contrairement à la version que l'on a vu, on va faire en sorte que le dictionnaire word_to_digit ne soit pas construit à chaque appelle de la fonction, mais directement chargé sur la pile depuis les constantes. Le bytecode va ressembler à ceci:

LOAD_CONST 1 0 LOAD_FAST 1 0 BINARY_SUBSCR RETURN_VALUE

co_const va contenir (None, {'one':1, 'two':2}) et co_varnames (number)

Remarquez le 0 après LOAD_CONST en fait, certain opérateur on une taille de 2 et d'autre de 0 (unpyc) lorsqu'ils ont une taille de 2, ils doivent être séparés de deux octets de la prochaine instruction d'où le 0.

Pour fabriquer un objet code on va utiliser types.CodeType. Le constructeur de cette classe est plutôt gourmand en nombre d'argument

argcount, kwonlyargcount, nlocals, stacksize, flags, codestring, constants, names, varnames, filename, name, firstlineno, lnotab[, freevars[, cellvars]]

Les arguments sont ceux que l'on a vu plus haut, mais sans le préfixe co_ pour ce qui est de filename, et firstlineno, dans la mesure où la fonction est générée, soit vous utilisez un nom de fichier du genre soit vous utilisez __file__ et inspect.currentframe().f_lineno pour le numéro de ligne.

import types
import inspect

bytecode = bytes((dis.opmap['LOAD_CONST'], 1, 0,
                  dis.opmap['LOAD_FAST'], 0, 0,
                  dis.opmap['BINARY_SUBSCR'],
                  dis.opmap['RETURN_VALUE']))

code = types.CodeType(1, 0, 1, 3, 0, bytecode, (None, {'one':1, 'two':2}),
                      (), ('number',), __file__, 'translate',
                      inspect.currentframe().f_lineno, b'\x00\x00')

Maintenant que l'on a notre objet code, il faut le mettre dans une fonction, pour ça, on va utiliser, types.FunctionType. Le premier argument est l'objet code, le second est un dictionnaire des variables globales.

translate2 = types.FunctionType(code, globals())
translate2('one') # 1
translate2('two') # 2

Comme on peut le voir, ça marche et comme on peut l'imaginer, c'est plus rapide.

print(timeit.Timer( 'translate("one"); translate("two")', "from __main__ import translate" ).timeit()) # 0.5960358490001454
print(timeit.Timer( 'translate2("one"); translate2("two")', "from __main__ import translate2" ).timeit()) # 0.30501958599961654

Doit-on écrire du bytecode pour optimiser les programmes ? undecided

Non, dans notre exemple, on aurait pu faire ceci:

def translate_factory():
    word_to_digit = {"one": 1,
                     "two": 2}
    def translate3(number):
        return word_to_digit[number]
    return translate3

translate3 = translate_factory()
print(timeit.Timer( 'translate3("one"); translate3("two")', "from __main__ import translate3" ).timeit()) # 0.3185168910003995

translate2 est aussi rapide que translate3, il ne faut pas écrire du bytecode, c'est illisible, indébugable, non-maintenable. Par contre le lire peut-être une bonne source d'information pour acquérir des bonne pratique. Un autre exemple d'utilisation du bytecode est l'ORM Pony qui lie le bytecode des instructions python pour les convertires en SQL

Dernière chose, Si jamais vous souhaitez utiliser FunctionType pour créer des fonctions dynamiquement en fonction des paramètre utilisateur.

  1. C'est une idée complètement Kamikaze au niveau sécurité
  2. Ne faites jamais ça.
  3. Si vous tenez vraiment à le faire je vous déconseille d'utiliser globals() comme dictionnaire en paramètre de FunctionType préférez, créer votre propre environnement en prenant que de petites choses dans builtins.
import builtins
glob_dict = {k: v for k, v in vars(builtins).items() if k in ('len', 'list', 'sum', 'str', 'zip')}

Voilou j'espère que vous avez découvert une autre facette du langage python et que cela vous a donné des idées à expérimenter, alors vos IDE et bon code laughing