SOLID: Liskov'sches Substitutionsprinzip

Hinter dem dritten Buchstaben des Akronyms SOLID verbirgt sich das sogenannte "Liskov'sche Substitutionsprinzip". Namensgebend ist Barbara Liskov, welche dieses Prinzip um 1988 definiert hat.

Vorwort

Das Liskov'sche Substitutionsprinzip (LSP) ist für einmal ein Prinzip, bei welchem es möglich ist, sehr klar zu sagen, wann es zur Gänze respektiert wurde, und wann nicht. Leider ist das Verständnis dieses Prinzipes nicht ganz einfach, weshalb ich es an verschiedenen Beispielen zu illustrieren versuchen werde.

Ziele des Prinzips

Das LSP beabsichtigt, dass Klassenhierarchien gewisse Eigenschaften aufweisen. Befolgt man das Prinzip sollen insbesondere folgende Dinge zutreffen:

  • An Allen Stellen, wo ein Objekt einer bestimmten Klasse verwendet werden kann, soll auch ein Objekt jeder Unterklasse dieser Klasse verwendet werden können, ohne dass sich das Verhalten ändert oder Fehler auftreten.
  • Unit-Tests, welche für die Überklasse erfolgreich sind, sollen auch für jede Unterklasse erfolgreich sein.

Das Prinzip - exemplarisch

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

Barbara Liskov, Data Abstraction and Hierarchy, SIGPLAN Notices, 23,5 (May, 1988).

 

Die sehr sperrige Definition macht eine Aussage über "Typen" und "Subtypen" und deren Verhalten. Etwas einfacher gesagt: Wenn eine Klasse SubClass von einer anderen Klasse Class erbt, dann ist SubClass ein SubTyp von Class, wenn sich jede Instanz von SubClass genau gleich wie eine Instanz von Class verhält, wenn sie als Class verwendet wird. Denn wenn eine Klasse von einer anderen erbt, gilt SubClass is a Class, und somit kann überall, wo eine Instanz von Class eingesetzt werden könnte auch eine von SubClass eingesetzt werden.

 

Hier ein konkretes Beispiel, genauer gesagt wohl das am häufigsten genannte Beispiel wenn es ums LSP geht:

 

Es gibt zwei Klassen: Rectangle und Square. Anschaulich scheint es naheliegend: Das Quadrat ist ein Spezialfall des Rechtecks, also Square is a Rectangle, und somit können wir das modellieren, indem die Klasse Square von der Klasse Rectangle erbt. Eine einfache Implementation (in C#) könnte so aussehen:

public class Rectangle
{
   public virtual double Width { get; set; }
   public virtual double Height { get; set; }

   public double GetArea()
   {
      return Height * Width;
   }
}


public class Square : Rectangle
{
   public override double Width
   {
      get => base.Width;
      set
      {
         base.Height = value;
         base.Width = value;
      }
   }

   public override double Height
   {
      get => base.Height;
      set
      {
         base.Height = value;
         base.Width = value;
      }
   }
}

Man sieht sofort die Besonderheit bei der Klasse Square: Damit sichergestellt ist, das Square-Instanzen auch immer schön quadratisch sind, muss jedesmal, wenn entweder Höhe oder Breite verändert werden, die andere Eigenschaft entsprechend angepasst werden.

 

In einem kleinen Testprogramm werden nun ein Rechteck und danach ein Quadrat erstellt. Das Quadrat wird dabei als Rechteck deklariert und genau gleich behandelt wie das Rechteck. Danach wird überprüft, ob die Eigenschaften der beiden Objekte gleich sind, denn wenn sie das nicht sind, würde es bedeuten, dass sie sich anders verhalten haben.

class Program
{
   public static void Main(string[] args)
   {
      Rectangle rectangle = new Rectangle
      {
         Width = 5,
         Height = 10
      };

      Rectangle square = new Square
      {
         Width = 5,
         Height = 10
      };

      Console.WriteLine("Rectangle properties: ");
      Console.WriteLine($"Width: {rectangle.Width}");
      Console.WriteLine($"Height: {rectangle.Height}");
      Console.WriteLine($"Area: {rectangle.GetArea()}");
      Console.WriteLine();

      Console.WriteLine("Square properties: ");
      Console.WriteLine($"Width: {square.Width}");
      Console.WriteLine($"Height: {square.Height}");
      Console.WriteLine($"Area: {square.GetArea()}");
      Console.WriteLine();

      Console.ReadKey();
   }
}

Es ist nun wohl wenig erstaunlich, dass die beiden Rechtecke nach diesen Operationen eben nicht identisch sind. Der Output sieht folgendermassen aus:

Rectangle properties:
Width: 5
Height: 10
Area: 50

Square properties:
Width: 10
Height: 10
Area: 100

Somit wird das LSP verletzt: Die Unterklasse Square hat sich nicht gleich verhalten wie die Überklasse Rectangle. Somit ist Square kein Subtype von Rectangle.

 

An dieser Stelle sei gesagt, dass Subclassing nicht dasselbe ist wie Subtyping. Square ist sehr wohl eine Subclass von Rectangle, denn Square erbt von Rectangle und der Code ist syntaktisch korrekt. Square ist aber kein Subtype, denn dafür müsste das Verhalten gleich sein.

 

Dieses Beispiel zeigt auch gut, wieso es eine gute Praxis ist, dem LSP zu folgen: Wenn man den Code des Beispielprogrammes studiert sieht es so aus, als würde an beiden Objekten exakt dasselbe vorgenommen und damit würde man erwarten, dass die beiden Objekte danach identisch sind. 

 

Das Verhalten, welches die Klasse Rectangle definiert, ist, dass sich bei einer Änderung des Properties Width das Property Height nicht verändert wird. In diesem Aspekt verhält sich die Klasse Square anders und ist somit kein Subtype.

 

Man könnte die Properties Width und Height readonly machen, also dass sie sich nach der Instanziierung nicht mehr verändern können. Dann wären die beiden Klassen LSP-konform.

Eine andere Aussage, welche für LSP-konforme Klassenhierarchien zutreffen muss: Alle Unterklassen müssen gleiche oder weniger starke Restriktionen aufweisen. Ein anschauliches Beispiel dafür:

 

An einem Bahnhof verkauft ein Billetverkäufer Billete. Nun müssen Sparmassnahmen ergriffen werden und der menschliche Verkäufer wird durch einen Billetautomaten ersetzt. Aus Klassen-Sicht: Ein Billetautomat ist ein Billetverkäufer und kann dieselben Funktionen erfüllen. Jedoch akzeptiert der Automat nur Zahlung mit Karte. Zwar kann er auch Billete verkaufen, aber da sein Verhalten anders ist, gibt es in Situationen ein Problem, welche beim vorherigen Verkäufer kein Problem waren. 

Das Prinzip - in der Praxis

Obwohl die Frage, ob eine Klassenhierarchie LSP-konform ist, ganz klar beantwortet werden kann, gibt es dennoch viele Diskussionen, wie, oder ob überhaupt, das LSP in der Praxis sinnvoll ist. Denn genau genommen sind in den allermeisten Programmiersprachen keine zwei voneinander erbenden Klassen identisch. In C# kann man das wie folgt aufzeigen:

Rectangle r = new Rectangle();
Rectangle s = new Square();
bool areIdentical =
   r.GetType().ToString() == s.GetType().ToString();

Mit einem nicht-absoluten Ansatz kann das LSP in der Praxis dennoch sinnvoll sein: Man muss definieren, in welchem Rahmen, bzw. in welchem Kontext, das LSP gelten soll, also wo genau sich Klasse und Unterklasse gleich verhalten sollen. Damit wird das LSP sehr ähnlich zum Design Pattern "Design By Contract".

 

Als positives, LSP-konformes Beispiel könnte man zum Abschluss folgendes nehmen:

Die Basisklasse ist Calculator und verfügt über die Grundfunktionen add(), subtract(), multiply(), und divide(). Eine zweite Klasse, ScientificCalculator, erbt von dieser Klasse und offeriert zusätzliche Funktionen wie squareRoot(). Die Grundfunktionen können jedoch beide Klassen ausführen, mit demselben Verhalten.