Jonathan Boccara's blog

The differences between tie, make_tuple, forward_as_tuple: How to Build a Tuple in C++?

Published October 16, 2020 - 0 Comments

Daily C++

Tuples are handy C++ components that appeared in C++11, and are a very useful help when programming with variadic templates.

To make things even simpler, C++ offers not one but three helpers to build tuples and make our variadic template code more expressive: std::make_tuple, std::tie and std::forward_as_tuple. All three reflect in their name the fact that they put values together to build a tuple.

But why are there three of them? It can’t be so complicated to build a tuple, right?

It turns out that those three functions help craft different sorts of tuples, and perhaps even more importantly, if in a given situation you don’t use the right one, then you may be good for undefined behaviour.

What, Undefined Behaviour, just for assembling a handful of values into a tuple?

Yes. Let’s see what this is all about.

Undefined behaviour when building a tuple the wrong way

Consider the following example of a class X that contains a tuple:

template<typename... Ts>
class X
{
public:
    explicit X(Ts const&... values);

    std::tuple<Ts const&...> values_;
};

values_ is a tuple of references (which is a legal thing, and can be useful–they came in handy in the smart output iterators library for example). This class holds references to the objects that are passed to its constructor.

Let’s try to implement the constructor.

The constructor of X receives a variadic pack of values, and has to create a std::tuple out of them. So let’s use… std::make_tuple then! This sounds like it could make a tuple for us, doesn’t it?

template<typename... Ts>
class X
{
public:
    explicit X(Ts const&... values) : values_(std::make_tuple(values...)) {}

    std::tuple<Ts const&...> values_;
};

Okay. Let’s now try to use our class, with an int and a std::string for example:

int main()
{
    int i = 42;
    auto s = std::string("universe");
    
    auto x = X<int, std::string>(i, s);
    
    std::cout << "i = " << std::get<0>(x.values_) << '\n';
    std::cout << "s = " << std::get<1>(x.values_) << '\n';
}

If all goes well, this program should output 42 and universe, because those are the contents of the tuple, right?

Here is what this program outputs:

i = -1690189040
s =

Not quite what we wanted. This is undefined behaviour. Here is the whole snippet if you’d like to play around with it.

To understand what is going on, we need to understand what std::make_tuple does, and what we should have used instead to make this code behave like we would have expected it (hint: we should have used std::tie).

std::make_tuple

As it appears in the previous example, std::make_tuple doesn’t just make a tuple. It contains some logic to determine the types of the values inside of the tuple it makes.

More specifically, std::make_tuple applies std::decay on each of the types it receives, in order to determine the corresponding type to store in the tuple. And std::decay removes the const and the reference attributes of a type.

As a result, if we pass lvalue references to std::make_tuple, as we did in the above example, std::make_tuple will store the corresponding decayed types. So in our example, std::make_tuple creates a tuple of type std::tuple<int, std::string>.

Then values_, the data member of class X, initialises all of its references (remember, it is a tuple of references) with the values inside of the unnamed, temporary tuple returned by std::make_tuple.

But this unnamed, temporary tuple returned by std::make_tuple gets destroyed at the end of the initialisation list of the constructor, leaving the references inside of values_ pointing to objects that no longer exist. Dereferencing those references therefore leads to undefined behaviour.

Note that there is an exception to the behaviour of std::make_tuple when it determines the types to store inside the tuple: if some of the decayed type is std::reference_wrapper<T>, then the tuple will have a T& at the corresponding positions.

So we could, in theory, rewrite our example with std::ref in order to create std::reference_wrappers:

#include <iostream>
#include <functional>
#include <tuple>

template<typename... Ts>
struct X
{
    explicit X(Ts const&... values) : values_(std::make_tuple(std::ref(values)...)) {}
    
    std::tuple<Ts const&...> values_;
};

int main()
{
    int i = 42;
    auto s = std::string("universe");
    
    auto x = X<int, std::string>(i, s);
    
    std::cout << "i = " << std::get<0>(x.values_) << '\n';
    std::cout << "s = " << std::get<1>(x.values_) << '\n';
}

Now this program outputs what we wanted:

i = 42
s = universe

However, we shouldn’t use that, because there is a simpler solution: std::tie.

std::tie

Like std::make_tuple, std::tie takes a variadic pack of parameters and creates a tuple out of them.

But unlike std::make_tuple, std::tie doesn’t std::decay the types of its parameters. Quite the opposite in fact: it keeps lvalue references to its parameters!

So if we rewrite our example by using std::tie instead of std::make_tuple:

#include <iostream>
#include <tuple>

template<typename... Ts>
struct X
{
    explicit X(Ts const&... values) : values_(std::tie(values...)) {}
    
    std::tuple<Ts const&...> values_;
};

int main()
{
    int i = 42;
    auto s = std::string("universe");
    
    auto x = X<int, std::string>(i, s);
    
    std::cout << "i = " << std::get<0>(x.values_) << '\n';
    std::cout << "s = " << std::get<1>(x.values_) << '\n';
}

The we get the following output:

i = 42
s = universe

Which is what we want.

What happened is that std::tie returned a tuple of references (of type std::tuple<int&, std::string&> pointing to the arguments it received (i and s). values_ therefore also references those initial parameters.

std::forward_as_tuple

There is a third helper that takes a variadic pack of values and creates a tuple out of them: std::forward_as_tuple.

To understand what it does and how it differs from std::make_tuple and std::tie, note that it has forward in its name, just like std::forward or like “forward” in “forwarding reference”.

std::forward_as_tuple determines the types of the elements of the tuple like std::forward does: if it receives an lvalue then it will have an lvalue reference, and if it receives an rvalue then it will have an rvalue reference (not sure about lvalues and rvalues in C++? Check out this refresher).

To illustrate, consider the following example:

#include <iostream>
#include <tuple>
#include <type_traits>

std::string universe()
{
    return "universe";
}

int main()
{
    int i = 42;
    
    auto myTuple = std::forward_as_tuple(i, universe());
    
    static_assert(std::is_same_v<decltype(myTuple), std::tuple<int&, std::string&&>>);
}

This program compiles (which implies that the static_assert has its condition verified).

i is an lvalue, universe() is an rvalue, and the tuple returned by std::forward_as_tuple contains a lvalue reference and an rvalue reference.

What should I use to build my tuple?

In summary, when you need to build a tuple, use:

  • std::make_tuple if you need values in the returned tuple,
  • std::tie if you need lvalue references in the returned tuple,
  • std::forward_as_tuple if you need to keep the types of references of the inputs to build the tuple.

Make sure you choose the right one, otherwise you program might end up with dragons, clowns and butterflies.

You will also like

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin