Was ist ein Beispiel für das Liskov-Substitutionsprinzip?


908

Ich habe gehört, dass das Liskov-Substitutionsprinzip (LSP) ein Grundprinzip des objektorientierten Designs ist. Was ist es und was sind einige Beispiele für seine Verwendung?


Weitere Beispiele für die Einhaltung und Verletzung von LSP finden Sie hier
StuartLC,

1
Diese Frage hat unendlich viele gute Antworten und ist daher zu weit gefasst .
Raedwald

Antworten:


892

Ein gutes Beispiel für LSP (von Onkel Bob in einem Podcast, den ich kürzlich gehört habe) war, dass manchmal etwas, das in natürlicher Sprache richtig klingt, im Code nicht ganz funktioniert.

In der Mathematik ist a Squarea Rectangle. In der Tat ist es eine Spezialisierung eines Rechtecks. Das "ist ein" macht Lust, dies mit Vererbung zu modellieren. Wenn Sie jedoch in Code, aus dem Sie Squarestammen Rectangle, a ableiten möchten , Squaresollte a überall dort verwendet werden können, wo Sie a erwarten Rectangle. Dies führt zu einem seltsamen Verhalten.

Stellen Sie sich vor Sie hatten SetWidthund SetHeightMethoden auf Ihrer RectangleBasisklasse; das scheint vollkommen logisch. Allerdings , wenn Ihr RectangleHinweis auf eine spitz Square, dann SetWidthund SetHeightmacht keinen Sinn , weil eine Einstellung würde den anderen ändern , es zu entsprechen. In diesem Fall besteht Squareder Liskov-Substitutionstest mit nicht Rectangleund die Abstraktion, von der Squaregeerbt wurde, Rectangleist schlecht.

Geben Sie hier die Bildbeschreibung ein

Sie sollten sich die anderen unbezahlbaren Motivationsposter von SOLID Principles ansehen .


19
@ m-shar Was ist, wenn es sich um ein unveränderliches Rechteck handelt, sodass wir anstelle von SetWidth und SetHeight die Methoden GetWidth und GetHeight verwenden?
Pacerier

139
Moral der Geschichte: Modellieren Sie Ihre Klassen basierend auf Verhaltensweisen, die nicht auf Eigenschaften basieren. Modellieren Sie Ihre Daten basierend auf Eigenschaften und nicht auf Verhaltensweisen. Wenn es sich wie eine Ente verhält, ist es sicherlich ein Vogel.
Sklivvz

193
Nun, ein Quadrat ist eindeutig eine Art Rechteck in der realen Welt. Ob wir dies in unserem Code modellieren können, hängt von der Spezifikation ab. Der LSP gibt an, dass das Subtypverhalten mit dem in der Basistypspezifikation definierten Basistypverhalten übereinstimmen sollte. Wenn die Spezifikation des Rechteckbasistyps besagt, dass Höhe und Breite unabhängig voneinander festgelegt werden können, sagt LSP, dass Quadrat kein Untertyp eines Rechtecks ​​sein kann. Wenn die Rechteckspezifikation besagt, dass ein Rechteck unveränderlich ist, kann ein Quadrat ein Untertyp eines Rechtecks ​​sein. Es geht um Subtypen, die das für den Basistyp angegebene Verhalten beibehalten.
SteveT

63
@ Pacerier gibt es kein Problem, wenn es unveränderlich ist. Das eigentliche Problem hierbei ist, dass wir keine Rechtecke modellieren, sondern "umformbare Rechtecke", dh Rechtecke, deren Breite oder Höhe nach der Erstellung geändert werden kann (und wir betrachten sie immer noch als dasselbe Objekt). Wenn wir die Rechteckklasse auf diese Weise betrachten, ist es klar, dass ein Quadrat kein "umformbares Rechteck" ist, da ein Quadrat nicht umgeformt werden kann und (im Allgemeinen) immer noch ein Quadrat ist. Mathematisch gesehen sehen wir das Problem nicht, weil Veränderlichkeit in einem mathematischen Kontext nicht einmal Sinn macht.
Asmeurer

14
Ich habe eine Frage zum Prinzip. Warum wäre das Problem, wenn Square.setWidth(int width)es so implementiert würde : this.width = width; this.height = width;? In diesem Fall ist garantiert, dass die Breite der Höhe entspricht.
MC Emperor

488

Das Liskov-Substitutionsprinzip (LSP, ) ist ein Konzept in der objektorientierten Programmierung, das Folgendes besagt:

Funktionen, die Zeiger oder Verweise auf Basisklassen verwenden, müssen Objekte abgeleiteter Klassen verwenden können, ohne es zu wissen.

Im Mittelpunkt von LSP stehen Schnittstellen und Verträge sowie die Entscheidung, wann eine Klasse erweitert werden soll, und die Verwendung einer anderen Strategie wie der Komposition, um Ihr Ziel zu erreichen.

Der effektivste Weg , den ich gesehen habe , diesen Punkt zu illustrieren war in Head First OOA & D . Sie stellen ein Szenario vor, in dem Sie Entwickler eines Projekts sind, um ein Framework für Strategiespiele zu erstellen.

Sie präsentieren eine Klasse, die ein Board darstellt, das so aussieht:

Klassen Diagramm

Alle Methoden verwenden X- und Y-Koordinaten als Parameter, um die Kachelposition in der zweidimensionalen Anordnung von zu lokalisieren Tiles. Auf diese Weise kann ein Spieleentwickler im Verlauf des Spiels Einheiten auf dem Brett verwalten.

In dem Buch werden die Anforderungen dahingehend geändert, dass die Spielrahmenarbeit auch 3D-Spielbretter unterstützen muss, um flugfähige Spiele aufzunehmen. So wird eine ThreeDBoardKlasse eingeführt, die sich erweitert Board.

Auf den ersten Blick scheint dies eine gute Entscheidung zu sein. Boardbietet sowohl die Heightund Width-Eigenschaften als auch ThreeDBoarddie Z-Achse.

Wenn Sie sich alle anderen Mitglieder ansehen, von denen es geerbt wurde, bricht es zusammen Board. Die Methoden für AddUnit, GetTile, GetUnitsund so weiter, nehmen alle X- und Y - Parameter in der BoardKlasse , aber das ThreeDBoardbraucht einen Z - Parameter als auch.

Sie müssen diese Methoden also erneut mit einem Z-Parameter implementieren. Der Z-Parameter hat keinen Kontext zur BoardKlasse und die von der BoardKlasse geerbten Methoden verlieren ihre Bedeutung. Eine Codeeinheit, die versucht, die ThreeDBoardKlasse als Basisklasse zu verwenden, Boardhätte kein Glück.

Vielleicht sollten wir einen anderen Ansatz finden. Anstatt zu erweitern Board, ThreeDBoardsollte aus BoardObjekten bestehen. Ein BoardObjekt pro Einheit der Z-Achse.

Dies ermöglicht es uns, gute objektorientierte Prinzipien wie Kapselung und Wiederverwendung zu verwenden und verletzt LSP nicht.


10
Siehe auch Kreis-Ellipsen-Problem auf Wikipedia für ein ähnliches, aber einfacheres Beispiel.
Brian

Zitat von @NotMySelf: "Ich denke, das Beispiel soll lediglich zeigen, dass das Erben von Board im Kontext von ThreeDBoard keinen Sinn macht und alle Methodensignaturen mit einer Z-Achse bedeutungslos sind."
Contango

1
Wenn wir also einer Child-Klasse eine andere Methode hinzufügen, aber die gesamte Funktionalität von Parent in der Child-Klasse immer noch Sinn macht, würde dies den LSP beschädigen? Da wir einerseits die Schnittstelle für die Verwendung des Kindes ein wenig geändert haben, andererseits, wenn wir das Kind als Elternteil umwandeln, funktioniert der Code, der erwartet, dass ein Elternteil gut funktioniert.
Nickolay Kondratyev

5
Dies ist ein Anti-Liskov-Beispiel. Liskov bringt uns dazu, das Rechteck vom Platz abzuleiten. Mehr-Parameter-Klasse von weniger-Parameter-Klasse. Und Sie haben schön gezeigt, dass es schlecht ist. Es ist wirklich ein guter Witz, als Antwort markiert und 200-mal als Anti-Liskov-Antwort auf die Liskov-Frage bewertet worden zu sein. Ist das Liskov-Prinzip wirklich ein Irrtum?
Gangnus

3
Ich habe gesehen, wie Vererbung falsch funktioniert. Hier ist ein Beispiel. Die Basisklasse sollte 3DBoard und die abgeleitete Klasse Board sein. Das Board hat immer noch eine Z-Achse von Max (Z) = Min (Z) = 1
Paulustrious

169

Substituierbarkeit ist ein Prinzip in der objektorientierten Programmierung, das besagt, dass in einem Computerprogramm, wenn S ein Subtyp von T ist, Objekte vom Typ T durch Objekte vom Typ S ersetzt werden können

Lassen Sie uns ein einfaches Beispiel in Java machen:

Schlechtes Beispiel

public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

Die Ente kann fliegen, weil sie ein Vogel ist. Aber was ist damit:

public class Ostrich extends Bird{}

Strauß ist ein Vogel, aber er kann nicht fliegen, Straußklasse ist ein Subtyp der Klasse Vogel, aber er kann die Fliegenmethode nicht verwenden, das heißt, wir brechen das LSP-Prinzip.

Gutes Beispiel

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 

3
Schönes Beispiel, aber was würden Sie tun, wenn der Kunde hat Bird bird. Sie müssen das Objekt auf FlyingBirds übertragen, um Fly verwenden zu können, was nicht schön ist, oder?
Moody

17
Nein. Wenn der Client hat Bird bird, bedeutet dies, dass er nicht verwenden kann fly(). Das ist es. Das Bestehen von a Duckändert nichts an dieser Tatsache. Wenn der Client hat FlyingBirds bird, sollte es auch dann Duckimmer so funktionieren , wenn es bestanden wird .
Steve Chamaillard

9
Wäre dies nicht auch ein gutes Beispiel für die Schnittstellentrennung?
Saharsh

Ausgezeichnetes Beispiel Danke Mann
Abdelhadi Abdo

6
Wie wäre es mit Interface 'Flyable' (ich kann mir keinen besseren Namen vorstellen). Auf diese Weise verpflichten wir uns nicht in diese starre Hierarchie. Es sei denn, wir wissen, dass wir sie wirklich brauchen.
Dritter

132

LSP betrifft Invarianten.

Das klassische Beispiel ist die folgende Pseudocode-Deklaration (Implementierungen weggelassen):

class Rectangle {
    int getHeight()
    void setHeight(int value)
    int getWidth()
    void setWidth(int value)
}

class Square : Rectangle { }

Jetzt haben wir ein Problem, obwohl die Schnittstelle übereinstimmt. Der Grund ist, dass wir Invarianten verletzt haben, die sich aus der mathematischen Definition von Quadraten und Rechtecken ergeben. Die Art und Weise, wie Getter und Setter arbeiten, Rectanglesollte die folgende Invariante erfüllen:

void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

Diese Invariante muss jedoch durch eine korrekte Implementierung von verletzt werden Square, daher ist sie kein gültiger Ersatz für Rectangle.


35
Und daher die Schwierigkeit, mit "OO" alles zu modellieren, was wir tatsächlich modellieren möchten.
DrPizza

9
@ DrPizza: Auf jeden Fall. Allerdings zwei Dinge. Erstens können solche Beziehungen immer noch in OOP modelliert werden, wenn auch unvollständig oder mithilfe komplexerer Umwege (wählen Sie die für Ihr Problem geeignete aus). Zweitens gibt es keine bessere Alternative. Andere Abbildungen / Modellierungen haben die gleichen oder ähnliche Probleme. ;-)
Konrad Rudolph

7
@NickW In einigen Fällen (aber nicht oben) können Sie einfach die Vererbungskette invertieren - logischerweise ist ein 2D-Punkt ein 3D-Punkt, bei dem die dritte Dimension nicht berücksichtigt wird (oder 0 - alle Punkte liegen auf derselben Ebene in) 3D-Raum). Das ist aber natürlich nicht wirklich praktisch. Im Allgemeinen ist dies einer der Fälle, in denen die Vererbung nicht wirklich hilft und keine natürliche Beziehung zwischen den Entitäten besteht. Modellieren Sie sie separat (zumindest kenne ich keinen besseren Weg).
Konrad Rudolph

7
OOP soll Verhalten und nicht Daten modellieren. Ihre Klassen verletzen die Kapselung, noch bevor sie gegen LSP verstoßen.
Sklivvz

2
@AustinWBryan Yep; Je länger ich in diesem Bereich arbeite, desto häufiger verwende ich Vererbung nur für Schnittstellen und abstrakte Basisklassen und Komposition für den Rest. Es ist manchmal etwas mehr Arbeit (tippweise), aber es vermeidet eine ganze Reihe von Problemen und wird von anderen erfahrenen Programmierern weithin wiederholt.
Konrad Rudolph

77

Robert Martin hat ein ausgezeichnetes Papier über das Liskov-Substitutionsprinzip . Es werden subtile und nicht so subtile Möglichkeiten erörtert, wie das Prinzip verletzt werden kann.

Einige relevante Teile des Papiers (beachten Sie, dass das zweite Beispiel stark verdichtet ist):

Ein einfaches Beispiel für eine Verletzung von LSP

Eine der auffälligsten Verstöße gegen dieses Prinzip ist die Verwendung von C ++ Run-Time Type Information (RTTI) zur Auswahl einer Funktion basierend auf dem Typ eines Objekts. dh:

void DrawShape(const Shape& s)
{
  if (typeid(s) == typeid(Square))
    DrawSquare(static_cast<Square&>(s)); 
  else if (typeid(s) == typeid(Circle))
    DrawCircle(static_cast<Circle&>(s));
}

Offensichtlich ist die DrawShapeFunktion schlecht geformt. Es muss über jede mögliche Ableitung der ShapeKlasse Bescheid wissen und muss geändert werden, wenn neue Ableitungen von Shapeerstellt werden. In der Tat sehen viele die Struktur dieser Funktion als Anathema für objektorientiertes Design an.

Quadrat und Rechteck, eine subtilere Verletzung.

Es gibt jedoch andere, weitaus subtilere Möglichkeiten, den LSP zu verletzen. Stellen Sie sich eine Anwendung vor, die die RectangleKlasse wie unten beschrieben verwendet:

class Rectangle
{
  public:
    void SetWidth(double w) {itsWidth=w;}
    void SetHeight(double h) {itsHeight=w;}
    double GetHeight() const {return itsHeight;}
    double GetWidth() const {return itsWidth;}
  private:
    double itsWidth;
    double itsHeight;
};

[...] Stellen Sie sich vor, eines Tages fordern die Benutzer die Möglichkeit, neben Rechtecken auch Quadrate zu bearbeiten. [...]

Ein Quadrat ist eindeutig ein Rechteck für alle normalen Absichten und Zwecke. Da die ISA-Beziehung gilt, ist es logisch, die Square Klasse als abgeleitet von zu modellieren Rectangle. [...]

Squareerbt die SetWidthund SetHeightFunktionen. Diese Funktionen sind für a völlig ungeeignet Square, da Breite und Höhe eines Quadrats identisch sind. Dies sollte ein wichtiger Hinweis darauf sein, dass ein Problem mit dem Design vorliegt. Es gibt jedoch eine Möglichkeit, das Problem zu umgehen. Wir könnten überschreiben SetWidthund SetHeight[...]

Beachten Sie jedoch die folgende Funktion:

void f(Rectangle& r)
{
  r.SetWidth(32); // calls Rectangle::SetWidth
}

Wenn wir einen Verweis auf ein SquareObjekt an diese Funktion übergeben, wird das SquareObjekt beschädigt, da die Höhe nicht geändert wird. Dies ist eine eindeutige Verletzung von LSP. Die Funktion funktioniert nicht für Ableitungen ihrer Argumente.

[...]


14
Viel zu spät, aber ich dachte, dies sei ein interessantes Zitat in diesem Artikel: Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one. Wenn eine Vorbedingung für eine Kinderklasse stärker ist als eine Vorbedingung für eine Elternklasse, können Sie ein Elternteil nicht durch ein Kind ersetzen, ohne die Vorbedingung zu verletzen. Daher LSP.
user2023861

@ user2023861 Sie haben vollkommen recht. Ich werde darauf basierend eine Antwort schreiben.
Inf3rno

40

LSP ist erforderlich, wenn ein Code denkt, dass er die Methoden eines Typs aufruft T, und möglicherweise unwissentlich die Methoden eines Typs aufruft S, wobei S extends T(dh Sder Supertyp erbt, von ihm abgeleitet ist oder ein Subtyp davon ist T).

Dies tritt beispielsweise auf, wenn eine Funktion mit einem Eingabeparameter vom Typ Tmit einem Argumentwert vom Typ aufgerufen (dh aufgerufen) wird S. Oder wenn einem Bezeichner vom Typ Tein Wert vom Typ zugewiesen wird S.

val id : T = new S() // id thinks it's a T, but is a S

LSP erfordert, dass die Erwartungen (dh Invarianten) für Methoden vom Typ T(z. B. Rectangle) nicht verletzt werden, wenn stattdessen die Methoden vom Typ S(z. B. Square) aufgerufen werden.

val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation

Sogar ein Typ mit unveränderlichen Feldern hat immer noch Invarianten, z. B. erwarten die unveränderlichen Rechteck-Setter, dass Dimensionen unabhängig voneinander geändert werden, aber die unveränderlichen Quadrat-Setter verletzen diese Erwartung.

class Rectangle( val width : Int, val height : Int )
{
   def setWidth( w : Int ) = new Rectangle(w, height)
   def setHeight( h : Int ) = new Rectangle(width, h)
}

class Square( val side : Int ) extends Rectangle(side, side)
{
   override def setWidth( s : Int ) = new Square(s)
   override def setHeight( s : Int ) = new Square(s)
}

LSP erfordert, dass jede Methode des Subtyps Skontravariante Eingabeparameter und eine kovariante Ausgabe hat.

Kontravariante bedeutet, dass die Varianz der Richtung der Vererbung widerspricht, dh der Typ Sijedes Eingabeparameters jeder Methode des Subtyps Smuss derselbe sein oder ein Supertyp des Typs Tides entsprechenden Eingabeparameters der entsprechenden Methode des Supertyps T.

Kovarianz bedeutet, dass die Varianz in der gleichen Richtung der Vererbung liegt, dh der Typ Soder Ausgabe jeder Methode des Subtyps Smuss gleich sein oder ein Subtyp des Typs Toder entsprechenden Ausgabe der entsprechenden Methode des Supertyps T.

Dies liegt daran, dass der Aufrufer, wenn er glaubt, einen Typ zu haben T, eine Methode aufruft T, Argumente vom Typ liefert Tiund die Ausgabe dem Typ zuweist To. Wenn tatsächlich die entsprechende Methode von aufgerufen wird S, wird jedes TiEingabeargument einem SiEingabeparameter zugewiesen , und die SoAusgabe wird dem Typ zugewiesen To. Wenn also Sinicht kontravariant wäre Ti, könnte ein Subtyp Xi- der kein Subtyp von Siwäre - zugewiesen werden Ti.

Zusätzlich für Sprachen (zB Scala oder Ceylon) , die Definition-site Varianz Annotationen auf Typ Polymorphismus Parameter haben (dh Generika), die Co- oder Wider- Richtung der Varianz Annotation für jeden Typ Parameter des Typs Tmüssen gegenüber oder gleiche Richtung jeweils zu jedem Eingabeparameter oder Ausgang (jeder Methode von T), der den Typ des Typparameters hat.

Zusätzlich wird für jeden Eingabeparameter oder Ausgang, der einen Funktionstyp hat, die erforderliche Varianzrichtung umgekehrt. Diese Regel wird rekursiv angewendet.


Die Untertypisierung ist geeignet, wenn die Invarianten aufgezählt werden können.

Es wird viel darüber geforscht, wie Invarianten modelliert werden können, damit sie vom Compiler erzwungen werden.

Typestate (siehe Seite 3) deklariert und erzwingt Zustandsinvarianten orthogonal zum Typ. Alternativ können Invarianten erzwungen werden, indem Zusicherungen in Typen konvertiert werden . Um beispielsweise zu bestätigen, dass eine Datei vor dem Schließen geöffnet ist, kann File.open () einen OpenFile-Typ zurückgeben, der eine close () -Methode enthält, die in File nicht verfügbar ist. Eine Tic-Tac-Toe-API kann ein weiteres Beispiel für die Verwendung der Typisierung sein, um Invarianten zur Kompilierungszeit zu erzwingen. Das Typsystem kann sogar Turing-vollständig sein, z . B. Scala . Abhängig typisierte Sprachen und Theorembeweiser formalisieren die Modelle der Typisierung höherer Ordnung.

Aufgrund der Notwendigkeit, dass die Semantik über die Erweiterung abstrahiert , erwarte ich, dass die Verwendung der Typisierung zur Modellierung von Invarianten, dh der einheitlichen Denotationssemantik höherer Ordnung, dem Typestate überlegen ist. "Erweiterung" bezeichnet die unbegrenzte, permutierte Zusammensetzung einer unkoordinierten, modularen Entwicklung. Weil es für mich das Gegenteil von Vereinigung und damit Freiheitsgraden zu sein scheint, zwei voneinander abhängige Modelle (z. B. Typen und Typestate) zum Ausdrücken der gemeinsamen Semantik zu haben, die für eine erweiterbare Komposition nicht miteinander vereinheitlicht werden können . Beispielsweise wurde die Ausdrucksproblem- ähnliche Erweiterung in den Bereichen Subtypisierung, Funktionsüberladung und parametrische Typisierung vereinheitlicht.

Meine theoretische Position ist, dass es für das Vorhandensein von Wissen (siehe Abschnitt „Zentralisierung ist blind und nicht geeignet“) niemals ein allgemeines Modell geben wird, das eine 100% ige Abdeckung aller möglichen Invarianten in einer Turing-vollständigen Computersprache erzwingen kann. Damit Wissen existiert, gibt es viele unerwartete Möglichkeiten, dh Unordnung und Entropie müssen immer zunehmen. Dies ist die entropische Kraft. Um alle möglichen Berechnungen einer möglichen Erweiterung zu beweisen, müssen alle möglichen Erweiterungen a priori berechnet werden.

Aus diesem Grund existiert das Halting-Theorem, dh es ist unentscheidbar, ob jedes mögliche Programm in einer Turing-vollständigen Programmiersprache beendet wird. Es kann nachgewiesen werden, dass ein bestimmtes Programm beendet wird (eines, für das alle Möglichkeiten definiert und berechnet wurden). Es ist jedoch unmöglich zu beweisen, dass alle möglichen Erweiterungen dieses Programms beendet sind, es sei denn, die Möglichkeiten zur Erweiterung dieses Programms sind nicht vollständig (z. B. durch abhängige Eingabe). Da die Grundvoraussetzung für die Vollständigkeit von Turing eine unbegrenzte Rekursion ist , ist es intuitiv zu verstehen, wie Gödels Unvollständigkeitssätze und Russells Paradoxon auf die Erweiterung zutreffen.

Eine Interpretation dieser Theoreme bezieht sie in ein verallgemeinertes konzeptuelles Verständnis der entropischen Kraft ein:

  • Gödels Unvollständigkeitssätze : Jede formale Theorie, in der alle arithmetischen Wahrheiten bewiesen werden können, ist inkonsistent.
  • Russells Paradoxon : Jede Mitgliedschaftsregel für eine Gruppe, die eine Gruppe enthalten kann, zählt entweder den spezifischen Typ jedes Mitglieds auf oder enthält sich selbst. Somit können Mengen entweder nicht erweitert werden oder sie sind unbegrenzte Rekursion. Zum Beispiel schließt sich das Set von allem, was keine Teekanne ist, selbst ein, das sich selbst einschließt, das sich selbst einschließt usw. Daher ist eine Regel inkonsistent, wenn sie (möglicherweise eine Menge und enthält) die spezifischen Typen nicht auflistet (dh alle nicht angegebenen Typen zulässt) und keine unbegrenzte Erweiterung zulässt. Dies ist die Menge von Mengen, die nicht Mitglieder von sich selbst sind. Diese Unfähigkeit, über alle möglichen Erweiterungen hinweg konsistent und vollständig aufgezählt zu werden, ist Gödels Unvollständigkeitssatz.
  • Liskov-Substitutionsprinzip : Im Allgemeinen ist es ein unentscheidbares Problem, ob eine Menge die Teilmenge einer anderen ist, dh die Vererbung ist im Allgemeinen unentscheidbar.
  • Linsky-Referenzierung : Es ist unentscheidbar, was die Berechnung von etwas ist, wenn es beschrieben oder wahrgenommen wird, dh Wahrnehmung (Realität) hat keinen absoluten Bezugspunkt.
  • Satz von Coase : Es gibt keinen externen Bezugspunkt, daher wird jede Barriere für unbegrenzte externe Möglichkeiten versagen.
  • Zweiter Hauptsatz der Thermodynamik : Das gesamte Universum (ein geschlossenes System, dh alles) tendiert zu maximaler Unordnung, dh zu maximalen unabhängigen Möglichkeiten.

17
@ Shelyby: Du hast zu viele Dinge gemischt. Die Dinge sind nicht so verwirrend, wie Sie es sagen. Ein Großteil Ihrer theoretischen Behauptungen beruht auf schwachen Gründen wie "Damit Wissen existiert, gibt es viele unerwartete Möglichkeiten, ........." UND "Im Allgemeinen ist es ein unentscheidbares Problem, ob eine Menge die Teilmenge einer anderen ist, dh Vererbung ist in der Regel unentscheidbar '. Sie können für jeden dieser Punkte ein separates Blog erstellen. Wie auch immer, Ihre Behauptungen und Annahmen sind höchst fragwürdig. Man darf keine Dinge benutzen, die man nicht kennt!
Aknon

1
@aknon Ich habe einen Blog , der diese Dinge ausführlicher erklärt. Mein TOE-Modell der unendlichen Raumzeit sind unbegrenzte Frequenzen. Es ist für mich nicht verwirrend, dass eine rekursive induktive Funktion einen bekannten Startwert mit einer unendlichen Endgrenze hat oder eine koinduktive Funktion einen unbekannten Endwert und eine bekannte Startgrenze hat. Die Relativitätstheorie ist das Problem, sobald die Rekursion eingeführt ist. Aus diesem Grund entspricht Turing complete einer unbegrenzten Rekursion .
Shelby Moore III

4
@ShelbyMooreIII Du gehst in zu viele Richtungen. Dies ist keine Antwort.
Soldalma

1
@Soldalma es ist eine Antwort. Sehen Sie es nicht im Abschnitt Antwort. Dein ist ein Kommentar, weil er sich im Kommentarbereich befindet.
Shelby Moore III

1
Wie deine Mischung mit Scala World!
Ehsan M. Kermani

24

Ich sehe in jeder Antwort Rechtecke und Quadrate und wie man den LSP verletzt.

Ich möchte anhand eines Beispiels aus der Praxis zeigen, wie der LSP angepasst werden kann:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return $result; 
    }
}

Dieses Design entspricht dem LSP, da das Verhalten unabhängig von der von uns verwendeten Implementierung unverändert bleibt.

Und ja, Sie können LSP in dieser Konfiguration verletzen, indem Sie eine einfache Änderung wie folgt vornehmen:

<?php

interface Database 
{
    public function selectQuery(string $sql): array;
}

class SQLiteDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // sqlite specific code

        return $result;
    }
}

class MySQLDatabase implements Database
{
    public function selectQuery(string $sql): array
    {
        // mysql specific code

        return ['result' => $result]; // This violates LSP !
    }
}

Jetzt können die Untertypen nicht mehr auf die gleiche Weise verwendet werden, da sie nicht mehr das gleiche Ergebnis liefern.


6
Das Beispiel verletzt LSP nicht nur, solange wir die Semantik einschränken Database::selectQuery, nur die Teilmenge von SQL zu unterstützen, die von allen DB-Engines unterstützt wird. Das ist kaum praktikabel ... Trotzdem ist das Beispiel immer noch leichter zu verstehen als die meisten anderen, die hier verwendet werden.
Palec

5
Ich fand diese Antwort am einfachsten zu verstehen.
Malcolm Salvador

23

Es gibt eine Checkliste, um festzustellen, ob Sie gegen Liskov verstoßen oder nicht.

  • Wenn Sie eines der folgenden Elemente verletzen -> verletzen Sie Liskov.
  • Wenn Sie keine verletzen -> können Sie nichts schließen.

Checkliste:

  • In der abgeleiteten Klasse sollten keine neuen Ausnahmen ausgelöst werden : Wenn Ihre Basisklasse ArgumentNullException ausgelöst hat, durften Ihre Unterklassen nur Ausnahmen vom Typ ArgumentNullException oder von ArgumentNullException abgeleitete Ausnahmen auslösen. Das Auslösen von IndexOutOfRangeException ist eine Verletzung von Liskov.
  • Voraussetzungen können nicht gestärkt werden : Angenommen, Ihre Basisklasse arbeitet mit einem Mitglied int. Jetzt erfordert Ihr Untertyp, dass int positiv ist. Dies ist eine verstärkte Vorbedingung, und jetzt ist jeder Code, der zuvor mit negativen Ints einwandfrei funktioniert hat, fehlerhaft.
  • Nachbedingungen können nicht geschwächt werden : Angenommen, Ihre Basisklasse benötigt. Alle Verbindungen zur Datenbank sollten geschlossen sein, bevor die Methode zurückgegeben wird. In Ihrer Unterklasse haben Sie diese Methode überschrieben und die Verbindung für die weitere Wiederverwendung offen gelassen. Sie haben die Nachbedingungen dieser Methode geschwächt.
  • Invarianten müssen erhalten bleiben : Die schwierigste und schmerzhafteste Einschränkung, die es zu erfüllen gilt. Invarianten sind einige Zeit in der Basisklasse verborgen und die einzige Möglichkeit, sie aufzudecken, besteht darin, den Code der Basisklasse zu lesen. Grundsätzlich müssen Sie sicher sein, dass beim Überschreiben einer Methode alles Unveränderliche nach der Ausführung Ihrer überschriebenen Methode unverändert bleibt. Das Beste, was ich mir vorstellen kann, ist, diese unveränderlichen Einschränkungen in der Basisklasse durchzusetzen, aber das wäre nicht einfach.
  • Verlaufsbeschränkung : Wenn Sie eine Methode überschreiben, dürfen Sie eine nicht änderbare Eigenschaft in der Basisklasse nicht ändern. Werfen Sie einen Blick auf diesen Code und Sie können sehen, dass Name als nicht änderbar definiert ist (privater Satz), aber SubType führt eine neue Methode ein, mit der er geändert werden kann (durch Reflexion):

    public class SuperType
    {
        public string Name { get; private set; }
        public SuperType(string name, int age)
        {
            Name = name;
            Age = age;
        }
    }
    public class SubType : SuperType
    {
        public void ChangeName(string newName)
        {
            var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);
        }
    }
    

Es gibt zwei weitere Elemente: Kontravarianz von Methodenargumenten und Kovarianz von Rückgabetypen . Aber es ist in C # nicht möglich (ich bin ein C # -Entwickler), daher interessieren sie mich nicht.

Referenz:


Ich bin auch ein C # -Entwickler und ich werde sagen, dass Ihre letzte Aussage ab Visual Studio 2010 mit dem .NET 4.0-Framework nicht wahr ist. Die Kovarianz der Rückgabetypen ermöglicht einen stärker abgeleiteten Rückgabetyp als von der Schnittstelle definiert. Beispiel: Beispiel: IEnumerable <T> (T ist kovariant) IEnumerator <T> (T ist kovariant) IQueryable <T> (T ist kovariant) IGrouping <TKey, TElement> (TKey und TElement sind kovariant) IComparer <T> (T. ist kontravariant) IEqualityComparer <T> (T ist kontravariant) IComparable <T> (T ist kontravariant) msdn.microsoft.com/en-us/library/dd233059(v=vs.100).aspx
LCarter

1
Tolle und zielgerichtete Antwort (obwohl die ursprünglichen Fragen mehr Beispiele als Regeln betrafen).
Mike

22

Der LSP ist eine Regel über den Vertrag der Klassen: Wenn eine Basisklasse einen Vertrag erfüllt, müssen vom LSP abgeleitete Klassen auch diesen Vertrag erfüllen.

In Pseudo-Python

class Base:
   def Foo(self, arg): 
       # *... do stuff*

class Derived(Base):
   def Foo(self, arg):
       # *... do stuff*

Erfüllt LSP, wenn jedes Mal, wenn Sie Foo für ein abgeleitetes Objekt aufrufen, genau die gleichen Ergebnisse erzielt werden wie beim Aufrufen von Foo für ein Basisobjekt, solange arg identisch ist.


9
Aber ... wenn Sie immer das gleiche Verhalten haben, wozu dann die abgeleitete Klasse?
Leonid

2
Sie haben einen Punkt verpasst: Es ist das gleiche beobachtete Verhalten. Sie können beispielsweise etwas durch O (n) -Leistung durch etwas funktional Äquivalentes ersetzen, jedoch durch O (lg n) -Leistung. Oder Sie ersetzen etwas, das auf mit MySQL implementierte Daten zugreift, und ersetzen es durch eine speicherinterne Datenbank.
Charlie Martin

@Charlie Martin, die eher auf eine Schnittstelle als auf eine Implementierung codiert - das finde ich toll. Dies gilt nicht nur für OOP. Funktionale Sprachen wie Clojure fördern dies ebenfalls. Selbst in Bezug auf Java oder C # denke ich, dass die Verwendung einer Schnittstelle anstelle einer abstrakten Klasse plus Klassenhierarchien für die von Ihnen bereitgestellten Beispiele selbstverständlich wäre. Python ist nicht stark typisiert und hat keine wirklichen Schnittstellen, zumindest nicht explizit. Meine Schwierigkeit ist, dass ich seit mehreren Jahren OOP mache, ohne mich an SOLID zu halten. Jetzt, wo ich darauf gestoßen bin, scheint es einschränkend und fast widersprüchlich.
Hamish Grubijan

Nun, Sie müssen zurückgehen und sich Barbaras Originalpapier ansehen. Reports-archive.adm.cs.cmu.edu/anon/1999/CMU-CS-99-156.ps Es wird nicht wirklich in Bezug auf Schnittstellen angegeben, und es ist eine logische Beziehung, die in keiner gilt (oder nicht) Programmiersprache, die irgendeine Form von Vererbung hat.
Charlie Martin

1
@ HamishGrubijan Ich weiß nicht, wer dir gesagt hat, dass Python nicht stark typisiert ist, aber sie haben dich angelogen (und wenn du mir nicht glaubst, starte einen Python-Interpreter und versuche es 2 + "2"). Vielleicht verwechseln Sie "stark typisiert" mit "statisch typisiert"?
Asmeurer

21

Lange Rede kurzer Sinn , lassen wir Rechtecke Rechtecke und Quadrate Quadrate, praktisches Beispiel , wenn eine Elternklasse erstreckt, müssen Sie entweder KONSERVE die genaue Eltern API oder sie zu verlängern.

Angenommen , Sie haben ein Basis- ItemsRepository.

class ItemsRepository
{
    /**
    * @return int Returns number of deleted rows
    */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        return $numberOfDeletedRows;
    }
}

Und eine Unterklasse, die es erweitert:

class BadlyExtendedItemsRepository extends ItemsRepository
{
    /**
     * @return void Was suppose to return an INT like parent, but did not, breaks LSP
     */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        // we broke the behaviour of the parent class
        return;
    }
}

Dann könnte ein Client mit der Base ItemsRepository-API arbeiten und sich darauf verlassen.

/**
 * Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
 *
 * Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
 * but if the sub-class won't abide the base class API, the client will get broken.
 */
class ItemsService
{
    /**
     * @var ItemsRepository
     */
    private $itemsRepository;

    /**
     * @param ItemsRepository $itemsRepository
     */
    public function __construct(ItemsRepository $itemsRepository)
    {
        $this->itemsRepository = $itemsRepository;
    }

    /**
     * !!! Notice how this is suppose to return an int. My clients expect it based on the
     * ItemsRepository API in the constructor !!!
     *
     * @return int
     */
    public function delete()
    {
        return $this->itemsRepository->delete();
    }
} 

Der LSP ist fehlerhaft, wenn das Ersetzen der übergeordneten Klasse durch eine Unterklasse den Vertrag der API bricht .

class ItemsController
{
    /**
     * Valid delete action when using the base class.
     */
    public function validDeleteAction()
    {
        $itemsService = new ItemsService(new ItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is an INT :)
    }

    /**
     * Invalid delete action when using a subclass.
     */
    public function brokenDeleteAction()
    {
        $itemsService = new ItemsService(new BadlyExtendedItemsRepository());
        $numberOfDeletedItems = $itemsService->delete();

        // $numberOfDeletedItems is a NULL :(
    }
}

Weitere Informationen zum Schreiben wartbarer Software finden Sie in meinem Kurs: https://www.udemy.com/enterprise-php/


20

Funktionen, die Zeiger oder Verweise auf Basisklassen verwenden, müssen Objekte abgeleiteter Klassen verwenden können, ohne es zu wissen.

Als ich zum ersten Mal über LSP las, ging ich davon aus, dass dies in einem sehr strengen Sinne gemeint war, was im Wesentlichen der Implementierung der Schnittstelle und dem typsicheren Casting gleichkam. Dies würde bedeuten, dass LSP entweder durch die Sprache selbst sichergestellt wird oder nicht. In diesem strengen Sinne ist ThreeDBoard beispielsweise für den Compiler sicherlich ein Ersatz für Board.

Nachdem ich mehr über das Konzept gelesen hatte, stellte ich fest, dass LSP im Allgemeinen breiter interpretiert wird.

Kurz gesagt, was es für Client-Code bedeutet, zu "wissen", dass das Objekt hinter dem Zeiger von einem abgeleiteten Typ ist und nicht vom Zeigertyp, ist nicht auf die Typensicherheit beschränkt. Die Einhaltung von LSP kann auch durch Prüfen des tatsächlichen Verhaltens des Objekts überprüft werden. Das heißt, Sie untersuchen die Auswirkung der Status- und Methodenargumente eines Objekts auf die Ergebnisse der Methodenaufrufe oder die Arten von Ausnahmen, die vom Objekt ausgelöst werden.

Wenn wir noch einmal auf das Beispiel zurückkommen, können die Board-Methoden theoretisch so gestaltet werden, dass sie auf ThreeDBoard einwandfrei funktionieren. In der Praxis wird es jedoch sehr schwierig sein, Verhaltensunterschiede zu vermeiden, mit denen der Client möglicherweise nicht richtig umgeht, ohne die Funktionalität zu beeinträchtigen, die ThreeDBoard hinzufügen soll.

Mit diesem Wissen kann die Bewertung der LSP-Einhaltung ein hervorragendes Instrument sein, um festzustellen, wann die Zusammensetzung der geeignetere Mechanismus für die Erweiterung vorhandener Funktionen ist, anstatt die Vererbung.


19

Ich denke, jeder hat irgendwie abgedeckt, was LSP technisch ist: Sie möchten im Grunde in der Lage sein, von Subtypdetails zu abstrahieren und Supertypen sicher zu verwenden.

Liskov hat also drei Regeln:

  1. Signaturregel: Es sollte eine gültige Implementierung jeder Operation des Supertyps im Subtyp syntaktisch geben. Etwas, das ein Compiler für Sie überprüfen kann. Es gibt eine kleine Regel, weniger Ausnahmen auszulösen und mindestens so zugänglich zu sein wie die Supertyp-Methoden.

  2. Methodenregel: Die Implementierung dieser Operationen ist semantisch einwandfrei.

    • Schwächere Voraussetzungen: Die Subtypfunktionen sollten mindestens das verwenden, was der Supertyp als Eingabe verwendet hat, wenn nicht mehr.
    • Stärkere Nachbedingungen: Sie sollten eine Teilmenge der Ausgabe der erzeugten Supertypmethoden erzeugen.
  3. Eigenschaftsregel: Dies geht über einzelne Funktionsaufrufe hinaus.

    • Invarianten: Dinge, die immer wahr sind, müssen wahr bleiben. Z.B. Die Größe eines Sets ist niemals negativ.
    • Evolutionäre Eigenschaften: Normalerweise hat dies etwas mit Unveränderlichkeit oder der Art der Zustände zu tun, in denen sich das Objekt befinden kann. Oder das Objekt wächst nur und schrumpft nie, sodass die Subtyp-Methoden es nicht schaffen sollten.

Alle diese Eigenschaften müssen beibehalten werden, und die zusätzliche Subtyp-Funktionalität sollte die Supertypeigenschaften nicht verletzen.

Wenn diese drei Dinge erledigt sind, haben Sie sich von den zugrunde liegenden Dingen entfernt und schreiben lose gekoppelten Code.

Quelle: Programmentwicklung in Java - Barbara Liskov


18

Ein wichtiges Beispiel für die Verwendung von LSP sind Softwaretests .

Wenn ich eine Klasse A habe, die eine LSP-kompatible Unterklasse von B ist, kann ich die Testsuite von B zum Testen von A wiederverwenden.

Um die Unterklasse A vollständig zu testen, muss ich wahrscheinlich einige weitere Testfälle hinzufügen, aber mindestens kann ich alle Testfälle der Oberklasse B wiederverwenden.

Eine Möglichkeit, dies zu realisieren, besteht darin, eine von McGregor als "Parallele Hierarchie zum Testen" bezeichnete Struktur aufzubauen: Meine ATestKlasse erbt von BTest. Dann ist eine Art Injektion erforderlich, um sicherzustellen, dass der Testfall mit Objekten vom Typ A und nicht vom Typ B funktioniert (ein einfaches Muster für die Vorlagenmethode reicht aus).

Beachten Sie, dass die Wiederverwendung der Supertestsuite für alle Unterklassenimplementierungen tatsächlich eine Möglichkeit ist, zu testen, ob diese Unterklassenimplementierungen LSP-kompatibel sind. Man kann also auch argumentieren, dass man die Superklasse-Testsuite im Kontext einer beliebigen Unterklasse ausführen sollte .

Siehe auch die Antwort auf die Stackoverflow-Frage " Kann ich eine Reihe wiederverwendbarer Tests implementieren, um die Implementierung einer Schnittstelle zu testen? "


14

Lassen Sie uns in Java veranschaulichen:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }

   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

class Car extends TransportationDevice
{
   @Override
   void startEngine() { ... }
}

Hier gibt es kein Problem, oder? Ein Auto ist definitiv ein Transportmittel, und hier können wir sehen, dass es die startEngine () -Methode seiner Oberklasse überschreibt.

Fügen wir ein weiteres Transportgerät hinzu:

class Bicycle extends TransportationDevice
{
   @Override
   void startEngine() /*problem!*/
}

Jetzt läuft nicht alles wie geplant! Ja, ein Fahrrad ist ein Transportgerät, hat jedoch keinen Motor und daher kann die Methode startEngine () nicht implementiert werden.

Dies sind die Arten von Problemen, zu denen ein Verstoß gegen das Liskov-Substitutionsprinzip führt, und sie können meistens durch eine Methode erkannt werden, die nichts tut oder sogar nicht implementiert werden kann.

Die Lösung für diese Probleme ist eine korrekte Vererbungshierarchie, und in unserem Fall würden wir das Problem lösen, indem wir Klassen von Transportgeräten mit und ohne Motoren unterscheiden. Obwohl ein Fahrrad ein Transportmittel ist, hat es keinen Motor. In diesem Beispiel ist unsere Definition des Transportgeräts falsch. Es sollte keinen Motor haben.

Wir können unsere TransportationDevice-Klasse wie folgt umgestalten:

class TrasportationDevice
{
   String name;
   String getName() { ... }
   void setName(String n) { ... }

   double speed;
   double getSpeed() { ... }
   void setSpeed(double d) { ... }
}

Jetzt können wir TransportationDevice für nicht motorisierte Geräte erweitern.

class DevicesWithoutEngines extends TransportationDevice
{  
   void startMoving() { ... }
}

Und erweitern Sie TransportationDevice für motorisierte Geräte. Hier ist es besser, das Engine-Objekt hinzuzufügen.

class DevicesWithEngines extends TransportationDevice
{  
   Engine engine;
   Engine getEngine() { ... }
   void setEngine(Engine e) { ... }

   void startEngine() { ... }
}

Dadurch wird unsere Fahrzeugklasse spezialisierter, während das Liskov-Substitutionsprinzip eingehalten wird.

class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

Und unsere Fahrradklasse entspricht auch dem Liskov-Substitutionsprinzip.

class Bicycle extends DevicesWithoutEngines
{
   @Override
   void startMoving() { ... }
}

9

Diese Formulierung des LSP ist viel zu stark:

Wenn es für jedes Objekt o1 vom Typ S ein Objekt o2 vom Typ T gibt, so dass für alle Programme P, die in Bezug auf T definiert sind, das Verhalten von P unverändert bleibt, wenn o2 durch o1 ersetzt wird, dann ist S ein Subtyp von T.

Was im Grunde bedeutet, dass S eine andere, vollständig gekapselte Implementierung genau derselben Sache wie T ist. Und ich könnte mutig sein und entscheiden, dass Leistung Teil des Verhaltens von P ist ...

Grundsätzlich verstößt jede Verwendung von Spätbindung gegen den LSP. Es ist der springende Punkt von OO, ein anderes Verhalten zu erzielen, wenn wir ein Objekt einer Art durch ein anderes Objekt ersetzen!

Die von Wikipedia zitierte Formulierung ist besser, da die Eigenschaft vom Kontext abhängt und nicht unbedingt das gesamte Verhalten des Programms umfasst.


2
Ähm, diese Formulierung gehört Barbara Liskov. Barbara Liskov, "Datenabstraktion und Hierarchie", SIGPLAN Notices, 23,5 (Mai 1988). Es ist nicht "viel zu stark", es ist "genau richtig" und es hat nicht die Implikation, die Sie denken, dass es hat. Es ist stark, hat aber genau die richtige Stärke.
DrPizza

Dann gibt es sehr wenige Untertypen im wirklichen Leben :)
Damien Pollet

3
"Verhalten ist unverändert" bedeutet nicht, dass ein Subtyp genau die gleichen konkreten Ergebniswerte liefert. Dies bedeutet, dass das Verhalten des Subtyps dem entspricht, was im Basistyp erwartet wird. Beispiel: Der Basistyp Shape kann eine draw () -Methode haben und festlegen, dass diese Methode die Form rendern soll. Zwei Untertypen der Form (z. B. Quadrat und Kreis) würden beide die draw () -Methode implementieren und die Ergebnisse würden unterschiedlich aussehen. Solange das Verhalten (Rendern der Form) mit dem angegebenen Verhalten von Shape übereinstimmt, sind Square und Circle gemäß LSP Subtypen von Shape.
SteveT

9

In einem sehr einfachen Satz können wir sagen:

Die untergeordnete Klasse darf ihre Basisklassenmerkmale nicht verletzen. Es muss damit fähig sein. Wir können sagen, es ist dasselbe wie Subtypisierung.


9

Liskovs Substitutionsprinzip (LSP)

Wir entwerfen ständig ein Programmmodul und erstellen einige Klassenhierarchien. Dann erweitern wir einige Klassen und erstellen einige abgeleitete Klassen.

Wir müssen sicherstellen, dass die neuen abgeleiteten Klassen nur erweitert werden, ohne die Funktionalität alter Klassen zu ersetzen. Andernfalls können die neuen Klassen unerwünschte Effekte erzeugen, wenn sie in vorhandenen Programmmodulen verwendet werden.

Das Substitutionsprinzip von Liskov besagt, dass, wenn ein Programmmodul eine Basisklasse verwendet, der Verweis auf die Basisklasse durch eine abgeleitete Klasse ersetzt werden kann, ohne die Funktionalität des Programmmoduls zu beeinträchtigen.

Beispiel:

Nachfolgend finden Sie das klassische Beispiel, für das das Substitutionsprinzip von Liskov verletzt wird. Im Beispiel werden 2 Klassen verwendet: Rechteck und Quadrat. Nehmen wir an, dass das Rectangle-Objekt irgendwo in der Anwendung verwendet wird. Wir erweitern die Anwendung und fügen die Square-Klasse hinzu. Die quadratische Klasse wird unter bestimmten Bedingungen von einem Factory-Muster zurückgegeben, und wir wissen nicht genau, welcher Objekttyp zurückgegeben wird. Aber wir wissen, dass es ein Rechteck ist. Wir erhalten das Rechteckobjekt, setzen die Breite auf 5 und die Höhe auf 10 und erhalten die Fläche. Für ein Rechteck mit der Breite 5 und der Höhe 10 sollte die Fläche 50 betragen. Stattdessen beträgt das Ergebnis 100

    // Violation of Likov's Substitution Principle
class Rectangle {
    protected int m_width;
    protected int m_height;

    public void setWidth(int width) {
        m_width = width;
    }

    public void setHeight(int height) {
        m_height = height;
    }

    public int getWidth() {
        return m_width;
    }

    public int getHeight() {
        return m_height;
    }

    public int getArea() {
        return m_width * m_height;
    }
}

class Square extends Rectangle {
    public void setWidth(int width) {
        m_width = width;
        m_height = width;
    }

    public void setHeight(int height) {
        m_width = height;
        m_height = height;
    }

}

class LspTest {
    private static Rectangle getNewRectangle() {
        // it can be an object returned by some factory ...
        return new Square();
    }

    public static void main(String args[]) {
        Rectangle r = LspTest.getNewRectangle();

        r.setWidth(5);
        r.setHeight(10);
        // user knows that r it's a rectangle.
        // It assumes that he's able to set the width and height as for the base
        // class

        System.out.println(r.getArea());
        // now he's surprised to see that the area is 100 instead of 50.
    }
}

Fazit:

Dieses Prinzip ist nur eine Erweiterung des Open-Close-Prinzips und bedeutet, dass wir sicherstellen müssen, dass neue abgeleitete Klassen die Basisklassen erweitern, ohne ihr Verhalten zu ändern.

Siehe auch: Open Close-Prinzip

Einige ähnliche Konzepte für eine bessere Struktur: Konvention über Konfiguration


8

Das Liskov-Substitutionsprinzip

  • Die überschriebene Methode sollte nicht leer bleiben
  • Die überschriebene Methode sollte keinen Fehler auslösen
  • Das Verhalten der Basisklasse oder der Schnittstelle sollte aufgrund des abgeleiteten Klassenverhaltens nicht geändert (überarbeitet) werden.

7

Ein Nachtrag:
Ich frage mich, warum niemand über die Invarianten, Voraussetzungen und Post-Bedingungen der Basisklasse geschrieben hat, die von den abgeleiteten Klassen eingehalten werden müssen. Damit eine abgeleitete Klasse D von der Basisklasse B vollständig unterstützt werden kann, muss Klasse D bestimmte Bedingungen erfüllen:

  • In-Varianten der Basisklasse müssen von der abgeleiteten Klasse beibehalten werden
  • Die Voraussetzungen der Basisklasse dürfen durch die abgeleitete Klasse nicht gestärkt werden
  • Nachbedingungen der Basisklasse dürfen durch die abgeleitete Klasse nicht geschwächt werden.

Der Abgeleitete muss sich also der drei oben genannten Bedingungen bewusst sein, die von der Basisklasse auferlegt werden. Daher sind die Regeln für die Untertypisierung im Voraus festgelegt. Dies bedeutet, dass die Beziehung "IS A" nur eingehalten werden darf, wenn bestimmte Regeln vom Subtyp eingehalten werden. Diese Regeln in Form von Invarianten, Vorkodierungen und Nachbedingungen sollten durch einen formellen „ Entwurfsvertrag “ festgelegt werden.

Weitere Diskussionen dazu finden Sie in meinem Blog: Liskov-Substitutionsprinzip


6

Der LSP besagt in einfachen Worten , dass Objekte derselben Oberklasse miteinander ausgetauscht werden können sollten, ohne etwas zu beschädigen .

Zum Beispiel, wenn wir eine haben Catund eine DogKlasse von einer abgeleiteten AnimalKlasse sollten alle Funktionen die Klasse Tier mit der Lage sein , zu verwenden Catoder Dognormal und verhalten.


4

Wäre die Implementierung von ThreeDBoard in Bezug auf eine Reihe von Boards so nützlich?

Vielleicht möchten Sie ThreeDBoard-Scheiben in verschiedenen Ebenen als Board behandeln. In diesem Fall möchten Sie möglicherweise eine Schnittstelle (oder eine abstrakte Klasse) für Board abstrahieren, um mehrere Implementierungen zu ermöglichen.

In Bezug auf die externe Schnittstelle möchten Sie möglicherweise eine Board-Schnittstelle für TwoDBoard und ThreeDBoard herausrechnen (obwohl keine der oben genannten Methoden passt).


1
Ich denke, das Beispiel soll lediglich zeigen, dass das Erben von Board im Kontext von ThreeDBoard keinen Sinn macht und alle Methodensignaturen mit einer Z-Achse bedeutungslos sind.
NotMyself

4

Ein Quadrat ist ein Rechteck, bei dem die Breite der Höhe entspricht. Wenn das Quadrat zwei verschiedene Größen für die Breite und Höhe festlegt, verletzt es die quadratische Invariante. Dies wird durch die Einführung von Nebenwirkungen umgangen. Aber wenn das Rechteck eine setSize (Höhe, Breite) mit der Voraussetzung 0 <Höhe und 0 <Breite hatte. Die abgeleitete Subtypmethode erfordert height == width; eine stärkere Voraussetzung (und das verstößt gegen lsp). Dies zeigt, dass Quadrat zwar ein Rechteck ist, aber kein gültiger Untertyp, da die Vorbedingung verstärkt ist. Die Umgehung (im Allgemeinen eine schlechte Sache) verursacht eine Nebenwirkung und dies schwächt die Post-Bedingung (die lsp verletzt). setWidth auf der Basis hat die Postbedingung 0 <width. Das Abgeleitte schwächt es mit Höhe == Breite.

Daher ist ein Quadrat mit veränderbarer Größe kein Rechteck mit veränderbarer Größe.


4

Dieses Prinzip wurde von Barbara Liskov eingeführt 1987 und erweitert das Open-Closed-Prinzip, indem es sich auf das Verhalten einer Oberklasse und ihre Subtypen konzentriert.

Ihre Bedeutung wird deutlich, wenn wir die Konsequenzen einer Verletzung betrachten. Stellen Sie sich eine Anwendung vor, die die folgende Klasse verwendet.

public class Rectangle 
{ 
  private double width;

  private double height; 

  public double Width 
  { 
    get 
    { 
      return width; 
    } 
    set 
    { 
      width = value; 
    }
  } 

  public double Height 
  { 
    get 
    { 
      return height; 
    } 
    set 
    { 
      height = value; 
    } 
  } 
}

Stellen Sie sich vor, der Kunde verlangt eines Tages die Möglichkeit, neben Rechtecken auch Quadrate zu bearbeiten. Da ein Quadrat ein Rechteck ist, sollte die Quadratklasse von der Rechteckklasse abgeleitet werden.

public class Square : Rectangle
{
} 

Auf diese Weise stoßen wir jedoch auf zwei Probleme:

Ein Quadrat benötigt nicht sowohl Höhen- als auch Breitenvariablen, die vom Rechteck geerbt werden. Dies kann zu einer erheblichen Verschwendung von Speicher führen, wenn Hunderttausende von Quadratobjekten erstellt werden müssen. Die vom Rechteck geerbten Eigenschaften des Setzers für Breite und Höhe sind für ein Quadrat ungeeignet, da Breite und Höhe eines Quadrats identisch sind. Um sowohl Höhe als auch Breite auf den gleichen Wert festzulegen, können Sie zwei neue Eigenschaften wie folgt erstellen:

public class Square : Rectangle
{
  public double SetWidth 
  { 
    set 
    { 
      base.Width = value; 
      base.Height = value; 
    } 
  } 

  public double SetHeight 
  { 
    set 
    { 
      base.Height = value; 
      base.Width = value; 
    } 
  } 
}

Wenn nun jemand die Breite eines quadratischen Objekts festlegt, ändert sich seine Höhe entsprechend und umgekehrt.

Square s = new Square(); 
s.SetWidth(1); // Sets width and height to 1. 
s.SetHeight(2); // sets width and height to 2. 

Gehen wir weiter und betrachten diese andere Funktion:

public void A(Rectangle r) 
{ 
  r.SetWidth(32); // calls Rectangle.SetWidth 
} 

Wenn wir dieser Funktion einen Verweis auf ein quadratisches Objekt übergeben, verletzen wir den LSP, da die Funktion für Ableitungen ihrer Argumente nicht funktioniert. Die Eigenschaften width und height sind nicht polymorph, da sie im Rechteck nicht als virtuell deklariert sind (das quadratische Objekt wird beschädigt, da die Höhe nicht geändert wird).

Wenn wir jedoch die Setter-Eigenschaften als virtuell deklarieren, werden wir einem weiteren Verstoß ausgesetzt sein, dem OCP. Tatsächlich führt die Erstellung eines abgeleiteten Klassenquadrats zu Änderungen am Basisklassenrechteck.


3

Die klarste Erklärung für LSP, die ich bisher gefunden habe, war: "Das Liskov-Substitutionsprinzip besagt, dass das Objekt einer abgeleiteten Klasse ein Objekt der Basisklasse ersetzen kann, ohne Fehler im System zu verursachen oder das Verhalten der Basisklasse zu ändern "von hier . Der Artikel enthält ein Codebeispiel für die Verletzung und Behebung von LSP.


1
Bitte geben Sie die Codebeispiele für den Stapelüberlauf an.
Sebenalern

3

Angenommen, wir verwenden ein Rechteck in unserem Code

r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);

In unserer Geometrieklasse haben wir gelernt, dass ein Quadrat eine spezielle Art von Rechteck ist, da seine Breite der Länge seiner Höhe entspricht. Lassen Sie uns auch eine SquareKlasse basierend auf diesen Informationen erstellen:

class Square extends Rectangle {
    setDimensions(width, height){
        assert(width == height);
        super.setDimensions(width, height);
    }
} 

Wenn wir das Ersetzen Rectanglemit Squarein unserem ersten Code, dann wird es brechen:

r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);

Dies liegt daran, dass der Squareeine neue Voraussetzung hat, die wir in der RectangleKlasse nicht hatten : width == height. Laut LSP sollten die RectangleInstanzen durch RectangleInstanzen der Unterklasse ersetzt werden können. Dies liegt daran, dass diese Instanzen die Typprüfung für RectangleInstanzen bestehen und daher unerwartete Fehler in Ihrem Code verursachen.

Dies war ein Beispiel für den Teil "Voraussetzungen können in einem Subtyp nicht verstärkt werden" im Wiki-Artikel . Zusammenfassend lässt sich sagen, dass ein Verstoß gegen LSP wahrscheinlich irgendwann zu Fehlern in Ihrem Code führt.


3

LSP sagt, dass Objekte durch ihre Untertypen ersetzt werden können. Andererseits weist dieses Prinzip auf

Untergeordnete Klassen sollten niemals die Typdefinitionen der übergeordneten Klasse aufheben.

Das folgende Beispiel hilft dabei, LSP besser zu verstehen.

Ohne LSP:

public interface CustomerLayout{

    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            return; //it isn`t rendered in this case
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

Fixierung durch LSP:

public interface CustomerLayout{
    public void render();
}


public FreeCustomer implements CustomerLayout {
     ...
    @Override
    public void render(){
        //code
    }
}


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public void render(){
        if(!hasSeenAd)
            showAd();//it has a specific behavior based on its requirement
        //code
    }
}

public void renderView(CustomerLayout layout){
    layout.render();
}

2

Ich ermutige Sie, den Artikel zu lesen: Verstoß gegen das Liskov-Substitutionsprinzip (LSP) .

Dort finden Sie eine Erklärung zum Liskov-Substitutionsprinzip, allgemeine Hinweise, anhand derer Sie erraten können, ob Sie bereits gegen das Liskov-Substitutionsprinzip verstoßen haben, und ein Beispiel für einen Ansatz, mit dem Sie Ihre Klassenhierarchie sicherer machen können.


2

Das LISKOV SUBSTITUTION PRINCIPLE (aus dem Buch von Mark Seemann) besagt, dass wir in der Lage sein sollten, eine Implementierung einer Schnittstelle durch eine andere zu ersetzen, ohne den Client oder die Implementierung zu beschädigen. Dieses Prinzip ermöglicht es, Anforderungen zu erfüllen, die in der Zukunft auftreten, selbst wenn wir können. ' Ich sehe sie heute nicht voraus.

Wenn wir den Computer von der Wand trennen (Implementierung), fallen weder die Steckdose (Schnittstelle) noch der Computer (Client) aus (wenn es sich um einen Laptop handelt, kann er sogar einige Zeit mit seinen Batterien betrieben werden). . Bei Software erwartet ein Client jedoch häufig, dass ein Dienst verfügbar ist. Wenn der Dienst entfernt wurde, erhalten wir eine NullReferenceException. Um mit dieser Art von Situation fertig zu werden, können wir eine Implementierung einer Schnittstelle erstellen, die „nichts“ tut. Dies ist ein Entwurfsmuster, das als Null-Objekt bekannt ist [4] und ungefähr dem Herausziehen des Computers von der Wand entspricht. Da wir lose Kopplung verwenden, können wir eine echte Implementierung durch etwas ersetzen, das nichts tut, ohne Probleme zu verursachen.


2

Das Substitutionsprinzip von Likov besagt, dass, wenn ein Programmmodul eine Basisklasse verwendet , der Verweis auf die Basisklasse durch eine abgeleitete Klasse ersetzt werden kann, ohne die Funktionalität des Programmmoduls zu beeinträchtigen.

Absicht - Abgeleitete Typen müssen ihre Basistypen vollständig ersetzen können.

Beispiel - Co-Varianten-Rückgabetypen in Java.


1

Hier ist ein Auszug aus diesem Beitrag , der die Dinge gut verdeutlicht:

[..] Um einige Prinzipien zu verstehen, ist es wichtig zu erkennen, wann gegen sie verstoßen wurde. Das werde ich jetzt tun.

Was bedeutet die Verletzung dieses Prinzips? Dies impliziert, dass ein Objekt den Vertrag nicht erfüllt, der durch eine mit einer Schnittstelle ausgedrückte Abstraktion auferlegt wird. Mit anderen Worten bedeutet dies, dass Sie Ihre Abstraktionen falsch identifiziert haben.

Betrachten Sie das folgende Beispiel:

interface Account
{
    /**
     * Withdraw $money amount from this account.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
    private $balance;
    public function withdraw(Money $money)
    {
        if (!$this->enoughMoney($money)) {
            return;
        }
        $this->balance->subtract($money);
    }
}

Ist das eine Verletzung von LSP? Ja. Dies liegt daran, dass der Vertrag des Kontos besagt, dass ein Konto zurückgezogen werden würde, dies ist jedoch nicht immer der Fall. Was soll ich also tun, um das Problem zu beheben? Ich ändere nur den Vertrag:

interface Account
{
    /**
     * Withdraw $money amount from this account if its balance is enough.
     * Otherwise do nothing.
     *
     * @param Money $money
     * @return mixed
     */
    public function withdraw(Money $money);
}

Voilà, jetzt ist der Vertrag erfüllt.

Diese subtile Verletzung zwingt einen Kunden häufig dazu, den Unterschied zwischen den verwendeten konkreten Objekten zu erkennen. In Anbetracht des Vertrags des ersten Kontos könnte dies beispielsweise folgendermaßen aussehen:

class Client
{
    public function go(Account $account, Money $money)
    {
        if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
            return;
        }
        $account->withdraw($money);
    }
}

Und dies verstößt automatisch gegen das Open-Closed-Prinzip [dh für die Geldabhebungspflicht. Weil Sie nie wissen, was passiert, wenn ein Objekt, das gegen den Vertrag verstößt, nicht genug Geld hat. Wahrscheinlich gibt es nur nichts zurück, wahrscheinlich wird eine Ausnahme ausgelöst. Sie müssen also prüfen, ob dies der Fall isthasEnoughMoney() nicht Teil einer Schnittstelle ist. Diese erzwungene konkretklassenabhängige Prüfung ist also eine OCP-Verletzung.

Dieser Punkt befasst sich auch mit einem Missverständnis, das mir bei LSP-Verstößen häufig begegnet. Es heißt: "Wenn sich das Verhalten eines Elternteils bei einem Kind geändert hat, verstößt es gegen LSP." Dies ist jedoch nicht der Fall - solange ein Kind nicht gegen den Vertrag seiner Eltern verstößt.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.