Flexibel programmieren mit dem Fluent Interface
Seite 4: VerflĂĽssigung
FlĂĽssige API muss Aufgaben delegieren
Diese beiden Einschränkungen – inkonsistente Objekterzeugung und unzureichende Vorgabe der Grammatik durch Code Completion – lassen diese Implementierung eines Abschnittes nicht flüssig, sondern eher zartschmelzend erscheinen.
Ein vollständiger Übergang in den flüssigen Aggregatzustand lässt sich durch eine Grammatik erreichen. Dies bedeutet, die Reihenfolge der erlaubten Methodenaufrufe zu analysieren und die Stellen zu identifizieren, die das zu konstruierende Objekt in einem inkonsistenten und damit unbrauchbaren Zustand hinterlassen. Ein Zustandsdiagramm hilft da weiter (Abbildung 1).
Der Einstieg in die Konstruktion erfolgt direkt bei der Klasse Abschnitt
. Von hier aus kann die Methode mitNamen()
aufgerufen werden und liefert einen konsistenten Abschnitt ohne Werte zurĂĽck. Die Methoden mitWert()
sowie undMitWert()
hingegen dĂĽrfen keinen Abschnitt zurĂĽckliefern. Bei ihrem Aufruf ist der Abschnitt in einem inkonsistenten und unfertigen Zustand. Erst ein weiterer Methodenaufruf von anStelle()
liefert einen konsistenten Abschnitt.
Es kann viele unfertige Zustände während der Konstruktion eines Objektes mittels eines Fluent Interface geben. Sie zu identifizieren ist aber nur die halbe Miete; unfertige Zustände werden in sogenannten Deskriptoren implementiert. Durch den Einsatz eines Deskriptors muss die Abschnittsklasse nicht mehr sich selbst zurückgeben, wenn mitWert()
oder undMitWert()
an ihr aufgerufen wird. Der Benutzer erhält statt eines Objekts vom Typ Abschnitt
eins vom Typ des Deskriptors. In diesem Fall soll er WertZuStelleDescriptor
heiĂźen. Der Benutzer kann jetzt nur noch die Methoden aufrufen, die der Deskriptor bereitstellt. Code wie der folgende wĂĽrde nun nicht mehr kompilieren:
Abschnitt abschnitt =
Abschnitt.mitNamen("abschnittsname").mitWert("a").mitWert("b").anStelle(2);
Der erste Aufruf von mitWert()
liefert einen Deskriptor zurĂĽck, an dem ein erneuter Aufruf von mitWert()
nicht erlaubt ist. Somit kann das Programm nur noch konsistente Objekte erzeugen.
Zwar ist die Arbeit mit dem Descriptor-Entwurfsmuster gewöhnungsbedürftig, aber dafür flexibel ausbaubar. Deskriptoren können andere Deskriptoren zurückgeben und somit Grammatiken beliebiger Komplexität realisieren.
Ebenfalls gelöst durch den Einsatz von Deskriptoren ist die unzureichende Vorgabe der Grammatik durch Code Completion: Überall im Quellcode werden bei der Erzeugung des Abschnittes nur die Methoden angeboten, die der definierten Grammatik entsprechen. Den um den Einsatz von Deskriptoren modifizierten Abschnitt zeigt Listing 3.
Die deutlichste Ă„nderung ist in der Methode mitWert()
zu sehen: Durch einen Callback-Aufruf mit dem Interface WertZuStelleAddierer
wird anstelle eines Abschnitt
s ein AbschnittWertZuStelleDescriptor
zurĂĽckgegeben. Die Frage ist, warum hier eine anonyme Klasse zum Einsatz kommt, statt dass der Abschnitt dem Deskriptor gleich eine Referenz des zu konstruierenden Abschnitts, also sich selbst, ĂĽbergibt. In der klassischen Implementierung eines Abschnitts gibt es eine Methode add(String wert, int stelle)
, die der Deskriptor direkt aufrufen könnte.
Wenn der Deskriptor eine Referenz auf Abschnitt
setzt, bedeutet das allerdings eine zyklische Abhängigkeit zwischen ihm und dem Abschnitt, ergo schlechtes Design. Aus Sicht des Fluent Interface gibt es noch einen pragmatischen Grund, dies zu vermeiden. Bei der Konstruktion des Objekts sollen keine Methoden mitwirken, die außerhalb der definierten Grammatik sind. add()
wäre genau solch eine Methode. Ihren Aufruf verhindert das Interface WertZuStelleAddierer
. Listing 4 zeigt den Code des Addierers und des Deskriptors.
Das Sequenzdiagramm in Abbildung 2 soll das verdeutlichen. Zunächst wird ein Abschnitt
vom Test
mitNamen()
erzeugt. Für den Addierer gibt das Interface nur die Schnittstelle vor, und die dazugehörige anonyme Klasse bestimmt die Implementierung anhand des Entwurfmusters "Befehl" (Command). Der Deskriptor nimmt den Addierer auf und ruft ihn zusammen mit dem Wert auf, sobald der Konstrukteur an der Abschnittsklasse mitWert()
aufruft. Der Aufrufer hat dann nur noch die Möglichkeit, die öffentlichen Methoden des Deskriptors aufzurufen, also die nächsten grammatikalisch validen Schritte zu gehen. In diesem Fall ist dies die Methode anStelle()
und der RĂĽckgabewert ist wieder Abschnitt
.
Dank der flüssigen Schnittstelle ist die Lesbarkeit des Codes nach wie vor gegeben, und die definierte Grammatik garantiert Flexibilität und Konsistenz. Darüber hinaus ist die API jetzt übersichtlich – allerdings nur für die Konstruktion. Nicht so gut steht es mit der weiteren Verwendung im Lebenszyklus des Objekts, denn an einem fertig konstruierten Objekt können nun folgende Methoden aufgerufen werden:
static Abschnitt mitNamen(String name)
AbschnittWertZuStelleDescriptor mitWert(String wert)
AbschnittWertZuStelleDescriptor undMitWert(String wert)
String findeWertAnStelle(int stelle)
boolean hatWert(String wert)
String getName()
Die ersten drei Methoden – die des Fluent Interface – dienen zur Konstruktion eines Abschnitts und werden am fertig gebauten Objekt nicht mehr benötigt. Hier sind Methoden mit entsprechender Logik gefragt, wie sie bei den beiden nächsten Methoden gegeben ist, oder abfragende Methoden wie die letzte. Schließlich bleibt die Frage offen: Wie kann man einem fertigen Objekt später ein weiteres Stelle-Wert-Paar hinzufügen? Das wäre über die Methoden des Fluent Interface möglich, allerdings drücken solche Satzfetzen nicht mehr aus, was sie tun:
abschnitt.mitWert("weiterer wert").anStelle(5);
Methoden zur Konstruktion eigenen sich nicht zur späteren Manipulation eines Objekts. Hinzu kommt, dass die Abschnittsklasse zwei Aufgaben gleichzeitig übernimmt: die Konstruktion eines komplexen Datentyps (sich selbst) sowie die Datenhaltung samt Logik. Dies aber widerspricht dem Prinzip des "Separation of Concerns" (SoC) [d], das besagt, dass eine Klasse nur eine Aufgabe erfüllen sollte.