C++ Part I · Module 9 · Arrays, Strings, and Aggregate Data
Structured Bindings
Every example since Chapter 9.7 has reached into a struct one dot at a time — r.field.width, p->field.offset — and that is correct, readable code which becomes noise in one specific situation. A function that returns a group of two or three related values hands the caller a single object, and the caller almost always wants the members rather than the object: it names a local variable for the whole thing, then immediately writes that name three times to get at what it actually came for. C++17 added a declaration form that skips the middle step and gives the members their own names directly. The first half of this chapter is that form, which is small, and the diagnostics it produces when you get the member count wrong, which are good. The second half is the part that actually matters and the reason the frozen purpose names it explicitly: writing auto here means the same thing it meant in a range-based for loop in Chapter 5.6, which is that you get copies. Measured with the allocation counter from Chapter 9.6, the copying form allocated and both reference forms did not — and when the binding is a copy, writing through one of those names changes nothing, silently.
Intermediate16 min readC++Part IFundamentalsC++17
Part I · Module 9 · Chapter 9.10 · Structured Bindings
1. The Noise This Removes
Chapter 6.3 established that a function returning several related values should return a struct rather than use output parameters. That is right, and here is what the caller then writes:
Reading r = latest();
std::cout << r.cycle << ' ' << r.value << '\n';The variable r exists for one line and is never wanted as a whole. What the caller came for is a cycle and a value. C++17 lets it say so:
auto [cycle, value] = latest();
std::cout << cycle << ' ' << value << '\n';A structured binding declares names for an object's members instead of a name for the object.
That is the whole feature, and §5 is where the care is needed.
2. The Form
auto [name1, name2, name3] = expression;The names in brackets are matched positionally to the members, in declaration order — the same rule Chapter 9.8 §3 gave for brace initialization, running the other way. There is no matching by name: the first name gets the first member, whatever either is called.
It works on three things:
A struct whose members are accessible — the usual case, and class types with public members too.
An array of known length — measured, with int arr[3]:
int arr[3] = {1, 2, 3};
auto [x, y, z] = arr; // compiles clean; x=1 y=2 z=3A std::pair or std::tuple — library types from Module 19. A std::pair holds exactly two values and is what a std::map stores, which is why the canonical example is the one in the index:
auto [key, value] = *it; // *it is a std::pairThis chapter borrows std::pair and std::map for §3 and §8 without teaching them, because they are where the feature is most used and the frozen annotation names them.
3. All Four Forms, Measured
#include <iostream>
#include <map>
#include <string>
#include <utility>
struct Reading { int cycle; double value; };
Reading latest() { return Reading{42, 3.5}; }
int main()
{
auto [cycle, value] = latest();
std::cout << "by value from a function: " << cycle << ", " << value << '\n';
Reading r{1, 2.0};
auto [c1, v1] = r; // a COPY
c1 = 99;
std::cout << "after writing through a copy, r.cycle = " << r.cycle << '\n';
auto& [c2, v2] = r; // a REFERENCE
c2 = 99;
std::cout << "after writing through a reference, r.cycle = " << r.cycle << '\n';
std::pair<std::string, int> entry{"width", 8};
const auto& [key, val] = entry;
std::cout << "a pair: " << key << " = " << val << '\n';
std::map<std::string, int> widths{{"addr", 32}, {"data", 64}};
for (const auto& [name, bits] : widths)
std::cout << " " << name << " is " << bits << " bits\n";
return 0;
}Measured output:
by value from a function: 42, 3.5
after writing through a copy, r.cycle = 1
after writing through a reference, r.cycle = 99
a pair: width = 8
addr is 32 bits
data is 64 bitsLine 2 and line 3 are the chapter. Same syntax but for one &, and writing 99 either reached the original object or vanished.
And the last three lines are the form you will write most: a range-based for over a map, where const auto& [name, bits] names the key and the value instead of leaving you to remember which of .first and .second is which.
4. The Diagnostics Are Good
Three mistakes, each measured on its own:
Wrong count. Both directions are caught, and the message is unusually direct:
auto [a] = r; error: type 'Reading' decomposes into 2 elements,
but only 1 name was provided
auto [a,b,c] = r; error: type 'Reading' decomposes into 2 elements,
but 3 names were providedThis is a real benefit over r.cycle: if a member is added to the struct, every structured binding of it stops compiling and you are told where. That is the same maintenance property Chapter 9.9 §8 got from -Wswitch, from a different direction.
Inaccessible members. You cannot decompose a type whose members are private — measured:
error: cannot decompose private member 'a' of 'Hidden'Which is the correct refusal: a type with private members has decided its members are not the interface, and Module 11 is where that decision is made. Structured bindings are for aggregates, which Chapter 9.8 §2 defined as types that are just their members.
Assigning through a const binding is refused as you would expect:
const auto [a,b] = r; a = 5;
error: cannot assign to variable 'a' with const-qualified type 'const int'One limitation worth knowing because it surprises people: you cannot ignore a member. There is no placeholder — you must name all of them. Writing auto [a, _] = r; compiles, but _ is an ordinary variable name, not a wildcard.
The compensation is that an unused binding is treated as one entity rather than several, which is visible in the message — measured with neither name used:
warning: unused variable '[a, b]'It names '[a, b]', not a and b separately. Measured, that cuts both ways:
| Code | Diagnostic |
|---|---|
auto [a, b] = r; — neither used | warning: unused variable '[a, b]' |
auto [a, b] = r; a = 5; — one used | clean |
auto [a, b] = r; std::cout << b; — the other used | clean |
So naming a member you do not use costs you nothing — no warning, no (void) cast needed — which is what makes the missing placeholder a small inconvenience rather than a real problem. The price is that you also lose per-member unused detection: the compiler will not tell you that one of the three names you wrote was never read.
5. The Half That Bites: auto Copies
This is Chapter 5.6's decision, in new syntax, with the same consequence.
Chapter 5.6 measured that for (auto x : v) copies each element and for (auto& x : v) does not, and that writing through the copy changes nothing. Structured bindings inherit exactly that, because the auto in front of the brackets qualifies the hidden object the names refer to:
| Written | The members name | Writing through a name |
|---|---|---|
auto [a, b] = r; | copies | changes the copy only |
auto& [a, b] = r; | the original members | changes r |
const auto& [a, b] = r; | the original members, read-only | refused at compile time |
§3's measurement is the proof: c1 = 99 left r.cycle at 1, and c2 = 99 set it to 99.
The & is easy to miss, and the failure is silent. There is no diagnostic for writing through a copy — it is a perfectly legal thing to do, and if your intent was to modify the original then nothing in the code says so.
6. And the Copy Has a Cost
Chapter 9.6 §4 built an allocation counter by replacing operator new. The same tool answers the question here. The struct holds a std::string with a 40-character value — past the 22-character inline capacity measured in Chapter 9.6 §4, so a copy of it must allocate:
struct Signal { std::string name; int width; };
Signal s{"a_deliberately_long_signal_name_past_sso", 32};Measured:
construction took 1 allocation(s)
auto [n,w] = s : 1 allocation(s)
const auto& [n,w] = s : 0 allocation(s)
auto& [n,w] = s : 0 allocation(s)The copying form allocated; neither reference form did. One & is the difference between a heap allocation and none, in a declaration whose purpose was readability.
And the Chapter 9.6 qualification applies here too, for the same reason: with a short name the count would have been 0 for every row, because the small-string optimization would have absorbed it. The cost is real and it is invisible in a small test.
7. It Is a Declaration, So Scope Applies
Nothing special here, but worth saying because the brackets make it look like a new kind of statement. A structured binding is a declaration, so everything in Module 7 applies unchanged: the names are block-scoped, they shadow outer names of the same name per Chapter 7.1 §6, and they live until the end of the enclosing block per Chapter 7.2.
Two consequences:
You cannot declare them and assign later. There is no auto [a, b]; — a structured binding must be initialized, like a reference.
They work in an if with an initializer, which is Chapter 5.1 §8's form:
if (auto [it, inserted] = m.insert({"addr", 32}); inserted) {
// the names are scoped to the if, and `inserted` is the condition
}That combination — C++17's if initializer with C++17's structured bindings — is the idiomatic way to call a function that returns both a result and a status, and it keeps both names out of the enclosing scope.
8. The C++ Version, Measured
Structured bindings are C++17. Compiling the same file at -std=c++14 produced, four times:
warning: decomposition declarations are a C++17 extension [-Wc++17-extensions]A warning rather than an error because clang implements it as an extension; a different compiler or -pedantic-errors may reject it. Chapter 1.3 §3 fixed this curriculum at C++17, so the feature is available throughout, but it is the kind of thing to check before using it in an existing codebase.
The pre-C++17 way to do roughly this was std::tie:
int cycle; double value;
std::tie(cycle, value) = someTuple; // the old formwhich required declaring the variables first, so it could not deduce their types and could not bind to a plain struct at all. You will meet it in older code. It is named here, not taught.
9. What to Actually Do
Use a structured binding when you want a function's returned members and not the object — it is exactly Chapter 6.3's return-a-struct advice made comfortable at the call site.
Use const auto& [a, b] in a range-based for, always, unless you are modifying elements.
Use plain auto when binding a function's return value — there is nothing to copy twice.
Use auto& when you intend to modify the original, and check for the & whenever a write seems not to take effect.
Prefer it to .first and .second when iterating a map. [name, bits] says what they are; .second does not.
Do not use it on a type with private members — it is refused, correctly.
Remember you must name every member. There is no placeholder — but an unused name costs nothing, because the warning covers the whole binding.
Treat a sudden compile error in every binding of a struct as information, not an obstacle: a member was added, and these are the places that need to know.
10. Mistakes Worth Making Once
Writing auto [a, b] and then modifying a, expecting the original to change. Measured: r.cycle stayed at 1. No diagnostic.
Writing auto instead of const auto& in a loop. Measured: one allocation per bind where there should be none — per iteration.
Testing the copy cost with a short string. The small-string optimization reports 0 either way, and the conclusion drawn is the wrong one.
Assuming names match members by name. Matching is positional, exactly as in Chapter 9.8 §3.
Trying to skip a member with _. _ is just a variable name.
Trying to decompose a type with private members. error: cannot decompose private member 'a' of 'Hidden'.
Using it on C++14 or earlier. Measured: a -Wc++17-extensions warning here, and possibly an error elsewhere.
Declaring one without an initializer. Not allowed, same as a reference.
11. Interview Relevance
"What are structured bindings?" A C++17 declaration that names an object's members instead of the object, matching positionally. Mentioning that it works on aggregates, arrays and std::pair/std::tuple is the complete answer.
"Is auto [a, b] = x; a copy?" Yes, and this is the question that is actually being asked. The strong answer gives the three forms and what each means, and ideally notes that writing through a copy fails silently.
"Why const auto& [k, v] in a loop over a map?" To avoid copying each element — and for a map whose values are strings or vectors, that copy allocates. The measurement in §6 is the kind of concrete detail that distinguishes a memorised answer.
"How is this different from std::tie?" std::tie needs the variables declared first, so it cannot deduce types and cannot bind a plain struct. Structured bindings declare and deduce in one step.
12. What You Should Be Able to Say Now
- A structured binding declares names for an object's members instead of a name for the object — C++17.
- Names are matched positionally, in declaration order. There is no matching by name.
- It works on accessible-member aggregates, arrays of known length, and
std::pair/std::tuple. - Measured: writing through a copy left
r.cycleat 1; writing throughauto&set it to 99. - Measured with an allocation counter:
auto→ 1 allocation,const auto&→ 0,auto&→ 0. - That measurement needed a string past the inline capacity — a short one reports 0 for every form.
const auto&by default,auto&to modify, plainautoonly deliberately — the same rule as Chapter 5.6 and Chapter 6.5.- Plain
autois right when binding a function's return value, because there is nothing to copy twice. - The wrong member count is a compile error that names both numbers — useful when a struct gains a member.
- Private members cannot be decomposed, which is the correct refusal.
- You cannot ignore a member;
_is an ordinary name. The unused warning covers'[a, b]'as a whole, so naming one you never read is free — and per-member unused detection is lost. - It is a declaration, so Module 7's scope rules apply, it must be initialized, and it works in an
ifinitializer. - Measured: at
-std=c++14it warnsdecomposition declarations are a C++17 extension.
13. Exercises
Exercise 1 — three forms. For Reading r{1, 2.0};, write the binding that copies, the one that allows modifying r, and the one that is read-only without copying. Say which you would write by default.
Exercise 2 — predict. Given auto [c, v] = r; c = 7; what is r.cycle? Now with auto& [c, v]. Give the reason, not just the values.
Exercise 3 — the loop. Rewrite for (const auto& p : widths) use(p.first, p.second); as a structured binding, and say what const auto& is buying for a std::map<std::string, int>.
Exercise 4 — predict the diagnostic. With struct Reading { int cycle; double value; }; say which compile and give the message otherwise: (a) auto [a] = r; (b) auto [a,b,c] = r; (c) auto [a,b]; (d) const auto [a,b] = r; a = 1; (e) auto [a,_] = r;
Exercise 5 — design the measurement. You suspect a colleague's loop copies each element. Describe how you would prove it, and state the one property your test data must have for the result to mean anything.
Exercise 6 — when the copy is right. Give a case where plain auto [a, b] = f(); is the correct choice and explain why const auto& would buy nothing.
Exercise 7 — the added member. Reading gains a third member. Say what happens to every existing auto [a, b] = ... of it, and argue whether that is a cost or a benefit.
14. Summary
- A structured binding names an object's members rather than the object — C++17, matched positionally.
- It works on aggregates, arrays, and
std::pair/std::tuple. - The
autoqualifies the hidden object, soautocopies andauto&does not. - Measured: a write through a copy left the original at 1; through a reference it became 99 — silently, with no diagnostic.
- Measured:
autocost 1 allocation, both reference forms 0 — and only with a string past the inline capacity. const auto&by default — the same rule as Chapters 5.6 and 6.5, and the language's shortest form is the expensive one.- Plain
autois right for a function's return value. - Count mismatches are compile errors; private members cannot be decomposed; no member can be skipped, though an unused name is free.
- It is a declaration: scope rules apply, initialization is required, and it combines with the
ifinitializer. - Measured:
-std=c++14warnsdecomposition declarations are a C++17 extension.
This module has now built ten chapters' worth of tools for representing data that is more than one value, and they overlap deliberately. A fixed-size table of numbers can be a built-in array or it can wait for std::vector. A piece of text can be a char array, a std::string, or a std::string_view, and all three appeared with measurements arguing for different ones. A register description can be three loose variables, a struct, or a struct with an enum class member. Every one of those chapters answered how does this work; none of them answered which should I use for the thing in front of me. The last chapter of the module is that question, and it is a judgement chapter in the sense Chapter 6.9 established — no new syntax, a small number of questions to ask about the data itself, and the module's own measurements as the evidence for each answer.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
