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.


See Also