C++ / CONTROL FLOW
Range-based for loops and accidental copies
Predict and control whether a range-based for loop copies each element, and choose between auto, auto&, const auto&, and auto&& deliberately.
What you will learn
- Read for (T x : r) as T x = *it; executed once per element
- Use const auto& to read, auto& to write through, auto only for cheap copies
- Spot the map trap: a spelled-out element type that mismatches value_type copies
- Reach for auto&& when elements are proxies or prvalues, as in vector<bool> or views
Understanding Range-based for loops and accidental copies
A range-based for loop is defined as sugar for an iterator loop: the range expression is evaluated once and bound to a hidden auto&& reference, begin and end are computed once before the first iteration, and then your declaration runs as if you had written T x = *it; at the top of the body. That last part is the whole lesson. The loop variable is not a window onto the container; it is a fresh object or reference initialized from one element per iteration, and which of the two you get is decided by the type you wrote, never by the container.
When you write auto, deduction drops the reference from *it and hands you the element's value type, so every iteration runs a constructor and every iteration runs a destructor. For int that is one register move and costs nothing; for std::string or std::vector<double> it is a heap allocation per element, and the compiler cannot optimize it away because the copy has its own address and possibly observable side effects. The same mechanism explains why assigning to an auto loop variable leaves the container untouched: you modified an object that dies before the next iteration, which is legal code and therefore never an error.
So pick the loop variable from intent: const auto& to inspect, auto& to modify in place, auto only when copying is trivial or you genuinely want scratch space. Spelling the element type out by hand is where the trap closes, because a declared type that is merely convertible from the element rather than identical to it makes the compiler materialize a temporary and bind your reference to that temporary; iterating a std::map<std::string, int> with const std::pair<std::string, int>& copies every key despite the ampersand. In generic code, or over ranges whose dereference yields a proxy or a prvalue, auto&& is the honest spelling: it binds to lvalues and rvalues alike and copies nothing.
<iostream>
<string>
<utility>
<vector>
struct Tag {
std::string name;
explicit Tag(std::string n) : name(std::move(n)) {}
Tag(const Tag& other) : name(other.name) {
std::cout << "copy " << name << '\n';
}
};
int main() {
std::vector<Tag> tags;
tags.reserve(3); // so vector growth cannot add copies of its own
tags.emplace_back("alpha");
tags.emplace_back("beta");
tags.emplace_back("gamma");
std::cout << "by value:\n";
for (Tag t : tags)
std::cout << " " << t.name << '\n';
std::cout << "by const reference:\n";
for (const Tag& t : tags)
std::cout << " " << t.name << '\n';
}
The type written in the loop head, not the container, decides whether each iteration binds the element or copy-initializes a throwaway object from it.
Worked examples
Lost writes
Shows that mutating an auto loop variable changes a per-iteration copy, while auto& writes into the container.
<iostream>
<vector>
int main() {
std::vector<int> v{1, 2, 3};
for (auto x : v)
x *= 2;
std::cout << "after the auto pass:";
for (int n : v)
std::cout << ' ' << n;
std::cout << '\n';
for (auto& x : v)
x *= 2;
std::cout << "after the auto& pass:";
for (int n : v)
std::cout << ' ' << n;
std::cout << '\n';
}
Example explained
Line 1for (auto x : v) deduces int and copy-initializes x from each element, so x *= 2 doubles a local that is destroyed at the end of the iteration.
Line 2The vector still prints 1 2 3, and there is no diagnostic because writing to your own local variable is perfectly well-formed.
Line 3for (auto& x : v) makes x an int& bound to the element itself, so x *= 2 stores through the reference into the vector's buffer.
Line 4The printing loops use int by value on purpose: for a fundamental type the copy is cheaper than the indirection through a reference.
The map pair mismatch
Proves that const std::pair<std::string, int>& binds to a temporary copy of a map element, while const auto& binds to the element.
<iostream>
<map>
<string>
int main() {
std::map<std::string, int> m{{"ada", 36}, {"alan", 41}};
std::cout << std::boolalpha;
for (const std::pair<std::string, int>& kv : m)
std::cout << "spelled out: " << kv.first
<< " aliases the element? "
<< (&kv.second == &m.at(kv.first)) << '\n';
for (const auto& kv : m)
std::cout << "deduced: " << kv.first
<< " aliases the element? "
<< (&kv.second == &m.at(kv.first)) << '\n';
}
Example explained
Line 1The value_type of std::map<std::string, int> is std::pair<const std::string, int>; the key is const, because changing it in place would break the tree ordering.
Line 2Binding const std::pair<std::string, int>& to that element needs a conversion, so a temporary pair is constructed (copying the key string) and the reference is bound to it, which is why the address check reports false.
Line 3const auto& deduces const std::pair<const std::string, int>&, an exact match that binds directly to the node's data, so the address check reports true.
Line 4clang flags the first loop with -Wrange-loop-construct; this is one of the few accidental copies a warning flag will find for you.
Structured bindings do not change the rule
Demonstrates that auto [k, v] destructures a copy of the element while auto& [k, v] destructures the element in place.
<iostream>
<map>
<string>
int main() {
std::map<std::string, int> scores{{"ada", 1}, {"grace", 2}};
for (auto [name, score] : scores)
score += 10;
for (auto& [name, score] : scores)
score += 100;
for (const auto& [name, score] : scores)
std::cout << name << " = " << score << '\n';
}
Example explained
Line 1auto [name, score] copies the whole pair first and then names the copy's members, so score += 10 is discarded with the copy.
Line 2auto& [name, score] binds to the element, so score += 100 lands in the map; name is deduced as const std::string& because the key half is const.
Line 3The printed values are 101 and 102, not 111 and 112, which shows exactly one of the two passes reached the container.
Line 4ada comes before grace because std::map iterates in key order, not insertion order.
Important notes
For std::vector<bool>, dereferencing yields a proxy object by value, so auto& does not compile; use auto&& or plain auto, and note the surprise that a copied proxy still refers to the same bit, so writing through it does change the container.
Before C++23 only the outermost temporary in the range expression is lifetime-extended, so for (auto c : make_config().tags()) iterates into a destroyed object; C++23 extends every temporary there, but the portable fix is to bind the intermediate object to a named variable first.
Common mistakes
Writing for (auto x : v) x = something; and expecting v to change; the write lands on a copy that is destroyed at the end of the iteration, so the container is unchanged and no error is reported.
Naming a map element as const std::pair<std::string, int>&; the real element type has a const key, so each iteration builds a converted temporary and copies the key string while the ampersand makes the loop look copy-free.
Calling push_back or insert on the container being iterated; begin and end are evaluated once before the loop, so a reallocation leaves both iterators dangling and the loop reads freed memory.
Try it yourself
Change, predict, then run
Fill a std::vector<std::string> with three names, append an exclamation mark to every element with for (auto& s : names), and print the vector to confirm it worked. Then change auto& to auto, rerun, and watch the appends vanish.
Open the C++ workspaceCheck your understanding
A std::map<std::string, int> holds a thousand entries. Which loop head reads every entry without copying a single string?
- for (auto entry : m)
- for (const std::pair<std::string, int>& entry : m)
- for (const auto& entry : m)
- for (std::pair<const std::string, int> entry : m)
Show answer
The element type is std::pair<const std::string, int>, and const auto& deduces exactly that and binds to the node's data, so nothing is constructed. Option 1 is the tempting one because of the ampersand, but the missing const on the key makes the declared type merely convertible, so the compiler materializes a converted pair per iteration and the reference just extends that temporary's life; option 3 names the type correctly but takes it by value, which copies anyway.