Das "sexy" Builder-Pattern
Wer für längere Zeit mit objektorientierten Programmiersprachen (OOP) gearbeitet hat, sollte irgendwann mal “Design Patterns” über den Weg gelaufen sein; sowohl dem Konzept als auch dem Buch:
In diesem Buch gibt es sehr viele Entwurfsmuster. Manche benutzt man häufiger, wie z.B. Factory, Strategy oder Iterator, und manche weniger, ich persönlich würde Flyweight und Memento dazuzählen.
Heute geht es jedoch um mein absolutes Lieblingspattern, den Builder, und darum, wie man ihn noch ein kleines bisschen besser machen kann.
Was ist ein Builder?
Um festzustellen, wie man einen Builder erst richtig schön machen kann, muss man erstmal wissen, was ein normaler Builder kann und was nicht.
(Für die folgenden Codebeispiele werde ich Java
nutzen, ich bin mir jedoch sicher, dass jeder, der sich einigermaßen wohl mit C++
, C#
, php
, oder einer anderen OOP fühlt, hier zurechtfinden wird.)
Das Problem
Kennst du das, wenn du ein Objekt erstellen willst, aber der Konstruktionsprozess etwas messy wird? Stellen wir uns vor, wir möchten eine Klasse schreiben, welche die Änderung an einer Spalte in einer SQL-Datenbank darstellt:
public class SQLColumn<T extends DataType> {
public SQLColumn(
String name,
boolean allowNulls,
Class<T> dataType,
T defaultValue,
int length,
boolean incrementing,
boolean isForeignKey,
boolean hasIndex
) { /* ... */ }
...
Dieser Konstruktor ist jetzt schon recht groß und wir haben nicht einmal ansatzweise alle Konfigurationsmöglichkeiten abgedeckt. Hinzu kommt, dass aus vielen Kombinationen häufig Werte implizit hervorgehen, und dass man andere gar nicht braucht.
(length
und incrementing
sind z.B. gegenseitig exklusiv, da man das Erste nur auf Text und das Zweite nur auf
Zahlen anwendet.)
Man könnte an dieser Stelle den Konstruktor überladen, oder, wie der Fachmann sagen würde: “Einen telescoping Constructor erstellen”, das klingt allerdings deutlich zu fancy für das, was es am Ende macht.
Angenommen, wir hätten jetzt noch einen extra Konstruktor für Zahlen …
public static final int NO_LENGTH = -1;
public SQLColumn(
String name,
boolean allowNulls,
Class<T> dataType,
T defaultValue,
// length ausgelassen
boolean incrementing,
boolean isForeignKey,
boolean hasIndex
) { this(name, allowNulls,
dataType, defaultValue,
NO_LENGTH,
incrementing, isForeignKey, hasIndex);
}
… und einen für Texte …
public SQLColumn(
String name,
boolean allowNulls,
Class<T> dataType,
T defaultValue,
int length,
// incrementing ausgelassen
boolean isForeignKey,
boolean hasIndex
) { this(name, allowNulls,
dataType, defaultValue,
length,
false, isForeignKey, hasIndex);
}
… dann wäre es für unsere Klasse sicher nicht schwer, bei einer tatsächlich vollständigen Implementierung die 5.000 Zeilen alleine nur mit Konstruktoren zu knacken.
Auch das Aufrufen wäre nicht angenehm, da Konstruktoren keine Namen haben und man so anhand der Parametertypen und Reihenfolge raten müsste, was sie tatsächlich machen. Wirklich nicht schön.
Der gängige Builder
Normalerweise würde man sich jetzt taktisch Standardwerte ausdenken und einen Builder schreiben, mit dem man die Spalten inkrementell zusammenbauen kann. Das könnte dann so aussehen:
new SQLColumnBuilder()
.name("id")
.dataType(BigInteger.class, BigInteger.ZERO)
.incrementing()
.hasIndex()
.build();
… normalerweise würde man es jetzt aber auch akzeptieren, dass ein Anwender mehrfach die name()
-Methode aufruft, da jede Funktion im Builder dasselbe Objekt zurückgibt und sich nicht unbedingt merkt, was er schon gemacht hat.
Ein Builder hat nämlich nun mal traditionell die Form:
class SomeBuilder {
SomeBuilder() { /* ... */ }
SomeBuilder a() { /* ... */ }
SomeBuilder b() { /* ... */ }
SomeBuilder c() { /* ... */ }
Something build() { /* ... */ }
}
… und die lässt keine bestimmten Pfade und beliebige Wiederholung zu.
Es wird halt häufig darauf vertraut, dass man den Builder intuitiv “richtig” bedient, da er ja so schön lesbar ist, aber das ist meines Erachtens nicht immer sinnvoll.
Ein Realbeispiel für so ein Designproblem ist der
java.net.HttpRequest.Builder
aus Java 11, mit dem man z.B. so etwas anstellen kann:
var request = HttpRequest.newBuilder(URI.create("https://xtay2.com"))
.uri(URI.create("https://neDochNicht.site")) // Uri wurde schon gesetzt
// Zwei Funktionen zum Setzen von Headern
.setHeader("Authorization", "") // Akzeptieren von explizit leeren Headern
.headers("X-Trace-Id", UUID.randomUUID().toString())
.DELETE()
.GET() // Ändern der Request Method, nachdem sie auf DELETE gesetzt wurde
// Beliebiges Setzen von bool-Feld,
// ohne das klar ist, was der default-case ist
.expectContinue(true)
.expectContinue(false) // Auch das kann geändert werden.
.build();
(Dieser Code gibt weder Compiler- noch Runtime-Fehler)
Natürlich ist auch ein Builder nur ein Mittel zum Zweck, wenn die erste Prämisse jedoch clean Code ist, kann man sich noch etwas mehr Mühe geben.
Der segmentierte Builder
Der SegmentedEXpression… Yeah - Builder, (an dem Akronym arbeite ich noch), ist ein Designkonzept, mit welchem der Builder zu einem noch expressiveren Werkzeug werden kann. Die Geheimzutat heißt: interface
.
Wir nehmen aus Spaß nochmal den SQLColumnBuilder
, dieses Mal spezifizieren wir ihn allerdings in einem Interface.
interface SQLColumnBuilder<T extends DataType> {
Jede Spalte in einer Datenbank hat einen Namen und einen Datentypen. Diese beiden Werte können wir also durch eine statische Factory erzwingen:
static SQLColumnBuilder<T> newInstance(String name, Class<T> dataType) { ... }
Ebenfalls kann jede Spalte einen Default-Wert haben oder nullable sein (Dann braucht sie keinen expliziten default value). Der Trick ist es jetzt, eine “Builder-Stage” zurückzugeben, in der das Thema Default-Value geklärt ist.
// Eigene Builder Stage für default-Values
interface SQLColumnBuilder1<T>
extends SQLColumnBuilder2<T> { // Stage1 kann übersprungen werden
SQLColumnBuilder2 defaultVal(T o);
}
// Eigene Builder Stage für nullability-Constraints
interface SQLColumnBuilder2<T> {
SQLColumnBuilder3 nullable();
SQLColumnBuilder3 nonNull();
}
// Spätestens hier ist ein default-Value definiert oder null
interface SQLColumnBuilder3<T> {
...
Und schon ist es nur noch möglich, diese vier Wege einzuschlagen …
newInstance(name, defaultCls).nonNull() ...
newInstance(name, defaultCls).nullable() ...
newInstance(name, defaultCls).defaultVal(someVal).nonNull() ...
newInstance(name, defaultCls).defaultVal(someVal).nullable() ...
… während die folgenden Kriegsverbrechen verboten sind:
// Wiederholen von Calls die einmalig sein sollten
newInstance(name, defaultCls).nonNull().nonNull().nonNull() ...
// Setzen von sich wiedersprechenden Builder-Methods
newInstance(name, defaultCls).nonNull().nullable() ...
// Vollständiges Weglassen von nullability constraint
newInstance(name, defaultCls).somethingElse().build();
Fazit
Auch wenn das Naming besser als Builder1, Builder2, Builder3, … sein sollte, glaube ich, das Argument, dass man die größten Verwirrungen, die für den Anwender des Builders existieren, mit ein paar gut platzierten Interfaces ausmerzen kann, steht. Das Beste ist übrigens: Man muss ja nicht mal für jedes Interface eine eigene Klasse schreiben. Es reicht, eine Builderklasse zu entwickeln, die einfach alle Interfaces implementiert. Die Reihenfolge wird durch das upcasting der Rückgabewerte der einzelnen Methoden eingehalten:
class SomeBuilder implements BuilderStage1, BuilderStage2, BuilderStage3 {
// Privater Konstruktor
private SomeBuilder() { /* ... */ }
// Static Factory, gecasted zu erster Stage
static BuilderStage1 a() { /* ... */ return new SomeBuilder(); }
// Jeweils nächste Stage zurückgeben
BuilderStage2 b() { /* ... */ return this; }
BuilderStage3 c() { /* ... */ return this; }
Something build() { /* ... */ return result; }
}