Aller au contenu

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 utiliser super() ; - 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 :

def calculer_total(articles, remise):
    ...

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

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 !
42
haha

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")
Bonjour Léa ! Bonjour Léa ! 
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]))
{'Alice': 14.0, 'Bob': 11.0}
(1, 9)

⚠️ Dans de vieux tutoriels, tu verras from typing import List, Dict puis List[int]. Depuis Python 3.9, on écrit directement list[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 :

def formater(valeur: int | float) -> str:
    return f"{valeur:.2f}"


print(formater(3), formater(2.5))
3.00 2.50

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"))
0.5 None
Bonjour, inconnu ! Bonjour, 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)]))
992

__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)
{'Alice': [14.5]}

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

pip install mypy

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 : trouver peut renvoyer None, mais on range le résultat dans une variable annoncée int (mypy te rappelle de gérer le cas None) ;
  • ligne 11 : "vingt" n'est pas un int.

Une fois corrigé :

$ mypy mon_script.py
Success: no issues found in 1 source file

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 Mode sur basic ou standard).
  • mypy --strict mon_script.py est 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
Rex mange.
Rex fait un bruit.
Rex rapporte la balle !

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())
Rex fait : Wouf !
Félix fait : Miaou.
Nemo fait un bruit.

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))
Rex, 4 an(s), berger allemand
{'nom': 'Rex', 'age': 4, 'race': 'berger allemand'}

⚠️ Si l'enfant définit son propre __init__, celui du parent n'est plus appelé automatiquement. Oublier super().__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 toujours object : en Python, toutes les classes héritent de object, 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}")
Rectangle d'aire 12.00
Cercle d'aire 3.14
Triangle d'aire 6.00
Aire totale : 21.14

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())
Coin coin !
BIP. COIN. BIP.

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 ABC et possède au moins une méthode marquée @abstractmethod est abstraite : on ne peut pas créer d'objet directement à partir d'elle.
  • Une sous-classe qui oublie de définir aire est elle aussi abstraite : l'erreur apparaît dès la création de l'objet, et non au moment où on appelle aire() bien plus tard.
  • @abstractmethod est 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))
Renault : Moteur de 130 ch démarré
True 4

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 Moteur par MoteurElectrique qui a aussi une méthode demarrer) ;
  • 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)
[99, 1, 2]

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))
2 1

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

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)
21
69.8
Refusé : température hors limites : 45
21

Comment ça fonctionne :

  1. @property au-dessus de def temperature(self) crée une propriété : lire salon.temperature appelle cette méthode.
  2. @temperature.setter au-dessus d'une deuxième méthode du même nom définit ce qui se passe quand on écrit salon.temperature = ....
  3. 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).
  4. Dans __init__, self.temperature = temperature passe 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'écrire objet.temperature. Inutile donc d'écrire des méthodes get_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"))
Date(14, 7, 2025)
Date(3, 2, 2026)
Date(25, 12, 2026)

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)
True False True
False
date invalide : 31/4/2026

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)
Produit(nom='Stylo', prix=1.5, stock=100)
0
True
99

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))
4 x Cahier = 10.00 €
LigneCommande(produit='Cahier', prix_unitaire=2.5, quantite=4)

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)
Equipe(nom='Rouges', membres=['Léa'])
Equipe(nom='Bleus', membres=[])

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)
Produit(nom='Clavier', prix=49.9, stock=0)
Erreur : prix négatif pour souris : -5

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=True génère <, <=, >, >= : les objets se comparent attribut par attribut, dans l'ordre de déclaration (comme des tuples). sorted, min et max fonctionnent directement.
  • frozen=True rend 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)
AttributeError: 'Chien' object has no attribute '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())
TypeError: 'float' object is not callable

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

from dataclasses import dataclass


@dataclass
class Equipe:
    nom: str
    membres: list[str] = []
ValueError: mutable default <class 'list'> for field membres is not allowed: use default_factory

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

from dataclasses import dataclass


@dataclass
class Produit:
    nom: str
    prix: float = 0.0
    stock: int
TypeError: non-default argument 'stock' follows default argument 'prix'

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

def ajouter_tva(prix: float) -> float:
    return prix * 1.2


print(ajouter_tva("10"))
TypeError: can't multiply sequence by non-int of type 'float'

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 attribut electrique: bool ;
  • Voiture : un attribut supplémentaire places, et decrire() qui complète celle du parent avec super().
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 une ValueError ;
  • une propriété fahrenheit (lecture + écriture) qui convertit dans les deux sens ;
  • une propriété en lecture seule etat qui 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_texte qui 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 les n morceaux 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)
13:47
Bohemian Rhapsody 5:54
Get Lucky 4:08

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 utilise formater, 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])
['0h20min00s', '0h45min00s', '1h30min00s']
2h15min00s

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.

formes = [Cercle(1), Rectangle(2, 5), Carre(3), Cercle(2.5)]
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 avec collections.deque), méthodes ajouter, suivant() -> Morceau | None, __len__ ;
  • Volume : un niveau entre 0 et 100, avec monter(pas=10), baisser(pas=10) (bornés) ;
  • Lecteur : contient une File et un Volume, avec un attribut en_cours: Morceau | None. Méthodes lire_suivant() -> str et etat() -> 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, _solde interne, propriété en lecture seule solde, méthodes deposer(montant) et retirer(montant) (avec ValueError si montant ≤ 0 ou solde insuffisant), et un historique: list[str].
  • CompteEpargne(CompteBancaire) : ajoute un taux annuel (ex : 0.03) et un plafond (22 950 € par défaut). Redéfinis deposer pour refuser un dépôt qui dépasserait le plafond (puis appelle la version du parent avec super()). Ajoute appliquer_interets() qui dépose les intérêts d'un mois (taux / 12), arrondis au centime, en ignorant le plafond.
  • CompteCourant(CompteBancaire) : ajoute un decouvert autorisé et redéfinit retirer en 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)

  1. Une dataclass Objet (nom, description) avec une méthode utiliser(porteur).
  2. Une dataclass Potion(Objet) avec un champ soin : utiliser rend des PV au porteur.
  3. Une dataclass Inventaire (capacité, liste d'objets avec default_factory), avec ajouter (refuse si plein) et retirer_premiere_potion() -> Potion | None.

Personnages (héritage, classe abstraite, propriétés)

  1. Une classe abstraite Personnage : nom, pv_max, force, défense, un Inventaire (composition).
  2. Une propriété pv dont le setter garde toujours la valeur entre 0 et pv_max.
  3. Une propriété est_vivant et une propriété classe (le nom de la classe).
  4. subir_degats(degats) -> int : dégâts réels = dégâts − défense, minimum 1. Renvoie les dégâts réels.
  5. attaquer(cible), jouer_tour(cible) (boit une potion si PV < 30 %, sinon appelle action_speciale).
  6. Une méthode abstraite action_speciale(cible) -> str.
  7. Trois sous-classes de héros, chacune avec un __init__ qui appelle super().__init__ avec ses propres statistiques :
  8. Guerrier : quand ses PV passent sous 50 %, il entre en rage (dégâts doublés) ;
  9. Mage : possède du mana ; lance une boule de feu (dégâts fixes, coûte du mana), sinon attaque normalement et recharge ;
  10. Voleur : redéfinit subir_degats pour esquiver 25 % des attaques, et porte des coups critiques.
  11. Une classe Monstre dont l'action spéciale est une simple attaque.

Combat

  1. 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).
  2. 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 !).
  3. Tout le code est annoté et passe mypy sans erreur.

Étapes suggérées

  1. Écris Objet, Potion et Inventaire, et teste-les seuls.
  2. Écris Personnage avec la propriété pv. Teste que perso.pv = 999 est ramené à pv_max et perso.pv = -5 à 0.
  3. Écris Monstre (la sous-classe la plus simple) et fais combattre deux monstres l'un contre l'autre.
  4. Ajoute Guerrier, puis Mage, puis Voleur, en testant chacun.
  5. Écris la fonction combat et une fonction main qui prépare les équipes.
  6. Lance mypy rpg.py et 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é niveau calculée à partir de l'expérience.
  • Un Soigneur qui soigne l'allié le plus blessé : il faudra lui donner accès à son équipe.
  • Sauvegarder l'équipe en JSON avec asdict et une méthode de classe depuis_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` signale :
$ 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)
Et en effet, à l'exécution, ce code plante avec `TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'`, car Bob n'est pas dans l'annuaire. mypy l'a trouvé **sans exécuter le programme**, uniquement grâce à l'annotation `-> int | None`. Correction de l'appel :
def chercher_age(annuaire: dict[str, int], nom: str) -> int | None:
    return annuaire.get(nom)


age = chercher_age({"Alice": 30}, "Bob")
if age is None:
    print("Âge inconnu")
else:
    print(age + 1)
Âge inconnu
Après le test `if age is None`, mypy sait que dans le `else`, `age` est forcément un `int` : plus d'erreur. Remarque aussi `resultat: dict[str, int] = {}` : un dictionnaire vide doit être annoté, sinon mypy ne peut pas deviner ce qu'il contiendra.
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")
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
`Velo` n'a pas de paramètre de vitesse : il transmet lui-même la valeur `25` au parent. `temps_trajet` n'est écrite qu'**une fois**, dans `Vehicule`, et sert aux deux sous-classes. La boucle est polymorphe : elle appelle `decrire()` sans se soucier du type exact.
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)
25 77.0 liquide
100.0 vapeur
Erreur : -300 °C est sous le zéro absolu
Erreur : property 'etat' of 'Temperature' object has no setter
Une seule donnée est stockée (`_celsius`) ; `fahrenheit` et `etat` sont **calculées**. (mypy détecte d'ailleurs la tentative `t.etat = "plasma"` sans exécuter le code : `error: Property "etat" defined in "Temperature" is read-only`.) Le setter de `fahrenheit` passe par `self.celsius = ...` : la validation du zéro absolu s'applique donc aussi aux degrés Fahrenheit, sans la réécrire. `etat` n'a pas de setter : elle est en lecture seule.
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)
13:47
Bohemian Rhapsody 5:54
Get Lucky 4:08
Erreur : note invalide : 7 (0 à 5)
La fonction `formater_duree` est partagée par les deux classes : inutile de la dupliquer. La `Playlist` **contient** des `Morceau` : c'est de la composition. `field(default_factory=list)` garantit que chaque playlist a sa propre liste.
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)
['0h20min00s', '0h45min00s', '1h30min00s']
2h15min00s
2h -> 2h00min00s
1h05min -> 1h05min00s
90s -> 0h01min30s
3h10 -> 3h10min00s
Erreur : durée illisible : 'deux heures'
Points clés : - `depuis_texte` et `depuis_minutes` sont des **constructeurs alternatifs** : ils fabriquent un objet `Duree` à partir d'autres données, et s'appellent sur la classe (`Duree.depuis_texte(...)`). - `formater` ne dépend d'aucun objet : c'est une **méthode statique**, qu'on peut aussi utiliser seule (`Duree.formater(3600)`). - `@dataclass(order=True)` génère les comparaisons à partir de l'unique champ `secondes`, d'où `sorted` qui fonctionne sans écrire `__lt__`. - `__add__` est la méthode spéciale appelée par l'opérateur `+`. - L'analyse du texte lit les caractères un par un : les chiffres s'accumulent dans `nombre`, et chaque lettre d'unité (`h`, `m`, `s`) « consomme » le nombre accumulé. On remplace d'abord `min` par `m` pour n'avoir que des unités d'une lettre. (Pour des formats plus complexes, on utiliserait des *expressions régulières*, module `re`, un sujet à part entière.)
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)
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
`Carre` hérite de `Rectangle` et se contente d'appeler `super().__init__(cote, cote)` : il récupère gratuitement `aire` et `perimetre`. `__str__` utilise `type(self).__name__`, qui donne le nom de la **vraie** classe de l'objet, d'où `Carre` et non `Rectangle`. Attention, ce modèle a une limite connue : si on modifie `carre.largeur = 10`, l'objet n'est plus un carré. Pour des formes **modifiables**, la composition ou une classe `Carre` indépendante serait plus sûre ; pour des formes qu'on ne modifie pas après création, l'héritage est ici élégant.
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())
▶ 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.
$ mypy lecteur.py
Success: no issues found in 1 source file
Aucune classe n'hérite d'une autre : tout est **composition**. Le `Lecteur` **a une** file et **un** volume, et leur délègue le travail. `field(default_factory=File)` fonctionne avec n'importe quelle classe, pas seulement `list`. L'annotation `Morceau | None` force à vérifier `is None` avant d'utiliser `en_cours`, et mypy le contrôle.
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)
CompteCourant de Alice : -150.00 €
CompteEpargne de Alice : 10075.19 €
Refusé : plafond de 22950.00 € dépassé
['+10000.00', '+25.00', '+25.06', '+25.13']
Points clés : - `CompteEpargne.deposer` **ajoute une vérification** puis délègue le reste au parent avec `super().deposer(montant)` : la validation « montant positif » et l'historique ne sont pas réécrits. - `appliquer_interets` appelle `super().deposer(...)` **directement**, ce qui saute la vérification du plafond de `CompteEpargne` : les intérêts peuvent faire dépasser le plafond, comme sur un vrai livret. - Les intérêts sont composés : chaque mois, ils sont calculés sur un solde un peu plus élevé (25,00 puis 25,06 puis 25,13). - `comptes: list[CompteBancaire]` contient des sous-classes différentes : c'est le polymorphisme. Mais attention, mypy signale `comptes[1].appliquer_interets()` : pour lui, `comptes[1]` est un `CompteBancaire`, qui n'a pas cette méthode ! Le programme fonctionne, mais mypy affiche `error: "CompteBancaire" has no attribute "appliquer_interets" [attr-defined]`. Une solution propre consiste à garder une variable typée précisément : `epargne = CompteEpargne(...)`, ou à tester `if isinstance(compte, CompteEpargne):` avant l'appel.
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()
Sortie avec la graine 4 (identique à chaque exécution) :
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.
Vérification des types, même en mode strict :
$ mypy --strict rpg.py
Success: no issues found in 1 source file
Points importants : - **La propriété `pv`** est le cœur de la robustesse : `porteur.pv += self.soin` dans `Potion` ou `self.pv -= reels` dans `subir_degats` ne peuvent jamais sortir des bornes, quel que soit le code qui modifie les PV. Le calcul `porteur.pv - avant` donne le soin **réel** (une potion ne soigne pas au-delà du maximum). - **Héritage** : chaque héros ne définit que ce qui le distingue. Le `Voleur` redéfinit `subir_degats` et appelle `super().subir_degats(...)` quand il n'esquive pas ; le `Mage` complète `__str__` avec `super().__str__()`. - **Classe abstraite** : impossible de créer un `Personnage()` générique, et une nouvelle classe qui oublierait `action_speciale` serait refusée dès sa création. - **Polymorphisme** : `combat` et `jouer_tour` appellent `action_speciale` sans jamais tester le type du personnage. Ajouter une classe `Paladin` ne demande **aucune** modification de `combat`. - **Composition** : chaque personnage **a un** `Inventaire`, qui **a des** `Objet`. `Potion` hérite d'`Objet` (une potion **est un** objet), mais `Personnage` n'hérite pas d'`Inventaire`. - **Hasard reproductible** : un seul objet `random.Random(graine)` est partagé par tout le jeu. Avec la même graine, on obtient exactement le même combat, ce qui permet de reproduire un bug. Change la graine dans `main(...)` pour voir d'autres combats. - `decrire` est une **méthode statique** : elle ne dépend d'aucun personnage, mais elle appartient logiquement à la classe. - Les annotations entre guillemets (`"Personnage"`) sont nécessaires dans `Objet` et `Potion`, car la classe `Personnage` est définie **plus bas** dans le fichier.

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, notamment super().__init__(...), à ne jamais oublier.
  • isinstance(objet, Classe) tient compte de l'héritage ; toutes les classes héritent de object. Les exceptions personnalisées héritent de Exception.
  • 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.
  • @property transforme une méthode en attribut calculé ; @nom.setter permet de valider les affectations. La valeur réelle est stockée dans un attribut interne au nom différent (_nom).
  • @classmethod (reçoit cls) sert surtout aux constructeurs alternatifs ; @staticmethod (ne reçoit rien) est une fonction utilitaire rangée dans la classe.
  • @dataclass génère __init__, __repr__ et __eq__ ; field(default_factory=list) pour les listes ; __post_init__ pour valider ; options order=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 retour X | 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 @classmethod et @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.

← Chapitre précédent | Sommaire | Chapitre suivant →