LSP (Liskov Substitution Principle) est le troisième principe de SOLID, qui définit les conditions d’un héritage correct en programmation orientée objet. Le principe a été formulé par Barbara Liskov en 1987 et formalisé comme suit : si S est un sous-type de T, alors les objets de type T peuvent être remplacés par des objets de type S sans modifier les propriétés du programme. Comme le souligne le livre de Robert Martin Clean Architecture (2017), le principe de substitution exige qu’une sous-classe n’affaiblisse pas le contrat de la classe de base.
Points clés
LSP (Liskov Substitution Principle) est le principe de substitution formulé par Barbara Liskov à la conférence OOPSLA en 1987. Définition formelle : soit q(x) une propriété démontrable des objets x de type T. Alors q(y) doit être démontrable pour les objets y de type S, où S est un sous-type de T. En termes simples : les objets d’une sous-classe doivent se comporter de manière à ce que le code qui fonctionne avec la classe de base continue de fonctionner correctement avec la sous-classe.
En pratique, LSP signifie qu’une sous-classe ne doit pas violer le contrat de la classe de base. Le contrat inclut les préconditions (ce qui est nécessaire pour appeler une méthode), les postconditions (ce qui est garanti après l’appel) et les invariants (conditions qui persistent pendant la durée de vie de l’objet). Une sous-classe peut renforcer les préconditions ou affaiblir les postconditions — c’est précisément une violation de LSP.
Un exemple classique de violation de LSP est un carré héritant d’un rectangle. La méthode setWidth d’un rectangle définit la largeur, tandis que pour un carré elle définit à la fois la largeur et la hauteur. Un client qui s’attend au comportement d’un rectangle (modifier un côté n’affecte pas l’autre) obtient un résultat inattendu. Un carré n’est pas un sous-type valide d’un rectangle.
LSP établit trois conditions pour un héritage correct : les préconditions de la sous-classe ne peuvent pas être plus fortes que celles de la classe de base (la sous-classe n’exige pas plus), les postconditions de la sous-classe ne peuvent pas être plus faibles que celles de la classe de base (la sous-classe ne garantit pas moins), et les invariants de la classe de base doivent être préservés dans la sous-classe. Ces conditions sont connues sous le nom de règle de conception par contrat selon Bertrand Meyer.
Si au moins une condition est violée, le code utilisant le polymorphisme peut échouer. Le compilateur ne vérifie pas les contrats sémantiques, seulement les contrats syntaxiques. Par conséquent, LSP est une question de discipline architecturale, pas de typage statique.
Le mécanisme LSP est basé sur la compatibilité comportementale des types. Si la classe S hérite de la classe T, le code client doit pouvoir utiliser S partout où T est attendu sans modifier son comportement. Cela inclut non seulement les signatures des méthodes mais aussi leur sémantique.
LSP n’interdit pas à une sous-classe d’ajouter un nouveau comportement. Il est interdit de violer les attentes du code écrit pour la classe de base. Si la classe de base garantit que la méthode save ne lève pas d’exceptions, la sous-classe ne doit pas en lever. Si la classe de base retourne une valeur non négative, la sous-classe ne doit pas retourner une valeur négative.
Dans les projets réels, LSP est le plus souvent violé lors de l’ajout de logique conditionnelle dans les méthodes de la sous-classe : « si condition — lever une exception », « si condition — retourner null ». Chacune de ces « surprises » sape le polymorphisme et oblige le code client à vérifier le type de l’objet avant d’appeler — ce qui contredit l’idée même de la conception orientée objet.
Dans les projets mobiles, une violation typique de LSP se produit lors de la création de ViewModels de base. Si BaseViewModel garantit que la méthode onCleared libère toutes les ressources, et qu’une sous-classe remplace cette méthode par une méthode vide — tout code qui dépend du nettoyage des ressources via un appel polymorphe à onCleared fonctionnera incorrectement. LSP exige que la sous-classe appelle super.onCleared() ou effectue le même travail elle-même. La composition via LifecycleObserver est une alternative qui élimine la violation de LSP dans la gestion du cycle de vie.
Les indicateurs principaux de violation de LSP incluent : vérifier le type de l’objet via instanceof ou is avant d’appeler une méthode, des implémentations vides de méthodes (stubs), lever NotImplementedError ou UnsupportedOperationException, retourner null au lieu d’une valeur. Chacun de ces modèles signale que la sous-classe n’est pas un sous-type valide.
Un autre signe courant est l’héritage dans le but de réutiliser du code plutôt que de modéliser une relation « est-un » (is-a). La classe Bird a une méthode fly(). La classe Penguin hérite de Bird et remplace fly() par une méthode vide ou levante une exception. C’est une violation de LSP : un manchot n’est pas un sous-type valide d’oiseau.
Dans le développement mobile, LSP est violé lors de la création de classes de base ViewHolder, Fragment ou ViewController avec des méthodes stub. Si une sous-classe n’utilise pas la moitié des méthodes de la classe de base — l’héritage a été mal choisi. La composition ou la ségrégation d’interfaces résout le problème plus correctement.
Un test simple pour vérifier LSP : écrivez un test unitaire pour la classe de base qui vérifie son contrat (valeurs de retour, exceptions, effets secondaires). Exécutez ce test pour chaque sous-classe. Si le test échoue — LSP est violé. Cette approche est appelée « test via le contrat de la classe de base ».
Dans les projets Android, un tel test est utile pour ViewModel et Repository. Si BaseViewModel garantit un état Loading avant une erreur, et qu’une sous-classe lève une erreur sans Loading — le test détectera la violation de LSP au stade CI.
Regardons un exemple Android avec la gestion de ClickListener. Une violation de LSP se produit lorsque l’implémentation de base garantit quelque chose et que la sous-classe le viole.
// Classe de base avec garantie : onClick sera appelé
open class BaseClickListener {
open fun onClick(view: View) {
// traitement de base
}
}
// Violation de LSP : la sous-classe ajoute une condition qui lève une exception
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Solution correcte : contrat non violé
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
Un exemple iOS avec le protocole DataSource démontre une violation de LSP en retournant nil au lieu de données :
// Protocole avec contrat : retourne des données ou une erreur
protocol DataProvider {
func fetchData() async throws -> [String]
}
// Violation de LSP : retourne nil sans erreur
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // tableau vide au lieu d’une erreur
}
}
// Conformité LSP correcte
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Règle pratique : si une sous-classe ne peut pas remplir le contrat de la classe de base, elle ne devrait pas être une sous-classe. Une alternative est d’extraire une interface avec un contrat minimal et de l’implémenter dans chaque type à sa manière.
La composition est préférable à l’héritage dans les situations où la relation « est-un » (is-a) est ambiguë ou conditionnelle. Un exemple classique : un Manager est-il un Employee ? Oui. Mais un Square est-il un Rectangle valide ? LSP dit « non ». Si vous doutez de la justesse de l’héritage — choisissez la composition.
Dans le développement mobile, la composition est souvent utilisée via l’injection de dépendances : au lieu d’hériter du comportement d’une classe de base, une classe le reçoit via un constructeur. Un ViewModel n’hérite pas de Repository mais l’accepte comme dépendance. Cela élimine la violation de LSP par définition — pas d’héritage, pas de violation de contrat.
Signes que l’héritage doit être remplacé par la composition : la sous-classe n’utilise pas certaines méthodes de la classe de base, la sous-classe remplace les méthodes par des stubs vides, le code client vérifie le type de l’objet via instanceof. Dans ces cas, l’héritage a été mal choisi et LSP est violé.
Les interfaces résolvent le problème LSP sans héritage : chaque type implémente exactement les méthodes dont il a besoin. Au lieu d’une classe de base commune Bird avec une méthode fly() (où Penguin ne vole pas) — une interface Flyable que seuls les oiseaux volants implémentent. Penguin implémente Bird sans méthode fly() — LSP n’est pas violé.
Dans l’architecture Android, cette approche est appliquée via des interfaces UseCase ségréguées : au lieu d’un grand UseCase avec les méthodes getAll, getById, save, delete — des interfaces séparées GetItemsUseCase, SaveItemUseCase. Un client dépend uniquement de l’interface nécessaire, et toute classe implémentant cette interface est correcte du point de vue LSP.
Questions fréquentes
L’héritage est un mécanisme de langage ; LSP est une règle pour l’utilisation correcte de ce mécanisme. L’héritage garantit la compatibilité des signatures (syntaxe) ; LSP exige la compatibilité comportementale (sémantique). L’héritage sans LSP donne un polymorphisme qui se brise à l’exécution.
Si la classe de base garantit un retour non nul — oui. Si le contrat permet null (valeur optionnelle) — non. LSP n’interdit pas null ; il interdit l’affaiblissement du contrat. Étudiez la documentation de la classe de base et vérifiez si le contrat de la sous-classe est compatible.
LSP s’applique aux protocoles de la même manière qu’aux classes. Une implémentation de protocole doit respecter le contrat sémantique : si un protocole définit une méthode comme non-throwing, l’implémentation ne doit pas lever d’erreurs. Swift ne vérifie pas cela au niveau du compilateur — la responsabilité incombe au développeur.
Sealed class en Kotlin est un cas particulier car la hiérarchie est fermée et connue du compilateur. LSP s’applique moins aux sealed class car tous les sous-types sont énumérés explicitement dans l’expression when. Une erreur d’une sous-classe sealed sera locale, pas une erreur polymorphe cachée.
Écrivez un test paramétré pour la classe de base qui s’exécute pour toutes ses sous-classes. Le test vérifie les contrats comportementaux clés : valeurs de retour, exceptions, états. Si le test échoue sur l’une des sous-classes — LSP est violé. En CI, un tel test empêche la régression du code polymorphe.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi