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.