C++23: From imperative loops to declarative ranges
Posted: 2025-10-11
Aim to describe what you want to achieve, not how
to do it. Instead of writing low-level for and
while loops, C++23 ranges lets you express your logic
directly. This makes your code cleaner, more readable, and less
error-prone by focusing on the high-level intent, not the mechanics.
What
A low-level loop is a for, while, or
do statement, such as:
std::vector<gc::ObjectMetadata> output;
for (const auto& namespace : data.namespaces)
output.push_back(namespace->object_metadata());
return output;
One should, instead, write something like:
return data.namespaces |
std::views::transform(&Namespace::object_metadata) |
std::ranges::to<std::vector>();
The | operator chains these operations, elegantly
connecting the output of one view into the input of the next. The views
are evaluated lazily as std::ranges::to produces the output
vector.
Feasibility
But don’t take this to the extreme!
This principle applies when you can readably express the computation
plainly using a combination of these functions (from
std::views and/or std::ranges):
- Core operators:
transformandfilter - Forcing evaluation:
to,for_each - Interacting with maps:
keysandvalues - Conditional checks:
any_ofandall_of. - Slicing and dicing:
take,drop,take_while,drop_while,chunk,stride - Generating new views:
iota,repeat - Combining:
join,concat,zip,chunk,slide - Others:
reverse
In my experience, this short list already covers something like 80% of the iterations in my programs.
I’m not including fold functions in my list. For those,
lower-level loops may be clearer, depending on the situation
(especially when the accumulator logic is complex).
Reason 1: Declarative code is more readable
There’s a similarity between this way of defining iterations and array languages, which put the emphasis on the operations rather than the operands. This tends to make code generally shorter and more declarative. Your expressions are framed around the computations themselves, rather than the implementation detailes. The logical structure is no longer obscured by (less important) implementation details.
Low-level loops that are trivial reflect their logic acceptably:
bool kobolds_found = false;
for (const auto& monster : monsters)
if (monster.name() == KOBOLD) {
kobolds_found = true;
break;
}
The problem is that you can’t know in advance how software will evolve —which are the trivial loops that will grow arms and legs?
And even so, I find this more clear:
bool kobolds_found = std::ranges::any_of(
monsters, [](const auto& monster) { return monster.name() == KOBOLD; })
Declarative code is more readable because of various reasons:
High-level view of the computation. Readers can immediately see the structure of the computation, without having to scan the lower-level details for things like early
returnorbreakstatements.Immutability: expressions instead of statements. This style encourages writing pure expressions that produce a new value rather than sequentially executed statements that modify state.
Lower cognitive load. You tend to introduce fewer named variables and their scope tends to be reduced.
Loop-related identifiers are constrained to lambdas that need them.
Lambdas introduced can signal explicitly the only local variables they need.
Reason 2: Complex loops stand out 🐉
The second reason is a second-order effect.
Judiciously adopting this practice makes the few remaining low-level
loop statements (which can’t be expressed with standard iteration
functions) stand out with visible “here be dragons” warnings (because
you see the explicit for or while loop),
rather than camouflaging in sea of innocuous loops.
What about performance?
The runtime efficiency of views/ranges is generally comparable to low-level loops.
I benchmarked the performance of these two blocks:
// Block 1: `std::ranges` and `std::views`:
std::ranges::copy(std::views::iota(0, kMaxElement) |
std::views::filter([](int i) { return i % 2 == 0; }) |
std::views::transform([](int i) { return i * i; }),
std::back_inserter(output));
// Block 2: Low-level `for` loop:
for (int i = 0; i < kMaxElement; ++i)
if (i % 2 == 0) output.push_back(i * i);
With 1 million elements, std::ranges took 3.386 ms and
for took 3.476 (median across 100 runs, with
std::vector::reserve, on
g++ (Debian 14.2.0-19+build5) 14.2.0 with
-O3), which are effectively identical (within the margin of
error).
Obviously, one should avoid redundantly evaluating things, but that’s true regardless of the approach taken.
Related
Concrete types yield better maintainability: Why the common advice of “express your functions in generic types” doesn’t always yield better maintainability.
Fail loudly: a plea to stop hiding bugs: We often make the mistake of hiding logical errors in our software to make it seem more robust. The thinking is simple and seductive: ignore the unexpected condition, prevent a crash, let the program continue. The end result is the opposite. guaranteeing that our system’s invariants always hold becomes more difficult.
Up: Essays