Chapitre 5.4.8La programmation objet

Modéliser le monde réel dans le code.

7 minutes de lecture

Jusqu'ici, on a manipulé des types simples, des nombres, du texte, des booléens. Mais dans la vraie vie, les développeurs travaillent avec des concepts autrement plus riches, un client, une commande, un produit. La programmation orientée objet, souvent abrégée POO, permet de créer ses propres types pour modéliser ces concepts.

L'idée date des années 1960 et du langage Simula, conçu en Norvège pour simuler des systèmes réels comme le trafic d'un port. Ses auteurs se sont rendu compte qu'il était plus simple de décrire un navire une bonne fois pour toutes, avec ses caractéristiques et ce qu'il sait faire, que d'éparpiller ces informations dans des dizaines de variables séparées. Soixante ans plus tard, la quasi-totalité du code que tu croises repose sur cette idée.

Modéliser le métier

Avant d'écrire la moindre ligne de code, il faut comprendre le domaine métier. C'est pour ça qu'il est crucial de briefer au maximum les développeurs : le vocabulaire utilisé, les concepts, l'organisation, le besoin actuel, les idées futures... Tout ça peut modifier la conception au plus profond du code.

On appelle Domain Driven Design (DDD) l'approche de conception visant à calquer le code sur le domaine métier de la vie réelle. Nous lui consacrerons un chapitre entier plus loin.

Exemple :

Dans l'entreprise, il y a un service marketing et un service finance. Les deux suivent les ventes, mais pas de la même façon. Le marketing veut le nombre de paiements et le chiffre d'affaires. La finance a besoin du détail des transactions pour calculer la TVA, les frais du partenaire de paiement...

En tant que développeur, plusieurs questions se posent immédiatement :

  • Le vocabulaire : le marketing parle de "paiements", la finance de "transactions". Est-ce vraiment le même concept ? Par exemple, un client doit payer son abonnement. On initie une transaction bancaire. Elle échoue. Dans 2 jours, on en lance une autre. On en déduit donc qu'un paiement est composé de plusieurs transactions. On peut maintenant modéliser cette liaison dans le code.
  • Le périmètre : le marketing ne s'intéresse pas aux remboursements ni aux fraudes. Il ne regarde que les ventes. La finance, si.
  • Les données : pour le marketing, un paiement c'est une date et un montant. Pour la finance, une transaction contient la TVA, le partenaire utilisé, les frais associés...

On va affiner pour mettre en place un langage commun entre tout le monde : le langage ubiquitaire. Cette modélisation peut prendre un paquet de temps, mais c'est un investissement qui se rembourse sur le long terme.

Mon exemple préféré que je croise au moins 2 fois par an : "produit" vs "offre". C'est une confusion classique chez les entreprises qui vendent des abonnements, où le même service se décline en une dizaine de formules commerciales.

Les classes et les objets

Une fois le domaine modélisé, on crée des types dédiés dans le code. Pour ça, on écrit une classe : la description de ce que contient et fait un concept.

Imagine un architecte qui dessine un plan pour une maison. À partir de ce plan, il peut construire tout un lotissement de maisons quasi-identiques. La classe, c'est le plan. L'objet, c'est la maison construite.

Une classe en JavaScript
class Client {
    constructor(email) {
        this.email = email
        this.transactions = []
    }

    ajouterTransaction(montant, date) {
        this.transactions.push({ montant: montant, date: date })
    }

    totalDepense() {
        let total = 0
        this.transactions.forEach(function(t) {
            total = total + t.montant
        })
        return total
    }
}

// Création d'un objet "robert" à partir de la classe Client
let robert = new Client("robert@gmail.com")
robert.ajouterTransaction(29.99, "2024-01-15")
robert.ajouterTransaction(29.99, "2024-02-15")

console.log(robert.email)  // robert@gmail.com
console.log(robert.totalDepense())  // 59.98

Le mot-clé new crée un nouvel objet à partir de la classe. En langage technique on parle "de créer une instance" ou "d'instancier" une classe. Le constructor est la fonction qui s'exécute automatiquement à la création pour initialiser l'objet. Le mot-clé this fait référence à l'objet lui-même : this.email signifie "l'email de cet objet-ci".

ajouterTransaction et totalDepense sont des fonctions attachées à la classe. Dans le contexte d'un objet, on les appelle des "méthodes" plutôt que des "fonctions". C'est exactement le même concept, juste un nom différent.

Robert est un Client de la même façon que 42 est un nombre et "toto" une chaîne de caractères. D'ailleurs, dans certains langages, les types primitifs sont aussi représentés comme des objets. Sur une string par exemple, on peut faire "bonjour".length pour connaître la longueur du texte.

L'encapsulation

Le premier intérêt de l'objet, c'est de ranger au même endroit des données et les traitements qui vont avec. Le total dépensé par Robert se calcule dans la classe Client, et nulle part ailleurs. Le jour où la règle change, parce qu'il faut désormais exclure les transactions remboursées, on modifie une seule méthode et tout le programme en profite.

C'est ce qu'on appelle l'encapsulation. L'objet expose ce qu'il sait faire et protège la façon dont il le fait. Vu de l'extérieur, on demande robert.totalDepense() sans jamais avoir à savoir comment la liste des transactions est rangée à l'intérieur.

Exemple :

Une voiture expose une pédale d'accélérateur. Tu appuies, elle avance. Tu n'as pas besoin de savoir si c'est un moteur thermique, un moteur électrique, ou quelle quantité de carburant est injectée. Le constructeur peut changer tout le moteur sans changer ta façon de conduire, tant que la pédale garde le même rôle.

Sans encapsulation, la règle de calcul se retrouve recopiée dans quinze endroits du code. On en corrige quatorze le jour où elle change, et le quinzième produit un chiffre faux pendant des mois avant que quelqu'un ne s'en aperçoive.

L'héritage

En modélisant un métier, on tombe vite sur deux concepts presque identiques, qui ne se séparent que sur quelques détails. Un client particulier et un client professionnel partagent un email et des transactions, mais le second a en plus un numéro de TVA et des conditions tarifaires. Plutôt que de tout recopier, on peut dire que ClientPro est un type particulier de Client. Techniquement, on va mettre en place un héritage des propriétés et des comportements de la classe mère Client vers la classe enfant ClientPro. Dans la plupart des langages, on dit que la classe ClientPro "étend" la classe Client, qu'elle récupère automatiquement tout ce que Client contient et sait faire, et qu'elle y ajoute de nouvelles fonctionnalités.

Pour la suite de ce chapitre, on quitte le JavaScript. PHP se prête mieux à ces concepts, parce qu'il permet de dire explicitement ce qu'on cherche justement à montrer ici. Tu devrais malgré tout réussir à lire la quasi-totalité de ce qui suit, et c'est une nouvelle preuve que les langages de programmation se ressemblent bien plus qu'on ne le croit. À la fin de ce guide, tu arriveras à lire l'essentiel du code que tu croiseras, quel que soit le langage dans lequel il est écrit.

Un héritage simple
class ClientPro extends Client
{
    public function __construct(string $email, public string $numeroTva)
    {
        parent::__construct($email);   // on appelle le constructeur de Client
    }

    public function totalDepenseHorsTaxe(): float
    {
        return $this->totalDepense() / 1.2;
    }
}

La question à se poser avant de mettre en place un héritage entre un enfant X et un parent Y : est-ce que X est un Y ?
Mais attention, "être" quelque chose ne fait pas tout. Personne n'aurait l'idée d'inventer une classe mère TrucQuiRoule au-dessus d'une boule de bowling et d'une voiture. Se ressembler sur un point ne fait pas une famille.

Autre limite à connaître, presque tous les langages refusent l'héritage multiple, où une classe descendrait de deux mères à la fois. Une classe n'a donc qu'un seul parent direct. Et c'est tant mieux ! Nous allons voir juste après que l'héritage n'est pas toujours la bonne réponse, et qu'il peut introduire de mauvaises conceptions sur le long terme. Rien n'interdit en revanche d'empiler les générations, la mère ayant à son tour une mère.

Généralisation et spécialisation

L'héritage sert surtout à préciser des concepts qui n'ont pas vraiment de sens tout seuls. Par exemple, un paiement se fait par carte bancaire ou par prélèvement, chacun avec ses règles et ses informations propres. Ces deux classes partagent beaucoup, on leur donne donc une mère commune MoyenDePaiement.

Une hiérarchie de moyens de paiement
class MoyenDePaiement
{
    public bool $estParDefaut = false;
    public string $ajouteLe = "2026-03-14";

    public function definirParDefaut(): void
    {
        $this->estParDefaut = true;
    }

    public function debiter(float $montant): void
    {
        // ... et là, on écrit quoi ?
    }
}

class CarteBancaire extends MoyenDePaiement
{
    public function __construct(
        public string $nomDuTitulaire,
        public string $numero,
        public int $cvc,
    ) {}

    public function debiter(float $montant): void { /* appel au partenaire bancaire */ }
}

class Prelevement extends MoyenDePaiement
{
    public function __construct(
        public string $iban,
        public string $referenceDuMandat,
    ) {}

    public function debiter(float $montant): void { /* génération d'un ordre SEPA */ }
}

La classe MoyenDePaiement sert de type principal pour les règles métier qui se contentent du concept général, sans avoir besoin de savoir à quoi elles ont affaire. Lister les moyens enregistrés d'un client, désigner celui à utiliser par défaut, retenir depuis quand il est là, tout ça se traite pareil pour une carte et pour un prélèvement, et s'écrit donc une seule fois dans la mère.

Sauf qu'une méthode résiste : debiter. Chaque enfant sait la remplir, la mère non, débiter une carte et générer un ordre SEPA n'ont rien en commun. Et surtout, le concept de moyen de paiement lui-même est trop vague, trop abstrait. Si tu arrives à la caisse d'un magasin en annonçant que tu vas payer avec ton moyen de paiement, on te demandera lequel tu vas concrètement utiliser.

Beaucoup de langages offrent la possibilité de déclarer explicitement la classe MoyenDePaiement comme étant abstraite. Les développeurs ne pourront alors plus créer un moyen de paiement sans préciser concrètement s'il s'agit d'une carte ou d'un prélèvement. Il suffit du mot-clé abstract, posé devant la classe et devant la méthode qu'on refuse d'écrire.

La même mère, rendue abstraite
abstract class MoyenDePaiement
{
    public bool $estParDefaut = false;

    // Écrite ici une seule fois, elle sert à tous les enfants
    public function definirParDefaut(): void
    {
        $this->estParDefaut = true;
    }

    // Déclarée, mais pas écrite, chaque enfant devra fournir sa version
    abstract public function debiter(float $montant): void;
}

new MoyenDePaiement();                          // refusé : la classe est abstraite
new CarteBancaire("Robert", "4970...", 123);    // autorisé

L'intérêt est double : on empêche la création d'objets qui n'ont aucun sens dans le métier, et on oblige chaque enfant à fournir les comportements que la mère déclare sans savoir les écrire.

Quand l'héritage se retourne contre toi

L'héritage est séduisant, et c'est justement son piège. Il fige une seule façon de classer les choses, décidée en début de projet, quand on ne connaît encore ni tous les cas ni ce que le métier demandera dans deux ans. Et tout le reste du code s'appuie ensuite dessus. Reprenons la hiérarchie de tout à l'heure.

Une hiérarchie qui paraît évidente
abstract class MoyenDePaiement
{
    abstract public function debiter(float $montant): void;
    abstract public function rembourser(float $montant): void;
}

class CarteBancaire extends MoyenDePaiement { /* numéro, cvc, date d'expiration */ }
class Prelevement extends MoyenDePaiement   { /* iban, référence du mandat */ }

Sur le papier, c'est limpide. Une carte est un moyen de paiement, un prélèvement aussi. Les ennuis commencent quand le métier évolue, ce qu'il fait toujours :

  1. Un cas qui ne rentre pas : six mois plus tard, on ajoute le paiement à la livraison. Rien n'est débité au moment de la commande, l'argent change de main plus tard, en espèces, et il n'y a rien à rembourser automatiquement. Or debiter et rembourser sont posés dans la classe mère, donc imposés à tout ce qui en descend. Pour faire entrer ce nouveau cas, il faut toucher à la racine, celle dont dépend tout le reste.
  2. Une deuxième façon de classer : arrive le paiement en trois fois. Une carte peut être en trois fois, un prélèvement aussi. Il y a donc maintenant deux manières de trier les moyens de paiement, par instrument et par échéancier, alors qu'un héritage n'en accepte qu'une puisque chaque classe n'a qu'un seul parent. Il faut choisir celle qui portera la hiérarchie, et écrire une classe pour chaque combinaison de l'autre, CarteEnTroisFois, PrelevementEnTroisFois. Et si demain un troisième critère apparaît, le paiement à trente jours réservé aux professionnels par exemple, le nombre de variantes à écrire est encore multiplié.
  3. La tentation de mal hériter : la classe d'à côté contient déjà le code dont tu as besoin, et il suffit d'en hériter pour le récupérer. C'est là que les hiérarchies tournent à l'absurde et qu'une voiture devient un TrucQuiRoule.

La composition

Les développeurs ont tiré les leçons de décennies d'héritage à outrance. Ils préfèrent maintenant "composer" des objets. Au lieu de faire descendre une classe d'une autre, on lui donne les briques dont elle a besoin, chacune responsable d'une seule chose. On ne dit plus qu'un moyen de paiement est d'un certain type, mais qu'il a un instrument et un échéancier.

La même idée, en composition
// Le moyen de paiement ne sait ni comment l'argent circule,
// ni comment la somme est découpée. Il délègue à ses briques.
class MoyenDePaiement
{
    public function __construct(
        private Instrument $instrument,   // une carte, un iban, un compte Paypal
        private Echeancier $echeancier,   // tout de suite, en trois fois, à la livraison
    ) {}

    public function encaisser(float $montant): void
    {
        foreach ($this->echeancier->decouper($montant) as $echeance) {
            $this->instrument->debiter($echeance);
        }
    }
}

$carteEnTroisFois = new MoyenDePaiement(new Carte("4970...", 123), new EnPlusieursFois(3));
$prelevement = new MoyenDePaiement(new Iban("FR76..."), new ToutDeSuite());
$paypal = new MoyenDePaiement(new ComptePaypal("robert@gmail.com"), new ToutDeSuite());

Les pièges de tout à l'heure disparaissent. Le paiement à la livraison devient un échéancier de plus, et le découpage en trois fois est écrit une seule fois et sert à tous les instruments.

Chaque brique reste indépendante, testable et remplaçable, et on peut en changer une sans toucher aux autres.

Préférer la composition à l'héritage <adage de développeur>

Ça ne condamne pas l'héritage, qui reste le bon outil quand la relation est durable et évidente. La question à se poser tient en une phrase : est-ce que cette chose est définitivement l'autre, ou est-ce qu'elle a seulement une particularité de plus ?

Pourquoi c'est important pour toi ?

Même si tu n'écriras peut-être jamais de code, comprendre cette démarche de modélisation change ta façon de communiquer avec les équipes techniques. Quand un développeur te demande de clarifier la différence entre un "produit" et une "offre", ce n'est pas du pinaillage. C'est la fondation sur laquelle tout le système repose.

Un vocabulaire flou en entrée produit un code flou en sortie. Et un code flou produit des bugs, de la dette technique et des fonctionnalités qui ne correspondent pas au besoin.

Ça explique aussi pourquoi certaines demandes qui semblent minuscules coûtent cher. "Ajouter juste un statut sur la commande" peut toucher une classe dont dépendent la facturation, les emails et les statistiques. À l'inverse, une demande qui te paraît énorme est parfois triviale, parce que le concept avait été bien modélisé dès le départ.

Prends le temps de formaliser ton vocabulaire métier, même en dehors du code. Qu'une "commande" ne désigne pas tout à fait la même chose au marketing et à la logistique n'est pas un problème, chaque métier a son modèle et le rôle des développeurs est de traduire de l'un vers l'autre. Ce qui coûte cher, c'est quand personne n'a remarqué la différence et que deux équipes croient parler du même concept.

PrécédentLa récursivité Tous les chapitres