Vai al contenuto

Principio di sostituzione di Liskov

Il Principio di Sostituzione di Liskov è uno dei cinque principi di progettazione che costituiscono SOLID e probabilmente uno dei più difficili da comprendere appieno. Prima di approfondire come possiamo applicarlo al nostro codice, diamo un’occhiata alla definizione:

In un programma, se S è un sottotipo di T, gli oggetti di tipo T possono essere sostituiti con oggetti di tipo S senza alterare nessuna delle proprietà desiderabili del programma (correttezza, compito svolto, ecc.)

Il principio di sostituzione di Liskov afferma che qualsiasi implementazione di una classe astratta o di un’interfaccia dovrebbe essere sostituibile ovunque l’astrazione sia accettata. Quando estendi una classe, ricorda che dovresti essere in grado di passare oggetti della sottoclasse al posto degli oggetti della classe genitore senza “rompere” il codice client. Ciò significa che la sottoclasse dovrebbe rimanere compatibile con il comportamento della superclasse. Quando si esegue l’override di un metodo, estendere il comportamento di base anziché sostituirlo completamente con qualcos’altro. Ogni classe che implementa un’interfaccia, deve essere in grado di sostituire qualsiasi riferimento nel codice che implementa quella stessa interfaccia.

Che cos’è esattamente un sottoclasse?

Nella maggior parte dei linguaggi orientati agli oggetti un sottoclasse può essere una classe che estende un’altra classe o una classe che implementa un’interfaccia.

Diamo un’occhiata a un esempio in PHP.

interface Animale {}
class Gatto implements Animale {}
class Persiano extends Gatto{}

// una classe che implementa un interfaccia è considerata una sottoclasse dell'interfaccia
is_subclass_of(Gatto::class, Animale::class); // bool(true)

// una classe che estende un'altra classe è considerata una sottoclasse della classe madre
is_subclass_of(Persiano::class, Gatto::class); // bool(true)

// Per estensione, la classe figlio è considerata una sottoclasse dell'interfaccia implementata dalla classe madre
is_subclass_of(Persiano::class, Animale::class); // bool(true)

Covarianza e controvarianza

In informatica, covarianza e controvarianza sono proprietà che caratterizzano alcuni operatori sui tipi. Un operatore è covariante se conserva la relazione di sottotipo, controvariante se la inverte.

Come definito nel principio aperto-chiuso (la “O” in SOLID), le classi e soprattutto le interfacce dovrebbero essere aperte per l’estensione ma chiuse per la modifica. Quindi non è possibile semplicemente chiudere ogni tipo dall’estensione.

Invece, possiamo imporre alcuni requisiti in modo che l’utilizzo del tipo originale non possa essere modificato.

1. Gli argomenti del metodo del sottotipo devono essere controvarianti 

Supponiamo di avere un’interfaccia RifugioGatti al cui interno vi è la firma del metodo takeIn che prende in input un oggetto di tipo Gatto (quindi essenzialmente accogliere un nuovo gatto).

interface RifugioGatti {
    public function takeIn(Gatto $cat): void;
}

Quando implementiamo questa interfaccia, dobbiamo assicurarci che qualsiasi codice chiama il metodo takeIn() insieme a un Gatto. Tuttavia, poiché gli argomenti del metodo di un sottotipo sono controvarianti, possiamo ampliare i tipi di argomento.

class RifugioAnimali implements RifugioGatti {
    public function takeIn(Animale $animal): void 
    {
        // Questa è ancora un'implementazione valida, perché rispettiamo il 
        // contratto dell'interfaccia affermando che takeIn() dovrebbe sempre accettare un Gatto come 
        // argomento. Accettando qualsiasi Animale invece non abbiamo violato tale 
        // requisito.
    }
}

Il fatto che gli argomenti del metodo siano controvarianti non solo significa che possiamo ampliare i loro tipi, ma anche che non possiamo restringere i loro tipi.

class RifugioGattoPersiano implements RifugioGatti {
    public function takeIn(Persiano $persiano): void 
    {
        // Questa NON è un'implementazione valida, perché restringendo il 
        // tipo di argomento a Persiano, violando il contratto definito in 
        // RifugioGatti dove era consentito a qualsiasi Gatto.
    }
}

Per le implementazioni dell’interfaccia, questo di solito è abbastanza chiaro perché siamo abituati a codificare in base all’interfaccia e non all’implementazione.

Ma lo stesso vale anche per le classi che estendono un’altra classe, quando gli sviluppatori a volte dimenticano che devono ancora onorare il contratto della classe originale affinché il Principio di sostituzione di Liskov rimanga valido.

2. I tipi restituiti dal metodo del sottotipo devono essere covarianti

La Covarianza è l’opposto della Controvarianza. Il fatto che i tipi restituiti siano covarianti significa che puoi renderli più stretti nelle sottoclassi, ma non più ampi. Questo può essere illustrato come segue.

interface RifugioGatti {
    public function adottaGatto(): Gatto;
}

class RifugioGattoPersiano implements RifugioGatti {
    public function adottaGatto(): Persiano
    {
        // Anche se abbiamo ristretto il tipo di restituzione a Persiano anziché a Gatto, 
        // stiamo ancora rispettando il contratto RifugioGatti perché tecnicamente 
        // restituiamo sempre un Gatto come richiesto.
    }
}

Invece:

class RifugioGattoPersiano implements RifugioGatti { 
    public function adottaGatto(): Animale
    {
        // Questa NON è un'implementazione valida, perché allargando il tipo restituito 
        // non possiamo più garantire che l'istanza restituita sarà un Gatto e 
        // il codice che chiama questo metodo potrebbe non sapere come gestire un altro tipo 
        // di Animale.
    }
}

3. Nessuna nuova eccezione dovrebbe essere generata dai metodi della sottoclasse, a meno che tali eccezioni siano esse stesse sottoclassi di eccezioni generate dai metodi della superclasse.

L’idea è che se una sottoclasse inizia a generare eccezioni che il tipo originale non genererebbe mai, il codice che utilizza quella sottoclasse non saprebbe come gestire quell’eccezione se si aspetta solo le eccezioni del tipo originale.

Tuttavia, in molti casi, potrebbe non avere nemmeno un modo sensato per gestire tali eccezioni e l’eccezione verrebbe rilevata dal gestore degli errori globale configurato nell’applicazione.