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):

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:

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.