Table of Contents
In der objektorientierten Programmierung ist das Erstellen von Systemen, die sowohl robust als auch anpassungsfähig sind, die ständige Herausforderung. Zu den grundlegenden Prinzipien, die Entwickler zu diesem Ziel führen, gehört das Liskov Substitutionsprinzip (LSP). LSP wurde 1987 von Barbara Liskov konzipiert und ist die dritte Säule der fünf SOLID-Prinzipien für Softwaredesign und befasst sich mit einer kritischen Frage: Wenn Sie eine Unterklasse erstellen, die von einer Elternklasse erbt, können Sie jede Instanz des Elternteils sicher durch eine Instanz des Kindes ersetzen, ohne das Programm zu brechen? Die Antwort des Prinzips ist ein definitives "Ja" - aber nur, wenn die Unterklasse den von den Eltern definierten Vertrag respektiert. Dieser Artikel untersucht das Liskov Substitutionsprinzip eingehend und bietet klare Definitionen, praktische Beispiele und Strategien, um sicherzustellen, dass Ihre Klassenhierarchien vertrauenswürdig und flexibel bleiben.
Was ist das Liskov Substitutionsprinzip?
Das Liskov Substitutionsprinzip besagt, dass Objekte einer Oberklasse durch Objekte ihrer Unterklassen ersetzt werden sollten, ohne die Richtigkeit des Programms zu beeinträchtigen. Mit anderen Worten, wenn eine Funktion oder Methode für die Arbeit mit einem Basistyp entwickelt wurde, sollte sie auch mit jedem abgeleiteten Typ arbeiten, ohne dass eine Änderung oder unerwartete Nebenwirkungen erforderlich sind. Barbara Liskov hat diese Idee erstmals 1987 in ihrer Keynote auf der Konferenz über objektorientierte Programmiersysteme, Sprachen und Anwendungen (OOPSLA) artikuliert und ist seitdem zu einem Eckpfeiler des objektorientierten Designs geworden.
LSP ist im Grunde genommen eine Verhaltens-Subtypisierung. Es reicht nicht aus, dass eine Subklasse die gleichen Methodensignaturen hat wie ihre Eltern (syntaktische Konformität); die Subklasse muss auch die Absichten und Einschränkungen der Elternklasse berücksichtigen. Wenn eine Subklasse das grundlegende Verhalten einer Elternmethode ändert - zum Beispiel durch das Werfen einer Ausnahme, die die Eltern niemals werfen, indem sie einen Wert zurückgibt, der den Elternvertrag verletzt oder strengere Bedingungen erfordert - dann ist die Substitution nicht sicher und das Design verletzt LSP.
Formale Definition und Hintergrund
Barbara Liskovs ursprüngliche formale Definition lautet wie folgt:
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 ist, wenn o1 durch o2 ersetzt wird, dann ist S ein Subtyp von T.
Diese Definition betont, dass ein Subtyp (S) durch seinen Supertyp (T) in jedem Programm (P) ersetzt werden muss, das in Bezug auf T geschrieben ist. Das beobachtbare Verhalten des Programms sollte erhalten bleiben. Dieses Konzept steht in engem Zusammenhang mit der von Bertrand Meyer entwickelten Design by Contract (DbC) -Methodik, bei der jede Methode explizite Voraussetzungen (was vor Ablauf der Methode zutreffen muss) und Postbedingungen (was danach zutreffen muss) hat.
Für weitere Informationen siehe das Original ] Liskov und Wing Paper , das das Konzept formalisiert hat.
Warum ist LSP wichtig?
Die Einhaltung von LSP bringt mehrere entscheidende Vorteile für objektorientierte Systeme:
- Zuverlässigkeit und Korrektheit: Code, der einen Basistyp verwendet, kann darauf vertrauen, dass sich jede Unterklasse gemäß dem Vertrag des Basistyps verhält.
- Polymorphismus: Polymorphismus ist die Fähigkeit, Objekte verschiedener Klassen über eine gemeinsame Schnittstelle zu behandeln. Ohne LSP wird Polymorphismus gefährlich, weil das Ersetzen einer Unterklasse falsche Ergebnisse liefern kann. LSP stellt sicher, dass polymorpher Code wie beabsichtigt funktioniert.
- Wartung und Erweiterbarkeit: Wenn LSP-Verstöße vermieden werden, erfordert das Hinzufügen neuer Unterklassen keine Änderung des vorhandenen Codes, der vom Basistyp abhängt. Dies entspricht dem Open/Closed Principle (OCP) – Software-Entitäten sollten für die Erweiterung geöffnet, aber für die Änderung geschlossen sein.
- Testability: Unit-Tests, die gegen eine Basisklasse geschrieben wurden, können wiederverwendet werden, um Unterklassen zu validieren.
LSP ist nicht nur ein akademisches Konzept, es hat direkte praktische Auswirkungen. Wenn Sie beispielsweise in einem Zahlungsverarbeitungssystem eine Basisklasse mit einer FLT: 1 haben, erwarten Sie, dass alle Unterklassen (z. B. FLT: 2) Zahlungen ohne Fehler oder Nebenwirkungen verarbeiten, die die Basisklasse nicht erwartet. Verstöße können hier zu Einnahmenverlusten oder beschädigten Daten führen.
Verstöße gegen das Liskov-Substitutionsprinzip
Die Erkennung von LSP-Verstößen ist der erste Schritt, um sie zu beheben.
Voraussetzungen stärken
Wenn eine Basisklassenmethode einen ganzzahligen Parameter erwartet, stärkt das Hinzufügen einer Bedingung in der Unterklasse, dass die ganze Zahl positiv sein muss (während die Basisklasse eine ganze Zahl akzeptiert), die Voraussetzung. Clients, die eine negative Zahl an die Basisklasse übergeben haben, würden nun mit der Unterklasse scheitern. Beispiel:
// Base class
class UserService {
public void assignRole(int userId) { ... }
}
// Subclass violation
class AdminService extends UserService {
@Override
public void assignRole(int userId) {
if (userId <= 0) throw new IllegalArgumentException("Invalid ID");
...
}
}
Schwächung der Postbedingungen
Wenn die Basisklasse einen bestimmten Rückgabewert oder Nebeneffekt garantiert, verletzt eine Unterklasse, die diese Garantie reduziert, LSP. Zum Beispiel könnte eine Basisklassenmethode immer eine Nicht-Null-String zurückgeben; eine Unterklasse, die zurückgibt, schwächt in einigen Fällen die Postbedingung.
Neue Ausnahmen auswerfen
Unterklassen sollten keine Ausnahmen auswerfen, die die Basisklasse nicht ausgibt (es sei denn, diese Ausnahmen sind bereits zulässig), wenn Clients der Basisklasse nur fangen und eine Unterklasse eine ausgibt, führt die Substitution zu unerwarteten Abstürzen.
Entfernen oder Überschreiben von Methoden, die vererbt werden sollten
Wenn eine Unterklasse eine Methode außer Kraft setzt, um nichts zu tun (leerer Körper) oder eine zu werfen, ist das ein klarer Verstoß.
Klassisches Beispiel: Rechteck, Quadrat und die Area Trap
Das am häufigsten zitierte Beispiel für LSP-Verstöße beinhaltet geometrische Formen. Viele Lehrbücher beginnen mit einer Klasse mit , die und Methoden hat, und erstellen dann eine Subklasse, die diese überschreibt, um Breite == Höhe zu erzwingen.
// Base class
class Rectangle {
protected int width, height;
public void setWidth(int w) { width = w; }
public void setHeight(int h) { height = h; }
public int getArea() { return width * height; }
}
// Subclass violation
class Square extends Rectangle {
@Override
public void setWidth(int w) {
width = height = w;
}
@Override
public void setHeight(int h) {
width = height = h;
}
}
Betrachten Sie nun eine Funktion, die mit einem funktioniert:
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50; // Expect 50
}
Wenn man ein passiert, setzt die Methode die Breite auf 5 und dann die Höhe auf 10 – aber das Quadrat setzt auch die Breite auf 10, so dass das Quadrat mit Breite = 10, Höhe = 10, Fläche = 100 endet. Die Behauptung scheitert. Die ist nicht ersetzbar für , weil es die Postbedingung ändert: Nach und ist die Fläche eines Rechtecks 50, aber die Fläche eines Quadrats ist 100.
Die Fixe: vermeiden Sie es, von zu erben. Stattdessen entwerfen Sie eine abstrakte Klasse mit einer gemeinsamen Methode und lassen Sie sowohl ] als auch unabhängig voneinander implementieren. Alternativ verwenden Sie Zusammensetzung: Ein Quadrat kann ein Rechteck mit gleichen Seiten sein, aber das Aussetzen von Settern, die die Invariante brechen, ist das eigentliche Problem. Das Prinzip wird oft als “Brechen Sie den Vertrag nicht.” zusammengefasst.
Real-World-Beispiel: Payment Gateway Integrationen
Stellen Sie sich ein E-Commerce-System mit einer Basisklasse vor :
abstract class PaymentGateway {
public abstract void charge(double amount);
public void refund(double amount) { /* default implementation */ }
}
Unterklassen sind und . Ein Client, der Zahlungen verarbeitet, kann und später anrufen. Ein Verstoß tritt auf, wenn sich über hinwegsetzt, um eine Ausnahme zu werfen, weil die PayPal-API eine Rückerstattungs-ID benötigt, nicht nur einen Betrag.
Wie man repariert: Entweder (a) stellt sicher, dass der Vertrag der Basisklasse die Möglichkeit beinhaltet, dass nicht unterstützt wird (z. B., dass sie einen Erfolgs-Boolean zurückgibt oder eine geprüfte Ausnahme erklärt), oder (b) gestaltet die Hierarchie neu, so dass nicht alle Gateways Rückerstattungen unterstützen. Zum Beispiel, stellen Sie eine Schnittstelle ein und lassen Sie nur Gateways, die Rückerstattungen unterstützen, diese implementieren. Der Client überprüft dann , bevor er die Rückerstattung aufruft. Dies respektiert LSP, weil die Basis keine funktionierende Rückerstattungsmethode verspricht.
Wie man das Liskov Substitutionsprinzip in der Praxis befolgt
Die Implementierung von LSP erfordert Disziplin in Design und Testen.
- Design by Contract: Definieren Sie eindeutig Voraussetzungen, Postbedingungen und Invarianten für Basisklassenmethoden. Dokumentieren Sie, was jede Methode erwartet und garantiert. Dann stellen Sie sicher, dass jede Unterklasse dies erfüllt. Tools wie Contracts in .NET oder JML für Java können helfen, diese Regeln durchzusetzen.
- Bevorzugung von Schnittstellen gegenüber abstrakten Klassen: Schnittstellen definieren einen Vertrag ohne Implementierungsdetails. Sie sind natürlich auf LSP ausgerichtet, da jede implementierende Klasse die gesamte Schnittstelle erfüllen muss. Mit abstrakten Klassen ist es einfacher, versehentlich Abhängigkeiten einzuführen.
- Wenn eine Unterklasse das Verhalten bis zum Bruch des Basisvertrags außer Kraft setzen müsste, ist es oft besser, Komposition zu verwenden. zum Beispiel, anstatt zu erben, eine Klasse zu haben, die intern eine verwendet, aber die Setter nicht bloßstellt.
- Test auf Substitutability: Schreibe parametrisierte Tests, die die gleichen Szenarien sowohl gegen Basis- als auch gegen abgeleitete Typen ausführen. Wenn ein Test mit dem Basistyp besteht, aber mit einem abgeleiteten Typ fehlschlägt, hast du einen LSP-Verstoß. Diese Praxis ist besonders leistungsfähig, wenn Test-Doppel verwendet werden.
- Nach Covariant Return Types suchen: Einige Sprachen erlauben covariante Return Types (z.B. kann eine Subclass-Methode einen spezifischeren Typ als die Basis zurückgeben). Dies ist in Ordnung, solange die Postbedingung nicht geändert wird. Stellen Sie sicher, dass das zurückgegebene Objekt immer noch alle Erwartungen des Rückgabewerts des Basistyps erfüllt.
- Vermeiden Sie wahllos überschreibende konkrete Methoden: Wenn Sie das Bedürfnis verspüren, eine konkrete Methode in einer Unterklasse außer Kraft zu setzen, fragen Sie sich, ob Vererbung das richtige Werkzeug ist. Vielleicht war die Basisklasse zu konkret. Machen Sie die Methode nur abstrakt oder virtuell, wenn Sie beabsichtigen, dass Unterklassen das Verhalten kontrolliert ändern.
LSP und die anderen soliden Prinzipien
LSP ist eng mit den anderen SOLID-Prinzipien verbunden, insbesondere dem Open/Closed-Prinzip (OCP) und dem Dependency Inversion-Prinzip (DIP):
- LSP und OCP: OCP besagt, dass Klassen für Erweiterungen geöffnet, aber für Änderungen geschlossen sein sollten. LSP stellt sicher, dass Erweiterungen (Unterklassen) den vorhandenen Code, der den Basistyp verwendet, nicht brechen. Ohne LSP würde das Hinzufügen einer neuen Unterklasse eine Änderung des Clientcodes erfordern, um das neue Verhalten zu handhaben, was OCP verletzt.
- LSP und DIP: DIP rät abhängig von Abstraktionen, nicht von Konkretionen. LSP ist hier wesentlich, weil die Abstraktion (Schnittstelle oder Basisklasse) stabil und zuverlässig sein muss. Wenn Unterklassen LSP verletzen, ist die Abstraktion keine vertrauenswürdige Abhängigkeit mehr und das System wird zerbrechlich.
- LSP und Interface Segregation (ISP): ISP fördert kleine, fokussierte Schnittstellen. Dies unterstützt natürlich LSP, weil eine kleine Schnittstelle einen engen Vertrag definiert, der leichter zu erfüllen ist. Eine Klasse, die eine übergroße Schnittstelle implementiert, kann Schwierigkeiten haben, alle Teile zu erfüllen, was zu Verstößen wie dem Werfen von führt.
Für einen umfassenden Überblick über die SOLID-Prinzipien lesen Sie den SOLID-Artikel von Wikipedia.
Schlussfolgerung
Das Liskov Substitutionsprinzip ist weit mehr als eine theoretische Nettigkeit; es ist ein praktisches Werkzeug für den Aufbau objektorientierter Systeme, die sicher zu erweitern und einfach zu warten sind. Indem sichergestellt wird, dass sich Unterklassen so verhalten, dass sie sich für ihre Elternklassen substituierbar verhalten, können sich Entwickler auf Polymorphismus verlassen, ohne Angst vor versteckten Fehlern. Das Prinzip fördert sorgfältige Überlegungen über Klassenhierarchien, was zu Designs führt, die die Zusammensetzung gegenüber der Vererbung bevorzugen, klar definierte Verträge und robuste Tests.
Denken Sie daran, dass LSP-Verstöße oft als subtile Inkonsistenzen im Verhalten auftreten: eine Methode, die eine unerwartete Ausnahme darstellt, eine Methode, die stillschweigend nichts tut, oder eine Methode, die den Zustand auf eine Weise verändert, die die Basisklasse nie beabsichtigt hat. Indem Sie auf diese roten Fahnen achten und die oben diskutierten Richtlinien anwenden, können Sie Hierarchien schaffen, die den Test der Zeit bestehen. Barbara Liskovs Einsicht führt Entwickler weiterhin zu zuverlässigerer und flexiblerer Software. Für weitere Studien sollten Sie Robert C. Martins Agile Software Development: Principles, Patterns, and Practices lesen, die umfangreiche Beispiele für LSP und die anderen SOLID-Prinzipien bietet.
Externe Referenzen, die in diesem Artikel verwendet werden: