Das Interface Segregation Principle - Nicht nur auf den Schnitt kommt es an

In unserem heutigen SOLID-Beitrag geht es um das „Interface Segregation Principle“. Wie wir im Blog sehen werden, ist dabei weniger die Trennung nach Interfaces das Problem, als vielmehr die an manchen Stellen wieder benötigte, sinnvolle Zusammenführung von Funktionalitäten aus verschiedenen Interfaces eines Objekts.

Vorweg: Die Fachabteilung war ganz begeistert, dass wir neulich die Anforderung zur Aufnahme von nicht registrierten Kunden so schnell und sauber umsetzen konnten. Die ausführliche Geschichte und wie die Anwendung des Liskov Substitution Principle zu einer sauberen Lösung beigetragen hat, kann man im „L“-Blog unserer SOLID-Reihe nachlesen.

Allerdings hat diese Erweiterung den Einfallsreichtum angespornt. Die neue Vision: Zukünftig sollen weitere neue Kundentypen im System eingeführt werden können. Außerdem soll es möglich sein, bestehende Kunden in einen anderen Kundentyp zu wandeln ohne dazu den Kunden im System neu anlegen zu müssen. 

Trennung von Daten und Verhalten

Um das Open-Close-Prinzip für das bestehende System bestmöglich zu wahren, passen wir das Domänenmodell an die neuen Anforderungen an.

Wir trennen die Kundendaten vom Kundentyp-spezifischen Kundenverhalten. (siehe hierzu auch Robert C. Martin, CleanCode, Kapitel 6 „Data“). Wir führen dazu eine neue Klasse „CustomerData“ ein. Zu den Kundendaten gehört ab jetzt auch die Information, von welchem Typ der Kunde ist.

Zukünftig werden Kunden in zwei Schritten ins System geladen: 

  1. Ein leeres Objekt des Kundentyps erzeugen.
  2. Das leere Objekt mit den Informationen aus dem Kundendaten-Objekt befüllen.

Den CustomerPersister passen wir entsprechend an. Dadurch wird es möglich, neue Kundentypen einzuführen, ohne den bestehenden Code anfassen oder kompilieren zu müssen. Die Vorarbeiten sind damit abgeschlossen.

Methoden zweckmäßig gruppieren

Betrachten wir unseren Code im Gesamten, fällt auf, dass die Klasse Customer zwar das Single Responsibility Principle (SRP) befolgt, sich die Methoden der Klasse aber trotzdem zwei unterschiedlichen Zwecken zuordnen lassen:

Service Provider-Funktionalität:

  • Befüllen des Objekts und Manipulation der im Objekt enthaltenen Daten (alle setter-Methoden).
  • Diese Methoden sollen nur von solchen Codeteilen genutzt werden, die explizit für die Manipulation der Daten zuständig sind.

Application Programming-Funktionalität:

  • Benutzen des Objekts, Auslesen der Objektdaten (alle getter-Methoden).
  • Diese Methoden sollen Codeteilen bereitgestellt werden, die mit Customer-Objekten arbeiten, sie aber nicht verändern dürfen (z.B. der CustomerExplorer)

Hier kommt das Interface Segregation Principle zur Anwendung. Es besagt, dass große Interfaces in mehrere kleine Interfaces mit jeweils zusammengehörigen Methoden aufgeteilt werden sollten. Das bringt zwei entscheidende Vorteile mit sich: Zum einen wird eine implementierende Klasse nicht gezwungen, (leere) Methoden vorzuhalten, die für die Klasse selbst ohne Nutzen sind, aber durch das Interface vorgegeben werden; zum anderen ermöglicht es benutzenden Klassen, gegen genau das Interface zu entwickeln, dessen Methoden man im aktuellen Anwendungsfall benötigt. Beides zusammen macht den Code sauberer, wartbarer und bietet Schutz vor ungewollter Manipulation. In unserem Fall bringt es Sicherheit darüber, dass in CustomerExplorer nicht versehentlich Daten manipuliert werden. 

Wir klammern die SPI-Methoden aus der Sichtbarkeit des CustomerExplorer aus, indem sie in ein eigenes Service Provider Interface gelegt werden, der CustomerExplorer aber gegen das Application Programming Interface mit den getter-Methoden implementiert wird.

Wir extrahieren dazu aus unserer Klasse die Interfaces ConfigurableCustomer und ProcessibleCustomer. ConfigurableCustomer fungiert dabei als SPI, ProcessibleCustomer als API.

Nun können nur noch dort Kundendaten manipuliert werden, wo gegen die konkrete Klasse oder die ConfigurableCustomer-Schnittstelle entwickelt wird.

Funktionalität aus mehreren Interfaces nutzen

Wir müssen unsere Anwendung noch entsprechend anpassen, indem wir in CustomerPersister die load-Methode so verändern, dass sie als Rückgabetyp ProcessibleCustomer hat. Programmteile, die über den CustomerPersisterKunden laden, können somit keine Kundendaten mehr manipulieren. 

Was ganz einfach und einleuchtend klingt, bringt uns bei genauerem Hinsehen zu einem unschönen Problem: Beim Laden von Kunden benutzt der CustomerPersister natürlich die setter-Methoden, um das leere Kundenobjekt mit Kundendaten zu befüllen. Alle setter-Methoden sind in ConfigurableCustomer gewandert, zurückgeben wollen wir aber ProcessibleCustomer. Nach kurzer Überlegung behelfen wir uns mit einem Typecast und passen die load-Methode folgendermaßen an:

Wir kompilieren. Alles funktioniert. Die Tests laufen mit kleineren Anpassungen sauber durch. Was soll da noch schiefgehen? – Mission erfüllt. 

Ein gefährlicher Trugschluss!

Durch unser Behelfskonstrukt mit dem Typecast haben wir ein nicht zu unterschätzendes Risiko geschaffen. Ein Risiko, das zudem schwer zu erkennen ist. Eine der eingangs vorgestellten Visionen der Fachabteilung war, dass neue Kundentypen einfach ins System hinzugefügt werden können. 

Mit unserer jetzigen Implementierung geben wir vor, dass der Kundentyp eine Klasse sein muss, die das Interface ProcessibleCustomer implementiert. Das ist aber nur die halbe Wahrheit. Der Kundentyp muss eine Klasse sein, die das Interface ProcessibleCustomer UND das Interface ConfigurableCustomer implementiert. Das ist aus dem Code weder ersichtlich, noch wird es geprüft. Ein Entwickler, der sich vor der Implementierung eines neuen Kundentyps nicht genau den CustomerPersister anschaut, wird nicht bemerken, dass sein neuer Kundentyp beide Interfaces benötigt. Er wird wahrscheinlich nur ProcessibleCustomer implementieren. 

Sein neuer Kundentyp kann sogar kompiliert werden. Wenn nicht in letzter Sekunde noch einige automatisierte Tests zuschlagen, dann wird Code ausgeliefert, der zur Laufzeit, beim Laden eines Kunden des neuen Typs, eine ClassCastException wirft. Unser Programm stürzt ab.

Ein teuer erkauftes Risiko, „nur“ um ein SOLID-Prinzip durchzusetzen, das unsere Anwendung allem Anschein nach gar nicht solider macht, sondern sogar fragiler. Nein, so können wir das nicht ausliefern. 

Die bessere Lösung

Wir müssen noch etwas an unserer Implementierung feilen. Wir sind nämlich auf ein Problem des Interface Segregation Principles gestoßen, das Vielen nicht bewusst ist: Das ISP sorgt zwar dafür, dass man je nach Nutzungskontext gegen das am besten passende Interface implementiert – doch was soll man tun, wenn man an einer Stelle im Code Funktionalität aus mehr als einem der implementierten Interfaces benutzen muss? Ein neues Interface erzeugen mit der Schnittmenge an benötigter Funktionalität? – Das würde wohl sehr schnell zu einer unwartbaren Vielzahl an Interfaces führen, mit nicht absehbaren Seiteneffekten in den implementierenden Klassen.

Glücklicherweise gibt es in Java eine wesentlich bessere Lösung. Der Weg dorthin führt über Generics. Generics geben Sicherheit zur Compile-Zeit, und sie können der Entwicklung elegant vorgeben, dass in einer Klasse verpflichtend mehrere Interfaces implementiert werden müssen.

Das syntaktische Konstrukt dazu sieht so aus:

<T extends A & B>

In unserem Beispiel typisieren wir dazu die Klasse CustomerData und geben dadurch vor, dass jeder Kundentyp beide Interfaces implementieren muss:

Die load-Methode im CustomerPersister erweitern wir ebenfalls um die Typisierung

Sollte ein Entwickler versuchen, einen Kundentyp zu bauen, der nicht beide Interfaces implementiert, erhält er einen Compile-Fehler, dessen Fehlermeldung klar kommuniziert, dass nicht alle verpflichtenden Interfaces implementiert werden. 

Es gibt nun kein Typecasting mehr in unserem Code. Wenn wir einen neuen Kundentyp schreiben, können wir sicher sein, dass der Kundentyp alle notwendigen Anforderungen erfüllt. 

Wir haben die gestellten Anforderungen der Fachabteilung erfüllt, haben die Objektstrukturen verbessert, und das Interface Segregation Principle so angewendet, dass wir gegen das bestmöglich passende Interface implementieren. Und zwar selbst dann, wenn das im speziellen Nutzungskontext nicht ein sondern zwei oder mehr Interfaces sind.

Fazit

Das Interface Segregation Principle führt zu stabilerem Code, indem es fachlich zusammengehöriges Verhalten in Schnittstellen sinnvoller Größe teilt. Das macht die implementierenden Klassen übersichtlicher und gibt den benutzenden Klassen klare Funktionalität vor. Dort wo Funktionalität aus mehreren Interfaces benötigt wird, lässt sie sich mit Hilfe von Generics elegant und sicher zusammenziehen.

Ich möchte allerdings davor warnen, nun in freudigem Elan alle Interfaces auf eine Methode zu reduzieren und je nach Nutzungskontext mit langen Generics-Konstrukten den passenden Zuschnitt zu gestalten. Wie bei den anderen vier SOLID-Prinzipien setzt auch das Interface Segregation Principle einen vernünftigen, bedachten Umgang mit Code durch den Entwickler voraus. Der Weg über die Generics ist für Sonderfälle gedacht, wenn die fachlich sinnvolle Trennung nicht zum technisch notwendigen Implementierungsfall passt. Wo möglich, sollten Interfaces immer so eingesetzt werden, dass sie eindeutig in den Nutzungskontext passen.

Hinweis: In frühen Versionen des JDK6 befindet sich ein Bug, der zu Compile-Fehlern beim Einsatz des beschrieben Generics-Konstrukts führt: JDK-6302954. 


Quellen / Links:

Robert C. Martin - Clean Code

http://www.clean-code-developer.de/Interface-Segregation-Principle-ISP.ashx?From=ISP

http://bugs.java.com/bugdatabase/view_bug.do?bug_id=6302954