· 6 min read ·

C++ Ranges at Five: Composable by Design, Complicated in Practice

Source: isocpp

The C++20 ranges library landed with substantial promise. Hannes Hauswedell’s talk at MeetingC++ 2025 revisits that promise five years on, and it provides a useful occasion to examine what the design decisions actually bought us and what they cost.

The ranges library is not a single feature. It is a collection of related decisions: lazy range adaptors, the pipe operator for composition, sentinel-based iteration, a concept hierarchy for range categories, and borrowed ranges for memory safety. Each of these came with tradeoffs, and five years of production usage has made those tradeoffs legible.

What the Library Set Out to Do

The design traces its roots to Eric Niebler’s ranges-v3 library, which began around 2014 and grew into the proposal paper P0896R4, “The One Ranges Proposal”, merged into C++20. The core goal was functional-style composition without intermediate allocations. Instead of:

std::vector<int> evens;
for (int x : v) {
    if (x % 2 == 0) evens.push_back(x);
}
std::vector<int> doubled;
for (int x : evens) {
    doubled.push_back(x * 2);
}

you write:

auto result = v | std::views::filter([](int x){ return x % 2 == 0; })
                | std::views::transform([](int x){ return x * 2; });

The second version is lazy. No intermediate vectors are allocated. result is a view that produces values on demand during iteration. This was the design bet: that lazy evaluation through nested view types would be worth the complexity it introduced.

The Sentinel Decision

One of the less-discussed but structurally important decisions in C++20 ranges was allowing sentinel types that differ from the iterator type. In classic C++ iteration, begin() and end() return the same type. The ranges model allows end() to return a different type, where the only requirement is that the iterator can be compared against it.

This enables patterns that were awkward before. An unbounded range like std::views::iota(0) does not need to store an impossible end iterator; its sentinel is simply std::unreachable_sentinel_t. Null-terminated string parsing becomes natural: iterate until the sentinel condition is true rather than computing a length upfront.

The cost is that some algorithms, particularly those requiring a common range where iterator and sentinel have the same type, need std::views::common to adapt views back to the classic model. That wrapper adds friction at boundaries with older code, and the mental overhead of explaining why v.end() and views::filter(v, pred).end() return different types is real when onboarding developers who learned iterators the traditional way.

The Const Problem

Five years in, const-correctness is the most consistent pain point. Many range adaptors in C++20 are not const-iterable. std::views::filter is the canonical case. Its begin() caches the first iterator satisfying the predicate, which means the view must be mutable to call begin() at all.

void process(std::vector<int>& v) {
    auto filtered = v | std::views::filter([](int x){ return x > 0; });
    for (int x : filtered) { /* fine */ }
}

void process_const(const auto& filtered_view) {
    for (int x : filtered_view) { /* compile error */ }
}

The caching is a performance optimization: without it, every call to begin() on a filtered range would scan from the start. But the consequence is that filter_view objects cannot be passed through const references, cannot be stored as const members, and cannot be iterated safely in parallel. This was known at standardization time and was judged acceptable. In practice, it creates situations where the library forces you to reason about mutability in ways that contradict standard C++ conventions around const.

C++23 did not fix this. The caching behavior is part of the specified semantics and changing it would be a breaking change. It is the kind of decision that looks reasonable in isolation and reveals its costs only once developers try to integrate views into larger codebases with established patterns around const correctness.

Borrowed Ranges and Dangling Iterators

The borrowed range concept is one of the more elegant safety features in the design. When you call a range algorithm on a temporary:

auto it = std::ranges::find(get_vector(), 42);

If get_vector() returns a temporary std::vector<int>, any iterator into it becomes invalid the moment the expression ends. The ranges library handles this by returning std::ranges::dangling instead of an iterator when the input range is a temporary that does not model borrowed_range. What would have been undefined behavior becomes a compile error.

Borrowed ranges are those that do not own their data, like std::string_view or std::span, or types that explicitly opt in via the enable_borrowed_range trait. The distinction is useful and the safety guarantee is real. The cost is a new concept developers must understand before they can interpret error messages involving dangling iterators, and an occasional need to annotate custom range types to participate in the model.

Compile Times and Type Complexity

The most common practical complaint is compile time. A chain of range adaptors produces a deeply nested template type. A pipeline like:

auto view = v | views::filter(pred)
              | views::transform(f1)
              | views::chunk(4)
              | views::transform(f2);

produces a type roughly equivalent to transform_view<chunk_view<transform_view<filter_view<vector<int>&, ...>, ...>, ...>, ...>. This is not a runtime problem; it is a compile-time problem because each instantiation requires the compiler to work through substantial template machinery, including concept checking at every layer.

Community benchmarks and conference presentations consistently show that ranges-heavy code compiles measurably slower than equivalent loop-based code, with the gap widening as pipeline depth increases. GCC and Clang have improved their handling of ranges over successive releases, but the fundamental issue is structural.

Rust takes a similar lazy iterator approach. The difference is that Rust’s monomorphization model tends to produce cleaner diagnostics when something goes wrong, partly because the iterator adapter types have cleaner names and partly because type inference handles more of the annotation burden. The Java Streams API avoids compile complexity by erasing types at the stream boundary, at the cost of runtime overhead through virtual dispatch. C++ chose to preserve full type information, which maximizes optimization opportunities at runtime but amplifies compile complexity proportionally with pipeline depth.

What C++23 Fixed

The most practically significant addition in C++23 was std::ranges::to. Without it, materializing a lazy range into a container required manual iteration:

// C++20
std::vector<int> result;
std::ranges::copy(v | views::filter(pred), std::back_inserter(result));

// C++23
auto result = v | views::filter(pred) | std::ranges::to<std::vector>();

This was a known gap in C++20. The absence made ranges feel incomplete for any workflow involving collecting results, since the lazy model is only useful if you can eventually terminate it into something concrete without ceremony.

C++23 also added std::views::zip, std::views::enumerate, std::views::chunk, std::views::slide, std::views::stride, std::views::cartesian_product, and std::views::adjacent, along with fold algorithms (std::ranges::fold_left, std::ranges::fold_right) and std::ranges::contains. The library went from a functional but sparse foundation to something considerably more complete.

C++23 also added std::generator, which integrates with the range model and allows ranges to produce values through coroutine suspension rather than explicit iterator state machines. That fills a category that was otherwise awkward to implement with view types alone.

The Five-Year Verdict

The core design holds up. Lazy evaluation through composable view types with a concept-constrained interface abstracts over the relevant algorithmic properties correctly. The pipe syntax makes intent legible and the range concept hierarchy maps usefully onto what algorithms actually need from their inputs.

The places where the design created lasting pain are more specific. The const-iterable problem from filter_view’s caching is a genuine design error that will be difficult to correct without breaking changes. The compile-time cost is structural and has not been resolved. The friction at boundaries between range adaptors and classic iterator-pair algorithms creates overhead in mixed codebases.

C++23 addressed the most glaring gap with std::ranges::to and extended the adaptor vocabulary considerably. C++26 has proposals in progress, including std::views::concat for joining heterogeneous ranges and further algorithm additions. The trajectory is clearly toward a more complete and polished library.

C++ ranges are better today than they were in 2020, and the foundational bet on lazy, composable, zero-overhead iteration was correct. The remaining friction sits in specific implementation decisions, not in the overall model. That is a better position to be in than if the design itself were the problem.

Was this interesting?