Standard Library[lib]

std::tie, std::pair and std::tuple: unpacking and comparing

Section
Standard Library [lib]
Standard
C++17
Compilers
MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
Published
Length
8 min read
In this answer10 clauses
  1. 1What std::tie does
  2. 2Structured bindings: what replaced std::tie for unpacking
  3. 3std::tie for comparisons, and operator<=>
  4. 4std::pair and std::tuple: making them, and std::get
  5. 5Returning several values: a pair, a tuple or a struct
  6. 6References that dangle: std::minmax and std::forward_as_tuple
  7. 7std::apply and std::make_from_tuple
  8. 8How big a pair and a tuple are
  9. 9What each standard changed
  10. 10Which to use

Abstract

std::tie(a, b) builds a std::tuple of references to variables that already exist, so assigning a pair or a tuple to it writes each element into them, and std::ignore skips one. Since C++17 a structured binding, auto [a, b] = f();, declares the names and unpacks in one line; std::tie remains for existing variables. Since C++20 a defaulted operator<=> compares all members in order, and for your own function’s results the Core Guidelines prefer a struct with named members.

A pair drawn as one box of two hatched cells on the left and two separate hatched boxes, the variables, on the right; an arrow runs from each cell to its own box, the upper one in blue.
Figure 1 — A pair’s two values, each going to its own variable.

std::tie(a, b) builds a tuple of references to variables you already have, so assigning a pair or a tuple to it writes each element into them, and std::ignore in a position skips that element. Since C++17 a structured binding, auto [a, b] = f();, declares the variables and unpacks in one line; std::tie remains for variables that already exist. Its other use, comparing member by member with std::tie(a.x, a.y) < std::tie(b.x, b.y), is done since C++20 by a defaulted operator<=>. std::pair holds two values and std::tuple any number, without names: for your own function’s results, return a struct.

This answer is written for C++17, which added structured bindings. std::pair dates from C++98, std::tuple, std::tie and std::ignore from C++11; what each standard added is listed near the end. Structured bindings first shipped in GCC 7, Clang 4 and Visual Studio 2017 15.3 (Since When’s C++17 table).

The samples were built with MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2 and Clang 23.1 (libc++): the first two on Windows x64 with Microsoft’s standard library, the others on Compiler Explorer, Linux x86-64. Each output shown is what all four printed unless the text says otherwise.

What changed: a pair or tuple is unpacked with std::tie(a, b) = f(); auto [a, b] = f();.

since C++17

What std::tie does

std::tie (header <tuple>, like std::ignore) returns a std::tuple of lvalue references to its arguments ([tuple.creation]: “Returns: tuple<TTypes&...>(t...)”), and a tuple can be assigned from a pair, so the assignment writes through each reference into your variable. Here std::set::insert returns a std::pair of an iterator and a bool:

[Example 1:
std::set<int> seen{2, 4};
std::cout << std::boolalpha;

std::set<int>::iterator it;
bool inserted = false;

std::tie(it, inserted) = seen.insert(7);
std::cout << *it << ' ' << inserted << '\n';    // 7 true

std::tie(std::ignore, inserted) = seen.insert(4);
std::cout << "4 inserted: " << inserted << '\n';  // false

static_assert(std::is_same_v<
              decltype(std::tie(it, inserted)),
              std::tuple<std::set<int>::iterator&, bool&>>);

— end example]

The second insert found 4 already there. std::ignore accepts any value and keeps none ([tuple.syn]), and the static_assert passed on all four.

A pair of two hatched cells, an arrow marked with an equals sign into std::tie(it, inserted), a tuple of two cells with arrows down to the variables it and inserted. Below, smaller: std::tie(std::ignore, inserted), whose first arrow ends at nothing.
Figure 2 — std::tie builds a tuple of references to your variables, so assigning a pair to it writes each value where its reference points, and std::ignore drops the value in its place.

A returned tuple unpacks the same way:

[Example 2:
// the newest reading: sensor, value, time
std::tuple<int, double, int> latest() {
    return {4, 21.5, 1200};
}
// ...
int sensor = 0;
double value = 0.0;
int time = 0;

std::tie(sensor, value, time) = latest();
std::cout << sensor << ' ' << value << ' ' << time << '\n';

std::tie(std::ignore, value, std::ignore) = latest();

— end example]

It printed 4 21.5 1200; the last line assigns value alone.

Structured bindings: what replaced std::tie for unpacking

A structured binding declares the names and unpacks in one statement. auto [a, b] = p; initializes a hidden copy of p and names the copy’s parts; auto& [a, b] = p; names p’s own ([dcl.struct.bind]):

[Example 3:
auto [it, inserted] = seen.insert(7);
std::cout << *it << ' ' << std::boolalpha << inserted << '\n';

std::pair<int, double> p{4, 21.5};      // sensor, value

auto [sensor, value] = p;               // names the parts of a copy
value = 99.0;
std::cout << "auto:  value " << value
          << ", p.second " << p.second << '\n';

auto& [sensor_ref, value_ref] = p;      // names p's own parts
value_ref = 99.0;
std::cout << "auto&: value " << value_ref
          << ", p.second " << p.second << '\n';

double window[2]{19.5, 23.0};
auto [low, high] = window;              // an array

Reading r{4, 21.5, 1200};
auto [id, reading, time] = r;           // a struct's public members

— end example]

It printed 7 true, then:

[Example 4:
auto:  value 99, p.second 21.5
auto&: value 99, p.second 99

— end example]

In a range-for over a map, auto [key, value] copies each element, so looping over a std::map takes const auto&.

std::tie is still the tool when the variables already exist, as in a loop whose last result is read after it (range() returns a std::pair<double, double>):

[Example 5:
double lo = 0.0;
double hi = 0.0;
for (const auto& batch : batches) {
    std::tie(lo, hi) = range(batch);    // assigns, declares nothing
    std::cout << lo << " to " << hi << '\n';
}
std::cout << "last batch: " << lo << " to " << hi << '\n';

— end example]

It printed 19 to 21.5, 18.5 to 25 and last batch: 18.5 to 25. A binding inside the loop would declare a new lo and hi, gone at its closing brace.

It is also the tool for skipping an element. A binding names every element, and before C++26 each name must be new in its scope. C++26 lets _ be declared again in a block, so auto [added, _] = seen.insert(7); may be followed by auto [found, _] = seen.insert(4);.

std::tie for comparisons, and operator<=>

Tuples compare lexicographically, so std::tie(a.x, a.y) < std::tie(b.x, b.y) compares the xs and looks at the ys only when the xs are equal. That is the classic way to write operator<, the one cppreference’s std::tie page shows. Since C++20 a defaulted operator<=> does it for every member, in declaration order:

[Example 6:
namespace with_tie {
struct Reading {
    int sensor;
    double value;
    int time;

    friend bool operator<(const Reading& a, const Reading& b) {
        return std::tie(a.sensor, a.value, a.time)
             < std::tie(b.sensor, b.value, b.time);
    }
};
}  // namespace with_tie
// ...
namespace with_spaceship {
struct Reading {
    int sensor;
    double value;
    int time;

    auto operator<=>(const Reading&) const = default;   // C++20
};
}  // namespace with_spaceship

— end example]

As C++20, std::sort put a batch of either type in the same order, 2/25/1262 4/21.5/1100 4/21.5/1200 4/22/1260 7/19/1201 (sensor/value/time). A member added later joins the defaulted comparison; the tie must be edited. Defaulting operator<=> also declares a defaulted operator== unless the class declares one ([class.compare.default]), so == and != work too (all four, as C++20); the tie version has only <.

std::tie stays the short way to order by some of the members, or in another order:

[Example 7:
// by time, then by sensor; value plays no part
bool earlier(const Reading& a, const Reading& b) {
    return std::tie(a.time, a.sensor) < std::tie(b.time, b.sensor);
}
// ...
std::vector<Reading> batch{{7, 19.0, 1200}, {4, 22.0, 1260},
                           {4, 21.5, 1200}, {2, 25.0, 1200}};
std::sort(batch.begin(), batch.end(), earlier);

— end example]

It printed the readings by time, then sensor: 1200 2 25, 1200 4 21.5, 1200 7 19, 1260 4 22. Under earlier, two readings that differ only in value are equivalent; writing comparators for std::sort and std::map shows what that does to a std::set key.

std::pair and std::tuple: making them, and std::get

std::pair (<utility>) holds two values, first and second; std::tuple (<tuple>) holds any number, read with std::get, by position or, since C++14, by type. Since C++17, class template argument deduction takes the types from the values, so neither needs template arguments or a make_ function:

[Example 8:
std::pair p{4, 21.5};                   // std::pair<int, double>
std::cout << p.first << ' ' << p.second << '\n';
// ...
std::tuple t{4, 21.5, 1200};        // std::tuple<int, double, int>
std::cout << std::get<0>(t) << ' '      // by position
          << std::get<double>(t) << ' ' // by type
          << std::get<2>(t) << '\n';
std::cout << std::tuple_size_v<decltype(t)> << " elements\n";
// ...
auto q = std::make_pair(4, 21.5);       // the same type as p

— end example]

It printed 4 21.5, 4 21.5 1200 and 3 elements. The library hands you pairs too, each type checked with static_assert on all four:

  • std::set::insert and std::map::insert: a std::pair of an iterator and a bool
  • equal_range on a std::set: a std::pair of two iterators
  • std::minmax(1, 2): a std::pair<const int&, const int&>

std::get by type requires that “The type T occurs exactly once in Types” ([tuple.elem]), so with two doubles it is ill-formed:

[Example 9:
// sensor, lowest value, highest value
std::tuple<int, double, double> summary{4, 19.5, 23.0};
std::cout << std::get<double>(summary) << '\n';     // which one?

— end example]

All four stopped at a static assertion in the library; GCC’s reads:

[Example 10:
error: static assertion failed: the type T in std::get<T> must occur exactly once in the tuple

— end example]

Use the index, std::get<1>(summary), or a struct with names.

Returning several values: a pair, a tuple or a struct

Return a struct. Rule F.21 of the Core Guidelines: “Prefer using a named struct if possible. Otherwise, a tuple is useful in variadic templates.” The library’s newer results follow it: std::ranges::minmax_element (C++20) returns a std::ranges::min_max_result with members min and max, and std::from_chars (C++17) a std::from_chars_result with ptr and ec.

range() is written three times with the same body, returning a std::pair<double, double> (in as_pair), a std::tuple<double, double> (in as_tuple) and this struct:

[Example 11:
struct Range {
    double lo;
    double hi;
};

Range range(const std::vector<Reading>& batch) {
    auto [min_it, max_it] =
        std::minmax_element(batch.begin(), batch.end(), by_value);
    return {min_it->value, max_it->value};
}
// ...
auto p = as_pair::range(batch);
std::cout << p.first << " to " << p.second << '\n';

auto t = as_tuple::range(batch);
std::cout << std::get<0>(t) << " to " << std::get<1>(t) << '\n';

auto r = as_struct::range(batch);
std::cout << r.lo << " to " << r.hi << '\n';

auto [lo1, hi1] = as_pair::range(batch);
auto [lo2, hi2] = as_tuple::range(batch);
auto [lo3, hi3] = as_struct::range(batch);   // a struct too

— end example]

Each call printed 19 to 25, but only r.lo and r.hi say what they hold.

When the function may return nothing, or fail, the type is std::optional or, since C++23, std::expected; for one value of several types, std::variant.

References that dangle: std::minmax and std::forward_as_tuple

std::minmax(a, b) returns a std::pair<const T&, const T&> that refers to its arguments, so when one of them is a temporary, a binding to the result dangles:

[Example 12:
int n = 20;
auto [lo, hi] = std::minmax(n, n + 1);  // hi refers to n + 1: gone
std::cout << lo << ' ' << hi << '\n';   // undefined behavior

— end example]

n + 1 is destroyed at the end of the full-expression, so reading hi on the next line is undefined behavior; lo refers to n and is fine. auto does not help: the hidden object it declares is the returned std::pair<const int&, const int&> itself, a pair of references. The sample was built and never run. At /W4 /permissive- for MSVC and -Wall -Wextra for the others, at -O0 and -O2:

Build Warned How
GCC 16.2, libstdc++ yes -Wdangling-reference
Clang 19.1, Microsoft’s STL yes -Wdangling
Clang 23.1, libc++ yes -Wdangling
Clang 23.1, libstdc++ no nothing at -Wall -Wextra
MSVC 19.44 no, nor at /Wall only Code Analysis with C++ Core Check, C26815

GCC’s -Wdangling-reference, on with -Wextra, said possibly dangling reference to a temporary. Clang’s warning comes from the library: Microsoft’s STL and libc++ mark the parameters of std::minmax for it; libstdc++ does not. const int& top = std::max(n, n + 1); and const auto& [lo, hi] were caught by the same three builds.

The initializer_list overload returns values instead: auto [lo, hi] = std::minmax({n, n + 1}); binds a std::pair<int, int> and printed 20 21.

The three functions that build a tuple from arguments differ in what they keep. From int sensor, double value and the literal 21.5, checked with static_assert on all four:

Call Type Holds
std::make_tuple(sensor, value) std::tuple<int, double> copies
std::tie(sensor, value) std::tuple<int&, double&> references, to lvalues only
std::forward_as_tuple(sensor, 21.5) std::tuple<int&, double&&> what it was given

After sensor = 7;, std::get<0> printed 4 for the make_tuple result and 7 for the tie. The double&& refers to the temporary 21.5, gone at the end of the full-expression, so use std::forward_as_tuple within the full-expression that consumes it, as in emplace with std::piecewise_construct, not stored in an auto variable for later.

std::apply and std::make_from_tuple

std::apply (C++17) calls a function with a tuple’s elements as its arguments, in order:

[Example 13:
// one line of the log
void log_line(int sensor, double value, int time) {
    std::cout << "sensor " << sensor << " at " << time << ": " << value
              << '\n';
}
// ...
std::tuple<int, double, int> row{4, 21.5, 1200};
std::apply(log_line, row);              // log_line(4, 21.5, 1200)

std::apply([](const auto&... field) {   // every element, in order
    ((std::cout << '[' << field << ']'), ...);
}, row);

— end example]

It printed sensor 4 at 1200: 21.5, then [4][21.5][1200]. std::make_from_tuple<Reading>(row) builds an object from a tuple the same way. For an aggregate such as Reading it needs C++20, which lets aggregates be initialized with parentheses: all four rejected it as C++17 and built it as C++20.

How big a pair and a tuple are

A std::pair was the size of the struct with the same members on all three libraries; a std::tuple was sometimes larger, only on Microsoft’s STL. sizeof in bytes, as C++17 on x86-64 (MSVC and Clang 19.1 share Microsoft’s STL and agreed); Celsius is an empty struct:

Type Microsoft’s STL libstdc++ libc++ Struct, same members
tuple<int, double, int> 24 24 24 24
tuple<char, char, int> 12 8 8 8
tuple<char, char, double> 24 16 16 16
tuple<Celsius, double> 16 8 8 16
pair<int, double> 16 16 16 16
pair<Celsius, double> 16 16 16 16

The standard does not fix how a tuple lays out its elements ([tuple.tuple]), so the libraries differ; libstdc++ and libc++ give an empty element of a tuple no room.

What each standard changed

  • C++11: std::tuple, std::tie and std::ignore; std::pair is from C++98.
  • C++14: std::get by type, and a constexpr std::tie.
  • C++17: structured bindings, class template argument deduction, std::apply and std::make_from_tuple.
  • C++20: operator<=> for std::pair and std::tuple, a defaulted operator<=> for your own types, and lambdas that capture a structured binding.
  • C++23: tuple-like conversions, such as a std::pair<double, double> from a std::array of two; std::views::zip, whose elements were std::tuple<int&, double&>; and std::format for both, which printed (4, 21.5) and (4, 21.5, 1200).
  • C++26: a structured binding may carry an attribute, be a condition, introduce a pack or be constexpr, and _, a placeholder name for any local declaration, may name more than one in a block. All but constexpr were built as C++26 (/std:c++latest on MSVC, -std=c++2c on Clang 19.1, -std=c++26 on the others):
Form MSVC 19.44 Clang 19.1 GCC 16.2 Clang 23.1
_ twice in one block error C2374 built built built
[[maybe_unused]] on one name error C2760 built built built
a binding as an if condition error C2059 built, with a warning built built
a pack, ...rest error C2760 error built built

A binding pack is allowed only inside a template ([dcl.pre]).

Which to use

You want Write
New variables for the parts auto [a, b] = f(); (C++17); auto& to refer to the original
To assign to variables that exist std::tie(a, b) = f();
To skip an element std::ignore in std::tie; in C++26, _ in a binding (not MSVC 19.44)
A type ordered by all its members auto operator<=>(const T&) const = default; (C++20)
An order by some members, or another order std::tie of those members on each side
Two values from a library function its std::pair, unpacked with a binding
Many values of mixed types, in generic code std::tuple, with std::apply
Your own function’s results a struct with named members

More answers about the library’s types are in Standard library.

Questions

What does std::tie do in C++?

std::tie(a, b) returns a std::tuple of lvalue references to a and b. Assigning a pair or a tuple to it assigns each element to the variable its reference refers to, so std::tie(it, inserted) = s.insert(7); stores a std::set’s iterator and bool in two variables declared earlier. std::ignore in a position accepts any value and keeps none, which skips that element. Compared with another tie, it compares the variables lexicographically, the pre-C++20 way to write operator<.

Should I use std::tie or structured bindings?

Use a structured binding (C++17), such as auto [it, inserted] = s.insert(7);, when you want new variables: it declares and unpacks in one statement. Use std::tie when the variables already exist, as when a loop updates values that are read after it, and to skip an element with std::ignore where the C++26 placeholder _ is not available. A structured binding always declares new names; it cannot assign to variables you already have.

How do I ignore a value when unpacking a tuple?

Put std::ignore in its place in std::tie: std::tie(std::ignore, inserted) = s.insert(4); keeps only the bool, and it built as C++17 with MSVC, GCC and Clang. A structured binding names every element; since C++26 the name _ can stand for each element you do not want, more than once in the same block. GCC 16.2, Clang 19.1 and Clang 23.1 accepted two such bindings as C++26, and MSVC 19.44 rejected the second _ as a redefinition.

What is the difference between std::pair and std::tuple, and when is a struct better?

std::pair holds exactly two values, named first and second; std::tuple holds any number, read with std::get by position or, when the type occurs once, by type. Neither says what the values mean. For a function’s results, the C++ Core Guidelines (F.21) prefer a named struct and keep tuples for variadic templates, and a struct with public members still unpacks with a structured binding.

How do I return multiple values from a function in C++?

Return a struct with a named member for each value, such as a Range with lo and hi. A caller that wants separate variables unpacks it with a structured binding, auto [lo, hi] = range(batch);, which works on a struct as on a pair or a tuple (C++17). A std::pair or std::tuple return works too, but the caller then reads first and second or std::get by index. Rule F.21 of the C++ Core Guidelines: to return multiple out values, prefer returning a struct.

Why does std::get by type not compile?

std::get<T>(t) (C++14) requires T to occur exactly once among the tuple's element types, so on a std::tuple<int, double, double>, std::get<double> is ill-formed: there are two. Microsoft's STL, libstdc++ and libc++ each stop with a static assertion; GCC's reads: the type T in std::get<T> must occur exactly once in the tuple. Use the index, std::get<1>, or a struct whose members have names.

Read next

  1. C++ int to string: to_string, format or to_chars, not itoaStandard Library · Miriam Castellanos · written for C++17
  2. C++ string (std::string) explained with examplesStandard Library · Miriam Castellanos · written for C++17
  3. C++ string comparison: ==, compare() and strcmpStandard Library · Miriam Castellanos · written for C++17