Über unsMediaKontaktImpressum
Stefan Dirschnabel 12. Juni 2018

SOLID [3] – Das Liskov Substitution Principle

Dies ist der vierte Artikel unserer Serie über "SOLID", das sind fünf Prinzipien zum Entwurf und der Entwicklung wartbarer Softwaresysteme. Das LSP hilft Sonderfälle in den Anforderungen übersichtlich abzubilden. Gratulation, wenn Sie bis jetzt durchgehalten und alle Artikel gelesen haben. Wir steigen jetzt weiter in ein fortgeschrittenes Konzept des Softwaredesigns ein! 

Zu Beginn unserer Artikelserie habe ich Ihnen die SOLID-Prinzipien und Ihre Bedeutung für die Softwareentwicklung anhand eines Einführungsartikels verdeutlicht. Unser Beispiel einer Stereoanlage wurde danach von meinem Kollegen Andre Krämer deutlich weiterentwickelt. In diesem Artikel werde ich auf einen immer wiederkehrenden und erschwerenden Faktor eingehen: sehr spezielle Anforderungen, um die sich das bestehende Klassenmodell meist nur schwierig erweitern lässt. 

Haben Sie die Einführung in die SOLID-Prinzipien gelesen?

Wo wir stehen

Wir möchten eine Stereoanlage entwickeln. Verschiedene Audiogeräte (CdSpieler, KassettenDeck, Radio) werden dabei mittels eines Controllers an einen Lautsprecher angeschlossen. Außerdem nimmt der Controller die Eingaben aus der Benutzeroberfläche entgegen.

Durch Anwendung des Dependency Inversion Principle wurde die Verständlichkeit der Controller-Klasse verbessert. Der Controller ist nicht mehr von den einzelnen Geräten (CdSpieler, Radio, Kassettendeck) abhängig. Er muss nicht mehr mit komplexen Switch-Case Statements auf die unterschiedlichen Geräte und deren Bedienung eingehen. Stattdessen wurde die Abstraktion eines Audiogerätes eingeführt. Diese Abstraktion beschreibt allgemein ein Audiogerät aus Sicht des Controllers. Im ersten Schritt war es die Verantwortlichkeit der jeweiligen Audiogeräte, diese Abstraktion sinnvoll zu implementieren. 

Die Verwender des Controllers müssen die Audiogerät-Abstraktion nicht kennen, Sie wählen über eine (zum Beispiel in lesbaren Text lokalisierbare) GUID Ihr passendes Gerät aus.

Bei genauer Betrachtung zeigte sich jedoch: die Audiogeräte erfüllen plötzlich verschiedene Aufgaben. Zum einen implementieren sie ihre entsprechende Businesslogik. Zum anderen enthalten sie auch Methoden um die Abstraktion für den Controller zu erfüllen. Durch die Anwendung des Single Responsibility Principle wurde die Anpassung der spezifischen Geräte an die Audiogeräte-Abstraktion in eine Adapterklasse verschoben. Verantwortlichkeiten werden nicht mehr in einer Klasse vermischt und Änderungen an den Requirements führen zu gezielten Anpassungen an kleinen, leicht wartbaren Klassen.

Für das Liskovsche Substitutionsprinzip setzen wir deswegen auf folgendem Programmcode auf:

class Controller
{
    private readonly Lautsprecher _lautsprecher = new Lautsprecher();
    private readonly IDictionary<Guid, Audiogerät> _audiogeräteNachGuid;
    private Audiogerät _aktivesAudiogerät;

    public IEnumerable<Guid> VerfügbareAudiogeräteGuids => 
                                                  _audiogeräteNachGuid.Keys;

    public Guid AktivesAudiogerät
    {
        set
        {
            _lautsprecher.Aktiv = false;
            Stop();

            _aktivesAudiogerät = _audiogeräteNachGuid[value];

            _lautsprecher.SetzeAudiostream(_aktivesAudiogerät.AudioStream);

            PlayPause();
            _lautsprecher.Aktiv = true;
        }
    }

    public void PlayPause()
    {
        _aktivesAudiogerät.PlayPause();
    }

    public void Stop()
    {
        _aktivesAudiogerät.Stop();
    }

    public void Vorwärts()
    {
        _aktivesAudiogerät.Vorwärts();
    }

    public void Rückwärts()
    {
        _aktivesAudiogerät.Rückwärts();
    }
}

abstract class AudioGerät
{
    public abstract Guid Guid { get; }

    public abstract Stream AudioStream { get; }

    public abstract void PlayPause();
    public abstract void Stop();
    public abstract void Vorwärts();
    public abstract void Rückwärts();
}

class CdSpieler
{
    public Stream AudioStream { get; }
    public bool SpieltGerade { get; }

    public void Start() { /* ... */ }
    public void Pause() { /* ... */ }
    public void Stop() { /* ... */ }
    public void NächsterTitel() { /* ... */ }
    public void VorherigerTitel() { /* ... */ }
}

class CdSpielerAudiogerätAdapter : Audiogerät
{
    private readonly CdSpieler _cdSpieler = new CdSpieler();

    public override Guid Guid { get; }

    public override Stream AudioStream
    {
        get
        {
            return _cdSpieler.AudioStream;
        }
    }

    public override void PlayPause()
    {
        _cdSpieler.SpieltGerade ? _cdSpieler.Pause() : _cdSpieler.Start();
    }

    public override void Stop()
    {
        _cdSpieler.Stop();
    }

    public override void Vorwärts()
    {
        _cdSpieler.NächsterTitel();
    }

    public override void Rückwärts()
    {
        _cdSpieler.VorherigerTitel();
    }
}

Daily Business: Spezialanforderungen

Falls Sie das ursprüngliche Klassendiagramm noch im Kopf haben, ist Ihnen vielleicht aufgefallen, dass unsere bisherige Implementierung einer vereinfachten Version entspricht. Unser CdSpieler müsste eigentlich noch mehr können. Es fehlt noch ein Property zur Anzeige der Anzahl an Titeln auf der CD, sowie eine Methode, um direkt zu einem Titel zu springen:

class CdSpieler
{
    public Stream AudioStream { get; }
    public int Titelanzahl { get; } // fehlt bisher!
    public bool SpieltGerade { get; }
   
    public void Start() { /* ... */ }
    public void Pause() { /* ... */ }
    public void Stop() { /* ... */ }
    public void NächsterTitel() { /* ... */ }
    public void VorherigerTitel() { /* ... */ }
    public void TitelSetzen(int titelnummer) { /* ... */ } // fehlt bisher!
}

Die Anzahl an Titeln ist notwendig, da unsere Stereoanlage diese auf dem Display anzeigen möchte. Das Springen zu einem Titel wird über ein Zahlenfeld an der Bedienoberfläche ermöglicht und muss daher von unserem Programm unterstützt werden. 

Natürlich können wir diese neuen Anforderungen in der CdSpieler-Klasse kodieren. Da der Controller mit einer Abstraktion des CdSpielers kommuniziert – dem Audiogerät – müsste die Abstraktion um das entsprechende Property bzw. die entsprechende Methode erweitert werden. Bedenken Sie: der Controller kennt nur den Typ, die Klasse Audiogerät. Für den Controller sind die Methoden des CdSpielers unsichtbar.

Dies hat aber Auswirkungen auf alle anderen Geräte, welche die Abstraktion implementieren müssen. Zwar könnte das Radio noch die Anzahl der gerade verfügbaren Sender liefern, spätestens aber das KassettenDeck kann die Frage nach der Anzahl der Titel oder das Springen zu einem Titel nicht sinnvoll beantworten. 

Das KassettenDeckAudioGerät könnte nicht einmal einen sinnvollen Standardwert für die Anzahl an Titeln liefern. Das Werfen einer NotImplementedException führt ebenfalls zu schwer auffindbaren und nicht mehr nachvollziehbaren Fehlern. Die Verwender der Audiogerät-Abstraktion wissen nicht, welche konkrete Implementierung eines Audiogeräts zum Einsatz kommt und wie sich diese verhält. Als Verwender der Audiogerät-Abstraktionen erwarten Sie einfach, dass diese "ihren Job erledigen".

class RadioAudiogerätAdapter : Audiogerät
{    
    public int Titelanzahl { 
        get 
        { 
            return -1; // Oder 0? Oder 1? Oder sonstwas? Exception?
        };
    }
    
    public override void TitelSetzen(int titelnummer)
    {
        throw new NotImplementedException(„BÄM“);
    }
}

Würden wir die Audiogerät-Klasse um die Titelanzahl oder die Methode TitelSetzen erweitern, wäre das Liskovsche Substitutionsprinzip verletzt.

Definition des LSP

An dieser Stelle ist es sinnvoll auf die formale Definition des Liskovschen Substitutionsprinzips einzugehen. Sie lautet:

"Sei q(x) eine beweisbare Eigenschaft von Objekten x des Typs T. Dann soll q(y) des Typs S wahr sein, wobei S ein Untertyp von T ist."

Diese zunächst schwer zu verstehende Definition bedeutet vereinfacht ausgedrückt, dass es möglich sein muss, eine Subklasse anstelle ihrer Superklasse zu verwenden. Das LSP sagt: Sie müssen jederzeit eine Superklasse durch eine Subklasse ersetzen (substituieren) können. Das resultierende Programm darf keine semantischen Fehler aufweisen. 

Durch unsere Erweiterung der Audiogerät-Klasse wäre es aber nicht mehr möglich, in jedem Fall eine Subklasse (also eine Implementierung) dieser zu verwenden.

Das LSP ist einfach zu verletzen und schwierig zu entdecken. Voraussetzung für das LSP ist, dass Sie Vererbung einsetzen. Ohne Vererbung ist das LSP praktisch nicht existent, da es sich erst dadurch definiert. Eine Vielzahl an Programmen krankt an unentdeckten LSP-Problemen. Im günstigsten Falle stürzen diese Programme dann bei diversen "Sonderpfaden" in der Programmlogik ab – im ungünstigen Falle arbeiten sie mit falschen Daten weiter und der Fehler wird womöglich erst viel später bemerkt. Größere Vererbungshierarchien sind logischerweise anfälliger für diese Art von Problem.

Der Einfachheit halber sind wir in diesem Artikel bisher nur auf typisierte Sprachen eingegangen. In der zunehmend stärker werdenden Webentwicklung spielen dynamisch typisierte Sprachen wie Javascript aber eine entscheidende Rolle. Wenn Sie mit dynamisch typisierten Sprachen arbeiten, müssen Sie zusätzlich die Kovarianz und Kontravarianz-Restriktionen beachten. In typisierte Sprachen wie C# können Sie nicht gegen die Kovarianz- und Kontravarianz-Restriktionen verstoßen, da der Compiler nichts anderes zulässt und Fehler ausgibt. Eine tiefgreifende Analyse der Kovarianz- und Kontravarianz-Restriktionen würde diesen Artikel jedoch sprengen.

Mögliche Lösungen

Das hier gezeigte Szenario ist typisch für viele IT-Projekte. Es existiert eine "schöne" Abstraktion für diverse Geräte, Motoren, Finanzprodukte o. ä. Dann gibt es spezielle Anforderungen oder Eigenschaften, die sich nicht mehr auf die Abstraktion übertragen lassen. Wenn Sie das initiale Klassendiagramm genauer betrachten, werden Ihnen noch mehr Stellen auffallen, an denen sich die Abstraktion nicht sinnvoll erweitern lässt. Waren die Bemühungen in unseren vorigen Artikeln also sinnlos?

Fakt ist: In der Praxis führt kein Weg an der Kodierung von Spezialfällen vorbei. Wie also lässt sich unser Modell unter der Berücksichtigung des LSP erweitern?

Lösung 1: Unvereinbarkeit

Die erste Möglichkeit ist denkbar einfach – aber leider auch unbefriedigend: Sie akzeptieren die Unvereinbarkeit der verschiedenen Geräte und brechen die zuvor eingeführte Abstraktion (zumindest teilweise) wieder auf. Normalerweise ist das nicht die gewünschte Lösung und Sie wollen die existierende Abstraktion nicht aufgeben. Gerade durch die Abstraktion wird das Programm lesbarer und die Wiederverwendbarkeit diverser Programmteile wird erhöht. Dies macht unser Programm wartbarer. Bedenken Sie aber: da wir hier über fortgeschrittene Konzepte des Softwaredesigns sprechen, kann ich kaum eine pauschale Antwort auf diese Frage geben. Je nach Situation kann es durchaus sinnvoll sein, über diese Option nachzudenken.

Lösung 2: Optionalität

Es gibt aber noch weitere Optionen. Eine mögliche Variante ist die sogenannte „Optionalität“. Die Idee hinter der Optionalität kann schnell durch ein Klassendiagramm (s. Abb. 1) veranschaulicht werden.

Für schwer in allen Subklassen implementierbare Methoden definieren Sie an der Abstraktion eine zusätzliche Can…() Methode. Der Verwender der Superklasse prüft vor Verwendung der Do()-Routine mittels CanDo() ob er diese überhaupt aufrufen darf. Im einfachsten Fall kann das im Controller so aussehen:

class Controller
{
    // ...
    public void TitelSetzen(int titelnummer)
    {
        if(_aktivesAudiogerät.CanSetzeTitel())
        _aktivesAudiogerät.TitelSetzen(titelnummer);
    }
    // ...
}

Dieses durchaus gängige Pattern wurde auch im .NET-Framework z. B. bei der CanSeek()-Methode der Stream-Klasse umgesetzt.

Die Lösung mit der Optionalität hilft Ihnen, "Liskov-kompatibel" zu werden, ohne die zuvor eingeführte Abstraktion aufzugeben. Aber sie besitzt auch Nachteile.

Nichts hindert den Verwender daran, die Do-Methode aufzurufen. Es liegt in der Verantwortung des Verwenders, immer auf die CanDo-Methode zu prüfen. Das ist fehleranfällig. Der Verwender muss ja auch erst einmal wissen, dass die Optionalität existiert. Theoretisch müsste er bei jeder Methode auf die Existenz einer Can-Methode prüfen. 

Lösung 3: Interface Segregation Principle

Gegen Ende des Artikels habe ich noch eine gute und eine schlechte Nachricht für Sie. Zuerst die Gute: Es gibt noch eine bessere Lösung. Jetzt die Schlechte: Diese Lösung verwendet das Interface Segregation Principle und Sie müssen sich bis zu unserem nächsten Artikel mit der Auflösung gedulden.

Fazit

Sie haben das Liskovsche Substitutionsprinzip kennen und verstehen gelernt. Komplexere Vererbungshierarchien sind für eine Verletzung dieses Prinzips besonders anfällig. Dieses ist immer dann verletzt, wenn Sie in Ihrem Programm nicht immer eine Ableitung anstelle der abgeleiteten Klasse verwenden können. 

Wenn das Klassenmodell nur schwer erweiterbar ist und Sie Sonderfälle abbilden müssen, haben Sie verschiedene Möglichkeiten, LSP-konform zu werden: Sie akzeptieren die Unvereinbarkeit, Sie verwenden das vorgestellte Muster der Optionalität oder Sie verwenden das Interface Segregation Principle. Obwohl sich die formale Definition des LSP zunächst kompliziert anhört, ist sie – entsprechend vermittelt – nicht schwierig zu verstehen. Alle SOLID-Prinzipien bedingen sich gegenseitig. Im nächsten Artikel werde ich Ihnen auch das noch fehlende Interface Segregation Principle erklären. 

SOLID [4]
Autor
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben