The Problem about a Wrapper Function Taking Arguments in Arbitrary Order and its Solution in C ++ 17

Option one, banal and bad

So, let’s start by declaring this very wrapper function:

template<typename... Ts>
void wrapper(Ts&&... args)
{
  static_assert(sizeof...(args) == 3, "Invalid number of arguments");

  [...]
}

We accept an arbitrary number universal links to objects of various types as arguments, and immediately check that there are exactly three arguments passed. As long as everything goes well… Next, we need to somehow arrange them in the correct order and shove them into do_something… The first (and dumbest) thought is to use std::tuple:

template<typename... Ts>
void wrapper(Ts&&... args)
{
  static_assert(sizeof...(args) == 3, "Invalid number of arguments");

  std::tuple<bool, int, std::string_view> f_args;
  
  [... как-то заполняем f_args ...]
  
  // и вызываем do_something с аргументами в нужном порядке
  std::apply(do_something, f_args);
}

The next question is how to fill in f_args? Obviously, you need to somehow go through the original arguments (args) and cram them into elements std::tuple in the correct order using a helper lambda like this:

auto bind_arg = [&](auto &&arg) {
  using arg_type = typename std::remove_reference<decltype(arg)>::type;

  if constexpr (std::is_same<arg_type, bool>::value) {
    std::get<0>(f_args) = std::forward<decltype(arg)>(arg);
  } else if constexpr (std::is_same<arg_type, int>::value) {
    std::get<1>(f_args) = std::forward<decltype(arg)>(arg);
  } else if constexpr (std::is_same<arg_type, std::string_view>::value) {
    std::get<2>(f_args) = std::forward<decltype(arg)>(arg);
  } else {
    static_assert(false, "Invalid argument type"); // не сработает
  }
};

But here we are faced with a minor hindrance – this lambda does not compile. The reason is that since the expression being tested instatic_assert (stupidly false) does not depend on the lambda argument, then static_assert fires not when a specific instance of a lambda is created from its template, but also during compilation of the template itself. The solution is simple – replace false to something depending on arg… For example, it is highly doubtful that arg here someday will have a type void:

static_assert(std::is_void<decltype(arg)>::value, "Invalid argument type");

So, this is understandable. How to further call this bind_arg for each item from args? Come to the rescue convolutions:

(bind_arg(std::forward<decltype(args)>(args)), ...);

Here we perform unary convolution using the comma operator, which in our case is converted by the compiler to something like the following (I used the indices in square brackets for clarity only):

(bind_arg(std::forward<decltype(args[0])>(args[0])),
 (bind_arg(std::forward<decltype(args[1])>(args[1])),
  (bind_arg(std::forward<decltype(args[2])>(args[2])))));

So good. But there is one problem: how to find out if all the elements std::tuple initialized correctly? After all wrapper can be called something like this:

wrapper(false, false, 1);

and in the first element f_args the value will be written twice, and the last one will remain value-initialized to the default. Disorder. We’ll have to stick on runtime crutches:

template<typename... Ts>
void wrapper(Ts&&... args)
{
  static_assert(sizeof...(args) == 3, "Invalid number of arguments");

  std::tuple<bool, bool, bool> is_arg_bound;
  std::tuple<bool, int, std::string_view> f_args;

  auto bind_arg = [&](auto &&arg) {
    using arg_type = typename std::remove_reference<decltype(arg)>::type;

    if constexpr (std::is_same<arg_type, bool>::value) {
      std::get<0>(is_arg_bound) = true;
      std::get<0>(f_args) = std::forward<decltype(arg)>(arg);
    } else if constexpr (std::is_same<arg_type, int>::value) {
      std::get<1>(is_arg_bound) = true;
      std::get<1>(f_args) = std::forward<decltype(arg)>(arg);
    } else if constexpr (std::is_same<arg_type, std::string_view>::value) {
      std::get<2>(is_arg_bound) = true;
      std::get<2>(f_args) = std::forward<decltype(arg)>(arg);
    } else {
      static_assert(std::is_void<decltype(arg)>::value, "Invalid argument type");
    }
  };

  (bind_arg(std::forward<decltype(args)>(args)), ...);

  if (!std::apply([](auto... is_arg_bound) { return (is_arg_bound && ...); }, is_arg_bound)) {
    std::cerr << "Invalid arguments" << std::endl;

    return;
  }

  std::apply(do_something, f_args);
}

Yes it works, but … somehow not happy… First, runtime crutches, but I would like all checks to be performed exclusively at compile time. Secondly, when sticking in std::tuple there is a completely unnecessary copying or moving of the argument. Thirdly, completely unnecessary value initialization of elements occurs when creating the std::tuple… Yes, for the types of arguments from the problem it does not look scary, but what if there is something heavier? Bad, cumbersome, ugly, ineffective.

But what if you approach from the other side?

Option two, final

What if, instead of storing the arguments in a tuple, we immediately receive an argument of the required type? Something like:

template<typename... Ts>
void wrapper(Ts&&... args)
{
  static_assert(sizeof...(args) == 3, "Invalid number of arguments");

  do_something(get_arg_of_type<bool>(std::forward<Ts>(args)...),
               get_arg_of_type<int>(std::forward<Ts>(args)...),
               get_arg_of_type<std::string_view>(std::forward<Ts>(args)...));
}

There is little to do – to write this very get_arg_of_type()… Let’s start simple – with a signature:

template<typename R, typename... Ts>
R get_arg_of_type(Ts&&... args)
{
  [...]
}

That is, we have in the template arguments the type R (the one that the function should find and return), and as part of the function arguments – a set of arguments of different types args, among which, in fact, you need to search. But how can you walk through them? Let’s use compile time recursion:

template<typename R, typename T, typename... Ts>
R get_arg_of_type(T&& arg, Ts&&... args)
{
  using arg_type = typename std::remove_reference<decltype(arg)>::type;

  if constexpr (std::is_same<arg_type, R>::value) {
    return std::forward<T>(arg);
  } else if constexpr (sizeof...(args) > 0) {
    return get_arg_of_type<R>(std::forward<Ts>(args)...);
  } else {
    static_assert(std::is_void<decltype(arg)>::value, "An argument with the specified type was not found");
  }
}

We modify the signature, highlighting the first argument separately, check its type with R, if it matched, we return it right away, if not, see if we still have arguments in args and call get_arg_of_type() recursively (by shifting the elements one left), if not, we print a compile-time error.

Almost good, but … not quite. There is one extra copy / move left – after all, returning an object of type R, the compiler is forced to create it, and RVO will not work here. What to do? Comes to the rescue decltype (auto):

template<typename R, typename T, typename... Ts>
decltype(auto) get_arg_of_type(T&& arg, Ts&&... args)
{
  [... все остальное без изменений ...]
}

and voila – now get_arg_of_type() instead of an object, it returns a reference of exactly the same type as the first argument arg

So, no runtime crutches, no unnecessary copies or movements (the wrapper is completely transparent in this sense), no additional initializations. I decided to stop at this option, but it will be curious to see some even more effective option in the comments. You can play with the last solution live here (std::string_view there it is replaced by a more “heavy” one std::string for a more visual demonstration of the work of perfect forwarding).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *