Recently, I stumbled upon a C++14 feature which I hadn’t seen before. Baffled at
first, I needed to run through a couple of examples before I fully understood
what was going and why this new feature was so useful. As the title suggest, I’m
talking about std::index_sequence
and related objects. (Note: I ran across
this while perusing rpclib source code, specifically, the file
rpc/detail/call.h
).
Here’s the sequence of examples that got me there. First, imagine you wanted a function that takes a tuple and returns a subset of said tuple, where the indexes of the elements you want to have in the subset are known at compile time. You would, of course, prefer to have these indexes passed as template arguments rather than as function arguments. Here’s a stab at how such a function could look like:
Looks simple, but it took me more than one go to get right. First, note the
usage of decltype(auto)
in the function signature. Spelling out the return
type would have been annoying otherwise (and I’m not even sure how to do that
were I restricted to C++03 features). Where I stumbled was the order of the
template arguments: At first, I had placed Args
before indexes
, which of
course breaks type deduction. This highlights the first problem: What if I had
more template arguments, maybe more variadic ones? Could I run into deduction
issues? Since I’m passing in a std::tuple
, template deduction gets a huge
hint: Whatever’s in the tuple defines Args
. But if there’s no tuple, maybe I’d
run into the case where automatic type deduction would conflate my indexes
with my Args
.
Is there a way I can properly demarcate the indexes
from the other Args
?
Well, yeah, that’s what this article is about, so how about we use
std::index_sequence
:
What’s occurring here? Instead of passing the template arguments to
tuple_subset
directly, we instead pass an object of type std::index_sequence
as a function argument. Now, we can fully rely on automatic type deduction, and
can reorder the template argument order as we like.
If you’re like me and learned C++ in the 90s, you might think: Wait, aren’t we
now creating a new object and pushing it onto the stack just to avoid some
template ambiguity? Well, possibly – but note that tuple_subset()
in this
example doesn’t actually define two arguments, it only lists two types! A smart
compiler can (and should) thus make the choice to not actually create an object
here, but optimize it out. The utility of using the (fictive) object to help
with type deduction is still there!
This is a good time to take a look at the C++ standard to see what we actually
have here. C++14 defines std::integer_sequence
as a compile-time
sequence of integers of type T (see
cppreference.com).
std::index_sequence
is a specialization, where type T equals size_t
, i.e.,
it’s a sequence of size_t
values, which are typically used as indexes in C++.
Let’s ignore the other integer types for now.
The header <utility>
defines some helper factories to create index
sequences: std::make_index_sequence<N>()
creates a sequence 0, 1, …, N-1.
If we wanted to create a function that doesn’t return any subset, but truncates
tuples starting at the beginning, this would work:
OK, that was boring. More interesting is std::index_sequence_for
. Given a
variable-length type T, it creates an index sequence ‘0, 1, …, N-1’, where
N is the sizeof()...
value. My example of extracting subsets of a tuple is
now slowly losing its educational value, but for the sake of completing this
train of thought, here’s an unnecessarily complicated way of copying a tuple:
We return a subset of the original tuple, that’s actually the full set:
What this does is:
- Call
tuple_rube_goldberg_copy()
with the original tuple - Inside that function, automatically generate a list of sequences (at compile-time!) for the entire length of the tuple.
- Call
tuple_subset()
not only with the original tuple, but also with a full list of indexes of all the tuple elements.
OK, nice. We can do fancy stuff with tuples that would have been painful with older C++ versions. But the last example in particular is also not very useful, so, no big loss there. Which takes me to the code I was originally reviewing:
We pass a functor and a tuple that contains arguments to a function, and say “please call the functions with the arguments that are in the tuple”. And we do that with two functions, each one-liners. This is elegant and efficient – and given move semantics and compile-time expansions, this is probably not all that much more inefficient than calling the functor directly. Nice!
What did I learn:
- Helping the compiler when it’s faced with multiple variadic template arguments is generally a good idea.
- Template- and compiler-magic doesn’t only happen when you see
<>
. Function arguments can also be privy to compile-time optimization. - We can unpack tuples or arbitrary length as arguments to functors at compile-time.