Search code examples
c++design-patternsc++20

Overridable struct of params


I'm trying to find the most optimal way of doing the following. I have a big hierarchical structure of heterogeneous parameters like this (just as an example)

struct ServiceParams {
    struct FilterParams {
        bool remove_odds;
        bool remove_primes;
    }
    size_t length;
    std::optional<float> threshold;
    FilterParams filter_params;
}

The service which uses these parameters fills the values from a config file while starting, and then the service allows a user to provide their own parameters via request:


class Service {
    ServiceParams default_params;
    bool HandleRequest(Data data, std::optional<ServiceParams> user_params) {
         return DoSomeStuff(data, user_params.value_or(default_params))
    }
}

So the question is: what is the best way of allowing user to specify only some part of user_params and use other params stored in default_params.

What I did consider so far:

Dynamic structure like std::map<std::string, std::any>

I could use std::map<std::string, std::any> for parameters and just loop (recursively) through user provided key-value store and override all the parameters which were specified.

The main drawback is: hard to read and understand the code and static code analysers wouldn't show me the places where a certain parameter is used. And I need to provide getters for each of the parameters.

Make all the params to be std::optional
struct OptionalServiceParams {
    struct OptionalFilterParams {
        std::optional<bool> remove_odds;
        std::optional<bool> remove_primes;
    }
    std::optional<size_t> length;
    std::optional<std::optional<float>> threshold;
    std::optional<OptionalFilterParams> filter_params;
}

Drawback: but I don't want the inside implementations be bothered by these std::optional-s. It's not their job to extract contained values all the time.

Use both: the current and with std::optional-s

I can create the above optional structure for user's requests and user the current params structure for the inside implementation.

Drawback: I need to override all the parameters manually one by one

auto params = default_params;
if (user_params.length.has_value()) 
    params.length = user_params.length.value();
if (user_params.threshold.has_value()) 
    params.threshold = user_params.threshold.value();
if (user_params.filter_params.has_value()) {
    if (user_params.filter_params->remove_odds.has_value())
        params.filter_params.remove_odds = user_params.filter_params->remove_odds.value();
    if (user_params.filter_params->remove_primes.has_value())
        params.filter_params.remove_primes = user_params.filter_params->remove_primes.value();
}

It's easy to make a bug and it's hard to maintain and scale. And of course it's a lot of code duplication.

Make the above two structures as one but template

We could make it as (truncated, the question becomes too long)

template <template <typename ValueType>, typename...> typename Container = std::type_identity_t>
struct ServiceParams {
    Container<size_t> length;
    Container<std::optional<float>> threshold;
}

and use it like this

ServiceParams<> default_params;
ServiceParams<std::optional> user_params;

Now I can use default_params just as is (inside implementation) and there is no code duplication. Drawback: it doesn't solve the problem with manual overriding all the params. And in contrast with the previous approach it's almost impossible to support designated initialisers here.

Resume

The other languages (e.g. python) provides reflection and I can treat a structure as a key-value store without a need of making getters. Plus static code analysers shows me the places where a certain parameter is used.

I'm 100% sure some classic approach exists but I failed to find it online.


Solution

  • How about something like this: Define tag types that allow overloading (when the type is known at compile time) or variants if it is not.

    #include <variant>
    #include <initializer_list>
    
    namespace params {
    struct filter_params_remove_odd{ bool value; };
    struct filter_params_remove_primes{ bool value; };
    struct length{ size_t value; };
    } /* namespace params */
    
    struct ServiceParams {
        struct FilterParams {
            bool remove_odds;
            bool remove_primes;
        };
        using OverrideVariant = std::variant<
            params::filter_params_remove_odd,
            params::filter_params_remove_primes,
            params::length>;
        size_t length;
        std::optional<float> threshold;
        FilterParams filter_params;
    
        void apply(params::filter_params_remove_odd param) noexcept
        { filter_params.remove_odds = param.value; }
    
        void apply(params::filter_params_remove_primes param) noexcept
        { filter_params.remove_primes = param.value; }
    
        void apply(params::length param) noexcept
        { length = param.value; }
    
        void apply(const OverrideVariant& param) noexcept
        {
            std::visit([this]<class Param>(const Param& param) mutable {
                        this->apply(param); },
                param);
        }
        void apply_all(std::initializer_list<OverrideVariant> params) noexcept
        {
            for(const OverrideVariant& param: params)
                apply(param);
        }
        template<class... Param>
        void apply_all(Param&&... params) noexcept
        { (apply(std::forward<Param>(params)), ...); }
    };
    
    
    bool HandleRequest(ServiceParams params,
          std::initializer_list<ServiceParams::OverrideVariant> user_params)
    {
        params.apply_all(user_params);
        return DoSomething(params);
    }
    template<class... Param>
    bool HandleRequest(ServiceParams params, Param&&... user_params)
    {
        params.apply_all(std::forward<Param>(user_params)...);
        return DoSomething(params);
    }
    
    void MyRequest(const ServiceParams& params)
    {
        // use initializer list overload
        HandleRequest(params, {
            params::filter_params_remove_odd{true},
            params::length{12},
        });
    }
    void MyRequest2(const ServiceParams& params)
    {
        // use parameter pack overload
        HandleRequest(params,
            params::filter_params_remove_odd{true},
            params::length{12});
    }
    

    Alternatively, an enum class for the parameter and a variant or std::any for the value are also a good combination.

    One downside of the variant approach is that you lose ABI compatibility whenever you add new parameters. This may or may not be an issue depending on your use case and coding style. In that case, an enum class + any pair per parameter would be a better option. Then of course you have to deal with mismatched types at runtime.