· 7 min read ·

The For Loop's Long Road to Safety in C++

Source: isocpp

The C++ for loop is inherited directly from C, and it has accumulated decades of failure modes. Off-by-one errors, signed/unsigned mismatches, iterator invalidation, accidental copies: the traditional three-part loop exposes enough surface for bugs that entire static analysis tools exist largely to catch them. Andrzej Krzemieński’s article from December 2025 on isocpp.org revisits this problem and traces the increasingly bug-proof alternatives C++ has introduced across successive standards.

This post follows that thread further, looking at each class of loop bug in concrete terms, how each successive standard addressed it, and where the remaining rough edges still live.

The Bug Zoo Inside Three Semicolons

A traditional index-based loop looks simple but hides multiple failure modes simultaneously.

for (int i = 0; i < vec.size(); ++i) {
    process(vec[i]);
}

This compiles with a warning on most configurations because vec.size() returns std::size_t, an unsigned type, and comparing it to int i triggers a signed/unsigned mismatch. Switching to std::size_t i resolves the warning, but introduces a subtler hazard: subtraction in the loop body on an empty container underflows silently into a very large number.

The off-by-one is the more classic failure. Whether the bound uses < or <=, whether the index starts at 0 or 1, whether the limit is n or n-1: any of these gets the count wrong. Reviewers miss these, and tests often do too, especially on boundary values.

Iterator invalidation presents a different class of problem. If you erase an element from a std::vector inside a loop indexed by position, the indices shift and you skip an element. Using iterators directly and calling erase invalidates the iterator unless you capture the return value:

// Undefined behavior: iterator invalidated by erase
for (auto it = vec.begin(); it != vec.end(); ++it) {
    if (should_remove(*it)) {
        vec.erase(it); // it is now invalid; next ++it is UB
    }
}

// Correct: reassign from erase return value
for (auto it = vec.begin(); it != vec.end(); ) {
    if (should_remove(*it)) {
        it = vec.erase(it);
    } else {
        ++it;
    }
}

The correct idiom is non-obvious enough that it appears regularly in code review findings, especially in maintenance code where a removal operation is added to an existing loop body.

Range-Based For: The First Cleanup

C++11’s range-based for loop eliminates the index bookkeeping entirely:

for (const auto& elem : vec) {
    process(elem);
}

Under the hood, this desugars to approximately:

{
    auto&& __range = vec;
    auto __begin = __range.begin();
    auto __end   = __range.end();
    for (; __begin != __end; ++__begin) {
        const auto& elem = *__begin;
        process(elem);
    }
}

The signed/unsigned problem disappears because you never write a bound. The off-by-one disappears because the loop runs from begin to end by definition. The iterator invalidation bug is largely mitigated because you cannot call erase without going through the container explicitly, which at least makes the hazard visible rather than implicit.

What range-for does not protect against is the accidental copy. Writing auto elem instead of auto& elem for a container of strings or large objects copies on every iteration. This is a performance bug rather than a correctness bug, but it surfaces often enough in review to warrant a habit of always writing const auto& unless you intend to modify or move.

C++17 added structured bindings, which compose naturally with range-for:

std::map<std::string, int> scores = {{"alice", 90}, {"bob", 85}};

for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << "\n";
}

Before structured bindings you would write pair.first and pair.second, which is verbose and easy to transpose. The structured binding makes the semantics explicit and removes a small but real class of confusion bugs in map and multimap iteration.

C++20 Ranges: Iteration as a Composable Pipeline

The ranges library in C++20 represents a deeper rethink. Rather than iterating and performing operations inside the loop body, ranges let you express transformations as a pipeline of views that evaluate lazily.

#include <ranges>
#include <vector>

std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// Lazy pipeline: no intermediate allocations
auto even_squares = data
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * n; });

int sum = 0;
for (int n : even_squares) sum += n;
// sum == 4 + 16 + 36 + 64 + 100 == 220

The filter and transform views produce elements on demand as the range-for iterates. No intermediate std::vector is allocated, eliminating a whole category of bugs where intermediate containers are populated incorrectly or iterated separately from the transformation that produced them.

C++20 also introduced projection parameters to the standard algorithms, substantially reducing the need for wrapper lambdas:

struct Person { std::string name; int age; };
std::vector<Person> people = { /* ... */ };

// Sort by name field directly
std::ranges::sort(people, {}, &Person::name);

// Find by age
auto it = std::ranges::find(people, 30, &Person::age);

The second parameter is the comparator (defaulted to std::less{} with {}) and the third is the projection. The pre-ranges API required a full comparator lambda just to project on a member; the projection mechanism removes that boilerplate and makes the intent clear.

C++23 Fills the Remaining Gaps

C++20 ranges shipped missing several views that felt obviously necessary. C++23 delivered most of them.

std::views::enumerate yields index-value pairs, giving you the position alongside the element without managing a separate index variable:

for (auto [i, val] : std::views::enumerate(vec)) {
    std::cout << i << ": " << val << "\n";
}

Before this, getting an index in a range-for required either a manual counter variable (reintroducing footgun territory) or a verbose std::views::iota composition. The design went through nine revisions (P2164) before landing in C++23, mostly around the return type of the index and how it interacts with structured bindings.

std::views::zip iterates multiple ranges in lockstep:

std::vector<int> xs = {1, 2, 3};
std::vector<int> ys = {4, 5, 6};

for (auto [a, b] : std::views::zip(xs, ys)) {
    result.push_back(a + b);
}

Other C++23 additions include std::views::chunk for splitting into fixed-size sub-ranges, std::views::slide for a sliding window, std::views::stride for every nth element, std::views::adjacent for overlapping n-tuples, and std::ranges::to for collecting a view pipeline back into a container without writing a manual loop. Together these address iteration patterns that previously required carefully written index arithmetic or multiple intermediate allocations.

The Pitfalls That Remain

Modern C++ iteration is substantially safer than it was in 2003, but some hazards persist.

The ranges library introduced std::ranges::owning_view (via P2415) specifically to handle temporaries: when you pipe an rvalue range through a view adaptor, the range is owned by the resulting view rather than merely referenced. So iterating a temporary inline is safe:

// SAFE: owning_view takes ownership of the temporary vector
for (int n : std::vector{1, 2, 3} | std::views::filter(is_even)) { ... }

The danger is storing a view that references an external range that can be destroyed before the view is used:

// DANGEROUS: local vector is destroyed on return, view dangles
auto get_filtered(const std::vector<int>& src) {
    std::vector<int> local = preprocess(src);
    return local | std::views::filter(is_even);
}

The compiler does not warn about this. std::ranges::dangling catches the case where a ranges algorithm would return an iterator into a temporary, but view pipelines that outlive their underlying containers still require programmer discipline.

The std::vector<bool> specialization is another persistent problem. Range-based for over a vector<bool> with auto& does not give a reference to bool; it gives a proxy object, because vector<bool> packs bits. Code that passes the loop variable by reference to a function expecting bool& will either fail to compile or behave unexpectedly. The specialization is widely regarded as a mistake, but removing it would break ABI, so it persists.

Comparing the Design Space

A comparison with Rust’s iterator model is useful context. Rust iterators are consumed: calling .filter(), .map(), and other adapters produces a lazy chain where ownership and lifetimes are tracked statically by the compiler. The common pitfalls around dangling references or concurrent modification are caught at compile time. The Rust Iterator trait provides over 70 adapter methods, covering essentially every composition pattern C++ is gradually adding through ranges views.

C++ cannot adopt the same ownership model without abandoning decades of ABI compatibility, so the ranges library works through a different mechanism: concepts constrain what types can be used as ranges and iterators, but lifetimes still require programmer care. The result is a system substantially safer than raw loops, but short of what Rust’s borrow checker provides automatically.

Python makes iteration almost entirely safe by giving up performance guarantees. Everything is a reference to a heap-allocated object, there is no analog of undefined behavior from iterator invalidation, and the language does not support the low-level patterns that make C++ iteration dangerous in the first place. The trade-off is that Python’s iteration model cannot express zero-allocation lazy pipelines over stack-allocated memory, which is a large fraction of C++ performance-critical code.

Where This Leaves C++

The trajectory across these standards is clear. Each version since C++11 has reduced the surface area for loop bugs: C++11 eliminated most index arithmetic, C++17 made structured iteration over associative containers ergonomic, C++20 made pipeline-style iteration lazy and composable, C++23 filled in the most commonly missing views. The code you write today to iterate over a filtered and transformed range is both more readable and harder to get wrong than an equivalent loop from 2005.

What remains is mostly in the lifetime and ownership layer, where C++ has always deferred to programmer discipline. The ranges library made iteration more expressive; the language’s underlying lifetime model has not fundamentally changed. For most code, modern C++ iteration is safe enough. For the cases where it is not, you will not get a helpful compiler error; you will get undefined behavior or a subtle logical bug, which is where sanitizers, static analysis tools, and careful code review still earn their keep.

The cppreference page on ranges is the practical reference for what is available across standards. If you are not yet using views::filter, views::transform, and views::enumerate in everyday code, the friction cost is low and the safety improvement over equivalent hand-written loops is concrete.

Was this interesting?