Image de l'article

Les nouveautés de Python 3.10

On passe en revue toutes les nouveautés apportées par la version 3.10 de Python.

Publié le 17 octobre 2021 par Cam

Lundi 4 octobre 2021 est sorti la toute nouvelle version de Python, la version 3.10. Un an s’est écoulé depuis la sortie de la 3.9, voyons ensemble quelles évolutions majeures nous réserve cette nouvelle version !

Mais avant de rentrer dans le vif du sujet, laissez-moi introduire la notion de PEP ou Python Extension Proposal propre à la communauté Python.

Les PEP, ou Python Extension Proposale, ou encore proposition d'amélioration de Python, correspondent à des propositions d'amélioration publiques et publiées sur le site de Python. Chaque proposition est identifiée par un numéro et une fois validée, elle est intégrée aux nouvelles versions de Python.

Deux des plus connues sont sans doute la PEP8 permettant de définir des règles de développement communes entre développeurs ou encore la PEP20 aussi appelée The Zen of Python. Un ensemble de 20 guidelines pour nous aider à mieux cerner l'état d'esprit de Python et de coder de la façon la plus pythonique qu'il soit !

Le filtrage par motif

Rien de moins que 3 PEP pour cette grande nouveauté qui a longtemps fait débat parmi les développeurs Python !

PEP 634 - Structural Pattern Matching: Specification
PEP 635 - Structural Pattern Matching: Motivation and Rationale
PEP 636 - Structural Pattern Matching: Tutorial

Le filtrage par motif permet de faire correspondre différents motifs à différentes actions, comme on pourrait le faire avec une série de if...elif...else.

On utilise pour ce faire deux nouveaux mots clés : match et case qui existent dans de nombreux autres langages de programmation :

match subject:
    case <pattern_1>:
        <action_1>
    case <pattern_2>:
        <action_2>
    case <pattern_3>:
        <action_3>
    case _:
        <action_wildcard>

subject représentant l'objet que l'on souhaite faire correspondre à un motif. On peut ensuite utiliser autant de case que nécessaire pour faire correspondre le sujet à un motif correspondant.

Pour comprendre le fonctionnement du filtrage par motif, rien de mieux que quelques exemples :

for number in [1, 2, 3, 4]:
    match number:
        case 1:
            print("La variable 'number' est égale à 1.")
        case 2:
            print("La variable 'number' est égale à 2.")
        case 3:
            print("La variable 'number' est égale à 3.")
        case autre:
            print(f"La variable 'number' contient le nombre {autre}.")

Le code ci-dessus est équivalent à :

for number in [1, 2, 3, 4]:
    if number == 1:
        print("La variable 'number' est égale à 1.")
    elif number == 2:
        print("La variable 'number' est égale à 2.")
    elif number == 3:
        print("La variable 'number' est égale à 3.")
    else:
        print(f"La variable 'number' contient le nombre {number}.")

On remarque que la syntaxe avec match et case est moins verbeuse et rend le code plus facile à lire.

Mais le filtrage par motif va plus loin qu'un simple remplacement des structures conditionnelles.

Il est possible de réaliser des opérations plus complexes en faisant correspondre réellement des "motifs" et non pas juste des valeurs exactes.

Pour illustrer mon propos j'ai choisi 3 exemples concrets dans lesquels le filtrage par motif serait préférable (selon moi) à une structure if...elif classique.

Exemple 1 : Trier des données de couleur

Imaginons que nous récupérons un jeu de données et que nous souhaitons départager les couleurs avec une couche alpha de celle qui n'en ont pas.

colors = [(128, 0, 255), (255, 255, 255), (255, 255, 0, 128)]

for color in colors:
    match color:
        case [r, g, b]:
            print("L'élément est une couleur.")
        case [r, g, b, a]:
            print(f"L'élément est une couleur avec couche alpha de {a}.")

Python va automatiquement faire correspondre les tuples de notre liste aux motifs et assigner chaque élément du tuple aux noms que nous avons définis à l'intérieur du motif (r, g et b pour le premier motif, r, g, b, a pour le second).

Si notre jeu de données contient un tuple qui ne correspond à aucun des motifs (donc un tuple qui contiendrait moins de 2 ou plus de 4 éléments), il serait tout simplement ignoré.

On peut également trier des données de différent type et ajouter une structure conditionnelle à l'intérieur des différents case pour plus de précision :

colors = [(128, 0, 255), (255, 255, 255), (255, 255, 0, 128), "#FF00FF"]

for color in colors:
    match color:
        case [r, g, b] if r == g == b:
            print("L'élément est une teinte de gris")
        case [r, g, b]:
            print("L'élément est une couleur.")
        case [r, g, b, a]:
            print("L'élément est une couleur avec couche alpha.")
        case hex if color.startswith("#") and len(color) == 7:
            print(f"L'élément est une couleur hexadécimale.")

Et pourquoi pas imbriquer un match dans un autre ? Pas sûr que cela reste très lisible, mais c'est possible :

for color in colors:
    match color:
        case [x, y]:
            print("L'élément est une coordonnée.")
        case [r, g, b]:
            match r, g, b:
                case (0, 0, 0):
                    print("L'élément est du noir pur.")
                case (255, 255, 255):
                    print("L'élément est du blanc pur.")
                case _:
                    print("L'élément est une couleur.")
        case [r, g, b, a]:
            print("L'élément est une couleur avec couche alpha.")
        case hex if color.startswith("#") and len(color) == 7:
            print(f"L'élément est une couleur hexadécimale.")

Exemple 2 : Trier des données

Un autre exemple dans lequel nous pourrions avoir recours au filtrage par motif pour trier des données provenants par exemple d'un fichier excel et pour lesquelles certaines informations pourraient être manquantes :

data = [
            (1938, "Patrick", "Durand", 29000),
            ("Patrick", "Durand", 29000),
            (9382, "Alice", "Smith", 39000),
            ("Jean", "Bon", None),
            ("Maurice", "Dubois"),
        ]

for d in data:
    match d:
        case employe_data if str(d[0]).isdigit():
            print(f"Employé valide: {employe_data}")
        case [first_name, last_name, salary] if str(d[-1]).isdigit():
            print(f"L'identifiant est manquant pour {first_name}.")
            print("Génération d'un identifiant aléatoire...")
        case invalid_data:
            print(f"Données non valides : {invalid_data}")

Dans ce cas de figure, on considère les données valides si la première cellule contient un identifiant.

Si la cellule ne contient que 3 éléments et que le dernier élément est un nombre, on génère un identifiant aléatoire pour l'employé.

Si le salaire est manquant, on décide que les données ne sont pas valides et nous les affichons.

Exemple 3 : Réagir au choix d'un utilisateur

Et pour finir, un exemple très courant dans lequel nous devons prendre des décisions en fonction de la saisie d'un utilisateur :

import sys

while True:
    action = input("Que voulez-vous faire ? ")
    match action.split():
        case ["Jouer"]:
            print("Ok, on commence le jeu")
        case ["Quitter"]:
            print("Ok bye !")
            sys.exit()
        case ["Avancer", ("Nord" | "Sud" | "Est" | "Ouest") as orientation]:
            print(f"Ok, vous avancez d'1 unité vers le {orientation}.")
        case ["Attaquer", ennemy]:
            print(f"Ok, on attaque {ennemy} !")
        case _:
            print("Veuillez entrer une commande valide...")

C'est probablement l'exemple le plus probant dans lequel le filtrage par motif rend le code plus concis et clair qu'avec une structure conditionnelle classique.

L'utilisation de l'opérateur « pipe » permet de cibler précisément les directions disponibles pour le joueur :

case ["Avancer", ("Nord" | "Sud" | "Est" | "Ouest") as orientation]:

Notez également l'utilisation du mot clé as pour récupérer dans une variable la saisie de l'utilisateur (ici dans orientation).

Le filtrage par motif est selon moi une des plus importants ajouts à Python depuis les f-string. Je pense qu'il nous faudra du temps pour changer nos habitudes et penser à utiliser match et case plutôt qu'une bonne vieille structure conditionnelle, mais les possibilités offertes par cette façon de faire vont se révéler utiles dans de nombreuses situations.

À noter que les mots clés match et case ne sont pas réservés par Python. On parle de « soft keywords », vous pouvez donc utiliser ces termes par exemple comme nom de variable et le code Python pré 3.10 restera donc compatible sans que cela ne cause d'erreur.

Union des annotations de type simplifiée

PEP 604 - Allow writing union types as X | Y

Les annotations de type 🤔 ?

Comme je l’évoquais dans cet extrait gratuit de ma formation, Python est un langage interprété et dynamique. À tout moment de la création de notre script, il nous est possible de changer le type d’une variable pour une raison ou pour une autre. Par exemple, nous pouvons redéfinir une variable à laquelle un entier a été assigné pour ensuite en faire une chaîne de caractères.

Python est aussi un langage fortement typé, c’est-à-dire que les opérations réalisées sur des variables de différents types feront planter notre code lors de son exécution. Additionner une variable de type entier avec une autre de type chaîne de caractères lèvera une exception.

TypeError: unsupported operand type(s) for +: 'int' and 'str'

Pour prévenir ce genre de désagrément et pour permettre une meilleure documentation de votre code base, Python a introduit dans sa version 3.5 grâce à la PEP 484, la possibilité de recourir de façon optionnelle et dans tout ou partie de votre code au type hinting ou annotations de type.

Cette option offerte aux développeurs permet de déclarer dans la définition d’une ou d'un ensemble de fonctions, le type des variables utilisées en paramètres et pour ses valeurs retour. Le module typing issu de la bibliothèque standard sera alors très utile.

Une écriture des unions plus facile

Dans le cadre de cette PEP 604 que l’on pourrait traduire par "Permettre d'écrire des types d'union sous la forme X | Y", Python nous offre la possibilité d’utiliser le « pipe » à la place de typing.Union afin de définir si la variable appartient à tel ou tel type.

# Au lieu de :
def foo(numbers: list[Union[int, str]], param: Optional[int]) -> Union[float, str]:
    pass

# On peut faire :
def foo(numbers: list[int | str], param: int | None) -> float | str:
    pass

On peut donc remplacer Union[int, str] par int | str.

Pour remplacer Optional, on utilise l'union avec l'objet None.

int | None est donc similaire à Optional[int].

Pour retrouver les améliorations de Python 3.9 et notamment l'utilisation des annotations de type pour list[] et dict[] n'hésitez pas à consulter ma vidéo sur le sujet.

À noter que cette syntaxe est également disponible avec les fonctions isinstance et issubclass.

Comme je l’illustre dans cette courte vidéo sur la vérification des types avec la fonction isinstance, son second paramètre permet de renseigner un ou plusieurs types (à l’aide d’un tuple) afin de voir si notre variable, le premier paramètre, appartient bien au(x) type(s) défini(s) par le second paramètre.

Ici, l’utilisation du tuple peut donc être substituée par l'opérateur « pipe » comme ceci :

# Au lieu de :
>>> isinstance(5, (int, str))
True
>>> issubclass(bool, (int, float))
True

# On peut maintenant faire :
>>> isinstance(5, int | str)
True
>>> issubclass(bool, int | float)
True
Alias des annotations de type

PEP 613 - Explicit Type Aliases

On reste dans les annotations de types avec cette nouveauté.

Si vous consultez régulièrement des ressources issues de la communauté Python, il n'est pas impossible que vous ayez déjà rencontré cet extrait du Zen of Python :

Explicit is better than implicit

C'est sans doute l'un des extraits les plus populaires de cette PEP20.

La PEP 613 permet désormais d'annoter un alias de type grâce à la classe TypeAlias, rendant ainsi notre code plus explicite qu'auparavant.

Imaginons une fonction qui permette d'additionner deux nombres :

def addition(a, b):
    return a + b

On peut rajouter des annotations de type pour spécifier que a et b peuvent être des nombres entiers ou décimaux :

def addition(a: int | float, b: int | float) -> int | float:
    return a + b

Vous remarquerez que j'utilise ci-dessus l'opérateur d'union, qui est une nouveauté de Python 3.10 que nous avons vu dans cet article ;)

Le code ci-dessus fonctionne mais n'est pas optimal. int | float n'est pas une annotation de type très explicite et nous la répétons 3 fois.

On peut donc créer un alias de type de cette façon pour éviter la répétition :

Nombre = int | float

def addition(a: Nombre, b: Nombre) -> Nombre:
    return a + b

C'est mieux pour éviter la répétition, et la création d'un alias nous permet d'avoir un code plus explicite.

Mais pour quelqu'un qui lit notre code et ne connait pas les alias de type, la ligne Nombre = int | float n'est pas très explicite.

C'est ce que vise à corriger cette PEP 613 en permettant d'annoter notre alias de type avec la classe TypeAlias :

from typing import TypeAlias

Nombre: TypeAlias = int | float

def addition(a: Nombre, b: Nombre) -> Nombre:
    return a + b

On comprend ainsi sans ambiguïté que Nombre est un alias de type et sert à annoter notre code.

Des messages d'erreurs plus précis

PEP 626 - Precise line numbers for debugging and other tools.

Comme je le mentionnais plus haut, Python est un langage interprété. Dans cette PEP 626 c'est donc son interpréteur qui a été amélioré. Ainsi, cette nouvelle version de Python sera l'occasion d'apporter plus de précision aux messages d'erreur qui nous sont retournés.

Vous avez dit des erreurs ? 🤔 😇

Par exemple l'oubli d'une parenthèse ou une accolade sera maintenant clairement notifié par l'interpréteur Python :

expected = {9: 1, 18: 2, 19: 2,
some_other_code = foo()

Avant Python 3.10, on aurait eu cette erreur :

File "example.py", line 3
    some_other_code = foo()
                    ^
SyntaxError: invalid syntax

Avec ce message d'erreur, impossible de deviner ce qui ne va pas sans retourner voir notre code et l'inspecter plus en détail.

Mais ça c'est du passé, avec Python 3.10 la ligne contenant le problème sera clairement spécifiée et un message plus explicite vous indiquera clairement ce qui ne va pas :

File "example.py", line 1
    expected = {9: 1, 18: 2, 19: 2,
               ^
SyntaxError: '{' was never closed

Le petit caractère (^) qui sert à indiquer l'origine du problème sera également plus explicite dans de nombreux cas. Par exemple avec une parenthèse manquante autour d'un générateur, avant Python 3.10, seul le début du générateur était indiqué :

>>> foo(x, z for z in range(10), t, w)
  File "<stdin>", line 1
    foo(x, z for z in range(10), t, w)
           ^
SyntaxError: Generator expression must be parenthesized

Avec Python 3.10, c'est l'intégralité du générateur qui est "surligné" par le symbole ^ :

>>> foo(x, z for z in range(10), t, w)
  File "<stdin>", line 1
    foo(x, z for z in range(10), t, w)
           ^^^^^^^^^^^^^^^^^^^^
SyntaxError: Generator expression must be parenthesized

De nombreux autres cas de figure sont désormais explicitement indiqué par la SyntaxError :

Oubli des deux points

>>> if rocket.position > event_horizon
  File "<stdin>", line 1
    if rocket.position > event_horizon
                                      ^
SyntaxError: expected ':'

Oubli des parenthèses

>>> {x,y for x,y in zip('abcd', '1234')}
  File "<stdin>", line 1
    {x,y for x,y in zip('abcd', '1234')}
     ^
SyntaxError: did you forget parentheses around the comprehension target?

Oubli d'une virgule entre les éléments d'un dictionnaire

>>> items = {
... x: 1,
... y: 2
... z: 3,
  File "<stdin>", line 3
    y: 2
       ^
SyntaxError: invalid syntax. Perhaps you forgot a comma?

Utilisation du symbole d'assignation pour vérifier l'égalité

>>> if rocket.position = event_horizon:
  File "<stdin>", line 1
    if rocket.position = event_horizon:
                       ^
SyntaxError: cannot assign to attribute here. Maybe you meant '==' instead of '='?

Autre situation, dans l'utilisation de chaînes de caractères. Parfois nous oublions les guillemets simples ou triples pour les fermer ce qui provoque une erreur de type EOF/EOL. Cette nouvelle version de l'interpréteur nous indiquera le début de la chaîne de caractères concernée. Encore un gain de temps supplémentaire ! 😊

Il y a d'autres erreurs moins importantes que j'ai omis mais que vous pouvez retrouver dans la documentation de Python

Vérification optionnelle de la taille avec zip

PEP 618 - Add Optional Length-Checking To zip.

Que l’on pourrait traduire par “Ajout de l’option vérification de la longueur pour la fonction zip”.

La fonction built-in zip permet de générer un itérateur à partir de types itérables comme les chaînes de caractères, les listes ou les tuples. Parfois les longueurs des itérables ne sont pas toutes égales et l’itérateur final est construit sur la base de l'itérable donné le plus court.

Grâce au paramètre optionnel strict qui accepte un booléen, (False par défaut) nous pourrons affiner le comportement voulu.

Dans le cas où ce paramètre sera passé à True, une exception de type ValueError sera levée, ce qui aura pour objectif de nous alerter et d'anticiper notre réponse si deux itérables d'une longueur différente sont donnés.

Si nous reprenons l’exemple de cette vidéo portant sur l’utilisation de la fonction zip cela donnerait :

# Comportement par défaut :
>>> dict(zip([54, 65, 76], ['Pierre', 'Paul', 'Patrick']))
{54: 'Pierre', 65: 'Paul', 76: 'Patrick'}
>>> dict(zip([54, 65], ['Pierre', 'Paul', 'Patrick']))
{54: 'Pierre', 65: 'Paul'}

# Avec le mode strict à True, une erreur est levée
>>> for people in zip([54, 65], ['Pierre', 'Paul', 'Patrick'], strict=True):
...     print(people)
ValueError: zip() argument 2 is longer than argument 1

L'erreur sera levée au moment de parcourir l'objet généré par la fonction zip. Vous pouvez donc créer un objet avec deux itérables de longueur différente et le mode strict sans que cela ne provoque d'erreur sur le moment.

>>> zip([54, 65], ['Pierre', 'Paul', 'Patrick'], strict=True)
<zip object at 0x109c63500>
Dépréciation du module distutils

PEP 632 - Deprecate distutils module

Chaque nouvelle version peut aussi comprendre la dépréciation de certains objets et/ou modules qui deviendront ensuite obsolètes. Ces décisions prises par les développeurs de Python font aussi l'objet d'une PEP et sont intégrées aux nouvelles versions.

Dans le cas de la version 3.10 nous pouvons noter par exemple la dépréciation de la bibliothèque distutils utilisée pour "fournir le support pour la création et l'installation de modules supplémentaires dans une installation Python".

Cette PEP nous encourage donc à ne plus utiliser ce module dans nos futurs développements et nous alerte sur son avenir notamment pour des questions de maintien du code existant. En effet, il pourra ensuite devenir définitivement obsolète à l'occasion de future mise à jour.

Vous noterez que dans cette PEP 632 la section "motivation" nous informe, comme son nom l'indique, sur les motivations de cette décision mais aussi nous propose une alternative avec le module setuptools.

Ils pensent à tout, ces développeurs de Python 😊

Quand peut-on l'utiliser ?

La version 3.10 de Python est disponible dès à présent sur le site officiel de Python.

Je ne vous conseille cependant pas d'utiliser trop rapidement les dernières versions de Python. Chaque version de Python est amplement testée avant d'être lancé. Mais il est impossible de prévoir tous les bugs et comme à chaque version majeure de Python, des versions mineures arriveront dans les prochains mois pour régler les bugs qui seront détectés dans les mois à venir.

De nombreuses bibliothèques ne sont également pas encore compatibles avec cette version de Python. Si vous changez de version de Python sur vos projets, il est donc probable que de nombreux packages ne pourront plus s'exécuter correctement.

J'ai bien conscience que les nouveautés de cette nouvelle version sont assez nombreuses et elle comporte encore plus de points que ceux évoqués. Dans cet article, j'ai repris ceux qui ont retenu mon attention et ceux qui me semblaient être importants dans votre utilisation de Python au quotidien.

Si vous souhaitez en savoir plus, je vous invite à vous rendre à ce lien ou celui-ci.

Vous pourrez alors découvrir et approfondir le contenu des PEP évoquées et bien plus encore.

Bonne installation et belle découverte de cette version 3.10 de Python 🐍