C++ Range-Based For Loop: auto, auto& and const auto&
The C++ range-based for loop visits each element without an index. When to use auto, auto& or const auto&, and iterating a map with structured bindings.
- Course: C++ study plan
- Module: Control flow
- Kind: Lesson
- Reading time: 13 min
- Runtime: Clang 18 · C++20
What is a range-based for loop in C++?
A range-based for loop, added in C++11, visits each element of a container in turn: for (const auto& x : v). It works on anything with begin() and end() — standard containers, std::string, built-in arrays and initialiser lists — and removes the index, the bound and the subscript. The declaration decides whether each element is copied or referenced.
Lesson
Most loops over a container do not need an index; they need each element in turn. The range-based for, added in C++11, says exactly that — for (element : container) — and removes the three places an index loop can be wrong: the start, the bound and the subscript. What it does not remove is the question of how each element is handed to you: by copy, by reference or by const reference, spelled auto, auto& and const auto&. Choosing wrongly is silent — a loop that modifies copies compiles and does nothing. This lesson covers what the loop expands to, the three declarations and when each is right, structured bindings over a std::map, when an index loop is still the answer, and why a container must not change while it is being iterated.
The form and what it expands to
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> names{"ada", "bob", "cy"};
for (const std::string& name : names) {
std::cout << name << '\n';
}
for (char c : std::string{"abc"}) std::cout << c << ' ';
for (int x : {10, 20, 30}) std::cout << x << ' ';
int fib[] = {1, 1, 2, 3, 5};
for (int f : fib) std::cout << f << ' ';
std::cout << '\n';
return 0;
}
The compiler rewrites for (decl : range) body into roughly:
{
auto&& __range = range;
auto __begin = std::begin(__range);
auto __end = std::end(__range);
for (; __begin != __end; ++__begin) {
decl = *__begin;
body
}
}
Three consequences follow. Anything with begin() and end() works: every standard container, std::string, a built-in array whose size the compiler knows, an initialiser list, a std::string_view. The end is computed once, before the first iteration. And decl is initialised from *__begin each time — which is where the copy question comes from.
auto, auto& and const auto&
std::vector<std::string> words{"one", "two"};
for (auto w : words) w += "!"; // w is a copy; words is unchanged
for (auto& w : words) w += "!"; // w refers to the element; words is modified
for (const auto& w : words) std::cout << w; // read-only, no copy
| Declaration | Meaning | Use when |
|---|---|---|
auto x | copy of each element | the element is small (int, char, a pointer) or you want a copy to alter |
auto& x | reference to the element | modifying in place |
const auto& x | read-only reference | reading anything bigger than a register — strings, vectors, structs |
The first line above is the trap: it compiles, runs, and leaves words as it was, because each w is a fresh std::string destroyed at the end of its iteration. There is no warning. The reverse mistake, auto where const auto& was meant, is invisible too and merely slow — a loop over a std::vector<std::string> with auto copies every string. The rule that avoids both: const auto& by default, auto& to modify, plain auto only for small types. Writing the type instead of auto (const std::string&, char&) is equally correct and often clearer.
std::string s = "hello";
for (char& c : s) {
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
<cctype> functions take and return int, and the unsigned char cast is the correct way to hand them a char (Module 5, lesson 3).
A map, in order, with structured bindings
A std::map<K, V> iterates in key order — that is what makes it a map and not an unordered_map — and each element is a std::pair<const K, V>. C++17 structured bindings unpack it in the declaration:
std::map<std::string, int> freq{{"pear", 2}, {"apple", 5}, {"fig", 1}};
for (const auto& [word, count] : freq) {
std::cout << word << ' ' << count << '\n'; // apple 5, fig 1, pear 2
}
for (auto& [word, count] : freq) count *= 2; // values are modifiable; keys are const
The key is const in the pair because changing it would break the tree's ordering; auto& therefore gives a mutable count and an immutable word. auto [k, v] without the reference copies every pair. Before C++17 the same loop read entry.first and entry.second. Sorted output for free is the reason the frequency-count exercise uses std::map: an unordered_map would have to be copied into a vector and sorted first, because its iteration order is unspecified and differs between runs and library versions.
When an index is still the answer
Range-for gives you elements, not positions. Reach for an index loop when the body needs:
- the position itself —
std::cout << i << ": " << v[i]; - a neighbour —
v[i] == v[i - 1]for runs, orv[i] < v[i + 1]for "is sorted"; - two ranges in lockstep —
a[i] * b[i]for a dot product; - a direction or stride — backwards, every second element, from index 3;
- to stop early at a position you then use after the loop.
A counter declared beside a range-for (std::size_t i = 0; for (const auto& x : v) { /* … */ ++i; }) is a legitimate middle ground when only the position is missing. C++20 std::views::reverse (for (int x : v | std::views::reverse)) covers "backwards"; std::views::enumerate is C++23 and not available on this track's runtime — Module 14 covers views.
Do not modify the container you are iterating
std::vector<int> v{1, 2, 3};
for (int x : v) {
if (x == 2) v.push_back(4); // undefined behaviour
}
The loop holds __begin and __end from before the first iteration. push_back may reallocate the vector's storage, after which both point into freed memory; even without reallocation, __end still points at the old end. erase is the same story with iterators that now skip or repeat. The rule: a range-for reads a container whose size does not change in the body. To build a filtered or extended result, write into a second container; to delete while walking, use explicit iterators and the it = v.erase(it) idiom (Module 13, lesson 6). Modifying the elements through auto& is fine — the container's shape is what must hold still.
What goes wrong
| Mistake | What happens |
|---|---|
for (auto s : strings) s += "x"; | Alters copies; the container is unchanged, no warning. |
for (auto s : strings) read(s); | Correct, and copies every string. Use const auto&. |
for (auto& x : {1, 2, 3}) x *= 2; | Compile error: the elements of an initialiser list are const. |
for (auto [k, v] : m) | Copies every pair; const auto& reads, auto& modifies v. |
for (int x : longLongs) | Narrows each element silently; use auto or the right type. |
push_back/erase inside the loop | Invalidated iterators: undefined behaviour. |
for (auto x : make().items()) | If items() returns a reference, the temporary make() is gone before the loop runs (C++23 fixes this); bind make() to a variable first. |
Key takeaways
for (decl : range)walksbegin()toend(), computing the end once and initialisingdeclfrom each element.const auto&to read,auto&to modify,autoonly for small types — the copy trap is silent.- A
std::mapiterates in key order;const auto& [key, value]unpacks each entry, and the key is alwaysconst. - When the body needs a position, a neighbour or two ranges at once, use an index loop.
- Never grow or shrink the container inside its own range-for.
Common questions
Should I use auto or const auto& in a range-based for loop?
Use const auto& to read, auto& to modify elements in place, and plain auto only for small types such as int or char. for (auto s : strings) s += "x"; alters copies and leaves the container unchanged with no warning, and auto over a vector of strings copies every string.
How do you iterate over a map in C++?
Use a range-based for with C++17 structured bindings: for (const auto& [key, value] : m). A std::map visits its entries in key order, and each entry is a std::pair<const K, V>, so through auto& you can modify the value but never the key.
Can you modify a vector while iterating over it with a range-based for?
You can change its elements through auto&, but not its size. push_back or erase inside the loop invalidates the iterators the loop holds — push_back may reallocate the storage — which is undefined behaviour. Build the result in a second container, or erase with explicit iterators and it = v.erase(it).
How do you get the index in a range-based for loop?
The loop hands you elements, not positions. When the body needs the position, a neighbour or two ranges in lockstep, use an index loop; when only the position is missing, keep a counter beside the loop and increment it each pass. std::views::enumerate arrives only in C++23.
What does a range-based for loop expand to?
Roughly: bind the range to auto&& __range, take std::begin and std::end once, then run for (; __begin != __end; ++__begin), initialising the declared variable from *__begin each pass. That is why the end is computed only once, and why changing the container's size breaks the loop.
Exercises
Word frequencies in order
Read an integer n, then n words (whitespace-separated, possibly across several lines). Count how often each word appears in a std::map<std::string, int> and print one line per distinct word as <word> <count>, in the map's own order — a range-based for with a structured binding const auto& [word, count]. Finish with distinct=<number of distinct words>.
Note that a std::map orders its keys with < on std::string, so uppercase letters sort before lowercase ones.
Input: n, then n words. Output: one line per distinct word, then distinct=<k>.
Example: 6 then the cat saw the dog the →
cat 1
dog 1
saw 1
the 3
distinct=4Clamp in place
Read two integers lo hi (lo ≤ hi), then an integer n (n ≥ 1), then n integers into a std::vector<int>. Clamp every element into the range [lo, hi] in place with a range-based for whose loop variable is a reference (int& or auto&) — a loop over copies would leave the vector unchanged. Then print the clamped values on one line separated by single spaces, and on the next line sum=<sum of the clamped values>; read the values for that with const auto& and keep the sum in a long long.
Input: lo hi, then n, then n integers. Output: two lines.
Example: 0 10 / 5 / -3 4 12 10 0 →
0 4 10 10 0
sum=24In this module: Control flow
- Branching — if, else and the conditional operator
- switch — labels, fallthrough and what to switch on
- Loops — for, while, do…while and the traps between them
- Range-based for — iterating without indices (this lesson)
- Loop patterns — search, accumulate, sentinel, EOF and two pointers
- Checkpoint — Control flow
← Loops — for, while, do…while and the traps between them · Loop patterns — search, accumulate, sentinel, EOF and two pointers →