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.
La composition
Section intitulée « La composition »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<?phpclass LigneCommande{ private Produit $produit; private int $quantite;
public function __construct(Produit $produit, int $quantite) { $this->produit = $produit; $this->quantite = $quantite; }
public function sousTotal(): float { return $this->produit->getPrix() * $this->quantite; }}
class Commande{ /** @var LigneCommande[] */ private array $lignes = [];
public function ajouter(Produit $produit, int $quantite): void { $this->lignes[] = new LigneCommande($produit, $quantite); }
public function total(): float { $total = 0.0; foreach ($this->lignes as $ligne) { $total += $ligne->sousTotal(); } return $total; }}
$commande = new Commande();$commande->ajouter(new Produit("Sirop d'érable 540 ml", 14.50), 2);echo $commande->total(); // 29- Un attribut peut avoir pour type une classe :
private Produit $produit. - Le commentaire
@var LigneCommande[]précise ce que contient le tableau, que PHP ne sait pas typer lui-même.
import java.util.ArrayList;import java.util.List;
public class LigneCommande { private Produit produit; private int quantite;
public LigneCommande(Produit produit, int quantite) { this.produit = produit; this.quantite = quantite; }
public double sousTotal() { return produit.getPrix() * quantite; }}
public class Commande { private List<LigneCommande> lignes = new ArrayList<>();
public void ajouter(Produit produit, int quantite) { lignes.add(new LigneCommande(produit, quantite)); }
public double total() { double total = 0; for (LigneCommande ligne : lignes) { total += ligne.sousTotal(); } return total; }}
// Utilisation (dans une méthode main)Commande commande = new Commande();commande.ajouter(new Produit("Sirop d'érable 540 ml", 14.50), 2);System.out.println(commande.total()); // 29.0Chaque 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.
L’héritage : L’idée de famille
Section intitulée « L’héritage : L’idée de famille »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")); // trueextendsdésigne la classe parente.super(...)appelle le constructeur parent ;super.methode()appelle la version parente d’une méthode redéfinie.
<?phpclass ProduitPerissable extends Produit{ private string $dateLimite;
public function __construct(string $nom, float $prix, string $dateLimite) { parent::__construct($nom, $prix); $this->dateLimite = $dateLimite; }
public function estPerime(string $aujourdhui): bool { return $aujourdhui > $this->dateLimite; }
public function prixFormate(): string { return parent::prixFormate() . ' (à consommer avant le ' . $this->dateLimite . ')'; }}
$tire = new ProduitPerissable('Tire sur la neige', 6.25, '2026-04-30');var_dump($tire->estPerime('2026-05-02')); // bool(true)echo $tire->prixFormate(); // "6,25 $ (à consommer avant le 2026-04-30)"extendsdésigne la classe parente.parent::__construct(...)appelle le constructeur parent ;parent::methode()appelle la version parente d’une méthode redéfinie.
public class ProduitPerissable extends Produit { private String dateLimite;
public ProduitPerissable(String nom, double prix, String dateLimite) { super(nom, prix); this.dateLimite = dateLimite; }
public boolean estPerime(String aujourdhui) { return aujourdhui.compareTo(dateLimite) > 0; }
@Override public String prixFormate() { return super.prixFormate() + " (à consommer avant le " + dateLimite + ")"; }}
// Utilisation (dans une méthode main)ProduitPerissable tire = new ProduitPerissable("Tire sur la neige", 6.25, "2026-04-30");System.out.println(tire.estPerime("2026-05-02")); // trueextendsdésigne la classe parente.super(...)appelle le constructeur parent ;@Overridesignale une redéfinition, et le compilateur vérifie qu’une méthode parente correspond bien.
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.
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.
Classe abstraite et interface
Section intitulée « Classe abstraite et interface »L’héritage fait apparaître deux besoins voisins, que les langages outillent de deux façons différentes.
Classe abstraite
Section intitulée « Classe abstraite »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.targetrefuse l’instanciation directe, et la méthode non redéfinie lève une erreur.
<?phpabstract class Remise{ protected string $libelle;
public function __construct(string $libelle) { $this->libelle = $libelle; }
abstract public function appliquer(float $prix): float;
public function decrire(float $prix): string { return $this->libelle . ' : ' . number_format($this->appliquer($prix), 2, ',', ' ') . ' $'; }}
class RemisePourcentage extends Remise{ private float $taux;
public function __construct(string $libelle, float $taux) { parent::__construct($libelle); $this->taux = $taux; }
public function appliquer(float $prix): float { return round($prix * (1 - $this->taux), 2); }}
$soldes = new RemisePourcentage('Soldes du printemps', 0.20);echo $soldes->decrire(14.50); // "Soldes du printemps : 11,60 $"abstract classinterditnew Remise(...).abstract public functiondéclare une méthode sans corps : toute sous-classe doit la fournir, sinon elle est elle-même abstraite.
public abstract class Remise { protected String libelle;
public Remise(String libelle) { this.libelle = libelle; }
public abstract double appliquer(double prix);
public String decrire(double prix) { return libelle + " : " + String.format("%.2f $", appliquer(prix)); }}
public class RemisePourcentage extends Remise { private double taux;
public RemisePourcentage(String libelle, double taux) { super(libelle); this.taux = taux; }
@Override public double appliquer(double prix) { return Math.round(prix * (1 - taux) * 100) / 100.0; }}
// Utilisation (dans une méthode main)Remise soldes = new RemisePourcentage("Soldes du printemps", 0.20);System.out.println(soldes.decrire(14.50)); // "Soldes du printemps : 11.60 $"abstract classinterditnew Remise(...); la vérification a lieu à la compilation.
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().
Interface
Section intitulée « Interface »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.
<?phpinterface Exportable{ public function versTableau(): array;}
class Produit implements Exportable{ // attributs et constructeur comme avant
public function versTableau(): array { return ['nom' => $this->nom, 'prix' => $this->prix]; }}
class Commande implements Exportable{ // lignes et méthodes comme avant
public function versTableau(): array { return ['total' => $this->total()]; }}
function exporterEnJson(Exportable $objet): string{ return json_encode($objet->versTableau());}implementsengage la classe à fournir toutes les méthodes de l’interface ; en oublier une provoque une erreur dès le chargement de la classe.- Le paramètre typé
Exportableaccepte n’importe quel objet qui implémente l’interface, quelle que soit sa classe.
import java.util.Map;
public interface Exportable { Map<String, Object> versTableau();}
public class Produit implements Exportable { // attributs et constructeur comme avant
@Override public Map<String, Object> versTableau() { return Map.of("nom", nom, "prix", prix); }}
public class Commande implements Exportable { // lignes et méthodes comme avant
@Override public Map<String, Object> versTableau() { return Map.of("total", total()); }}implementsengage la classe ; le compilateur refuse une classe qui n’a pas fourni toutes les méthodes.
| 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.
<?php
function afficherExport(Exportable $objet): void{ echo exporterEnJson($objet) . PHP_EOL;}
$produit = new Produit("Sirop d'érable 540 ml", 14.50);$commande = new Commande();
afficherExport($produit);afficherExport($commande);Le paramètre est déclaré Exportable, et non Produit ou Commande.
PHP accepte donc n’importe quel objet qui respecte ce contrat.
Lorsque exporterEnJson() appelle :
$objet->versTableau();PHP exécute la version de versTableau() correspondant à l’objet réellement reçu.
public static void afficherExport(Exportable objet) { System.out.println(objet.versTableau());}
// UtilisationProduit produit = new Produit("Sirop d'érable 540 ml", 14.50);Commande commande = new Commande();
afficherExport(produit);afficherExport(commande);Le paramètre est de type Exportable, mais l’objet reçu peut appartenir à n’importe quelle classe qui implémente cette interface.
La version de versTableau() exécutée dépend de l’objet réellement fourni.
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.
Dépendances et injection par le constructeur
Section intitulée « Dépendances et injection par le constructeur »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éé.
On peut utiliser une interface pour éviter que Commande dépende d’une classe précise.
<?php
interface CalculateurTaxes{ public function calculer(float $montant): float;}Une première implémentation peut appliquer un taux donné :
class CalculateurTaxesPourcentage implements CalculateurTaxes{ private float $taux;
public function __construct(float $taux) { $this->taux = $taux; }
public function calculer(float $montant): float { return $montant * $this->taux; }}Commande reçoit maintenant son calculateur dans son constructeur :
class Commande{ /** @var LigneCommande[] */ private array $lignes = [];
private CalculateurTaxes $calculateurTaxes;
public function __construct(CalculateurTaxes $calculateurTaxes) { $this->calculateurTaxes = $calculateurTaxes; }
public function ajouter(Produit $produit, int $quantite): void { $this->lignes[] = new LigneCommande($produit, $quantite); }
public function sousTotal(): float { $total = 0.0;
foreach ($this->lignes as $ligne) { $total += $ligne->sousTotal(); }
return $total; }
public function total(): float { $sousTotal = $this->sousTotal(); $taxes = $this->calculateurTaxes->calculer($sousTotal);
return $sousTotal + $taxes; }}La création des objets se fait maintenant à l’extérieur de Commande :
$taxes = new CalculateurTaxesPourcentage(0.14975);
$commande = new Commande($taxes);
$commande->ajouter( new Produit("Sirop d'érable 540 ml", 14.50), 2);
echo $commande->total();Commande connaît seulement l’interface CalculateurTaxes.
Elle ignore quelle classe concrète effectue le calcul et comment celle-ci a été configurée.
public interface CalculateurTaxes { double calculer(double montant);}public class CalculateurTaxesPourcentage implements CalculateurTaxes {
private double taux;
public CalculateurTaxesPourcentage(double taux) { this.taux = taux; }
@Override public double calculer(double montant) { return montant * taux; }}Commande reçoit ensuite la dépendance :
public class Commande {
private List<LigneCommande> lignes = new ArrayList<>(); private CalculateurTaxes calculateurTaxes;
public Commande(CalculateurTaxes calculateurTaxes) { this.calculateurTaxes = calculateurTaxes; }
public void ajouter(Produit produit, int quantite) { lignes.add(new LigneCommande(produit, quantite)); }
public double sousTotal() { double total = 0;
for (LigneCommande ligne : lignes) { total += ligne.sousTotal(); }
return total; }
public double total() { double sousTotal = sousTotal();
return sousTotal + calculateurTaxes.calculer(sousTotal); }}CalculateurTaxes taxes = new CalculateurTaxesPourcentage(0.14975);
Commande commande = new Commande(taxes);Commande dépend de l’interface CalculateurTaxes, et non de CalculateurTaxesPourcentage.
Pourquoi injecter la dépendance ?
Section intitulée « Pourquoi injecter la dépendance ? »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());class SansTaxes implements CalculateurTaxes{ public function calculer(float $montant): float { return 0; }}
$commande = new Commande(new SansTaxes());public class SansTaxes implements CalculateurTaxes {
@Override public double calculer(double montant) { return 0; }}
Commande 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 :
Commandedépend d’un contrat :CalculateurTaxes.- Plusieurs classes peuvent respecter ce contrat.
- Le programme choisit l’objet à fournir à
Commande. Commandeutilise cet objet sans connaître sa classe concrète.
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.
Récapitulatif
Section intitulée « Récapitulatif »- 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
protectedreste caché de l’extérieur mais visible des sous-classes, contrairement à un attributprivate. - 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 ?