Aller au contenu principal

Programmation structurée (Python) · L1 · Section 8/11

Typage (annotations)

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Typage statique (annotations)

Les annotations décrivent les types attendus: paramètres, retours, variables. Elles ne changent pas l'exécution; elles alimentent l'autocomplétion et les vérificateurs statiques (mypy, pyright) qui signalent les incohérences avant l'exécution.

Prérequis: fonctions (paramètres, valeurs de retour), structures de données.

Objectifs d'apprentissage:

  • Annoter des fonctions et des variables avec les types de base.
  • Décrire des collections avec list, dict et les unions A | B, Optional.
  • Écrire des classes de données avec @dataclass.
  • Vérifier un fichier annoté avec mypy.

#Annoter une fonction

pythonpython

1def salue(nom: str, fois: int = 1) -> str:2    return ' '.join([f'Bonjour {nom}'] * fois)3 4print(salue('Ada'))          # Bonjour Ada5print(salue('Ada', 2))       # Bonjour Ada Bonjour Ada

nom: str déclare le type du paramètre, -> str celui du retour. Python ignore ces indications à l'exécution (aucune conversion, aucune vérification), mais l'éditeur les exploite: il complète les méthodes de nom et signale salue(42) comme suspect.

#Lire et écrire une signature

Une signature décrit le domaine (les types acceptés en entrée) et le co-domaine (le type produit en sortie). C'est la première question des épreuves: avant d'écrire une fonction, il faut savoir ce qu'elle consomme et ce qu'elle produit. Une fonction récursive sur une structure a toujours la même forme de signature — la structure apparaît en entrée et en sortie.

Exercice de lecture. Pour chacune des fonctions suivantes, donner la signature et dire ce qu'elle calcule:

texttext

1function mistery1(n)2  if n mod 2 == 0 then return "Even"3  else return "Odd"4 5function mistery2(a, b, c)6  if (a >= b) and (a >= c) then return a7  if (b >= a) and (b >= c) then return b8  else return c9 10function use(n)11  return n * n12 13function mistery3(num1, num2)14  return use(num1) + use(num2)

Réponses.

FonctionSignatureCe qu'elle calcule
mistery1entier → chaînela parité sous forme textuelle
mistery2(entier, entier, entier) → entierle maximum des trois arguments
useentier → entierle carré
mistery3(entier, entier) → entierla somme des carrés des deux arguments
mistery4entier naturel → entier naturella somme des nombres pairs jusqu'à n
pythonpython

1def mistery1(n: int) -> str:2    return "Even" if n % 2 == 0 else "Odd"3 4def mistery2(a: int, b: int, c: int) -> int:5    return max(a, b, c)6 7def mistery3(num1: int, num2: int) -> int:8    return num1 * num1 + num2 * num29 10def mistery4(n: int) -> int:11    if n < 2:12        return 013    if n % 2 == 0:14        return n + mistery4(n - 2)

Deux points d'analyse.

mistery2 est le maximum. Les trois branches sont exhaustives. Le premier test reconnaît le cas où a est supérieur ou égal aux deux autres. S'il échoue, a n'est pas le maximum, et le second test désigne b; dans ce test, la condition b >= a est redondante — le premier test ayant échoué, on sait déjà que a n'est pas plus grand que b — mais elle ne nuit pas et rend le test lisible isolément. Le else final capture le cas restant, c.

mistery4 somme les pairs. Pour un n impair, la fonction délègue à n-1 sans rien accumuler; pour un n pair, elle ajoute n et poursuit sur n-2. La récursion s'arrête sur n < 2. Le coût est linéaire en n/2 appels, soit Θ(n).

#Variables et collections

pythonpython

1total: int = 02prix: dict[str, float] = {'pomme': 1.2}3notes: list[int] = [12, 9, 18]4noms: list[str] = ['Ada', 'Alan']5peut_echouer: str | None = None      # soit une chaîne, soit rien6 7print(sorted(prix.values()))    # [1.2]

Depuis Python 3.9, les types de collections s'écrivent directement (list[int], dict[str, float]); les formes historiques typing.List et typing.Dict restent valides pour le code plus ancien. str | None (union) remplace Optional[str]: la fonction peut retourner une chaîne ou None, et l'appelant doit envisager les deux cas.

#Classes de données: @dataclass

Une dataclass génère __init__, __repr__ et __eq__ à partir des champs annotés:

pythonpython

1from dataclasses import dataclass2 3@dataclass4class Utilisateur:5    identifiant: int6    nom: str7    courriel: str | None = None8 9ada = Utilisateur(1, 'Ada')10alan = Utilisateur(1, 'Ada')11print(ada)                    # Utilisateur(identifiant=1, nom='Ada', courriel=None)12print(ada == alan)            # True: égalité par contenu des champs13ada.courriel = 'ada@example.org'

Sans @dataclass, il faudrait écrire ces trois méthodes à la main, environ dix lignes de code mécanique. L'ordre des champs avec valeur par défaut doit rester en dernier.

#Vérifier avec mypy

bashbash

1pip install mypy2mypy mon_fichier.py
pythonpython

1# mon_fichier.py2def moyenne(valeurs: list[float]) -> float:3    return sum(valeurs) / len(valeurs)4 5moyenne([12, 'quinze'])    # mypy: Argument 1 has incompatible type

mypy parcourt le code sans l'exécuter et rapporte les incohérences: argument de mauvais type, retour manquant, None utilisé sans précaution. Typage graduel: annotez les nouvelles fonctions, laissez l'existant non annoté (traité comme Any), puis resserez progressivement.

#Ce que le typage apporte

Suggestions pertinentes
L'éditeur connaît le type de chaque variable et propose ses méthodes exactes.
Étape 1 / 3

#Exercices

  1. Fonction annotée. Écrivez def dividende(total: int, parts: int) -> float qui lève ValueError si parts <= 0. Vérifiez dividende(10, 4) vaut 2.5 et que mypy accepte le fichier.
  2. Dataclass compte bancaire. Définissez Compte avec titulaire: str et solde: float = 0.0, plus une méthode depot(montant: float) -> None. Créez deux comptes, déposez 100, affichez les deux: le second doit afficher un solde à 0.0.
  3. Union explicite. Écrivez def cherche(cle: str, d: dict[str, int]) -> int | None qui retourne la valeur ou None, puis forcez l'appelant à tester le None avant d'afficher.

#Corrections

Correction: fonction annotée
pythonpython

1def dividende(total: int, parts: int) -> float:2    if parts <= 0:3        raise ValueError(f"parts doit être > 0, reçu {parts}")4    return total / parts5 6print(dividende(10, 4))   # 2.5

mypy fichier.py ne rapporte aucune erreur: les annotations et les usages concordent.

Correction: dataclass compte bancaire
pythonpython

1from dataclasses import dataclass2 3@dataclass4class Compte:5    titulaire: str6    solde: float = 0.07 8    def depot(self, montant: float) -> None:9        if montant <= 0:10            raise ValueError('montant doit être positif')11        self.solde += montant12 13a = Compte('Ada')14b = Compte('Alan')

Chaque instance porte son propre solde: le __init__ généré crée bien un champ par instance, contrairement au piège de la valeur par défaut mutable des fonctions.

Correction: union explicite
pythonpython

1def cherche(cle: str, d: dict[str, int]) -> int | None:2    return d.get(cle)3 4ages = {'ada': 36}5for cle in ['ada', 'linus']:6    valeur = cherche(cle, ages)7    if valeur is None:8        print(f"{cle}: absent")9    else:10        print(f"{cle}: {valeur}")11# Sortie:12# ada: 3613# linus: absent

Le retour int | None oblige l'appelant à dissocier les deux cas; mypy signalerait une utilisation directe de valeur comme int sans garde.

#Quiz

Quel outil vérifie les types sans exécuter le code ?
Quel outil vérifie les types sans exécuter le code ?
Que produit l'annotation x: str | None = None ?
Que produit l'annotation x: str | None = None ?