Liskov Substitution Principle
Les sous-types doivent être substituables à leurs types parents
Principe
Formulé par Barbara Liskov en 1987 : si S est un sous-type de T, alors tout objet de type T peut être remplacé par un objet de type S sans altérer le comportement du programme.
Autrement dit : une sous-classe (ou une implémentation d'interface) ne doit pas casser le contrat établi par son type parent.
Violation classique
L'exemple canonique est le rectangle/carré :
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
// Un carré impose width === height
setWidth(w: number) {
this.width = w;
this.height = w; // brise le contrat de Rectangle
}
setHeight(h: number) {
this.height = h;
this.width = h; // brise le contrat de Rectangle
}
}
function resizeAndCalculate(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
// Attendu : 50. Avec un Square : 100
console.log(rect.area());
}
resizeAndCalculate(new Rectangle(2, 2)); // 50
resizeAndCalculate(new Square(2)); // 100 -> comportement inattenduSquare ne peut pas remplacer Rectangle sans changer le comportement. La hiérarchie est incorrecte.
En TypeScript : interfaces et contrats
interface Storage {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
class LocalStorage implements Storage {
get(key: string) { return localStorage.getItem(key); }
set(key: string, value: string) { localStorage.setItem(key, value); }
remove(key: string) { localStorage.removeItem(key); }
}
class InMemoryStorage implements Storage {
private store = new Map<string, string>();
get(key: string) { return this.store.get(key) ?? null; }
set(key: string, value: string) { this.store.set(key, value); }
remove(key: string) { this.store.delete(key); }
}
// Les deux implémentations sont substituables
function saveUserPreference(storage: Storage, key: string, value: string) {
storage.set(key, value);
}
saveUserPreference(new LocalStorage(), 'theme', 'dark');
saveUserPreference(new InMemoryStorage(), 'theme', 'dark'); // même comportementEn React : composants et props
LSP s'applique aussi aux composants. Un composant qui étend un autre (via les props) ne doit pas casser les attentes.
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}
// Button de base
function Button({ onClick, children, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// IconButton doit respecter le contrat de ButtonProps
function IconButton({ onClick, children, disabled, icon }: ButtonProps & { icon: React.ReactNode }) {
return (
<button onClick={onClick} disabled={disabled}>
{icon}
{children}
</button>
);
}
// IconButton est substituable à Button partout où ButtonProps est attenduSignal d'alarme
Une implémentation qui lève une exception ou ignore une méthode de l'interface parent viole LSP.
class ReadOnlyStorage implements Storage {
get(key: string) { return localStorage.getItem(key); }
set(key: string, value: string) {
throw new Error('Read only'); // viole le contrat
}
remove(key: string) {
throw new Error('Read only'); // viole le contrat
}
}Si ReadOnlyStorage ne peut pas implémenter Storage sans casser son contrat, c'est que Storage est trop large. C'est un problème d'ISP (principe suivant).