Note : cet article est également disponible sous forme de présentation, et est visible en ligne sur la Forge Kaizen : « Reconnaissance de schémas structurels avec Python 3.10 «
Introduction
Le « Structural Pattern Matching » est une syntaxe qui combine, de manière élégante :
- La reconnaissance de structures, pour tester des instances ;
- l’extraction de valeurs, via l’assignation de variables ;
- éventuellement des tests additionnels.
Le tutoriel officiel fait l’objet d’un PEP dédié, le Python Enhancement Proposal 636.
Ça vient de sortir dans Python 3.10, sorti le 4 octobre 2021.

Ça me fait penser au switch...case
, est-ce de ça que l’on parle ?
Non, car le switch
ne fait qu’optimiser un branchement ; là on parle de quelques chose de bien plus puissant.
Le switch
avait été demandé dans Python, mais rejeté car apportant peu au langage ; voir les PEP 275 et PEP 3103.
Peut-on quand même s’en servir comme switch... case
?
Ce serait inintéressant, mais essayons :
>>> def select(value) -> None:
... match value:
... case 2: # valeur statique
... print("le premiers premier")
... case 3 | 5 | 7: # qui peuvent être combinées
... print("un des deuxièmes premiers")
... case _: # cas par défaut
... print("peut-être dans les troisièmes premiers ?")
...
>>> select(3)
un des deuxièmes premiers
>>> select(11)
peut-être dans les troisièmes premiers ?
A comparer avec le classique if...elif
:
>>> def select(value) -> None:
... if value == 2:
... print("le premiers premier")
... elif value in [3, 5, 7]: # ou une variable, un itérateur...
... print("un des deuxièmes premiers")
... else:
... print("peut-être dans les troisièmes premiers ?")
...
Le seul gain est de n’avoir value
qu’une seule fois, avec plusieurs défauts :
- les valeurs définies dans les
case
sont nécessairement statiques ; in
explicite l’appartenance, et on peut fournir un conteneur dynamique ;- les
if...elif
prennent un seul niveau d’indentation ; else
exprime plus clairement le cas par défaut.
Donc, vraiment, le Structural Pattern Matching n’est pas prévu pour ça !
L’usage véritable, la base
Avec le Structural Pattern Matching, on combine facilement :
- l’expression d’une structure attendue, en l’exprimant de manière naturelle ;
- l’assignation de variable, en donnant simplement un nom dans l’attendu.
Voici l’exemple d’un argument, action
, sous forme de liste, qui est déstructuré en fonction de son contenu, avec assignation de name
et other_action
au passage :
>>> from typing import Union
>>> def react(action: Union[str, list[str]]) -> None:
... match action:
... case ["logged in", *name]: # 'name' est assigné
... print(f"Bonjour {' '.join(name)}")
... case ["logged out"]:
... print("Au revoir.")
... case other_action: # cas qui matche toujours, et assigne 'other_action'
... print(f"On se connaît ? ({other_action})")
...
>>> react(['logged in', "Sarah", "Connor"])
Bonjour Sarah Connor
>>> react(['logged out'])
Au revoir.
>>> react(42)
On se connaît ? (42)
L’implémentation avec if...elif
serait bien moins claire :
>>> def react(action: Union[str, list[str]]) -> None:
... if action[0] == "logged in" and len(action) >= 2:
... print(f"Bonjour {' '.join(action[1:])}")
... elif action == ["logged out"]:
... print("Au revoir.")
... else:
... print(f"On se connaît ? ({action})")
...
Avec le Structural Pattern Matching, les gains sont immédiats :
- sur l’expression de la contrainte :
- l’attendu est directement une structure,
- les conditions à respecter s’expriment simplement (vous auriez pensé à vérifier la longueur de
action
dans le premier cas ?) ;
- sur la sémantique, ça permet une assignation avec des noms spécifiques à chaque cas :
- pour signifier que
action[1:]
est en faitname
, - dans le cas par défaut,
action
est renommé enother_action
, car notre fontion ne sait pas quoi en faire.
- pour signifier que
L’usage plus complet
Après avoir vu la base, voyons l’intégralité des fonctionnalités offertes.
Déstructuration d’instances
Avec le structural Pattern Matching, on peut directement :
- spécifier un attendu sous forme d’instances de classes ;
- demander l’assignation de leurs attributs vers des variables locales.
Imaginons donc quelques classes :
>>> from dataclasses import dataclass
>>> @dataclass
... class A:
... x : int
... y : int
...
>>> @dataclass
... class B:
... """Chu !"""
... z : str
...
Et écrivons une fonction qui fait la distinction, et qui assigne directement les attributs pertinents :
>>> def render(obj) -> None:
... match obj:
... case A(x=x, y=y): # matche sur une instance de 'A', et assigne 'x' et 'y'
... print(f"type A: {x}, {y}")
... case B(z=z) as b: # assigne 'z' et 'b'
... print(f"type B: {z} ({b.__doc__})")
... case _: # matche toujours, sans rien assigner, même pas '_'
... print("pas de type A")
...
>>> render(A(x=1, y=2))
type A: 1, 2
>>> render(B('Pika Pika'))
type B: Pika Pika (Chu !)
>>> render([1, 2])
pas de type A
Cela fonctionne également sur les types de base :
>>> def filtrage(obj) -> None:
... match obj:
... case float(obj) | int(obj): # sur les built-ins
... print("un nombre")
... case {"source": url, "title": title}: # sur les dictionnaires
... print(f"un lien vers {title} ({url})")
...
>>> filtrage(dict(source='https://doc.python.org', title="la bible !"))
un lien vers la bible ! (https://doc.python.org)
Petite subtilité, les dictionnaires ont un traitement particulier :
- pour « matcher » sur un dictionnaire, les clés doivent être des valeurs littérales ;
- les autres clés du dictionnaire sont ignorées (leur présence n’empêche pas le match).
Critère sur les attributs… donc aussi sur des compositions d’objets ?
Eh oui, la structure de l’attendu est récursive !
D’autre part, il est possible d’ajouter des contraintes sur des attributs.
On peut donc écrire le code suivant :
>>> @dataclass
... class C:
... a : A # object imbriqué
...
>>> def render(obj) -> None:
... match obj:
... case C(A(y=0)) as c: # contrainte sur 'y', assigne 'c'
... print(f"A imbriqué à y nul : {c}")
... case C(A(y=y) as a): # assigne 'y' et 'a'
... print(f"A imbriqué à y non nul, {a.x}/{y}")
...
>>> render(C(A(5, 0)))
A imbriqué à y nul : C(a=A(x=5, y=0))
>>> render(C(A(5, 7)))
A imbriqué à y non nul, 5/7
Combiner une assignation et une contrainte
La syntaxe précédente permet de spécifier une contrainte, mais pas d’assigner la valeur ; pour avoir les deux, il faut filtrer avec des gardes :
>>> def renderBig(*args) -> None:
... match args:
... case [x, y] if x > 10: # premier usage de garde
... print("grand x")
... case [0, y]: # équivalent à 'case [x, y] if x == 0:'
... print("x nul")
... case [x, 0]: # équivalent à 'case [x, y] if y == 0:'
... print("y nul")
...
>>> renderBig(12, 2)
grand x
>>> renderBig(2, 0)
y nul
« Un grand pouvoir implique de grandes responsabilités »
Bon, tout ça c’est super, mais hélas : qui dit nouvelle syntaxe, dit nouveaux pièges pour les développeurs, car derrière tout ce bonheur se cachent quelques comportements peu intuitifs :
- pour un
case
face à une variable, il faut l’identifier « avec point » ; - l’assignation se fait dans l’ordre des attributs ;
- les variables assignées débordent ;
- la déstructuration basique matche des itérables, sans distinguer les tuples des listes ;
_
n’est jamais assigné parmatch...case
.
Comment ça, « il faut identifier avec point » ?
Pour utiliser un nom de variable comme une contrainte, il faut que la variable soit qualifiée, sinon Python assigne la valeur à cette variable :
>>> crit = 3
>>> match 1:
... case crit: # matche, donc réassigne 'crit' !
... print(f"j'ai matché ! <3")
...
j'ai matché ! <3
>>> crit # argh
1
Il faudra donc utiliser des énumérés, des attributs de classe, ou des variables importées d’autres modules.
Comment ça, « assignation dans l’ordre des attributs » ?
Autre piège, les attributs sont assignés dans l’ordre :
case A(y):
assigne le premier attribut deA
ày
(doncx
!)case A(y=y):
assigne l’attributy
deA
ày
Recommandation personnelle : toujours nommer explicitement l’attribut à extraire.
En fait, c’est comme l’appel aux fonctions : écrire systématiquement le nom des arguments utilisés est certes plus long, mais permet d’être robuste aux changements de signature.
Comment ça, « les variables assignées débordent » ?
Comportement très Pythonesque : comme dans un for
, les variables assignées débordent du bloc, et ce même si un garde rejète le match :
>>> match "robert":
... case [0, y]: # ça matche pas à cause de la structure attendue
... print("point à x nul")
... case x if len(x) > 10: # ça matche pas à cause du garde
... print("long nom")
... case _:
... print("rien de particulier")
...
rien de particulier
>>> y # pas assigné, car n'a pas matché : ça OK
Traceback (most recent call last):
...
NameError: name 'y' is not defined
>>> x # assigné, car a matché, même si le garde n'a pas laissé passer !
'robert'
Attention donc quand des gardes sont utilisés.
Comment ça, « pas de distinction entre listes et tuples » ?
Eh oui, l’expression de la structure attendue est la même que pour des déstructurations d’itérables :
>>> t = (1, 2, 3) # un tuple
>>> match t:
... case [1, 2, 3]: # exprimé comme liste
... print("liste qui commence à 1")
... case (1, 2, 3): # exprimé comme tuple
... print("tuple qui commence à 1")
...
liste qui commence à 1
La syntaxe case []
ou case ()
ne fait que dire à Python que l’on attend un itérable, sans contrainte sur le type exact. Si on veut les distinguer, il faut donc spécifier explicitement le type attendu :
>>> t = (1, 2, 3) # un tuple
>>> match t:
... case list([1, 2, 3]):
... print("liste qui commence à 1")
... case tuple([1, 2, 3]):
... print("tuple qui commence à 1")
...
tuple qui commence à 1
Comment ça, « _
n’est pas assigné » ?
Eh non, _
est utilisé comme wildcard, et n’est (dans ce cadre uniquement !) plus une vraie variable :
>>> _ = 'underscore' # on l'assigne
>>> match [0, 1]:
... case _: # ça matche toujours, sans assigner
... print(_) # et on print-e bien notre variable initiale
...
underscore
Et là non plus, même si c’est plus attendu :
>>> _ = 'underscore'
>>> match [0, 1]:
... case [_, x]:
... print(f"x: {x}")
...
x: 1
>>> _ # pas réassigné, ok
'underscore'
Note : dans l’interpréteur, _
est alloué à la dernière valeur affichée, sauf si une variable _
a été assignée explicitement.
Tiens, à propos d’itérables, ça marche avec les générateurs ?
Le tutoriel ne précise pas ce cas, et un usage naïf ne matche pas :
>>> g = (i**2 for i in range(3)) # un générateur
>>> match g:
... case [0, 1, 4]:
... print("trouvé !")
...
>>> # pas de print ? ah ben ça a pas matché...
>>> list(g) # qu'y reste-t-il ? Eh bien tout !
[0, 1, 4]
Il faut donc explicitement convertir le générateur en itérable :
>>> g = (i**2 for i in range(3))
>>> match list(g): # on consomme le générateur
... case [0, 1, 4]:
... print("trouvé !")
...
trouvé !
>>> list(g) # qu'y reste-t-il ? Ce coup-ci, rien
[]
Annexes
Est-ce une idée nouvelle ?
Eh non, le Pattern Maching est présent depuis toujours dans d’autres langages :
Plus récemment :
- C# l’a intégré dans C# 7.0, en 2017 ;
- le support par Java est encore en cours d’implémentation, voir JEP-394 et JEP 406.
Total ou pas total ?
À l’opposé des langages cités précédemment, le Structural Pattern Matching de Python n’exige pas d’embranchement ‘total’ (une fonction totale retourne toujours quelque chose), et il n’y a donc pas d’obligation à couvrir l’ensemble des possibilités.
Finalement, ce n’est pas une surprise, Python ayant un style plus procédural, et s’appuyant plutôt sur les exceptions pour gérer les flux d’erreur (comme pour les if
quoi).
Il faudra donc utiliser des outils d’analyse statique, genre mypy quand le pattern matching sera supporté.
À propos de cette présentation
Cet article a été rédigé en Markdown, et est géré par un pipeline Gitlab, qui apporte de belles fontionnalités :