LYCÉE → PRÉPA · L02

Module L02 · Partie F · Coder comme un professionnel

Programmation orientée objet, pour de vrai.

Un objet, c’est des données et les opérations qui ont le droit de les toucher, réunies derrière une porte. La POO n’est pas une syntaxe : c’est une façon de découper un problème en responsabilités. Ce module va des quatre piliers aux patrons de conception que vous retrouverez dans ROS, PyTorch et tous les gros logiciels.

Durée : 3 séances · Prérequis : séance 16 du collège, module L01. Objectifs : encapsulation, héritage, polymorphisme, abstraction ; méthodes spéciales ; composition vs héritage ; dataclasses ; patrons Stratégie, Observateur, État, Fabrique ; principes SOLID.

Ce que vous saurez faire à la fin
  • Modéliser un système (robot, jeu, simulation) en classes aux responsabilités claires.
  • Choisir entre héritage et composition, et justifier.
  • Écrire une hiérarchie abstraite avec abc et un polymorphisme sans if type(x) == ….
  • Reconnaître et appliquer cinq patrons de conception.

Références : Design Patterns (Gamma et al.), Fluent Python (Ramalho), cours MIT 6.0001, freeCodeCamp « Object Oriented Programming ».

Fiche de cours · Définitions

Le vocabulaire de la programmation orientée objet

Définition (classe, instance, attribut, méthode). Une classe est un moule : elle décrit des attributs (l’état) et des méthodes (les opérations). Une instance (objet) est une valeur fabriquée par la classe ; self désigne l’instance sur laquelle une méthode est appelée.
Définition (encapsulation). Regrouper état et opérations, et n’exposer que ce qui est nécessaire (interface publique) en cachant le reste (convention _prive). L’invariant de classe est une propriété de l’état vraie après le constructeur et après chaque méthode publique (ex. « le solde est ≥ 0 », « la pile de la file est triée »).
Définition (héritage, polymorphisme). Une classe B hérite de A (B(A)) si toute instance de B est aussi une instance de A et récupère ses méthodes, qu’elle peut redéfinir. Le polymorphisme : un même appel obj.methode() exécute le code de la classe réelle de obj (liaison dynamique).
Définition (classe abstraite, interface, duck typing). Une classe abstraite déclare des méthodes sans corps (@abstractmethod) et ne peut pas être instanciée ; elle définit un contrat. En Python, le contrat peut aussi être implicite : tout objet qui a les bonnes méthodes convient (« s’il marche comme un canard… »). Un Protocol (typing) rend ce contrat vérifiable statiquement.
Définition (composition vs héritage). Composition : un objet possède un autre (self.moteur = Moteur()). Héritage : un objet est un autre. Règle : préférer la composition ; hériter seulement si la relation « est un » est vraie et que le code appelant doit pouvoir substituer.

Fiche de cours · Formules

Les règles et « formules » de conception

PrincipeÉnoncéTest rapide
S — Responsabilité uniqueUne classe a une seule raison de changerPouvez-vous décrire la classe sans « et » ?
O — Ouvert/ferméOuvert à l’extension, fermé à la modificationAjouter un cas oblige-t-il à modifier un if isinstance ?
L — Substitution de LiskovToute instance de B doit pouvoir remplacer une instance de A sans casser le programmeB affaiblit-elle une postcondition ou renforce-t-elle une précondition de A ? Alors ce n’est pas un héritage valide
I — Ségrégation des interfacesPlusieurs petites interfaces valent mieux qu’une grosseUne classe implémente-t-elle des méthodes « vides » ?
D — Inversion des dépendancesDépendre d’abstractions, pas d’implémentationsPeut-on tester la classe avec un faux capteur ?
MRO (ordre de résolution des méthodes) : Python cherche obj.m dans classe(obj), puis ses parents, dans l’ordre Classe.__mro__ (linéarisation C3) super().m() appelle le suivant dans cet ordre, pas forcément la classe parente écrite

Méthodes spéciales à connaître : __init__ (construction), __repr__ (débogage, non ambigu) / __str__ (affichage), __eq__ + __hash__ (égalité et utilisation comme clé), __lt__ (tri), __len__, __iter__, __getitem__, __add__/__mul__ (opérateurs), __enter__/__exit__ (with), __call__.

Fiche de cours · Théorèmes et démonstrations

Ce qu’on peut prouver sur des objets

Proposition 1 (invariant de classe). Si le constructeur établit une propriété I de l’état, si chaque méthode publique préserve I (I vrai avant ⇒ I vrai après), et si l’état n’est modifié que par ces méthodes, alors I est vrai à tout instant observable de la vie de l’objet.
Par récurrence sur le nombre k d’appels de méthodes publiques effectués depuis la construction. k = 0 : I vrai par le constructeur. Hérédité : avant le (k+1)-ième appel, I est vrai (hypothèse) ; l’appel préserve I ; entre deux appels, l’état ne change pas (encapsulation). Donc I est vrai après le (k+1)-ième appel. Remarque : la preuve échoue exactement là où l’encapsulation est violée — un attribut modifié de l’extérieur peut casser I. C’est la justification de _prive et des propriétés en lecture seule.
Proposition 2 (pourquoi Liskov est nécessaire). Si Carré hérite de Rectangle avec des setters indépendants de largeur et hauteur, alors il existe un programme correct pour Rectangle qui devient incorrect pour Carré.
Le programme r.largeur = 2; r.hauteur = 3; assert r.aire() == 6 est correct pour tout Rectangle (postcondition des setters : l’autre dimension est inchangée). Pour un Carré, le setter de hauteur doit aussi changer la largeur (sinon l’objet n’est plus un carré), donc aire() vaut 9 : l’assertion échoue. Carré renforce la postcondition des setters de manière incompatible ; la substitution est impossible. Conclusion : « est un » en géométrie n’implique pas « est un » en programmation quand il y a mutation. Solutions : objets immuables, ou Carré et Rectangle frères sous une abstraction Forme.
Proposition 3 (égalité et hachage). Si a == b alors on doit avoir hash(a) == hash(b) ; sinon les dict et set sont incohérents.
Un dict range a dans la case d’indice hash(a) mod m. Une recherche de b regarde la case hash(b) mod m ; si elle diffère de celle de a, b n’est pas trouvé bien que b == a. D’où : redéfinir __eq__ impose de redéfinir __hash__ sur les mêmes attributs (Python met d’ailleurs __hash__ = None automatiquement quand on redéfinit seulement __eq__). Un objet mutable utilisé comme clé viole cette règle dès qu’il change : ne hacher que des objets immuables (@dataclass(frozen=True)).

Fiche de cours · Méthodes

Méthodes de conception et pièges

Méthode — concevoir une classe. (1) Nommer le concept (un nom, pas un verbe). (2) Lister l’état minimal et son invariant. (3) Lister les opérations en termes du domaine (deposer, pas set_solde). (4) Décider ce qui est immuable. (5) Écrire __repr__ et __eq__ dès le début (tests lisibles). (6) Écrire les tests de l’invariant avant l’implémentation.
Méthode — remplacer un if isinstance par du polymorphisme. Chaque branche du if devient une méthode de la sous-classe correspondante ; l’appelant écrit obj.action() sans connaître le type. Ajouter un type n’exige plus de modifier l’appelant (principe ouvert/fermé).
Méthode — choisir entre héritage et composition. Poser deux questions : « toute instance de B est-elle utilisable partout où A l’est ? » (Liskov) et « B a-t-elle besoin de l’implémentation de A, ou seulement de son interface ? ». Si la réponse à la première est non, ou à la seconde « seulement l’interface », composer (ou utiliser une classe abstraite commune).

Pièges : attribut de classe mutable partagé par toutes les instances (class A: items = []) ; oublier super().__init__() ; redéfinir __eq__ sans __hash__ ; hériter pour « réutiliser du code » ; classes « Manager » fourre-tout ; getters/setters triviaux au lieu d’attributs publics ou de @property.

Fiche de cours · Exercices corrigés

Exercices corrigés

Exercice 1. On écrit une classe Pile avec empiler, depiler, sommet, est_vide. Énoncer l’invariant, les préconditions, et prouver que depiler renvoie le dernier élément empilé non encore dépilé.
Correction. État : une liste _t. Invariant : _t contient les éléments non dépilés, dans l’ordre d’empilement (le dernier empilé en dernière position). Précondition de depiler/sommet : pile non vide (sinon lever IndexError). Preuve : empiler(x) fait _t.append(x) — x devient le dernier, invariant conservé. depiler fait _t.pop() qui retire et renvoie le dernier élément de _t, qui est par l’invariant le dernier empilé non dépilé ; les autres restent dans l’ordre : invariant conservé.
Exercice 2. Une hiérarchie Capteur (abstrait, lire()), Ultrason, Lidar ; un Robot possède une liste de capteurs et une méthode distance_min(). Écrire le code sans aucun isinstance, puis expliquer comment ajouter une Camera sans toucher à Robot.
Correction.
from abc import ABC, abstractmethod
class Capteur(ABC):
    @abstractmethod
    def lire(self) -> float: ...          # distance en m
class Ultrason(Capteur):
    def lire(self): return 0.42            # (mesure réelle sur PC)
class Lidar(Capteur):
    def lire(self): return 1.7
class Robot:
    def __init__(self, capteurs): self.capteurs = list(capteurs)
    def distance_min(self): return min(c.lire() for c in self.capteurs)
Ajouter class Camera(Capteur) avec sa méthode lire suffit : Robot n’appelle que le contrat lire(). C’est le principe ouvert/fermé rendu concret par le polymorphisme.
Exercice 3. Expliquer pourquoi @dataclass(frozen=True) class Point: x: float; y: float peut servir de clé de dictionnaire, et pourquoi la version non gelée ne le peut pas.
Correction. Avec frozen=True, dataclass génère __eq__ (comparaison champ à champ) et __hash__ (hachage du tuple des champs), et interdit la mutation : la cohérence égalité/hachage (Proposition 3) est garantie pour toute la vie de l’objet. Sans frozen, dataclass génère __eq__ mais met __hash__ = None : l’objet est non hachable (TypeError), précisément parce qu’une mutation après insertion rendrait le dict incohérent.

01 / Les quatre piliers

Encapsulation : l’objet protège son état

Pourquoi cacher ?

Si tout le programme peut écrire batterie._charge = -50, il faut vérifier la validité partout. Si seule la classe peut l’écrire, on vérifie une fois, dans le setter. L’invariant « 0 ≤ charge ≤ max » est garanti par construction. Python ne force rien (pas de private comme en Java/C++), mais la convention _nom est respectée par tous, et @property permet de commencer avec un attribut simple et d’ajouter une validation plus tard sans changer l’interface. Un double underscore __nom déclenche le « name mangling » (_Batterie__nom) : à réserver pour éviter les collisions dans les hiérarchies.

01 / Les quatre piliers

Héritage et polymorphisme : un même message, des réponses différentes

Ce que le polymorphisme remplace

Sans lui, on écrirait if isinstance(f, Rectangle): … elif isinstance(f, Disque): … partout où on manipule des formes, et il faudrait modifier tous ces if à chaque nouvelle forme. Avec lui, ajouter Triangle = écrire une classe, point. C’est le principe ouvert/fermé : ouvert à l’extension, fermé à la modification. Le MRO (method resolution order) est l’ordre dans lequel Python cherche une méthode : Carre → Rectangle → Forme → object.

01 / Les quatre piliers

Abstraction : forcer le contrat avec abc

Une classe abstraite ne s’instancie pas ; elle dit « voici ce que mes filles doivent fournir ». Le code qui utilise un Capteur n’a besoin que du contrat, jamais des détails. En C++ : méthodes virtuelles pures ; en Java : interfaces ; en OCaml : signatures de modules.

02 / Le modèle objet de Python

Méthodes spéciales : parler le langage de Python

Tableau des méthodes spéciales
SyntaxeMéthode
str(x), print(x)__str__ (sinon __repr__)
x + y, x - y, x * y, x / y, x // y, x % y, x ** y__add__, __sub__, __mul__, __truediv__, __floordiv__, __mod__, __pow__
x < y, x <= y, x == y__lt__, __le__, __eq__
len(x), x[i], x[i] = v, i in x__len__, __getitem__, __setitem__, __contains__
for a in x__iter__ (ou __getitem__)
x()__call__
with x:__enter__, __exit__
x.attr inconnu__getattr__

C’est ainsi que NumPy fait a @ b, que pathlib fait Path("a") / "b", et que PyTorch rend tenseur.backward() possible. Une classe bien écrite se comporte comme un type natif.

02 / Le modèle objet de Python

Dataclasses, gestionnaires de contexte, itérateurs

Quand utiliser quoi
  • dataclass : un objet qui est surtout des données (mesure, point, configuration). Génère __init__, __repr__, __eq__, et order=True les comparaisons ; frozen=True le rend hashable et immuable.
  • Gestionnaire de contexte (with) : tout ce qui s’ouvre et doit se fermer — fichier, port série, verrou, chronomètre, connexion. Garantit la fermeture même en cas d’exception.
  • Itérateur : un objet qu’on peut parcourir. __iter__ renvoyant un générateur est la façon la plus simple.
  • NamedTuple (typing.NamedTuple) : un tuple avec des noms, encore plus léger qu’une dataclass gelée.

03 / Conception

Composition plutôt qu’héritage

La règle

Héritez quand la relation est « est un » et que la sous-classe peut remplacer la classe mère partout (principe de substitution de Liskov). Composez quand la relation est « a un » ou « utilise un ». L’héritage crée un couplage fort : changer la mère change toutes les filles. La composition permet d’assembler et de remplacer des pièces — comme un vrai robot. Le célèbre contre-exemple : class Carre(Rectangle) avec des setters de largeur et hauteur indépendants viole Liskov (fixer la largeur d’un carré change sa hauteur : une fonction écrite pour Rectangle se trompera).

03 / Conception

Patron Stratégie : l’algorithme est un objet qu’on branche

Les fonctions Python étant des objets, une stratégie peut aussi être une simple fonction passée en paramètre (key= de sorted est une stratégie !). Une classe devient utile quand la stratégie a un état ou plusieurs méthodes.

03 / Conception

Patron Observateur : réagir sans coupler

Où vous le retrouverez

Les interfaces graphiques (bouton.on_click(...)), le JavaScript (addEventListener), ROS (un topic auquel des nœuds s’abonnent), Qt (signaux/slots), les interruptions matérielles (module L18). L’émetteur ne sait pas qui écoute : c’est ce découplage qui permet d’ajouter un enregistreur, un affichage, un freinage d’urgence sans modifier le capteur.

03 / Conception

Patron État : chaque état est une classe

Comparez avec la version « une grosse fonction et des if etat == … » de la séance 28 : ici chaque état est isolé, testable seul, et ajouter un état ne touche pas aux autres. Pour 3 états, c’est plus long ; pour 15 états et 40 transitions (un vrai robot), c’est la seule façon de rester sain.

03 / Conception

Fabrique et registre : créer sans connaître la classe

Principes SOLID, en une ligne chacun
  • Single responsibility : une classe, une raison de changer.
  • Open/closed : étendre sans modifier (polymorphisme, registre).
  • Liskov : une sous-classe doit pouvoir remplacer sa mère partout.
  • Interface segregation : plusieurs petites interfaces valent mieux qu’une grosse.
  • Dependency inversion : dépendre d’abstractions (Capteur), pas d’implémentations (Ultrason) ; injecter les dépendances.

Ces principes ne sont pas des lois : ce sont des heuristiques qui rendent le code facile à changer. Le test ultime : « si le client demande X demain, combien de fichiers dois-je toucher ? »

Cours

Cours 1 — Objets, classes, instances : le vocabulaire exact

TermeDéfinitionEn Python
ClasseLe moule : décrit les attributs et les méthodesclass Robot:
Instance / objetUn exemplaire concret créé à partir de la classer = Robot()
Attribut d’instanceDonnée propre à chaque objetself.vitesse = 0 dans __init__
Attribut de classeDonnée partagée par toutes les instancesNB_ROUES = 2 au niveau de la classe
MéthodeFonction attachée à la classe ; reçoit l’instance en premier argumentdef avancer(self, d):
ConstructeurMéthode appelée à la création__init__ (initialise ; __new__ crée, rarement utile)
Méthode de classe / statiqueReçoit la classe / ne reçoit rien@classmethod def depuis_json(cls, s) ; @staticmethod
PropriétéMéthode déguisée en attribut@property

Ce qui se passe vraiment. r.avancer(3) est du sucre pour Robot.avancer(r, 3) : Python cherche avancer dans type(r) puis ses parents (MRO), et passe r comme self. Un objet est un dictionnaire d’attributs (r.__dict__) plus un pointeur vers sa classe. C’est pourquoi on peut ajouter un attribut à la volée — et pourquoi __slots__ existe pour l’interdire et économiser la mémoire.

Cours

Cours 2 — Concevoir : de l’énoncé aux classes (méthode CRC)

Méthode. Pour passer d’un problème à une architecture objet : 1) souligner les noms de l’énoncé (candidats classes), les verbes (candidats méthodes) ; 2) pour chaque classe, écrire une fiche CRC : Classe, Responsabilités (ce qu’elle sait et ce qu’elle fait — 3 à 5 lignes maximum), Collaborateurs (les classes dont elle a besoin) ; 3) éliminer les classes sans responsabilité, fusionner celles qui en partagent, séparer celles qui en ont trop ; 4) décider pour chaque lien : héritage (« est un ») ou composition (« a un ») ; 5) écrire les signatures avant les corps.

ClasseResponsabilitésCollaborateurs
RobotConnaît sa pose ; coordonne capteurs et moteurs ; exécute un cycle de contrôleCapteur, Moteur, Controleur
Capteur (abstrait)Fournit une mesure ; connaît son nom et son unité
Ultrason(Capteur)Mesure une distance par échoBroche
Controleur (abstrait)Transforme des mesures en commandesCapteur (lecture), Moteur (écriture)
JournalEnregistre les événements horodatés ; exporte en CSV

Signaux d’une mauvaise conception : une classe « Gestionnaire » qui fait tout ; des isinstance en cascade (remplacer par du polymorphisme) ; une sous-classe qui redéfinit une méthode pour ne rien faire (l’héritage est faux) ; deux classes qui accèdent aux attributs privés l’une de l’autre (les fusionner ou clarifier l’interface) ; un constructeur à 12 paramètres (introduire un objet de configuration).

Cours

Cours 3 — Égalité, hachage, copie : les pièges des objets mutables

Retenez : définir __eq__ supprime le __hash__ par défaut ; si vous voulez des objets égaux et utilisables comme clés, définissez les deux sur des attributs qui ne changent pas, ou utilisez @dataclass(frozen=True) qui fait tout correctement.

TP guidé

TP — Simulateur de robot en objets, testé (sur PC, 1 h 30)

Exercices

Exercices auto-corrigés — classes et méthodes spéciales

Exercice 1 — Une classe Duree

Complétez Duree (secondes entières) : construction, __add__, __lt__, __eq__, __str__ au format h:mm:ss, et une méthode de classe depuis_texte("1:02:03").

Correction
class Duree:
    def __init__(self, secondes):
        if secondes < 0: raise ValueError("durée négative")
        self.secondes = int(secondes)
    def __add__(self, o): return Duree(self.secondes + o.secondes)
    def __lt__(self, o): return self.secondes < o.secondes
    def __eq__(self, o): return isinstance(o, Duree) and self.secondes == o.secondes
    def __str__(self):
        h, r = divmod(self.secondes, 3600); m, s = divmod(r, 60); return f"{h}:{m:02d}:{s:02d}"
    @classmethod
    def depuis_texte(cls, t):
        h, m, s = map(int, t.split(":")); return cls(3600 * h + 60 * m + s)

Exercice 2 — Polymorphisme sans isinstance

Trois formes de messages arrivent d’un robot : Position(x, y), Batterie(pourcent), Alerte(texte). Écrivez une classe de base Message avec une méthode abstraite resume(), les trois sous-classes, et la fonction rapport(messages) qui concatène les résumés — sans aucun isinstance.

Correction
class Message(ABC):
    @abstractmethod
    def resume(self) -> str: ...
class Position(Message):
    def __init__(self, x, y): self.x, self.y = x, y
    def resume(self): return f"pos({self.x}, {self.y})"
class Batterie(Message):
    def __init__(self, p): self.p = p
    def resume(self): return f"batt {self.p}%"
class Alerte(Message):
    def __init__(self, t): self.t = t
    def resume(self): return f"ALERTE {self.t}"
def rapport(messages): return " | ".join(m.resume() for m in messages)

Exercices

Exercices auto-corrigés — patrons de conception

Exercice 3 — Observateur avec désabonnement

Complétez Evenement : abonner(cb) renvoie une fonction qui désabonne ; emettre(*args) appelle les abonnés dans l’ordre d’abonnement ; un abonné qui lève une exception ne doit pas empêcher les suivants (l’exception est comptée dans erreurs).

Correction
    def abonner(self, cb):
        self._abonnes.append(cb)
        def desabonner():
            if cb in self._abonnes: self._abonnes.remove(cb)
        return desabonner
    def emettre(self, *args):
        for cb in list(self._abonnes):
            try: cb(*args)
            except Exception: self.erreurs += 1

Exercice 4 — Fabrique par registre

Un décorateur @forme("cercle") enregistre des classes ; creer(description) construit l’objet à partir d’un dictionnaire {"type": ..., **params} et lève KeyError avec le nom inconnu sinon. Toutes les formes ont aire().

Correction
def forme(nom):
    def enregistrer(cls): REGISTRE[nom] = cls; return cls
    return enregistrer
def creer(description):
    d = dict(description); t = d.pop("type")
    if t not in REGISTRE: raise KeyError(t)
    return REGISTRE[t](**d)

04 / Défis

Défi ★ — Une hiérarchie de comptes

Consigne

Classe abstraite Compte (solde encapsulé, deposer, retirer abstrait, __repr__). CompteCourant : découvert autorisé jusqu’à −500. Livret : jamais négatif, méthode interets(taux). Une fonction total(comptes) polymorphe. Tests pour chaque règle.

Correction
class Compte(ABC):
    def __init__(self, titulaire, solde=0.0):
        self.titulaire, self._solde = titulaire, float(solde)
    @property
    def solde(self): return self._solde
    def deposer(self, m):
        if m <= 0: raise ValueError("montant positif attendu")
        self._solde += m
    @abstractmethod
    def retirer(self, m) -> bool: ...
    def __repr__(self): return f"{type(self).__name__}({self.titulaire}, {self._solde:.2f})"

class CompteCourant(Compte):
    DECOUVERT = -500
    def retirer(self, m):
        if self._solde - m < self.DECOUVERT: return False
        self._solde -= m; return True

class Livret(Compte):
    def retirer(self, m):
        if m > self._solde: return False
        self._solde -= m; return True
    def interets(self, taux): self._solde *= 1 + taux

def total(comptes): return sum(c.solde for c in comptes)

c, l = CompteCourant("Ana", 100), Livret("Ana", 1000)
assert c.retirer(550) and c.solde == -450 and not c.retirer(100)
assert not l.retirer(2000) and l.retirer(1000) and l.solde == 0
l.deposer(100); l.interets(0.03); assert abs(l.solde - 103) < 1e-9
assert total([c, l]) == -450 + 103

04 / Défis

Défi ★★ — Un simulateur de jeu à composants

Consigne

Modélisez un jeu où des entités (joueur, monstre, tourelle) sont composées de composants : Position, Vie, Attaque, Deplacement. Une entité n’a pas forcément tous les composants (la tourelle ne se déplace pas). Une boucle de jeu de 10 tours : les entités avec Deplacement se rapprochent du joueur, celles avec Attaque frappent si à distance ≤ 1, la vie descend, les morts disparaissent. Pas d’héritage entre entités : uniquement de la composition et un dictionnaire de composants.

Correction (extrait)
from dataclasses import dataclass
@dataclass
class Position: x: int; y: int
@dataclass
class Vie: points: int
@dataclass
class Attaque: degats: int
@dataclass
class Deplacement: vitesse: int

def distance(a, b):
    pa, pb = a.composants["position"], b.composants["position"]
    return abs(pa.x - pb.x) + abs(pa.y - pb.y)

def tour(entites, joueur):
    for e in entites:
        if e is joueur: continue
        if e.a("deplacement"):
            p, pj = e.composants["position"], joueur.composants["position"]
            v = e.composants["deplacement"].vitesse
            p.x += max(-v, min(v, pj.x - p.x)); p.y += max(-v, min(v, pj.y - p.y))
        if e.a("attaque") and distance(e, joueur) <= 1:
            joueur.composants["vie"].points -= e.composants["attaque"].degats
    entites[:] = [e for e in entites if not e.a("vie") or e.composants["vie"].points > 0]

joueur = Entite("joueur", position=Position(0, 0), vie=Vie(20))
monstre = Entite("monstre", position=Position(5, 0), vie=Vie(5), attaque=Attaque(2), deplacement=Deplacement(1))
tourelle = Entite("tourelle", position=Position(0, 1), attaque=Attaque(1))
entites = [joueur, monstre, tourelle]
for t in range(10):
    tour(entites, joueur)
print(joueur.composants["vie"])

C’est l’architecture Entité-Composant-Système (ECS) des moteurs de jeu (Unity, Bevy) : les données dans des composants, la logique dans des systèmes qui parcourent les entités possédant tel composant. Elle évite l’explosion des hiérarchies (« MonstreVolantQuiTire » hérite de quoi ?).

04 / Défis

Défi ★★★ — Esprit prépa : un moteur d’expressions symboliques

Consigne

Construisez une hiérarchie Expr : Const, Var, Add, Mul, Pow (exposant entier). Chaque nœud sait : s’évaluer avec un dictionnaire de valeurs, se dériver par rapport à une variable (renvoie une nouvelle Expr), se simplifier (0·x → 0, 1·x → x, x + 0 → x, constantes repliées) et s’afficher. Surchargez + * ** pour écrire x = Var("x"); f = 3 * x**2 + 2 * x + 1. Vérifiez que f.derivee("x") simplifiée affiche 6*x + 2 et que f.evaluer({"x": 2}) vaut 17.

Correction
class Const(Expr):
    def __init__(self, v): self.v = v
    def evaluer(self, env): return self.v
    def derivee(self, x): return Const(0)
    def simplifier(self): return self
    def __repr__(self): return str(self.v)

class Var(Expr):
    def __init__(self, nom): self.nom = nom
    def evaluer(self, env): return env[self.nom]
    def derivee(self, x): return Const(1 if x == self.nom else 0)
    def simplifier(self): return self
    def __repr__(self): return self.nom

class Add(Expr):
    def __init__(self, a, b): self.a, self.b = a, b
    def evaluer(self, env): return self.a.evaluer(env) + self.b.evaluer(env)
    def derivee(self, x): return Add(self.a.derivee(x), self.b.derivee(x))
    def simplifier(self):
        a, b = self.a.simplifier(), self.b.simplifier()
        if isinstance(a, Const) and isinstance(b, Const): return Const(a.v + b.v)
        if isinstance(a, Const) and a.v == 0: return b
        if isinstance(b, Const) and b.v == 0: return a
        return Add(a, b)
    def __repr__(self): return f"{self.a} + {self.b}"

class Mul(Expr):
    def __init__(self, a, b): self.a, self.b = a, b
    def evaluer(self, env): return self.a.evaluer(env) * self.b.evaluer(env)
    def derivee(self, x):              # (uv)' = u'v + uv'
        return Add(Mul(self.a.derivee(x), self.b), Mul(self.a, self.b.derivee(x)))
    def simplifier(self):
        a, b = self.a.simplifier(), self.b.simplifier()
        if isinstance(a, Const) and isinstance(b, Const): return Const(a.v * b.v)
        for u, v in ((a, b), (b, a)):
            if isinstance(u, Const):
                if u.v == 0: return Const(0)
                if u.v == 1: return v
        if isinstance(a, Const) and isinstance(b, Mul) and isinstance(b.a, Const):
            return Mul(Const(a.v * b.a.v), b.b).simplifier()
        return Mul(a, b)
    def __repr__(self):
        def p(e): return f"({e})" if isinstance(e, Add) else str(e)
        return f"{p(self.a)}*{p(self.b)}"

class Pow(Expr):
    def __init__(self, a, n): self.a, self.n = a, n
    def evaluer(self, env): return self.a.evaluer(env) ** self.n
    def derivee(self, x):              # (u^n)' = n u^(n-1) u'
        return Mul(Mul(Const(self.n), Pow(self.a, self.n - 1)), self.a.derivee(x))
    def simplifier(self):
        a = self.a.simplifier()
        if self.n == 0: return Const(1)
        if self.n == 1: return a
        if isinstance(a, Const): return Const(a.v ** self.n)
        return Pow(a, self.n)
    def __repr__(self): return f"{self.a}**{self.n}"

x = Var("x")
f = 3 * x**2 + 2 * x + 1
print(f, "=", f.evaluer({"x": 2}))
print("f' =", f.derivee("x").simplifier())

Vous venez d’écrire le cœur d’un système de calcul formel (SymPy fait la même chose avec des milliers de règles) et le cœur de la différentiation automatique qui fait tourner PyTorch : un graphe d’expressions dérivé mécaniquement par la règle de la chaîne. Module L14 pour la suite.

05 / Vérification

Un Robot et un Moteur : quelle relation ?

Deux questions supplémentaires

1. Que garantit une méthode @abstractmethod ? Qu’aucune sous-classe ne peut être instanciée sans l’avoir définie : le contrat est vérifié à la création de l’objet, pas au premier appel raté.

2. Pourquoi field(default_factory=list) et pas = [] ? Parce qu’une valeur par défaut est créée une fois à la définition : toutes les instances partageraient la même liste.

Référence

Les mots à retenir

MotDéfinition
EncapsulationCacher l’état, l’exposer par des méthodes qui maintiennent les invariants.
HéritageUne classe étend une autre ; relation « est un ».
PolymorphismeUn même appel exécute un code différent selon la classe de l’objet.
AbstractionClasse qui définit un contrat sans l’implémenter (abc).
CompositionUn objet en contient d’autres ; relation « a un ».
LiskovUne sous-classe doit être substituable à sa mère.
StratégiePatron : l’algorithme est un objet interchangeable.
ObservateurPatron : des abonnés réagissent aux événements d’un émetteur.
ÉtatPatron : chaque état est une classe avec son comportement.
FabriquePatron : créer des objets à partir d’une description.
ECSEntité-Composant-Système, architecture des moteurs de jeu.

Pour continuer

Vous concevez des systèmes

Module suivant : implémenter soi-même les structures de données — listes chaînées, arbres, tas, tables de hachage — pour comprendre ce que coûte vraiment chaque opération.

À faire chez soi

← L01SommaireL03 : Structures de données →