Search code examples
c++templatesc++17template-meta-programming

Overloading Functions with Dummy Structs vs Template Specialisation


I'm refactoring some C++ and came across some dummy structs being used to differentiate between overloaded functions. I'm considering replacing this with template specialization but want to fully understand the consequences first.

We've got an enum class which is often used to decide how to handle some value:

enum class OptionEnum : int {
    optionA,
    optionB
};

void foo( const int val, const OptionEnum option ){
    if( option == OptionEnum::optionA ){
        doSomething( val );
    } else if ( option == OptionEnum::optionB ) {
        doSomethingElse( val );
    } else {
        throw std::invalid_argument( "Unsupported Option" );
    }

    doMoreStuff();
}

However when the function is particularly complex the current code uses overloading and some dummy structs to get the correct overload resolution:

struct OptionsStruct{};
static constexpr struct OptionStructA : OptionsStruct{} optionA;
static constexpr struct OptionStructB : OptionsStruct{} optionB;

void foo( const int val, const OptionStructA & ){
    doSomething( val );
}

void foo( const int val, const OptionStructB & ){
    doSomethingElse( val );
}

This is often done through some intermediate templated function that doesn't care which option is used but needs to know which overload to call:

template < typename OptionStructAorB >
void bar( const int val, const OptionStructAorB option ){
    int result = processVal( val );
    foo( result, option );
}

void baz( const int val, const OptionEnum option ){
    if ( option == OptionEnum::optionA ){
        bar( val, optionA );
    } else if ( option == OptionEnum::optionB ) {
        bar( val, optionB );
    } else {
        throw std::invalid_argument( "Unsupported Option in baz" );
    }
}

int main(int argc, char* argv[]) {
    const int inputVal = std::atoi(argv[1]);
    const std::string optionString = argv[2];

    OptionEnum inputOption;
    if ( optionString == "a" ){
        inputOption = OptionEnum::optionA;
    } else if ( optionString == "b" ) {
        inputOption = OptionEnum::optionB;
    } else {
        std::cout << "Invalid option " << optionString << std::endl;
        return 1;
    }

    baz(inputVal, inputOption);

    return 0;
}

I'm considering getting rid of the dummy structs and overloaded functions and replacing them with template specialization like so:

template< OptionEnum option >
void foo(const int val);

template<>
void foo< OptionEnum::optionA >(const int val){
    doSomething(val);
}

template<>
void foo< OptionEnum::optionB >(const int val){
    doSomethingElse(val);
}

template< OptionEnum option >
void bar(const int val){
    int result = processVal(val);
    foo<option>(result);
}

void baz( const int val, const OptionEnum option ){
    if ( option == OptionEnum::optionA ){
        bar<OptionEnum::optionA>( val );
    } else if ( option == OptionEnum::optionB ) {
        bar<OptionEnum::optionB>( val );
    } else {
        throw std::invalid_argument( "Unsupported Option in baz" );
    }
}

Aside from opinionated concerns around style and readability, are there any specific advantages to the original dummy struct approach over the template specialization approach in terms of performance, type safety, and maintainability (specific to C++17)? Could there be maintainability issues when adding a new enum value that is worse in the new approach for example?

(Yes, foo() probably shouldn't be overloaded at all and we should just have fooA() and fooB() but the codebase is max spaghetti and this is a compromise)


Solution

  • Tag dispatching is not wrong, so you mostly change style.

    In current implementation, tag inherits from a base class for no apparent reason though.

    You might directly have:

    enum class OptionEnum : int {
        optionA,
        optionB
    };
    
    static constexpr std::integral_constant<OptionEnum::optionA> optionA;
    static constexpr std::integral_constant<OptionEnum::optionB> optionB;
    

    Could there be maintainability issues when adding a new enum value

    With switch, compiler might warn for missing case. and the conversion runtime to compile time value is done before the usage, so no one is better th an the other for that point.

    Using std::variant with std::visit might allow to do the switch only once:

    std::variant<
        std::integral_constant<OptionEnum, OptionEnum::optionA>,
        std::integral_constant<OptionEnum, OptionEnum::optionB>
    > toVariant(OptionEnum option)
    {
        switch (option) {
            case OptionEnum::optionA: return std::integral_constant<OptionEnum, OptionEnum::optionA>{};
            case OptionEnum::optionB: return std::integral_constant<OptionEnum, OptionEnum::optionB>{};
        }
        std::unreachable(); // or throw
    }
    

    and then

    std::visit(
        [&](auto option){ // or [&]<OptionEnum E>(std::integral_constant<OptionEnum, E>)
            foo(val, option); // tag dispatching
            bar<option()>(val); // template
        }, runtime_option);
    

    Are there any specific advantages in terms of performance, type safety, and maintainability (specific to C++17)?

    • Tag dispatching adds an extra empty parameter which should probably be optimized.

    • Error messages might differ when misuse:

    • missing the overload (for tag dispatch) would produce a compilation error with mismatch argument.

    • missing specialization would produce a linker error, or require to = delete the primary template.

    Are there any specific advantages to tag dispatching

    You cannot partially specialize functions,

    So you cannot have

    template <OptionEnum, typename T> void baz(T);
    
    template <typename T> void baz<OptionEnum::optionA>(T); // Not possible
    

    you have then to create a functor

    template <OptionEnum>
    struct baz_impl;
    
    template <>
    struct baz_impl<OptionEnum::optionA>
    {
        template <typename T>
        void operator() (T) const { /* .. */ }
    };
    template <>
    struct baz_impl<OptionEnum::optionB>
    {
        template <typename T>
        void operator() (T) const { /* .. */ }
    };
    
    template <OptionEnum option, typename T>
    void baz(T t) { baz_impl<option>{}(t); }
    

    but, with tag dispatch, you can simply have:

    template <typename T>
    void baz(std::integral_constant<OptionEnum, OptionEnum::optionA>, T)
    {
        //...
    }
    template <typename T>
    void baz(std::integral_constant<OptionEnum, OptionEnum::optionB>, T)
    {
        //...
    }
    

    I have my preference on tag dispatch style.

    The biggest issue in your code seems to be the dispatch which is repeated and which is error prone in case of addition of new enum value.