Programmation structurée (Python) · L1 · Section 8/11
Typage (annotations)
Progression
#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,dictet les unionsA | B,Optional. - Écrire des classes de données avec
@dataclass. - Vérifier un fichier annoté avec mypy.
#Annoter une fonction
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 Adanom: 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:
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.
| Fonction | Signature | Ce qu'elle calcule |
|---|---|---|
mistery1 | entier → chaîne | la parité sous forme textuelle |
mistery2 | (entier, entier, entier) → entier | le maximum des trois arguments |
use | entier → entier | le carré |
mistery3 | (entier, entier) → entier | la somme des carrés des deux arguments |
mistery4 | entier naturel → entier naturel | la somme des nombres pairs jusqu'à n |
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
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:
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
1pip install mypy2mypy mon_fichier.py1# mon_fichier.py2def moyenne(valeurs: list[float]) -> float:3 return sum(valeurs) / len(valeurs)4 5moyenne([12, 'quinze']) # mypy: Argument 1 has incompatible typemypy 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
#Exercices
- Fonction annotée. Écrivez
def dividende(total: int, parts: int) -> floatqui lèveValueErrorsiparts <= 0. Vérifiezdividende(10, 4)vaut2.5et que mypy accepte le fichier. - Dataclass compte bancaire. Définissez
Compteavectitulaire: stretsolde: float = 0.0, plus une méthodedepot(montant: float) -> None. Créez deux comptes, déposez 100, affichez les deux: le second doit afficher un solde à 0.0. - Union explicite. Écrivez
def cherche(cle: str, d: dict[str, int]) -> int | Nonequi retourne la valeur ouNone, puis forcez l'appelant à tester leNoneavant d'afficher.
#Corrections
Correction: fonction annotée
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.5mypy fichier.py ne rapporte aucune erreur: les annotations et les usages concordent.
Correction: dataclass compte bancaire
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
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: absentLe retour int | None oblige l'appelant à dissocier les deux cas; mypy signalerait une utilisation directe de valeur comme int sans garde.