Skip to content

Builders

Carsten Rudolph edited this page Jul 24, 2022 · 2 revisions

Builders allow to define objects created for the engine in a straightforward domain language style. They are, however, completely optional and you can also create each object individually. As an example, creating a (Vulkan) shader program without the builder syntax would look like this:

Array<UniquePtr<VulkanShaderModule>> modules;
modules.push_back(std::move(makeUnique<VulkanShaderModule>(device, ShaderStage::Vertex, "shaders/vertex_shader.spv", "main")));
modules.push_back(std::move(makeUnique<VulkanShaderModule>(device, ShaderStage::Fragment, "shaders/fragment_shader.spv", "main")));

auto shaderProgram = makeShared<VulkanShaderProgram>(std::move(modules));

Whilst the same could be achieved in a much more readable way using builders:

auto shaderProgram = device.buildShaderProgram()
    .withVertexShaderModule("shaders/vertex_shader.spv", "main")
    .withFragmentShaderModule("shaders/fragment_shader.spv", "main");

This page describes the builder architecture as implemented in the engine and how to use or customize it.

Enable Builder Types

Builders are enabled by default, however they can be disabled by setting the BUILD_DEFINE_BUILDERS option to OFF when configuring the project. This will only prevent the integrated builders for the backends from being defined. The builder base definitions, as well as the application builders are still created.

Builder Hierarchy

Builders are distinguished by their role within a builder hierarchy.

  • Root builders are the entry point for a builder hierarchy. They can be directly created and typically only expect a device to be passed to them (but are not required to in general). This is why the device interfaces provide build* methods for convenience.
  • Child builders require a parent builder to be specified. This can either be a root builder or another child builder.

The main difference between both types is the way they return the built instance. Root builders directly return the reference (as an rvalue) by overwriting the move conversion operator to the built type. Child builders return the parent builder, if their add method is called.

Some types require initialization logic to be executed after configuration. To allow for this, builders expose a protected build method, that can be overwritten. By default, this method does nothing, however most of the integrated builders overwrite this method to emulate the logic, the public constructor of the built object would execute otherwise.

There is one more important concept regarding the parent-children relationship in a builder hierarchy. If a child builder's add method is called, it calls an use method on the parent builder, passing a (rvalue) reference of the object instance it has built to it. This is done after calling build. This way, the parent builder is notified of the child instance, for example to store it within it's own instance.

Defining Builders

Every builder is defined by inheriting the Builder class. This class is a template that takes four arguments:

  • TDerived is the type of the implementing class itself in CRTP-fashion.
  • T represents the type of the object to build.
  • TParent identifies the parent builder type, if the builder is a child builder.
  • TPointer can be used to switch between different pointer implementations. By default it is set to UniquePtr<T>, but it can also be exchanged to SharedPtr<T> or some other smart pointer container. Note that changing the pointer type does not change the way, the object instance is returned by the builder. The builder always returns a rvalue reference of the internal pointer.

Defining a Root Builder

To define a root builder, first inherit from Builder by setting TParent to std::nullptr_t. If you want to create a UniquePtr<T>, a root builder can also be defined by only providing TDerived and T, since std::nullptr_t is the default value for TParent.

class Foo {
};

class FooBuilder : Builder<FooBuilder, Foo, std::nullptr_t, SharedPtr<Foo>> {
};

In the example above, a builder is defined, that builds SharedPtrs of the Foo type. Typically, the builder takes over initialization from the constructor, so let's define a private constructor and declare the builder a friend class, so it can access it. For this, we can use the LITEFX_BUILDER macro. Let's also give Foo a property to initialize.

class Foo {
    LITEFX_BUILDER(FooBuilder);

private:
    int m_prop;

public:
    Foo(int prop) : 
        m_prop(prop) 
    { 
    }

private:
    Foo() = default;

public:
    const int& prop() const { 
        return m_prop; 
    }

private:
    int& prop() {
        return m_prop;
    }
};

The public interface of Foo is immutable, so it is not possible to alter m_prop beyond initialization when calling the public constructor. The private interface, however allows for this. Since we've made FooBuilder a friend class using the LITEFX_BUILDER macro, we are able to call it from the builder. Next, let's add a constructor/destructor to the builder. We don't want the builder to be copied or moved, so we remove the respective constructors. Also let's add a method to initialize the property. Note how it returns a reference to FooBuilder. This is important, not only to chain multiple property initializer methods, but also to be able to call the move conversion operator at the end.

class FooBuilder : Builder<FooBuilder, Foo, std::nullptr_t, SharedPtr<Foo>> {
public:
    FooBuilder() noexcept :
        Builder(SharedPtr<Foo>(new Foo()) 
    {
    }

    FooBuilder(const FooBuilder&) = delete;
    FooBuilder(FooBuilder&&) = delete;
    virtual ~FooBuilder() noexcept = default;

public:
    FooBuilder& setProp(int prop) {
        this->instance()->prop() = prop;
        return *this;
    }
};

Note how the constructor creates a new Foo instance by calling the parameterless private constructor. Also note how the setProp method directly sets the property on the instance. Alternatively, an builder could cache the property in it's own (private) state and overwrite build to apply all configurations at once.

That's everything required to create a basic instance of Foo using the builder.

auto foo = FooBuilder()
    .setProp(42);

Defining a Child Builder

Let's next add another Bar type that is a child of Foo. In the FooBuilder we will add a method that returns the builder for the Bar object. We also define a use method that takes the pointer created by the child builder and sets it on the Foo instance. Again (as mentioned above), it is also possible to cache this pointer and only apply it in the build method.

class Bar {
    LITEFX_BUILDER(BarBuilder);

private:
    String m_name;

    // Implementation left out, but similar to above.
};

class Foo {
    LITEFX_BUILDER(FooBuilder);

private:
    UniquePtr<Bar> m_bar;

public:
    Foo(UniquePtr<Bar>&& bar) :
        m_bar(std::move(bar)) 
    {
    }

private:
    Foo() = default;

public:
    const Bar& bar() const {
        return *m_bar;
    }
};

class BarBuilder : Builder<BarBuilder, Bar, FooBuilder, UniquePtr<Bar>> {
};

class FooBuilder : Builder<FooBuilder, Foo, std::nullptr_t, SharedPtr<Foo>> {
public:
    // Constructors and Destructor

public:
    void use(UniquePtr<Bar>&& bar) {
        this->instance()->m_bar = std::move(bar);
    }

    BarBuilder makeBar() {
        return BarBuilder(*this);
    }
};

The BarBuilder implementation looks almost the same as the FooBuilder from earlier. Note, however, the different template arguments passed to the Builder class. First, we pass in the FooBuilder as TParent. Also in this example, Foo should own Bar, so we use the builder to create a UniquePtr instead. Let's go ahead and define the BarBuilder. The constructor looks a little bit different, since it receives a reference of the parent builder (note how we create the BarBuilder in FooBuilder::makeBar above).

class BarBuilder : Builder<BarBuilder, Bar, FooBuilder, UniquePtr<Bar>> {
public:
    BarBuilder(FooBuilder& parent) :
        Builder(parent, UniquePtr<Bar>(new Bar()) 
    {
    }

    BarBuilder(const BarBuilder&) = delete;
    BarBuilder(BarBuilder&&) = delete;
    virtual ~BarBuilder() noexcept = default;

public:
    BarBuilder& withName(const String& name) {
        this->instance()->m_name = name;
    }
};

That's already everything required to define both builders. The hierarchy can be defined as follows:

auto foo = FooBuilder()
    .makeBar().withName("Test").add()
    .setProp(42);

Object Initialization

Sometimes it is required to cache the configuration state of an object and apply it right before actually building it, for example to invoke additional initialization logic. As mentioned earlier, this is possible by overwriting the build method on a builder. This method is invoked when calling add in a child builder or when calling the move conversion operator to create an instance from a root builder. Let's change the implementation of the BarBuilder from the example above to cache the name and only set it when add is called.

class BarBuilder : Builder<BarBuilder, Bar, FooBuilder, UniquePtr<Bar>> {
private:
    String m_name;

public:
    BarBuilder(FooBuilder& parent) :
        Builder(parent, UniquePtr<Bar>(new Bar()) 
    {
    }

    BarBuilder(const BarBuilder&) = delete;
    BarBuilder(BarBuilder&&) = delete;
    virtual ~BarBuilder() noexcept = default;

public:
    BarBuilder& withName(const String& name) {
        m_name = name;    // Store the name in the builder state.
    }

protected:
    virtual void build() override {
        this->instance()->m_name = m_name;     // Set the name on the instance.
    }
};

Since the default implementation for build simply no-ops, this makes no difference in the way the builder is called from the client's side.