Troisième épisode de la série SOLID. Le L désigne le Liskov Substitution Principle, du nom de Barbara Liskov, informaticienne au MIT et lauréate du prix Turing en 2008. C'est le principe au nom le plus intimidant, mais l'idée est simple.
La définition
Barbara Liskov l'a énoncé en 1987, puis formalisé avec Jeannette Wing en 1994. En langage courant :
Si B est un sous-type de A, on doit pouvoir utiliser un objet B partout où un objet A est attendu, sans que le programme se comporte de façon incorrecte.
Autrement dit, hériter d'une classe ou implémenter une interface, c'est signer un contrat. PHP vérifie la forme de ce contrat (les signatures des méthodes), mais pas son comportement. C'est ce comportement que le principe de Liskov protège.
Le grand classique : le carré et le rectangle
En mathématiques, un carré est un rectangle particulier. Il paraît donc naturel d'écrire class Carre extends Rectangle :
<?php
class Rectangle
{
public function __construct(
protected int $largeur,
protected int $hauteur,
) {}
public function setLargeur(int $largeur): void
{
$this->largeur = $largeur;
}
public function setHauteur(int $hauteur): void
{
$this->hauteur = $hauteur;
}
public function aire(): int
{
return $this->largeur * $this->hauteur;
}
}
class Carre extends Rectangle
{
public function __construct(int $cote)
{
parent::__construct($cote, $cote);
}
// Un carré doit garder ses quatre côtés égaux…
public function setLargeur(int $largeur): void
{
$this->largeur = $this->hauteur = $largeur;
}
public function setHauteur(int $hauteur): void
{
$this->largeur = $this->hauteur = $hauteur;
}
}
Une fonction écrite pour des rectangles s'attend à ce que la largeur et la hauteur soient indépendantes :
<?php
function redimensionner(Rectangle $forme): int
{
$forme->setLargeur(5);
$forme->setHauteur(4);
return $forme->aire();
}
echo redimensionner(new Rectangle(2, 3)); // 20
echo redimensionner(new Carre(2)); // 16 !
Le carré respecte la signature de chaque méthode, mais il rompt une promesse implicite du rectangle : modifier la hauteur ne change pas la largeur. Le code client, parfaitement correct pour un rectangle, devient faux dès qu'on lui passe un carré. Pour le corriger, il faudrait y ajouter un if ($forme instanceof Carre)… ce qui annule tout l'intérêt de l'héritage.
La leçon : la relation « est un » du monde réel ne suffit pas. Il faut se demander si l'enfant se comporte comme le parent. Ici, la bonne solution consiste à ne pas lier les deux classes par héritage. Elles partagent seulement ce qu'elles ont vraiment en commun :
<?php
interface Forme
{
public function aire(): int;
}
final readonly class Rectangle implements Forme
{
public function __construct(public int $largeur, public int $hauteur) {}
public function aire(): int
{
return $this->largeur * $this->hauteur;
}
public function avecHauteur(int $hauteur): self
{
return new self($this->largeur, $hauteur);
}
}
final readonly class Carre implements Forme
{
public function __construct(public int $cote) {}
public function aire(): int
{
return $this->cote ** 2;
}
}
Les objets sont immuables : « changer » la hauteur d'un rectangle crée un nouveau rectangle. Plus aucune promesse ne peut être trahie.
Les règles du contrat
Pour respecter le principe, une classe enfant doit suivre trois règles, héritées de la « programmation par contrat » de Bertrand Meyer :
- Ne pas exiger plus (préconditions) : l'enfant doit accepter au moins tout ce que le parent accepte.
- Ne pas promettre moins (postconditions) : l'enfant doit garantir au moins tout ce que le parent garantit.
- Préserver les invariants : ce qui est toujours vrai pour le parent doit rester vrai pour l'enfant.
Depuis PHP 7.4, le langage vérifie une partie de ces règles sur les types : un enfant peut accepter des paramètres plus larges (contravariance) et renvoyer un type plus précis (covariance), mais pas l'inverse. Les règles qui portent sur les valeurs et le comportement restent sous votre responsabilité.
Exiger plus que le parent
<?php
class Remise
{
/** Accepte tout montant positif ou nul. */
public function appliquer(int $montantCentimes): int
{
return (int) round($montantCentimes * 0.9);
}
}
class RemiseFidelite extends Remise
{
public function appliquer(int $montantCentimes): int
{
// ✗ Précondition renforcée : le parent accepte n'importe quel montant.
if ($montantCentimes < 3_000) {
throw new DomainException('Remise fidélité possible à partir de 30 € seulement.');
}
return (int) round($montantCentimes * 0.85);
}
}
Partout où le code manipule une Remise, il peut l'appliquer à n'importe quel montant positif. Avec une RemiseFidelite, le même appel peut soudain lever une exception. La règle « pas de remise fidélité sous 30 € » est légitime, mais elle ne doit pas durcir le contrat : la sous-classe devrait renvoyer le montant inchangé, ou la règle devrait vivre dans le code qui choisit quelle remise appliquer.
Lever une exception « non supporté »
C'est la violation la plus courante dans la vraie vie :
<?php
interface Stockage
{
public function lire(string $chemin): string;
public function enregistrer(string $chemin, string $contenu): void;
}
final class StockageCdn implements Stockage
{
public function __construct(private string $urlDeBase) {}
public function lire(string $chemin): string
{
$contenu = file_get_contents($this->urlDeBase . '/' . $chemin);
if ($contenu === false) {
throw new RuntimeException("Fichier introuvable : $chemin");
}
return $contenu;
}
public function enregistrer(string $chemin, string $contenu): void
{
// ✗ Le CDN est en lecture seule : la promesse de l'interface est rompue.
throw new LogicException("Le CDN ne permet pas d'enregistrer de fichiers.");
}
}
Toute fonction qui reçoit un Stockage a le droit d'appeler enregistrer(). Avec un StockageCdn, le programme plante. Le problème ne vient pas vraiment de cette classe, mais de l'interface, qui promet trop : nous la découperons proprement dans le prochain article.
Vérifier la substitution avec des tests de contrat
Une technique efficace consiste à écrire les tests du contrat une seule fois, dans une classe abstraite, puis à les exécuter sur chaque implémentation :
<?php
use PHPUnit\Framework\TestCase;
abstract class StockageContratTest extends TestCase
{
abstract protected function creerStockage(): Stockage;
public function testRelitCeQuiAEteEnregistre(): void
{
$stockage = $this->creerStockage();
$stockage->enregistrer('notes/a.txt', 'bonjour');
$this->assertSame('bonjour', $stockage->lire('notes/a.txt'));
}
public function testEcraseUnFichierExistant(): void
{
$stockage = $this->creerStockage();
$stockage->enregistrer('notes/a.txt', 'v1');
$stockage->enregistrer('notes/a.txt', 'v2');
$this->assertSame('v2', $stockage->lire('notes/a.txt'));
}
}
final class StockageDisqueTest extends StockageContratTest
{
protected function creerStockage(): Stockage
{
return new StockageDisque(sys_get_temp_dir() . '/stockage-' . bin2hex(random_bytes(4)));
}
}
final class StockageMemoireTest extends StockageContratTest
{
protected function creerStockage(): Stockage
{
return new StockageMemoire();
}
}
Si une nouvelle implémentation ne respecte pas le contrat, ses tests échouent immédiatement, bien avant la mise en production.
Les signes d'un héritage bancal
- Du code client qui teste le type concret, avec
instanceof, pour traiter un cas à part. - Des méthodes redéfinies qui lèvent une
LogicExceptionou une erreur « non supporté ». - Des méthodes redéfinies vides, qui ne font rien.
- Une documentation du genre « attention, ne pas appeler
setLargeur()sur cette sous-classe ».
À retenir
- Hériter ou implémenter une interface, c'est s'engager sur un comportement, pas seulement sur des signatures.
- Un enfant peut accepter plus et promettre plus, jamais exiger plus ni promettre moins.
- « Est un » dans le monde réel ne veut pas dire « peut remplacer » dans le code.
- Les tests de contrat vérifient automatiquement que chaque implémentation tient ses promesses.
Notre StockageCdn nous laisse avec une interface qui promet trop. Comment la découper ? C'est l'objet du prochain article : le principe de ségrégation des interfaces.
La série SOLID
- S : responsabilité unique
- O : ouvert/fermé
- L : substitution de Liskov (vous êtes ici)
- I : ségrégation des interfaces
- D : inversion des dépendances