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 enum
Le module enum
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 :
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
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 : verify()
. 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 :
class Stages(Enum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 4
# ... et plusieurs autres encore !
Elles sont très permissives et on peut par exemple faire quelque chose comme :
class Stages(Enum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 'three'
STADIUM = [4]
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 IntEnum
a été introduite en Python 3.6 :
class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 'four'
On obtient une exception à l'exécution : ValueError: invalid literal for int() with base 10: 'four'
.
Notez que si on a STADIUM = '4'
(notez les simple quotes autour du 4), le code fonctionne. En effet, comme l'indique l'exception, IntEnum
utilise int()
pour obtenir la valeur et il s'avère que int('4') == 4
. On peut ainsi utiliser comme initializer une instance d'une classe qui fournit une méthode def __int__(self) -> int
.
IntEnum
est en fait une "mixed enum". Le principe des mixed enums est de faire un héritage (multiple) d'un type souhaité T
et d'enum
. 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 T
.
Après ces rappels, on va s'attarder dans la suite aux changements apportés par la version 3.11.
ReprEnum
Si on hérite ReprEnum
plutôt que Enum
, 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 :
ReprEnum
uses therepr()
ofEnum
, but thestr()
of the mixed-in data type.(...)
Inherit from
ReprEnum
to keep thestr()
/format()
of the mixed-in data type instead of using theEnum
-defaultstr()
.
L'affichage des IntEnum
s change à cause de ReprEnum
What’s New In Python 3.11 nous dit :
Changed
IntEnum
(...) to now inherit from ReprEnum, so theirstr()
output now matchesformat()
(bothstr(AnIntEnum.ONE)
andformat(AnIntEnum.ONE)
return'1'
, whereas beforestr(AnIntEnum.ONE)
returned'AnIntEnum.ONE'
.
Regardons ce que ça donne avec notre énumération Stages(IntEnum)
:
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}')
En 3.10 :
member Stages.DOWNTOWN
name DOWNTOWN
value 1
str() Stages.DOWNTOWN
repr() <Stages.DOWNTOWN: 1>
f-str 1
Affichage modifié en 3.11 :
member 1
name DOWNTOWN
value 1
str() 1
repr() <Stages.DOWNTOWN: 1>
f-str 1
Personnellement, je trouve ça plus logique, mais ce changement peut avoir des conséquences sur un code existant !
StrEnum
, pour faire comme IntEnum
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 :
class Characters(StrEnum):
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 'Eddie "Skate" Hunter'
StrEnum
hérite de ReprEnum
, ce qui implique que print(str(Characters.BLAZE))
et print(f'{Characters.BLAZE}')
affichent Blaze Fielding
. Si on avait fait Characters(Enum)
, l'affichage aurait donné Characters.BLAZE
. Comme pour IntEnum
, je trouve cet affichage logique.
On peut utiliser auto()
avec StrEnum
:
class Characters(StrEnum):
# ...
SKATE = auto()
str(Characters.SKATE))
sera alors skate
.
Il était déjà possible de faire un équivalent de StrEnum
avant Python 3.11, avec une simple enum mais le typage était moins fort. On pouvait par exemple faire :
class Characters(str, Enum):
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 8
Et ça passait crème. En effet, il est possible de construire une string à partir de 8 avec str(8)
. Ce n'est pas dit dans la doc, mais on peut regarder l'implémentation de StrEnum
dans enum.py
et on voit que le constructeur est redéfini et vérifie explicitement le typage avec des isinstance(..., str)
. Ce n'est pas le cas de IntEnum
.
Plus de vérifications avec le décorateur @verify
@unique
est présent depuis le début du module enum
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 :
@unique
class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 3
Ce code génère une exception : ValueError: duplicate values found in <enum 'Stages'>: STADIUM -> AMUSEMENT_PARK
.
Un nouveau décorateur, @verify
, est apparu en 3.11 :
A
class
decorator specifically for enumerations. Members fromEnumCheck
are used to specify which constraints should be checked on the decorated enumeration.
Il prend donc en paramètres des EnumCheck
s :
EnumCheck contains the options used by the
verify()
decorator to ensure various constraints; failed constraints result in aValueError
.
Seuls UNIQUE
, CONTINUOUS
et NAMED_FLAGS
sont disponibles pour le moment. @verify(UNIQUE)
est équivalent à @unique
.
On peut passer plusieurs flags en paramètres, ce qui est parfait pour notre exemple :
@verify(UNIQUE, CONTINUOUS)
class Stages(IntEnum):
DOWNTOWN = 1
BRIDGE_CONSTRUCTION = 2
AMUSEMENT_PARK = 3
STADIUM = 5
Une exception nous prévient qu'il manque une valeur : ValueError: invalid enum 'Stages': missing values 4
.
Rendre les membres accessibles dans le namespace global
Pour accéder à un membre, il faut normalement y accéder via la classe : Stages.STADIUM
.
Dans certains cas (et avec les éventuels risques de name clashes qui vont avec), vous pourriez souhaitez utiliser directement STADIUM
. C'est possible à partir de Python 3.11, en annotant votre classe avec @global_enum
.
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 :
@enum.member
A decorator for use in enums: its target will become a member.
@enum.nonmember
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 @member
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 Enum
est une static method. Ainsi, le code suivant ne fait pas ce qu'on souhaiterait, car il crée une énumération sans valeur :
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()
Ce code affiche :
[]
Attack? OK! Punch!
Pour corriger ça, il suffit d'annoter les fonctions :
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()
On obtient cette fois :
[<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!
Parfait ! Notez bien que Controls.attack
n'est pas callable (car c'est le membre de l'énumération) et qu'il faut utiliser .value
pour accéder réellement à la fonction.
À l'inverse, si vous voulez qu'une donnée soit statique à la classe, il faut utiliser @nonmember()
. 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 :
class Characters(StrEnum):
playable = nonmember(True)
AXEL = 'Axel Stone'
BLAZE = 'Blaze Fielding'
MAX = 'Max Thunder'
SKATE = 'Eddie "Skate" Hunter'
print(Characters.playable)
Comme toujours en Python, un champ de la classe est accessible via ses membres, donc on peut utiliser Characters.SKATE.playable
.
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é à __all__
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 enum
!