LSP (Liskov Substitution Principle) ist das dritte Prinzip von SOLID, das die Bedingungen für korrekte Vererbung in der objektorientierten Programmierung definiert. Das Prinzip wurde 1987 von Barbara Liskov formuliert und wie folgt formalisiert: Wenn S ein Subtyp von T ist, dann können Objekte des Typs T durch Objekte des Typs S ersetzt werden, ohne die Eigenschaften des Programms zu ändern. Wie in Robert Martins Buch Clean Architecture (2017) festgestellt, erfordert das Substitutionsprinzip, dass eine Unterklasse den Vertrag der Basisklasse nicht schwächt.
Wichtige Punkte
LSP (Liskov Substitution Principle) ist das Substitutionsprinzip, das 1987 von Barbara Liskov auf der OOPSLA-Konferenz formuliert wurde. Formale Definition: Sei q(x) eine beweisbare Eigenschaft von Objekten x des Typs T. Dann muss q(y) für Objekte y des Typs S beweisbar sein, wobei S ein Subtyp von T ist. Einfach ausgedrückt: Objekte einer Unterklasse müssen sich so verhalten, dass Code, der mit der Basisklasse arbeitet, auch mit der Unterklasse korrekt funktioniert.
In der Praxis bedeutet LSP, dass eine Unterklasse den Vertrag der Basisklasse nicht verletzen darf. Der Vertrag umfasst Vorbedingungen (was zum Aufruf einer Methode erforderlich ist), Nachbedingungen (was nach dem Aufruf garantiert wird) und Invarianten (Bedingungen, die während der Lebensdauer des Objekts bestehen bleiben). Eine Unterklasse kann Vorbedingungen verstärken oder Nachbedingungen abschwächen — das ist eine LSP-Verletzung.
Ein klassisches Beispiel für eine LSP-Verletzung ist ein Quadrat, das von einem Rechteck erbt. Die setWidth-Methode eines Rechtecks setzt die Breite, bei einem Quadrat setzt sie sowohl Breite als auch Höhe. Ein Client, der das Verhalten eines Rechtecks erwartet (Ändern einer Seite beeinflusst die andere nicht), erhält ein unerwartetes Ergebnis. Ein Quadrat ist kein gültiger Subtyp eines Rechtecks.
LSP legt drei Bedingungen für korrekte Vererbung fest: Die Vorbedingungen der Unterklasse dürfen nicht stärker sein als die Vorbedingungen der Basisklasse (die Unterklasse fordert nicht mehr), die Nachbedingungen der Unterklasse dürfen nicht schwächer sein als die Nachbedingungen der Basisklasse (die Unterklasse garantiert nicht weniger), und die Invarianten der Basisklasse müssen in der Unterklasse erhalten bleiben. Diese Bedingungen sind als Design-by-Contract-Regel nach Bertrand Meyer bekannt.
Wenn mindestens eine Bedingung verletzt wird, kann Code, der Polymorphie verwendet, fehlschlagen. Der Compiler prüft semantische Verträge nicht, sondern nur syntaktische. Daher ist LSP eine Frage der architektonischen Disziplin, nicht der statischen Typisierung.
Der LSP-Mechanismus basiert auf der Verhaltenskompatibilität von Typen. Wenn Klasse S von Klasse T erbt, sollte Clientcode S überall dort verwenden können, wo T erwartet wird, ohne sein Verhalten zu ändern. Dies umfasst nicht nur Methodensignaturen, sondern auch deren Semantik.
LSP verbietet einer Unterklasse nicht, neues Verhalten hinzuzufügen. Es ist verboten, die Erwartungen von Code zu verletzen, der für die Basisklasse geschrieben wurde. Wenn die Basisklasse garantiert, dass die save-Methode keine Ausnahmen wirft, darf die Unterklasse sie nicht werfen. Wenn die Basisklasse einen nicht-negativen Wert zurückgibt, darf die Unterklasse keinen negativen zurückgeben.
In realen Projekten wird LSP am häufigsten verletzt, wenn bedingte Logik in Methoden der Unterklasse hinzugefügt wird: „wenn Bedingung — Ausnahme werfen“, „wenn Bedingung — null zurückgeben“. Jede solche „Überraschung“ untergräbt die Polymorphie und zwingt Clientcode, den Objekttyp vor dem Aufruf zu prüfen — was der eigentlichen Idee des objektorientierten Designs widerspricht.
In mobilen Projekten tritt eine typische LSP-Verletzung beim Erstellen von Basis-ViewModels auf. Wenn BaseViewModel garantiert, dass die onCleared-Methode alle Ressourcen freigibt, und eine Unterklasse diese Methode leer überschreibt — funktioniert jeder Code, der auf die Ressourcenbereinigung durch einen polymorphen onCleared-Aufruf angewiesen ist, falsch. LSP erfordert, dass die Unterklasse super.onCleared() aufruft oder dieselbe Arbeit selbst ausführt. Komposition über LifecycleObserver ist eine Alternative, die LSP-Verletzung im Lebenszyklusmanagement beseitigt.
Hauptindikatoren für LSP-Verletzung umfassen: Überprüfung des Objekttyps über instanceof oder is vor dem Methodenaufruf, leere Methodenimplementierungen (Stubs), Werfen von NotImplementedError oder UnsupportedOperationException, Rückgabe von null statt eines Werts. Jedes dieser Muster signalisiert, dass die Unterklasse kein gültiger Subtyp ist.
Ein weiteres häufiges Anzeichen ist Vererbung zum Zweck der Code-Wiederverwendung statt zur Modellierung einer „ist-ein“-Beziehung. Die Klasse Bird hat eine Methode fly(). Die Klasse Penguin erbt von Bird und überschreibt fly() als leer oder eine Ausnahme werfend. Dies ist eine LSP-Verletzung: Ein Pinguin ist kein gültiger Subtyp eines Vogels.
In der mobilen Entwicklung wird LSP beim Erstellen von Basis-ViewHolder-, Fragment- oder ViewController-Klassen mit Stub-Methoden verletzt. Wenn eine Unterklasse die Hälfte der Methoden der Basisklasse nicht verwendet — wurde die Vererbung falsch gewählt. Komposition oder Schnittstellentrennung lösen das Problem korrekter.
Ein einfacher Test zur Überprüfung von LSP: Schreiben Sie einen Unit-Test für die Basisklasse, der ihren Vertrag (Rückgabewerte, Ausnahmen, Nebenwirkungen) prüft. Führen Sie diesen Test für jede Unterklasse aus. Wenn der Test fehlschlägt — ist LSP verletzt. Dieser Ansatz wird „Testen durch den Basisklassenvertrag“ genannt.
In Android-Projekten ist ein solcher Test für ViewModel und Repository nützlich. Wenn BaseViewModel einen Loading-Zustand vor einem Fehler garantiert und eine Unterklasse einen Fehler ohne Loading wirft — erkennt der Test die LSP-Verletzung in der CI-Phase.
Betrachten wir ein Android-Beispiel mit ClickListener-Behandlung. Eine LSP-Verletzung tritt auf, wenn die Basisimplementierung etwas garantiert und die Unterklasse es verletzt.
// Basisklasse mit Garantie: onClick wird aufgerufen
open class BaseClickListener {
open fun onClick(view: View) {
// grundlegende Behandlung
}
}
// LSP-Verletzung: Unterklasse fügt Bedingung hinzu, die eine Ausnahme wirft
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Korrekte Lösung: Vertrag nicht verletzt
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
Ein iOS-Beispiel mit dem DataSource-Protokoll demonstriert LSP-Verletzung durch Rückgabe von nil statt Daten:
// Protokoll mit Vertrag: gibt Daten oder Fehler zurück
protocol DataProvider {
func fetchData() async throws -> [String]
}
// LSP-Verletzung: gibt nil ohne Fehler zurück
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // leeres Array statt Fehler
}
}
// Korrekte LSP-Einhaltung
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Praktische Regel: Wenn eine Unterklasse den Vertrag der Basisklasse nicht erfüllen kann, sollte sie keine Unterklasse sein. Eine Alternative ist, eine Schnittstelle mit einem minimalen Vertrag zu extrahieren und sie in jedem Typ auf eigene Weise zu implementieren.
Komposition ist der Vererbung in Situationen vorzuziehen, in denen die „ist-ein“-Beziehung mehrdeutig oder bedingt ist. Ein klassisches Beispiel: Ist Manager ein Employee? Ja. Aber ist Square ein gültiges Rectangle? LSP sagt „nein“. Wenn Sie an der Korrektheit der Vererbung zweifeln — wählen Sie Komposition.
In der mobilen Entwicklung wird Komposition oft durch Abhängigkeitsinjektion verwendet: Anstatt das Verhalten von einer Basisklasse zu erben, erhält eine Klasse es über einen Konstruktor. Ein ViewModel erbt nicht von Repository, sondern akzeptiert es als Abhängigkeit. Dies beseitigt LSP-Verletzung per Definition — keine Vererbung, keine Vertragsverletzung.
Anzeichen dafür, dass Vererbung durch Komposition ersetzt werden sollte: Die Unterklasse verwendet einige Methoden der Basisklasse nicht, die Unterklasse überschreibt Methoden mit leeren Stubs, Clientcode prüft den Objekttyp über instanceof. In diesen Fällen wurde die Vererbung falsch gewählt und LSP ist verletzt.
Schnittstellen lösen das LSP-Problem ohne Vererbung: Jeder Typ implementiert genau die Methoden, die er benötigt. Statt einer gemeinsamen Basisklasse Bird mit einer fly()-Methode (wo Penguin nicht fliegen kann) — eine Flyable-Schnittstelle, die nur fliegende Vögel implementieren. Penguin implementiert Bird ohne fly()-Methode — LSP ist nicht verletzt.
In der Android-Architektur wird dieser Ansatz durch segregierte UseCase-Schnittstellen angewendet: Statt eines großen UseCase mit getAll-, getById-, save-, delete-Methoden — separate GetItemsUseCase-, SaveItemUseCase-Schnittstellen. Ein Client hängt nur von der benötigten Schnittstelle ab, und jede Klasse, die diese Schnittstelle implementiert, ist aus LSP-Sicht korrekt.
Häufig gestellte Fragen
Vererbung ist ein Sprachmechanismus; LSP ist eine Regel zur korrekten Verwendung dieses Mechanismus. Vererbung garantiert Signaturkompatibilität (Syntax); LSP erfordert Verhaltenskompatibilität (Semantik). Vererbung ohne LSP ergibt Polymorphie, die zur Laufzeit bricht.
Wenn die Basisklasse eine Nicht-null-Rückgabe garantiert — ja. Wenn der Vertrag null (optionalen Wert) zulässt — nein. LSP verbietet nicht null, sondern die Schwächung des Vertrags. Studieren Sie die Dokumentation der Basisklasse und prüfen Sie, ob der Vertrag der Unterklasse kompatibel ist.
LSP wird auf Protokolle genauso angewendet wie auf Klassen. Eine Protokollimplementierung muss den semantischen Vertrag einhalten: Wenn ein Protokoll eine Methode als non-throwing definiert, darf die Implementierung keine Fehler werfen. Swift prüft dies nicht auf Compiler-Ebene — die Verantwortung liegt beim Entwickler.
Sealed class in Kotlin ist ein Sonderfall, da die Hierarchie geschlossen und dem Compiler bekannt ist. LSP ist auf sealed class in geringerem Maße anwendbar, da alle Subtypen im when-Ausdruck explizit aufgelistet sind. Ein Fehler einer versiegelten Unterklasse ist lokal, kein versteckter polymorpher Fehler.
Schreiben Sie einen parametrisierten Test für die Basisklasse, der für alle ihre Unterklassen ausgeführt wird. Der Test prüft wichtige Verhaltensverträge: Rückgabewerte, Ausnahmen, Zustände. Wenn der Test bei einer Unterklasse fehlschlägt — ist LSP verletzt. In CI verhindert ein solcher Test die Regression von polymorphem Code.
Zusammenfassung
Wir entwickeln eine mobile Applikation schlüsselfertig
IT Sectr entwickelt seit 2017 iOS- und Android-Apps für Startups und Unternehmen. Wir beraten Sie und schlagen die beste Lösung vor.
Lesen Sie auch