-
Notifications
You must be signed in to change notification settings - Fork 7
Builders
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.
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.
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.
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 toUniquePtr<T>
, but it can also be exchanged toSharedPtr<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.
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 SharedPtr
s 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);
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);
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.
The contents of this Wiki, including text, images and other resources are licensed under a Creative Commons Attribution 4.0 International License.