C++17 Structured Bindings: How auto [a, b] Works, What It Copies, and Where It Dangles

Key takeaways

Structured bindings give names to the parts of a pair, tuple, array or struct. The key to using them correctly is the hidden object behind the names: the qualifiers you write apply to it, not to each name. This post covers the three kinds of decomposition, copy vs reference, the dangling traps, the compiler errors you will meet, and the tuple protocol for custom types.

What structured bindings do

Before C++17, getting the pieces out of a std::pair or std::tuple meant either .first/.second, std::get<0>(t), or declaring variables first and using std::tie. Structured bindings let you name the pieces directly in one declaration:

#include <iostream>
#include <string>
#include <tuple>

int main() {
    std::tuple<int, double, std::string> t = {42, 3.14, "Hello"};
    auto [i, d, s] = t;
    std::cout << i << ' ' << d << ' ' << s << '\n';   // 42 3.14 Hello
}

Where they help most is code that already deals in pairs: iterating a map, handling the (iterator, bool) result of insert, or a function that returns two related values.

std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 85}};
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << '\n';
}

name and score are far easier to read than entry.first and entry.second, particularly when a loop body is more than two lines long.

The hidden object: the one rule that explains everything

A structured binding declaration does not declare i, d and s as independent variables. The compiler creates one unnamed variable — call it e — initialized from the right-hand side, and the names you wrote become aliases for parts of e:

auto [x, y] = p;
// behaves roughly like:
auto e = p;          // one copy of the whole object
// x names e.x, y names e.y

The auto, auto&, const auto& or auto&& you write, and any const, apply to e, not to each name separately. That explains the behavior that otherwise looks surprising:

struct Point { int x, y; };

Point p{10, 20};
auto  [x1, y1] = p;   // e is a copy of p; x1 = 1 changes the copy only
auto& [x2, y2] = p;   // e is a reference to p; x2 = 100 changes p.x
x1 = 1;
x2 = 100;
std::cout << p.x << ' ' << p.y << '\n';   // 100 20

It also means you cannot pick different qualifiers per name — auto [const a, &b] is not valid syntax, and neither is writing explicit types like auto [int a, double b]. If you need one element by value and another by reference, bind the whole thing by reference and copy the one you need.

A consequence that trips people up with tuples of references: copying the tuple copies the references, not what they refer to. So auto (by value) can still modify outside data:

int n = 5;
std::tuple<int&, int> t{n, 1};
auto [r, v] = t;   // e is a copy of t, but its first element is still an int&
r = 42;
std::cout << n << '\n';   // 42

The same happens with a struct that has a reference or pointer member. “auto means a copy” is true of the hidden object, not necessarily of what the names end up touching.

Three kinds of things you can decompose

The compiler tries these in order.

Arrays

int arr[] = {1, 2, 3};
auto [a, b, c] = arr;    // e is a copy of the array; a, b, c name its elements
auto& [ra, rb, rc] = arr; // ra aliases arr[0]

This works for built-in arrays of known size. std::array also works, but via the tuple protocol below.

Tuple-like types

If std::tuple_size<E> is defined for the type, it is treated as tuple-like and each name is bound through get<I>. That covers std::pair, std::tuple and std::array, plus any type you opt in.

std::pair<int, int> divmod(int a, int b) { return {a / b, a % b}; }

auto [q, r] = divmod(17, 5);                         // q = 3, r = 2

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
auto [minIt, maxIt] = std::minmax_element(v.begin(), v.end());

std::map<std::string, int> m;
auto [it, inserted] = m.insert({"key", 10});
if (!inserted) { /* "key" already existed; it points at the old entry */ }

The insert case is a good example of a real readability win: inserted says what the bool means, where .second does not.

Simple structs

Otherwise, the type must be a class whose non-static data members are all public and all declared in the same class (either the class itself or a single base). The names bind to the members in declaration order.

struct Person {
    std::string name;
    int age;
    double height;
};

std::vector<Person> people = {{"Alice", 25, 165.5}, {"Bob", 30, 175.0}};
for (const auto& [name, age, height] : people) {
    std::cout << name << " (" << age << ", " << height << ")\n";
}

Because binding is by position, reordering the members of Person silently reorders what name, age and height mean in every binding. If age and height were both int, the code would still compile and simply swap values. This is the main argument against binding large structs you do not own: the binding is coupled to member order, which the struct’s author may consider an implementation detail. For your own small, stable types it is fine.

Compiler errors you will meet

Each of these is from GCC 10.3 with -std=c++17.

Wrong number of names.

auto [a, b, c] = Point{1, 2};
error: 3 names provided for structured binding
note: while 'Point' decomposes into 2 elements

Private members. Decomposition goes through member access, so private data is not reachable:

class Secret { int a = 1; public: int b = 2; };
auto [a, b] = Secret{};
error: cannot decompose inaccessible member 'Secret::a' of 'Secret'

Encapsulated classes need the tuple protocol (below) if you want them to be bindable.

Members spread across base and derived.

struct Base { int x; };
struct Derived : Base { int y; };
auto [a, b] = Derived{};
error: cannot decompose class type 'Derived': both it and its base class 'Base' have non-static data members

Binding a temporary with auto&.

auto& [x, y] = Point{10, 20};
error: cannot bind non-const lvalue reference of type 'Point&' to an rvalue of type 'Point'

This one is the language protecting you. const auto& or auto&& compile and are safe here: the temporary is bound directly to the hidden reference, so its lifetime is extended to the end of the scope, exactly as with an ordinary const Point& ref = Point{...}.

Assigning to a map key. In std::map<K, V>, the element type is std::pair<const K, V>, so the key is const even when you bind with auto&:

for (auto& [k, v] : m) { k = "b"; }   // error

GCC’s message is long, but the key part is operand types are '... {aka 'const std::__cxx11::basic_string<char>'}' — the const on the key’s type. Modifying v is fine; changing a key requires extracting the node (m.extract(it)) or erase-and-insert.

Where bindings dangle

Lifetime extension only applies when the temporary itself is bound to the hidden reference. It does not extend anything reached through a function call on the temporary:

struct Shape {
    Point origin{1, 2};
    const Point& getOrigin() const { return origin; }
};
Shape makeShape() { return {}; }

const auto& [ox, oy] = makeShape().getOrigin();   // dangling!

makeShape() returns a temporary Shape. getOrigin() returns a reference into it. The hidden reference binds to that Point&, not to the Shape, so the Shape is destroyed at the end of the full expression and ox, oy now name members of a dead object. GCC 10 compiles this with -Wall -Wextra without a warning. The fix is to bind by value (auto [ox, oy] = ... copies the Point before the Shape dies) or to keep the Shape in a named variable first.

The same trap appears in range-for loops over something reached through a temporary, such as for (auto& [k, v] : getConfig().entries()). Before C++23, only the outermost temporary in the range expression is kept alive; C++23 extended that to all temporaries in the range initializer, but code that must compile as C++17 or C++20 should not rely on it.

I find this dangling form the most dangerous use of structured bindings precisely because the syntax looks identical to the safe const auto& [x, y] = Point{...} case. When I see const auto& [...] = f().g() in a review, I ask for either a value binding or a named intermediate; the extra line is cheaper than a use-after-free that shows up only in optimized builds.

Other limitations worth knowing

  • Lambda capture. Capturing a structured binding name in a lambda is ill-formed in C++17 and allowed in C++20. Compilers differ in how strictly they enforce the C++17 rule (GCC 10.3 accepted it in my test even with -std=c++17, while older Clang versions reject it even in C++20 mode). The portable form is an init-capture: [x = x] or [&y = y].
  • static and thread_local. Allowed on structured bindings from C++20. GCC 10 in C++17 mode warns: structured binding declaration can be 'static' only in '-std=c++2a' or '-std=gnu++2a'.
  • constexpr. Not allowed on structured bindings in C++17 or C++20.
  • No nesting and no placeholders. You cannot write auto [a, [b, c]] = ..., and there is no standard way to skip an element before the _ placeholder proposal adopted for C++26. Unused names are harmless: GCC’s -Wunused-but-set-variable (structured binding declaration set but not used) fires only when none of the names are used.
  • Bit-fields can be bound, but you cannot take a non-const reference to one, so they behave like the underlying member does.

Making your own type bindable: the tuple protocol

For a class with private data, you can opt into decomposition by specializing std::tuple_size and std::tuple_element and providing get<I> — either as a member template or as a free function found by argument-dependent lookup.

#include <iostream>
#include <string>
#include <tuple>

class Config {
    std::string host_ = "localhost";
    int port_ = 8080;
public:
    template <std::size_t I>
    decltype(auto) get() const {
        if constexpr (I == 0) return (host_);   // parentheses: return a reference
        else if constexpr (I == 1) return (port_);
    }
};

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

int main() {
    Config cfg;
    auto [host, port] = cfg;
    std::cout << host << ':' << port << '\n';   // localhost:8080
}

A few details carry a lot of weight here:

  • std::tuple_size must be specialized as a complete type with a value member; deriving from std::integral_constant is the idiomatic way.
  • The parentheses in return (host_); matter. With decltype(auto), return host_; deduces std::string and returns a copy; return (host_); deduces const std::string&.
  • if constexpr (C++17) lets one template return different types for different I. Without it you would write separate overloads or specializations of get.
  • Specializing std::tuple_size and std::tuple_element for your own types is one of the few cases where adding to namespace std is explicitly permitted.

Once a type has this protocol, it also works with generic code that uses std::tuple_size — but not with std::get<0>(cfg), which is only defined for the standard types. If you want that too, you need a free get in your own namespace and callers must use unqualified get<0>(cfg).

When to use them, and when not to

Structured bindings are at their best when the pieces already have obvious names and the grouping is temporary: map entries, insert results, minmax_element, or a small helper that returns two values. They are weaker when the right-hand side is a large struct whose member order you do not control, and they are no substitute for a named return type — if a function returns std::pair<bool, std::string>, every caller has to remember which is which. A small struct such as struct ParseResult { bool ok; std::string message; }; documents itself and still supports auto [ok, message] = parse(...) at the call site.