Über unsMediaKontaktImpressum
Andre Krämer 15. Mai 2018

SOLID [1] – Das Dependency Inversion Principle

Kopplung zwischen Klassen ist eine der Hauptursachen für die schlechte Wartbarkeit von Software. Egal wie schlecht ein einzelnes Stück Code auch ist, es ist immer möglich, es zu verändern. Zur Not schreibt man es halt einfach neu. Das geht aber nur, wenn es isoliert betrachtet werden kann. Aber ist es bei einem großen System nicht ein unmöglicher Wunschtraum, dass Klassen einzeln für sich stehen? Nein, denn es geht nicht um komplette Isolation. Sicher müssen in einer großen Anwendung viele Klassen miteinander interagieren. Wir müssen aber verhindern, dass Klassen so miteinander verwoben sind, dass es praktisch unmöglich wird, eine gezielte Änderung durchzuführen.

Dazu sehen wir uns zunächst an, wie Kopplung, diese besonders negative Form der Abhängigkeit, entsteht. Dann lösen wir diese auf, indem wir das Dependency Inversion Principle (DIP) anwenden. Dadurch werden wir ein Verständnis dafür bekommen, wie ein effektives, kopplungsfreies Klassenmodell aussieht. Anstatt die Lösung anhand des DIP direkt einfach vorzusetzen, werden wir versuchen, uns den Weg zur Lösung schrittweise selbst zu erarbeiten, dadurch ist der Lerneffekt größer.

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

SOLID – Die 5 Prinzipien für objektorientiertes Softwaredesign

SOLID, das sind fünf Prinzipien zum Entwurf und der Entwicklung wartbarer Softwaresysteme. Lernen Sie, Clean Code Development anhand von Praxis-Beispielen kennen.
>> Weiterlesen

Das Modell

Als beispielhaftes Objektmodell, an dem wir das Entstehen von Kopplung demonstrieren möchten, haben wir ein Thema gewählt, das wir aus dem Alltag kennen: eine Stereoanlage.

Damit wir etwas hören können, brauchen wir zuerst einen Lautsprecher. Für unser Beispiel besteht dieser nur aus einem Element, der Anschlussmöglichkeit für eine Tonquelle, dargestellt durch das Property AudioAusgang.

    class Lautsprecher
    {
        public AudioStream AudioAusgang { set; }
    }

An diesen sollen diverse Geräte angeschlossen werden: CD-Spieler, Kassettendeck und Radio.

    class CdSpieler
    {
        public AudioStream AudioQuelle { get; }

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

    class Kassettendeck
    {
        public AudioStream AudioQuelle { get; }

        public void Abspielen() { /* ... */ }
        public void Anhalten() { /* ... */ }
        public void VorwärtsSpulen() { /* ... */ }
        public void RückwärtsSpulen() { /* ... */ }
    }

    class Radio
    {
        public AudioStream AudioQuelle { get; }

        public void NächsterSender() { /* ... */ }
        public void VorherigerSender() { /* ... */ }
    }

Analog zum AudioAusgang des Lautsprechers haben alle diese Geräte eine AudioQuelle zur Tonausgabe. Die restlichen Methoden steuern das Abspielen. Wie man aus der Praxis weiß, sind diese bei den verschiedenen Geräten sehr unterschiedlich. Genau hier setzt unsere Anforderung an. Trotz all dieser Unterschiede sollen verschiedene Audiogeräte-Klassen integriert werden. Das Ziel ist dabei, dass es nur eine Klasse – den Controller – gibt, mit der das Gesamtsystem nach außen hin repräsentiert wird. Diese soll dafür zwei Funktionen anbieten:

  1. Es kann eines der vorhandenen Geräte ausgewählt werden. Der Lautsprecher wird dann mit diesem Gerät verbunden.
  2. Das so aktivierte Gerät kann gesteuert werden. Dafür wurde die Semantik des CD-Spielers übernommen.

Die Signatur des gewünschten Controllers sieht folgendermaßen aus:

    class Controller
    {
        private readonly Lautsprecher _lautsprecher = new Lautsprecher();
        private readonly CdSpieler _cdSpieler = new CdSpieler();
        private readonly Kassettendeck _kassettendeck = new Kassettendeck();
        private readonly Radio _radio = new Radio();

        enum VerfügbareAudiogeräte { CD_SPIELER, KASSETTEN_DECK, RADIO }

        private VerfügbareAudiogeräte _aktivesAudiogerät;
        public VerfügbareAudiogeräte AktivesAudiogerät { set; }

        public void PlayPause() { /* ... */ }
        public void Stop() { /* ... */ }
        public void Vorwärts () { /* ... */ }
        public void Rückwärts () { /* ... */ }
    }

Zur Vereinfachung gehen wir davon aus, dass der Controller das ganze Modell einmalig instanziiert und dann über die gesamte Laufzeit hält.

Implementierung des Controllers

Nachdem die Anforderungen an unser Modell geklärt sind, folgt nun die Implementierung. Uns interessiert nicht die Funktionsweise der einzelnen Audiogeräte, diese werden einfach vorausgesetzt, sondern nur die des Controllers. Dieser ist für unsere Fragestellung besonders interessant, da seine Aufgabe die Integration verschiedener Klassen in ein Gesamtsystem ist. Dies klingt nach einem Thema, das sicher anfällig ist für Kopplungsprobleme.

Das erste Feature, dass wir implementieren wollen, ist die Auswahl des aktuellen Geräts. Wir bieten dafür einen Setter an, durch den ein Client das aktuelle Gerät setzen kann. Dafür wird das Enum VerfügbareAudiogeräte verwendet.

        private VerfügbareAudiogeräte _aktivesAudiogerät;
        public VerfügbareAudiogeräte AktivesAudiogerät
        {
            set
            {
                _lautsprecher.AudioAusgang.Aktiv = false;
                Stop();

                _aktivesAudiogerät = value;

                switch (_aktivesAudiogerät)
                {
                    case VerfügbareAudiogeräte.CD_SPIELER:
                        _lautsprecher.AudioAusgang = _cdSpieler.AudioQuelle;
                        break;

                    case VerfügbareAudiogeräte.RADIO:
                        _lautsprecher.AudioAusgang = _radio.AudioQuelle;
                        _radio.AudioQuelle.Aktiv = true;
                        break;

                    case VerfügbareAudiogeräte.KASSETTEN_DECK:
                        _lautsprecher.AudioAusgang = _kassettendeck.AudioQuelle;
                        break;

                    default:
                        throw new Exception("Invalid enum value");
                }

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

Konzentrieren wir uns zunächst auf die ersten und letzten beiden Zeilen. Ganz zu Anfang wird der Audioausgang deaktiviert, damit beim Umschalten keine Störgeräusche entstehen. Ganz am Ende wird er wieder aktiviert. Diese Vorgänge bilden einen äußeren Rahmen um die restlichen Aktionen. Es gibt noch einen zweiten, inneren Rahmen: In der zweiten Zeile stoppen wir die aktuelle Wiedergabe des (noch) aktuellen Geräts, in der vorletzten spielen wir das neu aktivierte Gerät ab – eine Komfortfunktion für die Bedienung des Gesamtgeräts. Diese geschachtelten Rahmen sind typisch für die Businesslogik von Integrationskomponenten. Man denke an Datenbanktransaktionen, die zu Beginn einer logischen Operation geöffnet werden und am Ende bestätigt oder zurückgenommen werden (committ/rollback). Diese beiden Rahmen sollen stellvertretend für Transaktionsfunktionalitäten stehen, deren Korrektheit für die Funktion des Gesamtsystems kritisch ist.

Innerhalb dieser Rahmen befindet sich die Umschaltung des aktuellen Audiogeräts. Dabei wird zunächst das aktive Gerät umgesetzt. Das alleine reicht allerdings nicht aus. Wir müssen auch dessen AudioQuelle mit dem AudioAusgang des Lautsprechers verbinden. Dieser Vorgang, der eigentlich nur aus einer einzigen logischen Aussage besteht, ist im Code extrem aufgebläht. Die Codestruktur bekommt ein Ungleichgewicht – der kritische Transaktionsrahmen verschwindet in Implementierungsdetails. Konkret muss nämlich ein Switch über den Enumerationswert des aktuell gesetzten Audiogerätes gemacht werden. Dabei sehen wir mehrfach dieselbe logische Aussage, die immer wieder redundant auftaucht. Doch halt – bei Aktivierung des Radios, und zwar nur in diesem Fall, wird auch noch dessen AudioQuelle aktiviert. Warum nun doch diese Asymmetrie? Ist sie gewollt, oder einfach nur ein Kopierfehler?

Wir können diese Frage allein unter Betrachtung dieser Methode nicht beantworten. Gehen wir deshalb zur nächsten. Schauen wir uns also die Methoden an, die eine Interaktion mit dem aktiven Audiogerät ermöglichen. Zunächst Stop

        public void Stop()
        {
            switch (_aktivesAudiogerät)
            {
                case VerfügbareAudiogeräte.CD_SPIELER:
                    _cdSpieler.Stop();
                    break;

                case VerfügbareAudiogeräte.RADIO:
                    _radio.AudioQuelle.Aktiv = false;
                    break;

                case VerfügbareAudiogeräte.KASSETTEN_DECK:
                    _kassettendeck.Anhalten();
                    _kassettendeckLäuft = false;
                    break;

                default:
                    throw new Exception("Invalid enum value");
            }
        }

Beim CD-Spieler ist das Stoppen noch offensichtlich. Aber wie wir es auch anstellen, wir können vom Radio aus den Sender nicht zum Anhalten bewegen. Daher folgender Kompromiss: Wenn wir die Ausgabe schon nicht pausieren können, wollen wir wenigstens mit dieser Funktionalität das Radio stummschalten. Praktischerweise stellt der AudioStream eine Eigenschaft "Aktiv" zur Verfügung, die wir dafür nutzen können. Dadurch wird auch klar, warum beim Aktivieren des Radios, und nur bei diesem, die Zeile _radio.AudioQuelle.Aktiv = true stand. Wir wissen nicht, welchen Zustand die Eigenschaft beim Deaktivieren des Radios hatte. Wenn der Benutzer aber das Radio wieder aktiviert, will er sicher auch wieder etwas hören, so, dass wir auf jeden Fall sicherstellen wollen, dass es mit dem Aktivieren auch wieder laut gestellt wird.

Beim Kassettendeck haben wir aber schon das nächste Mysterium. Warum wird nicht nur das Band angehalten, sondern diese Tatsache auch noch in einem Feld mitnotiert? Ist das nicht redundant? Versuchen wir unser Glück bei PlayPause. Vielleicht klärt sich dann diese Frage:

        private bool _kassettendeckLäuft = false;
        public void PlayPause()
        {
            switch (_aktivesAudiogerät)
            {
                case VerfügbareAudiogeräte.CD_SPIELER:
                    _cdSpieler.PlayPause();
                    break;

                case VerfügbareAudiogeräte.RADIO:
                    if (_radio.AudioQuelle.Aktiv)
                    {
                        _radio.AudioQuelle.Aktiv = false;
                    }
                    else
                    {
                        _radio.AudioQuelle.Aktiv = true;
                    }
                    break;

                case VerfügbareAudiogeräte.KASSETTEN_DECK:
                    if (_kassettendeckLäuft)
                    {
                        _kassettendeck.Anhalten();
                        _kassettendeckLäuft = false;
                    }
                    else
                    {
                        _kassettendeck.Abspielen();
                        _kassettendeckLäuft = true;
                    }
                    break;

                default:
                    throw new Exception("Invalid enum value");
            }
        }

Zunächst zeigen wir die Definition des bisher (aus didaktischen Gründen) unterschlagenen Felds _kassettendeckLäuft. Tatsächlich sehen wir nun auch den Nutzen dieses Feldes. Während der CD-Player an sich eine PlayPause-Funktion kennt, ist dies bei Radio und Kassettendeck nicht der Fall. Sie muss also simuliert werden. Beim Radio können wir das leisten auf Basis des bereits bekannten Properties AudioQuelle.Aktiv, wir brauchen kein zusätzliches Feld. Das Stummschalten des Radios ist aber nur eine Notlösung, die wir beim Kassettendeck nicht übernehmen wollen. Bei diesem können wir PlayPause effektiv simulieren. Dazu speichern wir den aktuellen Abspielzustand hier und in Stop im Feld _kassettendeckLäuft, damit wir diesen beim nächsten Aufruf wieder berücksichtigen können.

Die Implementierung von Vorwärts und Rückwärts ist für uns uninteressant – sie bringt keine neuen Erkenntnisse.

Wartbarkeitsprobleme der Implementierung

Damit wäre unsere Inspektion des Ist-Standes vollständig. Der Code ist alles andere als kompliziert und es werden alle Features umgesetzt. Es gibt aber einige systematische Probleme mit der Implementierung:

  • Die Switches: In jeder Methode haben wir das Selbe Switch-Konstrukt. Leider ist die Ähnlichkeit zwischen ihnen zwar optisch zu erkennen, sie lässt sich aber nicht durch ein direktes Refactoring ausnutzen. Das Problem ist, dass es zwar einen logischen Zusammenhang zwischen den Enumerationswerten der Audiogeräte (z. B. CD_SPIELER) zu den eigentlichen Feldern (z. B. _cdSpieler) gibt, dieser aber implizit ist. Das heißt, wir müssen ihn technisch gesehen immer wieder von neuem manuell programmieren. Dabei können natürlich diverse Fehler passieren.
    • Verwechslungen: Es ist leicht möglich, dass zwar ein CD_SPIELER als Label verwendet wird, dann aber nicht, oder schlimmer noch, nicht durchgehend, _cdSpieler angesprochen wird.
    • Schlechte Erweiterbarkeit: Wenn ein neues Audiogerät hinzugefügt werden soll, muss zunächst ein neuer Enumerationswert und ein neues Feld definiert werden. Dann müssen alle Stellen angepasst werden, die ein Switch über das Enum machen – und bloß keine Stelle vergessen! Blöd nur, dass der Compiler hier nicht weiterhilft. Dasselbe gilt natürlich, falls ein Gerät entfernt werden soll – und sei es nur zu Testzwecken.
  • Die Cases: Nicht weniger problematisch ist, was sich innerhalb der verschiedenen Cases abspielt. Viele dieser Cases sind über die Klasse verteilt und beinhalten den Code aller Geräte hinsichtlich eines Features. Das ist aber eine unpraktische Gruppierung, denn die verschiedenen Case-Zweige haben nichts miteinander zu tun. Die Frage, wie das Radio Stop implementiert, hat keine Relevanz dafür, wie es beim Kassettendeck gehandhabt wird. Diese Fragen sind voneinander unabhängig. Trotzdem ist deren Code örtlich stark miteinander verwoben.
    Was aber für den Entwickler sehr relevant ist, ist die Frage, wie die verschiedenen Adaptionsvorgänge eines Gerätes aussehen. Nur dadurch lassen sich auch, wie bereits gesehen, bestimmte Felder der Klasse verstehen. So wird z. B. _kassettendeckLäuft in mehreren Methoden angesprochen, aber immer nur im Zusammenhang mit dem Kassettendeck. Für das Gesamtverständnis der Kassettendeck-Anbindung ist es deshalb notwendig, von Methode zu Methode zu springen und den jeweils interessanten Case herauszusuchen. Die anderen Zweige müssen wir in Gedanken ausblenden. Ebenso die Felder – wir müssen uns die heraussuchen, die mit dem aktuellen Fall zu tun haben. Die aktuelle Gruppierung des Codes in der Klasse erschwert also unsere Arbeit.
  • Die Kopplung: Bei den erstgenannten Problemen handelt es sich eigentlich bereits um konkrete Beispiele für Kopplung. Es gibt aber noch viele andere, subtilere Formen. Dadurch, dass der Controller Beziehungen zu vielen anderen Klassen unterhält, ist er von Änderungen an diesen Klassen abhängig. Jede Änderung an der Signatur eines Audiogeräts bedingt eine Anpassung der Businesslogik. Schlimmer noch, jede Änderung der Semantik bedarf zumindest einer Überprüfung. Semantik lässt sich aber ändern, ohne, dass der Compiler uns warnt.  

Eigentlich ist es selbstverständlich, aber es soll noch einmal klargestellt werden: Der vorgestellte Code in dieser Größe ist auch mit diesen Problemen wartbar. Das Modell entspricht aber eher dem ersten Zyklus eines größeren Projektes. Im Laufe von weiteren Entwicklungsschritten wird der Codeumfang typischerweise immer nur weiter zu- und selten abnehmen. Die Anlagen für eine schlechte Wartbarkeit sind aber bereits gelegt. Wenn der Code nun weiter wächst, besteht die Gefahr, dass der Zeitpunkt verpasst wird, um die Mängel zu beseitigen.

Lösung der Wartbarkeitsprobleme im Controller

Starten wir also bei Punkt 1. Noch einmal das Kernproblem: Wir haben immer wieder logisch redundante Switch-Konstrukte, die wir nicht direkt abschaffen können, weil der Zusammenhang zwischen dem Enum, über das das aktuelle Gerät adressiert wird, und dem eigentlichen Gerätefeld implizit ist. Machen wir also diesen Zusammenhang explizit! Wir definieren eine gemeinsame Abstraktion, die alle Audiogeräte vertritt – die abstrakte Klasse Audiogerät. Diese speichern wir dann in Zukunft im Feld _aktivesAudiogerät anstatt des Enumerationswertes. Dieser entfällt komplett. Dadurch gibt es keine doppelte Repräsentation des Konzeptes Audiogerätes mehr und die Switch-Blöcke entfallen, da wir einfach immer direkt das Objekt in _aktivesAudiogerät aufrufen. 

So weit so gut, aber es stellen sich nun zwei Fragen:

  • Was soll Bestandteil dieser abstrakten Klasse sein?
  • Wenn es keine Enumeration VerfügbareAudiogeräte mehr gibt, wie wird dann vom Benutzer des Controllers das zu aktivierende Gerät identifiziert?

Wenn wir die Audiogeräte nicht mehr indirekt über ein Enum identifizieren wollen, muss es eine abstrakte Fähigkeit zur Identifikation des Audiogerätes geben, die von der Basisklasse geliefert wird. Eine GUID bietet sich hier an.

Wir definieren Audiogerät im ersten Schritt folgendermaßen:

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

Durch diese Beschlüsse ergibt sich folgende, neue Signatur für den Controller:

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

        public IEnumerable<Guid> VerfügbareAudiogeräteGuids => _audiogeräteNachGuid.Keys;
        private Audiogerät _aktivesAudiogerät;
        public Guid AktivesAudiogerät{ set; }

        public void PlayPause() { /* ... */ }
        public void Stop() { /* ... */ }
        public void Vorwärts () { /* ... */ }
        public void Rückwärts () { /* ... */ }
    }

Da aus Sicht des Controllers nun alle Audiogeräte gleich sind, macht es auch keinen Sinn mehr, sie einzeln in Feldern zu halten. Stattdessen halten wir alle von ihnen in einer Collection. Damit machen wir uns letztendlich von den konkreten Audiogeräten und deren Anzahl komplett unabhängig. 

Der Benutzer setzt das aktive Audiogerät über eine GUID. Diese ist einer Aufzählung entnommen, die dynamisch aus den GUIDs der vorhandenen Audiogeräte entnommen wird.

Sehen wir uns nun die Implementierung des Setters von AktivesAudiogerät an:

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

                _aktivesAudiogerät = _audiogeräteNachGuid[value];

                _lautsprecher.AudioAusgang = _aktivesAudiogerät.AudioQuelle;

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

An Stelle des Switch-Blocks steht nur noch eine einzige Zeile – wir müssen ja jetzt nicht mehr zwischen den verschiedenen Geräten unterscheiden. Damit ist unser Problem Nr.1 behoben. Damit der Zugriff auf AudioQuelle funktioniert, haben wir diese in die Basisklasse aufgenommen:

    abstract class Audiogerät
    {
        public abstract Guid Guid { get; }
        public abstract AudioStream AudioQuelle { get; }
    }

Was bedeutet Abstraktion?

Diese Änderungen waren alle noch recht naheliegend. Doch nun sind wir an einem kritischen Punkt angekommen. Bis zu dieser Stelle war die Definition von Audiogerät naheliegend – wir haben gerade z. B. ein Property abstrahiert, das sowieso von allen Geräten angeboten wurde. Wie sollen wir aber so unterschiedliche Interaktionsmöglichkeiten, wie sie unsere Audiogeräte nun einmal haben, in eine gemeinsame Abstraktion bringen?

An dieser Stelle passiert üblicherweise einer von zwei Fehlern: Entweder wird der Versuch mit der Basisklasse hier abgebrochen und der alte, problembehaftete Code bleibt bestehen. Alternativ wird eine Basisklasse definiert, die einfach alle Funktionalitäten beinhaltet, die prinzipiell möglich sind. Dabei handelt es sich dann aber gar nicht mehr um eine Abstraktion, sondern um ein unverständliches Sammelsurium an Funktionalitäten – neue Wartungsprobleme entstehen.

Im Kern beruht das Problem auf einem Missverständnis. Der Versuch der Abstraktion wird aus der Perspektive der verschiedenen Klassen betrachtet, die eine Leistung erbringen. Es wird versucht, eine gemeinsame Schnittstelle zu unterschiedlichen Dingen zu ermöglichen. Gleichzeitig sollten so viele "Features" wie möglich in die Basisklasse "gerettet werden". Das ist aber der falsche Ansatz. Eine sinnvolle Abstraktion dient immer einem Zweck.

Ein Beispiel: Wir wollen die Karte eines Geländes haben. Doch was interessiert uns dabei? Die Topographie? Die Politik? Die Verkehrswege? Die vorausgesagten Temperaturen? Die Satellitenperspektive? ...

Wie wir sehen gibt es nicht "die" Abstraktion eines Sachverhaltes und gerade das Weglassen ermöglicht erst ihre Nutzung. Stellen sie sich nur vor, wir wollten wirklich eine Karte benutzen, die alle diese Informationen auf einmal enthält – unmöglich!

Wenn wir uns die Frage nach einer passenden Abstraktion stellen, müssen wir also erst den richtigen Blickwinkel finden. In unserem Fall ist das der Blick des Controllers. Doch was benötigt der Controller von den Audiogeräten bzgl. der Interaktionsmöglichkeiten? Wie wäre es damit:

    class Controller
    {
        /* ... Felder und Setzen von _aktivesAudiogerät ... */

        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();
        }
    }

Man könnte sagen, der Controller spielt eine Runde "Wünsch' Dir was". Er gibt eine ihm angenehme Abstraktion vor. Diese würde dadurch so aussehen:

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

        public abstract AudioStream AudioQuelle { get; }

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

Auf den ersten Blick mag diese Definition der Basisklasse absurd wirken. Der komplette Code der Controllermethoden zur Interaktion besteht nur noch aus identischen Aufrufen der Abstraktion. Das liegt aber darin begründet, dass es sich nur um Beispielcode handelt. In Produktivcode sind immer noch viele andere Belange geschäftlicher und technischer Art unterzubringen, selbst wenn einzelne Serviceabstraktionen sehr direkt den Wünschen einer Nutzerklasse entsprechen.

Was aber wahrscheinlich noch nicht klar ist: Das Kopplungsproblem ist komplett gelöst. Die Abstraktion entspricht genau dem, was wir brauchen und ist hundertprozentig unter der Kontrolle des Controllers. Natürlich spezifizieren und dokumentieren wir diese noch ausgiebig, aber dann geben wir sie an den Implementierer des Services weiter und lösen dadurch jede Menge an Problemen:

  • Es gibt technisch-syntaktische Änderungen an einer Serviceimplementierung?
    Uns egal. Dann kann der Lieferant halt nicht mehr kompilieren. Er muss die Basisklasse implementieren.
  • Es gibt semantische Änderungen, die nicht vom Compiler erfasst werden?
    Dafür spannen wir ein Netz an UnitTests um unsere Abstraktion, gegen die auch alle Lieferanten testen müssen.

Wir sehen also: Gegen den Willen des Controllers passiert nichts, was eine Änderung seines Codes bedingen würde. Anders herum, wenn sich das Verhalten der Geräte aus Sicht des Controllers ändern muss, so passt der Controller die Abstraktion an und alle Lieferanten müssen sich bewegen. Änderungen der Businesslogik erfolgen damit nur, weil sich die Anforderungen, sprich die Kundenwünsche, geändert haben. Also keine "plötzlichen" Anpassungen mehr, weil sich die Technik geändert hat.

Implementierung der abstrakten Basisklasse in den Audiogeräten

Die bisherigen Änderungen lösen das Problem der Abhängigkeit aus Sicht des Controllers – der Code wird dabei deutlich vereinfacht. Wie sieht es aber auf der anderen Seite aus? Die verschiedenen Audiogeräte müssen ja nun die Last der Abhängigkeit schultern. Schauen wir uns zunächst CdSpieler an.

    class CdSpieler : Audiogerät
    {
        public override Guid Guid { get; }

        public override AudioStream AudioQuelle { get; }

        public override void PlayPause() { /* ... */ }
        public override void Stop() { /* ... */ }
        public override void Vorwärts() { /* ... */ }
        public override void Rückwärts() { /* ... */ }
    }

Es mag zuerst überraschen, aber die Klasse hat sich so gut wie gar nicht geändert. Wir erinnern uns aber an den Grund: Die Controller-API orientierte sich bei ihrer Definition an der des CD-Spielers. Dementsprechend war auch die Vermittlung zwischen den Methoden des Controllers und des CD-Spielers immer straightforward. Anders ausgedrückt: Die Switch-Blöcke, die zum CD-Spieler gehörten, waren immer sehr einfach strukturiert und im wesentlichen reine Aufrufweiterleitungen – zwischen Controller und CdSpieler gab es einen gemeinsamen Konsens, wie die Schnittstelle eines Audiogerätes auszusehen hat. Dieser drückt sich nun dadurch noch einmal aus, dass die Implementierung von Audiogerät für CdSpieler kein Problem darstellt.

Wenn wir uns aber an die anderen Switch-Blöcke erinnern, wissen wir, dass das für die anderen Geräte nicht der Fall war. Wie sieht es z. B. beim Kassettendeck aus?

    class Kassettendeck : Audiogerät
    {
        public override AudioStream AudioQuelle { get; }

        public void Abspielen() { /* ... */ }
        public void Anhalten() { /* ... */ }
        public void VorwärtsSpulen() { /* ... */ }
        public void RückwärtsSpulen() { /* ... */ }        

        public override Guid Guid { get; }

        private bool _kassettendeckLäuft = false;
        public override void PlayPause()
        {
            if (_kassettendeckLäuft)
            {
                Anhalten();
                _kassettendeckLäuft = false;
            }
            else
            {
                Abspielen();
                _kassettendeckLäuft = true;
            }
        }

        public override void Stop()
        {
            Anhalten();
            _kassettendeckLäuft = false;
        }

        public override void Vorwärts()
        {
            VorwärtsSpulen();
        }

        public override void Rückwärts()
        {
            RückwärtsSpulen();
        }
    }

Hier wurde bewusst ein interessantes Vorgehen gewählt: Kassettendeck muss AudioQuelle implementieren – das tut es auch. Gleichzeitig wurde aber die alte API komplett beibehalten. Die Änderungen sind also rein additiv. Aus Sicht einer Softwareevolution hat das den Vorteil, dass Kassettendeck gegenüber eventuellen anderen Nutzern durch das Refactoring nicht inkompatibel wird (vgl. OCP).

Wie sieht denn nun die Implementierung der abstrakten Klasse aus? Geradlinig! Den Code kennen wir nämlich bereits. Wie bereits vorher im Controller wird nun in dem Teil des Kassettendecks, der die Basisklasse implementiert, die PlayPause-Funktionalität simuliert. Nur diesmal ist das ganze Feature auch an einem Ort zusammengefasst und zwar da, wo es hingehört. Im direkten, örtlichen Zusammenhang ist es auch leicht zu verstehen, anstatt zusammen mit anderen Features im Controller vermischt zu werden. 

Wie wir sehen, haben wir durch die neuen Anforderungen der Basisklasse keine Mehrarbeit geschaffen. Im Gegenteil, wir haben die 3 großen Probleme der ursprünglichen Implementierung gelöst. Dabei mussten wir den Code lediglich sinnvoll umstrukturieren. Es geht nicht um unterschiedlichen Inhalt des Codes, sondern über eine langfristig tragfähige Aufteilung des Codes in mehrere, gleichbleibend kleine Artefakte mit klaren Verantwortlichkeiten. Die zusätzliche Abstraktion ist dabei, wie schon diskutiert, kein Mehraufwand, sondern ein Werkzeug für diese Zusammenarbeit.

Die formale Definition des Dependency Inversion Principle

Mit diesem Wissen können wir nun die formale Definition des Dependency Inversion Principle liefern. Sie ist naturgemäß sehr kompakt. Wir haben viele Erkenntnisse an diesem Beispiel gewonnen. Zählen wir diese jeweils noch einmal passend zu den einzelnen Punkten auf:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
    Wir haben nun ein Verständnis dafür, wie Kopplung zwischen – und sogar innerhalb von – Klassen entsteht. Wir lösen sie auf, indem wir die gewünschte Interaktion zwischen einer Serviceklasse und deren Nutzer in einer abstrakten Klasse modellieren. Dadurch wird die Interaktion zu einem eigenen Modellbestandteil, der klar definiert, implementiert und getestet werden kann. Da der Servicenutzer den Kontrakt definiert, ist die Interaktion genau für ihn zugeschnitten und "passt" genau in seine Implementierung.
    Selbst für den Serviceanbieter ist diese Lösung ein Vorteil. Er kann in seiner Implementierung trennen zwischen einer allgemeinen API, die "natürlich" aus dem Service heraus entsteht, und einer API, die speziell durch die Implementierung einer abstrakten Klasse herausgefordert wird. Funktionalität wird dadurch im Code sinnvoll gruppiert.
  • Abstractions should not depend on details. Details should depend on abstractions.
    Dadurch, dass jede Abstraktion immer aus der ganz bestimmten Sichtweise eines jeweiligen Benutzers definiert ist, verschwindet auch jegliches Interesse an den API- oder Implementierungsdetails einzelner Serviceklassen. Es zählt nur die Abstraktion, nach der sich die Serviceklasse vollumfänglich richtet. Falls ein Servicenutzer sich von einem Feature inspirieren lässt und dies nutzen möchte, fordert er es halt in der Abstraktion an – aber in seiner Semantik. Die Serviceklassen liefern dann dieses Verhalten. Nicht die Technik ist Treiber von Veränderungen, sondern die Geschäftslogik – auch bekannt als zahlender Kunde.

Zum Schluss

Jeder kennt das Softwareprojekt mit der "Bug Farm": Die Klasse mit tausenden von Zeilen, die niemand mehr versteht und immer wieder die Quelle von Bugs ist. Es ist sehr unwahrscheinlich, dass eine kleine, gut wartbare Klasse plötzlich zu so einem Problem wird. Vielmehr werden die Probleme ignoriert, solange sie noch einfach lösbar sind – bis sie es dann nicht mehr sind.

Wenn Sie das Dependency Inversion Principle verinnerlicht haben, wissen sie, wie Code aussieht, der langfristige Wartbarkeitsprobleme verursacht – Abhängigkeiten von Geschäftslogik zu technischen Abstraktionen. Dafür haben Sie nun eine Lösungsstrategie.

Also, lassen Sie uns diese Probleme lösen, solange es noch einfach ist!

SOLID [2] – Das Single Responsibility Principle

SOLID, das sind fünf Prinzipien zum Entwurf und der Entwicklung wartbarer Softwaresysteme. Das SRP stellt die Frage nach der optimalen Größe einer leicht wartbaren Klasse.
>> Weiterlesen
Autor

Andre Krämer

Andre Krämer ist langjähriger Software-Architekt, -Trainer und -Berater mit den Schwerpunkten Microsoft.Net und C++. Sein Fokus liegt in der Entwicklung komplexer und performancekritischer Datenverarbeitungs- und…
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben