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
- 1What std::tie does
- 2Structured bindings: what replaced std::tie for unpacking
- 3std::tie for comparisons, and operator<=>
- 4std::pair and std::tuple: making them, and std::get
- 5Returning several values: a pair, a tuple or a struct
- 6References that dangle: std::minmax and std::forward_as_tuple
- 7std::apply and std::make_from_tuple
- 8How big a pair and a tuple are
- 9What each standard changed
- 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.

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::, 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::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:
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 returned tuple unpacks the same way:
// 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]):
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:
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::):
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:: 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:
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:
// 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:
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::insertandstd::map::insert: astd::pairof an iterator and aboolequal_rangeon astd::set: astd::pairof two iteratorsstd::minmax(1, 2): astd::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:
// 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:
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:: (C++20) returns a std:: with members min and max, and std::from_chars (C++17) a std:: with ptr and ec.
range() is written three times with the same body, returning a std:: (in as_pair), a std:: (in as_tuple) and this struct:
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:: that refers to its arguments, so when one of them is a temporary, a binding to the result dangles:
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:: 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:: and const auto& [lo, hi] were caught by the same three builds.
The initializer_list overload returns values instead: auto [lo, hi] = std:: 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:: |
std:: |
copies |
std:: |
std:: |
references, to lvalues only |
std:: |
std:: |
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:: within the full-expression that consumes it, as in emplace with std::, 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:
// 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:: 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::tieandstd::ignore;std::pairis from C++98. - C++14:
std::getby type, and aconstexprstd::tie. - C++17: structured bindings, class template argument deduction,
std::applyandstd::make_from_tuple. - C++20:
operator<=>forstd::pairandstd::tuple, a defaultedoperator<=>for your own types, and lambdas that capture a structured binding. - C++23: tuple-like conversions, such as a
std::from apair<double, double> std::arrayof two;std::views::zip, whose elements werestd::; andtuple<int&, double&> std::formatfor 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 butconstexprwere built as C++26 (/std:c++lateston MSVC,-std=c++2con Clang 19.1,-std=c++26on 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:: |
| 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.
Sources
- [tuple.creation] Tuple creation functionsC++ standard working draft
- [tuple.elem] Element accessC++ standard working draft
- [dcl.struct.bind] Structured binding declarationsC++ standard working draft
- std::tiecppreference.com
- std::paircppreference.com
- Structured binding declarationcppreference.com
- F.21: To return multiple “out” values, prefer returning a structC++ Core Guidelines
- Options Controlling C++ DialectGCC documentation