A Custom Tuple: Understanding How it Could Work #
Source code: 1uc/modern_numerics_cxx
Using std::tuple is quite simple:
auto ix = std::tuple<int, double>{1, 3.0};
auto i = ix.get<0>();
auto x = ix.get<1>();
What we’ll try to understand here is where a tuple can store its elements.
Let’s agree that tuple must work for any size. Hence explicitly instantiating
all case up to, say, 8 elements is not an option.
Part I: Creating a Place to Store Elements #
We need a way of creating a struct with the right elements. One way to do so
is recursion and inheritance. This has the side effect that the elements
will be arranged in reverse order. Unfortunately, using recursion and
aggregation is met with a minor road block that simply obscures the
essentials.
A template class we can specialize.
template <class... Args>
class tuple_impl;
This works by creating a member variable for one type, and inheriting
the other member variables from a smaller tuple_impl, recursively.
template <class T, class... Args>
class tuple_impl<T, Args...> : public tuple_impl<Args...> {
private:
// Now we have a place to store a value of type `T`.
T value;
};
Once we’re out of types, we stop the recursion.
template <>
class tuple_impl<> {};
Part II: Accessing Elements #
We need a way of accessing the values. To do so we will number the arguments
and expose that information to tuple_impl. We make a slight modification to
the previous attempt:
template <size_t index, class... Args>
class tuple_impl;
template <size_t index, class T, class... Args>
class tuple_impl<index, T, Args...> : public tuple_impl<index + 1, Args...> {
which allows us to implement the member get as follows:
private:
using super = tuple_impl<index + 1, Args...>; // (1)
public:
template <size_t requested_index>
typename std::enable_if<requested_index == index, T>::type get() { // (2)
return value;
}
using super::get; // (3)
private:
T value;
};
There’s three comments to be made:
-
We give the base class a private name, e.g.
super. -
We’d like
get<index>()to return ourvalue. Therefore, we could try to make our specialization ofgetbe the only specialization forrequested_index == index. Sounds like a task forenable_if. We can use the return type as the place to cause SFINAE. -
We need the other specializations of
getto be visible.
We can end the recursion as follows:
template <size_t index>
class tuple_impl<index> {
public:
// End of the recursion.
template <size_t requested_index>
typename std::enable_if<requested_index >= index, void>::type get() {
static_assert(false, "Past the end.");
}
};
Part III: Constructor #
Same trick, peal of one type by giving it a name and recurse on the rest. Notice that in this variation the elements are initialized in reverse order.
tuple_impl(T value, Args &&...args)
: super(std::forward<Args>(args)...), value(value) {}
Part IV: Public API #
There’s considerable ugliness in tuple_impl. Some if it is only to make some
of the recursion simpler. Fortunately, we can hide it all:
template <class... Args>
class tuple {
public:
tuple(Args &&...args) : impl(std::forward<Args>(args)...) {}
template <size_t requested_index>
auto get() {
return impl.template get<requested_index>(); // (1)
}
private:
tuple_impl<0, Args...> impl; // (2)
};
Two comments:
-
Because
impls type depends on a template parameter, we must use.template. -
Using aggregation will prevent users from writing code that takes a
constreference to the base type. Meaning, this hides the existence oftuple_impla little bit better.
Test Run #
Let’s write a short program demonstrating some of the features:
int main() {
auto ix = tuple<int, double>(1, 3.1);
// Note that due to alignment/padding, this isn't `sizeof(int) +
// sizeof(double)`.
static_assert(sizeof(tuple<int, double>) == sizeof(std::tuple<int, double>),
"Size differs from std::tuple.");
std::cout << ix.get<0>() << std::endl;
std::cout << ix.get<1>() << std::endl;
// Causes a compilation error:
// std::cout << ix.get<2>() << std::endl;
}