SOLID [4] – Das Interface Segregation Principle
Bei der Entwicklung eines tragfähigen Softwaredesigns gibt es eine Vielzahl an Herausforderungen. Ein gutes Design führt nicht nur zu einer guten Erweiterbarkeit und weniger Fehlern im Programmcode. Gutes Softwaredesign erleichtert es Dritten unsere Klassen und Schnittstellen zu verstehen und zu verwenden. Mit diesem Artikel lernen Sie selbsterklärende Schnittstellen zu beschreiben und aufgeblasene, unverständliche Klassen zu vermeiden. Unser fünfter Artikel über die SOLID-Prinzipien greift eine noch offene Fragestellung aus dem letzten Artikel auf, kann aber auch ohne Vorwissen verstanden werden.
Wo wir stehen
Zur Erklärung des Interface Segregation Principle (ISP) benutzen wir das in unseren vorherigen Artikeln eingeführte Beispiel einer Stereoanlage. Eine Controller-Klasse nimmt dabei die Eingaben des Benutzers entgegen und steuert daraufhin verschiedene Audiogeräte an. Je nach gewähltem Audiogerät kann es sich dabei entweder um das Radio, das Kassettendeck oder den CD-Spieler handeln. Die Audiogeräte-Abstraktion wurde aus Sicht des Controllers definiert und hat die Wartbarkeit unseres Programms verbessert. Sie beschreibt abstrakt, was der Verwender von einem Audiogerät erwartet und verhindert, dass der Controller sich mit der individuellen Ansteuerung eines Geräts befassen muss.
Da die Audiogeräte neben ihren spezifischen Methoden auch die Audiogeräte-Abstraktion implementieren, wurde die Implementierung der Abstraktion von der eigentlichen Implementierung des Geräts getrennt. Spezielle Adapterklassen bilden die Audiogeräte auf die Audiogerät-Schnittstelle ab. Aus einer Klasse wurden dadurch zwei und unser Programm Single Responsibility-konform.
Nicht immer ist es jedoch möglich, alle Spezialfälle der abstrahierten Geräte in einer Abstraktion unterzubringen. So auch in unserem letzten Artikel über das Liskovsche Substitutionsprinzip beschrieben.
Beispielsweise möchte die Stereoanlage die Anzahl der Titel auf dem Display anzeigen – natürlich nur sofern der CdSpieler aktiv ist. Ohnehin kann das KassettenDeck keinen sinnvollen Wert für die Anzahl an verfügbaren Titeln liefern. Damit der Controller die Anzahl der Titel des CdSpielers abfragen kann, musste die Abstraktion des Audiogeräts erweitert werden. Dies hat zu einer Verletzung des LSP geführt, da es nicht immer möglich ist, die Abstraktion Audiogerät mit allen möglichen Implementierungen zu ersetzen (zu substituieren). Eine erste Lösung war die Definition einer CanGetTitelanzahl-Methode und eine Abfrage dieser in der GetDisplayText-Methode des Controllers. Diese Variante ist jedoch fehleranfällig, da der Konsument den Aufruf der CanGetTitelanzahl-Methode leicht vergessen kann.
Zum aktuellen Zeitpunkt sieht unser Klassendiagramm sowie der Quellcode wie folgt aus:
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 { /* Implementierungsdetails */ } } 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(); } public string GetDisplayText() { if (_aktivesAudiogerät.CanGetTitelanzahl()) return _aktivesAudiogerät.GetTitelanzahl().ToString(); return _aktivesAudiogerät.SpieltGerade ? "playing" : string.Empty; } } abstract class Audiogerät { public abstract Guid Guid { get; } public abstract Stream AudioStream { get; } public abstract bool SpieltGerade { get; } public abstract int Titelanzahl { get; } public abstract void PlayPause(); public abstract void Stop(); public abstract void Vorwärts(); public abstract void Rückwärts(); public abstract bool CanGetTitelanzahl(); public abstract int GetTitelanzahl(); } class CdSpieler { public Stream AudioStream { get; } public int Titelanzahl { 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 => _cdSpieler.AudioStream; public override bool SpieltGerade => _cdSpieler.SpieltGerade; public override int Titelanzahl => _cdSpieler.Titelanzahl; public override void PlayPause() { if (_cdSpieler.SpieltGerade) _cdSpieler.Pause(); else _cdSpieler.Start(); } public override void Stop() { _cdSpieler.Stop(); } public override void Vorwärts() { _cdSpieler.NächsterTitel(); } public override void Rückwärts() { _cdSpieler.VorherigerTitel(); } public override bool CanGetTitelanzahl() { return true; } public override int GetTitelanzahl() { return _cdSpieler.Titelanzahl; } }
Problem und Lösung: der kleinste gemeinsame Nenner
Offensichtlich liegt der Schwachpunkt unserer bisherigen Lösung nicht in der GetDisplayText()-Methode des Controllers, sondern im Entwurf unserer Audiogerät-Abstraktion. Diese beschreibt nicht mehr den kleinsten gemeinsamen Nenner unserer verschiedenen Audiogeräte.
Bedenken Sie: Selbst wenn die Implementierungen der Schnittstelle einen Default-Wert liefern könnten, erhöht ein zu großes Interface die Anzahl der bei Schnittstellenänderungen anzupassenden Subklassen. Sofern diese Art von Kopplung nicht notwendig ist, wollen wir sie natürlich verhindern.
Da wir auch weiterhin mit dem Audiogerät bzw. überhaupt mit Schnittstellen arbeiten möchten, hilft nur das Titelanzahl-Property aus der Audiogerät-Abstraktion zu entfernen und in eine eigene Abstraktion zu verlagern.
Diese Schnittstelle kann dann ganz individuell von den jeweiligen Audiogeräten implementiert werden. Die Can…()-Methode wird damit obsolet. Denn für den Verwender bedeutet die reine Existenz der Schnittstelle, dass er die angebotenen Dienste konsumieren kann. Ein passender Schnittstellenname wäre damit z. B. ICanGetTitelanzahl.
Es gibt aber auch andere Möglichkeiten, das Interface sinnvoll zu benennen. Mehr Sinn würde ICanProvideTitelanzahl ergeben. Immerhin möchten wir für einen potentiellen Konsumenten unserer Schnittstelle ausdrücken, dass diese die Titelanzahl liefert (und nicht, dass diese die Titelanzahl irgendwo abfragen kann). In beiden Fällen ergibt das vorangestellte "I" auch sprachlich Sinn: "Ich kann die Titelanzahl liefern". Wenn Sie ohne Prefix arbeiten möchten, ist das genauso gut möglich: ProvidesTitelanzahl. Ein Prefix ist gerade bei C# zwar oft Konvention, aber nicht unbedingt notwendig, denn aus Verwendersicht ist es egal, ob es sich um eine abstrakte Klasse, Klasse oder um ein Interface handelt.
Unser CdSpieler implementiert damit zusätzlich die IProvideTitelanzahl-Schnittstelle. Der aufrufende Code im Controller ändert sich zu:
public string GetDisplayText() { var providesTitelanzahl = _aktivesAudiogerät as IProvideTitelanzahl; if (providesTitelanzahl != null) return providesTitelanzahl.GetTitelanzahl().ToString(); return _aktivesAudiogerät.SpieltGerade ? "playing" : string.Empty; }
Wie Sie dem Programmcode entnehmen können, ist ein Cast zu IProvideTitelanzahl notwendig. Die Tatsache, dass das Audiogerät die Schnittstelle implementiert, entspricht der Optionalität aus dem vorherigen Beispiel. Der Vorteil dieser Variante liegt darin, dass die Methode GetTitelanzahl ohne den Cast nicht verfügbar ist und ein ungültiger Aufruf von GetTitelanzahl damit nicht möglich ist.
Definition ISP
Unbedachte Erweiterungen an unserer Audiogerät-Abstraktion führen zu einer aufgeblasenen und für die Subklassen schwierig zu implementierenden Schnittstelle. Dies führt uns zu der Definition des Interface Segregation Principle:
"Clients should not be forced to depend on methods that they do not use."
Ein zu großes Interface erhöht zudem die Wahrscheinlichkeit, dass Sie bei Änderungen in der Schnittstelle umso mehr Subklassen anpassen müssen. Da wir das nicht wollen, sollen die Subklassen möglichst nur diejenigen Methoden implementieren müssen, die sie wirklich benötigen.
Ein häufiges Argument gegen das ISP ist die erhöhte Anzahl an resultierenden Schnittstellen. Meine Meinung an dieser Stelle ist, dass ein gesundes Augenmaß Abhilfe schafft. Overengineering sollten Sie natürlich nie betreiben. In unserem Beispiel war die Einführung eines dedizierten Interfaces jedoch zwingend notwendig.
Spätere Erweiterungen an einem Interface bzw. die Aufteilung eines Interfaces in mehrere kleine Interfaces sind kein Design-Smell. Häufig ist es auch schwierig, die richtige Größe eines Interfaces von Anfang an zu bestimmen. Definieren Sie nicht von Beginn an möglichst viele kleine Interfaces. Es ist durchaus valide, den Entwurf auch später noch anzupassen. Gerade durch neue Anforderungen und Erweiterungen im Programmcode können spätere Anpassungen sinnvoll und nicht vorhersehbar sein.
In diesem Fall spreche ich auch ganz gezielt von Anpassungen, denn es handelt sich nicht um Refactoring. Gerade in agilen Projekten ist es nicht möglich, immer alle Eventualitäten zu bedenken. Oft ergeben sich auch erst bei der Implementierung – also nach dem initialen Entwurf – neue Erkenntnisse, sodass Sie den Entwurf reflektieren und danach anpassen müssen.
Reflektieren Sie bei neuen Anforderungen auch immer Ihr Klassenmodell und führen Sie nicht einfach blindlings Ergänzungen in den Schnittstellen durch. Das Interface Segregation Principle kommt während der Entwicklung immer wieder zum Einsatz.
Fazit
Sie haben das Interface Segregation Principle kennen und verstehen gelernt. Eine große Schnittstelle könnte zwar auf eine Verletzung des ISP hinweisen, bedeutet aber nicht automatisch, dass das Interface Segregation Principle tätsächlich verletzt ist. Wie in unserem Beispiel müssen Sie Ihre Domäne und die darin vorkommenden semantischen Einheiten differenziert betrachten. Häufig bildet Ihr Klassenmodell Dinge aus der echten Welt ab. Die von Ihren Klassen realisierten Schnittstellen entsprechen dann den Funktionalitäten dieser semantischen Einheiten. Wenn sich Funktionalitäten zwischen den Klassen zu sehr unterscheiden, sollten Sie mit einem gesunden Augenmaß die Anwendung des ISP prüfen. Um ISP-konform zu werden, führen Sie eine Extract-Interface-Anpassung durch. In unserem nächsten Artikel freuen wir uns, Ihnen das letzte noch ausstehende SOLID-Prinzip vorstellen zu dürfen: das Open Closed Principle.