Liskov, rettangoli, quadrati e oggetti null

5

Continuo a pensare di avere la mia testa avvolta nel Principio di sostituzione di Liskov, e poi mi rendo conto di non farlo. Qui è dove ho raccolto StackExchange:

  1. La sottoclasse di Square da Rectangle viola LSP perché se cambi la larghezza di un quadrato anche l'altezza cambia.
  2. Gli oggetti nulli non violano LSP.

Quelli sembrano contraddirsi l'un l'altro per me. Se ciò non funziona:

square.setWidth(40);
square.setHeight(50);
width = square.getWidth(); // Returns 50 instead of 40. Liskov hates that.

Allora perché non è così male?

nullRectangle.setWidth(40);
nullRectangle.setHeight(50);
width = nullRectangle.getWidth(); // Returns 0 or null. Isn't that worse?

Cosa mi manca qui?

    
posta M. Christian 12.09.2017 - 19:54
fonte

5 risposte

4

Quello che hai trovato è semplicemente il caso limite in cui l'oggetto Null potrebbe non essere una buona soluzione.

Il punto di null object è quello di rendere il comportamento come se avessi prima controllato null .

Nel tuo caso, se questo codice è previsto comportamento:

Rectangle nullRectangle = null;
int width = 0;
if (nullRectangle != null)
{
    nullRectangle.setWidth(40);
    nullRectangle.setHeight(50);
    width = nullRectangle.getWidth();
}
// width is 0 if nullRectangle is null

Dieci% dinull object come descritto nel tuo codice è un sostituto logico.

Ma l'idea di avere "rettangolo nullo" non ha molto senso dal punto di vista della modellazione. L'oggetto Null è davvero utilizzabile solo come implementazione di astrazioni, dove chiamare un metodo non significa che il chiamante si aspetti sempre qualcosa. Se il chiamante di qualsiasi metodo dell'astrazione vede "nessuna operazione" o "nessun dato restituito" come risultato valido, è possibile utilizzare null object . Se invece è previsto un valore diverso da zero come ritorno, allora oggetto nullo vìola effettivamente LSP.

    
risposta data 12.09.2017 - 20:03
fonte
2

Penso che il problema Rettangolo / Quadrato sia il risultato di un'analisi errata del dominio.

Rettangoli matematici / quadrati

Dalla matematica, sappiamo tutti che i quadrati sono casi speciali di rettangoli, che traduciamo in Square come sottoclasse di Rectangle , e va bene finché restiamo con il concetto matematico di rettangolo e quadrato che non include la modifica della larghezza o dell'altezza dell'oggetto . Per le forme immutabili, il modello di ereditarietà è conforme al principio di Liskov.

Oggetti programma mutabili

Non appena introduciamo un setter, ad es. setHeight() , l'analogia matematica non regge più e dobbiamo fare una nuova analisi.

Che cosa significa setHeight(50) ? Quando lo vediamo in una classe Rectangle , supponiamo tutti che la larghezza rimanga la stessa, quindi modifica le proporzioni della forma. E questa è un'operazione che, applicata a un quadrato, produce un rettangolo non quadrato. Quindi un metodo Square.setHeight() non può essere conforme al contratto Rectangle -inspired poiché dovrebbe modificare la classe dell'oggetto da Square a Rectangle (il che è impossibile in tutti i linguaggi di programmazione che conosco).

Quindi, avere una classe mutabile Square eredita da Rectangle viola Liskov.

La fonte del male qui è la mutabilità. Se setHeight() dovesse restituire un'istanza fresca invece di modificare lo stato dell'istanza originale, potremmo risolvere la situazione. Quindi chiamare setHeight() su Square potrebbe facilmente restituire un Rectangle con le nuove dimensioni. Potrebbe persino ereditare l'implementazione da Rectangle .

    
risposta data 05.11.2017 - 20:19
fonte
1

C'è già una risposta accettata, ma volevo segnalare un paio di cose.

Gli oggetti dovrebbero avere un comportamento che eseguono. Altrimenti ci sono pochi motivi per usare uno su una struttura. I tuoi oggetti di esempio non hanno alcun tipo di comportamento, solo i dati a cui hai reso più complicato l'accesso tramite getter e setter. Il tuo esempio viola Liskov principalmente perché non è comunque orientato agli oggetti. È proceduralmente leggendo e scrivendo i dati con una struttura specifica.

Voglio anche menzionare che "Oggetti Null" non significano null puntatori. Significano un'implementazione della classe con un comportamento predefinito. Vedi questo video .

Quindi come potrebbe essere LSP?

Se penso a Shape2D in termini di comportamento che voglio che faccia per me, una cosa che mi viene in mente è il calcolo dell'area. Perché questo sarà diverso tra le forme.

public class Shape2D
{
    public virtual double GetArea() { return 0.0; }
}

Qui sto usando Shape2D come oggetto Null - una forma che non ha area. Può essere esteso sottoclassando e sovrascrivendo GetArea() .

Returning double as the area is a questionable choice. Because there are other implications with area, like the unit of measure or that it can be infinite. Maybe an Area class should be created too if this were production code.

Quindi creiamo alcune implementazioni di area bidimensionale: avere cose.

public class Square : Shape2D
{
    private double _sideLength;
    public Square(double sideLength) { _sideLength = sideLength; }
    public override double GetArea()
        { return _sideLength ^ 2 }
}

public class Rectangle : Shape2D
{
    private double _length;
    private double _height;
    public Rectangle(double length, double height) { ... }
    public override double GetArea()
        { return _length * _height; }
}

public class Circle : Shape2D
{
    private double _radius;
    public Circle(double radius) { _radius = radius; }
    public override double GetArea()
        { return Math.Pi * (_radius ^ 2); }
}

A questo punto, ci si potrebbe chiedere. "Perché non calcolo solo l'area nel costruttore?" oppure "Perché non creare un metodo statico per calcoli di area diversi?" Bene, per quanto riguarda le forme 2D complesse? Come un ottagono concavo definito da vettori? Forse la particolare implementazione della forma è computazionalmente costosa per calcolare l'area? Penso che alla fine richieda qualche intuizione per determinare quale sia la via giusta. Ma nell'esempio qui, lo calcoliamo solo quando richiesto.

Nella domanda originale, hai cambiato la larghezza del rettangolo modificando i dati privati sul rettangolo. Ma qui, dovresti creare un new Rectangle con dimensioni diverse.

Quindi questo segue LSP, perché per qualsiasi metodo che prende un Shape2D , potresti passare in Square , Shape2D (Oggetto Nullo) o qualsiasi forma che potresti implementare in futuro come ConcaveOctagon senza il metodo conoscendolo.

    
risposta data 12.09.2017 - 22:54
fonte
1

Il "trucco" del classico problema di Square-Rectangle LSP è che se definiamo un rettangolo come un oggetto che deve soddisfare il contratto ...

rectangle.setWidth(40);
rectangle.setHeight(50);
assert(rectangle.getWidth() == 40);

... quindi estendilo a un quadrato, quindi violiamo quel contratto. Questo perché stiamo mescolando una definizione personalizzata di un rettangolo e una definizione classica. In quanto tale, stiamo cercando di far rientrare la definizione classica in una definizione personalizzata, che non funziona. In sostanza, è un trucco da salotto inteso a far pensare a comportamenti di sottotipo e obblighi contrattuali. Potremmo facilmente mostrare che la suddetta affermazione di un oggetto Rectangle fallisce anche per un rettangolo con qualche riscrittura astuta:

rectangle.setTopEdgeLength(40);
rectangle.setBottomEdgeLength(50);
assert(rectangle.getTopEdgeLength() == 40);

Ad ogni modo, abbastanza enigmi, diamo un'occhiata a ...

Definizione classica

Mettere correttamente, quadrati e rettangoli sono entrambi tipi di quadrilateri, un quadrilatero essendo un poligono con 4 spigoli e 4 vertici. Classicamente, un quadrato è un rettangolo, ma diamo un'occhiata alle definizioni di Wikipedia di un rettangolo e un quadrato:

  • Rettangolo : un quadrilatero con quattro angoli retti
  • Square : un quadrilatero con quattro angoli retti e quattro lati uguali

Notare qualcosa lì? Gli unici punti in comune tra un rettangolo e un quadrato sono che entrambi sono quadrilateri, ed entrambi hanno quattro angoli retti. Nessuna menzione riguarda la lunghezza dei lati, poiché questi sono derivati dal contratto, non parte del contratto. Possiamo dimostrarlo con alcune semplici affermazioni logiche:

  • se una forma è un quadrilatero e la forma ha quattro angoli retti, i lati opposti avranno la stessa lunghezza
  • se una forma è quadrilatera e la forma ha quattro angoli retti e quattro lati uguali, la lunghezza di ogni lato sarà uguale alla lunghezza di qualsiasi altro lato (questa è in realtà una tautologia)

Se vogliamo mantenere la definizione classica di rettangoli e quadrati e rappresentarli in codice con i metodi setWidth() e setHeight() in posizione, il contratto deve essere:

square.setWidth(40);
square.setHeight(50);
assert(square.isQuadrilateral());
assert(square.hasFourRightAngles());

Se la larghezza è ancora quella che impostiamo nella riga 1 è irrilevante, poiché tale regola non fa parte degli obblighi contrattuali dell'oggetto principale Rectangle.

Per riassumere, il problema iniziale è una delle definizioni contrattuali. Non sto dicendo che non dovresti avere un oggetto Rectangle che soddisfi rectangle.setWidth(40); rectangle.setHeight(50); assert(rectangle.getWidth() == 40); , ma se definisci il tuo oggetto Rectangle in quel modo, quando lo estendi devi assicurarti che il contratto sia ancora valido, altrimenti violerai LSP .

    
risposta data 13.09.2017 - 03:55
fonte
0

Penso che ci siano due trappole in questo esempio.

Il primo è già indicato nei commenti: ereditarietà basata sulla struttura interna e non dovrebbe essere : rompe l'incapsulamento dell'oggetto, è fragile, è procedurale dopotutto.

Quindi la soluzione sembra utilizzare la sottotipizzazione, in questo modo:

interface IRectangle
{
    public function setWidth($width);

    public function setHeight($height);
}

class Rectangle implements IRectangle
{
    private $width;
    private $height;

    public function setWidth($width)
    {
        $this->width = $width;
    }

    public function setHeight($height)
    {
        $this->height = $height;
    }
}

class Square implements IRectange
{
    private $side;

    public function setWidth($width)
    {
        $this->side = $width;
    }

    public function setHeight($height)
    {
        $this->side = $height;
    }
}

Ma c'è la stessa violazione del contratto.

Quindi ecco la seconda trappola. In questo contesto , caratterizzato dalle responsabilità degli oggetti, dovrebbero esserci due diverse astrazioni, rappresentate con due interfacce diverse: IRectangle e ISquare .

Quindi sembra che questo famigerato esempio sia solo un puzzle logico, quello in cui c'è un errore logico ma nessuno sa dove sia. Quali sono le motivazioni alla base del detto "quadrato è un caso speciale di un rettangolo"? In che contesto è un caso speciale? Qual è l'esatto caso d'uso quando un quadrato si comporta come un rettangolo? Probabilmente ce ne sono alcuni, ma sicuramente non è quello in cui viene cambiata la larghezza del rettangolo mentre l'altezza rimane la stessa. Quindi prima di tutto questo stupido esempio viola il buon senso, quindi OOP, e solo dopo viola la LSP.

    
risposta data 05.11.2017 - 16:52
fonte