Builder:Komponenten

Aus MimiPedia

Komponenten

Die Anwendung eines Buildes im Code erfolgt stets in drei Schritten, auch wenn diese nicht immer unmittelbar hintereinander ausgeführt werden müssen:

  1. Erzeugen des Builders
  2. Konfiguration oder Manipulation des vom Builder zu erzeugenden Objektes
  3. Herausgabe des gewünschten Objekts

Auch wenn es Variationen darüber giebt was die einzelnen Methoden des Builders tatsächlich tun, giebt es zu jeder der genannten Schritte auch eine Gruppe von Methoden die zur Ausführung des Schrittes beitragen. Wir haben damit drei Gruppen von Methoden:

  1. Creator-Methoden zur Erzeugung
  2. Modifier-Methoden zur Manipulation
  3. Generator-Methoden zur Auslieferung des Ergebnisses

Während man oft nur eine Erzeugungs- und in der Regel nur eine Methode zur Auslieferung des Objekts benötigt, hat man üblicherweise einige oder viele Konfigurations-Methoden. Der Begriff "Generator"-Methode ist dabei diskutabel, weil das Objekt nicht notwendigerweise in der "Generator"-Methode erzeugt werden muß.

Builder erzeugen mit dem Creator

Um mit einem Builder arbeiten zu können, muß man zunächst ein passendes Objekt erzeugen. Dazu hat man in Java zwei Möglichkeiten: Mit einem passenden Konstruktor einerseits oder einer (in der Regel statischen) Factory Methode -- die natürlich ihrerseits einen Konstruktor aufruft -- andererseits.

Offensichtlich braucht man in jedem Fall einen Konstruktor, es geht also eher um die Frage ob man diesen Konstruktor direkt oder über eine geeignete Methode zugänglich macht.

Konstruktor

Die Konstruktor-Variante liegt den meisten Entwicklern wohl am nächsten. Dabei wird für jede Art der Erzeugung ein Konstruktor definiert -- maximal einen ohne und beliebig viele mit Parametern. Da die Befüllung in der Regel über Modifier-Methoden geschieht, steuert man mit den Konstruktor-Parametern seltener die Erzeugung des Builders selbst, oftmals aber die Initial-Befüllung.

Ein naheligendes Beispiel ist die Implementierung eines Konstruktors für die Erzeugung neuer Generate und eines Konstruktors zur Manipulation existierender Generate:

public class PersonBuilder {
   private Person person;
   public PersonBuilder() {
      person = new Person();
   }
   public PersonBuilder(Person person) {
      this.person = person;
   }
}

Man kann dem Konstruktor auch Werte zur Initial-Belegung mitgeben:

public class AmpelBuilder {
  private Ampel ampel = new Ampel();
  public AmpelBuilder(Color initial) {
     ampel.farbe = initial;
  }
  ...
}

Von dem Konzept, im Konstruktor alle nicht-optionalen Werte zu setzen wird an dieser Stelle allerdings abgeraten. Einerseits bietet es eine gewisse statische Sicherheit, führt aber in der Praxis zu immer häßlicheneren Konstruktoren. Besser lesbar ist immer die Variante, die Konsistenz des Generats -- oder die Möglichkeit der Erzeugung -- in der Generator-Methode zu prüfen.

Die Verwendung von Konstruktoren findet ihre Grenze da, wo sich die Konstruktoren nicht mehr anhand der Typen ihrer Parameter-Liste unterscheiden lassen. Das ist schlicht und ergreifend unmöglich zu implementieren.

Statische Methoden

Der Personen-Builder des vorangegangenen Absatzes würde mit statischen Methoden so aussehen:

public class PersonBuilder {
   private Person person;

   public static PersonBuilder empty() {
      return new PersonBuilder (new Person());
   }

   public static PersonBuilder use(Person person) {
      return new PersonBuilder (person);
   }

   private PersonBuilder(Person person) {
      this.person = person;
   }
}

Wir verwenden hier weiterhin den Konstruktor zur Initialisierung, machen ihn aber private und rufen ihn über die statischen Methoden auf. Das macht im vorliegenden Beispiel keinen großen Unterschied, sieht nur etwas anders aus.

Betrachten wir nochmal das Ampel-Beispiel von vorhin. Der Aufruf new AmpelBuilder(Color.BLUE) ist nun nicht besonders sinnvoll. Wollten wir das mit der Konstruktor-Lösung verhindern, blieben nicht viel Möglichkeiten. Wir könnten ein Enum definieren, dort nur die Ampel-Farben zulassen und einen Konstruktor mit einem entsprechenden Enum-Parameter definieren. Statische Methoden bieten eine weniger aufwändige Möglichkeit:

static AmpelBuilder rot() {
  return new AmpelBuilder(Color.RED);
}
static AmpelBuilder gelb() {
  return new AmpelBuilder(Color.YELLOW);
}
static AmpelBuilder gruen() {
  return new AmpelBuilder(Color.GREEN);
}

Der Konstruktor wird dann wieder private deklariert.

Wann wählt man was?

Wenn man die freie Wahl hat, ist die Entscheidung tatsächlich eine Geschmacksfrage. Wenn man mehr Creator-Methoden braucht als die Konstruktor-Lösung es hergiebt, giebt es eigentlich keine Wahl. Es sei denn, man bevorzugt seltsame Lösungen mit Selektor-Parametern.

Auf jeden Fall sollte man dabei aber die Parameter-Listen so kurz wie möglich halten. Und generische Parameter mit Typen wie int oder String sollten bei der Erzeugung nur dann verwendet werden, wenn tatsächlich alle Werte erlaubt sind. Wenn man etwa eine eMail-Adresse als String übergiebt, kann man dem Konstruktor nicht ansehen welche Bedeutung der übergeben String hat. Eine statische Methode könnte forMailAddress heißen.

Eine viel elegantere Lösung bietet ein eigener Datentyp "EmailAdresse", was mithilfe von record sehr schlank zu implementieren ist.

Das Generat definieren mit dem Modifier

Die Modifier-Methoden versorgen dem Builder mit Daten aus denen das Generat befüllt wird. Sie haben alle den selben Aufbau:

Builder methode(Typ wert) {
   // übernehme Daten aus "wert"
   return this;
}

Die Modifier-Methoden sollten einen einzelnen Parameter haben. In Ausnahmen kann man auch auf den Parameter verzichten. Mehr als einen Parameter sollte man aber nicht übergeben. Ausgenommen ist die Übergabe beliebig vieler Parameter -- die vararg-Form.

Wer unbedingt mehrere Parameter an die Modifier-Methode übergeben möchte, folge der "Clean Code"-Methode und definiere ein Parameter-Objekt, das die Werte in einem Objekt zusammenfaßt. Dafür kann man sehr gut einen Builder schreiben...

Einen Wert übernehmen

Im einfachsten Fall wird ein einzelner Wert übergeben. Die Methode sollte nicht setIrgendwas heißen, das giebt nur Verwirrung mit nicht-Builder-Klassen. Empfehlenswert ist das Präfix "with":

FooBuilder withName(String name) {
   foo.setName(name);
   return this;
}

Der Wert wird übernommen und in Gänze in das Ergebnis-Objekt übernommen. Sollen nur Teile des übergebenen Arguments verwendet werden, sollte man einen anderen Präfix wählen, zum Beispiel "use" oder "from" oder entsprechende Konstrukte. Hier soll nur der Name der übergebenen Person verwendet werden:

FooBuilder useForName(Person person) {
   foo.setName(person.getName());
   return this;
}

Auf Namenskonventionen wird im Anschluß eingegangen.

Werte aus einem Objekt extrahieren

Wenn nicht das gesamte übergebene Objekt verwendet werden soll sondern nur ein Teil davon, sollte sich das in der Benamung niederschlagen.

Soll etwa nur der Name einer person verwendet werden, kann man schreiben:

public FooBuilder usePersonForName(Person person) {
   foo.setName(person.getName());
   return this;
}

Man kann das verwendete Objekt im Namen auch weglassen und useForName schreiben. Werden generische Typen wie String oder Zahlen verwendet sollte man aber immer dazuschreiben als was der Parameter übergeben wird, denn aus dem Datentyp geht es nicht hervor.

werden mehrere Komponenten des übergebenen Objekts verwendet kann man gegebenenfalls einfach use schreiben. Dann muß aber aus dem Kontext bzw. im Team klar sein was da geschieht. Grundsätzlich gilt: So genau wie nötig, so allgemein wie möglich.

Generate anderer Builder

Möchte man Objekte mit dem Builder setzen, verwendet man nicht selten Objekte die andere Builder generieren. Zum Setzen der Adresse einer Person in der Klasse

public class Person {
   Adresse adresse;
}

kann man im Builder die Methode

public PersonBuilder with(Adresse adresse) {
    person.adresse = adresse;
    return this;
}

definieren und wie folgt aufrufen:

new PersonBuilder().with(new AdressBuilder().get());

Wenn man die Modifier-Methode mit dem Builder als Parameter-Typ definiert:

public PersonBuilder with(AdressBuilder adresse) {
    person.adresse = adresse.get();
    return this;
}

Sieht der Aufruf so aus:

new PersonBuilder().with(new AdressBuilder());

Man spart sich immerhin den Aufruf der Generator-Methode. Man schafft damit aber auch eine Möglichkeit für den einbettenden Builder, das übergeben Objekt zu modifizieren. Das ist etwa dann hilfreich, wenn das eingebettete Objekt eine Referenz über das einbettende Objekt haben soll (das Beispiel ist vielleicht nicht sehr sinnvoll):

public PersonBuilder with(AdressBuilder adresse) {
   adresse.setBewohner(person);
   person.adresse = adresse.get();
   return this;
}

Da wir hier nur eine Referenz setzen wollen, werden wir nicht die Generator-Methode des Personen-Builder aufrufen.

Boolean-Werte

Boolean-Parameter sind immer häßlich, weil die Bedeutung von true und false nur aus dem Kontext erschlossen werden kann. Alternativ kann man hier zwei parameterlose Modifier-Methoden anbieten -- eine für true und eine false. Ist der default-Wert klar, kann man auch auf eine der beiden Methoden verzichten:

public FooBuilder enabled() {
   foo.setEnabled(true);
   return this;
}
public FooBuilder disabled() {
   foo.setEnabled(false);
   return this;
}

Für einen Manipulator-Builder kann man auch eine "toggle"-Methode anbieten, die den aktuellen Wert negiert.

Sammlungen manipulieren

Enthält das Objekt eine Sammlung von anderen Objekten, benötigt man in der Regel eine Methode zum Hinzufügen einzelner Elemente. Niemals sollte man eine Collection zur Manipulation herausrücken, das bringt nur Probleme. Getter- und Setter für Collections anzubieten ist so ziemlich das Dümmste was man machen kann...

Unter Sammlung wollen wir hier jedes Konstrukt verstehen, das Objekte einer gemeinsamen Klasse zusammenfaßt. Also List-, Set-, Array-, und alle anderen Collection-Objekte sowie klassische Java-arrays und selbstgebaute Objekte.

Grundsätzlich lassen sich auch Methoden für das Entfernen von Listen-Elementen definieren, aber beim Zusammenbauen von Objekten braucht man das eher selten.

Als Präfix bietet sich hier "add" ganz von selbst an :-) Im folgenden Code-Schnipsel wird bei Bedarf auch die Collection selbst erzeugt. In der Regel ist es besser, sie bei der Objekt-Erzeugung selbst anzulegen.

public FooBuilder add(Adresse adresse) {
   if (foo.getAdressen() == null) {
      foo.setAdressen(new ArrayList<Adressen>());
   }
   foo.getAdressen().add(adresse);
   return this;
}

Eine Liste von Objekten läßt sich damit so hinzufügen:

adresssListe.stream().forEach(FooBuilder::add);

Bisweilen ist es aber eleganter eine Sammlung von Objekten komplett zu übergeben. Der Stream ist auch hier der Parameter-Typ der Wahl, weil sich jede Sammlung ohne overhead in einen Stream überführen läßt. Wir behalten in jedem Fall die add-Methode und fühen die Stream-Variante als convenience-Methode hinzu:

public FooBuilder add(Stream<Adresse> stream) {
   stream.forEach(this::add);
   return this;
}

Eine null-Prüfung sollte man bei Sammlungen eigentlich nicht brauchen. Sammlungen sollten niemals null sein, höchstens leer. Eine weitere valide Variante ist die Verwendung einer variablen Parameter-Liste. Auch sie läßt sich über einen stream verarbeiten:

public FooBuilder add(Adresse... adressen) {
   Arrays.stream(adressen).forEach(this::add);
   return this;
}

oder -- wenn wir die Stream-Variante ebenfalls implementiert haben:

public FooBuilder add(Adresse... adressen) {
   add(Arrays.stream(adressen));
   return this;
}

Das "convenience-Prinzip" hat den Vorteil, daß man die Hinzufügung -- und mögliche zugehörige Logik -- nur einmal implementieren muß. Gegebenenfalls muß man hier aber tatsächlich die Performance in Betracht ziehen. Die Abfrage ob das Listen-Objekt vorhanden ist etwa sollte man nicht bei jedem add ausführen.

zum Schluß sei noch die Variante angesprochen bei der der Inhalt der Sammlung ersetzt wird. Das kann zwar sinnvoll sein, ist aber mit Vorsicht zu genießen, weil dadurch vorangegangene Hinzufügungen Rückgängig gemacht werden. Das ist nicht intuitiv und kann leicht zu Fehlern führen:

public FooBuilder replace(Stream<Adresse> stream) {
   foo.setAdressen(stream.collect(Collectors.toList()));
   return this;
}

Namens-Konventionen

Es giebt keine kanonischen Naming-Regeln, man sollte sich daher darum bemühen eine -- zumindest im Team -- allgemeinverständlicher Nomenklatur zu schaffen.

Das empfohlene Namensschema in Java sieht für Methoden ein verbiales Konstrukt vor. Für Setter verwendet man daher den Imperativ des Verbs "to set" mit dem Namen des Atrtributs.

Da die Modifier-Methoden des Builders keine void-Methoden sind wie die Setter, sondern die Instanz des verwendeten Builders liefern, verbietet sich "set" als Präfix.

Eine Alternative ist die Verwendung von "with":

public FooBuilder withName(String name);

Das bedeutet eine Abweichung vom Verb-Schema, drückt aber aus was geschieht. Der Builder erhält dadurch einen eher statischen Charakter. Legt der typ des übergebenen Objekt fest -- oder zumindestes nahe -- was gesetzt wird, kann man den Attribut-Namen auch weglassen:

FooBuilder with(StatusEnum status);
FooBuilder with(Person person);

Hat das Foo-Objekt mehr als eine Person oder mehr als einen Status, kann man das Attribut natürlich nicht weglassen. Die Methode hieße dann aber eher withAntragsteller bzw. withVorherigerStatus oder so ähnlich.

Hier eine kleine Auflistung möglicher Präfixe:

add Hinzufügen von Elementen zu einer Sammlung
use Verwendung eines Objekts zur Manipulation
from Extraktion von Daten aus einem Objekt
for Verwendung des Parameters für einen bestimmten Zweck

Die Namenskonventionen sollten immer Gegenstand einer Team-Debatte sein.

Den Bau abschließen mit dem Generator

Ist die Konfiguration des Objekts abgeschlossen, kann es vom Benutzer abgeholt werden. Der Builder beitet dafür in der Regel eine einzige Methode an, die üblicherweise build oder kürzer get heißt. Der Name sollte im Projekt einheitlich gewählt werden. Wenn der Benutzer beim Aufruf der Generator-Methode entscheiden kann, welche Art Objekt er haben möchte, kann der Builder auch mehr als eine Generator-Methode anbieten.

Das Objekt wurde entweder schon früher erzeugt und von der Generator-Methode nur ausgeliefert oder es wird erst beim Aufrufen der Generator-Methode erzeugt. In der Generator-Methode kann auch eine Validierung durchgeführt werden. Schlägt diese fehl, sollte kein Objekt geliefert werden. Dann sollte entweder eine Exception fliegen oder das Objekt in ein Ergebnis-Objekt verpackt werden.

Streng genommen ist der raison d'être der Generator-Methode nicht das Erzeugen, sondern das Ausliefern. Wir bleiben trotzdem beim Ausdruck "Generator"-Methode. Auch wenn es nicht immer akkurat ist, ist die Vorstellung, daß die Genertor-Methode das gewünschte Objekt herstellt das vorher definiert wurde ein adäquates Denk-Muster.

Das Objekt sichern

Wenn das Objekt nicht erst in der Abschluß-Methode erzeugt wird, muß man dafür sorgen daß es vom Builder nicht verändert wird. Hält der Builder weiterhin die Referenz auf das erzeugte Objekt, verändert jeder Aufruf der with-Methode das erzeugte Objekt. Und ein wiederholter Aufruf der Abschluß-Methode liefert jedesmal das selbe Objekt.

Man kann im Team die Vereinbarung treffen, daß jedes Builder-Objekt nach Abholen des Objekts nicht mehr verwendet wird. Das schützt aber nicht vor unbeabsichtigten Fehlern. Die einfachste Sicherung sieht so aus:

Foo build() {
   result = this.foo;
   this.foo = null;
   return result;

Jeder Aufruf einer with-Methode führt nun zu einer NullpointerException. Wenn nötig, kann man die with-Methoden nun mit einer entsprechenden Abfrage sichern um eine schönere Exception zu erzeugen.