LSP (Liskov Substitution Principle) è il terzo principio di SOLID, che definisce le condizioni per un’ereditarietà corretta nella programmazione orientata agli oggetti. Il principio è stato formulato da Barbara Liskov nel 1987 e formalizzato come: se S è un sottotipo di T, allora gli oggetti di tipo T possono essere sostituiti da oggetti di tipo S senza modificare le proprietà del programma. Come osservato nel libro di Robert Martin Clean Architecture (2017), il principio di sostituzione richiede che una sottoclasse non indebolisca il contratto della classe base.
Punti chiave
LSP (Liskov Substitution Principle) è il principio di sostituzione formulato da Barbara Liskov alla conferenza OOPSLA nel 1987. Definizione formale: sia q(x) una proprietà dimostrabile degli oggetti x di tipo T. Allora q(y) deve essere dimostrabile per gli oggetti y di tipo S, dove S è un sottotipo di T. In parole povere: gli oggetti di una sottoclasse devono comportarsi in modo che il codice che lavora con la classe base continui a funzionare correttamente anche con la sottoclasse.
In pratica, LSP significa che una sottoclasse non deve violare il contratto della classe base. Il contratto include precondizioni (ciò che è necessario per chiamare un metodo), postcondizioni (ciò che è garantito dopo la chiamata) e invarianti (condizioni che persistono per tutta la vita dell’oggetto). Una sottoclasse può rafforzare le precondizioni o indebolire le postcondizioni — questa è proprio una violazione di LSP.
Un esempio classico di violazione di LSP è un quadrato che eredita da un rettangolo. Il metodo setWidth di un rettangolo imposta la larghezza, mentre per un quadrato imposta sia larghezza che altezza. Un cliente che si aspetta il comportamento di un rettangolo (modificare un lato non influisce sull’altro) ottiene un risultato inaspettato. Un quadrato non è un sottotipo valido di un rettangolo.
LSP stabilisce tre condizioni per un’ereditarietà corretta: le precondizioni della sottoclasse non possono essere più forti di quelle della classe base (la sottoclasse non richiede di più), le postcondizioni della sottoclasse non possono essere più deboli di quelle della classe base (la sottoclasse non garantisce di meno), e gli invarianti della classe base devono essere preservati nella sottoclasse. Queste condizioni sono note come regola del Design by Contract secondo Bertrand Meyer.
Se almeno una condizione viene violata, il codice che utilizza il polimorfismo può fallire. Il compilatore non verifica i contratti semantici, solo quelli sintattici. Pertanto, LSP è una questione di disciplina architettonica, non di tipizzazione statica.
Il meccanismo LSP si basa sulla compatibilità comportamentale dei tipi. Se la classe S eredita dalla classe T, il codice cliente deve poter usare S ovunque sia previsto T senza modificare il suo comportamento. Questo include non solo le firme dei metodi ma anche la loro semantica.
LSP non vieta a una sottoclasse di aggiungere nuovo comportamento. È vietato violare le aspettative del codice scritto per la classe base. Se la classe base garantisce che il metodo save non lancia eccezioni, la sottoclasse non deve lanciarle. Se la classe base restituisce un valore non negativo, la sottoclasse non deve restituirne uno negativo.
Nei progetti reali, LSP viene violato più spesso quando si aggiunge logica condizionale nei metodi della sottoclasse: «se condizione — lancia un’eccezione», «se condizione — restituisci null». Ognuna di queste «sorprese» mina il polimorfismo e costringe il codice cliente a verificare il tipo dell’oggetto prima di chiamare — contraddicendo l’idea stessa del design orientato agli oggetti.
Nei progetti mobili, una tipica violazione di LSP si verifica quando si creano ViewModel di base. Se BaseViewModel garantisce che il metodo onCleared rilasci tutte le risorse, e una sottoclasse sovrascrive questo metodo come vuoto — qualsiasi codice che fa affidamento sulla pulizia delle risorse tramite una chiamata polimorfica a onCleared funzionerà in modo errato. LSP richiede che la sottoclasse chiami super.onCleared() o esegua lo stesso lavoro da sola. La composizione tramite LifecycleObserver è un’alternativa che elimina la violazione di LSP nella gestione del ciclo di vita.
Gli indicatori principali di violazione di LSP includono: verificare il tipo dell’oggetto tramite instanceof o is prima di chiamare un metodo, implementazioni vuote di metodi (stub), lanciare NotImplementedError o UnsupportedOperationException, restituire null invece di un valore. Ciascuno di questi modelli segnala che la sottoclasse non è un sottotipo valido.
Un altro segno comune è l’ereditarietà finalizzata al riutilizzo del codice piuttosto che a modellare una relazione «è-un» (is-a). La classe Bird ha un metodo fly(). La classe Penguin eredita da Bird e sovrascrive fly() come vuoto o che lancia un’eccezione. Questa è una violazione di LSP: un pinguino non è un sottotipo valido di uccello.
Nello sviluppo mobile, LSP viene violato quando si creano classi base ViewHolder, Fragment o ViewController con metodi stub. Se una sottoclasse non usa metà dei metodi della classe base — l’ereditarietà è stata scelta in modo errato. La composizione o la segregazione delle interfacce risolve il problema più correttamente.
Un test semplice per verificare LSP: scrivete un test unitario per la classe base che verifichi il suo contratto (valori restituiti, eccezioni, effetti collaterali). Eseguite questo test per ogni sottoclasse. Se il test fallisce — LSP è violato. Questo approccio si chiama «test attraverso il contratto della classe base».
Nei progetti Android, tale test è utile per ViewModel e Repository. Se BaseViewModel garantisce uno stato Loading prima di un errore, e una sottoclasse lancia un errore senza Loading — il test rileverà la violazione di LSP nella fase CI.
Consideriamo un esempio Android con la gestione di ClickListener. Una violazione di LSP si verifica quando l’implementazione di base garantisce qualcosa e la sottoclasse lo viola.
// Classe base con garanzia: onClick verrà chiamato
open class BaseClickListener {
open fun onClick(view: View) {
// gestione di base
}
}
// Violazione di LSP: la sottoclasse aggiunge una condizione che lancia un’eccezione
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Soluzione corretta: contratto non violato
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
Un esempio iOS con il protocollo DataSource dimostra una violazione di LSP restituendo nil invece di dati:
// Protocollo con contratto: restituisce dati o errore
protocol DataProvider {
func fetchData() async throws -> [String]
}
// Violazione di LSP: restituisce nil senza errore
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // array vuoto invece di errore
}
}
// Corretta conformità LSP
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Regola pratica: se una sottoclasse non può adempiere al contratto della classe base, non dovrebbe essere una sottoclasse. Un’alternativa è estrarre un’interfaccia con un contratto minimo e implementarla in ogni tipo a modo suo.
La composizione è preferibile all’ereditarietà in situazioni in cui la relazione «è-un» (is-a) è ambigua o condizionale. Un esempio classico: un Manager è un Employee? Sì. Ma un Square è un Rectangle valido? LSP dice «no». Se dubitate della correttezza dell’ereditarietà — scegliete la composizione.
Nello sviluppo mobile, la composizione viene spesso utilizzata tramite iniezione di dipendenze: invece di ereditare il comportamento da una classe base, una classe lo riceve tramite un costruttore. Un ViewModel non eredita da Repository ma lo accetta come dipendenza. Ciò elimina la violazione di LSP per definizione — niente ereditarietà, niente violazione del contratto.
Segni che l’ereditarietà dovrebbe essere sostituita dalla composizione: la sottoclasse non usa alcuni metodi della classe base, la sottoclasse sovrascrive metodi con stub vuoti, il codice cliente verifica il tipo dell’oggetto tramite instanceof. In questi casi, l’ereditarietà è stata scelta in modo errato e LSP è violato.
Le interfacce risolvono il problema LSP senza ereditarietà: ogni tipo implementa esattamente i metodi di cui ha bisogno. Invece di una classe base comune Bird con un metodo fly() (dove Penguin non vola) — un’interfaccia Flyable che solo gli uccelli volanti implementano. Penguin implementa Bird senza il metodo fly() — LSP non è violato.
Nell’architettura Android, questo approccio viene applicato tramite interfacce UseCase segregate: invece di un grande UseCase con metodi getAll, getById, save, delete — interfacce separate GetItemsUseCase, SaveItemUseCase. Un cliente dipende solo dall’interfaccia necessaria, e qualsiasi classe che implementa quell’interfaccia è corretta dal punto di vista LSP.
Domande frequenti
L’ereditarietà è un meccanismo del linguaggio; LSP è una regola per l’uso corretto di tale meccanismo. L’ereditarietà garantisce la compatibilità delle firme (sintassi); LSP richiede la compatibilità comportamentale (semantica). L’ereditarietà senza LSP dà polimorfismo che si rompe a runtime.
Se la classe base garantisce un ritorno non nullo — sì. Se il contratto ammette null (valore opzionale) — no. LSP non vieta null; vieta l’indebolimento del contratto. Studiate la documentazione della classe base e verificate se il contratto della sottoclasse è compatibile.
LSP si applica ai protocolli allo stesso modo che alle classi. Un’implementazione di protocollo deve rispettare il contratto semantico: se un protocollo definisce un metodo come non-throwing, l’implementazione non deve lanciare errori. Swift non verifica questo a livello di compilatore — la responsabilità è dello sviluppatore.
Sealed class in Kotlin è un caso speciale perché la gerarchia è chiusa e nota al compilatore. LSP si applica alle sealed class in misura minore perché tutti i sottotipi sono elencati esplicitamente nell’espressione when. Un errore di una sottoclasse sealed sarà locale, non un errore polimorfico nascosto.
Scrivete un test parametrizzato per la classe base che viene eseguito per tutte le sue sottoclassi. Il test verifica i contratti comportamentali chiave: valori restituiti, eccezioni, stati. Se il test fallisce su una delle sottoclassi — LSP è violato. In CI, tale test previene la regressione del codice polimorfico.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche