Liskov-Substitution Principle

Lesezeit: 8 Minuten

Die SOLID-Prinzipien zielen darauf ab, objektorientierte Software langfristig wartbarer zu machen. In meinem Blog widme ich jedem der fünf Prinzipien einen eigenen Beitrag.

Das Liskov-Substition Principle (LSP) ist ein Prinzip aus der objektorientierten Programmierung, das ursprünglich von Barbara Liskov im Jahr 1987 auf einer Konferenz beschrieben wurde. Es bildet zusammen mit vier anderen Prinzipien das Akronym SOLID und steht für dessen dritten Buchstaben. Es hat folgende Aussage:

Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

Barbara Liskov and Jeannette Wing

Eine weniger mathematische Definition wurde später von Robert C. Martin formuliert:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

Robert C. Martin

Vereinfacht gesagt müssen sich Ableitungen an die Spezifikationen der vererbenden Klassen halten, damit ihr Einsatz nicht zu Fehlern führt. Sie dürfen also keine strikteren Anforderungen haben, jedoch durchaus weniger strikte.

Geometrie-Beispiel

Ein populäres Beispiel für das LSP ist das Kreis-Elipse-Problem auch bekannt als Quadrat-Viereck-Problem. Auf den ersten Blick ist ein Kreis eine Spezialisierung einer Elipse. Es ist also vorstellbar, eine Klasse Kreis von einer Basisklasse Elipse abzuleiten.

class Elipse {
    
    private int width;
    private int height;
    
    public void setWidth(int width) { this.width = width; }
    public int getWidth() { return this.width; }
    public void setHeight(int height) { this.height = height; }
    public int getHeight() { return this.height; }

}

class Kreis extends Elipse {
    
    public void setWidth(int width) { 
        super.setWidth(width); 
        super.setHeight(width); 
    }

    public void setHeight(int height) {
        super.setWidth(width); 
        super.setHeight(width); 
    }

}

Die Klasse Kreis verletzt folgende implizite Regel: Eine Elipse hat eine Höhe und eine Breite. Beide können unabhängig voneinander gesetzt werden.

Folgender Code verdeutlicht, dass diese Beziehung zwischen Elipse und Kreis keine gute Idee ist:

    @Test
    public void shouldHaveWidhtAndHeight() {
        // given
        final int width = 3;
        final int height = 5;
        final Kreis kreis = new Kreis();

        // when
        kreis.setWidth(width);
        kreis.setHeight(height);
        
        // then
        assertThat(kreis.getWidth()).isEqualTo(width);
        assertThat(kreis.getHeight()).isEqualTo(height);
    }

Dieser Test schlägt fehl, da der Kreis nicht die erwartete Breite von 3 hat, sondern die Breite gleich der Höhe von 5 ist. Der Kreis hat einen Radius von 5, da die Höhe zuletzt gesetzt wurde. Dreht man die Reihenfolge der Setter um, hat der Kreis einen Radius von 3. Solche subtilen Abhängigkeiten sollte man vermeiden und hier besser auf eine Ableitung verzichten:

class Kreis {

    private int radius;

    public void setRadius(int radius) { this.radius = radius; }
    public int getRadius() { return this.radius; }

}

LSP im Überblick

Das LSP definiert folgende Regeln für Ableitungen

  • Keine kovarianten Eingabeparameter
  • Keine kontravarianten Ausgabeparameter
  • Keine neuen Exceptions
  • Keine strikteren Voraussetzungen
  • Keine schwächeren Nachbedingungen
  • Invarianten müssen beibehalten werden
  • Unveränderliche Felder der vererbenden Klasse müssen unveränderlich bleiben

Keine kovarianten Eingabeparameter

Eine Verletzung dieser Regel wird bereits von der Semantik der Sprache Java verhindert. Kovarianz bedeutet „in Richtung der Vererbung“. Ein Methodenparameter einer Ableitung darf daher weiter gefasst sein, als in der vererbenden Klasse, nicht jedoch enger. Nehmen wir an, wir haben drei Klassen in einer Vererbungshierarchie, die Autos repräsentieren und eine Klasse Stellfläche, der ein Auto zugewiesen werden kann.

class Fahrzeug {}

class Pkw extends Fahrzeug {}

class Kleinwagen extends Pkw {}

abstract class Stellflaeche {
    public abstract void setAuto(Pkw pkw);
}

Folgender, in der Sprache Java nicht kompilierfähiger Code, verletzt das LSP:

class Garage extends Stellflaeche {
    @Override public void setAuto(Kleinwagen kleinwagen) {}
}

Diese Implementierung hingegen verletzt das LSP nicht:

class Garage extends Stellflaeche {
    @Override public void setAuto(Fahrzeug fahrzeug) {}
}

Der Grund dafür ist, ein Algorithmus für Stellflächen davon ausgehen muss, dass die Methode setAuto() mit einem Pkw aufgerufen werden kann, ohne dass es zu einem Fehler oder unerwartetem Verhalten kommt. Wenn eine Ableitung darüber hinaus auch andere Fahrzeuge akzeptiert, so verletzt dies nicht die Vorgaben der Klasse Stellflaeche.

Keine kontravarianten Ausgabeparameter

Auch diese Regel kann in der Sprache Java nicht verletzt werden. Kontravarianz bedeutet „entgegen der Vererbungsrichtung“. Bezogen auf das vorherige Beispiel mit den Autos bedeutet dies, ein in der Klasse Stellflaeche definierter Getter getAuto() darf in einer Ableitung mit dem Rückgabewert Kleinwagen deklariert werden, aber nicht mit dem Rückgabewert Fahrzeug. Man behält in einer Ableitung also besser die Typisierung Pkw der vererbenden Klasse bei, wenn man sowohl Getter als auch Setter dafür anbieten möchte.

Keine neuen Exceptions

Diese Regel kann in Java verletzt werden, wenn man keine Checked Exceptions einsetzt. Wird in einer abgeleiteten Methode eine IllegalArgumentException geworfen, obwohl die vererbende Methode dies nicht tut, so ist das LSP verletzt.

Keine strikteren Voraussetzungen

Voraussetzungen (preconditions) sehe ich in zwei Facetten:

  • Voraussetzungen, die Eingabeparameter betreffen
  • Voraussetzungen, die den State der Instanz betreffen

Wenn eine Methode als Argument eine Zahl entgegennimmt und diese nicht weiter validiert wird, so verletzt eine Ableitung der Methode das LSP, wenn sie den gültigen Wertebereich dieser Zahl einschränkt, indem sie beispielsweise nur Zahlen zwischen 1 und 100 akzeptiert.

Wirft die write()-Methode eines Writers eine IllegalStateException, wenn sich der Writer nicht im State opened befindet, so verletzt eine Ableitung das LSP, wenn sie zusätzlich eine Modus read-only einführt, bei dem der Write-Aufruf auf die gleiche Weise abgebrochen wird, auch wenn der Writer im State opened ist.

Keine schwächeren Nachbedingungen

Nehmen wir an der Writer bietet eine Methode writeAll() an, die nach dem Schreiben den Stream schließt. Eine Ableitung, die die Methode writeAll() überschreibt muss nach Verwendung ebenfalls den Stream schließen, um die in der vererbenden Klasse implizit gegebene Nachbedingung „Stream wird nach dem Schreiben geschlossen“ zu erfüllen, andernfalls verletzt sie das LSP.

Invarianten müssen beibehalten werden

Die Invarianten einer Klasse müssen in einer Ableitung beibehalten werden. Sagen wir mal unser Writer cached Inhalte, die über die write()-Methode geschrieben werden, und schreibt sie aus Performance-Gründen erst gebündelt zu einem bestimmten Zeitpunkt in den Stream. Zur Steuerung des Cachings gibt es eine Variable, die dazu einen festen Wertebereich verwendet und angibt, an welche Stelle in die Caching-Datenstruktur die nächsten zu cachenden Daten geschrieben werden können. Setzt nun eine Ableitung diese Variablen auf einen Wert außerhalb des gültigen Bereichs oder aktualisiert nicht im gleichen Zuge die Datenlage im Cache, so wird das LSP verletzt.

Das Ziel dieser Regel ist es, bestehenden Code nicht kaputt zu machen, da sich die vererbende Klasse auf eine bestimmte Verwendung dieser Variablen verlässt um den Cache zu verwalten. Ändert eine Ableitung dieses Feld nun in einer ungültigen Weise, so funktioniert der bestehende Code nicht mehr. Einer Ableitung des Writers bleibt es aber überlassen einen eigenen unabhängigen Cache zu implementieren.

Unveränderliche Felder der vererbenden Klasse müssen unveränderlich bleiben

Diese Regel ist selbsterklärend. Viele Sprachen bieten Konstanten an, die sich per Definition nachträglich nicht mehr ändern lassen. Auch Java kann dies über das Schlüsselwort final sicherstellen, es gibt jedoch auch Fälle, in denen das nicht möglich ist, z.B. wenn eine solche Konstante ein persistiertes Feld einer über JPA verwalteten Entität ist oder eine Klasse für die Verwendung in einem Framework ein Interface implementieren muss, damit bestimmte Informationen durch dieses erst nach der Instanzierung gesetzt werden. In solchen Fällen ist es nicht immer möglich in der Klasse selbst zu gewährleisten, dass solche Konstanten nicht nachträglich durch eine Ableitung geändert werden, was das LSP verletzten würde.