Chapitre 11 — POO avancée : héritage, propriétés, dataclasses et typage¶
Durée estimée : 6 à 7 heures (cours + exercices + mini-projet)
Prérequis : Chapitre 10 — Classes et objets (classes,
__init__,self, méthodes,__str__/__repr__).Objectifs — À la fin de ce chapitre, tu sauras : - annoter ton code avec des types (
int,list[int],dict[str, float],X | None…) et le vérifier avec mypy ; - créer des classes qui héritent d'autres classes, redéfinir des méthodes et utilisersuper(); - tirer parti du polymorphisme et choisir entre héritage et composition ; - contrôler l'accès aux attributs avec@property; - écrire des méthodes de classe (@classmethod) et des méthodes statiques (@staticmethod) ; - écrire des classes de données concises et robustes avec@dataclass.
Cours¶
1. Les annotations de type¶
Le problème¶
Regarde cette fonction :
Que contient articles ? Une liste de prix ? Un dictionnaire ? Des objets Article ? Et remise, c'est 0.1 ou 10 (pour 10 %) ? Impossible de savoir sans lire tout le code. Les annotations de type (type hints) servent à l'écrire noir sur blanc :
def calculer_total(prix: list[float], remise: float) -> float:
"""Renvoie la somme des prix, avec une remise entre 0 et 1."""
return sum(prix) * (1 - remise)
print(calculer_total([10.0, 20.0, 5.5], 0.1))
On lit : « prix est une liste de float, remise est un float, et la fonction renvoie (->) un float ».
💡 Analogie : les annotations, ce sont les étiquettes sur les prises électriques (« 230 V », « USB-C »). Elles n'empêchent pas physiquement de brancher n'importe quoi, mais elles te disent ce qui est prévu, et un outil peut vérifier que tu ne te trompes pas.
Les annotations ne changent pas l'exécution¶
C'est le point le plus important à comprendre : Python ignore les annotations à l'exécution. Elles sont destinées aux humains, à ton éditeur et aux outils de vérification.
def doubler(nombre: int) -> int:
return nombre * 2
print(doubler(21))
print(doubler("ha")) # pas d'erreur à l'exécution !
Python a accepté une chaîne alors qu'on annonçait un int. Pour détecter ce genre d'erreur, on utilise un vérificateur de types comme mypy (section 2) ou l'éditeur VS Code, qui souligne le problème avant même que tu lances le programme.
La syntaxe de base¶
# Variables (rarement nécessaire : le type se devine souvent tout seul)
age: int = 30
prix: float = 9.99
nom: str = "Léa"
est_admin: bool = False
# Paramètres et valeur de retour
def saluer(prenom: str, fois: int = 1) -> str:
return ("Bonjour " + prenom + " ! ") * fois
# Une fonction qui ne renvoie rien renvoie None
def afficher_titre(titre: str) -> None:
print(titre.upper())
print(saluer("Léa", 2))
afficher_titre("menu")
Note la position : : type après chaque paramètre, avant la valeur par défaut ; -> type avant les deux-points de la fonction.
Les collections¶
Pour les structures de données du chapitre 04, on précise le type des éléments entre crochets :
| Annotation | Signification | Exemple de valeur |
|---|---|---|
list[int] |
liste d'entiers | [1, 2, 3] |
list[str] |
liste de chaînes | ["a", "b"] |
dict[str, float] |
dictionnaire clé str → valeur float |
{"pomme": 1.2} |
dict[str, list[int]] |
clé str → liste d'entiers |
{"Alice": [12, 15]} |
set[str] |
ensemble de chaînes | {"rouge", "vert"} |
tuple[int, int] |
tuple de exactement 2 entiers | (3, 4) |
tuple[str, int, float] |
tuple de 3 éléments de types différents | ("Léa", 25, 1.68) |
tuple[int, ...] |
tuple d'entiers de longueur quelconque | (1, 2, 3, 4) |
def moyennes(notes: dict[str, list[float]]) -> dict[str, float]:
"""Calcule la moyenne de chaque élève."""
return {eleve: sum(valeurs) / len(valeurs) for eleve, valeurs in notes.items()}
def min_max(valeurs: list[int]) -> tuple[int, int]:
return min(valeurs), max(valeurs)
print(moyennes({"Alice": [12, 16], "Bob": [9, 11, 13]}))
print(min_max([4, 8, 1, 9]))
⚠️ Dans de vieux tutoriels, tu verras
from typing import List, DictpuisList[int]. Depuis Python 3.9, on écrit directementlist[int],dict[str, float]en minuscules : c'est la forme moderne à utiliser.
« Ceci ou cela » : X | Y et X | None¶
Le symbole | (« ou ») indique qu'une valeur peut avoir plusieurs types :
Le cas le plus fréquent est X | None : « une valeur de type X, ou bien None ». On l'utilise pour une fonction qui peut ne rien trouver, ou un paramètre optionnel :
def trouver_prix(stock: dict[str, float], produit: str) -> float | None:
"""Renvoie le prix du produit, ou None s'il est inconnu."""
return stock.get(produit)
def saluer(nom: str | None = None) -> str:
if nom is None:
return "Bonjour, inconnu !"
return f"Bonjour, {nom} !"
stock = {"pomme": 1.2, "kiwi": 0.5}
print(trouver_prix(stock, "kiwi"), trouver_prix(stock, "mangue"))
print(saluer(), saluer("Hugo"))
L'intérêt : en voyant -> float | None, tu sais immédiatement que tu dois gérer le cas None avant de faire un calcul avec le résultat. Et mypy te le rappellera si tu oublies.
Annoter avec tes propres classes¶
Tes classes sont aussi des types :
class Livre:
def __init__(self, titre: str, pages: int) -> None:
self.titre = titre
self.pages = pages
def total_pages(livres: list[Livre]) -> int:
return sum(livre.pages for livre in livres)
print(total_pages([Livre("Dune", 896), Livre("Le Petit Prince", 96)]))
__init__ renvoie toujours None : on l'annote -> None.
Quand une méthode fait référence à sa propre classe (pas encore complètement définie à ce moment-là), on met le nom entre guillemets :
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def milieu(self, autre: "Point") -> "Point":
return Point((self.x + autre.x) / 2, (self.y + autre.y) / 2)
Les alias de type¶
Si une annotation est longue et répétée, donne-lui un nom avec l'instruction type (Python 3.12+) :
type Carnet = dict[str, list[float]]
def ajouter_note(carnet: Carnet, eleve: str, note: float) -> None:
carnet.setdefault(eleve, []).append(note)
notes: Carnet = {}
ajouter_note(notes, "Alice", 14.5)
print(notes)
Faut-il tout annoter ?¶
Non. Une règle raisonnable pour débuter :
- annote les signatures des fonctions et méthodes (paramètres et retour) : c'est là que c'est le plus utile ;
- n'annote pas les variables locales dont le type est évident (
total = 0,noms = ["a", "b"]) ; - annote une variable quand elle démarre vide ou à
None(resultats: list[str] = [],gagnant: Joueur | None = None).
2. Vérifier les types avec mypy¶
mypy est un outil qui lit ton code sans l'exécuter et vérifie que les types sont cohérents. On l'installe avec pip (voir chapitre 12) :
Prenons ce fichier mon_script.py, plein d'erreurs de type :
def moyenne(notes: list[float]) -> float:
return sum(notes) / len(notes)
def trouver(stock: dict[str, int], produit: str) -> int | None:
return stock.get(produit)
resultat = moyenne(["12", "15"])
quantite: int = trouver({"pomme": 3}, "poire")
age: int = "vingt"
On lance mypy dans le terminal :
$ mypy mon_script.py
mon_script.py:9: error: List item 0 has incompatible type "str"; expected "float" [list-item]
mon_script.py:9: error: List item 1 has incompatible type "str"; expected "float" [list-item]
mon_script.py:10: error: Incompatible types in assignment (expression has type "int | None", variable has type "int") [assignment]
mon_script.py:11: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
Found 4 errors in 1 file (checked 1 source file)
Chaque ligne indique le fichier, le numéro de ligne et le problème :
- ligne 9 : on passe des chaînes à une fonction qui attend des
float; - ligne 10 :
trouverpeut renvoyerNone, mais on range le résultat dans une variable annoncéeint(mypy te rappelle de gérer le casNone) ; - ligne 11 :
"vingt"n'est pas unint.
Une fois corrigé :
Quelques conseils :
- Dans VS Code, l'extension Python (Pylance) fait déjà une partie de ce travail en soulignant les incohérences pendant que tu tapes. Tu peux régler son niveau d'exigence dans les paramètres (
Python › Analysis: Type Checking Modesurbasicoustandard). mypy --strict mon_script.pyest beaucoup plus exigeant (il réclame des annotations partout). C'est un bon objectif pour un projet abouti, pas forcément pour débuter.- mypy ne vérifie que les fonctions annotées : une fonction sans aucune annotation est ignorée par défaut.
3. L'héritage¶
Le principe¶
Imagine un jeu avec des guerriers, des mages et des voleurs. Ils ont tous un nom, des points de vie, une méthode pour subir des dégâts… et chacun a aussi ses spécificités. Recopier le code commun dans trois classes serait une très mauvaise idée (une correction devrait être faite trois fois).
L'héritage permet de créer une classe à partir d'une autre : la nouvelle classe (l'enfant, ou sous-classe) reçoit automatiquement tous les attributs et méthodes de la classe existante (le parent, ou classe de base), et peut en ajouter ou en modifier.
💡 Analogie : en biologie, un chien est un mammifère : il hérite de tout ce qu'ont les mammifères (respirer, allaiter…), et ajoute ses propres caractéristiques (aboyer). En POO, si tu peux dire « un X est un Y », X peut hériter de Y.
class Animal:
def __init__(self, nom: str) -> None:
self.nom = nom
def manger(self) -> str:
return f"{self.nom} mange."
def parler(self) -> str:
return f"{self.nom} fait un bruit."
class Chien(Animal): # Chien hérite d'Animal
def rapporter(self) -> str:
return f"{self.nom} rapporte la balle !"
rex = Chien("Rex")
print(rex.manger()) # méthode héritée d'Animal
print(rex.parler()) # méthode héritée d'Animal
print(rex.rapporter()) # méthode propre à Chien
La syntaxe est class Enfant(Parent):. Chien n'a pas de __init__ : il utilise celui d'Animal, d'où l'attribut nom.
Redéfinir une méthode¶
Un enfant peut redéfinir (on dit aussi « surcharger », en anglais override) une méthode du parent, simplement en écrivant une méthode du même nom :
class Animal:
def __init__(self, nom: str) -> None:
self.nom = nom
def parler(self) -> str:
return f"{self.nom} fait un bruit."
class Chien(Animal):
def parler(self) -> str: # remplace la version d'Animal
return f"{self.nom} fait : Wouf !"
class Chat(Animal):
def parler(self) -> str:
return f"{self.nom} fait : Miaou."
class Poisson(Animal):
pass # garde la version d'Animal
for animal in [Chien("Rex"), Chat("Félix"), Poisson("Nemo")]:
print(animal.parler())
Quand on appelle animal.parler(), Python cherche la méthode d'abord dans la classe de l'objet, puis, s'il ne la trouve pas, dans son parent, puis dans le parent du parent, etc.
super() : appeler la version du parent¶
Souvent, on ne veut pas remplacer complètement la méthode du parent, mais la compléter. C'est le rôle de super(), qui donne accès aux méthodes de la classe parente. Le cas le plus courant est __init__ :
class Animal:
def __init__(self, nom: str, age: int) -> None:
self.nom = nom
self.age = age
def decrire(self) -> str:
return f"{self.nom}, {self.age} an(s)"
class Chien(Animal):
def __init__(self, nom: str, age: int, race: str) -> None:
super().__init__(nom, age) # le parent s'occupe de nom et age
self.race = race # l'enfant ajoute ce qui lui est propre
def decrire(self) -> str:
return super().decrire() + f", {self.race}" # on complète le texte du parent
rex = Chien("Rex", 4, "berger allemand")
print(rex.decrire())
print(vars(rex))
⚠️ Si l'enfant définit son propre
__init__, celui du parent n'est plus appelé automatiquement. Oubliersuper().__init__(...)est l'erreur d'héritage la plus courante (voir les pièges).
isinstance et issubclass¶
class Animal:
pass
class Chien(Animal):
pass
class Labrador(Chien):
pass
medor = Labrador()
print(isinstance(medor, Labrador), isinstance(medor, Chien), isinstance(medor, Animal))
print(isinstance(Animal(), Chien))
print(issubclass(Labrador, Animal))
print(Labrador.__mro__)
True True True
False
True
(<class '__main__.Labrador'>, <class '__main__.Chien'>, <class '__main__.Animal'>, <class 'object'>)
isinstance(objet, Classe): l'objet est-il de cette classe ou d'une de ses sous-classes ? Un labrador est un chien, et est un animal.issubclass(A, B): la classe A hérite-t-elle (directement ou non) de B ?__mro__(Method Resolution Order) montre l'ordre de recherche des méthodes. Tout en haut, il y a toujoursobject: en Python, toutes les classes héritent deobject, même quand on ne l'écrit pas. C'est de lui que viennent le__repr__par défaut (<__main__.Chien object at 0x...>) et bien d'autres choses.
Tu avais déjà utilisé l'héritage sans le savoir : au chapitre 08, class SoldeInsuffisantError(Exception): pass crée une classe qui hérite de Exception. Elle reçoit tout le comportement d'une exception (message, traceback…) sans une ligne de code supplémentaire. Et except LookupError attrape les KeyError parce que KeyError hérite de LookupError !
💡 Python permet aussi l'héritage multiple (
class C(A, B):). C'est un outil puissant mais délicat ; tu n'en auras pas besoin pour débuter.
4. Le polymorphisme¶
Le mot fait peur, l'idée est simple : des objets de types différents peuvent répondre au même message (la même méthode), chacun à sa façon. Le code qui les utilise n'a pas besoin de savoir à quel type précis il a affaire.
class Forme:
def aire(self) -> float:
raise NotImplementedError("chaque forme doit définir son aire")
def decrire(self) -> str:
return f"{type(self).__name__} d'aire {self.aire():.2f}"
class Rectangle(Forme):
def __init__(self, largeur: float, hauteur: float) -> None:
self.largeur = largeur
self.hauteur = hauteur
def aire(self) -> float:
return self.largeur * self.hauteur
class Cercle(Forme):
def __init__(self, rayon: float) -> None:
self.rayon = rayon
def aire(self) -> float:
return 3.14159 * self.rayon**2
class Triangle(Forme):
def __init__(self, base: float, hauteur: float) -> None:
self.base = base
self.hauteur = hauteur
def aire(self) -> float:
return self.base * self.hauteur / 2
formes: list[Forme] = [Rectangle(4, 3), Cercle(1), Triangle(6, 2)]
for forme in formes:
print(forme.decrire())
print(f"Aire totale : {sum(forme.aire() for forme in formes):.2f}")
Regarde la boucle : elle appelle forme.aire() sans jamais tester if isinstance(forme, Cercle).... Chaque objet sait calculer sa propre aire. Si demain tu ajoutes une classe Hexagone, la boucle fonctionnera sans aucune modification. C'est toute la puissance du polymorphisme.
Remarque aussi decrire(), écrite une seule fois dans Forme, qui appelle self.aire() : selon l'objet, c'est la version du Rectangle, du Cercle ou du Triangle qui est exécutée.
Le « typage canard »¶
En Python, le polymorphisme ne nécessite même pas l'héritage. Si un objet possède la bonne méthode, ça marche :
« Si ça marche comme un canard et que ça cancane comme un canard, alors c'est un canard. »
class Canard:
def cancaner(self) -> str:
return "Coin coin !"
class Robot:
def cancaner(self) -> str:
return "BIP. COIN. BIP."
for chose in [Canard(), Robot()]:
print(chose.cancaner())
C'est pour cela que len() fonctionne sur les listes, les chaînes, les dictionnaires… et sur ta Playlist du chapitre 10 : il suffit d'avoir une méthode __len__.
5. Les classes abstraites¶
Dans l'exemple des formes, Forme().aire() lèverait une NotImplementedError. Mais rien n'empêche de créer un objet Forme() qui n'a aucun sens, ou d'oublier de définir aire dans une sous-classe. Le module abc (Abstract Base Classes) permet de rendre cela impossible :
from abc import ABC, abstractmethod
class Forme(ABC):
@abstractmethod
def aire(self) -> float:
"""Chaque forme DOIT définir cette méthode."""
def decrire(self) -> str:
return f"{type(self).__name__} d'aire {self.aire():.2f}"
class Carre(Forme):
def __init__(self, cote: float) -> None:
self.cote = cote
def aire(self) -> float:
return self.cote**2
print(Carre(3).decrire())
Forme()
Carre d'aire 9.00
Traceback (most recent call last):
File "/home/toi/cours/mon_script.py", line 22, in <module>
Forme()
~~~~~^^
TypeError: Can't instantiate abstract class Forme without an implementation for abstract method 'aire'
- Une classe qui hérite de
ABCet possède au moins une méthode marquée@abstractmethodest abstraite : on ne peut pas créer d'objet directement à partir d'elle. - Une sous-classe qui oublie de définir
aireest elle aussi abstraite : l'erreur apparaît dès la création de l'objet, et non au moment où on appelleaire()bien plus tard. @abstractmethodest un décorateur : une ligne commençant par@placée au-dessus d'une fonction pour modifier son comportement. Tu vas en voir d'autres :@property,@classmethod,@staticmethod,@dataclass.
6. Composition ou héritage ?¶
L'héritage est tentant, mais il est souvent surutilisé par les débutants. Il existe une alternative : la composition, qui consiste à mettre un objet à l'intérieur d'un autre, comme la Commande qui contenait des Article au chapitre 10.
Le test le plus simple :
| Relation | Phrase | Technique | Exemple |
|---|---|---|---|
| est un | « un chien est un animal » | héritage | class Chien(Animal) |
| a un | « une voiture a un moteur » | composition | self.moteur = Moteur() |
Une voiture n'est pas un moteur ; elle en possède un. Faire hériter Voiture de Moteur serait absurde (une voiture aurait alors une méthode regler_injection à son niveau…).
class Moteur:
def __init__(self, puissance: int) -> None:
self.puissance = puissance
self.allume = False
def demarrer(self) -> str:
self.allume = True
return f"Moteur de {self.puissance} ch démarré"
class Roue:
def __init__(self, diametre: int) -> None:
self.diametre = diametre
class Voiture:
def __init__(self, marque: str, puissance: int) -> None:
self.marque = marque
self.moteur = Moteur(puissance) # une voiture A UN moteur
self.roues = [Roue(17) for _ in range(4)] # et A quatre roues
def demarrer(self) -> str:
return f"{self.marque} : {self.moteur.demarrer()}" # on DÉLÈGUE au moteur
voiture = Voiture("Renault", 130)
print(voiture.demarrer())
print(voiture.moteur.allume, len(voiture.roues))
La voiture délègue le travail à son moteur. Avantages de la composition :
- chaque classe reste petite et simple ;
- on peut changer une pièce sans toucher au reste (remplacer
MoteurparMoteurElectriquequi a aussi une méthodedemarrer) ; - on évite les hiérarchies d'héritage profondes et fragiles.
💡 Un conseil célèbre de la conception logicielle : « préfère la composition à l'héritage ». Utilise l'héritage quand la relation « est un » est évidente et que l'enfant peut remplacer le parent partout sans surprise. Dans le doute, compose.
Un exemple classique d'héritage mal choisi :
class Pile(list): # ❌ une pile « est une » liste ?
def empiler(self, element: int) -> None:
self.append(element)
def depiler(self) -> int:
return self.pop()
pile = Pile()
pile.empiler(1)
pile.empiler(2)
pile.insert(0, 99) # oups : une pile ne devrait pas permettre d'insérer au fond !
print(pile)
En héritant de list, la Pile a hérité de toutes les méthodes des listes, y compris celles qui cassent sa logique. Avec la composition, on n'expose que ce qu'on veut :
class Pile:
def __init__(self) -> None:
self._elements: list[int] = [] # la pile A une liste
def empiler(self, element: int) -> None:
self._elements.append(element)
def depiler(self) -> int:
return self._elements.pop()
def __len__(self) -> int:
return len(self._elements)
pile = Pile()
pile.empiler(1)
pile.empiler(2)
print(pile.depiler(), len(pile))
7. Les propriétés : @property¶
Le problème¶
Au chapitre 10, pour protéger la température d'un thermostat, on avait écrit une méthode regler(...) et une méthode temperature(). Ça marche, mais c'est lourd : on aimerait écrire salon.temperature = 21 et print(salon.temperature), comme un attribut normal, tout en gardant la validation.
@property permet exactement cela : une méthode déguisée en attribut.
Une propriété calculée (lecture seule)¶
class Rectangle:
def __init__(self, largeur: float, hauteur: float) -> None:
self.largeur = largeur
self.hauteur = hauteur
@property
def aire(self) -> float:
return self.largeur * self.hauteur
r = Rectangle(4, 3)
print(r.aire) # pas de parenthèses : on l'utilise comme un attribut
r.largeur = 10
print(r.aire) # toujours à jour, car recalculée à chaque accès
Si on avait stocké l'aire dans un attribut self.aire = largeur * hauteur dans __init__, elle serait devenue fausse après la modification de la largeur. La propriété est recalculée à chaque lecture : elle ne peut pas se désynchroniser.
Une propriété modifiable avec validation (setter)¶
class Thermostat:
def __init__(self, temperature: float = 19) -> None:
self.temperature = temperature # passe déjà par le setter !
@property
def temperature(self) -> float: # le « getter » : lecture
return self._temperature
@temperature.setter
def temperature(self, valeur: float) -> None: # le « setter » : écriture
if not 5 <= valeur <= 30:
raise ValueError(f"température hors limites : {valeur}")
self._temperature = valeur
@property
def fahrenheit(self) -> float:
return self._temperature * 9 / 5 + 32
salon = Thermostat()
salon.temperature = 21 # appelle le setter
print(salon.temperature) # appelle le getter
print(salon.fahrenheit)
try:
salon.temperature = 45
except ValueError as erreur:
print("Refusé :", erreur)
print(salon.temperature)
Comment ça fonctionne :
@propertyau-dessus dedef temperature(self)crée une propriété : liresalon.temperatureappelle cette méthode.@temperature.setterau-dessus d'une deuxième méthode du même nom définit ce qui se passe quand on écritsalon.temperature = ....- La vraie valeur est stockée dans un attribut interne avec un tiret bas,
_temperature. Il doit avoir un nom différent de la propriété (sinon : boucle infinie, voir les pièges). - Dans
__init__,self.temperature = temperaturepasse déjà par le setter : la validation s'applique aussi à la création.
Une propriété sans setter est en lecture seule : salon.fahrenheit = 50 provoque AttributeError: property 'fahrenheit' of 'Thermostat' object has no setter.
💡 En Python, on commence toujours par des attributs simples (
self.temperature = ...). Le jour où on a besoin de validation, on transforme l'attribut en propriété, et le code qui utilise la classe ne change pas : il continue d'écrireobjet.temperature. Inutile donc d'écrire des méthodesget_temperature()/set_temperature()comme dans d'autres langages.
8. Méthodes de classe et méthodes statiques¶
Jusqu'ici, toutes nos méthodes travaillaient sur un objet (self). Il existe deux autres sortes de méthodes.
@classmethod : une méthode qui reçoit la classe¶
Une méthode de classe reçoit la classe elle-même en premier paramètre, nommé par convention cls, au lieu de l'objet. Son usage principal : fournir d'autres façons de créer des objets (on parle de « constructeurs alternatifs »).
class Date:
def __init__(self, jour: int, mois: int, annee: int) -> None:
self.jour = jour
self.mois = mois
self.annee = annee
@classmethod
def depuis_texte(cls, texte: str) -> "Date":
"""Crée une Date à partir d'un texte 'JJ/MM/AAAA'."""
jour, mois, annee = (int(morceau) for morceau in texte.split("/"))
return cls(jour, mois, annee) # cls(...) équivaut ici à Date(...)
@classmethod
def depuis_iso(cls, texte: str) -> "Date":
"""Crée une Date à partir d'un texte 'AAAA-MM-JJ'."""
annee, mois, jour = (int(morceau) for morceau in texte.split("-"))
return cls(jour, mois, annee)
def __repr__(self) -> str:
return f"Date({self.jour}, {self.mois}, {self.annee})"
print(Date(14, 7, 2025))
print(Date.depuis_texte("03/02/2026")) # appelée sur la CLASSE, pas sur un objet
print(Date.depuis_iso("2026-12-25"))
Tu en as déjà utilisé : date.fromisoformat(...) et datetime.strptime(...) au chapitre 09 sont des méthodes de classe ! Pourquoi cls(...) plutôt que Date(...) ? Parce que si une sous-classe (DateAnniversaire(Date)) appelle depuis_texte, cls sera la sous-classe, et c'est bien un objet DateAnniversaire qui sera créé.
@staticmethod : une simple fonction rangée dans la classe¶
Une méthode statique ne reçoit ni self ni cls. C'est une fonction ordinaire, placée dans la classe parce qu'elle lui est logiquement liée :
class Date:
def __init__(self, jour: int, mois: int, annee: int) -> None:
if not Date.est_valide(jour, mois, annee):
raise ValueError(f"date invalide : {jour}/{mois}/{annee}")
self.jour = jour
self.mois = mois
self.annee = annee
@staticmethod
def est_bissextile(annee: int) -> bool:
return annee % 4 == 0 and (annee % 100 != 0 or annee % 400 == 0)
@staticmethod
def est_valide(jour: int, mois: int, annee: int) -> bool:
jours_par_mois = [31, 29 if Date.est_bissextile(annee) else 28, 31, 30,
31, 30, 31, 31, 30, 31, 30, 31]
return 1 <= mois <= 12 and 1 <= jour <= jours_par_mois[mois - 1]
print(Date.est_bissextile(2024), Date.est_bissextile(1900), Date.est_bissextile(2000))
print(Date.est_valide(29, 2, 2025))
try:
Date(31, 4, 2026)
except ValueError as erreur:
print(erreur)
Résumé¶
| Type de méthode | Décorateur | Premier paramètre | Accès à | Usage typique |
|---|---|---|---|---|
| Méthode d'instance | (aucun) | self (l'objet) |
l'objet et sa classe | 95 % des cas |
| Méthode de classe | @classmethod |
cls (la classe) |
la classe | constructeurs alternatifs |
| Méthode statique | @staticmethod |
(aucun) | rien de spécial | fonction utilitaire liée à la classe |
9. Les dataclasses¶
Le problème : beaucoup de code répétitif¶
Beaucoup de classes servent surtout à regrouper des données. Pour chacune, on réécrit le même code fastidieux :
class Produit:
def __init__(self, nom: str, prix: float, stock: int = 0) -> None:
self.nom = nom
self.prix = prix
self.stock = stock
def __repr__(self) -> str:
return f"Produit(nom={self.nom!r}, prix={self.prix!r}, stock={self.stock!r})"
def __eq__(self, autre: object) -> bool:
if not isinstance(autre, Produit):
return NotImplemented
return (self.nom, self.prix, self.stock) == (autre.nom, autre.prix, autre.stock)
Quinze lignes pour trois attributs ! Le module dataclasses génère tout cela automatiquement.
@dataclass¶
from dataclasses import dataclass
@dataclass
class Produit:
nom: str
prix: float
stock: int = 0
stylo = Produit("Stylo", 1.5, 100)
gomme = Produit("Gomme", 0.8)
print(stylo) # __repr__ automatique
print(gomme.stock) # valeur par défaut
print(stylo == Produit("Stylo", 1.5, 100)) # __eq__ automatique
stylo.stock -= 1 # attributs modifiables normalement
print(stylo.stock)
On déclare simplement les attributs avec leur type (les annotations de la section 1 sont ici obligatoires), et le décorateur @dataclass écrit pour nous __init__, __repr__ et __eq__. Les deux versions de Produit sont équivalentes, mais la seconde fait 5 lignes.
Une dataclass est une classe normale : tu peux lui ajouter des méthodes et des propriétés.
from dataclasses import dataclass
@dataclass
class LigneCommande:
produit: str
prix_unitaire: float
quantite: int = 1
@property
def total(self) -> float:
return self.prix_unitaire * self.quantite
def __str__(self) -> str:
return f"{self.quantite} x {self.produit} = {self.total:.2f} €"
ligne = LigneCommande("Cahier", 2.5, 4)
print(ligne)
print(repr(ligne))
Les valeurs par défaut mutables : field(default_factory=...)¶
Tu te souviens du piège de la liste partagée (chapitre 10) ? Les dataclasses te protègent : elles refusent une liste comme valeur par défaut. On utilise field(default_factory=list), qui crée une nouvelle liste pour chaque objet :
from dataclasses import dataclass, field
@dataclass
class Equipe:
nom: str
membres: list[str] = field(default_factory=list)
rouges = Equipe("Rouges")
bleus = Equipe("Bleus")
rouges.membres.append("Léa")
print(rouges)
print(bleus)
Valider avec __post_init__¶
__init__ est généré automatiquement… alors où mettre la validation ? Dans __post_init__, une méthode appelée juste après l'__init__ automatique :
from dataclasses import dataclass
@dataclass
class Produit:
nom: str
prix: float
stock: int = 0
def __post_init__(self) -> None:
if self.prix < 0:
raise ValueError(f"prix négatif pour {self.nom} : {self.prix}")
self.nom = self.nom.strip().capitalize()
print(Produit(" clavier ", 49.9))
try:
Produit("souris", -5)
except ValueError as erreur:
print("Erreur :", erreur)
Options utiles : order=True et frozen=True¶
from dataclasses import asdict, dataclass
@dataclass(order=True)
class Version:
majeure: int
mineure: int
correctif: int = 0
versions = [Version(3, 12, 1), Version(3, 9), Version(3, 12), Version(2, 7, 18)]
print(sorted(versions))
print(max(versions))
print(Version(3, 12) < Version(3, 13))
@dataclass(frozen=True)
class Point:
x: float
y: float
origine = Point(0, 0)
print(asdict(origine))
try:
origine.x = 5
except AttributeError as erreur:
print(type(erreur).__name__, ":", erreur)
[Version(majeure=2, mineure=7, correctif=18), Version(majeure=3, mineure=9, correctif=0), Version(majeure=3, mineure=12, correctif=0), Version(majeure=3, mineure=12, correctif=1)]
Version(majeure=3, mineure=12, correctif=1)
True
{'x': 0, 'y': 0}
FrozenInstanceError : cannot assign to field 'x'
order=Truegénère<,<=,>,>=: les objets se comparent attribut par attribut, dans l'ordre de déclaration (comme des tuples).sorted,minetmaxfonctionnent directement.frozen=Truerend les objets immuables : toute modification lève une erreur (FrozenInstanceError, une sous-classe d'AttributeError), que mypy repère même avant l'exécution (Property "x" defined in "Point" is read-only). Utile pour des valeurs qui ne doivent jamais changer (coordonnées, configuration…). Bonus : un objet gelé peut servir de clé de dictionnaire ou d'élément d'ensemble.asdict(objet)convertit une dataclass en dictionnaire : parfait pour la sauvegarde en JSON (chapitre 07).
Héritage de dataclasses¶
Les dataclasses peuvent hériter les unes des autres ; l'enfant ajoute ses champs après ceux du parent :
from dataclasses import dataclass
@dataclass
class Media:
titre: str
annee: int
@dataclass
class Livre(Media):
auteur: str
pages: int
@dataclass
class Film(Media):
realisateur: str
duree: int
print(Livre("Dune", 1965, "Frank Herbert", 896))
print(Film("Alien", 1979, "Ridley Scott", 117))
Livre(titre='Dune', annee=1965, auteur='Frank Herbert', pages=896)
Film(titre='Alien', annee=1979, realisateur='Ridley Scott', duree=117)
Dataclass ou classe classique ?¶
| Utilise une dataclass quand… | Utilise une classe classique quand… |
|---|---|
| l'objet sert surtout à porter des données | l'objet a surtout du comportement et un état interne complexe |
tu veux __repr__ et __eq__ gratuitement |
l'__init__ fait beaucoup plus que ranger les paramètres |
| les attributs sont publics et simples | tu veux contrôler finement chaque attribut |
Dans le doute, commence par une dataclass : c'est plus court, et tu peux toujours y ajouter des méthodes.
10. Tout assembler¶
Voici un petit exemple qui combine toutes les notions du chapitre : annotations, classe abstraite, héritage, super(), propriété, méthode de classe, dataclass et composition.
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class Adresse:
rue: str
ville: str
def __str__(self) -> str:
return f"{self.rue}, {self.ville}"
class Employe(ABC):
def __init__(self, nom: str, adresse: Adresse) -> None:
self.nom = nom
self.adresse = adresse # composition : un employé A une adresse
@property
@abstractmethod
def salaire_mensuel(self) -> float: ...
@classmethod
def depuis_ligne(cls, ligne: str) -> "Employe":
"""Crée l'employé adapté à partir d'une ligne 'type;nom;rue;ville;montant'."""
type_employe, nom, rue, ville, montant = ligne.split(";")
adresse = Adresse(rue, ville)
if type_employe == "salarie":
return Salarie(nom, adresse, float(montant))
return Freelance(nom, adresse, float(montant))
def __str__(self) -> str:
return f"{self.nom:<6} ({type(self).__name__:<9}) {self.salaire_mensuel:>8.2f} € — {self.adresse}"
class Salarie(Employe):
def __init__(self, nom: str, adresse: Adresse, salaire_annuel: float) -> None:
super().__init__(nom, adresse)
self.salaire_annuel = salaire_annuel
@property
def salaire_mensuel(self) -> float:
return self.salaire_annuel / 12
class Freelance(Employe):
def __init__(self, nom: str, adresse: Adresse, taux_journalier: float) -> None:
super().__init__(nom, adresse)
self.taux_journalier = taux_journalier
self.jours_factures = 0
@property
def salaire_mensuel(self) -> float:
return self.taux_journalier * self.jours_factures
@dataclass
class Entreprise:
nom: str
employes: list[Employe] = field(default_factory=list)
@property
def masse_salariale(self) -> float:
return sum(employe.salaire_mensuel for employe in self.employes)
lignes = [
"salarie;Alice;3 rue des Lilas;Lyon;42000",
"freelance;Bruno;8 quai Est;Nantes;450",
"salarie;Chloé;1 place Haute;Lille;36000",
]
societe = Entreprise("Python & Co")
for ligne in lignes:
societe.employes.append(Employe.depuis_ligne(ligne))
for employe in societe.employes:
if isinstance(employe, Freelance):
employe.jours_factures = 12
for employe in societe.employes:
print(employe)
print(f"Masse salariale de {societe.nom} : {societe.masse_salariale:.2f} €")
Alice (Salarie ) 3500.00 € — 3 rue des Lilas, Lyon
Bruno (Freelance) 5400.00 € — 8 quai Est, Nantes
Chloé (Salarie ) 3000.00 € — 1 place Haute, Lille
Masse salariale de Python & Co : 11900.00 €
Remarque @property et @abstractmethod empilés : chaque sous-classe doit fournir une propriété salaire_mensuel. Et masse_salariale profite du polymorphisme : elle additionne des salaires calculés de façons totalement différentes sans jamais tester le type.
Pièges fréquents¶
Piège 1 — Oublier d'appeler super().__init__()¶
class Animal:
def __init__(self, nom: str) -> None:
self.nom = nom
class Chien(Animal):
def __init__(self, nom: str, race: str) -> None:
self.race = race # ❌ et le nom ?
print(Chien("Rex", "labrador").nom)
En définissant son propre __init__, Chien a remplacé celui d'Animal, qui n'est donc jamais exécuté. ✅ Ajoute super().__init__(nom) au début du __init__ de l'enfant.
Piège 2 — La propriété qui s'appelle elle-même¶
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius
@property
def celsius(self) -> float:
return self.celsius # ❌ relit la propriété… qui relit la propriété…
@celsius.setter
def celsius(self, valeur: float) -> None:
self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter…
Temperature(20)
Traceback (most recent call last):
File "/home/toi/cours/mon_script.py", line 14, in <module>
Temperature(20)
~~~~~~~~~~~^^^^
File "/home/toi/cours/mon_script.py", line 3, in __init__
self.celsius = celsius
^^^^^^^^^^^^
File "/home/toi/cours/mon_script.py", line 11, in celsius
self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter…
^^^^^^^^^^^^
File "/home/toi/cours/mon_script.py", line 11, in celsius
self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter…
^^^^^^^^^^^^
File "/home/toi/cours/mon_script.py", line 11, in celsius
self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter…
^^^^^^^^^^^^
[Previous line repeated 995 more times]
RecursionError: maximum recursion depth exceeded
Dans le setter, self.celsius = ... déclenche… le setter, à l'infini. ✅ Stocke la valeur dans un attribut au nom différent, avec un tiret bas : self._celsius = valeur et return self._celsius.
Piège 3 — Appeler une propriété avec des parenthèses¶
class Cercle:
def __init__(self, rayon: float) -> None:
self.rayon = rayon
@property
def aire(self) -> float:
return 3.14159 * self.rayon**2
c = Cercle(2)
print(c.aire())
c.aire vaut déjà le résultat (12.56636) ; les parenthèses essaient ensuite d'« appeler » ce nombre. ✅ print(c.aire).
Piège 4 — Une valeur par défaut mutable dans une dataclass¶
Le message donne la solution. ✅ membres: list[str] = field(default_factory=list) (avec from dataclasses import dataclass, field).
Piège 5 — Un champ sans valeur par défaut après un champ qui en a une¶
C'est la même règle que pour les paramètres de fonction (chapitre 05) : les paramètres avec valeur par défaut viennent après ceux qui n'en ont pas. ✅ Réordonne les champs (nom, stock, puis prix = 0.0) ou donne une valeur par défaut à stock.
Piège 6 — Croire que les annotations sont vérifiées à l'exécution¶
Python n'a pas refusé la chaîne à l'entrée de la fonction : il a essayé, et l'erreur est arrivée plus loin, avec un message sans rapport avec l'annotation. Avec un entier (ajouter_tva(10)), ça fonctionne d'ailleurs très bien : un int est accepté partout où un float est attendu. ✅ Lance mypy (ou regarde les soulignements de VS Code) pour détecter l'erreur avant l'exécution, et valide les données venant de l'extérieur (saisie, fichier) avec de vraies vérifications (if + raise).
Piège 7 — Hériter là où il faudrait composer¶
class Moteur:
def demarrer(self) -> str:
return "Vroum"
class Voiture(Moteur): # ❌ une voiture n'EST PAS un moteur
pass
Ça fonctionne techniquement, mais le modèle est faux : une Voiture serait acceptée partout où on attend un Moteur, et chaque méthode ajoutée à Moteur apparaîtrait sur la voiture. ✅ Pose-toi la question « est un ou a un ? » : self.moteur = Moteur() dans Voiture.__init__.
Piège 8 — Oublier d'implémenter une méthode abstraite¶
from abc import ABC, abstractmethod
class Forme(ABC):
@abstractmethod
def aire(self) -> float: ...
class Carre(Forme):
def __init__(self, cote: float) -> None:
self.cote = cote
def surface(self) -> float: # ❌ mauvais nom : ce n'est pas « aire »
return self.cote**2
Carre(2)
TypeError: Can't instantiate abstract class Carre without an implementation for abstract method 'aire'
C'est justement l'intérêt des classes abstraites : l'oubli est détecté immédiatement. ✅ Renomme la méthode aire.
Exercices¶
Exercice 1 ⭐ — Annoter et vérifier¶
Ajoute des annotations de type complètes à ces fonctions, puis vérifie-les avec mypy. Corrige ensuite l'appel erroné que mypy détecte.
def initiales(prenom, nom):
return prenom[0].upper() + "." + nom[0].upper() + "."
def compter_voyelles(mots):
resultat = {}
for mot in mots:
resultat[mot] = sum(1 for lettre in mot.lower() if lettre in "aeiouy")
return resultat
def chercher_age(annuaire, nom):
return annuaire.get(nom)
print(initiales("ada", "lovelace"))
print(compter_voyelles(["python", "classe"]))
age = chercher_age({"Alice": 30}, "Bob")
print(age + 1)
Exercice 2 ⭐ — Une petite famille de véhicules¶
Écris une classe Vehicule (marque, vitesse maximale en km/h) avec une méthode decrire() et une méthode temps_trajet(distance_km) qui renvoie le temps en heures à vitesse maximale. Crée deux sous-classes :
Velo: pas de paramètre de vitesse (toujours 25 km/h), et un attributelectrique: bool;Voiture: un attribut supplémentaireplaces, etdecrire()qui complète celle du parent avecsuper().
vehicules = [Velo("Decathlon", electrique=True), Voiture("Renault", 180, places=5)]
for v in vehicules:
print(v.decrire(), f"— 100 km en {v.temps_trajet(100):.1f} h")
Decathlon (max 25 km/h), électrique — 100 km en 4.0 h
Renault (max 180 km/h), 5 places — 100 km en 0.6 h
Exercice 3 ⭐ — Propriétés de température¶
Écris une classe Temperature qui stocke une température en degrés Celsius (attribut interne _celsius) et propose :
- une propriété
celsius(lecture + écriture), qui refuse les valeurs inférieures au zéro absolu (−273,15 °C) avec uneValueError; - une propriété
fahrenheit(lecture + écriture) qui convertit dans les deux sens ; - une propriété en lecture seule
etatqui renvoie"glace","liquide"ou"vapeur"(pour l'eau, à pression normale).
t = Temperature(25)
print(t.celsius, t.fahrenheit, t.etat) # 25 77.0 liquide
t.fahrenheit = 212
print(t.celsius, t.etat) # 100.0 vapeur
t.celsius = -300 # ValueError
Exercice 4 ⭐⭐ — Dataclass de bibliothèque musicale¶
Écris une dataclass Morceau (titre, artiste, durée en secondes, note de 0 à 5, avec 0 par défaut), avec :
- une validation dans
__post_init__(durée positive, note entre 0 et 5) ; - une propriété
duree_textequi renvoie"3:05"pour 185 secondes.
Puis une dataclass Playlist (nom, liste de morceaux vide par défaut) avec :
ajouter(morceau);- une propriété
duree_totale(en texte, même format,"mm:ss") ; meilleurs(n)qui renvoie lesnmorceaux les mieux notés.
playlist = Playlist("Road trip")
playlist.ajouter(Morceau("Bohemian Rhapsody", "Queen", 354, 5))
playlist.ajouter(Morceau("Get Lucky", "Daft Punk", 248, 4))
playlist.ajouter(Morceau("Take On Me", "a-ha", 225, 3))
print(playlist.duree_totale) # 13:47
for morceau in playlist.meilleurs(2):
print(morceau.titre, morceau.duree_texte)
Exercice 5 ⭐⭐ — Méthodes de classe et statiques¶
Écris une classe Duree qui stocke un nombre total de secondes, avec :
@classmethod depuis_texte(cls, texte)qui accepte"1h30","45min","2h","1h05min"ou"90s";@classmethod depuis_minutes(cls, minutes);@staticmethod formater(secondes)qui renvoie"1h30min00s";__str__qui utiliseformater, et__add__pour additionner deux durées avec+;__lt__pour pouvoir les trier (ou utilise@dataclass(order=True)).
trajets = [Duree.depuis_texte("1h30"), Duree.depuis_texte("45min"), Duree.depuis_minutes(20)]
print([str(d) for d in sorted(trajets)])
print(trajets[0] + trajets[1])
Exercice 6 ⭐⭐ — Formes abstraites¶
Écris une classe abstraite Forme avec deux méthodes abstraites aire() et perimetre(), et une méthode concrète __str__ qui affiche le nom de la classe, l'aire et le périmètre. Crée Cercle, Rectangle et Carre. Astuce : un carré est un rectangle dont les deux côtés sont égaux ; utilise l'héritage et super() pour l'écrire en 3 lignes.
Affiche ensuite les formes triées par aire décroissante.
Cercle aire=19.63 périmètre=15.71
Rectangle aire=10.00 périmètre=14.00
Carre aire= 9.00 périmètre=12.00
Cercle aire= 3.14 périmètre= 6.28
Exercice 7 ⭐⭐⭐ — Composition : un lecteur de musique¶
Modélise un lecteur de musique par composition, avec des dataclasses et des annotations complètes :
Morceau(titre, durée en secondes) ;File: une file de morceaux (composition aveccollections.deque), méthodesajouter,suivant() -> Morceau | None,__len__;Volume: un niveau entre 0 et 100, avecmonter(pas=10),baisser(pas=10)(bornés) ;Lecteur: contient uneFileet unVolume, avec un attributen_cours: Morceau | None. Méthodeslire_suivant() -> stretetat() -> str.
lecteur = Lecteur()
for titre, duree in [("Intro", 60), ("Thème", 200), ("Final", 150)]:
lecteur.file.ajouter(Morceau(titre, duree))
lecteur.volume.monter(30)
print(lecteur.lire_suivant())
print(lecteur.lire_suivant())
print(lecteur.etat())
print(lecteur.lire_suivant())
print(lecteur.lire_suivant())
▶ Intro (1:00)
▶ Thème (3:20)
En cours : Thème | volume 80/100 | 1 morceau(x) en attente
▶ Final (2:30)
■ File vide, lecture terminée.
(Le volume démarre à 50.) Vérifie ton code avec mypy : il ne doit signaler aucune erreur.
Exercice 8 ⭐⭐⭐ — Le compte épargne¶
Reprends l'idée du mini-projet du chapitre 10, en version simplifiée et annotée :
CompteBancaire: titulaire,_soldeinterne, propriété en lecture seulesolde, méthodesdeposer(montant)etretirer(montant)(avecValueErrorsi montant ≤ 0 ou solde insuffisant), et unhistorique: list[str].CompteEpargne(CompteBancaire): ajoute untauxannuel (ex :0.03) et un plafond (22 950 € par défaut). Redéfinisdeposerpour refuser un dépôt qui dépasserait le plafond (puis appelle la version du parent avecsuper()). Ajouteappliquer_interets()qui dépose les intérêts d'un mois (taux / 12), arrondis au centime, en ignorant le plafond.CompteCourant(CompteBancaire): ajoute undecouvertautorisé et redéfinitretireren conséquence.
comptes: list[CompteBancaire] = [CompteCourant("Alice", decouvert=200), CompteEpargne("Alice", taux=0.03)]
comptes[0].deposer(100)
comptes[0].retirer(250)
comptes[1].deposer(10_000)
for _ in range(3):
comptes[1].appliquer_interets()
for compte in comptes:
print(compte)
try:
comptes[1].deposer(20_000)
except ValueError as erreur:
print("Refusé :", erreur)
CompteCourant de Alice : -150.00 €
CompteEpargne de Alice : 10075.19 €
Refusé : plafond de 22950.00 € dépassé
Mini-projet — Un jeu de rôle textuel¶
Cahier des charges¶
Tu vas programmer un combat automatique entre une équipe de héros et une équipe de monstres, dans un fichier rpg.py, en mobilisant toutes les notions du chapitre.
Objets (composition et dataclasses)
- Une dataclass
Objet(nom, description) avec une méthodeutiliser(porteur). - Une dataclass
Potion(Objet)avec un champsoin:utiliserrend des PV au porteur. - Une dataclass
Inventaire(capacité, liste d'objets avecdefault_factory), avecajouter(refuse si plein) etretirer_premiere_potion() -> Potion | None.
Personnages (héritage, classe abstraite, propriétés)
- Une classe abstraite
Personnage: nom,pv_max, force, défense, unInventaire(composition). - Une propriété
pvdont le setter garde toujours la valeur entre 0 etpv_max. - Une propriété
est_vivantet une propriétéclasse(le nom de la classe). subir_degats(degats) -> int: dégâts réels = dégâts − défense, minimum 1. Renvoie les dégâts réels.attaquer(cible),jouer_tour(cible)(boit une potion si PV < 30 %, sinon appelleaction_speciale).- Une méthode abstraite
action_speciale(cible) -> str. - Trois sous-classes de héros, chacune avec un
__init__qui appellesuper().__init__avec ses propres statistiques : Guerrier: quand ses PV passent sous 50 %, il entre en rage (dégâts doublés) ;Mage: possède du mana ; lance une boule de feu (dégâts fixes, coûte du mana), sinon attaque normalement et recharge ;Voleur: redéfinitsubir_degatspour esquiver 25 % des attaques, et porte des coups critiques.- Une classe
Monstredont l'action spéciale est une simple attaque.
Combat
- Une fonction
combat(heros, monstres, de): à chaque tour, chaque personnage vivant joue contre une cible vivante tirée au hasard dans l'équipe adverse. Le combat s'arrête quand une équipe est entièrement K.O. (ou au bout de 20 tours). - Le hasard passe par un objet
random.Random(graine)transmis aux personnages : avec la même graine, le combat est reproductible (pratique pour déboguer !). - Tout le code est annoté et passe
mypysans erreur.
Étapes suggérées¶
- Écris
Objet,PotionetInventaire, et teste-les seuls. - Écris
Personnageavec la propriétépv. Teste queperso.pv = 999est ramené àpv_maxetperso.pv = -5à 0. - Écris
Monstre(la sous-classe la plus simple) et fais combattre deux monstres l'un contre l'autre. - Ajoute
Guerrier, puisMage, puisVoleur, en testant chacun. - Écris la fonction
combatet une fonctionmainqui prépare les équipes. - Lance
mypy rpg.pyet corrige les erreurs signalées.
Pistes d'amélioration¶
- Un mode interactif : le joueur choisit l'action de chaque héros (attaquer, potion, action spéciale) et la cible.
- Une classe
Arme(dataclass) équipable, qui modifie la force : composition encore ! - De l'expérience et des niveaux : une propriété
niveaucalculée à partir de l'expérience. - Un
Soigneurqui soigne l'allié le plus blessé : il faudra lui donner accès à son équipe. - Sauvegarder l'équipe en JSON avec
asdictet une méthode de classedepuis_dict.
Corrigés¶
Corrigé de l'exercice 1
def initiales(prenom: str, nom: str) -> str:
return prenom[0].upper() + "." + nom[0].upper() + "."
def compter_voyelles(mots: list[str]) -> dict[str, int]:
resultat: dict[str, int] = {}
for mot in mots:
resultat[mot] = sum(1 for lettre in mot.lower() if lettre in "aeiouy")
return resultat
def chercher_age(annuaire: dict[str, int], nom: str) -> int | None:
return annuaire.get(nom)
print(initiales("ada", "lovelace"))
print(compter_voyelles(["python", "classe"]))
age = chercher_age({"Alice": 30}, "Bob")
print(age + 1)
$ mypy exercice1.py
exercice1.py:19: error: Unsupported operand types for + ("None" and "int") [operator]
exercice1.py:19: note: Left operand is of type "int | None"
Found 1 error in 1 file (checked 1 source file)
Corrigé de l'exercice 2
class Vehicule:
def __init__(self, marque: str, vitesse_max: float) -> None:
self.marque = marque
self.vitesse_max = vitesse_max
def decrire(self) -> str:
return f"{self.marque} (max {self.vitesse_max} km/h)"
def temps_trajet(self, distance_km: float) -> float:
return distance_km / self.vitesse_max
class Velo(Vehicule):
def __init__(self, marque: str, electrique: bool = False) -> None:
super().__init__(marque, 25)
self.electrique = electrique
def decrire(self) -> str:
texte = super().decrire()
return texte + (", électrique" if self.electrique else ", musculaire")
class Voiture(Vehicule):
def __init__(self, marque: str, vitesse_max: float, places: int) -> None:
super().__init__(marque, vitesse_max)
self.places = places
def decrire(self) -> str:
return f"{super().decrire()}, {self.places} places"
vehicules = [Velo("Decathlon", electrique=True), Voiture("Renault", 180, places=5)]
for v in vehicules:
print(v.decrire(), f"— 100 km en {v.temps_trajet(100):.1f} h")
Corrigé de l'exercice 3
ZERO_ABSOLU = -273.15
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius # passe par le setter (validation)
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, valeur: float) -> None:
if valeur < ZERO_ABSOLU:
raise ValueError(f"{valeur} °C est sous le zéro absolu")
self._celsius = valeur
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, valeur: float) -> None:
self.celsius = (valeur - 32) * 5 / 9 # réutilise le setter de celsius
@property
def etat(self) -> str:
if self._celsius <= 0:
return "glace"
if self._celsius < 100:
return "liquide"
return "vapeur"
t = Temperature(25)
print(t.celsius, t.fahrenheit, t.etat)
t.fahrenheit = 212
print(t.celsius, t.etat)
try:
t.celsius = -300
except ValueError as erreur:
print("Erreur :", erreur)
try:
t.etat = "plasma"
except AttributeError as erreur:
print("Erreur :", erreur)
Corrigé de l'exercice 4
from dataclasses import dataclass, field
def formater_duree(secondes: int) -> str:
minutes, reste = divmod(secondes, 60)
return f"{minutes}:{reste:02d}"
@dataclass
class Morceau:
titre: str
artiste: str
duree: int
note: int = 0
def __post_init__(self) -> None:
if self.duree <= 0:
raise ValueError(f"durée invalide : {self.duree}")
if not 0 <= self.note <= 5:
raise ValueError(f"note invalide : {self.note} (0 à 5)")
@property
def duree_texte(self) -> str:
return formater_duree(self.duree)
@dataclass
class Playlist:
nom: str
morceaux: list[Morceau] = field(default_factory=list)
def ajouter(self, morceau: Morceau) -> None:
self.morceaux.append(morceau)
@property
def duree_totale(self) -> str:
return formater_duree(sum(morceau.duree for morceau in self.morceaux))
def meilleurs(self, n: int) -> list[Morceau]:
return sorted(self.morceaux, key=lambda morceau: morceau.note, reverse=True)[:n]
playlist = Playlist("Road trip")
playlist.ajouter(Morceau("Bohemian Rhapsody", "Queen", 354, 5))
playlist.ajouter(Morceau("Get Lucky", "Daft Punk", 248, 4))
playlist.ajouter(Morceau("Take On Me", "a-ha", 225, 3))
print(playlist.duree_totale)
for morceau in playlist.meilleurs(2):
print(morceau.titre, morceau.duree_texte)
try:
Morceau("Silence", "John Cage", 273, 7)
except ValueError as erreur:
print("Erreur :", erreur)
Corrigé de l'exercice 5
from dataclasses import dataclass
UNITES = {"h": 3600, "m": 60, "s": 1}
@dataclass(order=True)
class Duree:
secondes: int
@classmethod
def depuis_minutes(cls, minutes: float) -> "Duree":
return cls(round(minutes * 60))
@classmethod
def depuis_texte(cls, texte: str) -> "Duree":
"""Accepte '1h30', '45min', '2h', '1h05min', '90s'."""
propre = texte.strip().lower().replace("min", "m")
total = 0
nombre = ""
for caractere in propre:
if caractere.isdigit():
nombre += caractere
elif caractere in UNITES and nombre:
total += int(nombre) * UNITES[caractere]
nombre = ""
else:
raise ValueError(f"durée illisible : {texte!r}")
if nombre: # « 1h30 » : un nombre final sans unité = des minutes
total += int(nombre) * 60
if not propre:
raise ValueError("durée vide")
return cls(total)
@staticmethod
def formater(secondes: int) -> str:
heures, reste = divmod(secondes, 3600)
minutes, secondes = divmod(reste, 60)
return f"{heures}h{minutes:02d}min{secondes:02d}s"
def __str__(self) -> str:
return self.formater(self.secondes)
def __add__(self, autre: "Duree") -> "Duree":
return Duree(self.secondes + autre.secondes)
trajets = [Duree.depuis_texte("1h30"), Duree.depuis_texte("45min"), Duree.depuis_minutes(20)]
print([str(d) for d in sorted(trajets)])
print(trajets[0] + trajets[1])
for texte in ["2h", "1h05min", "90s", "3h10"]:
print(texte, "->", Duree.depuis_texte(texte))
try:
Duree.depuis_texte("deux heures")
except ValueError as erreur:
print("Erreur :", erreur)
Corrigé de l'exercice 6
import math
from abc import ABC, abstractmethod
class Forme(ABC):
@abstractmethod
def aire(self) -> float: ...
@abstractmethod
def perimetre(self) -> float: ...
def __str__(self) -> str:
return f"{type(self).__name__:<12} aire={self.aire():5.2f} périmètre={self.perimetre():5.2f}"
class Cercle(Forme):
def __init__(self, rayon: float) -> None:
self.rayon = rayon
def aire(self) -> float:
return math.pi * self.rayon**2
def perimetre(self) -> float:
return 2 * math.pi * self.rayon
class Rectangle(Forme):
def __init__(self, largeur: float, hauteur: float) -> None:
self.largeur = largeur
self.hauteur = hauteur
def aire(self) -> float:
return self.largeur * self.hauteur
def perimetre(self) -> float:
return 2 * (self.largeur + self.hauteur)
class Carre(Rectangle):
def __init__(self, cote: float) -> None:
super().__init__(cote, cote)
formes: list[Forme] = [Cercle(1), Rectangle(2, 5), Carre(3), Cercle(2.5)]
for forme in sorted(formes, key=lambda f: f.aire(), reverse=True):
print(forme)
Corrigé de l'exercice 7
from collections import deque
from dataclasses import dataclass, field
@dataclass
class Morceau:
titre: str
duree: int
def __str__(self) -> str:
minutes, secondes = divmod(self.duree, 60)
return f"{self.titre} ({minutes}:{secondes:02d})"
@dataclass
class File:
morceaux: deque[Morceau] = field(default_factory=deque)
def ajouter(self, morceau: Morceau) -> None:
self.morceaux.append(morceau)
def suivant(self) -> Morceau | None:
if not self.morceaux:
return None
return self.morceaux.popleft()
def __len__(self) -> int:
return len(self.morceaux)
@dataclass
class Volume:
niveau: int = 50
def monter(self, pas: int = 10) -> None:
self.niveau = min(100, self.niveau + pas)
def baisser(self, pas: int = 10) -> None:
self.niveau = max(0, self.niveau - pas)
@dataclass
class Lecteur:
file: File = field(default_factory=File)
volume: Volume = field(default_factory=Volume)
en_cours: Morceau | None = None
def lire_suivant(self) -> str:
self.en_cours = self.file.suivant()
if self.en_cours is None:
return "■ File vide, lecture terminée."
return f"▶ {self.en_cours}"
def etat(self) -> str:
titre = self.en_cours.titre if self.en_cours is not None else "rien"
return (f"En cours : {titre} | volume {self.volume.niveau}/100 | "
f"{len(self.file)} morceau(x) en attente")
lecteur = Lecteur()
for titre, duree in [("Intro", 60), ("Thème", 200), ("Final", 150)]:
lecteur.file.ajouter(Morceau(titre, duree))
lecteur.volume.monter(30)
print(lecteur.lire_suivant())
print(lecteur.lire_suivant())
print(lecteur.etat())
print(lecteur.lire_suivant())
print(lecteur.lire_suivant())
Corrigé de l'exercice 8
class CompteBancaire:
def __init__(self, titulaire: str) -> None:
self.titulaire = titulaire
self._solde = 0.0
self.historique: list[str] = []
@property
def solde(self) -> float:
return self._solde
def deposer(self, montant: float) -> None:
if montant <= 0:
raise ValueError("le montant doit être positif")
self._solde += montant
self.historique.append(f"+{montant:.2f}")
def retirer(self, montant: float) -> None:
if montant <= 0:
raise ValueError("le montant doit être positif")
if montant > self._solde:
raise ValueError("solde insuffisant")
self._solde -= montant
self.historique.append(f"-{montant:.2f}")
def __str__(self) -> str:
return f"{type(self).__name__} de {self.titulaire} : {self._solde:.2f} €"
class CompteEpargne(CompteBancaire):
def __init__(self, titulaire: str, taux: float, plafond: float = 22_950) -> None:
super().__init__(titulaire)
self.taux = taux
self.plafond = plafond
def deposer(self, montant: float) -> None:
if self._solde + montant > self.plafond:
raise ValueError(f"plafond de {self.plafond:.2f} € dépassé")
super().deposer(montant)
def appliquer_interets(self) -> None:
interets = round(self._solde * self.taux / 12, 2)
if interets > 0:
super().deposer(interets) # on contourne volontairement le plafond
class CompteCourant(CompteBancaire):
def __init__(self, titulaire: str, decouvert: float = 0) -> None:
super().__init__(titulaire)
self.decouvert = decouvert
def retirer(self, montant: float) -> None:
if montant <= 0:
raise ValueError("le montant doit être positif")
if montant > self._solde + self.decouvert:
raise ValueError(f"découvert de {self.decouvert:.2f} € dépassé")
self._solde -= montant
self.historique.append(f"-{montant:.2f}")
comptes: list[CompteBancaire] = [CompteCourant("Alice", decouvert=200), CompteEpargne("Alice", taux=0.03)]
comptes[0].deposer(100)
comptes[0].retirer(250)
comptes[1].deposer(10_000)
for _ in range(3):
comptes[1].appliquer_interets()
for compte in comptes:
print(compte)
try:
comptes[1].deposer(20_000)
except ValueError as erreur:
print("Refusé :", erreur)
print(comptes[1].historique)
Corrigé du mini-projet
"""Petit jeu de rôle textuel : héritage, composition, propriétés, typage."""
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
# ---------- Objets et inventaire (composition) ----------
@dataclass
class Objet:
nom: str
description: str = ""
def utiliser(self, porteur: "Personnage") -> str:
return f"{porteur.nom} regarde {self.nom}, sans effet."
@dataclass
class Potion(Objet):
soin: int = 15
def utiliser(self, porteur: "Personnage") -> str:
avant = porteur.pv
porteur.pv += self.soin
return f"{porteur.nom} boit {self.nom} (+{porteur.pv - avant} PV)"
@dataclass
class Inventaire:
capacite: int = 5
objets: list[Objet] = field(default_factory=list)
def ajouter(self, objet: Objet) -> None:
if len(self.objets) >= self.capacite:
raise ValueError("inventaire plein")
self.objets.append(objet)
def retirer_premiere_potion(self) -> Potion | None:
for objet in self.objets:
if isinstance(objet, Potion):
self.objets.remove(objet)
return objet
return None
def __len__(self) -> int:
return len(self.objets)
# ---------- Hiérarchie de personnages (héritage) ----------
class Personnage(ABC):
def __init__(self, nom: str, pv_max: int, force: int, defense: int,
de: random.Random | None = None) -> None:
self.nom = nom
self.pv_max = pv_max
self._pv = pv_max
self.force = force
self.defense = defense
self.inventaire = Inventaire()
self.de = de if de is not None else random.Random()
# --- propriété : les PV restent toujours entre 0 et pv_max ---
@property
def pv(self) -> int:
return self._pv
@pv.setter
def pv(self, valeur: int) -> None:
self._pv = max(0, min(self.pv_max, valeur))
@property
def est_vivant(self) -> bool:
return self._pv > 0
@property
def classe(self) -> str:
return type(self).__name__
# --- comportements communs ---
def subir_degats(self, degats: int) -> int:
reels = max(1, degats - self.defense)
self.pv -= reels
return reels
@staticmethod
def decrire(degats: int) -> str:
return f"-{degats} PV" if degats > 0 else "esquivé !"
def attaquer(self, cible: "Personnage") -> str:
degats = cible.subir_degats(self.force + self.de.randint(0, 3))
return f"{self.nom} attaque {cible.nom} : {self.decrire(degats)}"
def jouer_tour(self, cible: "Personnage") -> str:
"""Choix automatique : se soigner si besoin, sinon action de classe."""
if self.pv < self.pv_max * 0.3:
potion = self.inventaire.retirer_premiere_potion()
if potion is not None:
return potion.utiliser(self)
return self.action_speciale(cible)
@abstractmethod
def action_speciale(self, cible: "Personnage") -> str:
"""Chaque classe de personnage DOIT définir son action."""
def __str__(self) -> str:
etat = f"{self.pv:>3}/{self.pv_max}" if self.est_vivant else "K.O."
return f"{self.nom} ({self.classe}) {etat}"
class Guerrier(Personnage):
def __init__(self, nom: str, de: random.Random | None = None) -> None:
super().__init__(nom, pv_max=45, force=8, defense=3, de=de)
def action_speciale(self, cible: Personnage) -> str:
if self.pv < self.pv_max / 2: # rage !
degats = cible.subir_degats(self.force * 2)
return f"{self.nom} entre en RAGE et frappe {cible.nom} : {self.decrire(degats)}"
return self.attaquer(cible)
class Mage(Personnage):
COUT_BOULE_DE_FEU = 10
def __init__(self, nom: str, de: random.Random | None = None) -> None:
super().__init__(nom, pv_max=30, force=4, defense=1, de=de)
self.mana = 30
def action_speciale(self, cible: Personnage) -> str:
if self.mana >= self.COUT_BOULE_DE_FEU:
self.mana -= self.COUT_BOULE_DE_FEU
degats = cible.subir_degats(14)
return f"{self.nom} lance une boule de feu sur {cible.nom} : {self.decrire(degats)}"
self.mana += 5
return self.attaquer(cible) + " (recharge son mana)"
def __str__(self) -> str:
return super().__str__() + f" mana {self.mana}"
class Voleur(Personnage):
def __init__(self, nom: str, de: random.Random | None = None) -> None:
super().__init__(nom, pv_max=35, force=6, defense=2, de=de)
def subir_degats(self, degats: int) -> int:
if self.de.random() < 0.25: # esquive
return 0
return super().subir_degats(degats)
def action_speciale(self, cible: Personnage) -> str:
if self.de.random() < 0.3:
degats = cible.subir_degats(self.force * 3)
return f"{self.nom} porte un COUP CRITIQUE à {cible.nom} : {self.decrire(degats)}"
return self.attaquer(cible)
class Monstre(Personnage):
def __init__(self, nom: str, pv_max: int, force: int, defense: int,
de: random.Random | None = None) -> None:
super().__init__(nom, pv_max, force, defense, de)
def action_speciale(self, cible: Personnage) -> str:
return self.attaquer(cible)
# ---------- Le combat ----------
def vivants(equipe: list[Personnage]) -> list[Personnage]:
return [membre for membre in equipe if membre.est_vivant]
def combat(heros: list[Personnage], monstres: list[Personnage],
de: random.Random, tours_max: int = 20) -> str:
for numero_tour in range(1, tours_max + 1):
print(f"--- Tour {numero_tour} ---")
for acteur in heros + monstres:
if not acteur.est_vivant:
continue
adversaires = vivants(monstres if acteur in heros else heros)
if not adversaires:
break
cible = de.choice(adversaires)
print(" " + acteur.jouer_tour(cible))
if not vivants(monstres):
return "Victoire des héros !"
if not vivants(heros):
return "Les monstres l'emportent..."
return "Match nul : tout le monde est épuisé."
def main(graine: int = 4) -> None:
de = random.Random(graine)
heros: list[Personnage] = [Guerrier("Brunhild", de), Mage("Elric", de), Voleur("Sly", de)]
for personnage in heros:
personnage.inventaire.ajouter(Potion("Petite potion", soin=15))
monstres: list[Personnage] = [
Monstre("Ogre", 80, 11, 3, de),
Monstre("Gobelin", 30, 7, 1, de),
Monstre("Gobelin", 30, 7, 1, de),
]
print("Héros :", ", ".join(str(p) for p in heros))
print("Monstres :", ", ".join(str(m) for m in monstres))
resultat = combat(heros, monstres, de)
print("=" * 40)
print(resultat)
for personnage in heros + monstres:
print(" ", personnage)
if __name__ == "__main__":
main()
Héros : Brunhild (Guerrier) 45/45, Elric (Mage) 30/30 mana 30, Sly (Voleur) 35/35
Monstres : Ogre (Monstre) 80/80, Gobelin (Monstre) 30/30, Gobelin (Monstre) 30/30
--- Tour 1 ---
Brunhild attaque Ogre : -7 PV
Elric lance une boule de feu sur Ogre : -11 PV
Sly attaque Gobelin : -6 PV
Ogre attaque Brunhild : -8 PV
Gobelin attaque Brunhild : -7 PV
Gobelin attaque Sly : -7 PV
--- Tour 2 ---
Brunhild attaque Ogre : -6 PV
Elric lance une boule de feu sur Gobelin : -13 PV
Sly attaque Gobelin : -6 PV
Ogre attaque Brunhild : -10 PV
Gobelin attaque Brunhild : -4 PV
Gobelin attaque Sly : -7 PV
--- Tour 3 ---
Brunhild entre en RAGE et frappe Ogre : -13 PV
Elric lance une boule de feu sur Ogre : -11 PV
Sly porte un COUP CRITIQUE à Gobelin : -17 PV
Ogre attaque Sly : esquivé !
Gobelin attaque Sly : -7 PV
Gobelin attaque Sly : esquivé !
--- Tour 4 ---
Brunhild entre en RAGE et frappe Gobelin : -15 PV
Elric attaque Gobelin : -3 PV (recharge son mana)
Sly porte un COUP CRITIQUE à Gobelin : -17 PV
Ogre attaque Elric : -12 PV
--- Tour 5 ---
Brunhild entre en RAGE et frappe Ogre : -13 PV
Elric attaque Ogre : -4 PV (recharge son mana)
Sly attaque Ogre : -4 PV
Ogre attaque Brunhild : -10 PV
--- Tour 6 ---
Brunhild boit Petite potion (+15 PV)
Elric lance une boule de feu sur Ogre : -11 PV
========================================
Victoire des héros !
Brunhild (Guerrier) 21/45
Elric (Mage) 18/30 mana 0
Sly (Voleur) 14/35
Ogre (Monstre) K.O.
Gobelin (Monstre) K.O.
Gobelin (Monstre) K.O.
Récapitulatif¶
- Les annotations de type documentent ton code :
nom: str,def f(x: int) -> float:,list[int],dict[str, float],tuple[int, int],X | None,-> None. Python les ignore à l'exécution ; mypy (et VS Code) les vérifie sans lancer le programme. - L'héritage (
class Enfant(Parent):) permet de réutiliser le code d'une classe. L'enfant peut ajouter des méthodes et en redéfinir.super()donne accès à la version du parent, notammentsuper().__init__(...), à ne jamais oublier. isinstance(objet, Classe)tient compte de l'héritage ; toutes les classes héritent deobject. Les exceptions personnalisées héritent deException.- Le polymorphisme : des objets différents répondent à la même méthode chacun à leur façon ; le code qui les utilise n'a pas à connaître leur type exact.
- Une classe abstraite (
ABC+@abstractmethod) impose aux sous-classes de définir certaines méthodes et ne peut pas être instanciée. - « Est un » → héritage ; « a un » → composition. Dans le doute, préfère la composition.
@propertytransforme une méthode en attribut calculé ;@nom.setterpermet de valider les affectations. La valeur réelle est stockée dans un attribut interne au nom différent (_nom).@classmethod(reçoitcls) sert surtout aux constructeurs alternatifs ;@staticmethod(ne reçoit rien) est une fonction utilitaire rangée dans la classe.@dataclassgénère__init__,__repr__et__eq__;field(default_factory=list)pour les listes ;__post_init__pour valider ; optionsorder=True,frozen=True;asdict()pour convertir en dictionnaire.
Checklist de validation¶
- Je sais annoter une fonction avec des paramètres de type
list[...],dict[..., ...]et un retourX | None. - J'ai lancé mypy sur un de mes fichiers et j'ai compris ses messages.
- Je sais créer une sous-classe qui appelle
super().__init__()et redéfinit une méthode. - Je sais expliquer le polymorphisme avec un exemple de boucle sur des objets différents.
- Je sais choisir entre héritage et composition avec le test « est un / a un ».
- Je sais écrire une propriété avec un setter qui valide la valeur, sans boucle infinie.
- Je sais quand utiliser
@classmethodet@staticmethod. - Je sais écrire une dataclass avec une liste par défaut et une validation dans
__post_init__. - Mon jeu de rôle fonctionne, est reproductible avec une graine, et passe mypy.