Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Generic fluent Builder in Java

I'm aware there've been similar questions. I haven't seen an answer to my question though.

I'll present what I want with some simplified code. Say I have a complex object, some of its values are generic:

public static class SomeObject<T, S> {
    public int number;
    public T singleGeneric;
    public List<S> listGeneric;

    public SomeObject(int number, T singleGeneric, List<S> listGeneric) {
        this.number = number;
        this.singleGeneric = singleGeneric;
        this.listGeneric = listGeneric;
    }
}

I'd like to construct it with fluent Builder syntax. I'd like to make it elegant though. I wish it worked like that:

SomeObject<String, Integer> works = new Builder() // not generic yet!
    .withNumber(4) 

    // and only here we get "lifted"; 
    // since now it's set on the Integer type for the list
    .withList(new ArrayList<Integer>()) 

    // and the decision to go with String type for the single value
    // is made here:
    .withTyped("something") 

    // we've gathered all the type info along the way
    .create();

No unsafe cast warnings, and no need to specify generic types upfront (at the top, where Builder is constructed).

Instead, we let the type info to flow in explicitly, further down the chain - along with withList and withTyped calls.

Now, what would be the most elegant way to achieve it?

I'm aware of the most common tricks, such as the use of recursive generics, but I toyed with it for a while and couldn't figure out how it applies to this use case.

Below is a mundane verbose solution which works in the sense of satisfying all requirements, but at the cost of great verbosity - it introduces four builders (unrelated in terms of inheritance), representing four possible combinations of T and S types being defined or not.

It does work, but that's hardly a version to be proud of, and unmaintainable if we expected more generic parameters than just two.

public static class Builder  {
    private int number;

    public Builder withNumber(int number) {
        this.number = number;
        return this;
    }

    public <T> TypedBuilder<T> withTyped(T t) {
        return new TypedBuilder<T>()
                .withNumber(this.number)
                .withTyped(t);
    }

    public <S> TypedListBuilder<S> withList(List<S> list) {
        return new TypedListBuilder<S>()
                .withNumber(number)
                .withList(list);
    }
}

public static class TypedListBuilder<S> {
    private int number;
    private List<S> list;

    public TypedListBuilder<S> withList(List<S> list) {
        this.list = list;
        return this;
    }

    public <T> TypedBothBuilder<T, S> withTyped(T t) {
        return new TypedBothBuilder<T, S>()
                .withList(list)
                .withNumber(number)
                .withTyped(t);
    }

    public TypedListBuilder<S> withNumber(int number) {
        this.number = number;
        return this;
    }
}

public static class TypedBothBuilder<T, S> {
    private int number;
    private List<S> list;
    private T typed;

    public TypedBothBuilder<T, S> withList(List<S> list) {
        this.list = list;
        return this;
    }

    public TypedBothBuilder<T, S> withTyped(T t) {
        this.typed = t;
        return this;
    }

    public TypedBothBuilder<T, S> withNumber(int number) {
        this.number = number;
        return this;
    }

    public SomeObject<T, S> create() {
        return new SomeObject<>(number, typed, list);
    }
}

public static class TypedBuilder<T> {
    private int number;
    private T typed;

    private Builder builder = new Builder();

    public TypedBuilder<T> withNumber(int value) {
        this.number = value;
        return this;
    }

    public TypedBuilder<T> withTyped(T t) {
        typed = t;
        return this;
    }

    public <S> TypedBothBuilder<T, S> withList(List<S> list) {
        return new TypedBothBuilder<T, S>()
                .withNumber(number)
                .withTyped(typed)
                .withList(list);
    }
}

Is there a more clever technique I could apply?

like image 294
Konrad Morawski Avatar asked Apr 21 '16 18:04

Konrad Morawski


1 Answers

Okay, so the more traditional step-builder approach would be something like this.

Unfortunately, because we're mixing generic and non-generic methods, we have to redeclare a lot of methods. I don't think there's a nice way around this.

The basic idea is just: define each step on an interface, then implement them all on the private class. We can do that with generic interfaces by inheriting from their raw types. It's ugly, but it works.

public interface NumberStep {
    NumberStep withNumber(int number);
}
public interface NeitherDoneStep extends NumberStep {
    @Override NeitherDoneStep withNumber(int number);
    <T> TypeDoneStep<T> withTyped(T type);
    <S> ListDoneStep<S> withList(List<S> list);
}
public interface TypeDoneStep<T> extends NumberStep {
    @Override TypeDoneStep<T> withNumber(int number);
    TypeDoneStep<T> withTyped(T type);
    <S> BothDoneStep<T, S> withList(List<S> list);
}
public interface ListDoneStep<S> extends NumberStep {
    @Override ListDoneStep<S> withNumber(int number);
    <T> BothDoneStep<T, S> withTyped(T type);
    ListDoneStep<S> withList(List<S> list);
}
public interface BothDoneStep<T, S> extends NumberStep {
    @Override BothDoneStep<T, S> withNumber(int number);
    BothDoneStep<T, S> withTyped(T type);
    BothDoneStep<T, S> withList(List<S> list);
    SomeObject<T, S> create();
}
@SuppressWarnings({"rawtypes","unchecked"})
private static final class BuilderImpl implements NeitherDoneStep, TypeDoneStep, ListDoneStep, BothDoneStep {
    private final int number;
    private final Object typed;
    private final List list;

    private BuilderImpl(int number, Object typed, List list) {
        this.number = number;
        this.typed  = typed;
        this.list   = list;
    }

    @Override
    public BuilderImpl withNumber(int number) {
        return new BuilderImpl(number, this.typed, this.list);
    }

    @Override
    public BuilderImpl withTyped(Object typed) {
        // we could return 'this' at the risk of heap pollution
        return new BuilderImpl(this.number, typed, this.list);
    }

    @Override
    public BuilderImpl withList(List list) {
        // we could return 'this' at the risk of heap pollution
        return new BuilderImpl(this.number, this.typed, list);
    }

    @Override
    public SomeObject create() {
        return new SomeObject(number, typed, list);
    }
}

// static factory
public static NeitherDoneStep builder() {
    return new BuilderImpl(0, null, null);
}

Since we don't want people accessing the ugly implementation, we make it private and make everyone go through a static method.

Otherwise it works pretty much the same as your own idea:

SomeObject<String, Integer> works =
    SomeObject.builder()
        .withNumber(4)
        .withList(new ArrayList<Integer>())
        .withTyped("something")
        .create();

// we could return 'this' at the risk of heap pollution

What is this about? Okay, so there's a problem in general here, and it's like this:

NeitherDoneStep step = SomeObject.builder();
BothDoneStep<String, Integer> both =
    step.withTyped("abc")
        .withList(Arrays.asList(123));
// setting 'typed' to an Integer when
// we already set it to a String
step.withTyped(123);
SomeObject<String, Integer> oops = both.create();

If we didn't create copies, we'd now have 123 masquerading around as a String.

(If you're only using the builder as the fluent set of calls, this can't happen.)

Although we don't need to make a copy for withNumber, I just went the extra step and made the builder immutable. We're creating more objects than we have to but there isn't really another good solution. If everyone is going to use the builder in the correct manner, then we could make it mutable and return this.


Since we're interested in novel generic solutions, here is a builder implementation in a single class.

The difference here is that we don't retain the types of typed and list if we invoke either of their setters a second time. This isn't really a drawback per se, it's just different I guess. It means that we can do this:

SomeObject<Long, String> =
    SomeObject.builder()
        .withType( new Integer(1) )
        .withList( Arrays.asList("abc","def") )
        .withType( new Long(1L) ) // <-- changing T here
        .create();
public static class OneBuilder<T, S> {
    private final int number;
    private final T typed;
    private final List<S> list;

    private OneBuilder(int number, T typed, List<S> list) {
        this.number = number;
        this.typed  = typed;
        this.list   = list;
    }

    public OneBuilder<T, S> withNumber(int number) {
        return new OneBuilder<T, S>(number, this.typed, this.list);
    }

    public <TR> OneBuilder<TR, S> withTyped(TR typed) {
        // we could return 'this' at the risk of heap pollution
        return new OneBuilder<TR, S>(this.number, typed, this.list);
    }

    public <SR> OneBuilder<T, SR> withList(List<SR> list) {
        // we could return 'this' at the risk of heap pollution
        return new OneBuilder<T, SR>(this.number, this.typed, list);
    }

    public SomeObject<T, S> create() {
        return new SomeObject<T, S>(number, typed, list);
    }
}

// As a side note,
// we could return e.g. <?, ?> here if we wanted to restrict
// the return type of create() in the case that somebody
// calls it immediately.
// The type arguments we specify here are just whatever
// we want create() to return before withTyped(...) and
// withList(...) are each called at least once.
public static OneBuilder<Object, Object> builder() {
    return new OneBuilder<Object, Object>(0, null, null);
}

Same thing about creating copies and heap pollution.


Now we're getting really novel. The idea here is that we can "disable" each method by causing a capture conversion error.

It's a little complicated to explain, but the basic idea is:

  • Each method somehow depends on a type variable which is declared on the class.
  • "Disable" that method by having its return type set that type variable to ?.
  • This causes a capture conversion error if we attempt to invoke the method on that return value.

The difference between this example and the previous example is that if we try to call a setter a second time, we will get a compiler error:

SomeObject<Long, String> =
    SomeObject.builder()
        .withType( new Integer(1) )
        .withList( Arrays.asList("abc","def") )
        .withType( new Long(1L) ) // <-- compiler error here
        .create();

Thus, we can only call each setter once.

The two major downsides here are that you:

  • can't call setters a second time for legitimate reasons
  • and can call setters a second time with the null literal.

I think it's a pretty interesting proof-of-concept, even if it's a little impractical.

public static class OneBuilder<T, S, TCAP, SCAP> {
    private final int number;
    private final T typed;
    private final List<S> list;

    private OneBuilder(int number, T typed, List<S> list) {
        this.number = number;
        this.typed  = typed;
        this.list   = list;
    }

    public OneBuilder<T, S, TCAP, SCAP> withNumber(int number) {
        return new OneBuilder<T, S, TCAP, SCAP>(number, this.typed, this.list);
    }

    public <TR extends TCAP> OneBuilder<TR, S, ?, SCAP> withTyped(TR typed) {
        // we could return 'this' at the risk of heap pollution
        return new OneBuilder<TR, S, TCAP, SCAP>(this.number, typed, this.list);
    }

    public <SR extends SCAP> OneBuilder<T, SR, TCAP, ?> withList(List<SR> list) {
        // we could return 'this' at the risk of heap pollution
        return new OneBuilder<T, SR, TCAP, SCAP>(this.number, this.typed, list);
    }

    public SomeObject<T, S> create() {
        return new SomeObject<T, S>(number, typed, list);
    }
}

// Same thing as the previous example,
// we could return <?, ?, Object, Object> if we wanted
// to restrict the return type of create() in the case
// that someone called it immediately.
// (The type arguments to TCAP and SCAP should stay
// Object because they are the initial bound of TR and SR.)
public static OneBuilder<Object, Object, Object, Object> builder() {
    return new OneBuilder<Object, Object, Object, Object>(0, null, null);
}

Again, same thing about creating copies and heap pollution.


Anyway, I hope this gives you some ideas to sink your teeth in to. : )

If you're generally interested in this sort of thing, I recommend learning code generation with annotation processing, because you can generate things like this much easier than writing them by hand. As we talked about in the comments, writing things like this by hand becomes unrealistic pretty quickly.

like image 154
Radiodef Avatar answered Oct 11 '22 20:10

Radiodef