std::tuple, tie and Structured Bindings: Returning Multiple Values in C++

Key takeaways

std::tuple groups a fixed number of values of different types. Access with std::get or structured bindings, unpack into existing variables with std::tie, compare lexicographically for free, and switch to a named struct once the values leave a small scope.

What std::tuple is for

std::tuple (C++11, header <tuple>) holds a fixed number of values whose types can all differ. It is the generalisation of std::pair to any count. The number and types of elements are part of the type: std::tuple<int, double, std::string> is a different type from std::tuple<int, std::string>, and nothing about it can change at run time.

That compile-time shape is what makes tuples useful in two places: returning a few values from a function without declaring a type for them, and in generic code where a template receives “some list of types” and needs to store one value of each. Variadic templates and std::apply both lean on this; see std::apply and make_from_tuple.

#include <iostream>
#include <string>
#include <tuple>

int main() {
    std::tuple<int, double, std::string> t{42, 3.14, "hello"};
    std::cout << std::get<0>(t) << ", " << std::get<1>(t) << ", " << std::get<2>(t) << '\n';
    // 42, 3.14, hello
}

Creating tuples: explicit types, make_tuple and CTAD

There are three common ways to build one, and they differ in what types you end up with:

std::tuple<int, double> t1{42, 3.14};              // types spelled out
auto t2 = std::make_tuple(42, 3.14, "hello");      // tuple<int, double, const char*>
std::tuple t3{42, 3.14, std::string("world")};     // C++17 CTAD: tuple<int, double, std::string>

static_assert(std::tuple_size_v<decltype(t2)> == 3);

The trap is in t2: a string literal decays to const char*, not std::string. That’s harmless while the literal lives forever, but if you later do the same with a char buffer on the stack, the tuple holds a pointer into memory that goes away. When the element should own a string, say so, either with explicit template arguments or std::string(...) as in t3.

std::tuple_size_v and std::tuple_element_t<I, T> let generic code ask how many elements there are and what type each one has, both at compile time.

Accessing elements

std::get<I>(t) returns a reference to element I. Since C++14 you can also ask by type, std::get<std::string>(t), as long as that type appears exactly once.

Both forms need a compile-time argument. This is the question people ask most, and the reason is structural: element 0 might be an int and element 2 a std::string, so std::get<i> with a runtime i would have no single return type. GCC’s diagnostic:

error: no matching function for call to 'get<i>(std::tuple<int, int, double>&)'
error: 'i' is not a constant expression

Asking by a type that appears twice fails too, with a less obvious message that points into the library internals (no matching function for call to '__get_helper2<int>(...)' in libstdc++). If you really need to pick an element at run time, that’s usually a sign the data wants to be an array or a std::variant, not a tuple.

Structured bindings

C++17 structured bindings make tuple returns pleasant to consume:

std::tuple<int, std::string, bool> parseUser() {
    return {123, "Alice", true};
}

auto [id, name, active] = parseUser();

Under the hood the compiler stores the whole tuple in a hidden variable and makes id, name and active refer to its elements. auto& binds to an existing tuple instead of copying it, and const auto& extends the lifetime of a returned temporary. The structured binding post covers these qualifiers and the rules for structs and arrays.

std::tie and std::ignore

Structured bindings always declare new names. When you want to assign into variables that already exist, use std::tie, which builds a tuple of references:

std::tuple<int, double> getValues() { return {42, 3.14}; }

int x;
double y;
std::tie(x, y) = getValues();            // x = 42, y = 3.14
std::tie(std::ignore, y) = getValues();  // only y is assigned

std::ignore is an object whose assignment operator accepts anything and does nothing. It is the only way to skip an element in C++17; structured bindings require a name for every element (common workaround: [[maybe_unused]] auto [a, unused, c] = ...).

Free lexicographic comparison

Tuples compare element by element, left to right, stopping at the first difference. Before C++20’s <=>, this was the standard trick for implementing ordering on a struct without writing the cascade of ifs by hand:

struct Person {
    std::string name;
    int age;
    double height;

    auto key() const { return std::tie(name, age, height); }
    bool operator<(const Person& o) const { return key() < o.key(); }
    bool operator==(const Person& o) const { return key() == o.key(); }
};

Because std::tie holds references, key() costs nothing beyond the comparisons themselves. It also keeps < and == looking at the same members, which is the property that matters most for std::set and std::map. In C++20, auto operator<=>(const Person&) const = default; does the same in one line; see three-way comparison.

The same comparison makes tuples usable as composite keys:

std::map<std::tuple<int, std::string>, double> scores;
scores[{1, "Math"}] = 85.5;
scores[{1, "English"}] = 92.0;
scores[{2, "Math"}] = 78.5;
scores[{2, "English"}] = 88.0;

for (const auto& [key, score] : scores) {
    const auto& [id, subject] = key;
    std::cout << id << ' ' << subject << ": " << score << '\n';
}
// 1 English: 92
// 1 Math: 85.5
// 2 English: 88
// 2 Math: 78.5

The output is ordered by id first, then subject alphabetically, which is exactly the tuple’s lexicographic order. For std::unordered_map you would have to supply a hash yourself, because the standard library provides no std::hash for tuples.

Sorting a vector of tuples

A custom comparator is still needed when the order isn’t simply “element 0, then element 1”:

std::vector<std::tuple<int, std::string, double>> students = {
    {1, "Alice", 85.5}, {2, "Bob", 92.0}, {3, "Charlie", 78.5},
    {4, "David", 92.0}, {5, "Eve", 85.5}};

// Score descending, then name ascending
std::sort(students.begin(), students.end(), [](const auto& a, const auto& b) {
    if (std::get<2>(a) != std::get<2>(b)) return std::get<2>(a) > std::get<2>(b);
    return std::get<1>(a) < std::get<1>(b);
});
// Bob: 92, David: 92, Alice: 85.5, Eve: 85.5, Charlie: 78.5

This is where tuples start to hurt readability. std::get<2> means “score” only to someone who remembers the declaration. A struct with a score member would make the comparator self-documenting.

Copies versus references

The three tuple factories differ in what they store, and mixing them up is the main source of tuple bugs:

int v = 42;
auto c1 = std::make_tuple(v);          // tuple<int>: a copy
std::get<0>(c1) = 100;                 // v is still 42
auto c2 = std::tie(v);                 // tuple<int&>
std::get<0>(c2) = 100;                 // v is now 100
auto c3 = std::make_tuple(std::ref(v)); // tuple<int&>: make_tuple unwraps reference_wrapper
std::get<0>(c3) = 7;                   // v is now 7

std::forward_as_tuple also creates references, but it keeps rvalues as rvalue references. It’s designed to be consumed immediately, as in map.emplace(std::piecewise_construct, std::forward_as_tuple(key), std::forward_as_tuple(args...)). Storing its result is dangerous:

auto t = std::forward_as_tuple(std::string("temp")); // tuple<std::string&&>
// the temporary string is destroyed at the end of this line; t now dangles

The same applies to returning std::tie(local1, local2) from a function: the references point at locals that no longer exist. Compilers often don’t warn about either case. This is a nasty one to debug when I hit it: a helper that returns std::tie(...) of its own locals can appear to work in a debug build, where the dead stack slots happen to keep their old values, and then print garbage once the optimizer reuses that stack memory.

Size and layout

A tuple does not add bookkeeping on top of its elements. On a 64-bit libstdc++ build:

struct Point { int x, y; };
sizeof(std::tuple<int, int>);                      // 8, same as Point
sizeof(std::tuple<int, double, std::string>);      // 48 (4 + padding + 8 + 32-byte string)
sizeof(std::tuple<char, int, char>);               // 12, padding as for a struct

These numbers depend on the ABI, and the order elements are laid out in memory is unspecified (libstdc++ actually stores them in reverse). Don’t memcpy tuples or assume element 0 is at offset 0. If layout matters, it’s a struct’s job.

pair, tuple or struct

  • std::pair: two values with conventional meaning, most often key and value. The standard library returns pairs (map::insert, std::minmax), so you’ll use them regardless.
  • std::tuple: generic code, where the element types come from template parameters, and short-lived local groupings such as the std::tie comparison trick.
  • A named struct: everything else, especially function return values in an API. Names document meaning, adding a field doesn’t renumber existing ones, and structured bindings still work: auto [count, error] = parse(input);.

The failure I see most with tuple return types is silent reordering. Someone changes tuple<int, int> from (width, height) to (height, width), every caller still compiles because the types match, and the bug surfaces far from the change, as a stretched image or a transposed matrix. A struct with named members makes the same change a compile error at every call site that used the old names.