Aller au contenu

Notions avancées d'orientée objet : composition, héritage et interfaces

La programmation orientée objet prend son sens lorsqu’on fait collaborer nos objets. Dans une véritable application, c’est généralement le cas. Une commande contient des lignes, une ligne désigne un produit, un calcul de taxes s’applique au total.

Cette page montre comment des objets collaborent, comment une classe peut en spécialiser une autre, comment plusieurs classes peuvent respecter un même contrat et comment limiter les dépendances entre les objets qui composent une application.

Une commande ne connaît pas elle-même le prix des produits qui la composent : elle le demande au produit concerné. Elle contient donc des objets d’une autre classe, et s’appuie sur leurs méthodes pour faire son propre travail.

On reprend la classe Produit de la page précédente, avec son accesseur getPrix().

class LigneCommande {
#produit;
#quantite;
constructor(produit, quantite) {
this.#produit = produit;
this.#quantite = quantite;
}
sousTotal() {
return this.#produit.getPrix() * this.#quantite;
}
}
class Commande {
#lignes = [];
ajouter(produit, quantite) {
this.#lignes.push(new LigneCommande(produit, quantite));
}
total() {
return this.#lignes.reduce((somme, ligne) => somme + ligne.sousTotal(), 0);
}
}
const commande = new Commande();
commande.ajouter(new Produit("Sirop d'érable 540 ml", 14.50), 2);
console.log(commande.total()); // 29

Chaque objet a une responsabilité et collabore avec les autres pour fournir l’information nécessaire à son niveau : la ligne calcule son sous-total et la commande calcule son total en additionnant les sous-totaux de ses lignes.

Tous les produits ne sont pas toujours traités de la même façon et peuvent avoir des règles ou attributs qui diffèrent de l’ensemble des produits.

Par exemple, un produit en promotion a un rabais et une règle de calcul; un produit périssable a une date de péremption; un produit renouvelable a une fréquence de renouvellement. Chacun reste un produit, avec les mêmes caractéristiques de base (nom, prix, disponibilité), mais ajoute ou adapte quelque chose. Plutôt que de recopier la classe Produit, on crée une classe spécialisée (ex: ProduitPerissable) qui en hérite.

Un ProduitPerissable est un Produit. Il doit donc pouvoir être utilisé partout où un Produit est attendu, tout en ajoutant ou spécialisant certains comportements.

class ProduitPerissable extends Produit {
#dateLimite;
constructor(nom, prix, dateLimite) {
super(nom, prix);
this.#dateLimite = dateLimite;
}
estPerime(aujourdhui) {
return aujourdhui > this.#dateLimite;
}
prixFormate() {
return super.prixFormate() + ` (à consommer avant le ${this.#dateLimite})`;
}
}
const tire = new ProduitPerissable("Tire sur la neige", 6.25, "2026-04-30");
console.log(tire.estPerime("2026-05-02")); // true
  • extends désigne la classe parente.
  • super(...) appelle le constructeur parent ; super.methode() appelle la version parente d’une méthode redéfinie.

La classe enfant fait deux choses différentes. Elle ajoute une méthode, estPerime(), que Produit n’a pas. Elle redéfinit prixFormate(), en réutilisant la version parente puis en la complétant.

La composition se lit « a un », l’héritage « est un ». Quand la relation « est un » est douteuse, la composition s’avère le plus souvent plus souple.

Une commande n’est pas une liste de lignes : elle en a. La faire hériter d’une classe de liste lui donnerait des méthodes sans rapport avec une commande, comme trier ou vider, alors que la composition ne lui confie que ce dont elle a besoin.

L’héritage fait apparaître deux besoins voisins, que les langages outillent de deux façons différentes.

Une famille de classes partage du code, mais la classe de tête (à la racine) n’a pas toujours de sens seule. Une remise en pourcentage et une remise d’un montant fixe se décrivent de la même façon, mais « une remise » sans précision ne sait pas calculer un prix. C’est le rôle d’une classe abstraite.

class Remise {
constructor(libelle) {
if (new.target === Remise) {
throw new Error("Remise est abstraite : utilisez une sous-classe.");
}
this.libelle = libelle;
}
appliquer(prix) {
throw new Error("appliquer() doit être redéfinie par la sous-classe.");
}
decrire(prix) {
return `${this.libelle} : ${this.appliquer(prix).toFixed(2)} $`;
}
}
class RemisePourcentage extends Remise {
#taux;
constructor(libelle, taux) {
super(libelle);
this.#taux = taux;
}
appliquer(prix) {
return Math.round(prix * (1 - this.#taux) * 100) / 100;
}
}
const soldes = new RemisePourcentage("Soldes du printemps", 0.20);
console.log(soldes.decrire(14.50)); // "Soldes du printemps : 11.60 $"
  • JavaScript n’a pas de mot-clé abstract. On imite le comportement : new.target refuse l’instanciation directe, et la méthode non redéfinie lève une erreur.

decrire() est écrite une seule fois, dans la classe abstraite, et s’appuie sur appliquer() sans savoir comment chaque sous-classe la calcule. Une RemiseMontant, qui retire un montant fixe, n’aurait qu’à fournir sa propre version de appliquer().

Des classes sans lien de parenté peuvent avoir des comportements (capacités) communs. Un produit et une commande n’ont rien en commun du point de vue de l’héritage, mais tous deux doivent pouvoir s’exporter en JSON. C’est le rôle d’une interface.

// JavaScript n'a pas de mot-clé interface : un objet convient
// dès qu'il possède la méthode attendue.
class Produit {
// attributs et constructeur comme avant
versTableau() {
return { nom: this.nom, prix: this.prix };
}
}
class Commande {
// lignes et méthodes comme avant
versTableau() {
return { total: this.total() };
}
}
function exporterEnJson(objet) {
return JSON.stringify(objet.versTableau());
}
  • Sans interface, rien ne vérifie à l’avance qu’un objet possède versTableau() : l’erreur n’apparaît qu’à l’appel.
Classe abstraite Interface
Instanciable avec new non non
Attributs oui pas d’attribut ordinaire
Méthodes oui oui
Méthodes avec leur code oui, à côté des méthodes abstraites en général, non ; possible avec certains langages
Usage par classe (enfant) une seule, par extends plusieurs, par implements
Relation exprimée « est une sorte de » « sait faire »
Exemple de cette page Remise Exportable
VérificationUne classe peut-elle hériter d'une classe abstraite et implémenter une interface en même temps ?

Oui, et c’est fréquent. class RemiseSaisonniere extends Remise implements Exportable reprend le code commun des remises et s’engage en plus à pouvoir s’exporter. Les deux mécanismes répondent à des questions différentes : ce que la classe est, et ce qu’elle sait faire. La limite porte seulement sur l’héritage, qui n’admet qu’un parent ; les interfaces, elles, peuvent être multiples.

Le polymorphisme : un même contrat, plusieurs comportements

Section intitulée « Le polymorphisme : un même contrat, plusieurs comportements »

Une interface devient particulièrement utile lorsqu’on cesse de se préoccuper de la classe exacte d’un objet pour travailler plutôt avec ce qu’il sait faire.

Dans la section précédente, Produit et Commande implémentent toutes deux Exportable. Une fonction peut donc recevoir un objet Exportable sans avoir besoin de savoir s’il s’agit d’un produit, d’une commande ou d’une autre classe.

function afficherExport(objet) {
console.log(JSON.stringify(objet.versTableau()));
}
const produit = new Produit("Sirop d'érable 540 ml", 14.50);
const commande = new Commande();
afficherExport(produit);
afficherExport(commande);

afficherExport() utilise uniquement la méthode versTableau(). Elle n’a pas besoin de savoir quelle classe a créé l’objet.

JavaScript ne vérifie cependant pas ce contrat à l’avance. Si l’objet reçu ne possède pas versTableau(), l’erreur apparaîtra au moment de l’exécution.

Le polymorphisme permet d’écrire du code qui dépend d’un contrat plutôt que d’une classe précise. On peut alors ajouter de nouvelles implémentations sans devoir modifier tout le code qui utilise ce contrat.

Par exemple, si une nouvelle classe Facture implémente Exportable, afficherExport() pourra immédiatement l’utiliser :

afficherExport($facture);

Il n’est pas nécessaire d’ajouter une condition comme :

if ($objet instanceof Produit) {
// ...
} elseif ($objet instanceof Commande) {
// ...
} elseif ($objet instanceof Facture) {
// ...
}

Chaque objet sait lui-même comment réaliser l’opération demandée.

VérificationPourquoi afficherExport() n'a-t-elle pas besoin de connaître les classes Produit et Commande ?

Parce qu’elle dépend uniquement du contrat Exportable. Toute classe qui implémente cette interface peut être utilisée à sa place. C’est le type réel de l’objet qui détermine quelle version de versTableau() sera exécutée.

Les objets d’une application ne travaillent généralement pas seuls.

Faisons maintenant évoluer notre classe Commande. Jusqu’ici, elle savait calculer le sous-total de ses lignes. Nous voulons maintenant lui permettre de calculer également les taxes.

On dit alors que le calculateur de taxes est une dépendance de Commande.

Une première possibilité serait de créer directement le calculateur dans Commande :

class Commande
{
private CalculateurTaxesPourcentage $calculateurTaxes;
public function __construct()
{
$this->calculateurTaxes = new CalculateurTaxesPourcentage(0.14975);
}
}

Cette solution fonctionne, mais Commande décide maintenant elle-même :

  • quel calculateur utiliser ;
  • comment le construire ;
  • quel taux lui transmettre.

La classe est donc fortement liée à cette implémentation particulière.

Si les règles de taxes changent, si une autre région utilise un calcul différent ou si nous voulons effectuer un test sans taxes, il faudra intervenir directement dans Commande.

Une autre possibilité consiste à recevoir la dépendance de l’extérieur.

C’est ce qu’on appelle l’injection de dépendances.

On commence par décrire ce qu’un calculateur de taxes doit savoir faire.

JavaScript n’impose pas d’interface formelle. Commande utilisera simplement tout objet possédant une méthode calculer().

class CalculateurTaxes {
#taux;
constructor(taux) {
this.#taux = taux;
}
calculer(montant) {
return montant * this.#taux;
}
}
class Commande {
#lignes = [];
#calculateurTaxes;
constructor(calculateurTaxes) {
this.#calculateurTaxes = calculateurTaxes;
}
ajouter(produit, quantite) {
this.#lignes.push(new LigneCommande(produit, quantite));
}
sousTotal() {
return this.#lignes.reduce(
(somme, ligne) => somme + ligne.sousTotal(),
0
);
}
total() {
const sousTotal = this.sousTotal();
const taxes = this.#calculateurTaxes.calculer(sousTotal);
return sousTotal + taxes;
}
}
const taxes = new CalculateurTaxes(0.14975);
const commande = new Commande(taxes);
commande.ajouter(
new Produit("Sirop d'érable 540 ml", 14.50),
2
);
console.log(commande.total());

Commande reçoit son calculateur dans son constructeur :

new Commande(taxes);

Elle sait l’utiliser, mais elle ne sait pas comment il a été créé.

Supposons maintenant qu’une commande particulière ne doive payer aucune taxe.

Il suffit de fournir un autre calculateur :

class SansTaxes {
calculer(montant) {
return 0;
}
}
const commande = new Commande(new SansTaxes());

Nous avons changé le comportement de la commande sans modifier la classe Commande.

C’est ici que polymorphisme et injection de dépendances se rejoignent :

  1. Commande dépend d’un contrat : CalculateurTaxes.
  2. Plusieurs classes peuvent respecter ce contrat.
  3. Le programme choisit l’objet à fournir à Commande.
  4. Commande utilise cet objet sans connaître sa classe concrète.
Une classe devrait généralement connaître ce dont elle a besoin, mais pas nécessairement décider quel objet concret créer pour répondre à ce besoin.

L’injection par le constructeur rend également les dépendances visibles :

new Commande($calculateurTaxes);

En lisant cette instruction, on voit immédiatement qu’une commande ne peut pas fonctionner sans calculateur de taxes.

VérificationPourquoi transmettre le calculateur de taxes au constructeur de Commande plutôt que de le créer à l'intérieur ?

Parce que Commande peut alors travailler avec différentes implémentations de CalculateurTaxes sans être modifiée. Le choix et la configuration du calculateur sont effectués à l’extérieur de la classe.

  • La composition permet à un objet d’en utiliser d’autres pour faire son travail : une commande contient des lignes, une ligne désigne un produit.
  • L’héritage permet à une classe enfant de reprendre une classe parente, d’y ajouter des méthodes et d’en redéfinir certaines. Une classe n’a qu’un seul parent.
  • Un attribut protected reste caché de l’extérieur mais visible des sous-classes, contrairement à un attribut private.
  • Une classe abstraite ne s’instancie pas ; elle met en commun du code et impose à ses sous-classes les méthodes déclarées abstract.
  • Une interface décrit un contrat que les classes qui l’implémentent s’engagent à respecter.
  • Le polymorphisme permet d’utiliser des objets de classes différentes à travers un même contrat. Le comportement exécuté dépend de l’objet réellement utilisé.
  • Une dépendance est un objet dont une autre classe a besoin pour accomplir son travail.
  • L’injection de dépendances consiste à fournir ces objets de l’extérieur plutôt qu’à les créer directement dans la classe. Avec l’injection par le constructeur, les dépendances nécessaires apparaissent explicitement parmi les paramètres du constructeur.

Quiz. ProduitPerissable est déclarée avec extends Produit. Que peut-elle faire ?

Quiz. Produit et Commande, sans lien de parenté, doivent toutes deux pouvoir s'exporter en JSON. Que choisir ?

Quiz. Pourquoi transmettre le calculateur de taxes au constructeur de Commande plutôt que de le créer à l'intérieur ?

Quiz. Une fonction reçoit un paramètre de type Exportable. Que peut-on lui transmettre ?