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++17Structured 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:
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:
| Specifier | Effect on __e | Effect on bindings |
|---|---|---|
auto | copy/move of the initializer | aliases into __e |
auto& | lvalue reference to initializer | aliases through __e |
const auto& | const reference; extends temporary lifetime | const aliases |
auto&& | forwarding; extends temporary lifetime | aliases 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
// 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.
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 rvalueFour 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:
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.scoreMember get — Cleaner Alternative to ADL
A member get<N>() is tried before the free-function ADL lookup, making it a cleaner encapsulation choice:
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:
for (const auto& [name, score] : leaderboard) {
render(name, score);
}Use auto&& in generic code when the value category of the range elements is unknown:
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&
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 extendedOnly 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:
auto [used, unused] = make_pair(1, 2);
// warning: 'unused' declared but not used
// [[maybe_unused]] auto [used, unused] = ... // suppresses ALL warnings, not selectiveSuppress selectively with (void)unused;, or use the C++26 wildcard below.
No Recursive Decomposition
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-formedC++26 Extensions
Structured Binding Packs (C++26, P1061)
C++26 allows a pack-expansion inside a structured binding declaration:
// 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 elementsThis 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:
// C++26
auto [_, result, _] = query_result(); // two discardsIn C++17–C++23, each _ is a distinct declaration; re-using the name in the same scope is ill-formed.
See Also
- Structured Bindings — Basics — array, aggregate, and pair/tuple decomposition
- Template Specialization — specializing
tuple_sizeandtuple_element - Ranges and Views — composing structured bindings with
views::enumerate,views::zip if constexpr— compile-time dispatch insideget<N>implementations