Buildervererbung (Draft)

Aus MimiPedia

Das Problem der Builder-Vererbung

Wenn die Objekte einer Klasse A durch den Builder BuilderA erzeigt werden, ist es naheliegend, die Objekte der abgeleiteten Klasse B durch den von BuilderA abgeleiteten Builder BuilderB zu erzeugen -- warum? Betrachten wir die Klassen

public class Foo {
    int x;
}
public class Bar extends Foo {
    int y;
}

Jeder Builder einer der beiden Klassen wird einen Modifier für das Feld x benötigen. Es liegt also nahe, einen Builder FooBuilder zu definieren von dem der Builder BarBuilder ableitet. Der erste Entwurf dafür sähe so aus:

public class FooBuilder {
    Foo obj;
    FooBuilder withX(int x) {
        obj.x = x;
        return this;
    }
    FooBuilder get() {
        return obj;
    }
}

Den Getter in BarBuilder zu überschreiben stellt kein Problem dar, da get stets die letzte Methode im Builder-Aufruf ist. Was aber geschieht, wenn wir die Methode withX auf den gedachten BarBuilder aufrufen wir erhalten eine FooBuilder-Instanz als Ergebnis und können keine BarBuilder-Methoden mehr aufrufen. Das führt und zum ersten Entwurf:

Erster Entwurf

Wir müssen also dafür sorgen, daß die Modifier-Methoden des abgeleiteten Builder die richtigen Objekte liefern. Dafür parametrisieren wir den FooBuilder und geben ihm als Typ-Parameter den entsprechenden -- abgeleiteten -- Builder mit:

public class FooBuilder<B extends FooBuilder<?>> {
    Foo obj;
    B withX(int x) {
        obj.x = x;
        return (B)this;
    }
    Foo get() {
        return obj;
    }
}

Damit withX den richtigen Builder-Typ liefert, muß der return-Wert gecastet werden. Unsere Lösung hat einen kleinen Haken. Der Compiler wird sich über den Cast (B)this beschweren, weil hier in ungeprüfter Cast stattfindet. Wir werden uns das später anschauen.

Der erste Entwurf des BarBuilder sieht -- noch ziemlich ungelenk -- sieht nun wie folgt aus. Das Generat wird hier der Bequemlichkeit halber in einem initialisierungs-Block erzeugt. Die Generat-Erzeugung wird noch zu diskutieren sein.

public class BarBuilder extends FooBuilder<BarBuilder> {
    {
        obj = new Bar();
    }
    BarBuilder withY(int y) {
        ((Bar)obj).y = y;
        return this;
    }
    @Override
    Bar get() {
        return (Bar)obj;
    }
}

Die Häßlichkeit entspringt der Tatsache, daß wir das Genrat nicht parametrisiert haben. Der Builder wird zwar funktionieren, aber schön ist was anderes. Da das foo-Feld im FooBuilder als Foo-Typ deklaiert wurde, können wir zwar Bar-Objekte hineinfüllen, müssen sie aber bei jeder Gelegenheit casten -- igitt!

Zweiter Entwurf

Die Lösung führt über die Parametrisierung des Generat-Typs:

public class FooBuilder<T extends Foo, B extends FooBuilder<T, B>> {
    T obj;
    B withX(int x) {
        obj.x = x;
        return (B) this;
    }
    T get() {
        return obj;
    }
}

Im ersten Entwurf mußte der BarBuilder die get-Methode überschreiben um sicherzustellen, daß der Builder auch ein Bar-Objekt liefert. Zur Laufzeit tat er das natürlich immer, tatsächlich geht es darum, auch zur Compile-Zeit sicherzustellen daß der Compiler mit dem richtigen Typ arbeiten kann. Der BarBuilder sieht nun so aus:

public class BarBuilder extends FooBuilder<Bar, BarBuilder> {
    {
        obj = new Bar();
    }
    BarBuilder withY(int y) {
        obj.y = y;
        return this;
    }
}

Das Feld obj hat nun für den Compiler in jedem Fall en richtigen Typ und auch der Builder-Typ wird in jedem Falle korrekt bestimmt.

Der Fall des unerwünschten B-Cast

Warum beschwert sich der Compiler nun über den Cast in der Modifier-Methode:

public class FooBuilder<T extends Foo, B extends FooBuilder<T, B>> {
    ...
    B withX(int x) {
        obj.x = x;
        return (B) this;
    }
    ...

Die Klasse FooBuilder vertraut hier darauf, daß die abgeleitete Klasse tatsächlich vom Typ B ist. Kann das garantiert werden? Nein. Wenn wir einen dritten Builder bauen:

public class BadBuilder extends FooBuilder<Bar, BarBuilder>

Dann wird die Methode withX versuchen this -- das vom Typ BadBuilder ist -- nach BarBuilder zu casten und kläglich scheitern...

Der Designer muß selbst entscheiden, ob das tatsächlich ein Problem ist. Wie sinnvoll ist es für einen Builder eine Ableitung zu machen wie oben gezeigt? Es bliebe zu zeigen, ob man damit tatsächlich eine sinnvolle implementierung konstruieren kann.

Wenn es nur um die drohende Class Cast Exception geht, kann man dem entgegenhalten: Man geht mit dem Cast das Risiko ein und kann es zur Compile-Zeit nicht verhindern -- na und? Spätestens bei der ersten Ausführung -- die natürlich im Unit-Tests stattfindet -- hat man die Exception und kann sie fixen. In aller Regel wird es sich ja wohl um einen Tippfehler handeln.

Wenn das nun tatsächlich absolut kein Zustand ist, kann man natürlich was dagegen tun. Man fügt dem FooBuilder einfach eine abstrakte Methode hinzu:

public abstract class FooBuilder<T extends Foo, B extends FooBuilder<T, B>> {
    T obj;
    B withX(int x) {
        obj.x = x;
        return getThis();
    }
    T get() {
        return obj;
    }
    abstract B getThis();
}

Jetzt kann nichts mehr schief gehen. Jede Klasse, die den Builder ableitet muß getThis so überschreiben, daß die Methode Objekte des richtigen Typs liefern muß.

Generat-Erzeugung

Was in den vorangegangenen Beispielen noch ausgesprochen krude aussieht, ist die Erzeugung des Generats, warum haben wir es in einem Initialisierungs-Block erzeugt und warum nicht in der FooBuilder-Klasse.

Wie in vorangegangenen Abschnitten beschrieben, kann man das Generat-Objekt zu jeder beliebigen Zeit erzeugen. In der Regel erzeiugt man es direkt bei Erzeugung des Builders oder bei Aufruf der Generat-Methode am Ende. Aber im Prinzip kann man das Generat zu jedem Zeitpunkt erzeugen während der Lebenszeit des Builders.

Man kann die Erzeugung in der Superklasse durchführen, also im FooBuilder. Da es aber nicht möglich ist, aus dem generischen Typ ein reales Objekt zu erzeugen, muß man auch hier wieder auf eine abstrakte Methode ausweichen:

abstract T create();

Damit kann man im FooBuilder das Objekt zu jeder beliebigen Zeit erzeugen. Daß man sich damit für alle abgeleiteten Builder festlegt, ist dabei in der Regel kein Problem. Das verzögerte Erzeugen erfordert die Pufferung der Eingaben, das läßt sich in der Regel nicht in de abgeleiteten Buildern nachträglich einführen.

Der Initialisierungs-Block in den Beispielen dient und nur als Platzhalter. Wir können jedes beliebige Konzept einsetzen das wir für sinnvoll halten, Generat-Erzeugung

  • Im Initialisierungsblock
  • Im Konstruktor
  • In statischen Factory-Methoden
  • In der Generat-Funktion
  • An jeder anderen Stelle

Es empfiehlt sich in jedem Falle ein Konzept durch die gesamte Builder-Hierarchie zu verwenden. In der Regel wird man nur die "Blatt-Builer" verwenden, also Builder von denen keine anderen Builder abgeleitet sind. Es empfiehlt sich, die anderen Klassen -- btw. ihre Konstruktoren -- gegen ungewollte oder unerwünschte Benutzung abzusichern, indem man sie protected deklariert.