Reconnaissance de schémas structurels avec Python 3.10

Kaizen Solutions / Pôle Python

Joël Bourgault

Ça vient de sortir

De quoi parle-t-on ?

  • Structural Pattern Matching = syntaxe qui combine, de manière élégante :
    • la reconnaissance de structures : test d’instance
    • l’extraction de valeurs : assignation de variable
    • éventuellement des tests
  • tutoriel officiel : Python Enhancement Proposal 636

Ça marche depuis quand ?

Est-ce un bête switch...case ?

  • non, car switch ne fait qu’optimiser un branchement
  • rejeté par le passé, car apportant peu au langage (PEP 275, PEP 3103)
  • le Structural Pattern Matching est bien plus puissant !

Et si je veux m’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 ?

À comparer avec 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 ?")
...

On n’a pas gagné grand chose

ok, on n’a écrit value qu’une seule fois, mais :

  • 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 clairement le cas par défaut.

Déstructurons en vrai

En combinant

  • expression de la structure attendue
  • assignation de variable
>>> 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)

Python < 3.10 savait pas faire aussi clair

>>> 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 Python ≥ 3.10, c’est quand même plus sympa

  • on décrit une structure, au lieu de spécifier chaque contrainte
  • ça simplifie la réflexion sur les conditions à respecter (vous auriez pensé à vérifier la longueur de action dans le premier cas ?)
  • ça donne des noms de variables aux valeurs extraites
    • pour signifier que action[1:] est en fait name
    • dans le else, pour renommer sémantiquement action

Déstructurons plus avant

Imaginons quelques classes

>>> from dataclasses import dataclass
>>> @dataclass
... class A:
...     x : int
...     y : int
...
>>> @dataclass
... class B:
...     """Chu !"""
...     z : str
...

On assigne directement des attributs

>>> 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

Et même 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)

À une profondeur arbitraire / avec des contraintes

>>> @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

Et en filtrant avec des gardes

>>> def renderBig(*args) -> None:
...     match args:
...         case [x, y] if x > 10:
...             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”

Nouvelle syntaxe ⇒ nouveaux bugs

Gare aux comportements non intuitifs :

  • pour un case face à une variable, il faut l’identifier “avec point”
  • gare à l’abus sur l’ordre des attributs
  • la déstructuration matche des itérables, et ne distingue pas les tuples des listes
  • _ n’est pas assigné dans match...case _

Comment ça “avec point” ?

Il faut que la variable soit qualifiée”, sinon ça assigne :

>>> crit = 3
>>> match 1:
...     case crit:  # matche, donc réassigne `crit` !
...         print(f"j'ai matché ! <3")
...
j'ai matché ! <3
>>> crit  # argh
1

On n’aura pas de soucis avec des énumérés, des attributs ou des variables importées.

Comment ça “l’ordre des attributs” ?

Il faut nommer les attributs :

  • case A(y): assigne le premier attribut de A à y (donc x !)
  • case A(y=y): assigne l’attribut y de A à y

Recommandation : toujours nommer explicitement l’attribut à extraire

Comment ça, pas de distinction entre listes et tuples ?

Si on tente simplement ceci :

>>> 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

Eh bien ça ne fait pas la distinction…

Matcher des listes et tuples

>>> 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

Et les générateurs ?

Rien dans la doc, 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]

Matcher des générateurs

Il faut être explicite :

>>> 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
[]

Portée des variables

Comme pour for, les variables assignées débordent du bloc, et les autres ne sont pas initialisées !

>>> match "robert":
...     case x if len(x) > 10:
...         print("long nom")
...     case [0, y]:
...         print("point à x nul")
...     case _:
...         print("rien de particulier")
...
rien de particulier
>>> x  # assigné, car a matché, même si le garde n'a pas laissé passer !
'robert'
>>> y  # pas assigné, car n'a pas matché
Traceback (most recent call last):
...
NameError: name 'y' is not defined

Comment ça, _ pas assigné ?

Eh non, _ utilisé comme wildcard et pas comme vraie variable :

>>> _ = 'underscore'  # on l'assigne
>>> match [0, 1]:
...     case _:
...         print(_)
...
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'

Annexes

Une idée nouvelle ?

Eh non, présent depuis toujours dans d’autres langages

Total ou pas total ?

  • totalité : une fonction retourne toujours quelque chose
    • notion fondamentale en programmation fonctionnelle (à la Scala, Haskell)
  • pas une notion centrale de Python
    • style plus procédural
    • utilisation courante des exceptions pour gérer le flux

Totalité, suite

  • cas par défaut évident à identifier sur if...else
  • moins direct avec match...case (il faut un case _ pour être sûr qu’il existe)
  • Python ne vérifie pas que tous les cas sont couverts ; il faudra utiliser un outil d’analyse statique (genre mypy)

À propos de cette présentation

  • écrite en Markdown
  • gérée par un pipeline Gitlab :
    • vérification des exemples avec doctest: python -m doctest <fichier>.md
    • conversion avec pandoc vers RevealJS
    • publication sur Gitlab Pages