Skip to content
C++
Language
since C++17
Intermediate

Structured Bindings — Advanced

"Deep dive: the tuple protocol, custom type support, reference semantics, the hidden anonymous object, and C++26 binding packs."

Structured Bindings — Advancedsince C++17

Structured bindings introduce a named anonymous object from which each binding name is drawn, following one of three resolution strategies — array indexing, aggregate member access, or the tuple protocol — and the entire mechanism is governed by the lifetime of that hidden object.

Overview

The Hidden Anonymous Object

Every structured binding declaration first creates an anonymous entity. Understanding this is the key to reasoning about lifetimes and mutation:

cpp
auto [a, b] = std::pair{1, 2.0};
// Compiler introduces:
//   auto __e = std::pair{1, 2.0};  // copy/move-initialized
//   int&    a = __e.first;         // binding refers into __e
//   double& b = __e.second;

The names a and b are not independent copies — they refer into __e. __e owns the storage; the bindings are aliases into it. This makes the specifier on the declaration critical:

SpecifierEffect on __eEffect on bindings
autocopy/move of the initializeraliases into __e
auto&lvalue reference to initializeraliases through __e
const auto&const reference; extends temporary lifetimeconst aliases
auto&&forwarding; extends temporary lifetimealiases through __e

The Three Resolution Strategies

Arrays (C++17): Bindings are __e[0], __e[1], etc. The count must match exactly.

Aggregates (C++17): Bindings map to non-static data members in declaration order. The type must have no user-provided constructor, no private or protected members, and no virtual functions. Base classes are supported since C++20.

Tuple protocol (C++17): Applies when std::tuple_size<T> is specialized. Each binding is initialized from get<N>(__e).


Syntax

cpp
// Array
int arr[3]{10, 20, 30};
auto [a, b, c] = arr;        // copies arr into __e
auto& [x, y, z] = arr;       // __e is reference to arr

// Aggregate
struct Vec3 { float x, y, z; };
auto [px, py, pz] = Vec3{1, 2, 3};

// Tuple protocol
auto [key, val] = std::pair<std::string, int>{"timeout", 30};

// C++17: structured binding in if-init
if (auto [it, inserted] = map.try_emplace("key", 42); inserted) {
    // use it
}

// Range-for (C++17)
for (auto& [k, v] : std::map<std::string, int>{}) {
    v *= 2;
}

Examples

Implementing the Tuple Protocol — Homogeneous Type

Specialize tuple_size and tuple_element in namespace std; put get in the type's own namespace so ADL finds it.

cpp
namespace geo {

struct Color3 {
    float r, g, b;
};

template<std::size_t I>
auto& get(Color3& c) noexcept {
    static_assert(I < 3);
    if constexpr (I == 0) return c.r;
    else if constexpr (I == 1) return c.g;
    else return c.b;
}

template<std::size_t I>
const auto& get(const Color3& c) noexcept {
    static_assert(I < 3);
    if constexpr (I == 0) return c.r;
    else if constexpr (I == 1) return c.g;
    else return c.b;
}

template<std::size_t I>
auto&& get(Color3&& c) noexcept {
    static_assert(I < 3);
    if constexpr (I == 0) return std::move(c.r);
    else if constexpr (I == 1) return std::move(c.g);
    else return std::move(c.b);
}

} // namespace geo

template<>
struct std::tuple_size<geo::Color3> : std::integral_constant<std::size_t, 3> {};

template<std::size_t I>
struct std::tuple_element<I, geo::Color3> { using type = float; };

// Usage:
geo::Color3 col{1.0f, 0.5f, 0.0f};
auto [r, g, b] = col;           // copies — r, g, b are float
auto& [dr, dg, db] = col;       // references — modifying dr modifies col.r
auto&& [mr, mg, mb] = geo::Color3{};  // move from rvalue

Four overloads are needed for correct value category propagation. Omitting the rvalue overload means get<N>(std::move(col)) falls back to the lvalue version, which compiles but produces the wrong result when the element type is move-only.

Implementing the Tuple Protocol — Heterogeneous Type

When field types differ, tuple_element must map each index to the correct type:

cpp
namespace db {

struct Row {
    std::string id;
    int         score;
    double      weight;
};

template<std::size_t I>
auto& get(Row& r) noexcept {
    if constexpr (I == 0) return r.id;
    else if constexpr (I == 1) return r.score;
    else return r.weight;
}

template<std::size_t I>
const auto& get(const Row& r) noexcept {
    if constexpr (I == 0) return r.id;
    else if constexpr (I == 1) return r.score;
    else return r.weight;
}

} // namespace db

template<>
struct std::tuple_size<db::Row> : std::integral_constant<std::size_t, 3> {};

template<> struct std::tuple_element<0, db::Row> { using type = std::string; };
template<> struct std::tuple_element<1, db::Row> { using type = int; };
template<> struct std::tuple_element<2, db::Row> { using type = double; };

// Usage:
db::Row row{"u-42", 91, 1.5};
auto& [id, score, weight] = row;
score += 5;                     // modifies row.score

Member get — Cleaner Alternative to ADL

A member get<N>() is tried before the free-function ADL lookup, making it a cleaner encapsulation choice:

cpp
struct Config {
    std::string host;
    uint16_t    port;

    template<std::size_t I>
    decltype(auto) get() & {
        if constexpr (I == 0) return (host);
        else return (port);
    }

    template<std::size_t I>
    decltype(auto) get() const& {
        if constexpr (I == 0) return (host);
        else return (port);
    }
};

template<>
struct std::tuple_size<Config> : std::integral_constant<std::size_t, 2> {};
template<> struct std::tuple_element<0, Config> { using type = std::string; };
template<> struct std::tuple_element<1, Config> { using type = uint16_t; };

Config cfg{"localhost", 8080};
auto& [host, port] = cfg;
port = 9090;

The extra parentheses in return (host) ensure decltype(auto) deduces a reference rather than a value copy.


Best Practices

Prefer const auto& for read-only range-for. It avoids copies, extends temporary lifetimes, and prevents accidental mutation:

cpp
for (const auto& [name, score] : leaderboard) {
    render(name, score);
}

Use auto&& in generic code when the value category of the range elements is unknown:

cpp
template<std::ranges::range R>
void process(R&& range) {
    for (auto&& [k, v] : range) {  // works for lvalue/rvalue elements
        consume(std::forward<decltype(v)>(v));
    }
}

Put get in the type's namespace, not in std. ADL finds it there; adding std::get overloads for non-standard types is technically undefined behaviour.


Common Pitfalls

Dangling Reference via auto&

cpp
auto& [a, b] = std::pair{1, 2};  // UB: __e is a dangling reference
                                  // (temporary not extended by auto&)

const auto& [a, b] = std::pair{1, 2};  // OK: temporary lifetime extended
auto&&      [a, b] = std::pair{1, 2};  // OK: temporary lifetime extended

Only const T& and T&& on the anonymous entity extend a temporary's lifetime. A plain auto& __e = temporary is a dangling reference from the outset.

Binding Type Is Not Always What You Expect

For the tuple protocol, the binding's type is std::tuple_element<N, T>::type, regardless of what get<N> returns. If get<0> returns const std::string& but tuple_element<0> says std::string, the binding is std::string — you get a copy. Ensure the specializations agree with the get return types.

Missing [[maybe_unused]] on Individual Bindings

In C++17 through C++23, [[maybe_unused]] cannot be applied to individual names within a structured binding:

cpp
auto [used, unused] = make_pair(1, 2);
// warning: 'unused' declared but not used
// [[maybe_unused]] auto [used, unused] = ...  // suppresses ALL warnings, not selective

Suppress selectively with (void)unused;, or use the C++26 wildcard below.

No Recursive Decomposition

cpp
std::tuple<std::pair<int,int>, double> nested{{1,2}, 3.0};
auto [p, d] = nested;
// p is std::pair<int,int> — not further decomposed
// auto [[a,b], d] = nested;  // ill-formed

C++26 Extensions

Structured Binding Packs (C++26, P1061)

C++26 allows a pack-expansion inside a structured binding declaration:

cpp
// C++26
auto [first, ...rest] = std::tuple{1, 2.0, "three"s};
// first : int
// rest  : pack {2.0, "three"}

// Sum all but the first element
auto sum = (rest + ...);

// Capture all
auto [...all] = std::tuple{10, 20, 30};
// all is a pack of three elements

This enables generic tuple manipulation without std::apply or index sequences.

_ as a Wildcard (C++26, P2169)

In C++26, _ may be declared multiple times in the same scope if it is not odr-used, making it a discard placeholder:

cpp
// C++26
auto [_, result, _] = query_result();  // two discards

In C++17–C++23, each _ is a distinct declaration; re-using the name in the same scope is ill-formed.


Custom tuple protocol

The tuple protocol is the only way to opt a type with private internals into structured bindings. Aggregates require all members to be public; the tuple protocol has no such restriction because get is a befriended or member function.

cpp
// C++17
class Token {
    std::string kind_;
    std::string value_;
public:
    Token(std::string k, std::string v)
        : kind_(std::move(k)), value_(std::move(v)) {}

    template<std::size_t I>
    decltype(auto) get() const& {
        if constexpr (I == 0) return (kind_);
        else                  return (value_);
    }
    template<std::size_t I>
    decltype(auto) get() & {
        if constexpr (I == 0) return (kind_);
        else                  return (value_);
    }
};

template<>
struct std::tuple_size<Token>
    : std::integral_constant<std::size_t, 2> {};
template<> struct std::tuple_element<0, Token> { using type = std::string; };
template<> struct std::tuple_element<1, Token> { using type = std::string; };

// Usage — private members exposed only through controlled get():
Token tok{"IDENT", "foo"};
auto& [kind, value] = tok;   // kind and value are std::string&

The key constraint: tuple_size<T>::value must be a constexpr integral constant expression, and tuple_element<I, T>::type must be provided for every I in [0, N). Missing either specialization causes a hard error at the binding site, not a substitution failure.


Structured bindings with const and ref qualifiers

The cv-ref specifier on the structured binding declaration governs the anonymous object, not each individual binding. This distinction matters when the tuple protocol returns references.

cpp
// C++17
std::pair<int, double> p{1, 2.0};

auto        [a, b] = p;   // __e is a copy of p; a,b refer into __e
auto&       [c, d] = p;   // __e is p&; c,d refer into p
const auto& [e, f] = p;   // __e is const p&; e,f are const
auto&&      [g, h] = p;   // __e is p& (lvalue overload of &&); g,h refer into p

// Extending a temporary:
const auto& [x, y] = std::pair{10, 20};  // OK: temporary lifetime extended to x,y's scope
auto&&      [u, v] = std::pair{10, 20};  // OK: same extension rule
auto&       [i, j] = std::pair{10, 20};  // UB: dangling — auto& does NOT extend temporaries

When the tuple protocol's get<N> returns a reference type and tuple_element<N> also declares a reference, using auto (copy) on the binding declaration still stores a copy of the referenced object in __e, so the bindings alias into that copy rather than the original.

cpp
// C++17
struct Ref2 {
    int& a;
    int& b;
    template<std::size_t I> int& get() { if constexpr(I==0) return a; else return b; }
};
template<> struct std::tuple_size<Ref2>  : std::integral_constant<std::size_t,2>{};
template<> struct std::tuple_element<0,Ref2>{ using type = int&; };
template<> struct std::tuple_element<1,Ref2>{ using type = int&; };

int x = 1, y = 2;
Ref2 refs{x, y};

auto  [p, q] = refs;  // p,q are int& — they still refer to x and y
auto& [r, s] = refs;  // r,s are int& — same; __e is refs&

Structured bindings in range-for loops

Range-for is the most common structured-binding site in everyday C++ code. Every iteration the binding names are re-initialized from the current element of the range.

cpp
// C++17
std::map<std::string, int> scores{{"alice", 95}, {"bob", 88}};

// Read-only iteration — const ref avoids copying the pair
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << '\n';
}

// Mutating values — ref through the map's mapped_type
for (auto& [name, score] : scores) {
    score += 5;  // modifies the map in-place
}

Be careful with proxy iterators. When a range's operator* returns a proxy object (e.g., std::vector<bool>, views::zip), auto& may not bind to the actual underlying storage.

cpp
// C++20 — zip view returns a tuple of references
std::vector<int>  ids   {1, 2, 3};
std::vector<float> vals {1.f, 2.f, 3.f};

for (auto&& [id, val] : std::views::zip(ids, vals)) {
    val *= id;   // modifies vals[i] in-place; auto&& is safest for proxy ranges
}

Range-for with enumerate (C++23) adds an index without a hand-written counter:

cpp
// C++23
for (auto&& [i, name] : std::views::enumerate(scores)) {
    std::cout << i << ": " << name.first << '\n';
}

Structured bindings in if and switch (C++17)

C++17 init-statements in if and switch combine structured bindings with scope-limiting. The binding lives for the duration of the entire if/else chain, keeping lookup results out of the enclosing scope.

cpp
// C++17 — map insertion with tightly scoped result
std::map<std::string, int> registry;

if (auto [it, ok] = registry.try_emplace("worker", 0); ok) {
    it->second = compute_initial_value();
} else {
    // 'it' still valid here — points to existing entry
    ++it->second;
}
// 'it' and 'ok' no longer in scope here

// Switch with init-statement
switch (auto [code, msg] = fetch_status(); code) {
    case 200: handle_ok(msg);    break;
    case 404: handle_missing();  break;
    default:  log_error(msg);    break;
}

Combine with std::optional to signal empty results without exceptions:

cpp
// C++17
auto try_parse(std::string_view s)
    -> std::optional<std::pair<int, std::string>>;

if (auto [opt_val] = std::tuple{try_parse("42")}; opt_val) {
    auto& [num, suffix] = *opt_val;
    use(num, suffix);
}

A simpler pattern using direct optional decomposition is not available until C++26 conditions (see below); for now nest the optional dereference as shown.


Structured bindings with std::map and std::unordered_map

Associative containers store std::pair<const Key, Value>, so the key binding is always const. Attempting to assign to it is a compile error, not a runtime one.

cpp
// C++17
std::unordered_map<std::string, std::vector<int>> index;

// Insert or default-construct, then append
for (const std::string& word : tokens) {
    index[word].push_back(line_no);
}

// Read-only traversal
for (const auto& [word, lines] : index) {
    std::cout << word << " appears on lines: ";
    for (int ln : lines) std::cout << ln << ' ';
    std::cout << '\n';
}

// Erase while iterating (C++20 erase_if)
std::erase_if(index, [](const auto& kv) {
    const auto& [word, lines] = kv;   // nested decomposition inside lambda
    return lines.empty();
});

find + structured binding avoids the double-lookup antipattern:

cpp
// C++17
if (auto it = index.find("lambda"); it != index.end()) {
    auto& [word, lines] = *it;   // dereference iterator to get pair
    lines.push_back(99);
}

Because it is an iterator, not a pair, you must dereference it first. Applying structured bindings directly to the iterator itself is a compile error.


C++26: structured bindings as conditions

P2497 (C++26) allows a structured binding declaration to appear directly as the condition of if, while, or for. The condition is satisfied when all non-discarded bindings satisfy their types' boolean conversion.

cpp
// C++26
// std::expected<Value, Error> as a condition
std::expected<Config, std::string> load_config(std::string_view path);

if (auto [cfg] = load_config("app.toml")) {
    // entered when load_config returns an engaged expected
    apply(cfg);
}

// Works with optional, too
std::optional<Connection> try_connect(std::string_view host);

while (auto [conn] = try_connect("db.local")) {
    process(conn);  // loop until connection fails
}

Prior to C++26, this required the init-statement workaround:

cpp
// C++17 workaround — verbose but equivalent
if (auto result = load_config("app.toml"); result) {
    auto& cfg = *result;
    apply(cfg);
}

The C++26 form is strictly more expressive only when the type models an explicit boolean conversion. For std::pair<bool, T> or other types without that conversion, the init-statement pattern from C++17 remains necessary.


See Also