Hinweis:
Dieser Blogartikel ist älter als 5 Jahre – die genannten Inhalte sind eventuell überholt.
Durch Verwendung eines Fluent Interface kann man in Java einige Vorteile von Named Parameters nachbilden. In diesem Artikel zeigen wir, wie.
In einigen populären Sprachen gibt es das Feature der Named Parameters. Damit ist es möglich, beim Aufruf einer Methode oder Funktion über den Parameternamen festzulegen, welcher Parameter welchen Wert erhalten soll. Insbesondere ist die Reihenfolge der Parameter in der Methodendeklaration nicht relevant für die Zuordnung und es ist außerdem möglich, nicht alle vorhandenen Parameter mit Werten zu belegen. Im letzten Fall greifen dann Standardwerte oder Fehlerbehandlungen.
In Java ist dies bis jetzt so nicht möglich. Es muss immer für jeden Parameter ein Wert eines kompatiblen Typs übergeben werden und die Reihenfolge muss exakt eingehalten werden. Die Zuordnung erfolgt ausschließlich über die Reihenfolge, woraus folgt, dass an der Aufrufstelle gerade bei Methoden mit vielen Parametern und mehrmaligem Vorkommen derselben Typen nicht ohne weiteres erkennbar ist, welcher Parameter im Aufruf welchen Wert erhält.
Das Konzept des Method-Chaining
Nun bieten Fluent Interfaces das Konzept des Method-Chaining. Damit hat man die Option, den Methodenaufruf durch ein Objekt darzustellen, dem man in einer verketteten Abfolge von einparametrischen und ausdrucksstark benannten Methoden die einzelnen Parameterwerte für die letztliche Ausführung übergibt. Die Reihenfolge ist dabei irrelevant, die Mutatoren zeigen mit ihrem jeweiligen Namen eindeutig den gesetzten Parameter an und es müssen nur die Werte gesetzt werden, die man konkret belegen will. Alle weiteren werden mit Standardwerten belegt und fehlende Parameter können bei der Ausführung der Methode erkannt werden. Im Grunde handelt es sich um ein Parameter-Objekt-Pattern – und da hier immer dasselbe Objekt zurückgeliefert wird, haben wir es mit einem Fluent Interface ohne Grammatik zu tun.
Nehmen wir einen Service an, der eine Methode mit mehreren Parametern anbietet, wobei ich hier aus Platzgründen nur eine geringe Anzahl verwende.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class ActionService { //* * @param number, mandatory * @param flag, optional, default = true *// void performAction( long number, // primitver Typ, dadurch kein Null-Wert möglich Boolean flag) { // kann Null sein boolean flagValue = true; if(flag != null) { flagValue = flag.booleanValue(); } ... // führe Funktion aus } } |
Ein Aufruf daran sähe beispielsweise so aus:
1 |
service.performAction(100L, false); |
Wenn man nun die Parameter, die die Aktion definieren in ein Parameter-Objekt mit einem Fluent Interface umwandelt und dieses im Service verwendet, sähe das so aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
public class ActionParameter { Long number = null; // Object-Typ, muss noch belegt werden, um valide zu sein boolean flag = true; // mit Standard-Wert vorbelegt public ActionParameter withNumber(long number) { // primitver Typ, um validen Wert zu forcieren this.number = number; return this; } public ActionParameter withFlag(boolean flag) { this.flag = flag; return this; } public boolean isValid() { return (number != null); } } public class ActionService { void performAction(ActionParameter parameter) { if(parameter.isValid()) { ... // führe Funktion aus } else { ... // behandle invalide Werte } } |
Es wären dann die folgenden Aufrufe möglich.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
service.perform(new ActionParameter() .withNumber(100L) .withFlag(true)); // equivalent to previous service.perform(new ActionParameter() .withFlag(false) .withNumber(100L)); // use default service.perform(new ActionParameter() .withNumber(100L)); // invalid: missing number value service.perform(new ActionParameter() .withFlag(true)); |
Das Parameter-Objekt kann natürlich auch über ein Builder Pattern mit Fluent Interface erzeugt werden, was recht praktisch ist wenn man eine Reihe von ähnlichen Aufrufen mit nur geringen Unterschieden in einzelnen Attributen ausführen möchte.
Der Methodenaufruf als Objekt
Es ist außerdem möglich, dass das Objekt nicht einfach die Parameter kapselt, sondern den ganzen Methodenaufruf repräsentiert. Der Aufrufer würde sich dabei mit Hilfe des Service eine Instanz dieser Action erzeugen, diese durch Belegen der Parameter mit Werten in einen validen Zustand bringen und letztlich an dieser Instanz beispielsweise eine action.invoke() Methode aufrufen.
Je nach Einsatzgebiet und Anwendungsfall kann diese Methode bereits die gesamte Funktion implementieren. Notwendige Kollaborateure kann der Service der Action Instanz bei Erzeugung übergeben haben, wodurch er für diese Funktion zu einer Factory wird. Alternativ kann der Service auch sich selbst an die Action übergeben, ggf. unter einem anderen Interface als dieses für den Aufrufer sichtbar ist, oder die Action kann als Innere Klasse implizit auf interne Funktionen des Service zugreifen. Hier ist hauptsächlich maßgebend, wie komplex die jeweilige Funktion ist und ob Kollaborateure aus einem DI-Kontext o.ä. involviert sind.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
public class FluentActionService { // für externe Aufrufer sichtbar, liefert Representant der Aktion zurück public Action performAction() { return new Action(); } // für den Representanten sichtbar private void performAction(long number, boolean flag) { ... // führe Funktion aus } public class Action { // innere Klasse mit implizitem Zugriff auf die erzeugende Service-Instanz private Action() { } Long number = null; // Object-Typ, muss noch belegt werden, um valide zu sein boolean flag = true; public Action withNumber(long number) { // primitver Typ, um validen Wert zu forcieren this.number = number; return this; } public Action withFlag(boolean flag) { this.flag = flag; return this; } public boolean isValid() { return (number != null); } public void invoke() { if(isValid()) { performAction(this.number.longValue(), this.flag)); } else { ... // behandle invalide Werte } } } } |
Die Action Instanz führt bei diesem Konzept einen Zustand mit, der abhängig von der Belegung der Pflichtparameter valide oder invalide ist. Nur in einem validen Zustand kann die Funktion erfolgreich ausgeführt werden. Allerdings ist das nur zur Laufzeit feststellbar. Es wäre doch wünschenswert, wenn man bereits zur Entwicklungszeit erkennen könnte, ob die Funktion valide ist oder nicht.
Das Fluent Interface ermöglicht es, bei jedem Methodenaufruf eine Instanz einer anderen Klasse zurück zu liefern. Es hat damit eine Grammatik und die beteiligten Klassen, die gemeinsam das Fluent Interface bilden, werden als Mediatoren bezeichnet. Wenn die Mediatoren die einzelnen Zustände eines entsprechenden Automaten repräsentieren, dann sind die Methoden die Transitionen zwischen diesen Zuständen. Zu Beginn hätte man also eine Instanz von InvalidAction und der Mutator für den letzten fehlenden Pflichtparameter würde dann eine Instanz von Action mit allen bisher gesammelten Werten liefern, die dann auch erst eine action.invoke() Methode aufweist.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
... public class InvalidAction { private InvalidAction() { } boolean flag = true; public Action withNumber(long number) { this.number = number; return new Action(number, this.flag); // liefert Instanz des neuen Mediators mit gesammelten Werten } public InvalidAction withFlag(boolean flag) { this.flag = flag; return this; } // keine invoke() Methode } public class Action { private Action(long number, boolean flag) { this.number = number; this.flag = flag; } long number = null; // valider Wert bei Erzeugung gesetzt boolean flag = true; public Action withNumber(long number) { // primitiver Typ, um valide Werte zu forcieren this.number = number; return this; } public Action withFlag(boolean flag) { this.flag = flag; return this; } public void invoke() { performAction(this.number, this.flag); } } |
Beim Aufruf sieht man von diesen Klassen erst einmal nichts, solange man die Methodenfolge nicht unterbricht.
1 2 3 4 5 6 7 |
service.performAction() // -> InvalidAction .withFlag(true) // -> InvalidAction, .invoke() führt zu Kompiler-Fehler .withNumber(50L) // -> Action .invoke(); // ist erst hier möglich |
Leider hat diese Vorgehensweise sehr starke Einschränkungen und weitere Nachteile.
Einschränkungen und Nachteile des Fluent Interface mit Grammatik
Der große Vorteil, dass nur Instanzen von validen Mediatoren eine .invoke() Methode haben, die also ansonsten gar nicht aufgerufen werden kann, und daher auch kein Validitätsprüfung zur Laufzeit durchführen muss, bringt mit sich, dass die Schnittstelle der Mediatoren sehr unflexibel wird und der innere Zustand der einzelnen Instanzen nicht zur Validierung beitragen kann. Effektiv bedeutet das, dass eine Transition immer nur zu einem Folgezustand führen kann, unabhängig davon, welcher Parameterwert der entsprechenden Methode eingegeben wird.
In diesem Beispiel muss ein invalidAction.withNumber(xx) immer zu einer validen Action führen. Sollte es fachliche Anforderungen an den Wert geben, z.B. nur positive Werte zu akzeptieren, ist diese Fallunterscheidung schlicht nicht möglich denn der Rückgabetyp der Methode ist als valide Action vorgegeben. Viel schwerwiegender als dieses Szenario ist die Tatsache, dass sämtliche Pflichtparameter mit Objekt-Typen diesen Ansatz unmöglich machen, weil nicht verhindert werden kann, dass die Mutatoren mit Null-Werten aufgerufen werden.
Klassenbasierte Validierung hat ihren Preis
Wie Eingangs erwähnt zeigt sich der praktische Nutzen des Konzepts erst wirklich, wenn man es mit einer größeren Anzahl von Parametern zu tun hat. Soll die Möglichkeit der beliebigen Reihenfolge bei der Parametereingabe aufrecht erhalten werden, wächst der Zustandsraum des Automaten sehr schnell, da für jede Permutation aller Pflichtparameter ein eigener Mediator angelegt werden muss. Die Mutatoren aller optionalen Parameter müssen dann an all diesen Klassen dupliziert werden. Eine Vererbung dieser gemeinsamen Methoden ist zwar möglich aber im Kontext eines Fluent Interface mit zusätzlicher Komplexität verbunden, wie ich in einem weiteren Artikel genauer ausführen werde.
Die Menge der anzulegenden Mediatoren kann auf eine lineare Entwicklung mit der Parameteranzahl reduziert werden, wenn man die Reihenfolge der Parametereingabe auf einen konkreten Pfad durch den Zustandsgraphen beschränkt. Dadurch geht aber wieder ein Teil der ursprünglich angestrebten Flexibilität verloren. Eine klassenbasierte Validierung ist also nur in einigen Fällen möglich und auch dann sollten Kosten und Nutzen gut abgewogen werden.
Solange die Validierung zur Laufzeit ausreicht, bietet ein Fluent Interface jedoch wie gezeigt eine gute Möglichkeit, sich dem Konzept der Named Parameters in Java anzunähern, wenn man darauf nicht verzichten möchte.
We’re hiring!
Tapetenwechsel gefällig? Wir sind auf der Suche nach begeisterten Software-Entwicklern, die uns im Umfeld von Java, .NET und JavaScript unterstützen und auch von extravaganteren Sprachen wie Go, Elixir und Clojure nicht zurückschrecken. Jetzt Bewerben!
Weiterlesen
Weitere Information zu unseren Leistungen gibt es auf unserer Website, der direkte Kontakt zu uns ist telefonisch unter +49 721 619 021-0 oder jederzeit per E-Mail an list-blog@inovex.de möglich.
One thought on “Named Parameters in Java mit Fluent Interfaces: Eine Annäherung”