Perché Square ereditando da Rectangle sarebbe problematico se sovrascrivessimo i metodi SetWidth e SetHeight?


105

Se un quadrato è un tipo di rettangolo, allora perché un quadrato non può ereditare da un rettangolo? O perché è un cattivo design?

Ho sentito persone dire:

Se hai creato Square derivato da Rectangle, un Square dovrebbe essere utilizzabile ovunque ti aspetti un rettangolo

Qual è il problema qui? E perché Square sarebbe utilizzabile ovunque ti aspetti un rettangolo? Sarebbe utilizzabile solo se creiamo l'oggetto Square e se sovrascriviamo i metodi SetWidth e SetHeight per Square di perché dovremmo avere qualche problema?

Se avevi metodi SetWidth e SetHeight sulla tua classe base Rectangle e se il riferimento Rectangle puntava su un quadrato, SetWidth e SetHeight non hanno senso perché impostarne uno cambierebbe l'altro in modo che corrispondesse. In questo caso Square non supera il test di sostituzione di Liskov con Rectangle e l'astrazione di avere eredità Square da Rectangle è negativa.

Qualcuno può spiegare gli argomenti di cui sopra? Ancora una volta, se superassimo i metodi SetWidth e SetHeight in Square, non risolveremmo questo problema?

Ho anche sentito / letto:

Il vero problema è che non stiamo modellando rettangoli, ma piuttosto "rettangoli rimodellabili", cioè rettangoli la cui larghezza o altezza possono essere modificati dopo la creazione (e lo consideriamo ancora come lo stesso oggetto). Se guardiamo alla classe rettangolo in questo modo, è chiaro che un quadrato non è un "rettangolo rimodellabile", perché un quadrato non può essere rimodellato ed essere comunque un quadrato (in generale). Matematicamente, non vediamo il problema perché la mutabilità non ha nemmeno senso in un contesto matematico

Qui credo che "ridimensionabile" sia il termine corretto. I rettangoli sono "ridimensionabili", così come i quadrati. Mi manca qualcosa nell'argomento sopra? Un quadrato può essere ridimensionato come qualsiasi rettangolo.


15
Questa domanda sembra terribilmente astratta. Esistono molti modi per utilizzare le classi e l'ereditarietà, indipendentemente dal fatto che far ereditare o meno una classe da una classe sia una buona idea di solito dipende principalmente dal modo in cui si desidera utilizzare tali classi. Senza un caso pratico non vedo come questa domanda possa ottenere una risposta pertinente.
aaaaaaaaaaaa

2
Usando un po 'di buon senso si ricorda che il quadrato è un rettangolo, quindi se l'oggetto della tua classe quadrata non può essere usato dove è richiesto il rettangolo, probabilmente è comunque un difetto di progettazione dell'applicazione.
Cthulhu,

7
Penso che la domanda migliore sia Why do we even need Square? È come avere due penne. Una penna blu e una penna rossa blu, gialla o verde. La penna blu è ridondante, tanto più nel caso del quadrato in quanto non ha alcun vantaggio in termini di costi.
Gusdor,

2
@eBusiness La sua astrattezza è ciò che lo rende una buona domanda di apprendimento. È importante essere in grado di riconoscere quali usi del sottotipo sono cattivi indipendentemente da casi d'uso particolari.
Doval,

5
@Cthulhu Non proprio. Il sottotipo riguarda il comportamento e un quadrato mutabile non si comporta come un rettangolo mutabile. Questo è il motivo per cui la metafora "è un ..." è cattiva.
Doval,

Risposte:


189

Fondamentalmente vogliamo che le cose si comportino in modo sensato.

Considera il seguente problema:

Mi viene dato un gruppo di rettangoli e voglio aumentare la loro area del 10%. Quindi quello che faccio è impostare la lunghezza del rettangolo a 1,1 volte rispetto a prima.

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    rectangle.Length = rectangle.Length * 1.1;
  }
}

Ora in questo caso, tutti i miei rettangoli ora hanno una lunghezza aumentata del 10%, che aumenterà la loro area del 10%. Sfortunatamente, qualcuno mi ha effettivamente passato un misto di quadrati e rettangoli, e quando la lunghezza del rettangolo è stata cambiata, così è stata la larghezza.

I miei test unitari superano perché ho scritto tutti i test unitari per utilizzare una raccolta di rettangoli. Ora ho introdotto un bug sottile nella mia applicazione che può passare inosservato per mesi.

Peggio ancora, Jim dalla contabilità vede il mio metodo e scrive qualche altro codice che usa il fatto che se passa quadrati nel mio metodo, ottiene un aumento del 21% delle dimensioni. Jim è felice e nessuno è più saggio.

Jim viene promosso per un lavoro eccellente in una divisione diversa. Alfred entra a far parte dell'azienda come junior. Nella sua prima segnalazione di bug, Jill di Advertising ha riferito che passare quadrati a questo metodo comporta un aumento del 21% e vuole che il bug sia risolto. Alfred vede che Quadrati e Rettangoli sono usati ovunque nel codice e si rende conto che è impossibile spezzare la catena ereditaria. Inoltre, non ha accesso al codice sorgente di contabilità. Quindi Alfred corregge il bug in questo modo:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

Alfred è contento delle sue superbe abilità di hacking e Jill firma che il bug è stato corretto.

Il mese prossimo nessuno viene pagato perché la contabilità dipendeva dalla possibilità di passare quadrati al IncreaseRectangleSizeByTenPercentmetodo e ottenere un aumento dell'area del 21%. L'intera azienda passa in modalità "priorità 1 bugfix" per rintracciare l'origine del problema. Tracciano il problema alla correzione di Alfred. Sanno che devono rendere felici sia la contabilità che la pubblicità. Quindi risolvono il problema identificando l'utente con la chiamata del metodo in questo modo:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  IncreaseRectangleSizeByTenPercent(
    rectangles, 
    new User() { Department = Department.Accounting });
}

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles, User user)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle || user.Department == Department.Accounting)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    else if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

E così via e così via.

Questo aneddoto si basa su situazioni del mondo reale che affrontano i programmatori quotidianamente. Le violazioni del principio di sostituzione di Liskov possono introdurre bug molto sottili che vengono rilevati solo anni dopo che sono stati scritti, a quel punto correggere la violazione romperà un mucchio di cose e non risolverà farà arrabbiare il tuo più grande cliente.

Esistono due modi realistici per risolvere questo problema.

Il primo modo è di rendere immutabile Rectangle. Se l'utente di Rectangle non può modificare le proprietà Lunghezza e Larghezza, questo problema scompare. Se vuoi un rettangolo con lunghezza e larghezza diverse, ne crei uno nuovo. I quadrati possono ereditare felicemente dai rettangoli.

Il secondo modo è spezzare la catena di eredità tra quadrati e rettangoli. Se si definisce un quadrato con una sola SideLengthproprietà e i rettangoli hanno una Lengthe Widthproprietà e non c'è eredità, è impossibile rompere accidentalmente le cose aspettandosi un rettangolo e ottenendo un quadrato. In termini di C #, potresti avere la sealtua classe rettangolo, il che assicura che tutti i rettangoli che ottieni siano in realtà rettangoli.

In questo caso, mi piace il modo "oggetti immutabili" di risolvere il problema. L'identità di un rettangolo è la sua lunghezza e larghezza. Ha senso che quando vuoi cambiare l'identità di un oggetto, quello che vuoi veramente è un nuovo oggetto. Se perdi un vecchio cliente e guadagni un nuovo cliente, non cambi il Customer.Idcampo dal vecchio cliente a quello nuovo, crei un nuovoCustomer .

Le violazioni del principio di sostituzione di Liskov sono comuni nel mondo reale, soprattutto perché un sacco di codice là fuori è scritto da persone che sono incompetenti / sotto la pressione del tempo / non si preoccupano / commettono errori. Può e porta ad alcuni problemi molto cattivi. Nella maggior parte dei casi, si preferisce invece la composizione rispetto all'eredità .


7
Liskov è una cosa e l'archiviazione è un altro problema. Nella maggior parte delle implementazioni, un'istanza Square che eredita da Rectangle richiederà spazio per la memorizzazione di due dimensioni, anche se ne è necessaria solo una.
el.pescado

29
Uso brillante di una storia per illustrare il punto
Rory Hunter

29
Bella storia ma non sono d'accordo. Il caso d'uso era: modificare l'area di un rettangolo. La correzione dovrebbe aggiungere un metodo sostituibile "ChangeArea" al rettangolo che si specializza in Square. Ciò non spezzerebbe la catena dell'eredità, renderebbe esplicito ciò che l'utente voleva fare e non avrebbe causato il bug introdotto dalla correzione menzionata (che sarebbe stata catturata in un'area di gestione temporanea adeguata).
Roy T.

33
@RoyT .: Perché un rettangolo dovrebbe sapere come impostare la sua area? Questa è una proprietà derivata interamente dalla lunghezza e larghezza. E più precisamente, quale dimensione dovrebbe cambiare: la lunghezza, la larghezza o entrambi?
cHao,

32
@Roy T. È tutto molto bello dire che avresti risolto il problema in modo diverso, ma il fatto è che questo è un esempio - seppure semplificato - di situazioni del mondo reale che gli sviluppatori affrontano quotidianamente quando mantengono prodotti legacy. E anche se hai implementato quel metodo, ciò non impedirà agli eredi di violare LSP e introdurre bug simili a questo. Questo è il motivo per cui praticamente ogni classe nel framework .NET è sigillata.
Stephen,

30

Se tutti i tuoi oggetti sono immutabili, non ci sono problemi. Ogni quadrato è anche un rettangolo. Tutte le proprietà di un rettangolo sono anche proprietà di un quadrato.

Il problema inizia quando si aggiunge la possibilità di modificare gli oggetti. O davvero - quando inizi a passare argomenti all'oggetto, non solo a leggere i getter di proprietà.

Esistono modifiche che puoi apportare a un Rettangolo che mantengono tutti gli invarianti della tua classe Rettangolo, ma non tutti gli invarianti Quadrati, come cambiare la larghezza o l'altezza. Improvvisamente il comportamento di un rettangolo non è solo le sue proprietà, ma anche le sue possibili modifiche. Non è solo ciò che ottieni da Rectangle, è anche ciò che puoi inserire .

Se il tuo rettangolo ha un metodo setWidthche è documentato come cambiare la larghezza e non modificare l'altezza, Square non può avere un metodo compatibile. Se si modifica la larghezza e non l'altezza, il risultato non è più un quadrato valido. Se hai scelto di modificare sia la larghezza che l'altezza del quadrato durante l'utilizzo setWidth, non stai implementando le specifiche di Rectangle's setWidth. Non puoi vincere.

Quando guardi cosa puoi "mettere" in un rettangolo e in un quadrato, quali messaggi puoi inviare loro, probabilmente scoprirai che qualsiasi messaggio che puoi validamente inviare a un quadrato, puoi anche inviare a un rettangolo.

È una questione di co-varianza contro contro-varianza.

I metodi di una sottoclasse corretta, uno in cui è possibile utilizzare le istanze in tutti i casi in cui è prevista la superclasse, richiedono ogni metodo per:

  • Restituisce solo i valori restituiti dalla superclasse, ovvero il tipo restituito deve essere un sottotipo del tipo restituito dal metodo superclasse. Il ritorno è una variante.
  • Accettare tutti i valori che il supertipo accetterebbe, ovvero i tipi di argomento devono essere supertipi dei tipi di argomento del metodo superclasse. Gli argomenti sono contro-varianti.

Quindi, tornando a Rettangolo e Quadrato: se Quadrato può essere una sottoclasse di Rettangolo dipende interamente da quali metodi ha Rettangolo.

Se Rectangle ha setter individuali per larghezza e altezza, Square non sarà una sottoclasse valida.

Allo stesso modo, se si rendono alcuni metodi di co-variante negli argomenti, come avere compareTo(Rectangle)su Rectangle ecompareTo(Square) su Square, avrai un problema a usare un quadrato come un rettangolo.

Se progetti Square e Rectangle in modo che siano compatibili, probabilmente funzionerà, ma dovrebbero essere sviluppati insieme, o scommetto che non funzionerà.


"Se tutti i tuoi oggetti sono immutabili, non ci sono problemi" - questa è un'apparenza apparentemente irrilevante nel contesto di questa domanda, che menziona esplicitamente i setter per larghezza e altezza
moscerino

11
L'ho trovato interessante, anche quando è "apparentemente irrilevante"
Jesvin Jose,

14
@gnat Direi che è rilevante perché il vero valore della domanda è riconoscere quando esiste una relazione di sottotipo valida tra due tipi. Dipende dalle operazioni dichiarate dal supertipo, quindi vale la pena sottolineare che il problema scompare se i metodi mutatori scompaiono.
Doval,

1
@gnat, anche i setter sono mutatori , quindi lrn sta essenzialmente dicendo: "Non farlo e non è un problema". Mi capita di concordare con l'immutabilità per i tipi semplici, ma fai un buon punto: per gli oggetti complessi, il problema non è così semplice.
Patrick M,

1
Consideralo in questo modo, qual è il comportamento garantito dalla classe 'Rectangle'? Che puoi cambiare larghezza e altezza INDIPENDENTI l'una dall'altra. (cioè setWidth e setHeight) metodo. Ora, se Square è derivato da Rectangle, Square deve garantire questo comportamento. Poiché square non può garantire questo comportamento, è una cattiva eredità. Tuttavia, se i metodi setWidth / setHeight vengono rimossi dalla classe Rectangle, non esiste tale comportamento e quindi è possibile derivare la classe Square da Rectangle.
Nitin Bhide

17

Ci sono molte buone risposte qui; La risposta di Stephen in particolare fa un buon lavoro nell'illustrare perché le violazioni del principio di sostituzione portano a conflitti nel mondo reale tra le squadre.

Ho pensato di poter parlare brevemente del problema specifico di rettangoli e quadrati, piuttosto che usarlo come metafora di altre violazioni dell'LSP.

Vi è un ulteriore problema con il tipo di rettangolo speciale è raramente menzionato, e cioè: perché ci fermiamo con quadrati e rettangoli ? Se siamo disposti a dire che un quadrato è un tipo speciale di rettangolo, allora sicuramente dovremmo anche essere disposti a dire:

  • Un quadrato è un tipo speciale di rombo - è un rombo con angoli quadrati.
  • Un rombo è un tipo speciale di parallelogramma: è un parallelogramma con lati uguali.
  • Un rettangolo è un tipo speciale di parallelogramma: è un parallelogramma con angoli quadrati
  • Un rettangolo, un quadrato e un parallelogramma sono tutti un tipo speciale di trapezio: sono trapezi con due serie di lati paralleli
  • Tutti i precedenti sono tipi speciali di quadrilateri
  • Tutti i precedenti sono tipi speciali di forme planari
  • E così via; Potrei continuare per qualche tempo qui.

Cosa diavolo dovrebbero essere qui tutte le relazioni? I linguaggi basati su ereditarietà di classe come C # o Java non sono stati progettati per rappresentare questo tipo di relazioni complesse con diversi tipi di vincoli. È meglio semplicemente evitare completamente la domanda non provando a rappresentare tutte queste cose come classi con relazioni di sottotitolo.


3
Se gli oggetti di forme sono immutabili, allora si potrebbe avere un IShapetipo che include un rettangolo di selezione e può essere disegnato, ridimensionato e serializzato e avere un IPolygonsottotipo con un metodo per riportare il numero di vertici e un metodo per restituire un IEnumerable<Point>. Si potrebbe quindi avere un IQuadrilateralsottotipo che deriva da IPolygon, IRhombuse IRectangle, da ciò, e ISquareda IRhombuse IRectangle. La mutabilità getterebbe tutto fuori dalla finestra e l'ereditarietà multipla non funziona con le classi, ma penso che vada bene con interfacce immutabili.
supercat

In realtà non sono d'accordo con Eric qui (non abbastanza per un -1 però!). Tutte quelle relazioni sono (possibilmente) rilevanti, come menziona @supercat; è solo un problema di YAGNI: non lo si implementa fino a quando non è necessario.
Mark Hurd,

Ottima risposta! Dovrebbe essere molto più alto.
andrew.fox

1
@MarkHurd - non è un problema YAGNI: la gerarchia basata sull'eredità proposta ha la forma della tassonomia descritta, ma non può essere scritta per garantire le relazioni che la definiscono. In che modo IRhombusgarantisce che tutto Pointrestituito dal Enumerable<Point>definito da IPolygoncorrisponda a bordi di uguale lunghezza? Poiché l'implementazione IRhombusdell'interfaccia da sola non garantisce che un oggetto concreto sia un rombo, l'ereditarietà non può essere la risposta.
A. Rager,

14

Da una prospettiva matematica, un quadrato è un rettangolo. Se un matematico modifica il quadrato in modo che non aderisca più al contratto quadrato, si trasforma in un rettangolo.

Ma nella progettazione di OO, questo è un problema. Un oggetto è quello che è, e questo include comportamenti e stato. Se tengo un oggetto quadrato, ma qualcun altro lo modifica in un rettangolo, ciò viola il contratto del quadrato senza colpa mia. Ciò fa accadere ogni sorta di cose cattive.

Il fattore chiave qui è la mutabilità . Una forma può cambiare una volta costruita?

  • Mutabile: se le forme possono cambiare una volta costruite, un quadrato non può avere una relazione is-a con il rettangolo. Il contratto di un rettangolo include il vincolo secondo cui i lati opposti devono avere la stessa lunghezza, ma i lati adiacenti non devono esserlo. Il quadrato deve avere quattro lati uguali. La modifica di un quadrato tramite un'interfaccia rettangolare può violare il contratto quadrato.

  • Immutabile: se le forme non possono cambiare una volta costruite, anche un oggetto quadrato deve sempre soddisfare il contratto rettangolare. Un quadrato può avere una relazione is-a con il rettangolo.

In entrambi i casi è possibile chiedere a un quadrato di produrre una nuova forma in base al suo stato con uno o più cambiamenti. Ad esempio, si potrebbe dire "creare un nuovo rettangolo basato su questo quadrato, tranne per il fatto che i lati opposti A e C sono lunghi il doppio". Poiché viene costruito un nuovo oggetto, la piazza originale continua ad aderire ai suoi contratti.


1
This is one of those cases where the real world is not able to be modeled in a computer 100%. Perchè così? Possiamo ancora avere un modello funzionale di un quadrato e un rettangolo. L'unica conseguenza è che dobbiamo cercare un costrutto più semplice per astrarre su quei due oggetti.
Simon Bergot,

6
C'è più in comune tra rettangoli e quadrati di quello. Il problema è che l'identità di un rettangolo e l'identità di un quadrato sono le sue lunghezze laterali (e l'angolo ad ogni intersezione). La soluzione migliore qui è rendere i quadrati ereditati dai rettangoli, ma renderli entrambi immutabili.
Stephen,

3
@Stephen Concordato. In realtà, renderli immutabili è la cosa ragionevole da fare indipendentemente dai problemi di sottotipizzazione. Non c'è motivo di renderli mutabili: non è più difficile costruire un nuovo quadrato o rettangolo che mutarlo, quindi perché aprire quella lattina di vermi? Ora non devi preoccuparti di alias / effetti collaterali e puoi usarli come chiavi per mappe / dadi, se necessario. Alcuni diranno "performance", a cui dirò "ottimizzazione prematura" fino a quando non avranno effettivamente misurato e dimostrato che l'hot spot è nel codice della forma.
Doval,

Scusate ragazzi, era tardi ed ero super stanco quando ho scritto la risposta. L'ho riscritto per dire cosa intendevo veramente, il cui nocciolo è la mutabilità.

13

E perché Square sarebbe utilizzabile ovunque ti aspetti un rettangolo?

Perché fa parte di ciò che significa essere un sottotipo (vedi anche: principio di sostituzione di Liskov). Puoi farlo, devi essere in grado di farlo:

Square s = new Square(5);
Rect r = s;
doSomethingWith(r); // written assuming a Rect, actually calls Square methods

In realtà lo fai sempre (a volte anche in modo più implicito) quando usi OOP.

e se scavalchiamo i metodi SetWidth e SetHeight per Square di perché dovremmo avere qualche problema?

Perché non puoi ragionevolmente ignorarli Square. Perché un quadrato non può "essere ridimensionato come un rettangolo". Quando l'altezza di un rettangolo cambia, la larghezza rimane la stessa. Ma quando l'altezza di un quadrato cambia, la larghezza deve cambiare di conseguenza. Il problema non è solo ridimensionare, ma ridimensionare in modo indipendente in entrambe le dimensioni.


In molte lingue, non hai nemmeno bisogno della Rect r = s;linea, puoi semplicemente doSomethingWith(s)e il runtime utilizzerà qualsiasi chiamata sper risolvere i Squaremetodi virtuali .
Patrick M,

1
@PatrickM Non ti serve in nessun linguaggio sano che abbia sottotitoli. Ho incluso quella riga per l'esposizione, per essere espliciti.

Quindi eseguire l'override setWidthe setHeightmodificare sia la larghezza che l'altezza.
ApproachingDarknessFish

@ValekHalfHeart Questa è precisamente l'opzione che sto prendendo in considerazione.

7
@ValekHalfHeart: Questa è precisamente la violazione del principio di sostituzione di Liskov che ti perseguiterà e ti farà passare diverse notti insonni cercando di trovare uno strano bug due anni dopo quando hai dimenticato come avrebbe dovuto funzionare il codice.
Jan Hudec,

9

Quello che stai descrivendo è in contrasto con quello che viene chiamato il principio di sostituzione di Liskov . L'idea di base dell'LSP è che ogni volta che usi un'istanza di una particolare classe, dovresti sempre essere in grado di scambiare un'istanza di qualsiasi sottoclasse di quella classe, senza introdurre bug.

Il problema di Rectangle-Square non è davvero un ottimo modo per presentare Liskov. Cerca di spiegare un principio generale usando un esempio che è in realtà abbastanza sottile, e si fonda su una delle definizioni intuitive più comuni in tutta la matematica. Alcuni lo chiamano il problema di Ellipse-Circle per questo motivo, ma è solo leggermente migliore. Un approccio migliore è fare un piccolo passo indietro, usando quello che io chiamo il problema del parallelogramma-rettangolo. Questo rende le cose molto più facili da capire.

Un parallelogramma è un quadrilatero con due coppie di lati paralleli. Ha anche due coppie di angoli congruenti. Non è difficile immaginare un oggetto Parallelogramma lungo queste linee:

class Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Un modo comune di pensare a un rettangolo è come un parallelogramma con angoli retti. A prima vista, questo potrebbe sembrare che Rectangle sia un buon candidato per l'ereditarietà dal Parallelogramma , in modo da poter riutilizzare tutto quel buonissimo codice. Però:

class Rectangle extends Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* BUG: Liskov violations ahead */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Perché queste due funzioni introducono bug in Rectangle? Il problema è che non è possibile modificare gli angoli in un rettangolo : sono definiti come sempre a 90 gradi, quindi questa interfaccia non funziona in realtà per l'eredità di Rettangolo dal parallelogramma. Se cambio un rettangolo in un codice che prevede un parallelogramma e quel codice tenta di cambiare l'angolazione, quasi sicuramente ci saranno dei bug. Abbiamo preso qualcosa che era scrivibile nella sottoclasse e l'abbiamo reso di sola lettura, e questa è una violazione di Liskov.

Ora, come si applica questo a quadrati e rettangoli?

Quando diciamo che puoi impostare un valore, generalmente intendiamo qualcosa di un po 'più forte della semplice capacità di scrivere un valore al suo interno. Implichiamo un certo grado di esclusività: se imposti un valore, salvo alcune circostanze straordinarie, rimarrà su quel valore fino a quando non lo imposti nuovamente. Ci sono molti usi per i valori su cui è possibile scrivere ma che non rimangono impostati, ma ci sono anche molti casi che dipendono da un valore che rimane dove si trova una volta impostato. Ed è qui che incontriamo un altro problema.

class Square extends Rectangle {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};

    /* BUG: More Liskov violations */
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* Liskov violations inherited from Rectangle */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

La nostra classe Square ha ereditato i bug da Rectangle, ma ne ha alcuni nuovi. Il problema con setSideA e setSideB è che nessuno di questi è più impostabile: puoi ancora scrivere un valore in uno di essi, ma cambierà da sotto di te se l'altro è scritto. Se lo cambio con un parallelogramma nel codice che dipende dalla capacità di impostare i lati indipendentemente l'uno dall'altro, andrà fuori di testa.

Questo è il problema, ed è il motivo per cui c'è un problema con l'uso di Rectangle-Square come introduzione a Liskov. Rectangle-Square dipende dalla differenza tra la capacità di scrivere su qualcosa e la possibilità di impostarla , e questa è una differenza molto più sottile rispetto alla possibilità di impostare qualcosa rispetto alla sola lettura. Rectangle-Square ha ancora valore come esempio, perché documenta un gotcha abbastanza comune da tenere d'occhio, ma non dovrebbe essere usato come esempio introduttivo . Lascia che lo studente apprenda prima le basi, quindi lancia qualcosa di più duro.


8

Il sottotipo riguarda il comportamento.

Affinché il tipo Bsia un sottotipo di tipo A, deve supportare ogni operazione che il tipo Asupporta con la stessa semantica (discorsi fantasiosi per "comportamento"). Usando la logica che ogni B è un A non non funziona - compatibilità comportamento ha l'ultima parola. Il più delle volte "B è una specie di A" si sovrappone a "B si comporta come A", ma non sempre .

Un esempio:

Considera l'insieme dei numeri reali. In qualsiasi lingua, possiamo aspettarci loro di sostenere le operazioni +, -, *, e /. Ora considera l'insieme di numeri interi positivi ({1, 2, 3, ...}). Chiaramente, ogni numero intero positivo è anche un numero reale. Ma il tipo di numeri interi positivi è un sottotipo del tipo di numeri reali? Diamo un'occhiata alle quattro operazioni e vediamo se numeri interi positivi si comportano allo stesso modo dei numeri reali:

  • +: Possiamo aggiungere numeri interi positivi senza problemi.
  • -: Non tutte le sottrazioni di numeri interi positivi producono numeri interi positivi. Es 3 - 5.
  • *: Possiamo moltiplicare numeri interi positivi senza problemi.
  • /: Non possiamo sempre dividere numeri interi positivi e ottenere un numero intero positivo. Es 5 / 3.

Quindi, nonostante gli interi positivi siano un sottoinsieme di numeri reali, non sono un sottotipo. Un argomento simile può essere fatto per interi di dimensione finita. Chiaramente ogni numero intero a 32 bit è anche un numero intero a 64 bit, ma 32_BIT_MAX + 1ti darà risultati diversi per ogni tipo. Quindi, se ti ho dato un programma e hai cambiato il tipo di ogni variabile intera a 32 bit in numeri interi a 64 bit, ci sono buone probabilità che il programma si comporti in modo diverso (il che significa quasi sempre in modo errato ).

Ovviamente, potresti definire +per gli ints a 32 bit in modo che il risultato sia un numero intero a 64 bit, ma ora dovrai riservare 64 bit di spazio ogni volta che aggiungi due numeri a 32 bit. Ciò può essere o non essere accettabile per te in base alle tue esigenze di memoria.

Perché è importante?

È importante che i programmi siano corretti. È probabilmente la proprietà più importante per un programma. Se un programma è corretto per un certo tipo A, l'unico modo per garantire che il programma continui ad essere corretto per alcuni sottotipi Bè se Bsi comporta come Ain ogni modo.

Quindi hai il tipo di Rectangles, la cui specifica dice che i suoi lati possono essere cambiati in modo indipendente. Hai scritto alcuni programmi che utilizzano Rectanglese presuppongono che l'implementazione segua le specifiche. Quindi hai introdotto un sottotipo chiamato i Squarecui lati non possono essere ridimensionati in modo indipendente. Di conseguenza, la maggior parte dei programmi che ridimensionano i rettangoli ora saranno errati.


6

Se un quadrato è un tipo di rettangolo, perché non è possibile ereditare un quadrato da un rettangolo? O perché è un cattivo design?

Per prima cosa, chiediti perché pensi che un quadrato sia un rettangolo.

Ovviamente la maggior parte della gente l'ha imparato alle elementari e sembrerebbe ovvio. Un rettangolo è una forma a 4 lati con angoli di 90 gradi e un quadrato soddisfa tutte queste proprietà. Quindi un quadrato non è un rettangolo?

La cosa però è che tutto dipende da quali sono i criteri iniziali per raggruppare gli oggetti, quale contesto stai osservando questi oggetti. Nella geometria le forme sono classificate in base alle proprietà dei loro punti, linee e angeli.

Quindi, prima ancora di dire "un quadrato è un tipo di rettangolo", devi prima chiederti, è basato su criteri a cui tengo .

Nella stragrande maggioranza dei casi non sarà affatto ciò che ti interessa. La maggior parte dei sistemi che modellano forme, come GUI, grafica e videogiochi, non si occupano principalmente del raggruppamento geometrico di un oggetto ma è un comportamento. Hai mai lavorato su un sistema che contava che un quadrato fosse un tipo di rettangolo in senso geometrico. Cosa ti darebbe, sapendo che ha 4 lati e angoli di 90 gradi?

Non stai modellando un sistema statico, stai modellando un sistema dinamico in cui le cose accadranno (le forme verranno create, distrutte, modificate, disegnate ecc.). In questo contesto ti preoccupi del comportamento condiviso tra gli oggetti, perché la tua preoccupazione principale è cosa puoi fare con una forma, quali regole devono essere mantenute per avere ancora un sistema coerente.

In questo contesto, un quadrato non è sicuramente un rettangolo , perché le regole che regolano il modo in cui il quadrato può essere modificato non sono le stesse del rettangolo. Quindi non sono lo stesso tipo di cose.

Nel qual caso non modellarli come tali. Perchè vorresti? Non ti guadagna nient'altro che una restrizione inutile.

Sarebbe utilizzabile solo se creiamo l'oggetto Square e se sovrascriviamo i metodi SetWidth e SetHeight per Square di perché dovremmo avere qualche problema?

Se lo fai anche se stai praticamente affermando nel codice che non sono la stessa cosa. Il tuo codice direbbe che un quadrato si comporta in questo modo e un rettangolo si comporta in questo modo ma sono sempre gli stessi.

Chiaramente non sono gli stessi nel contesto a cui tieni perché hai appena definito due comportamenti diversi. Quindi perché fingere che siano uguali se sono simili solo in un contesto a cui non ti interessa?

Ciò evidenzia un problema significativo quando gli sviluppatori arrivano in un dominio che desiderano modellare. È così importante chiarire a quale contesto sei interessato prima di iniziare a pensare agli oggetti nel dominio. A quale aspetto sei interessato. Migliaia di anni fa i Greci si prendevano cura delle proprietà condivise delle linee e degli angeli delle forme e le raggruppavano in base a queste. Ciò non significa che sei costretto a continuare quel raggruppamento se non è quello che ti interessa (che nel 99% delle volte nella modellazione del software non ti interessa).

Molte delle risposte a questa domanda si concentrano sul fatto che i sotto-caratteri riguardano il comportamento di raggruppamento perché causano loro le regole .

Ma è così importante capire che non lo stai facendo solo per seguire le regole. Lo stai facendo perché nella stragrande maggioranza dei casi è quello che ti interessa davvero. Non ti importa se un quadrato e un rettangolo condividono gli stessi angeli interni. Ti preoccupi di cosa possono fare pur rimanendo quadrati e rettangoli. Ti preoccupi del comportamento degli oggetti perché stai modellando un sistema incentrato sulla modifica del sistema in base alle regole del comportamento degli oggetti.


Se le variabili di tipo Rectanglevengono utilizzate solo per rappresentare valori , potrebbe essere possibile per una classe Squareereditare Rectanglee rispettare completamente il suo contratto. Sfortunatamente, molte lingue non fanno alcuna distinzione tra variabili che incapsulano valori e quelle che identificano entità.
supercat

Forse, ma allora perché preoccuparsi in primo luogo. Il punto del problema rettangolo / quadrato non è cercare di capire come far funzionare la relazione "un quadrato è un rettangolo", ma piuttosto rendersi conto che la relazione non esiste realmente nel contesto in cui si stanno usando gli oggetti (comportamentale) e come avvertimento di non imporre relazioni irrilevanti sul tuo dominio.
Cormac Mulhall,

O per dirla in altro modo: non provare a piegare il cucchiaio. È impossibile. Invece cerca solo di capire la verità, che non c'è un cucchiaio. :-)
Cormac Mulhall,

1
Avere un Squaretipo immutabile che eredita da un Rectnagletipo immutabile potrebbe essere utile se ci fossero alcuni tipi di operazioni che potrebbero essere fatte solo sui quadrati. Come esempio realistico del concetto, considera un ReadableMatrixtipo [tipo base un array rettangolare che potrebbe essere memorizzato in vari modi, anche scarsamente], e un ComputeDeterminantmetodo. Potrebbe avere senso ComputeDeterminantlavorare solo con un ReadableSquareMatrixtipo da cui deriva ReadableMatrix, che considererei un esempio di Squarederivazione da a Rectangle.
supercat,

5

Se un quadrato è un tipo di rettangolo, perché non è possibile ereditare un quadrato da un rettangolo?

Il problema sta nel pensare che se le cose sono in qualche modo correlate nella realtà, devono essere collegate esattamente allo stesso modo dopo la modellazione.

La cosa più importante nella modellazione è identificare gli attributi comuni e i comportamenti comuni, definirli nella classe di base e aggiungere ulteriori attributi nelle classi secondarie.

Il problema con il tuo esempio è che è completamente astratto. Finché nessuno lo sa, per cosa prevedi di usare quelle classi, è difficile indovinare quale design dovresti fare. Ma se vuoi davvero avere solo altezza, larghezza e ridimensionamento, sarebbe più logico:

  • definire Square come classe base, con widthparametro e resize(double factor)ridimensionando la larghezza in base al fattore dato
  • definisce la classe Rectangle e la sottoclasse di Square, perché aggiunge un altro attributo heighte sovrascrive la sua resizefunzione, che chiama super.resizee quindi ridimensiona l'altezza in base al fattore dato

Dal punto di vista della programmazione, in Square non c'è nulla che Rectangle non abbia. Non ha senso creare un quadrato come sottoclasse di Rectangle.


+1 Solo perché un quadrato è un tipo speciale di retto in matematica, non significa che sia lo stesso in OO.
Lovis,

1
Un quadrato è un quadrato e un rettangolo è un rettangolo. Le relazioni tra loro dovrebbero valere anche nella modellazione, oppure hai un modello piuttosto scadente. I veri problemi sono: 1) se li rendi mutabili, non modellerai più quadrati e rettangoli; 2) supponendo che solo perché esiste una relazione "è una" tra due tipi di oggetti, è possibile sostituire indiscriminatamente l'una con l'altra.
Doval,

4

Perché da LSP, la creazione di una relazione ereditaria tra i due e l'override setWidthe setHeightper garantire che il quadrato abbia entrambi lo stesso introduce un comportamento confuso e non intuitivo. Diciamo che abbiamo un codice:

Rectangle r = createRectangle(); // create rectangle or square here
r.setWidth(10);
r.setHeight(20);
print(r.getWidth()); // expect to print 10
print(r.getHeight()); // expect to print 20

Ma se il metodo è createRectangletornato Square, perché è possibile grazie Squareall'ereditarietà da Rectange. Quindi le aspettative vengono infrante. Qui, con questo codice, prevediamo che l'impostazione di larghezza o altezza provocherà solo cambiamenti rispettivamente in larghezza o altezza. Il punto di OOP è che quando lavori con la superclasse, hai una conoscenza zero di qualsiasi sottoclasse sotto di essa. E se la sottoclasse cambia il comportamento in modo che vada contro le aspettative che abbiamo sulla superclasse, allora c'è un'alta probabilità che si verifichino dei bug. E questi tipi di bug sono entrambi difficili da eseguire il debug e correggere.

Una delle idee principali su OOP è che si tratta di un comportamento, non di dati ereditati (che è anche una delle principali idee sbagliate dell'IMO). E se osservi il quadrato e il rettangolo, essi non hanno alcun comportamento in sé che potremmo mettere in relazione nella relazione di eredità.


2

Quello che dice LSP è che tutto ciò che eredita Rectangledeve essere un Rectangle. Cioè, dovrebbe fare qualunque cosa Rectanglefaccia.

Probabilmente la documentazione per Rectangleè scritta per dire che il comportamento di un Rectanglenome rè il seguente:

r.setWidth(10);
r.setHeight(20);
print(r.getWidth());  // prints 10

Se il tuo Square non ha lo stesso comportamento, allora non si comporta come un Rectangle. Quindi LSP dice che non deve ereditare da Rectangle. La lingua non può applicare questa regola, perché non può impedirti di fare qualcosa di sbagliato in una sostituzione del metodo, ma ciò non significa "va bene perché la lingua mi consente di ignorare i metodi" è un argomento convincente per farlo!

Ora, sarebbe possibile scrivere la documentazione Rectanglein modo tale da non implicare che il codice sopra riportato stampi 10, nel qual caso forse il tuo Squarepotrebbe essere un Rectangle. Potresti vedere la documentazione che dice qualcosa del tipo "questo fa X. Inoltre, l'implementazione in questa classe fa Y". In tal caso, hai una buona ragione per estrarre un'interfaccia dalla classe e distinguere tra ciò che garantisce l'interfaccia e ciò che la classe garantisce in aggiunta a quello. Ma quando la gente dice "un quadrato mutabile non è un rettangolo mutabile, mentre un quadrato immutabile è un rettangolo immutabile", fondamentalmente presumono che quanto sopra sia effettivamente parte della ragionevole definizione di rettangolo mutabile.


questo sembra semplicemente ripetere i punti spiegati in una risposta pubblicata 5 ore fa
moscerino del

@gnat: preferiresti che modificassi quell'altra risposta fino a circa questa brevità? ;-) Non credo di poter fare a meno di rimuovere i punti che presumibilmente gli altri risponditori ritengono necessari per rispondere alla domanda e penso che non lo siano.
Steve Jessop,


1

I sottotipi e, per estensione, la programmazione OO, spesso si basano sul Principio di sostituzione di Liskov, secondo cui qualsiasi valore di tipo A può essere utilizzato dove è richiesta una B, se A <= B. Questo è praticamente un assioma nell'architettura OO, vale a dire. si presume che tutte le sottoclassi disporranno di questa proprietà (e, in caso contrario, i sottotipi sono difettosi e devono essere corretti).

Tuttavia, si scopre che questo principio è irrealistico / non rappresentativo della maggior parte del codice, o addirittura impossibile da soddisfare (in casi non banali)! Questo problema, noto come il problema del rettangolo quadrato o del cerchio-ellisse ( http://en.wikipedia.org/wiki/Circle-ellipse_problem ) è un famoso esempio di quanto sia difficile da soddisfare.

Si noti che abbiamo potuto implementare Squares più-e-più-osservativamente equivalenti e rettangoli, ma solo buttando via sempre più funzionalità finché la distinzione è inutile.

Ad esempio, vedi http://okmij.org/ftp/Computation/Subtyping/

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.