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?
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?
Antworten:
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 Square
a 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 Square
stammen Rectangle
, a ableiten möchten , Square
sollte 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 SetWidth
und SetHeight
Methoden auf Ihrer Rectangle
Basisklasse; das scheint vollkommen logisch. Allerdings , wenn Ihr Rectangle
Hinweis auf eine spitz Square
, dann SetWidth
und SetHeight
macht keinen Sinn , weil eine Einstellung würde den anderen ändern , es zu entsprechen. In diesem Fall besteht Square
der Liskov-Substitutionstest mit nicht Rectangle
und die Abstraktion, von der Square
geerbt wurde, Rectangle
ist schlecht.
Sie sollten sich die anderen unbezahlbaren Motivationsposter von SOLID Principles ansehen .
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.
Das Liskov-Substitutionsprinzip (LSP, 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:
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 ThreeDBoard
Klasse eingeführt, die sich erweitert Board
.
Auf den ersten Blick scheint dies eine gute Entscheidung zu sein. Board
bietet sowohl die Height
und Width
-Eigenschaften als auch ThreeDBoard
die Z-Achse.
Wenn Sie sich alle anderen Mitglieder ansehen, von denen es geerbt wurde, bricht es zusammen Board
. Die Methoden für AddUnit
, GetTile
, GetUnits
und so weiter, nehmen alle X- und Y - Parameter in der Board
Klasse , aber das ThreeDBoard
braucht einen Z - Parameter als auch.
Sie müssen diese Methoden also erneut mit einem Z-Parameter implementieren. Der Z-Parameter hat keinen Kontext zur Board
Klasse und die von der Board
Klasse geerbten Methoden verlieren ihre Bedeutung. Eine Codeeinheit, die versucht, die ThreeDBoard
Klasse als Basisklasse zu verwenden, Board
hätte kein Glück.
Vielleicht sollten wir einen anderen Ansatz finden. Anstatt zu erweitern Board
, ThreeDBoard
sollte aus Board
Objekten bestehen. Ein Board
Objekt pro Einheit der Z-Achse.
Dies ermöglicht es uns, gute objektorientierte Prinzipien wie Kapselung und Wiederverwendung zu verwenden und verletzt LSP nicht.
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:
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.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Sie müssen das Objekt auf FlyingBirds übertragen, um Fly verwenden zu können, was nicht schön ist, oder?
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 Duck
immer so funktionieren , wenn es bestanden wird .
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, Rectangle
sollte 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
.
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
DrawShape
Funktion schlecht geformt. Es muss über jede mögliche Ableitung derShape
Klasse Bescheid wissen und muss geändert werden, wenn neue Ableitungen vonShape
erstellt 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
Rectangle
Klasse 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 modellierenRectangle
. [...]
Square
erbt dieSetWidth
undSetHeight
Funktionen. Diese Funktionen sind für a völlig ungeeignetSquare
, 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 überschreibenSetWidth
undSetHeight
[...]Beachten Sie jedoch die folgende Funktion:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Wenn wir einen Verweis auf ein
Square
Objekt an diese Funktion übergeben, wird dasSquare
Objekt 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.[...]
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.
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 S
der Supertyp erbt, von ihm abgeleitet ist oder ein Subtyp davon ist T
).
Dies tritt beispielsweise auf, wenn eine Funktion mit einem Eingabeparameter vom Typ T
mit einem Argumentwert vom Typ aufgerufen (dh aufgerufen) wird S
. Oder wenn einem Bezeichner vom Typ T
ein 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 S
kontravariante Eingabeparameter und eine kovariante Ausgabe hat.
Kontravariante bedeutet, dass die Varianz der Richtung der Vererbung widerspricht, dh der Typ Si
jedes Eingabeparameters jeder Methode des Subtyps S
muss derselbe sein oder ein Supertyp des Typs Ti
des entsprechenden Eingabeparameters der entsprechenden Methode des Supertyps T
.
Kovarianz bedeutet, dass die Varianz in der gleichen Richtung der Vererbung liegt, dh der Typ So
der Ausgabe jeder Methode des Subtyps S
muss gleich sein oder ein Subtyp des Typs To
der 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 Ti
und die Ausgabe dem Typ zuweist To
. Wenn tatsächlich die entsprechende Methode von aufgerufen wird S
, wird jedes Ti
Eingabeargument einem Si
Eingabeparameter zugewiesen , und die So
Ausgabe wird dem Typ zugewiesen To
. Wenn also Si
nicht kontravariant wäre Ti
, könnte ein Subtyp Xi
- der kein Subtyp von Si
wä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 T
mü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:
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.
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.
Es gibt eine Checkliste, um festzustellen, ob Sie gegen Liskov verstoßen oder nicht.
Checkliste:
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:
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.
2 + "2"
). Vielleicht verwechseln Sie "stark typisiert" mit "statisch typisiert"?
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/
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.
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:
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.
Methodenregel: Die Implementierung dieser Operationen ist semantisch einwandfrei.
Eigenschaftsregel: Dies geht über einzelne Funktionsaufrufe hinaus.
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
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 ATest
Klasse 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? "
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() { ... }
}
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.
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.
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
Das Liskov-Substitutionsprinzip
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:
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
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 Cat
und eine Dog
Klasse von einer abgeleiteten Animal
Klasse sollten alle Funktionen die Klasse Tier mit der Lage sein , zu verwenden Cat
oder Dog
normal und verhalten.
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).
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.
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.
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.
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 Square
Klasse basierend auf diesen Informationen erstellen:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Wenn wir das Ersetzen Rectangle
mit Square
in 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 Square
eine neue Voraussetzung hat, die wir in der Rectangle
Klasse nicht hatten : width == height
. Laut LSP sollten die Rectangle
Instanzen durch Rectangle
Instanzen der Unterklasse ersetzt werden können. Dies liegt daran, dass diese Instanzen die Typprüfung für Rectangle
Instanzen 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.
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();
}
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.
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.
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.
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.