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::tuple
s. 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:
#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;
}
1 Answer 1
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 &&
).
Explore related questions
See similar questions with these tags.