In una programmazione orientata agli oggetti, sistemi di costruzione che sono sia robusti che adattabili è la sfida perpetua. Tra i principi fondamentali che guidano gli sviluppatori verso tale obiettivo è il principio di sostituzione di Liskov (LSP). Concepito da Barbara Liskov nel 1987, LSP è il terzo pilastro dei cinque principi di SOLID per il software di progettazione, e si tratta di una domanda critica: quando si crea una classe di sostituto che eredita da una classe madre, può sostituire in modo sicuro qualsiasi ista.

Qual è il principio di sostituzione di Liskov?

Il principio di sostituzione di Liskov afferma che gli oggetti di una superclasse devono essere sostituibili con oggetti delle sue sottoclassi senza pregiudicare la correttezza del programma. In altre parole, se una funzione o un metodo è progettato per lavorare con un tipo di base, dovrebbe anche lavorare con qualsiasi tipo derivato senza richiedere modifiche o produrre effetti collaterali inaspettati.

LSP è fondamentalmente su sottotitoli comportamentali. Non è sufficiente che una sottoclasse abbia le stesse firme del metodo come suo genitore (conformance sintattica); la sottoclasse deve anche onorare le intenzioni e i vincoli della classe madre. Se una sottoclasse cambia il comportamento fondamentale di un metodo genitore – per esempio, gettando un'eccezione il genitore non getta mai, restituire un valore che viola il contratto del genitore, o richiedendo condizioni più severe.

Definizione formale e sfondo

La definizione formale originale di Barbara Liskov è la seguente:

“Se per ogni oggetto o]1]] di tipo S c'è un oggetto o2[]] di tipo T tale che per tutti i programmi P definito in termini di T, il comportamento di P è immutato quando o1] è sostituito da o[2

Questa definizione sottolinea che un sottotipo (S) deve essere sostituibile per il suo supertipo (T) in qualsiasi programma (P) che è scritto in termini di T. Il comportamento osservabile del programma deve essere conservato. Questo concetto è strettamente legato alla metodologia di Design by Contract (DbC), rafforzata da Bertrand Meyer, dove ogni metodo ha precondizioni esplicite (che deve essere vero prima che il metodo esegue) e postclasse condizioni (che devono essere

Per ulteriori informazioni, vedere l'originale Liskov e Wing paper[] che formalizzato il concetto. Inoltre, l'articolo Wikipedia su LSP[]] fornisce una buona panoramica.

Perché LSP è importante?

L'adesione a LSP porta diversi vantaggi critici ai sistemi orientati agli oggetti:

  • Affidabilità e correttezza:[] Codice che utilizza un tipo di base può fidarsi che qualsiasi sottoclasse si comporterà secondo il contratto del tipo di base.
  • Polimorfismo:[] Il polimorfismo è la capacità di trattare oggetti di classi diverse attraverso un'interfaccia comune. Senza LSP, il polimorfismo diventa pericoloso perché sostituendo una sottoclasse può produrre risultati errati. LSP assicura che il codice polimorfico funzioni come previsto.
  • Maintainability and Extensibility:[ Quando si evitano le violazioni LSP, l'aggiunta di nuove sottoclassi non richiede la modifica del codice esistente che dipende dal tipo di base. Questo si allinea al principio Open/Closed (OCP) – le entità software dovrebbero essere aperte per estensione ma chiuse per la modifica.
  • Testabilità:[]] I test dell'unità scritti contro una classe di base possono essere riutilizzati per convalidare le sottoclassi. Se una sottoclasse viola LSP, questi test falliranno, rivelando l'inconsistenza in anticipo.

LSP non è solo un concetto accademico; ha implicazioni pratiche dirette. Ad esempio, in un sistema di elaborazione dei pagamenti, se si dispone di una classe di base [ con un metodo [], ci si aspetta che tutte le sottoclassi (ad esempio, ], ]])) processano i pagamenti senza errori o effetti collaterali che la classe di base non prevede che la classe non può portare i dati corrotti.

Violazioni comuni del principio di sostituzione di Liskov

Riconoscere le violazioni dell'SPL è il primo passo verso fissarle. Ecco alcuni modelli tipici che spezzano il principio:

Rafforzare le condizioni precondizioni

Se un metodo di classe base prevede un parametro interi, aggiungendo una condizione nella sottoclasse che l'integer deve essere positivo (mentre la classe di base accetta qualsiasi integer) rafforza la precondizione. I clienti che hanno passato un numero negativo alla classe base ora fallirebbero con la sottoclasse. Esempio:

// Base class
class UserService {
 public void assignRole(int userId) { ... }
}
// Subclass violation
class AdminService extends UserService {
 @Override
 public void assignRole(int userId) {
 if (userId <= 0) throw new IllegalArgumentException("Invalid ID");
 ...
 }
}

Ridurre le condizioni di lavoro

Se la classe base garantisce un certo valore di ritorno o un effetto collaterale, una sottoclasse che riduce la garanzia viola LSP. Ad esempio, un metodo di classe base potrebbe sempre restituire una stringa non-null; una sottoclasse che ritorna in alcuni casi indebolisce la condizione post.

Lanciare nuove esenzioni

I sottoclassi non dovrebbero lanciare eccezioni che la classe di base non getta (a meno che queste eccezioni siano sottoclassi di eccezioni già consentite). Se i clienti della classe di base catturano solo , e una sottoclasse lancia un , la sostituzione causerà crash imprevisti.

Rimuovere o sovrascrivere i metodi che dovrebbero essere inerite

Se una sottoclasse supera un metodo per non fare nulla (corpo vuoto) o per lanciare un , questa è una chiara violazione. La sottoclasse non si comporta come classe base prevista.

Esempio classico: rettangolo, quadrato e il tratto di area

Molti libri di testo iniziano con una classe che ha [] e [] metodi, e poi creare una sottoclasse che sovrascrive questi per far rispettare la larghezza == altezza.

// Base class
class Rectangle {
 protected int width, height;
 public void setWidth(int w) { width = w; }
 public void setHeight(int h) { height = h; }
 public int getArea() { return width * height; }
}

// Subclass violation
class Square extends Rectangle {
 @Override
 public void setWidth(int w) {
 width = height = w;
 }
 @Override
 public void setHeight(int h) {
 width = height = h;
 }
}

Ora consideri una funzione che funziona con un :

void resize(Rectangle r) {
 r.setWidth(5);
 r.setHeight(10);
 assert r.getArea() == 50; // Expect 50
}

Quando passa un , il metodo imposta larghezza a 5 e poi altezza a 10 – ma la piazza è sovrastinta ] imposta larghezza a 10 pure, così il quadrato finisce con larghezza=10, altezza=10, area=100. L'affermazione fallisce.

La correzione:] Evitare di ereditare da . Invece, progettare una classe astratta con un metodo comune e avere entrambi e implementare in modo indipendente.

Esempio reale: Integrazioni gateway di pagamento

Immaginate un sistema di e-commerce con una classe di base :

abstract class PaymentGateway {
 public abstract void charge(double amount);
 public void refund(double amount) { /* default implementation */ }
}

] e ]. Un client che elabora i pagamenti potrebbe chiamare e in seguito . Una violazione si verifica se sovrascrive ]] per gettare un'eccezione perché l'API di PayPal richiede un ID di rimborso, non solo un importo.

[LT:0] Come risolvere:] Sia (a) assicurarsi che il contratto della classe base include la possibilità che ] potrebbe non essere supportato (ad esempio, rendere il ritorno di un booleano di successo o dichiarare un'eccezione verificata), o (b) ridisegnare la gerarchia in modo che non tutti i gateway supportano i rimborsi.

Come seguire il principio di sostituzione di Liskov nella pratica

LSP di attuazione richiede disciplina nella progettazione e nella sperimentazione.

  • Progetto per contratto:[] Specificare chiaramente le condizioni precondizioni, le condizioni postali e gli invarianti per i metodi di classe base. Documentare ciò che ogni metodo si aspetta e garantisce. Quindi assicurarsi che ogni sottoclasse rispetta.
  • Interfacce di lavoro su classi astratti:[[]] Le interfacce definiscono un contratto senza dettagli di implementazione. Sono naturalmente allineate con LSP perché qualsiasi classe di attuazione deve soddisfare l'intera interfaccia. Con classi astratti, è più facile introdurre accidentalmente dipendenze.
  • Usa composizione sopra l'ergonomia:[] Quando una sottoclasse avrebbe bisogno di sovrascrivere il comportamento al punto di rompere il contratto di base, è spesso meglio usare la composizione. Ad esempio, invece di ereditare da ], hanno una classe che usa internamente un
  • Test per la sostituibilità:[] Scrivere test parametrizzati che eseguono gli stessi scenari contro i tipi di base e derivati. Se un test passa con il tipo di base ma non riesce con un tipo derivato, si ha una violazione LSP. Questa pratica è particolarmente potente quando si utilizzano doppie test.
  • Controllo per i tipi di ritorno covarianti:[] Alcune lingue permettono di ottenere tipi di ritorno covarianti (ad esempio, un metodo di sottoclasse può restituire un tipo più specifico della base). Questo va bene finché non cambia la condizione post-vendita.
  • Avoid Overriding Concrete Methods Indiscriminatamente:[] Se sentite la necessità di sovrascrivere un metodo concreto in una sottoclasse, domandate se l'eredità è lo strumento giusto. Forse la classe base era troppo concreta. Rendere il metodo astratto o virtuale solo quando intendete sottoclassi cambiare il comportamento in modo controllato.

LSP e gli altri principi SOLID

LSP è profondamente interconnesso con gli altri principi SOLID, in particolare il principio aperto/calorato (OCP) e il principio di inversione di dipendenza (DIP):

  • LSP e OCP:[[] OCP afferma che le classi dovrebbero essere aperte per estensione ma chiuse per modifica. LSP assicura che le estensioni (sottoclassi) non rompono il codice esistente che utilizza il tipo di base. Senza LSP, l'aggiunta di una nuova sottoclasse richiederebbe la modifica del codice client per gestire il nuovo comportamento, violando OCP.
  • LSP e DIP:[] DIP consiglia a seconda delle astrazioni, non delle concrezioni. LSP è essenziale qui perché l'astrazione (interfaccia o classe base) deve essere stabile e affidabile. Se le sottoclassi violano LSP, l'astrazione non è più una dipendenza affidabile, e il sistema diventa fragile.
  • LSP e Interface Segregation (ISP): ISP incoraggia piccole interfacce focalizzate. Questo supporta naturalmente LSP perché una piccola interfaccia definisce un contratto stretto che è più facile da onorare. Una classe che implementa un'interfaccia di dimensioni superiori può lottare per soddisfare tutte le parti, portando a violazioni come lanciare .

Per una panoramica completa dei principi SOLID, controllare L'articolo SOLID di Wikipedia.

Conclusioni

Il principio di sostituzione di Liskov è molto più di una gentilezza teorica; è uno strumento pratico per la costruzione di sistemi orientati agli oggetti che sono sicuri da estendere e facile da mantenere. Garantisce che le sottoclassi si comportano in modo che è sostituibile per le loro classi madri, gli sviluppatori possono contare su polimorfismo senza paura di bug nascosti. Il principio incoraggia un pensiero attento alle gerarchie di classe, portando a progetti che favoriscono la composizione su contratti di successione robusti e chiaramente definiti.

Ricordate, le violazioni del LSP spesso appaiono come sottili incongruenze nel comportamento: un metodo che getta un'eccezione inaspettata, un metodo che silenziosamente non fa nulla, o un metodo che altera lo stato in un modo che la classe base non ha mai voluto.

Riferimenti esterni utilizzati in questo articolo: