7
\$\begingroup\$

Intro:

Imagine you have defined addition on types A and B with function A add(A,A) and B add(B,B). Now, you would like to have an addition function on tuple of these types, like tuple<A,B> add(tuple<A,B>,tuple<A,B>).

When we generalize this problem, we would like to extend an existing function working on some types to a function working on tuples of these types.

I call this extension function static_container_functor and the following code demonstrate its use:

int main() {

  std::tuple<double, int> t1{3.14159, -1}, t2{2.71828, 2};

  auto print = [](auto x) { std::cout << x << " "; };
  auto tuple_print = static_container_functor(print);
  tuple_print(t1); // prints "3.14159 -1 " and returns void instead of std::tuple<void,void> which is not possible

  std::cout << std::endl;

  auto add       = [](auto &&x, auto &&... y) { return (x + ... + y); };
  auto tuple_add = static_container_functor(add);
  auto t3 = tuple_add(t1, t1, t2); // t3 is of type std::tuple<double,int>
  tuple_print(t3);                 // prints "9.00146 0 "

  return 0;
}

I hope that it is clear to you by now what I'm trying to solve.

One way to think about static_container_functor is that it is somewhat glorified for_each for std::tuple.

What I would like to know:

I would like to know how can I possibly improve my code and whether I'm missing handling some cases. Also, I'm have attempted to do perfect forwarding and I do not know how to test if it works.

Furthermore, I have not employed the standard trick with std::index_sequence in the main implementation of static_container_functor but did a small variation of it to keep all of the code together in one function. I would like to know what do you think about it?

How to handle constexpr? In the example, how can I achieve that the tuple t3 can be constexpr when t1 and t2 are constexpr?

The function is called static_container_functor and not tuple_functor because I would like to extend it such that is works on std::variant and std::array. Ideally on anything that implements std::get<>. Any tips in that direction would be great.

Right now I'm doing a partial check, with static_assert, that the input arguments are really std::tuples. Would you recommend using SFINAE like in this post? However, for that I would need thatstd::enable_if works with automatic return type deduction.

Code:

https://godbolt.org/g/eWKxLo

#include <array>
#include <iostream>
#include <tuple>

template <std::size_t... I>
constexpr auto integral_sequence_impl(std::index_sequence<I...>) {
  return std::make_tuple((std::integral_constant<std::size_t, I>{})...);
}

template <std::size_t N, typename Indices = std::make_index_sequence<N>>
constexpr auto integral_sequence = integral_sequence_impl(Indices{});

template <typename Op>
auto static_container_functor(Op &&op) {
  return [&op](auto &&c, auto &&... d) {
    /* here I should add a test that c and d... are really tuples */

    constexpr int N = std::tuple_size_v<std::remove_reference_t<decltype(c)>>;
    static_assert(
        (true == ... ==
         (N == std::tuple_size_v<std::remove_reference_t<decltype(d)>>)),
        "All of the arguments must have the same length!");

    auto implementation = [&](auto... I) {
      auto slice = [&](auto idx) {
        return std::forward_as_tuple(std::get<idx>(c), std::get<idx>(d)...);
      };

      auto result = [&](auto idx) {
        return std::apply(op, std::forward<decltype(slice(idx))>(slice(idx)));
      };
      auto zero = std::integral_constant<size_t, 0>{};

      if constexpr /* Are all return types are void? Return void*/
          ((std::is_same_v<void, decltype(result(I))> && ... && true)) {

        (result(I), ...);
        return;

      } else /* All other cases. Return std::tuple  */ {
        using ReturnType = std::tuple<decltype(result(I))...>;
        return ReturnType{result(I)...};
      }
    };

    return std::apply(implementation, integral_sequence<N>);
  };
}

int main() {

  std::tuple<double, int> t1{3.14159, -1}, t2{2.71828, 2};

  auto print = [](auto x) { std::cout << x << " "; };

  auto tuple_print = static_container_functor(print);
  tuple_print(t1); // prints "3.14159 -1 " and returns void

  std::cout << std::endl;

  auto add       = [](auto &&x, auto &&... y) { return (x + ... + y); };
  auto tuple_add = static_container_functor(add);
  auto t3 = tuple_add(t1, t1, t2); // t3 is of type std::tuple<double,int>
  tuple_print(t3);                 // prints "9.00146 0 "

  return 0;
}
\$\endgroup\$
3
\$\begingroup\$

Compilation warnings

   auto zero = std::integral_constant<size_t, 0>{};

What's this for? It doesn't seem to be used, and removing it doesn't affect operation.

With that fixed, I get a nice clean compilation with my usual warnings g++ -std=c++17 -g -Wall -Wextra -Wwrite-strings -Wpedantic -Warray-bounds -Weffc++.


Includes and namespaces

<array> and <iostream> aren't required by the template, so shouldn't go with it when moved to a header. To make that clear, I'd defer including them until just before main().

integral_sequence_impl() and integral_sequence are internals, so should go into a "details" namespace that's not part of the public interface.


Cleverness

This is probably too cute:

constexpr int N = std::tuple_size_v<std::remove_reference_t<decltype(c)>>;
static_assert(
    (true == ... ==
     (N == std::tuple_size_v<std::remove_reference_t<decltype(d)>>)),
    "All of the arguments must have the same length!");

It looks like we believe in a Python-style chainable == operator, although that's not what's actually happening here. I think that could trip up future maintainers, who may try to "correct" it.

I'd prefer to replace it with this more-transparent equivalent using &&:

constexpr auto N = std::tuple_size_v<std::remove_reference_t<decltype(c)>>;
static_assert((... &&
               (N == std::tuple_size_v<std::remove_reference_t<decltype(d)>>)),
              "All of the arguments must have the same length!");

(I changed N to be auto, since the standard leaves the type of std::tuple_size unspecified.)


Simplifications

We don't need && true at the end of the std::is_same_v fold expression.; && ... is sufficient. Actually, I think we really ought to be using || ..., for those rare cases where only some overloads return void.

We could inline slice() (but then need a comment to replace the informative name):

    auto result = [&](auto idx) {
        return std::apply(op, std::forward_as_tuple(std::get<idx>(c), std::get<idx>(d)...));
        //                          ^^^ slice of idx'th element from from each input ^^^
    };

Tests

The definition of add can be simplified:

auto add = []( auto&&... x) { return (x + ...); };

It would be good to see tests of functions that modify their arguments, to ensure that lvalues are passed correctly. A simple variant is to change the type of x here:

  auto add = [](auto& x, auto&&... y) { return x = (x + ... + y); };

Along with

  tuple_print(t1);                 // prints "9.00146 0 "
  std::cout << '\n';

And the output does indeed match t3.

We can also create a test where the tuples to be operated on contain different types:

auto t1 = std::tuple<double, int>{3.14159, -1};
auto const t2 = std::tuple<float, unsigned char>{2.71828, 2};

I'm happy to report that this also works flawlessly (including in conjunction with the modifying test).

We could also include tests with other tuple-like types, such as std::array (I've confirmed that we can successfully mix types, using e.g. a tuple and an array of the same length - though a more realistic use-case would be a plain array and a std::array).

As indicated in the description, we're missing a test of the perfect forwarding. One way to demonstrate this works it to perform a test on a non-copyable type, such as std::unique_ptr:

auto concat = []( auto&&... x) { return (*x + ...); };
auto s1 = std::tuple<std::unique_ptr<std::string>>{std::make_unique<std::string>("foo")};
auto s2 = std::tuple<std::unique_ptr<std::string>>{std::make_unique<std::string>("bar")};
tuple_print(static_container_functor(concat)(s1,s2));
std::cout << '\n';

Style

Minor point, but most C++ authors would write the function accepting a forwarding reference as …functor(Op&& op) rather than …functor(Op &&op). The latter, being unusual, makes me double-scan that line every time I read it (and also couple of other places that have right-binding &&).

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.