Python 3.11 est sorti à la fin de l'année dernière. Comme souvent, il y a beaucoup de nouveautés. Une section a plus attiré mon œil que les autres : il y a eu beaucoup de changements et d'ajouts sur les enums ! Bon, la vérité, c'est que je cherchais comment faire quelque chose d'assez spécifique et j'ai vu que Python 3.11 apportait justement cette fonctionnalité... J'ai bien sûr immédiatement mis à jour mon interpréteur pour tester ça !
Dans cet article, je vous présente les nouveautés qui me semblent les plus prometteuses.
3.11 : une version importante pour le module <span class="css-span">enum</span>
Le module <span class="css-span">enum</span> est très stable depuis son apparition en version 3.4 et l'implémentation de la PEP 435.
Au début de la documentation, on voit :
<pre><code>New in version 3.6: Flag, IntFlag, auto
New in version 3.11: StrEnum, EnumCheck, ReprEnum, FlagBoundary, property, member, nonmember, global_enum, show_flag_values</code></pre>
La version 3.11 est donc une version qui apporte beaucoup de nouveautés. 9 sont listées au début de la documentation, mais il y en a une 10ᵉ qu'on trouve plus bas : <span class="css-span">verify()</span>. En vrai, la documentation est loin d'être parfaite, mais on s'en sort.
Streets of Rage
Pour le fun, j'ai décidé d'utiliser des exemples basés sur Streets of Rage. Quoi ?! Tu connais pas Streets of Rage ?! Mais fonce vite agrandir ta culture pop !
Mon épisode préféré est clairement le 2, mais j'utiliserai un peu le 1 aussi !
Ce qu'on pouvait déjà faire avant Python 3.11
Si on souhaite lister les niveaux de Streets of Rage 2, il est possible de faire une énumération comme celle-ci depuis Python 3.4 :
<pre><code>class Stages(Enum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 4
# ... et plusieurs autres encore !</code></pre>
Elles sont très permissives et on peut par exemple faire quelque chose comme :
<pre><code>class Stages(Enum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 'three'
STADIUM = [4]</code></pre>
Il est donc possible d'avoir des valeurs de types différents. Ça peut être pratique dans certains cas, mais on souhaite en général imposer le type des valeurs, comme dans notre exemple dans lequel chaque niveau correspond à un numéro. C'est pour cette raison que <span class="css-span">IntEnum</span> a été introduite en Python 3.6 :
<pre><code>class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 'four'</code></pre>
On obtient une exception à l'exécution : <span class="css-span">ValueError: invalid literal for int() with base 10: 'four'</span>.
Notez que si on a <span class="css-span">STADIUM = '4'</span> (notez les simple quotes autour du 4), le code fonctionne. En effet, comme l'indique l'exception, <span class="css-span">IntEnum</span> utilise <span class="css-span">int()</span> pour obtenir la valeur et il s'avère que <span class="css-span">int('4') == 4</span>. On peut ainsi utiliser comme initializer une instance d'une classe qui fournit une méthode <span class="css-span">def __int__(self) -> int</span>.
<span class="css-span">IntEnum</span> est en fait une "mixed enum". Le principe des mixed enums est de faire un héritage (multiple) d'un type souhaité <span class="css-span">T</span> et d'<span class="css-span">enum</span>. Ce n'est pas très bien documenté à mon goût, mais on trouve des explications dans le "Enum HOWTO" (ici et un peu là). On obtient ainsi une énumération dont les valeurs sont obligatoirement du même type <span class="css-span">T</span>.
Après ces rappels, on va s'attarder dans la suite aux changements apportés par la version 3.11.
<span class="css-span">ReprEnum</span>
Si on hérite <span class="css-span">ReprEnum</span> plutôt que <span class="css-span">Enum</span>, on créé une énumération pour laquelle la conversion en string de ses valeurs sera alors la même que la conversion du mixed-in type. La documentation précise :
<span class="css-span">ReprEnum</span> uses the <span class="css-span">repr()</span> of <span class="css-span">Enum</span>, but the <span class="css-span">str()</span> of the mixed-in data type.
(...)
Inherit from <span class="css-span">ReprEnum</span> to keep the <span class="css-span">str()</span> / <span class="css-span">format()</span> of the mixed-in data type instead of using the <span class="css-span">Enum</span>-default <span class="css-span">str()</span>.
L'affichage des <span class="css-span">IntEnum</span>s change à cause de <span class="css-span">ReprEnum</span>
What’s New In Python 3.11 nous dit :
Changed <span class="css-span">IntEnum</span> (...) to now inherit from ReprEnum, so their <span class="css-span">str()</span> output now matches <span class="css-span">format()</span> (both <span class="css-span">str(AnIntEnum.ONE)</span> and <span class="css-span">format(AnIntEnum.ONE)</span> return <span class="css-span">'1'</span>, whereas before <span class="css-span">str(AnIntEnum.ONE)</span> returned <span class="css-span">'AnIntEnum.ONE'</span>.
Regardons ce que ça donne avec notre énumération <span class="css-span">Stages(IntEnum)</span> :
<pre><code>print('member\t', Stages.DOWNTOWN)
print('name\t', Stages.DOWNTOWN.name)
print('value\t', Stages.DOWNTOWN.value)
print('str()\t', str(Stages.DOWNTOWN))
print('repr()\t', repr(Stages.DOWNTOWN))
print('f-str\t', f'{Stages.DOWNTOWN}')</code></pre>
En 3.10 :
<pre><code>member Stages.DOWNTOWN
name DOWNTOWN
value 1
str() Stages.DOWNTOWN
repr() <Stages.DOWNTOWN: 1>
f-str 1</code></pre>
Affichage modifié en 3.11 :
<pre><code>member 1
name DOWNTOWN
value 1
str() 1
repr() <Stages.DOWNTOWN: 1>
f-str 1</code><pre>
Personnellement, je trouve ça plus logique, mais ce changement peut avoir des conséquences sur un code existant !
<span class="css-span">StrEnum</span>, pour faire comme <span class="css-span">IntEnum</span> mais avec des strings
On a souvent besoin de faire une énumération qui ne contient que des strings, par exemple pour lister les personnages du jeu :
<pre><code>class Characters(StrEnum):
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 'Eddie "Skate" Hunter'</code></pre>
<span class="css-span">StrEnum</span> hérite de <span class="css-span">ReprEnum</span>, ce qui implique que <span class="css-span">print(str(Characters.BLAZE))</span> et <span class="css-span">print(f'{Characters.BLAZE}')</span> affichent <span class="css-span">Blaze Fielding</span>. Si on avait fait <span class="css-span">Characters(Enum)</span>, l'affichage aurait donné <span class="css-span">Characters.BLAZE</span>. Comme pour <span class="css-span">IntEnum</span>, je trouve cet affichage logique.
On peut utiliser <span class="css-span">auto()</span> avec <span class="css-span">StrEnum</span> :
<pre><code>class Characters(StrEnum):
# ...
SKATE = auto()</code></pre>
<span class="css-span">str(Characters.SKATE))</span> sera alors <span class="css-span">skate</span>.
Il était déjà possible de faire un équivalent de <span class="css-span">StrEnum</span> avant Python 3.11, avec une simple enum mais le typage était moins fort. On pouvait par exemple faire :
<pre><code>class Characters(str, Enum):
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 8</code></pre>
Et ça passait crème. En effet, il est possible de construire une string à partir de 8 avec <span class="css-span">str(8)</span>. Ce n'est pas dit dans la doc, mais on peut regarder l'implémentation de <span class="css-span">StrEnum</span> dans <span class="css-span">enum.py</span> et on voit que le constructeur est redéfini et vérifie explicitement le typage avec des <span class="css-span">isinstance(..., str)</span>. Ce n'est pas le cas de <span class="css-span">IntEnum</span>.
Plus de vérifications avec le décorateur <span class="css-span">@verify</span>
<span class="css-span">@unique</span> est présent depuis le début du module <span class="css-span">enum</span> et permet de s'assurer que chaque membre a une valeur... unique ! 😂
C'est très bien pour définir les niveaux du jeu et s'assurer qu'ils ont tous un numéro différent. Exemple :
<pre><code>@unique
class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 3</code></pre>
Ce code génère une exception : <span class="css-span">ValueError: duplicate values found in <enum 'Stages'>: STADIUM -> AMUSEMENT_PARK</span>.
Un nouveau décorateur, <span class="css-span">@verify</span>, est apparu en 3.11 :
A <span class="css-span">class</span> decorator specifically for enumerations. Members from <span class="css-span">EnumCheck</span> are used to specify which constraints should be checked on the decorated enumeration.
Il prend donc en paramètres des <span class="css-span">EnumCheck</span>s :
EnumCheck contains the options used by the <span class="css-span">verify()</span> decorator to ensure various constraints; failed constraints result in a <span class="css-span">ValueError</span>.
Seuls <span class="css-span">UNIQUE</span>, <span class="css-span">CONTINUOUS</span> et <span class="css-span">NAMED_FLAGS</span> sont disponibles pour le moment. <span class="css-span">@verify(UNIQUE)</span> est équivalent à <span class="css-span">@unique</span>.
On peut passer plusieurs flags en paramètres, ce qui est parfait pour notre exemple :
<pre><code>@verify(UNIQUE, CONTINUOUS)
class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 5</code></pre>
Une exception nous prévient qu'il manque une valeur : <span class="css-span">ValueError: invalid enum 'Stages': missing values 4</span>.
Rendre les membres accessibles dans le namespace global
Pour accéder à un membre, il faut normalement y accéder via la classe : <span class="css-span">Stages.STADIUM</span>.
Dans certains cas (et avec les éventuels risques de name clashes qui vont avec), vous pourriez souhaitez utiliser directement <span class="css-span">STADIUM</span>. C'est possible à partir de Python 3.11, en annotant votre classe avec <span class="css-span">@global_enum</span>.
Contrôler ce qui est membre et ce qui n'est pas membre
Deux nouveaux décorateurs permettent de contrôler explicitement ce qui est membre de l'énumération et ce qui ne l'est pas :
<span class="css-span">@enum.member</span>
A decorator for use in enums: its target will become a member.
<span class="css-span">@enum.nonmember</span>
A decorator for use in enums: its target will not become a member.
Quand on parle de membres d'une énumération, on parle de ses différentes valeurs possibles.
Ce décorateur <span class="css-span">@member</span> est très pratique pour définir une énumération dont les valeurs sont des fonctions.
Pour Streets of Rage 2, il nous faut par exemple une énumération des 3 actions de base que peut faire un personnage. Une fonction est un bon type pour représenter une action. Par défaut, une fonction définie dans une classe dérivant de <span class="css-span">Enum</span> est une static method. Ainsi, le code suivant ne fait pas ce qu'on souhaiterait, car il crée une énumération sans valeur :
<pre><code>class Controls(Enum):
def special_move():
print('Special move, massive damage!')
def attack():
print('Attack? OK! Punch!')
def jump():
print('The floor is lava! Jump!')
print(list(Controls))
Controls.attack()</code></pre>
Ce code affiche :
<pre><code>[]
Attack? OK! Punch!</code></pre>
Pour corriger ça, il suffit d'annoter les fonctions :
<pre><code>class Controls(Enum):
@member
def special_move():
print('Special move, massive damage!')
@member
def attack():
print('Attack? OK! Punch!')
@member
def jump():
print('The floor is lava! Jump!')
print(list(Controls))
Controls.attack.value()</code></pre>
On obtient cette fois :
<pre><code>[<Controls.special_move: <function Controls.special_move at 0x0000015B0080AD40>>,
<Controls.attack: <function Controls.attack at 0x0000015B00778680>>,
<Controls.jump: <function Controls.jump at 0x0000015B00822200>>]
Attack? OK! Punch!</code></pre>
Parfait ! Notez bien que<span class="css-span"> Controls.attack</span> n'est pas callable (car c'est le membre de l'énumération) et qu'il faut utiliser <span class="css-span">.value</span> pour accéder réellement à la fonction.
À l'inverse, si vous voulez qu'une donnée soit statique à la classe, il faut utiliser <span class="css-span">@nonmember()</span>. La syntaxe est un peu surprenante (je trouve) et la documentation officielle n'en donne aucun exemple. En voici donc un petit pour la route :
<pre><code>class Characters(StrEnum):
playable = nonmember(True)
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 'Eddie "Skate" Hunter'
print(Characters.playable)</code></pre>
Comme toujours en Python, un champ de la classe est accessible via ses membres, donc on peut utiliser <span class="css-span">Characters.SKATE.playable</span>.
Conclusion
Il y a beaucoup de nouveautés intéressantes dans cette version de Python 3.11 ! Quand ton langage principal est C++, où les enumérations sont vraiment très basiques, tu es comme un enfant dans un magasin de bonbons ! Je regrette quand même une documentation pas ouf (certaines features sont très mal, voire pas documentées) et des trucs trop bizarres (comme show_flag_values() qui n'est pas ajouté à <span class="css-span">__all__</span> et dont l'utilisabilité est vraiment mauvaise). Gageons que ça s'améliorera dans les prochaines versions et profitons dès maintenant de cette puissance supplémentaire dans le package <span class="css-span">enum</span> !
Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, à la fin de la ligne courante ou au début de la ligne suivante ?"
Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)
Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau.