Structural Pattern Matching, super évolution de Python 3.10

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 fait name,
    • dans le cas par défaut, action est renommé en other_action, car notre fontion ne sait pas quoi en faire.

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 :

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é par match...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 de A à y (donc x !)
  • case A(y=y): assigne l’attribut y de A à 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 :

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 :

  • vérification des exemples avec doctest, via une simple commande python -m doctest <fichier>.md ;
  • conversion avec pandoc vers une présentation RevealJS ;
  • publication de la présentation sur Gitlab Pages.