È una violazione del principio di sostituzione di Liskov?


133

Supponiamo di avere un elenco di entità Task e un ProjectTasksottotipo. Le attività possono essere chiuse in qualsiasi momento, tranne ProjectTasksche non possono essere chiuse una volta che hanno lo stato Avviato. L'interfaccia utente dovrebbe garantire che l'opzione per chiudere un avvio ProjectTasknon sia mai disponibile, ma nel dominio sono presenti alcune garanzie:

public class Task
{
     public Status Status { get; set; }

     public virtual void Close()
     {
         Status = Status.Closed;
     }
}

public class ProjectTask : Task
{
     public override void Close()
     {
          if (Status == Status.Started) 
              throw new Exception("Cannot close a started Project Task");

          base.Close();
     }
}

Ora quando si chiama Close()un'attività, è possibile che la chiamata fallisca se è ProjectTaskcon lo stato avviato, quando non sarebbe se fosse un'attività base. Ma questi sono i requisiti aziendali. Dovrebbe fallire. Questo può essere considerato una violazione del principio di sostituzione di Liskov ?


14
Perfetto per un esempio di violazione della sostituzione di Liskov. Non usare l'eredità qui e andrà tutto bene.
Jimmy Hoffa,

8
Potresti volerlo cambiare in public Status Status { get; private set; }:; altrimenti il Close()metodo può essere aggirato.
Giobbe

5
Forse è solo questo esempio, ma non vedo alcun vantaggio materiale nel rispetto dell'LSP. Per me, questa soluzione nella domanda è più chiara, più facile da capire e più facile da mantenere rispetto a una conforme a LSP.
Ben Lee,

2
@BenLee Non è più facile da mantenere. Sembra così solo perché lo vedi da solo. Quando il sistema è grande, assicurarsi che i sottotipi di Tasknon introducano bizzarre incompatibilità nel codice polimorfico di cui è a conoscenza Taskè un grosso problema. LSP non è un capriccio, ma è stato introdotto proprio per aiutare la manutenibilità nei sistemi di grandi dimensioni.
Andres F.

8
@BenLee Immagina di avere un TaskCloserprocesso che closesAllTasks(tasks). Questo processo ovviamente non tenta di catturare eccezioni; dopo tutto, non fa parte del contratto esplicito di Task.Close(). Ora introduci ProjectTaske improvvisamente TaskCloserinizi a lanciare (forse non gestite) eccezioni. Questo è un grosso problema!
Andres F.

Risposte:


174

Sì, è una violazione dell'LSP. Liskov principio di sostituzione richiede che

  • Le condizioni preliminari non possono essere rafforzate in un sottotipo.
  • Le postcondizioni non possono essere indebolite in un sottotipo.
  • Gli invarianti del supertipo devono essere conservati in un sottotipo.
  • Vincolo storico (la "regola della storia"). Gli oggetti sono considerati modificabili solo attraverso i loro metodi (incapsulamento). Poiché i sottotipi possono introdurre metodi che non sono presenti nel supertipo, l'introduzione di questi metodi può consentire cambiamenti di stato nel sottotipo che non sono ammessi nel supertipo. Il vincolo storico lo proibisce.

Il tuo esempio rompe il primo requisito rafforzando una precondizione per chiamare il Close()metodo.

Puoi risolverlo portando la pre-condizione rafforzata al livello più alto della gerarchia ereditaria:

public class Task {
    public Status Status { get; set; }
    public virtual bool CanClose() {
        return true;
    }
    public virtual void Close() {
        Status = Status.Closed;
    }
}

Stabilendo che una chiamata di Close()è valida solo nello stato in cui i CanClose()ritorni truesi applicano le condizioni preliminari al Task, nonché al ProjectTask, fissando la violazione LSP:

public class ProjectTask : Task {
    public override bool CanClose() {
        return Status != Status.Started;
    }
    public override void Close() {
        if (Status == Status.Started) 
            throw new Exception("Cannot close a started Project Task");
        base.Close();
    }
}

17
Non mi piace la duplicazione di quel controllo. Preferirei lanciare un'eccezione andando in Task.Close e rimuovere virtuale da Close.
Euforico

4
@Euphoric Questo è vero, fare in modo che il livello più alto Closefaccia il controllo e aggiungere una protezione DoClosesarebbe una valida alternativa. Tuttavia, volevo rimanere il più vicino possibile all'esempio del PO; migliorarlo è una domanda separata.
dasblinkenlight,

5
@Euforico: ma ora non c'è modo di rispondere alla domanda "È possibile chiudere questo compito?" senza provare a chiuderlo. Ciò forza inutilmente l'uso di eccezioni per il controllo del flusso. Devo ammettere, tuttavia, che questo genere di cose può essere portato troppo lontano. Se preso troppo lontano, questo tipo di soluzione può finire per produrre un pasticcio intraprendente. Indipendentemente da ciò, la domanda del PO mi sembra di più sui principi, quindi una risposta della torre d'avorio è molto appropriata. +1
Brian,

30
@Brian Il CanClose è ancora lì. Può ancora essere chiamato per verificare se l'attività può essere chiusa. Anche il check in Close dovrebbe chiamare questo.
Euforico

5
@Euforico: Ah, ho capito male. Hai ragione, è una soluzione molto più pulita.
Brian,

82

Sì. Questo viola LSP.

Il mio suggerimento è di aggiungere CanClosemetodo / proprietà all'attività di base, in modo che qualsiasi attività possa dire se l'attività in questo stato può essere chiusa. Può anche fornire un motivo per cui. E rimuovi il virtuale da Close.

Sulla base del mio commento:

public class Task {
    public Status Status { get; private set; }

    public virtual bool CanClose(out String reason) {
        reason = null;
        return true;
    }
    public void Close() {
        String reason;
        if (!CanClose(out reason))
            throw new Exception(reason);

        Status = Status.Closed;
    }
}

public ProjectTask : Task {
    public override bool CanClose(out String reason) {
        if (Status != Status.Started)
        {
            reason = "Cannot close a started Project Task";
            return false;
        }
        return base.CanClose(out reason);
    }
}

3
Grazie per questo, hai portato l'esempio di dasblinkenlight a un livello superiore, ma mi è piaciuta la sua spiegazione e la sua giustificazione. Spiacente, non posso accettare 2 risposte!
Paul T Davies,

Sono interessato a sapere perché la firma è CanClose (il motivo per String) del public virtual bool - usando out siete solo a prova di futuro? O c'è qualcosa di più sottile che mi manca?
Reacher Gilt,

3
@ReacherGilt Penso che dovresti controllare cosa fare / ref e leggere di nuovo il mio codice. Sei confuso. Semplicemente "Se l'attività non può essere chiusa, voglio sapere perché."
Euforico,

2
out non è disponibile in tutte le lingue, restituendo una tupla (o un semplice oggetto che incapsula la ragione e il booleano lo renderebbe più portatile in tutte le lingue OO anche se a costo di perdere la facilità di avere un bool diretto. Detto questo, per le lingue che FANNO supporto, niente di sbagliato in questa risposta.
Newtopian

1
Ed è OK rafforzare le condizioni preliminari per la proprietà CanClose? Vale a dire aggiungere la condizione?
Giovanni V,

24

Il principio di sostituzione di Liskov afferma che una classe base dovrebbe essere sostituibile con una qualsiasi delle sue sottoclassi senza alterare nessuna delle proprietà desiderabili del programma. Dal momento che ProjectTasksolleva un'eccezione solo quando chiuso, un programma dovrebbe essere modificato per adattarsi a tale, dovrebbe ProjectTaskessere utilizzato in sostituzione di Task. Quindi è una violazione.

Ma se modifichi Taskdichiarando che potrebbe sollevare un'eccezione quando chiuso, non violeresti il ​​principio.


Uso c # che non credo abbia questa possibilità, ma so che Java lo fa.
Paul T Davies,

2
@PaulTDavies Puoi decorare un metodo con quali eccezioni genera, msdn.microsoft.com/en-us/library/5ast78ax.aspx . Lo noti quando passi con il mouse sopra un metodo dalla libreria di classi base otterrai un elenco di eccezioni. Non viene applicato, ma rende comunque consapevole il chiamante.
Despertar,

18

Una violazione di LSP richiede tre parti. Il tipo T, il sottotipo S e il programma P che utilizza T ma riceve un'istanza di S.

La tua domanda ha fornito T (Task) e S (ProjectTask), ma non P. Quindi la tua domanda è incompleta e la risposta è qualificata: se esiste una P che non si aspetta un'eccezione, per quella P hai un LSP violazione. Se ogni P prevede un'eccezione, non vi è alcuna violazione LSP.

Tuttavia, è fare un SRP violazione. Il fatto che lo stato di un'attività possa essere modificato e la politica secondo cui determinati compiti in determinati stati non dovrebbero essere cambiati in altri stati, sono due responsabilità molto diverse.

  • Responsabilità 1: rappresentare un'attività.
  • Responsabilità 2: implementare le politiche che cambiano lo stato delle attività.

Queste due responsabilità cambiano per ragioni diverse e quindi dovrebbero essere in classi separate. Le attività dovrebbero gestire il fatto di essere un'attività e i dati associati a un'attività. TaskStatePolicy dovrebbe gestire il modo in cui le attività passano da uno stato all'altro in una determinata applicazione.


2
Le responsabilità dipendono fortemente dal dominio e (in questo esempio) dalla complessità degli stati delle attività e dei relativi modificatori. In questo caso, non vi è alcuna indicazione di tale cosa, quindi non ci sono problemi con SRP. Per quanto riguarda la violazione di LSP, credo che tutti abbiamo supposto che il chiamante non si aspettasse un'eccezione e l'applicazione dovrebbe mostrare un messaggio ragionevole invece di entrare in uno stato errato.
Euforico

Unca 'Bob risponde? "Non siamo degni! Non siamo degni!". Ad ogni modo ... Se ogni P prevede un'eccezione, non vi è alcuna violazione di LSP. MA se stipuliamo un'istanza T non possiamo lanciare un OpenTaskException(suggerimento, suggerimento) e ogni P si aspetta un'eccezione, allora cosa dice questo sul codice da interfacciare, non sull'implementazione? Di cosa sto parlando? Non lo so. Sono solo jazz che sto commentando una risposta di Unca Bob.
radarbob,

3
Hai ragione nel dimostrare che una violazione di LSP richiede tre oggetti. Tuttavia, la violazione di LSP esiste se esiste QUALSIASI programma P che era corretto in assenza di S ma fallisce con l'aggiunta di S.
kevin cline

16

Ciò può o meno costituire una violazione dell'LSP.

Sul serio. Ascoltami.

Se segui l'LSP, gli oggetti di tipo ProjectTaskdevono comportarsi come dovrebbero comportarsi gli oggetti di tipo Task.

Il problema con il tuo codice è che non hai documentato come Taskdovrebbero comportarsi gli oggetti di tipo . Hai un codice scritto, ma nessun contratto. Aggiungerò un contratto per Task.Close. A seconda del contratto che aggiungo, il codice per ProjectTask.Closeo non segue o meno l'LSP.

Dato il seguente contratto per Task.Close, il codice per ProjectTask.Close non segue l'LSP:

     // Behaviour: Moves the task to the closed state
     // and does not throw any Exception.
     // Default behaviour: Moves the task to the closed state
     // and does not throw any Exception.
     public virtual void Close()
     {
         Status = Status.Closed;
     }

Dato il seguente contratto per Task.Close, il codice per ProjectTask.Close non seguire la LSP:

     // Behaviour: Moves the task to the closed status if possible.
     // If this is not possible, this method throws an Exception
     // and leaves the status unchanged.
     // Default behaviour: Moves the task to the closed state
     // and does not throw any Exception.
     public virtual void Close()
     {
         Status = Status.Closed;
     }

I metodi che possono essere sovrascritti devono essere documentati in due modi:

  • Il "Comportamento" documenta ciò su cui può fare affidamento un client che conosce l'oggetto destinatario a Task, ma non sa di quale classe è un'istanza diretta. Indica anche ai progettisti di sottoclassi quali sostituzioni sono ragionevoli e quali non sono ragionevoli.

  • Il "Comportamento predefinito" documenta ciò su cui può fare affidamento un client che sa che l'oggetto destinatario è un'istanza diretta di Task(ovvero ciò che si ottiene se si utilizza new Task(). Indica inoltre ai progettisti di sottoclassi quale comportamento verrà ereditato se non lo fanno sovrascrivere il metodo.

Ora dovrebbero tenere le seguenti relazioni:

  • Se S è un sottotipo di T, il comportamento documentato di S dovrebbe perfezionare il comportamento documentato di T.
  • Se S è un sottotipo di (o uguale a) T, il comportamento del codice S dovrebbe perfezionare il comportamento documentato di T.
  • Se S è un sottotipo di (o uguale a) T, il comportamento predefinito di S dovrebbe perfezionare il comportamento documentato di T.
  • Il comportamento effettivo del codice per una classe dovrebbe perfezionare il comportamento predefinito documentato.

@ user61852 ha sollevato il punto che è possibile indicare nella firma del metodo che può sollevare un'eccezione, e semplicemente facendo questo (qualcosa che non ha un vero codice effetto) non si rompe più LSP.
Paul T Davies,

@PaulTDavies Hai ragione. Ma nella maggior parte delle lingue la firma non è un buon modo per dichiarare che una routine può generare un'eccezione. Ad esempio, nel PO (in C #, penso) la seconda implementazione di Closebutta. Quindi la firma dichiara che può essere generata un'eccezione - non dice che non lo sarà. Java fa un lavoro migliore in questo senso. Anche così, se dichiari che un metodo potrebbe dichiarare un'eccezione, dovresti documentare le circostanze in cui può (o lo farà). Quindi sostengo che per essere sicuri che LSP sia violato, abbiamo bisogno di documentazione oltre la firma.
Theodore Norvell,

4
Molte risposte qui sembrano ignorare completamente il fatto che non puoi sapere se un contratto è validato se non conosci il contratto. Grazie per la risposta
gnasher729,

Buona risposta, ma anche le altre risposte sono buone. Ne deducono che la classe base non genera eccezioni perché in quella classe non c'è nulla che mostri segni di ciò. Quindi il programma, che utilizza la classe base, non dovrebbe prepararsi per le eccezioni.
inf3rno,

Hai ragione che l'elenco delle eccezioni dovrebbe essere documentato da qualche parte. Penso che il posto migliore sia nel codice. C'è una domanda correlata qui: stackoverflow.com/questions/16700130/… Ma puoi farlo senza annotazioni, ecc ... puoi anche scrivere qualcosa di simile if (false) throw new Exception("cannot start")alla classe base. Il compilatore lo rimuoverà e il codice contiene ancora ciò che è necessario. Btw. abbiamo ancora una violazione di LSP con queste soluzioni alternative, perché il prerequisito è ancora rafforzato ...
inf3rno

6

Non è una violazione del principio di sostituzione di Liskov.

Il principio di sostituzione di Liskov afferma:

Let q (x) sia una proprietà dimostrabile sugli oggetti x di tipo T . Diamo S essere un sottotipo di T . Il tipo S viola il principio di sostituzione di Liskov se esiste un oggetto y di tipo S , tale che q (y) non è provabile.

Il motivo, per cui l'implementazione del sottotipo non costituisce una violazione del Principio di sostituzione di Liskov, è piuttosto semplice: nulla può essere dimostrato su ciò che Task::Close()effettivamente fa. Certo, ProjectTask::Close()genera un'eccezione quando Status == Status.Started, ma così potrebbe Status = Status.Closedin Task::Close().


4

Sì, è una violazione.

Suggerirei di avere la tua gerarchia al contrario. Se non tutto Taskè richiudibile, allora close()non appartiene Task. Forse vuoi un'interfaccia CloseableTaskche tutti i non- ProjectTaskspossono implementare.


3
Ogni compito è chiudibile, ma non in ogni circostanza.
Paul T Davies,

Questo approccio mi sembra rischioso poiché le persone potrebbero scrivere codice aspettandosi che tutte le attività implementino ClosableTask, sebbene modelli accuratamente il problema. Sono diviso tra questo approccio e una macchina a stati perché odio le macchine a stati.
Jimmy Hoffa,

Se Tasknon si implementa CloseableTaskda solo, stanno facendo un cast non sicuro da qualche parte per persino chiamare Close().
Tom G,

@TomG è quello di cui ho paura
Jimmy Hoffa,

1
C'è già una macchina a stati. L'oggetto non può essere chiuso perché è nello stato sbagliato.
Kaz,

3

Oltre ad essere un problema LSP, sembra che stia usando le eccezioni per controllare il flusso del programma (devo presumere che tu catturi questa banale eccezione da qualche parte e fai un flusso personalizzato piuttosto che lasciarlo andare in crash la tua app).

Sembra che questo sia un buon posto per implementare il modello di stato per TaskState e lasciare che gli oggetti di stato gestiscano le transizioni valide.


1

Mi manca qui una cosa importante relativa a LSP e Design by Contract - in precondizioni, è il chiamante la cui responsabilità è assicurarsi che le condizioni siano soddisfatte. Il codice chiamato, nella teoria DbC, non dovrebbe verificare il presupposto. Il contratto deve specificare quando un'attività può essere chiusa (ad es. CanClose restituisce True) e quindi il codice chiamante deve garantire il rispetto delle condizioni preliminari, prima di chiamare Close ().


Il contratto deve specificare qualsiasi comportamento necessario all'azienda. In questo caso, quel Close () genererà un'eccezione quando chiamato all'avvio ProjectTask. Questa è una post-condizione (dice cosa succede dopo che il metodo è stato chiamato) e soddisfarla è la responsabilità del codice chiamato.
Goyo,

@Goyo Sì, ma come altri hanno detto, l'eccezione viene sollevata nel sottotipo che ha rafforzato il presupposto e quindi violato il contratto (implicito) che chiamare Close () semplicemente chiude l'attività.
Ezoela Vacca,

Quale condizione preliminare? Non ne vedo nessuno.
Goyo,

@Goyo Controlla la risposta accettata, ad esempio :) Nella classe base, Close non ha precondizioni, viene chiamato e chiude l'attività. Nel bambino, tuttavia, esiste una condizione preliminare che lo stato non venga avviato. Come altri hanno sottolineato, si tratta di criteri più forti e il comportamento non è quindi sostituibile.
Ezoela Vacca,

Non importa, ho trovato il presupposto nella domanda. Ma poi non c'è nulla di sbagliato (dal punto di vista DbC) nel fatto che il codice chiamato controlli le condizioni preliminari e generi eccezioni quando non vengono rispettate. Si chiama "programmazione difensiva". Inoltre, se esiste una post-condizione che indica cosa succede quando la pre-condizione non è soddisfatta come in questo caso, l'implementazione deve verificare la pre-condizione al fine di garantire che la post-condizione sia soddisfatta.
Goyo,

0

Sì, è una chiara violazione di LSP.

Alcune persone sostengono che rendere esplicito nella classe base che le sottoclassi possano generare eccezioni lo renderebbe accettabile, ma non credo sia vero. Indipendentemente da ciò che documenti nella classe di base o in quale livello di astrazione sposti il ​​codice, le condizioni preliminari verranno comunque rafforzate nella sottoclasse, perché si aggiunge la parte "Impossibile chiudere un'attività di progetto avviata". Questo non è qualcosa che puoi risolvere con una soluzione alternativa, hai bisogno di un modello diverso, che non viola LSP (o dobbiamo allentare il vincolo "le condizioni preliminari non possono essere rafforzate").

Puoi provare il motivo del decoratore se vuoi evitare la violazione LSP in questo caso. Potrebbe funzionare, non lo so.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.