Was kann schief gehen, wenn das Liskov-Substitutionsprinzip verletzt wird?

27

Ich verfolgte diese hoch gestimmte Frage bezüglich eines möglichen Verstoßes gegen das Liskov-Substitutionsprinzip. Ich weiß, was das Liskov-Substitutionsprinzip ist, aber was mir immer noch nicht klar ist, kann schief gehen, wenn ich als Entwickler beim Schreiben von objektorientiertem Code nicht über das Prinzip nachdenke.

Aussenseiter
quelle
6
Was kann schief gehen, wenn Sie LSP nicht folgen? Im schlimmsten Fall rufen Sie Code-thulhu! ;)
FrustratedWithFormsDesigner
1
Als Autor dieser ursprünglichen Frage muss ich hinzufügen, dass es sich um eine akademische Frage handelte. Obwohl Verstöße zu Fehlern im Code führen können, hatte ich noch nie ein ernstes Fehler- oder Wartungsproblem, das auf einen Verstoß gegen LSP zurückzuführen ist.
Paul T Davies
2
@Paul Du hattest also nie ein Problem mit deinen Programmen aufgrund von verschlungenen OO-Hierarchien (die du nicht selbst entworfen hast, sondern möglicherweise erweitern musstest), bei denen Verträge von Leuten, die sich über den Zweck der Basisklasse nicht sicher waren, links und rechts gebrochen wurden beginnen mit? Ich beneide dich! :)
Andres F.
@PaulTDavies Der Schweregrad der Konsequenz hängt davon ab, ob die Benutzer (Programmierer, die die Bibliothek verwenden) über detaillierte Kenntnisse der Bibliotheksimplementierung verfügen (dh Zugriff auf den Bibliothekscode haben und mit diesem vertraut sind). Schließlich werden Benutzer Dutzende von Bedingungsprüfungen durchführen oder Wrapper erstellen um die Bibliothek zu berücksichtigen, nicht-LSP (klassenspezifisches Verhalten). Das schlimmste Szenario würde eintreten, wenn es sich bei der Bibliothek um ein kommerzielles Closed-Source-Produkt handelt.
Rwong
@Andres und rwong, bitte erläutern Sie diese Probleme mit einer Antwort. Die akzeptierte Antwort unterstützt Paul Davies so gut wie darin, dass die Konsequenzen geringfügig erscheinen (eine Ausnahme), die schnell bemerkt und behoben werden können, wenn Sie einen guten Compiler, einen statischen Analysator oder einen minimalen Komponententest haben.
user949300

Antworten:

31

Ich denke, es ist sehr gut in dieser Frage ausgedrückt, was einer der Gründe ist, die so hoch gewählt wurden.

Wenn Sie Close () für eine Task aufrufen, besteht die Möglichkeit, dass der Aufruf fehlschlägt, wenn es sich um eine ProjectTask mit dem Status "Gestartet" handelt, wenn es sich nicht um eine Basis-Task handelt.

Stellen Sie sich vor, Sie wollen:

public void ProcessTaskAndClose(Task taskToProcess)
{
    taskToProcess.Execute();
    taskToProcess.DateProcessed = DateTime.Now;
    taskToProcess.Close();
}

In dieser Methode wird gelegentlich der Aufruf von .Close () ausgelöst. Aufgrund der konkreten Implementierung eines abgeleiteten Typs müssen Sie nun das Verhalten dieser Methode ändern, das sich davon unterscheidet, wie diese Methode geschrieben würde, wenn Task keine Untertypen hätte, die vorhanden sein könnten an diese Methode übergeben.

Aufgrund von Verstößen gegen die Liskov-Substitution muss der Code, der Ihren Typ verwendet, über explizite Kenntnisse der internen Funktionsweise abgeleiteter Typen verfügen, um sie unterschiedlich zu behandeln. Dies koppelt den Code eng miteinander und erschwert im Allgemeinen die konsistente Verwendung der Implementierung.

Jimmy Hoffa
quelle
Bedeutet das, dass eine untergeordnete Klasse keine eigenen öffentlichen Methoden haben kann, die nicht in der übergeordneten Klasse deklariert sind?
Songo
@Songo: Nicht unbedingt: Es ist möglich, aber diese Methoden sind für einen Basiszeiger (oder eine Referenz oder Variable oder in welcher Sprache auch immer) "nicht erreichbar", und Sie benötigen einige Informationen zum Laufzeit-Typ, um den Typ des Objekts abzufragen bevor Sie diese Funktionen aufrufen können. Dies hängt jedoch stark mit der Syntax und der Semantik der Sprachen zusammen.
Emilio Garavaglia
2
Nein. Dies ist der Fall, wenn auf eine untergeordnete Klasse verwiesen wird, als wäre sie ein Typ der übergeordneten Klasse. In diesem Fall ist der Zugriff auf Mitglieder, die nicht in der übergeordneten Klasse deklariert sind, nicht möglich.
Chewy Gumball
1
@ Phil Yep; Dies ist die Definition von enger Kopplung: Das Ändern einer Sache führt zu Änderungen an anderen Dingen. Bei einer lose gekoppelten Klasse kann die Implementierung geändert werden, ohne dass Sie Code außerhalb der Klasse ändern müssen. Aus diesem Grund sind Verträge gut. Sie helfen Ihnen dabei, Änderungen an den Verbrauchern Ihres Objekts zu vermeiden: Halten Sie den Vertrag ein, und die Verbraucher müssen keine Änderungen vornehmen. Dadurch wird eine lose Kopplung erreicht. Wenn Ihre Kunden Code für Ihre Implementierung benötigen und nicht für Ihren Vertrag, ist dies eine enge Kopplung und erforderlich, wenn Sie gegen LSP verstoßen.
Jimmy Hoffa
1
@ user949300 Der Erfolg einer Software, die ihre Aufgabe erfüllt, ist kein Maß für Qualität, langfristige oder kurzfristige Kosten. Entwurfsprinzipien sind Versuche, Richtlinien zu schaffen, um die langfristigen Kosten von Software zu senken, und nicht, um Software zum "Funktionieren" zu bringen. Die Benutzer können alle gewünschten Prinzipien befolgen, ohne eine funktionierende Lösung zu implementieren, oder sie können keine befolgen und eine funktionierende Lösung implementieren. Obwohl Java-Sammlungen für viele Menschen funktionieren, bedeutet dies nicht, dass die Kosten für die Arbeit mit ihnen auf lange Sicht so günstig sind, wie sie sein könnten.
Jimmy Hoffa
13

Wenn Sie den in der Basisklasse definierten Vertrag nicht erfüllen, kann es zu einem unbeaufsichtigten Fehler kommen, wenn Sie fehlerhafte Ergebnisse erhalten.

LSP in Wikipedia Staaten

  • Voraussetzungen können in einem Subtyp nicht gestärkt werden.
  • Nachbedingungen können in einem Subtyp nicht abgeschwächt werden.
  • Invarianten des Supertyps müssen in einem Subtyp erhalten bleiben.

Sollte einer dieser Punkte nicht zutreffen, erhält der Anrufer möglicherweise ein Ergebnis, das er nicht erwartet.

Verhalten
quelle
1
Können Sie sich konkrete Beispiele vorstellen, um dies zu demonstrieren?
Mark Booth
1
@MarkBooth Das Problem mit der Kreisellipse / dem Quadrat-Rechteck kann hilfreich sein, um es zu veranschaulichen. Der Wikipedia-Artikel ist ein guter Anfang: en.wikipedia.org/wiki/Circle-ellipse_problem
Ed Hastings
7

Betrachten Sie einen klassischen Fall aus den Annalen der Interviewfragen: Sie haben Circle von Ellipse abgeleitet. Warum? Weil ein Kreis natürlich eine Ellipse ist!

Außer ... Ellipse hat zwei Funktionen:

Ellipse.set_alpha_radius(d)
Ellipse.set_beta_radius(d)

Natürlich müssen diese für Kreis neu definiert werden, da ein Kreis einen einheitlichen Radius hat. Sie haben zwei Möglichkeiten:

  1. Nach dem Aufruf von set_alpha_radius oder set_beta_radius werden beide auf den gleichen Betrag gesetzt.
  2. Nach dem Aufruf von set_alpha_radius oder set_beta_radius ist das Objekt kein Kreis mehr.

Die meisten OO-Sprachen unterstützen nicht die zweite, und das aus einem guten Grund: Es wäre überraschend, wenn Ihr Kreis kein Kreis mehr ist. Die erste Option ist also die beste. Beachten Sie jedoch die folgende Funktion:

some_function(Ellipse byref e)

Stellen Sie sich vor, dass eine Funktion e.set_alpha_radius aufruft. Aber da es sich bei e wirklich um einen Kreis handelte, wurde überraschenderweise auch der Beta-Radius festgelegt.

Und hier liegt das Substitutionsprinzip: Eine Unterklasse muss für eine Oberklasse substituierbar sein. Ansonsten passiert überraschendes.

Kaz Dragon
quelle
1
Ich denke, Sie können in Schwierigkeiten geraten, wenn Sie veränderbare Objekte verwenden. Ein Kreis ist auch eine Ellipse. Wenn Sie jedoch eine Ellipse, die auch ein Kreis ist, durch eine andere Ellipse ersetzen (was Sie mit einer Setter-Methode tun), kann nicht garantiert werden, dass die neue Ellipse auch ein Kreis ist (Kreise sind eine geeignete Teilmenge der Ellipsen).
Giorgio
2
In einer rein funktionalen Welt (mit unveränderlichen Objekten) hätte die Methode set_alpha_radius (d) den Rückgabetyp Ellipse (sowohl in der Ellipse als auch in der Kreisklasse).
Giorgio
@Giorgio Ja, ich hätte erwähnen sollen, dass dieses Problem nur bei veränderlichen Objekten auftritt.
Kaz Dragon
@ KazDragon: Warum sollte jemand eine Ellipse durch ein Kreisobjekt ersetzen, wenn wir wissen, dass eine Ellipse KEIN Kreis ist? Wenn jemand dies tut, hat er kein korrektes Verständnis für die Entitäten, die er modellieren möchte. Aber wenn wir diese Substitution zulassen, fördern wir dann nicht das lockere Verständnis des zugrunde liegenden Systems, das wir in unserer Software modellieren wollen, und schaffen so tatsächlich schlechte Software?
Einzelgänger
@maverick Ich glaube, du hast die Beziehung, die ich beschrieben habe, rückwärts gelesen. Die vorgeschlagene Beziehung ist umgekehrt: Ein Kreis ist eine Ellipse. Insbesondere ist ein Kreis eine Ellipse, bei der der Alpha-Radius und der Beta-Radius identisch sind. Die Erwartung könnte also sein, dass jede Funktion, die eine Ellipse als Parameter erwartet, ebenfalls einen Kreis haben könnte. Betrachten Sie berechne_Fläche (Ellipse). Wenn Sie einen Kreis passieren, erhalten Sie das gleiche Ergebnis. Das Problem ist jedoch, dass das Verhalten der Mutationsfunktionen von Ellipse nicht durch das von Circle ersetzt werden kann.
Kaz Dragon
6

Mit den Worten des Laien:

Ihr Code wird eine Menge CASE / switch-Klauseln enthalten .

Für jede dieser CASE / switch-Klauseln müssen von Zeit zu Zeit neue Fälle hinzugefügt werden, was bedeutet, dass die Codebasis nicht so skalierbar und wartbar ist, wie sie sein sollte.

Mit LSP funktioniert Code eher wie Hardware:

Sie müssen Ihren iPod nicht modifizieren, da Sie ein neues Paar externer Lautsprecher gekauft haben. Da sowohl der alte als auch der neue externe Lautsprecher dieselbe Schnittstelle verwenden, können sie ausgetauscht werden, ohne dass der iPod die gewünschte Funktionalität verliert.

Tulains Córdova
quelle
2
-1: Rundum schlechte Antwort
Thomas Eding
3
@ Thomas Ich bin anderer Meinung. Das ist eine gute Analogie. Er spricht davon, die Erwartungen nicht zu brechen, worum es beim LSP geht. (obwohl der Teil über case / switch ein bisschen schwach ist, stimme ich zu)
Andres F.
2
Und dann brach Apple LSP durch das Ändern der Anschlüsse. Diese Antwort lebt weiter.
Magus
Ich verstehe nicht, was switch-Anweisungen mit LSP zu tun haben. Wenn Sie sich auf das Umschalten beziehen, um typeof(someObject)zu entscheiden, was Sie "dürfen", dann sicher, aber das ist ein ganz anderes Anti-Pattern.
Sara
Eine drastische Reduzierung der Anzahl von switch-Anweisungen ist ein wünschenswerter Nebeneffekt von LSP. Da Objekte für jedes andere Objekt stehen können, das dieselbe Schnittstelle erweitert, müssen keine Sonderfälle behandelt werden.
Tulains Córdova
1

um mit dem UndoManager von Java ein reales Beispiel zu geben

es erbt von AbstractUndoableEditwessen Vertrag angibt, dass es 2 Zustände hat (rückgängig gemacht und wiederholt) und zwischen diesen mit einzelnen Aufrufen zu undo()und wechseln kannredo()

Der UndoManager hat jedoch mehr Status und verhält sich wie ein Rückgängig-Puffer (jeder Aufruf undomacht einige, aber nicht alle Änderungen rückgängig , wodurch die Nachbedingung geschwächt wird).

Dies führt zu der hypothetischen Situation, dass Sie einem CompoundEdit einen UndoManager hinzufügen, bevor Sie end()Undo aufrufen. Dieser CompoundEdit ruft dann Undo auf und ruft undo()jede Bearbeitung auf, sobald Ihre Änderungen teilweise rückgängig gemacht wurden

Ich habe mein eigenes UndoManagergewürfelt, um das zu vermeiden (ich sollte es aber wahrscheinlich in umbenennen UndoBuffer)

Ratschenfreak
quelle
1

Beispiel: Sie arbeiten mit einem UI-Framework und erstellen Ihr eigenes benutzerdefiniertes UI-Steuerelement, indem Sie die ControlBasisklasse in Unterklassen unterteilen. Die ControlBasisklasse definiert ein Verfahren , getSubControls()das soll eine Sammlung von verschachtelten Steuerelemente (falls vorhanden) zurückzukehren. Sie überschreiben jedoch die Methode, um tatsächlich eine Liste der Geburtsdaten der Präsidenten der Vereinigten Staaten zurückzugeben.

Was kann damit schief gehen? Es ist offensichtlich, dass das Rendern des Steuerelements fehlschlägt, da Sie nicht wie erwartet eine Liste der Steuerelemente zurückgeben. Höchstwahrscheinlich stürzt die Benutzeroberfläche ab. Sie brechen den Vertrag, den Unterklassen von Control einhalten sollen.

JacquesB
quelle
0

Sie können es auch unter dem Gesichtspunkt der Modellierung betrachten. Wenn Sie sagen, dass eine Klasseninstanz Aauch eine Klasseninstanz ist B, implizieren Sie, dass "das beobachtbare Verhalten einer Klasseninstanz Aauch als beobachtbares Verhalten einer Klasseninstanz klassifiziert werden kann B" (Dies ist nur möglich, wenn die Klasse Bweniger spezifisch ist als Klasse A.)

Wenn Sie also gegen den LSP verstoßen, besteht ein Widerspruch in Ihrem Entwurf: Sie definieren einige Kategorien für Ihre Objekte und respektieren diese in Ihrer Implementierung nicht. Es muss etwas falsch sein.

Als würde man eine Schachtel mit einem Etikett erstellen: "Diese Schachtel enthält nur blaue Kugeln" und dann eine rote Kugel hineinwerfen. Was nützt ein solches Tag, wenn es die falschen Informationen enthält?

Giorgio
quelle
0

Ich habe kürzlich eine Codebasis geerbt, die einige wichtige Liskov-Übertreter enthält. In wichtigen Klassen. Das hat mir große Schmerzen bereitet. Lass mich erklären warum.

Ich habe Class A, was davon herrührt Class B. Class Aund Class Beine Reihe von Eigenschaften gemeinsam nutzen, Class Adie die eigene Implementierung überschreiben. Das Festlegen oder Abrufen einer Class AEigenschaft hat andere Auswirkungen als das Festlegen oder Abrufen derselben Eigenschaft Class B.

public Class A
{
    public virtual string Name
    {
        get; set;
    }
}

Class B : A
{
    public override string Name
    {
        get
        {
            return TranslateName(base.Name);
        }
        set
        {
            base.Name = value;
            FunctionWithSideEffects();
        }
    }
}

Abgesehen von der Tatsache, dass dies eine äußerst schreckliche Methode für die Übersetzung in .NET ist, gibt es eine Reihe weiterer Probleme mit diesem Code.

In diesem Fall Namewird an mehreren Stellen ein Index und eine Flusssteuerungsvariable verwendet. Die oben genannten Klassen sind in der gesamten Codebasis sowohl in ihrer rohen als auch in ihrer abgeleiteten Form übersät. Wenn ich in diesem Fall gegen das Liskov-Substitutionsprinzip verstoße, muss ich den Kontext jedes einzelnen Aufrufs jeder der Funktionen kennen, die die Basisklasse verwenden.

Der Code verwendet Objekte von beiden Class Aund Class B, so dass ich nicht einfach Class Aabstrakt machen kann , um Leute zur Verwendung zu zwingen Class B.

Es gibt einige sehr nützliche Dienstprogrammfunktionen, die ausgeführt werden, Class Aund andere sehr nützliche Dienstprogrammfunktionen, die ausgeführt werden Class B. Im Idealfall würde Ich mag jede Nutzenfunktion verwenden können , die arbeiten auf können Class Aauf Class B. Viele der Funktionen, die a Class Bbenötigen, könnten a ohne Class Adie Verletzung des LSP problemlos in Anspruch nehmen.

Das Schlimmste daran ist, dass sich dieser spezielle Fall nur schwer umgestalten lässt, da die gesamte Anwendung von diesen beiden Klassen abhängt, die ganze Zeit auf beiden Klassen ausgeführt wird und in hundertfacher Hinsicht einen Bruch darstellt, wenn ich dies ändere (was ich tun werde) sowieso).

Um dies zu beheben, muss ich eine NameTranslatedEigenschaft erstellen , die die Class BVersion der NameEigenschaft ist, und jeden Verweis auf die abgeleitete NameEigenschaft sehr, sehr sorgfältig ändern , um meine neue NameTranslatedEigenschaft zu verwenden. Wenn jedoch auch nur eine dieser Referenzen falsch ist, kann die gesamte Anwendung in die Luft gehen.

Da es in der Codebasis keine Komponententests gibt, ist dies beinahe das gefährlichste Szenario, dem ein Entwickler begegnen kann. Wenn ich die Verletzung nicht ändere, muss ich große Mengen geistiger Energie aufwenden, um zu verfolgen, welcher Objekttyp bei jeder Methode bearbeitet wird, und wenn ich die Verletzung behebe, kann das gesamte Produkt zu einem ungünstigen Zeitpunkt explodieren.

Stephen
quelle
Was würde passieren, wenn Sie in der abgeleiteten Klasse die geerbte Eigenschaft mit einer anderen Art von Dingen beschatten würden, die denselben Namen haben (z. B. eine verschachtelte Klasse), neue Bezeichner erstellen BaseNameund TranslatedNamesowohl auf den Klasse-A-Stil Nameals auch auf die Klasse-B-Bedeutung zugreifen würden ? Dann würde jeder Versuch, Nameauf eine Variable des Typs zuzugreifen, Bmit einem Compilerfehler zurückgewiesen, sodass Sie sicherstellen könnten, dass alle Verweise in eines der anderen Formulare konvertiert wurden.
Supercat
Ich arbeite nicht mehr an diesem Ort. Es wäre sehr umständlich gewesen, das zu reparieren. :-)
Stephen
-4

Wenn Sie das Problem der Verletzung von LSP spüren möchten, überlegen Sie, was passiert, wenn Sie nur DLL / JAR der Basisklasse (keinen Quellcode) haben und eine neue abgeleitete Klasse erstellen müssen. Sie können diese Aufgabe niemals ausführen.

sgud
quelle
1
Das wirft einfach mehr Fragen auf, als dass es eine Antwort ist.
Frank