Search code examples
c++templatesgenericsstd

How to check that callable has a specific signature including params and returning type?


I have two families of functions which update variables in two different ways:

  1. Some functions called to give some change which should be then applied by a client code (calculate in the code below);
  2. Others "directly" change the values through the pointers (update in the code below).

I want to unify work with these families when it comes to call them for ranges (for sake of simplicity I omitted ranges usage here and just pass a single value).

Everything worked just fine until the moment when developer in client code mistakenly "kept" parameter in lambda, misguiding my process function.

See the code:

#include <type_traits>
#include <iostream>

void process(float& value, auto fn) {
    if constexpr (std::is_invocable_r_v<float, decltype(fn)>) {
        std::cout << "Add a change" << std::endl;
        value += fn();
    }
    else {
        std::cout << "Update the value" << std::endl;
        fn(value);
    }
}

void update(float*v)
{
    *v += 1.0f;
}

float calculate()
{
    return 1.0f;
}

int main() {
    float value = 0.0f;

    // Process by adding a change
    process(value, []() { return calculate(); });

    // Process by updating the value
    process(value, [](auto& v) { update(&v); });

    // User mistakenly kept parameter 'auto&' and used lambda with calculate call,
    // so the result will be lost in void process(...), since it will apply "Update the value" branch
    process(value, [](auto&) { return calculate(); });
}

As the result in the last process call, the process was called with lambda which takes a parameter and thus, lead to fn(value) call with losing of calculate result.

The question is, can I make a kind of a check in process which will ensure that if some callable passed as fn takes a parameter of float* type, it mustn't return a value (return type should be void) so that it won't be lost?

Any kind of compile-time error or assert would work.

I seem that I should play with this std::is_invocable_r_v<float, decltype(fn) somehow, including the type of the argument, but I can't figure out the way.

For sure I can write a wrapper which will bring all calculate functions to update functions, which is easier and safer, I guess, but still, can this be done without such a wrapper?


Solution

  • @Igor Tandetnik already gave a example that would work for the presented example. You could modify your process() function to the following:

    void process(float& value, auto fn) {
        if constexpr (std::is_invocable_r_v<float, decltype(fn)>) {
            std::cout << "Add a change" << std::endl;
            value += fn();
        }
        else if constexpr (std::is_same_v<void, decltype(fn(value))>) {
            std::cout << "Update the value" << std::endl;
            fn(value);
        }
        else {
            static_assert(false);
        }
    }
    

    This is a very fast and easy solution. However if you want to waste time with (almost) useless alternatives, here you go:

    What if fn looks like:

    [](float v) { update(&v); })
    

    The updating branch will be executed as intended, however it will not update the value passed to it but just the local copy of the argument.

    If you want to have more control over what is going to be executed in the updating and what in the adding branch, and therefore reduce errors, you can make use of the following (which I had help for from here):

    #include <concepts>
    #include <type_traits>
    #include <tuple>
    
    namespace FT {
        template <typename T>
        struct FunctionTraits;
    
        template <typename R, typename... Params>
        struct FunctionTraits<R(*)(Params...)> {
            using Ret = R;
    
            using Arity = std::integral_constant<std::size_t, sizeof...(Params)>;
    
            template <std::size_t i>
            struct Args {
                using type = std::tuple_element_t<i, std::tuple<Params...>>;
            };
        };
    
        template <typename C, typename R, typename... Params>
        struct FunctionTraits<R(C::*)(Params...) const> {
            using Ret = R;
    
            using Arity = std::integral_constant<std::size_t, sizeof...(Params)>;
    
            template <std::size_t i>
            struct Args {
                using type = std::tuple_element_t<i, std::tuple<Params...>>;
            };
        };
    
        template <typename T>
        struct FunctionTraits : FunctionTraits<decltype(&T::operator())> {};
    
        template <typename T>
            requires std::is_function_v<T>
        struct FunctionTraits<T> : FunctionTraits<decltype(&std::declval<T>())> {};
    
    
        template <typename T, typename F>
        concept Returns = std::is_same_v<T, typename FunctionTraits<F>::Ret>;
    
        template <typename F>
        constexpr std::size_t Arity = typename FunctionTraits<F>::Arity();
    
        template <std::size_t i, typename F>
        using ArgTypeAt = typename FunctionTraits<F>::template Args<i>::type;
    }
    
    template <typename F>
    concept IsAdder = FT::Returns<float, F> && FT::Arity<F> == 0;
    
    template <typename F>
    concept IsUpdater = FT::Returns<void, F> && FT::Arity<F> == 1 && std::is_same_v<float&, FT::ArgTypeAt<0, F>>;
    
    
    void process(float& value, auto fn) {
        if constexpr (IsAdder<decltype(fn)>) {
            std::cout << "Add a change" << std::endl;
            value += fn();
        }
        else if constexpr (IsUpdater<decltype(fn)>) {
            std::cout << "Update the value" << std::endl;
            fn(value);
        }
        else {
            static_assert(false);
        }
    }
    

    This also enforces (which was not originally my intention) that you don't use auto for the arguments passed to the lambda. Instead you have to use float directly.

    live example