The Toggle Builder

Posted: January 31, 2023 in Programming Recipes
Tags: , , ,

This post is about that little extra we might equip builder classes with.

I am with Klaus Iglberger that called for talking about software design at Meeting C++ 2022. This post is just a weeny contribution to the cause.

Suppose we have designed a builder on top of a third-party library:

class MessageBuilder
{
public:
    MessageBuilder& WithPayload(std::string payload)
    {
        m_payload = std::move(payload);
        return *this;
    }
    
    MessageBuilder& WithCompression()
    {
        m_compress = true;
        return *this;
    }
    
    MessageBuilder& WithAppender(AppenderParameters params)
    {
        m_options.UseAppender(params);
        return *this;
    }

    MessageBuilder& WithChunking(int size, bool enablePadding)
    {
        m_options.EnableChunking();
        m_options.SetChunkSize(size);
        m_options.SetChunkPadding(enablePadding);
        return *this;
    }

    // ...other functions...

    Message Build()
    {
        return SomeLibrary::CreateMessage(m_payload, m_compress, m_options);
    }
private:
    std::string m_payload;    
    bool m_compress = false;    
    MessageOptions m_options;
};

The library provides a function to create Message instances by taking a couple of mandatory parameters (payload and compress) and also an instance of MessageOptions that toggles additional features. Some of those need several functions to be called on the underlying object, therefore the builder pattern is a good fit for hiding such details to the user.

Sometimes, builders are encapsulated even further by “recipes”:

Message CreateDefaultMessage(std::string payload)
{
    return MessageBuilder{}.
        WithPayload(std::move(payload)).
        WithAppender({.secret = 23, .code = "APP-1"}).
        Build();
}

Message CreateMessageForPoorNetwork(std::string payload, int bandwidth)
{
    return MessageBuilder{}.
        WithPayload(std::move(payload)).
        WithCompression().
        WithChunking(CalculateOptimalChunkSizeFromNetworkBandwidth(bandwidth), false).
        Build();
}

// ...

However, if intended for general usage, the builder can result a bit uncomfortable when we go through some configuration object to toggle features. This often leads to a bunch of ifs:

Message CreateMessage(std::string payload, const Config& config)
{
    MessageBuilder builder;
    builder.WithPayload(std::move(payload));
    if (config.enableCompression)
    {
        builder.WithCompression();
    }
    if (config.chunking.enable)
    {
        builder.WithChunking(config.chunking.size, config.chunking.enablePadding);
    }
    // ...
    return builder.Build();
}

Here, we can equip the builder with a tiny feature that brings some fluency back:

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).
        When(config.enableCompression, &MessageBuilder::WithCompression).
        When(config.chunking.enable, &MessageBuilder::WithChunking, config.chunking.size, config.chunking.enablePadding).
        // ...
        .Build();
}

The code should be self-explanatory: When is like a toggle button that applies the function only if the feature is enabled, otherwise it just does nothing. I also like that all such ifs have been moved into a single place. The function is trivial to implement:

class MessageBuilder
{
public:
    template<typename... T>
    MessageBuilder& When(bool flag, auto fn, T&&... params)
    {        
        if (flag)
        {
            std::invoke(f, *this, std::forward<T>(params)...);
        }
        return *this;
    }
    
    // ...
};

The name When has been suggested by Davide Di Gennaro (I named it If, initially).

We can even put it into a mixin:

template<typename This>
struct ToggleBuilder
{
    template<typename... T>
    auto& When(bool flag, auto f, T&&... params)
    {
        auto& actualThis = static_cast<This&>(*this);
        if (flag)
        {
            std::invoke(f, actualThis, std::forward<T>(params)...);
        }
        return actualThis;
    }
};

class MessageBuilder : public ToggleBuilder<MessageBuilder>
{
   MessageBuilder& WithPayload(std::string payload)
    {
        m_payload = std::move(payload);
        return *this;
    }
    
    //...
};

This is a classical scenario where CRTP is opportune, as I blogged when I still had my hair.

Another tiny addition that works already is passing to When a callable:

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).
        When(config.enableCompression, &MessageBuilder::WithCompression).
        When(config.chunking.enable, [](MessageBuilder& builder){ 
            builder.WithChunking(SomeExpensiveCall(), AnotherExpensiveCall());
        }).
        // ...
        .Build();
}

Here above, we don’t call such expensive functions when the feature is turned off. In addition, that overload is useful to tie several builder calls to the very same toggle. For example:

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).        
        When(!config.appenders.empty(), [&](MessageBuilder& builder){ 
            for (const auto& appender : config.appenders)
            {
                builder.WithAppender(appender);
            }            
        }).
        // ...
        .Build();
}

Or, another example:

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).        
        When(config.someSpecialRecipeEnabled, [](MessageBuilder& builder){ 
            builder.WithAppender(GetSpecialAppenderParams()).
                    WithAppender(GetAnotherSpecialAppenderParams());
        }).
        // ...
        .Build();
}

Clearly, such lambdas might be provided as ready-made recipes, without modifying the builder at all:

struct AddSpecialRecipe
{
   void operator()(MessageBuilder& builder) const
   {
        builder.WithAppender(GetSpecialAppenderParams()).
                WithAppender(GetAnotherSpecialAppenderParams());
   }
};

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).        
        When(config.someSpecialRecipeEnabled, AddSpecialRecipe{}).
        // ...
        .Build();
}

Some people I showed this pattern commented also that returning the builder from the lambda is more consistent:

Message CreateMessage(std::string payload, const Config& config)
{
    return MessageBuilder{}
        WithPayload(std::move(payload)).        
        When(config.someSpecialRecipeEnabled, [](MessageBuilder& builder){ 
            return builder.WithAppender(GetSpecialAppenderParams()).
                           WithAppender(GetAnotherSpecialAppenderParams());
        }).
        // ...
        .Build();
}

However, I am not a big fan of nested lambdas, especially when unnecessary.

As Christian pointed out, that functionality is already working without needing any further modifications to the mixin. Yet, in the first version of this post, I showed the following code:

template<typename F>
concept FunctionPointer = std::is_member_function_pointer_v<F>;

template<typename This>
struct ToggleBuilder
{
    template<typename... T>
    auto& When(bool flag, FunctionPointer auto f, T&&... params)
    {
        auto& actualThis = static_cast<This&>(*this);
        if (flag)
        {
            std::invoke(f, actualThis, std::forward<T>(params)...);
        }
        return actualThis;
    }

    auto& When(bool flag, auto action)
    {
        auto& actualThis = static_cast<This&>(*this);
        if (flag)
        {
            action(actualThis);
        }
        return actualThis;
    }
};

The only reason I showed that more convoluted version is because now it should be easier to understand that we have only two options: pass either a member function or a callable. However, they are semantically the same (thanks to std::invoke). Since less code means less trouble, I have a bias towards the simpler:

template<typename This>
struct ToggleBuilder
{
    template<typename... T>
    auto& When(bool flag, auto f, T&&... params)
    {
        auto& actualThis = static_cast<This&>(*this);
        if (flag)
        {
            std::invoke(f, actualThis, std::forward<T>(params)...);
        }
        return actualThis;
    }
};

At this point, let me just show you a real application of this pattern.

I have been working with OnnxRuntime in C++ for a few years now and I have developed an internal library to use it more comfortably at my company. Among other things, the library provides a builder for crafting Session instances (apparently, this idea turned out to be also used by Rust people). Imagine something like this:

auto session = SessionBuilder{}.
    WithLogger(MakeLoggerFrom(config)).
    WithModel(config.modelPath).
    WithCUDAExecutionProvider(config.cudaOptions).
    WithOpenVINOExecutionProvider(config.openVinoOptions).
    WithOptimizationLevel(config.optimizationLevel).
    WithNumberOfIntraThreads(config.intraThreads).
    //...
    Build();

You don’t need to be an OnnxRuntime user to understand that code. Basically, you build a session to accelerate an inference model on, possibly, several accelerators (aka: execution providers) available on your target machine.

However, it can happen that you want to selectively enable some accelerators only. For this reason, the builder is equipped with the toggle feature:

auto session = SessionBuilder{}.
    WithLogger(MakeLoggerFrom(config)).
    WithModel(config.modelPath).
    When(config.useCuda, &SessionBuilder::WithCUDAExecutionProvider, config.cudaOptions).
    When(config.useOpenVino, &SessionBuilder::WithOpenVINOExecutionProvider, config.openVinoOptions).
    WithOptimizationLevel(config.optimizationLevel).
    WithNumberOfIntraThreads(config.intraThreads).
    //...
    Build();

The actual builder is a little bit more sophisticated but you should get the point.

Clearly, things can get out of hands pretty quickly. I tend not to encourage a usage of the pattern that is more complicated than this.

I thought this could be something interesting to share. Please let me know your thoughts, as usual.

Comments
  1. Christian says:

    I fail to see why the second overload of When is needed.
    When the parameter pack is empty, the regular When function will correctly call the passed in lambda as required.

    • Marco Arena says:

      You are right, the overload is not needed at all. I added it only for clarity (`FunctionPointer` makes clear that that overload accepts a function pointer). However, I get that it’s misleading. Let me edit the post. Thanks for pointing that out.

  2. sergegers says:

    I think C++ 23 Deducing This feature will be applicable here.

Leave a comment