Das - Liskov Substitution Principle - LSP

Subtypes must be substitutable for their base types. 

So, das „S“ und das „O“ der SOLID-Prinzipien haben unser Design hinsichtlich der Qualitätsmerkmale wie Wartbarkeit, Testbarkeit und Erweiterbarkeit bereits erheblich verbessert. Wir haben gesehen, dass es Sinn macht, einer Klasse möglichst nur eine Verantwortlichkeit zu übertragen (Single Responsibility Principle) und Programmcode offen für Erweiterungen, aber geschlossen für Änderungen zu implementieren (Open Closed Principle).

Kommen wir zum „L“, dem „Liskov Substitution Principle“ (LSP), auch „Ersetzbarkeitsprinzip“ genannt. Bei diesem Designprinzip steht die Beziehung zwischen abgeleiteten Klassen/Interfaces und deren Basistypen im Vordergrund. Barbara Liskov hat die Relevanz des Ersetzbarkeitsprinzips bereits 1987 erkannt und später in einer Veröffentlichung folgenden Grundsatz aufgestellt:

„Sei q(x) eine Eigenschaft des Objektes x vom Typ T, dann sollte q(y) für alle Objekte y des Typs S gelten, wobei S ein Subtyp von T ist.“ [1]

Diese Formulierung ist doch etwas sperrig. Die Interpretation von Robert C. Martin ist deutlich griffiger: Subtypen müssen sich so verhalten wie ihre Basistypen [2]. Mit anderen Worten: Bei der Implementierung abgeleiteter Klassen muss darauf geachtet werden, dass die Basisklasse erweitert wird, ohne das erwartete Verhalten der Basisfunktionalität zu verändern. Ein Verstoß gegen dieses Designziel kann dazu führen, dass abgeleitete Klassen unerwünschte Nebeneffekte erzeugen, wenn sie in bestehenden Programmteilen anstelle der Basisklasse verwendet werden. Auch im Zeitalter moderner, objektorientierter Programmiersprachen hat das Prinzip nach wie vor Relevanz, da durch das Vererbungskonzept und die damit einhergehenden polymorphen Methodenaufrufe ein Verstoß gegen LSP nicht per se ausgeschlossen werden kann.

Codebeispiel

Verdeutlichen wir uns das Prinzip am besten anhand eines konkreten Beispiels. An unsere SOLID-Anwendung wurde eine neue Anforderung gestellt: Neukunden soll es ermöglicht werden, Bestellvorgänge durchzuführen, ohne vorher einen Registrierungsprozess abzuschließen. Für diese nicht registrierten Kunden entfällt somit auch das Kundenscoring, da keine Informationen zum Verlauf historischer Bestellvorgängen vorliegen.

Rufen wir uns für die Umsetzung zunächst nochmals die Ausgangssituation der bestehenden Kundenklasse ins Gedächtnis:
 

public class Customer implements Serializable {

       protected Integer id;
      protected String name;
      protected int orderCount;
      protected int orderAmountSum;
      protected final CustomerScoring customerScoring;
      protected Boolean isVip;

       public Customer(CustomerScoring customerScoring) {
             this.customerScoring = customerScoring;
      }

       public double calculateScoring() {
             return customerScoring.calculateScoring(this);
      }
  

      /*
       * getter und setter Methoden wurden zugunsten der Lesbarkeit entfernt
       */

}
 

Um der dringenden Anforderung gerecht zu werden, wurde in einer Nachtschicht kurzerhand die Klasse CustomerUnregistered implementiert und von der bestehenden Kundenklasse abgeleitet:
 

public class CustomerUnregistered extends Customer {

       // Weitere Eigenschaften für nicht registrierte Kunden...

       public CustomerUnregistered() {
             super(null);
       }

       @Override
       public double calculateScoring() {
             // Scoring nicht vorhanden für nicht registrierte Kunden
             throw new UnsupportedOperationException();
       }

}
 

Da es nur für registrierte Kunden eine Scoring-Strategie gibt, wurde über den Customer-Konstruktor ganz hemdsärmelig das Scoring-Objekt einfach „null“ gesetzt. Ohne Scoring-Strategie keine Scoring-Berechnung. Daher wurde schnell noch die calculateScoring()-Methode überschrieben und „deaktiviert“. Hierfür gibt es ja schließlich die UnsupportedOperationException. Problem solved. Die Anforderung konnte termingerecht und mit minimalem Entwicklungsaufwand umgesetzt werden.

Nach dem Commit kam dann jedoch schnell das große Erwachen: Die in der Deployment-Pipeline angestoßenen automatischen Akzeptanztests des Scoring-Reports funktionierten nicht mehr! Ein Teil des Reports beinhaltete die Berechnung des durchschnittlichen Scorings aller Kunden:
 

public class ScoringReport {

       public double calculateAverageScoring(List<Customer> customerList) {
             if (CollectionUtils.isEmpty(customerList)) {
                    return0;
             }

             doublesumScoring = 0;
             for (Customer customer : customerList) {
                    sumScoring += customer.calculateScoring();
             }

             return sumScoring / customerList.size();
       }
}
 

Dabei wird über die Kundenliste iteriert und für jeden Kunden die calculateScoring()-Methode aufgerufen. Unsere neue Kundenklasse unterstützt jedoch diese Methode nicht und wirft beim Versuch des Aufrufs der Berechnung eine Exception. Die Klasse CustomerUnregistered verstößt somit gegen das Liskov Substitution Principle: Die neue Klasse kann nicht stellvertretend für die Basisklasse stehen, da die abgeleitete Klasse nicht den Vertrag der Basisklasse erfüllt.

Die Entwickler arbeiteten bereits fleißig an einer Lösung: Im ScoringReport wird die neue Kunden-Subklasse jetzt einfach als Sonderlösung behandelt:

       if (customer instanceof CustomerUnregistered) {
             // CustomerUnregistered.calculateScoring() nicht verfügbar
             continue;
       }

 

Dieser „Quickfix“ mag zwar zum Ziel führen, hat jedoch einen Haken: Falls die Anwendung später um weitere Kundenarten erweitert werden soll, müssten weitere instanceof-Sonderbehandlungen im Scoring-Report implementiert werden. Wie wir im vorherigen Blogbeitrag gelernt haben, verstößt die vermeintliche Lösung somit wiederum gegen das Open Closed Principle.

Vermeiden des Fehlers durch das „Liskov Substitution Principle“

Das Problem wurde rechtzeitig erkannt und eine Refactoring-Maßnahme getroffen. Anstatt weitere Sonderbehandlungen einzubauen, wurde die Vererbungshierarchie überdacht und restrukturiert. Eine abstrakte Kundenklasse beinhaltet nun die allgemeingültigen Kundeneigenschaften:

public abstract class Customer implements Serializable {

       protectedInteger id;
       protectedString name;
       protectedBoolean isVip;

       /* getter und setter Methoden wurden zugunsten der Lesbarkeit entfernt */
}

Die erbende Klasse CustomerRegistered erweitert die Kundenklasse um die Eigenschaften registrierter Kunden, beinhaltet die Scoring-Strategie und stellt die calculateScoring()-Funktion bereit:

public class CustomerRegistered extends Customer {

       private int orderCount;
       private int orderAmountSum;
       private final CustomerScoring customerScoring;

       public CustomerRegistered (CustomerScoring customerScoring) {
             this.customerScoring = customerScoring;
       }

       public double calculateScoring() {
             return customerScoring.calculateScoring(this);
       }
}

Die Subklasse CustomerUnregistered repräsentiert Kunden ohne abgeschlossene Registrierung:

public class CustomerUnregistered extends Customer {
       // Weitere Eigenschaften für nicht registrierte Kunden...
}

Durch das Refactoring lässt sich nun aus Client-Perspektive bedenkenlos ein Objekt vom Typ der Basisklasse Customer durch ein Objekt der Subklassen CustomerRegistered bzw. CustomerUnregistered austauschen, ohne unerwartetes Verhalten hervorzurufen. Da der ScoringReport auf Basis von registrierten Kunden ausgeführt wird, beinhaltet dessen API nun eine Liste vom Typ List<CustomerRegistered>.Der Verstoß gegen das Liskov Substitution Principle konnte dadurch aufgelöst werden.

Fazit

Das Liskov Substitution Principle gibt uns einen Grundsatz an die Hand, um die falsche Anwendung von Vererbungspraktiken zu vermeiden. Dort, wo ein Basistyp (Elternklasse) verwendet wird, muss stellvertretend für den Basistyp auch ein Subtyp (abgeleitete Klasse) eingesetzt werden können. Folglich darf ein Subtyp

  • die Sicht auf den Basistyp nicht einschränken;
  • die Vorbedingungen einer Methode, die durch den Basistyp definiert wird, einhalten oder abschwächen, jedoch nicht verschärfen;
  • die Nachbedingungen einer Methode, die durch den Basistyp definiert wird, einhalten oder verschärfen, jedoch nicht abschwächen;
  • nur Exceptions in einer Methode deklarieren, die auch in der Methodensignatur des Basistyps deklariert sind.

Wir haben festgestellt, dass das Liskov Substitution Principle sehr eng mit dem Open Closed Principle zusammenhängt. Oftmals verraten Typ-Prüfungen zur Laufzeit (instanceof), gefolgt von Casts auf den konkreten Subtyp, den Verstoß gegen die Prinzipien.

Im vereinfachten Codebeispiel dieses Blogs ist der Verstoß gegen das Liskov Substitution Principle offensichtlich. In komplexen Ableitungshierarchien hingegen ist die Verletzung des Prinzips oftmals nicht direkt auf den ersten Blick ersichtlich. Als Konsequenz des LSP sollte man daher sehr genau über die Notwendigkeit tiefer Vererbungshierarchien nachdenken. Anstatt Wiederverwendung stur über Ableitungen zu modellieren, sollte alternativ die Komposition der Vererbung vorgezogen werden („Favor Composition over Inheritance“). Mit der einhergehenden Implementierung gegen Interfaces erhöht man zusätzlich die Flexibilität und Wiederverwendbarkeit. Womit wir beim Thema „Interfaces“ und somit auch schon beim nächsten SOLID-Prinzip angelangt sind: Dem Interface Segregation Principle (ISP).

 

Quellen/Links:

[1] Barbara H. Liskov, Jeannette M. Wing: Behavioral Subtyping Using Invariants and Constraints.1999

[2] Robert C. Martin: The Liskov Substitution Principle, 1996