C++14 features: `std::index_sequence` and friends

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:

#include <iostream>
#include <tuple>
#include <utility> // Not required for compilation, but that's where
                   // index_sequence et al are defined.

template <size_t... indexes, typename... Args>
decltype(auto) tuple_subset(const std::tuple<Args...> &t)
{
    return std::make_tuple(std::get<indexes>(t)...);
}

int main(void)
{
    auto tup1 = std::make_tuple(1, "str", 2.3, size_t(0));
    auto tup2 = tuple_subset<1, 2>(tup1);
    std::cout << std::get<0>(tup2) << std::endl; // Prints 'str'
    return 0;
}

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:

#include <iostream>
#include <tuple>
#include <utility> // See above

template <typename... Args, size_t... indexes>
decltype(auto) tuple_subset(
        const std::tuple<Args...> &t,
        std::index_sequence<indexes...>
) {
    return std::make_tuple(std::get<indexes>(t)...);
}

int main(void)
{
    auto tup1 = std::make_tuple(1, "str", 2.3, size_t(0));
    auto tup2 = tuple_subset(tup1, std::index_sequence<1,2>{});
    std::cout << std::get<0>(tup2) << std::endl; // Prints 'str'
    return 0;
}

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:

#include <iostream>
#include <tuple>
#include <utility>

template <typename... Args, size_t... indexes>
decltype(auto) tuple_subset(
        const std::tuple<Args...> &t,
        std::index_sequence<indexes...>
) {
    return std::make_tuple(std::get<indexes>(t)...);
}

template <size_t new_tup_len, typename... Args>
decltype(auto) tuple_truncate(
        const std::tuple<Args...> &t
) {
    return tuple_subset(t, std::make_index_sequence<new_tup_len>{});
}


int main(void)
{
    auto tup1 = std::make_tuple(1, "str", 2.3, size_t(0));
    auto tup2 = tuple_truncate<2>(tup1);
    std::cout << std::get<1>(tup2) << std::endl; // Prints 'str'
    return 0;
}

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:

#include <iostream>
#include <tuple>
#include <utility> // See above

template <typename... Args, size_t... indexes>
decltype(auto) tuple_subset(
        const std::tuple<Args...> &t,
        std::index_sequence<indexes...>
) {
    return std::make_tuple(std::get<indexes>(t)...);
}

template <typename... Args>
decltype(auto) tuple_rube_goldberg_copy(const std::tuple<Args...> &t)
{
    return tuple_subset(t, std::index_sequence_for<Args...>{});
}

int main(void)
{
    auto tup1 = std::make_tuple(1, "str", 2.3, size_t(0));
    auto tup2 = tuple_rube_goldberg_copy(tup1);
    std::cout << std::get<1>(tup2) << std::endl; // Prints 'str'
    return 0;
}

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:

// https://github.com/rpclib/rpclib/blob/master/include/rpc/detail/call.h#L27-L38

template <typename Functor, typename... Args, std::size_t... I>
decltype(auto) call_helper(Functor func, std::tuple<Args...> &&params,
                           std::index_sequence<I...>) {
    return func(std::get<I>(params)...);
}

//! \brief Calls a functor with arguments provided as a tuple
template <typename Functor, typename... Args>
decltype(auto) call(Functor f, std::tuple<Args...> &args) {
    return call_helper(f, std::forward<std::tuple<Args...>>(args),
                       std::index_sequence_for<Args...>{});
}

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.