I have two families of functions which update variables in two different ways:
calculate
in the code below);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?
@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.