Supponiamo di aver appena definito la classe Documento
e di aver successivamente generato le classi Lettera
, SMS
e Romanzo
come classi figlie della classe Documento.
Il principio che trattiamo in questa lezione, enunciato per la prima volta da Barbara Liskov nel 1987, asserisce che ovunque nel nostro codice sia richiesto un oggetto di tipo Documento
debba essere possibile utilizzare un qualsiasi oggetto istanziato da una delle classi figlie di Documento
, come ad esempio SMS
, senza pregiudicare in alcun modo il buon funzionamento del programma.
La definizione formale del principio Liskov Substitution è espressa in questo modo:
“ Se per ogni oggetto o1 di tipo S c'è un oggetto o2 di tipo T, tale che per tutti i programmi P definiti in termini di T, il comportamento di P non vari sostituendo o1 a o2, allora S è un sottotipo di T ”
Come è facile violare l'LSP
Definiamo i metodi della classe Documento:
classe Documento
string getTitolo()
void setTitolo(string titolo)
string getTesto()
void setTesto(string testo)
Ora descriviamo un metodo di una classe Storage
che, ricevuto un oggetto di tipo Documento, si preoccupa salvare su disco rigido il contenuto dello stesso:
classe Storage
void salvaSuDisco(doc)
testo = doc.getTitolo() + " " + doc.getTesto()
scriviIlFile(rimuoviGliSpazi(doc.getTitolo()) + ".txt", testo)
Ok, ora concentriamoci sulla classe SMS
, in una prima istanza potremmo pensare di aggiungere due nuovi metodi per la gestione del dato destinatario
:
classe SMS che deriva da Documento
// oltre ai metodi della classe Documento, che sono automaticamente inclusi in SMS
string getDestinatario()
void setDestinatario(string numero_di_telefono)
A questo punto ipotizziamo di creare un oggetto di tipo SMS
:
messaggio = new SMS()
messaggio.setDestinatario("555 100 666")
messaggio.setTesto("Winter is coming")
e di volerlo salvare su disco:
disco_usb = new Storage()
disco_usb.salvaSuDisco(messaggio)
Riceviamo un messaggio come questo:
[!] ERRORE, non posso creare un file con nome ".txt"
Cosa è successo? Semplice, un attributo esposto dalla classe padre (Titolo
) non viene utilizzato dalla classe figlia ma viene richiesto da alcune funzioni che lavorano con oggetti di tipo Documento
. La classica, e sbagliata, soluzione per questo genere di problematiche è la seguente:
classe Storage
void salvaSuDisco(doc)
se doc è di tipo SMS
testo = doc.getDestinatario() + " " + doc.getTesto()
scriviIlFile(rimuoviGliSpazi(doc.getDestinatario()) + ".txt", testo)
altrimenti
testo = doc.getTitolo() + " " + doc.getTesto()
scriviIlFile(rimuoviGliSpazi(doc.getTitolo()) + ".txt", testo)
Il motivo per cui è richiesta aderenza al principio di Liskov gravita attorno al tipo di intervento che abbiamo appena effettuato: iniettando un controllo esplicito sulla tipologia dell'oggetto sul quale la funzione insiste abbiamo violato l'open/close principle. La classe Storage
è ora infatti aperta rispetto a Documento
ed a tutte le classi figlie. Questo significa che modifiche/aggiunte alla gerarchia della classe Documento
dovranno necessariamente comportare la modifica del metodo salvaSuDisco
.
La soluzione
Per gestire questo tipo di situazioni è di solito sufficiente identificare dove il comportamento della classe padre diverge in modo troppo consistente rispetto a quello della classe figlia e decidere in che modo operare:
Astrarre
Generalizzare la classe padre, fino ad astrarla se necessario, in modo da eliminare quegli aspetti
che sono in conflitto con la classe figlia. Questa soluzione comporta però la necessità di reimplementare
l'aspetto rimosso all'interno delle classi figlie che ne beneficiavano realmente,
rischiando di incorrere in pericolose duplicazioni di codice.
Rinunciare all'ereditarietà
Rimuovere la relazione tra classe padre e classe figlia. In questo modo però si perdono tutti
i benefici derivanti dall'ereditarietà: tutti i metodi studiati per la classe padre che possono andar
bene anche per la classe appena separata devono essere riscritti.
Creare un nuovo livello
Creare una nuova classe padre, ad esempio Contenuto
e spostare la classe figlia sotto questa. Generalizzare poi Contenuto
fino a renderlo compatibile con il comportamento di SMS
.
Implementare le differenze nella classe base
Implementare la dissonanza direttamente all'interno della classe padre ed utilizzare l'overriding nella classe figlia per definirne il diverso comportamento.
Ad esempio, la classe Documento
potrebbe essere ridefinita come segue:
classe Documento
string stampa()
ritorna titolo + " " + testo
string titoloFile()
ritorna rimuoviGliSpazi(titolo) + ".documento.txt"
mentre la classe SMS
potrebbe contenere la propria versione di stampa()
:
classe SMS che deriva da Documento
string stampa()
ritorna destinatario + " " + testo
string titoloFile()
ritorna rimuoviGliSpazi(destinatario) + ".sms.txt"
La funzione salvaSuDisco
a questo punto potrebbe limitarsi ad invocare i metodi appena definiti, delegando ai singoli oggetti le corrette implementazioni:
classe Storage
void salvaSuDisco(doc)
scriviIlFile(doc.titoloFile(), doc.stampa())
Conclusioni
Il principio di Liskov è importantissimo anche in funzione della sua stretta correlazione con il principio Open/Close che abbiamo visto nella lezione precedente. Un software che non rispetta questo principio rischia di sviluppare un alto grado di viscosità e rigidità in breve tempo.