The main illusion of structured binding is treating
const auto& [a, b] = get_pair();
as two independent reference declarations a and b. It is not.
The compiler creates one hidden variable, applies every qualifier (const, &, &&) only to that variable, and turns the names in brackets into special aliases.
How the declaration expands
cv-auto& [x, y] = expr;
Step 1. Hidden variable:
cv-auto& __e = expr;
Specifiers (const, volatile, & / &&, declaration-level attributes such as [[maybe_unused]]) apply to __e, not to x / y individually.
Step 2. Identifiers x and y are structured binding identifiers. Depending on __e:
Type of __e |
Meaning of x, y |
|---|---|
| Array | __e[0], __e[1] |
Tuple-like (std::tuple, std::pair, …) |
via std::tuple_element and get<i>(__e) |
| Struct / class with public data members | direct member access |
Trap 1: decltype does not report the “visible” reference type
struct Point { int x; int y; };
Point pt{10, 20};
auto& [a, b] = pt;
Intuition: decltype(a) → int& (because of auto&).
Reality: int.
static_assert(std::is_same_v<decltype(a), int>); // ok
static_assert(std::is_same_v<decltype(a), int&>); // no
Rule: for a binding identifier, decltype yields the member type (or tuple_element), not the reference type of the binding.
Mutation still hits the original object, because access goes through members of the reference __e:
a = 99; // pt.x == 99
For the type of the access expression (with value category), use double parentheses:
decltype((a)) // int&
Trap 2: lifetime and references inside the value
struct Data { std::string text; };
Data get_data() { return Data{"Hello World"}; }
const auto& [t1] = get_data();
// Safe for the temporary Data: lifetime extension covers the hidden variable’s scope.
A different bug is when the returned object already contains dangling references to locals:
std::pair<const std::string&, int> get_ref_pair() {
std::string local = "danger";
return {local, 1}; // local is destroyed on return — UB in the return itself
}
auto [s, val] = get_ref_pair();
// structured binding does not fix a reference to a destroyed string
Trap 3: const and raw pointers
const auto& const-qualifies the hidden variable (and aliases to members), not the data behind a raw pointer:
struct Node { int* ptr; };
int value = 42;
Node node{&value};
const auto& [p] = node;
// __e: const Node&; p corresponds to int* const as a member access
*p = 100; // valid: mutates data at the address, not the pointer member itself
Trap 4: no per-name reference/copy mix
You cannot bind one name by reference and copy another in a single list:
// syntax error:
// auto [&a, b] = get_pair();
auto [a, b] = get_pair(); // both follow copy rules from __e
auto& [ar, br] = get_pair(); // both via a reference __e
auto b_copy = br; // explicit copy if needed
C++20
Capturing structured binding identifiers in a lambda was forbidden in C++17; it is allowed in C++20:
auto [id, score] = get_student();
auto print = [id, score]() {
std::cout << id << ": " << score << '\n';
};
Individual names can also carry attributes such as [[maybe_unused]].
Adapting a custom class via the tuple-like protocol is covered in the next note.