POO vs Fonctionnel — 01 — Les piliers de la POO : encapsulation, héritage, polymorphisme

Encapsulation, héritage, polymorphisme et abstraction expliqués avec des exemples TypeScript et Python. Forces, limites et pièges courants de la POO.

01 — Les piliers de la POO : encapsulation, héritage, polymorphisme

Ce que tu vas apprendre

  • Les 4 piliers de la POO et ce qu'ils résolvent vraiment
  • Les pièges de l'héritage (Fragile Base Class, God Object)
  • Comment le polymorphisme remplace les switch/if imbriqués
  • Exemples complets TypeScript et Python

Prérequis


Pilier 1 : L'encapsulation

L'encapsulation consiste à regrouper données et comportements dans un objet, et à contrôler ce qui est accessible de l'extérieur.

L'objectif : protéger l'intégrité de l'état interne. Un objet ne devrait jamais se retrouver dans un état incohérent depuis l'extérieur.

typescript// Sans encapsulation — l'état peut être corrompé depuis n'importe où
class Order {
  status: string;
  items: any[];
  total: number;
}

const order = new Order();
order.status = "shipped";   // Passe de "pending" à "shipped" sans passer par "confirmed" — bug
order.total = -500;          // Total négatif — incohérent
typescript// Avec encapsulation — les règles sont appliquées par l'objet
class Order {
  private _status: "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";
  private _items: OrderItem[];
  private _total: number;

  constructor(items: OrderItem[]) {
    if (items.length === 0) throw new Error("Commande vide");
    this._status = "pending";
    this._items = [...items];
    this._total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  confirm(): void {
    if (this._status !== "pending") {
      throw new Error(`Impossible de confirmer une commande "${this._status}"`);
    }
    this._status = "confirmed";
  }

  get status(): string { return this._status; }
  get total(): number { return this._total; }
}

En Python :

pythonclass Order:
    TRANSITIONS = {
        "pending": {"confirmed", "cancelled"},
        "confirmed": {"shipped", "cancelled"},
        "shipped": {"delivered"},
        "delivered": set(),
        "cancelled": set(),
    }

    def __init__(self, items: list) -> None:
        if not items:
            raise ValueError("Commande vide")
        self._status = "pending"
        self._items = list(items)
        self._total = sum(i["price"] * i["quantity"] for i in items)

    def _transition(self, next_status: str) -> None:
        if next_status not in self.TRANSITIONS[self._status]:
            raise ValueError(f"Transition invalide : {self._status}{next_status}")
        self._status = next_status

    def confirm(self) -> None:
        self._transition("confirmed")

    @property
    def status(self) -> str:
        return self._status

    @property
    def total(self) -> float:
        return self._total

L'encapsulation ne cache pas de l'information — elle protège des invariants.

Un objet dont l'état peut être modifié librement depuis l'extérieur n'est qu'un dictionnaire avec des méthodes.


Pilier 2 : L'héritage

L'héritage permet à une classe d'hériter des propriétés et méthodes d'une classe parente. C'est le mécanisme de réutilisation de code historique de la POO.

typescriptclass Animal {
  constructor(protected name: string) {}

  describe(): string {
    return `Je suis ${this.name}`;
  }
}

class Dog extends Animal {
  speak(): string {
    return `${this.name} dit : Woof !`;
  }
}

class Cat extends Animal {
  speak(): string {
    return `${this.name} dit : Miaou !`;
  }
}

const dog = new Dog("Rex");
console.log(dog.describe()); // "Je suis Rex"
console.log(dog.speak());    // "Rex dit : Woof !"

Les pièges de l'héritage

L'héritage est l'une des fonctionnalités les plus mal utilisées de la POO. Deux problèmes récurrents :

Fragile Base Class : modifier la classe parente casse silencieusement les classes filles.

typescriptclass Vehicle {
  startEngine(): void {
    console.log("Moteur démarré");
    this.onEngineStart(); // Méthode que les sous-classes peuvent surcharger
  }

  protected onEngineStart(): void {}
}

class ElectricCar extends Vehicle {
  protected onEngineStart(): void {
    this.chargeCheck(); // Vérifie la batterie au démarrage
  }

  private chargeCheck(): void {
    console.log("Batterie : 80%");
  }
}

// Si Vehicle.startEngine() est modifié pour ne plus appeler onEngineStart()
// ElectricCar.chargeCheck() n'est plus jamais appelé — bug silencieux

God Object via héritage profond : des hiérarchies de 4-5 niveaux rendent le code illisible.

typescript// Éviter — hiérarchie trop profonde
class BaseEntity extends AbstractModel extends DataObject extends Serializable extends Cacheable {
  // Qui sait ce qu'on hérite vraiment ?
}

La règle reconnue dans l'industrie, popularisée par le GoF et par Eric Gamma lui-même après coup :

"Favor object composition over class inheritance." — Gamma, Helm, Johnson, Vlissides, Design Patterns (1994)

Préférer la composition :

typescript// Composition — explicite et découplé
class ElectricCar {
  private engine: Engine;
  private battery: Battery;

  constructor(engine: Engine, battery: Battery) {
    this.engine = engine;
    this.battery = battery;
  }

  start(): void {
    this.battery.check();
    this.engine.start();
  }
}
python# Python — composition
class ElectricCar:
    def __init__(self, engine: 'Engine', battery: 'Battery') -> None:
        self._engine = engine
        self._battery = battery

    def start(self) -> None:
        self._battery.check()
        self._engine.start()

Pilier 3 : Le polymorphisme

Le polymorphisme permet à différents objets de répondre à la même interface de manière appropriée à leur type.

C'est le pilier qui remplace les switch/if imbriqués sur des types.

Sans polymorphisme

typescriptfunction processPayment(payment: { type: string; amount: number }) {
  if (payment.type === "card") {
    console.log(`Paiement par carte : ${payment.amount}€`);
    // appel Stripe
  } else if (payment.type === "paypal") {
    console.log(`Paiement PayPal : ${payment.amount}€`);
    // appel PayPal API
  } else if (payment.type === "virement") {
    console.log(`Virement bancaire : ${payment.amount}€`);
    // logique SEPA
  }
  // Ajouter un nouveau type = modifier cette fonction
}

Avec polymorphisme

typescriptinterface PaymentMethod {
  process(amount: number): Promise<PaymentResult>;
  describe(): string;
}

class CardPayment implements PaymentMethod {
  constructor(private readonly cardToken: string) {}

  async process(amount: number): Promise<PaymentResult> {
    // Appel Stripe
    return { success: true, transactionId: "ch_xxx" };
  }

  describe(): string {
    return "Carte bancaire";
  }
}

class PayPalPayment implements PaymentMethod {
  constructor(private readonly email: string) {}

  async process(amount: number): Promise<PaymentResult> {
    // Appel PayPal
    return { success: true, transactionId: "PAY-xxx" };
  }

  describe(): string {
    return `PayPal (${this.email})`;
  }
}

// La fonction ne connaît pas le type concret — elle appelle l'interface
async function processPayment(method: PaymentMethod, amount: number): Promise<void> {
  const result = await method.process(amount);
  if (result.success) {
    console.log(`Paiement réussi via ${method.describe()}`);
  }
}

// Ajouter un nouveau type de paiement = ajouter une classe, pas modifier processPayment

En Python, le polymorphisme fonctionne par duck typing — pas besoin d'interface explicite :

pythonfrom abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class PaymentResult:
    success: bool
    transaction_id: str

class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount: int) -> PaymentResult:
        ...

    @abstractmethod
    def describe(self) -> str:
        ...

class CardPayment(PaymentMethod):
    def __init__(self, card_token: str) -> None:
        self._token = card_token

    def process(self, amount: int) -> PaymentResult:
        # Appel Stripe
        return PaymentResult(success=True, transaction_id="ch_xxx")

    def describe(self) -> str:
        return "Carte bancaire"

class PayPalPayment(PaymentMethod):
    def __init__(self, email: str) -> None:
        self._email = email

    def process(self, amount: int) -> PaymentResult:
        return PaymentResult(success=True, transaction_id="PAY-xxx")

    def describe(self) -> str:
        return f"PayPal ({self._email})"

def process_payment(method: PaymentMethod, amount: int) -> None:
    result = method.process(amount)
    if result.success:
        print(f"Paiement réussi via {method.describe()}")

Le polymorphisme applique le principe Open/Closed des SOLID : le code est ouvert à l'extension (ajouter une classe) mais fermé à la modification (ne pas toucher processPayment).


Pilier 4 : L'abstraction

L'abstraction consiste à exposer une interface simple qui cache la complexité d'implémentation.

typescript// L'utilisateur de Repository ne sait pas si c'est PostgreSQL, Redis, ou un fichier JSON
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

class PostgresUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    // Requête SQL, mapping, gestion des erreurs — caché
    const row = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
    return row ? mapRowToUser(row) : null;
  }

  async save(user: User): Promise<void> {
    await this.db.query(
      "INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET ...",
      [user.id, user.email, user.name]
    );
  }

  async delete(id: string): Promise<void> {
    await this.db.query("DELETE FROM users WHERE id = $1", [id]);
  }
}

En tests, on remplace PostgresUserRepository par un InMemoryUserRepository — le code métier ne le sait pas.


Ce que la POO résout bien (et ce qu'elle ne résout pas)

Forces et limites de la POO

Forces

  • Modélisation de domaines avec des entités à cycle de vie (User, Order, Session)
  • Encapsulation d'invariants complexes (transitions de statut, règles métier)
  • Polymorphisme pour l'extensibilité sans modification
  • Familiarité — la plupart des développeurs l'ont appris en premier

Limites

  • L'état mutable partagé est une source de bugs difficiles à reproduire
  • L'héritage crée des couplages forts difficiles à défaire
  • Les hiérarchies profondes rendent le code imprévisible
  • Plus difficile à paralléliser (état mutable non thread-safe par défaut)

Résumé

Pilier Ce qu'il apporte Piège courant
Encapsulation Protège les invariants Getters/setters sur tout = pas d'encapsulation
Héritage Réutilisation de code Hiérarchies profondes, Fragile Base Class
Polymorphisme Extensibilité sans modification Sur-ingénierie pour des cas simples
Abstraction Cache la complexité Abstractions prématurées

La POO n'est pas défaillante — elle est efficace sur les bons problèmes. L'enjeu est de savoir lesquels.

Étape suivante : 02 — Les piliers du fonctionnel


Sources

  • Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
  • Martin, R. C. (2000). Design Principles and Design Patterns. Object Mentor.
  • Dahl, O.-J., & Nygaard, K. (1966). SIMULA: An ALGOL-Based Simulation Language. Communications of the ACM, 9(9), 671-678.
  • Liskov, B. (1987). Data Abstraction and Hierarchy. SIGPLAN Notices, 23(5).
  • Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.