Das Builder Pattern [Teil 2]

Gepostet am: 15. September 2016

Von

Im ersten Teil dieser Artikelserie wurden das Builder Pattern selbst und ein paar typische Anwendungsfälle beschrieben. Nun stelle ich hier einige Varianten für die Implementierung eines Builders und seiner einzelnen Funktionsaspekte vor. Manche davon wurden bereits kurz angesprochen und sollen jetzt genauer erläutert werden. Es wird jeweils mit einem kleinen Beispiel dargestellt, wie der Builder verwendet wird und welche Implementierung das ermöglicht.

Method-Chaining

Ein Hauptgrund für die komfortable Verwendbarkeit des Builders ist der Einsatz von Method Chaining. Dabei liefern die Mutatoren jeweils wieder die Referenz auf den Builder zurück, wodurch sich mit einer Verkettung von Aufrufen die Spezifikation und Erzeugung der Product-Instanz im Quelltext sehr gut lesbar und nachvollziehbar darstellen lässt. Da hier immer dieselbe Builder-Instanz zurückgeliefert wird, stellt diese Art von Method-Chaining eine Umsetzung des Fluent Interface ohne Grammatik dar. In einem früheren Artikel beschreibe ich weiterführende Möglichkeiten, einen Builder mit Fluent Interface nutzbringend einzusetzen.

Test-DSL

Gerade die Lesbarkeit von Code ist in Testklassen von zentraler Bedeutung, damit die Tests einen Mehrwert als Anforderungsbeschreibung für den Produktiv-Code haben. In Clean Code wird empfohlen, die technische Implementierung von Datenaufbau und Ergebnisprüfung durch entsprechend benannte Hilfsmethoden u.ä. hinter einer DSL (Domain Specific Language) zu verbergen. Der Einsatz eines Fluent Interface im Builder und der Einsatz von Factory-Methoden für Builder-Instanzen mit vorbefüllten Standardwerten bieten hierfür optimale Voraussetzungen.

Kaskadierung

Das deklarative Erstellen von Testdaten gibt dem Leser eines Testfalls einen guten Überblick, mit welcher Eingabe die getestete Funktion gerade aufgerufen wird.

Gerade beim Erstellen von hierarchischen Objektstrukturen bietet die Verwendung eines Fluent Interface für die beteiligten Builder den Vorteil, die Struktur der Daten beim Aufbau klar sichtbar zu machen. Dabei kann die Mutator-Methode der aggregierenden Klasse (hier z.B. withItems()) wahlweise ein Item-Objekt oder auch ein Item.Builder-Objekt akzeptieren, welches dann intern erst zu einem Item aufgelöst wird.

Es ist natürlich auch möglich, die Erzeugung der Items bis zum Aufruf der build()-Methode des ProcessData.Builder zu verzögern, wenn man vorher die Item.Builder intern ansammelt. Gerade wenn für singuläre Attribute Builder-Instanzen akzeptiert wurden, würde dann nur vom zuletzt gesetzten Builder ein Objekt erzeugt.

Pflichtfelder

Die Verwendung eines Builders ist besonders hilfreich, wenn das Product viele optionale Attribute hat. Aber auch die Pflichtfelder müssen adäquat behandelt werden. Je nach den fachlichen Anforderungen kann es sinnvoll sein, einen leeren Builder zu erzeugen und im Laufe von mehreren Prozessen die notwendigen Attributwerte anzusammeln. Vor der Erzeugung des Product sollte dann die Vollständigkeit und Konsistenz validiert werden.

Ist es aber leicht möglich, zunächst alle Pflichtfeldwerte zu bestimmen, so liegt es nahe, diese auch für die Erzeugung des Builders bereits verpflichtend einzufordern. Dieser ist danach sofort in der Lage, valide Product-Instanzen zu erzeugen, kann aber vorher noch mit optionalen Werten angereichert werden.

Sollen statische Factory-Methoden zur Erzeugung von Builder-Instanzen benutzt werden, um die Lesbarkeit zu erhöhen, müssen die Pflichtfelder darin entsprechend durchgereicht werden. Gegebenenfalls sollte der Methodenname dann darauf hinweisen, für welche Attribute die Werte bestimmt sind, was bei Verwendung des Builder-Konstruktors wiederum nicht möglich wäre.

Sammeln der Attributwerte und Erzeugen der Instanz

Ein Builder hat die Aufgabe, Attributwerte zu sammeln und zu gegebener Zeit mit den aktuell gesetzten Werten eine Product-Instanz zu erzeugen. Dies ist intern wieder auf unterschiedliche Weise möglich, allerdings sollten dabei die Bedingungen erfüllt werden, die in Teil eins des Artikels im Abschnitt „Implementierung“ genannt wurden. Insbesondere Punkt 3), die Unabhängigkeit der erzeugten Instanzen, soll mit dem folgenden Beispiel erläutert werden.

Erzeugung unabhängiger Instanzen

Würde der Builder in seiner build()-Methode nicht jedes mal eine neue Item-Instanz erzeugen sondern bei jedem Aufruf immer die selbe Instanz zurück liefern, an der nur die aktuellen Attribute geändert wurden, so würde der oben gezeigte Prozess potentiell zu ungewollten Folgen führen. Nach mehrmaligem Aufruf von build() wären also mehrere Referenzen auf dieselbe Item-Instanz in Umlauf. Die Attribute dieser Instanz würden mit jedem Aufruf am Builder durch die letzten gesammelten Attribute überschrieben. Außerdem würden alle Prozesse, die Änderungen an ihrer Item-Instanz vornehmen, diese Werte ebenfalls für alle anderen Prozesse überschreiben. Es ist also unbedingt notwendig, bei jedem Aufruf an build() eine neue Instanz zu erzeugen, oder auf andere Weise sicherzustellen, dass build() nur ein einziges Mal aufgerufen werden kann.

Interne Kontainer-Instanz

Und tatsächlich ist es gar nicht so abwegig, schon vor Aufruf der build()-Methode die Product-Instanz bereits erzeugt zu haben. Denn wie in Teil eins abschließend erwähnt wird, ist die Duplikation der Attributfelder das größte Manko bei der Arbeit mit einem Builder. Um in der Builder-Klasse nicht jedes Attribut erneut definieren zu müssen, kann also einfach eine interne Product-Instanz angelegt werden, deren Attribute beim Sammeln am Builder direkt gesetzt werden.

Wie im letzten Abschnitt allerdings festgestellt wurde, kann in der build()-Methode nicht einfach diese vorhandene Instanz an den Aufrufer herausgegeben werden, weil ja irgendwie eine neue Instanz ins Spiel kommen muss. Und die interne Instanz einfach durch eine neue zu ersetzen würde semantisch zu einem vollständigen Zurücksetzen des Builders führen, was von außen nicht erkennbar ist. Es wird also eine Klon-Funktion benötigt, um eine Product-Instanz mit ihren Attributwerten zu kopieren.

Wo die Variante mit eigenen Attributen im Builder zu Duplikation führt, da entsteht durch die Verwendung einer internen Instanz eine stärkere technische Abhängigkeit des Builders von der Product-Implementierung. Gerade Seiteneffekte bei den Mutatoren führen zu Problemen, die Attribute müssen für das Klonen später wieder unverändert lesbar sein oder es muss außerhalb eine clone()-Methode verfügbar sein. Und generell können Änderungen an der Product-Klasse leichter zu Funktionsfehlern im Builder führen. Persönlich würde ich eher die Duplikation der Attribute empfehlen, insbesondere wenn dies automatisiert geschieht.

Setzen der Attributwerte

Unabhängig von der gewählten Variante gibt es immer einen Zeitpunkt, an dem der Builder die Attributwerte an der Product-Instanz setzt. Auch hier gibt es wieder mehrere Optionen, die größtenteils von der Implementierung des Product und insbesondere der Sichtbarkeit der jeweiligen Felder und Methoden abhängig sind.

Es kann ein Konstruktor verwendet werden, der gegebenenfalls manche oder alle Attribute entgegennimmt. Die Nutzung von Mutator-Methoden, hoffentlich ohne Seiteneffekte, ist sehr gängig und bei entsprechender Sichtbarkeit kann auf die Felder auch direkt zugegriffen werden.

Gerade dann, wenn dem Builder andere Möglichkeiten zur Instanziierung eröffnet werden sollen als sonstigen Aufrufern, ist die Platzierung der Builder-Klasse relativ zum Product besonders zu beachten.

Platzierung des Builder

Ein Builder wird immer eine starke semantische Abhängigkeit von seinem Product aufweisen, immerhin soll er mit der Instanzerzeugung ja einen wesentlichen Teil von dessen Lebenszyklus erbringen bzw. unterstützen. Im Mindesten muss er die Klasse erzeugen und alle Attribute verarbeiten können, die in seinen Anwendungsfällen relevant sind.

Unabhängiger Builder

Die loseste Bindung zwischen den beiden Klassen ergibt sich wohl, wenn ein Builder implementiert wird, um komfortabel Instanzen für Tests erzeugen zu können. Die Builder-Klasse kann dafür im Test-Scope in einem beliebigen Package untergebracht werden, solange sie die Product-Klasse von dort importieren, instanziieren und initialisieren kann. Bei der Auslieferung des Systems ist der Builder dann gar nicht enthalten. Statische Factory-Methoden sollten am Builder selbst platziert werden, weil es sonst zu einer zyklischen Abhängigkeit zwischen den beiden Klassen kommt.

Benachbarter Builder

Allerdings passiert es gerade bei der testgetriebenen Entwicklung nicht selten, dass der Builder auch bei der Implementierung der eigentlichen Prozesse ganz nützlich erscheint und daher in den Produktions-Scope verschoben wird. Liegen die Klassen an sehr unterschiedlichen Orten, kann es passieren, dass bei Änderungen des Product die Implementierung des Builders nicht nachgezogen wird. Daher sollten die Klassen einigermaßen nahe beieinander untergebracht werden.

Innerer Builder

Soll es aber nur dem Builder möglich sein, Instanzen des Product zu erzeugen oder schreibend auf dessen Attribute zuzugreifen, dann ist der Builder als innere Klasse des Product zu implementieren. Auf diese Weise können unveränderliche Klassen komfortabel erzeugt oder sonstige Validitätsbedingungen forciert werden. Hierdurch kommt es zu einer wesentlich höheren Kohäsion der beiden Klassen und es können unter Umständen auch unnötige Mutatoren in der Product-Klasse eingespart werden.

Empfehlungen

Persönlich finde ich es sinnvoll, den Builder als Top-Level Klasse anzulegen, sofern das möglich ist. Entweder im selben Package oder in einem .builder Subpackage. Wenn er nur in Tests verwendet wird, sollte er auch nur dort sichtbar sein. Nur bei einer notwendigen sehr hohen Bindung ist das nicht möglich.

Die Gefahr, dass Änderungen am Product im Builder nicht nachvollzogen werden, schätze ich als relativ gering ein. Immerhin soll die neue Funktionalität ja auch benutzt und getestet werden. Und wenn dafür dann keine Anpassung im Builder nötig war, dann war es wohl für die Anwendungsfälle des Builders im System nicht relevant.

Join us!

Wir sind auf der Suche nach talentierten Software-Entwicklern mit Schwerpunkt Java, .NET und JavaScript, die auch vor neueren Programmiersprachen wie Clojure und Go nicht zurückschrecken. Offene Stellen gibt es in Vollzeit, als Werkstudent*in oder Praktikant*in.

2017-11-27T13:49:35+00:00