3

I'm working on a text GUI library which comes with builders for UI components (Buttons, Panels, etc). I have a base class for these builders which I'd like to redesign so that meaningless component states can't be represented with the builder. Right now the API looks like this (over simplified):

interface ComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    // aligns a component within a grid (only if it is not positioned directly)
    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): U

    // sets the position of a component directly (only if it is not aligned
    fun withPosition(x: Int, y: Int): U

    // sets the title of a component (only applicable if it is wrapped in a box)
    fun withTitle(title: String): U

    // defines the type of box we use for decorating the component with
    fun withBoxType(boxType: BoxType): U

    // wraps the component with a box (only if no decoration renderers are set)
    fun wrapWithBox(): U

    // wraps the component with a shadow (only if no decoration renderers are set)
    fun wrapWithShadow(): U

    // sets the given decoration renderers (only if we dont use box/shadow wrapping)
    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): U
}

and it can be used like this:

Components.panel()
        .withSize(10, 10)
        .withPosition(5, 5)
        .wrapWithBox()
        .wrapWithShadow()
        .withTitle("Test")
        .withBoxType(BoxType.SINGLE)
        .build()

and the result looks like this:

example image here

The problem is that the builder can get into a meaningless state like this:

Components.panel()
        .withTitle("Test") // meaningless, we have no box
        .withBoxType(BoxType.SINGLE) // same as above
        .build()

The problem is that the builder is supposed to be a state machine where some of the methods above can be an initial state, but when I move into one of those states it determines where I can go from there (for example I should only see title/box type if I moved into the wrap with box state first.

I tried to write a prototype for this but I realized that this leads into a combinatorial complexity boom:

interface ComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): AlignedComponentBuilder<T, U>

    fun withPosition(x: Int, y: Int): PositionedComponentBuilder<T, U>

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): DecoratedComponentBuilder<T, U>

    fun wrapWithBox(wrapWithBox: Boolean = true): WrappedComponentBuilder<T, U>

    fun wrapWithShadow(wrapWithShadow: Boolean = true): WrappedComponentBuilder<T, U>
}

interface DecoratedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): AlignedDecoratedComponentBuilder<T, U>

    fun withPosition(x: Int, y: Int): PositionedDecoratedComponentBuilder<T, U>

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): DecoratedComponentBuilder<T, U>
}

interface AlignedDecoratedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): AlignedDecoratedComponentBuilder<T, U>

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): AlignedDecoratedComponentBuilder<T, U>
}

interface WrappedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): WrappedAlignedComponentBuilder<T, U>

    fun withPosition(x: Int, y: Int): WrappedPositionedComponentBuilder<T, U>

    fun withTitle(title: String): WrappedComponentBuilder<T, U>

    fun withBoxType(boxType: BoxType): WrappedComponentBuilder<T, U>

    fun wrapWithShadow(wrapWithShadow: Boolean = true): WrappedComponentBuilder<T, U>
}

interface AlignedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): U

    fun wrapWithBox(wrapWithBox: Boolean = true): WrappedComponentBuilder<T, U>

    fun wrapWithShadow(wrapWithShadow: Boolean = true): WrappedComponentBuilder<T, U>
}

interface PositionedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): PositionedDecoratedComponentBuilder<T, U>

    fun wrapWithBox(wrapWithBox: Boolean = true): WrappedPositionedComponentBuilder<T, U>

    fun wrapWithShadow(wrapWithShadow: Boolean = true): WrappedPositionedComponentBuilder<T, U>
}

interface PositionedDecoratedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withDecorationRenderers(vararg renderers: ComponentDecorationRenderer): PositionedDecoratedComponentBuilder<T, U>

}

interface WrappedPositionedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withTitle(title: String): WrappedPositionedComponentBuilder<T, U>

    fun withBoxType(boxType: BoxType): WrappedPositionedComponentBuilder<T, U>

    fun wrapWithShadow(wrapWithShadow: Boolean = true): WrappedPositionedComponentBuilder<T, U>
}

interface WrappedAlignedComponentBuilder<T : Component, U : ComponentBuilder<T, U>> : Builder<T> {

    fun withAlignmentWithin(tileGrid: TileGrid, componentAlignment: ComponentAlignment): WrappedAlignedComponentBuilder<T, U>

    fun withTitle(title: String): U

    fun withBoxType(boxType: BoxType): U

    fun wrapWithShadow(wrapWithShadow: Boolean = true): U
}

My question is: how can I write a builder API which retains its integrity but avoids the complexity boom above? I might add some other options later so it needs to be scalable.

I'm working with Kotlin so any answer should be within the bounds of what's possible with Java and/or Kotlin.

  • The only sensible way I see is some kind of compile-time meta-programming. Macros, etc. if your language has them. Code generation if it doesn't. – Jörg W Mittag May 20 at 19:39
  • Is there a reason you aren't defining each the behaviors as separate interfaces and then combining them to create the allowable operations for each of these types? – JimmyJames May 20 at 19:43
  • That's what I was afraid of. I want to keep my API simple to use so I don't really want to introduce any additional complexity for the consumers of my API. – Adam Arold May 20 at 19:43
  • 1
    BTW, writing code like this will, if nothing else, help you understand why combinatorial explosion is named "explosion". – Jörg W Mittag May 20 at 21:58
  • 1
    Have you considered moving the dependent properties to objects that they depend on? For example Components.panel().withBox(new box().withTitle("Test")). This would make it impossible to add a title to a component without a box. – John Wu Jun 4 at 22:55
3

The only meaningful way I can think of "the builder with internal state" is something I've read long time ago in this blog post.

The idea is similar to proposals in comments which can achieved with additional internal interfaces.

Roughly speaking, you must introduce separate interface for each set-method what gives you ability to create a streamlined sequence of calls.

The obvious benefits are auto-complete and state safety of the final object, but the last point (safety) is up to you, because it depends on how you'd design your builder.

I don't think it will affect user experience except from external documentation point (interface cluttering).

2

One way to achieve this without combinatorial explosion is to track whether the mandatory parameters have been provided through the builder's type parameters. Such a builder then becomes Builder<FirstPropertySet, SecondPropertySet, ..., LastPropertySet>, where we take Unit as true and Nothing as false (although you could use anything here, these just make the most sense for Kotlin).

Each mandatory method returns the builder with the type parameter for the property in question set to Unit. Optional methods simply leave the type parameters intact.

Furthermore, implementations of the builder should have a private constructor in order to prevent you from setting the type parameters yourself. In this particular instance I've provided a fake constructor (invoke operator in companion), although any static method would suffice.

Finally, in order to gain type-safety, we introduce the type-safe build method as an extension function over the builder with all type parameters set to Unit, which simply calls the unsafe build method of the interface.

class Component(
    val a: String,
    val b: Int,
    val c: Boolean,
    val d: Double
)

interface ComponentBuilder<ASet, BSet, CSet> {
    // mandatory
    fun withA(a: String): ComponentBuilder<Unit, BSet, CSet>
    fun withB(b: Int): ComponentBuilder<ASet, Unit, CSet>
    fun withC(c: Boolean): ComponentBuilder<ASet, BSet, Unit>

    // optional
    fun withD(d: Double): ComponentBuilder<ASet, BSet, CSet>

    fun unsafeBuild(): Component
}

// Type-checked build method
fun ComponentBuilder<Unit, Unit, Unit>.build(): Component =
    unsafeBuild()

class ComponentBuilderImpl<ASet, BSet, CSet>
    private constructor() : ComponentBuilder<ASet, BSet, CSet> {

    companion object {
        operator fun invoke(): ComponentBuilder<Nothing, Nothing, Nothing> =
            ComponentBuilderImpl()
    }

    private lateinit var a: String
    private var b: Int = 0
    private var c: Boolean = false
    private var d: Double = 0.7 // some default value

    override fun withA(a: String): ComponentBuilder<Unit, BSet, CSet> {
        this.a = a
        return this as ComponentBuilder<Unit, BSet, CSet>
    }

    override fun withB(b: Int): ComponentBuilder<ASet, Unit, CSet> {
        this.b = b
        return this as ComponentBuilder<ASet, Unit, CSet>
    }

    override fun withC(c: Boolean): ComponentBuilder<ASet, BSet, Unit> {
        this.c = c
        return this as ComponentBuilder<ASet, BSet, Unit>
    }

    override fun withD(d: Double): ComponentBuilder<ASet, BSet, CSet> {
        this.d = d
        return this
    }

    override fun unsafeBuild(): Component = Component(a, b, c, d)
}

fun main() {
    ComponentBuilderImpl().withB(3).build() // build() doesn't type-check
    ComponentBuilderImpl().withB(3).withA("x").withC(false).build() // type-checks
}
  • This is very interesting actually. I'm going to create a prototype for this soon! – Adam Arold Jun 5 at 11:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.