· 6 min read ·

C++ Ranges at Five: The Design Decisions That Aged Well and the Ones Still Causing Pain

Source: isocpp

The C++20 ranges library landed with a clear promise: composable, lazy, zero-overhead sequence processing expressed through a readable left-to-right pipeline syntax. Five years on, that promise has been kept in the ways that matter most, but some of the design decisions made to get it shipped have left a persistent tax on everyday usability. Hannes Hauswedell’s retrospective talk at MeetingC++ 2025, covered by isocpp.org, is a useful occasion to take stock of what held up and what the committee now has to live with.

What the Pipe Syntax Actually Gave Us

The central user-facing feature is operator| as a composition mechanism. A pipeline like this:

auto result = input
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; })
    | std::ranges::to<std::vector>();

creates no temporary containers and performs no work until the to<std::vector>() at the end pulls elements through. Each step in the chain is represented as a view wrapping the previous step. The underlying type of the full pipeline is something like std::vector<int> after collection, but the intermediate type is a deeply nested transform_view<filter_view<ref_view<vector<int>>>>. You use auto throughout and largely pretend the nesting is not there.

The lazy evaluation semantics come from how view types implement their iterators. Calling ++ on a filter_view iterator advances the underlying iterator until the predicate matches. Nothing runs until you iterate. This is not a new idea, and Eric Niebler’s range-v3 library proved the model worked in practice years before standardization. What C++20 did was take that model, thread it through the concept machinery, and make it part of the standard library.

The algorithmic half of the ranges design deserves separate recognition. Every constrained algorithm in std::ranges:: accepts an optional projection argument, applied to elements before comparison:

std::ranges::sort(people, {}, &Person::age);

This single feature eliminates an enormous amount of boilerplate. Before projections, sorting by a struct member meant writing a comparator lambda or a full function object. Now the call site is self-documenting. Projections are universally considered one of the best additions in C++20.

The sentinel type design is another genuine win. By allowing end() to return a different type than begin(), the ranges library can represent null-terminated strings, infinite sequences, and predicate-terminated sequences as first-class ranges. std::unreachable_sentinel_t compares false with any iterator, and a compiler can see through it to elide the loop termination check entirely in optimized builds. This is not a convenience feature; it is a correctness and performance feature with real payoff.

The dangling iterator protection mechanism is similarly underappreciated. When a ranges algorithm receives an rvalue range that does not model borrowed_range, it returns std::ranges::dangling instead of an iterator. Code that tries to use the result fails at compile time rather than silently dereferencing freed memory. That is the kind of zero-cost safety mechanism that makes the complexity of the design worthwhile.

The Const-Iteration Problem

The most consistently frustrating design decision in C++20 ranges is the non-constness of begin() on views like filter_view. The standard requires that begin() be O(1) amortized. For a filter_view, finding the first element that passes the predicate requires scanning forward. The solution chosen was to cache the result of the first begin() call inside the view, which makes begin() a mutating operation.

The consequence is that this does not compile:

const auto filtered = input | std::views::filter(pred);
for (int x : filtered) { ... }  // error: begin() is not const

Users expect const to mean “I won’t modify the elements.” Instead, it means “I cannot iterate at all.” The C++ standard’s own documentation for filter_view notes that const filter_view is not a range, which surprises nearly everyone who encounters it for the first time. Passing filtered views to functions as const& is a trap. The correct idiom is auto&, which is not how C++ programmers typically think about read-only function arguments.

This problem is not unique to filter_view. drop_while_view and chunk_by_view have the same constraint for the same reason. The committee chose the caching approach over the alternative of relaxing the O(1) complexity requirement, and that choice has compounded across every release since.

The split_view Redesign

The original C++20 split_view was designed to be maximally lazy, which meant its inner ranges were input_range only. The practical consequence was that you could not extract std::string_view slices from a split string, because string_view requires a contiguous_range. The most natural use case for string splitting was broken.

P2210 addressed this as a defect report against C++20, landing in C++23. The redesigned split_view is genuinely usable:

std::string s = "one,two,three";
for (auto part : s | std::views::split(',')) {
    std::cout << std::string_view(part) << '\n';
}

The original over-lazy design was renamed to lazy_split_view and is now mainly used for input ranges like std::istream_iterator. The existence of two split views with confusingly similar names is the ongoing maintenance cost of having shipped the first version without adequate real-world validation.

What C++23 Fixed

C++23 significantly expanded the adaptor vocabulary while addressing several of the gaps from C++20. The most consequential addition is ranges::to<Container>(), which collects a pipeline into a container. The absence of this in C++20 required users to use std::back_inserter or manual loops, which undermined the whole composability story. You could write a beautifully readable pipeline and then have to break out of it at the end to actually store the results.

The new adaptors cover cases that were conspicuously absent. views::zip zips multiple ranges into a view of tuples. views::enumerate produces (index, element) pairs. views::chunk and views::slide cover non-overlapping and overlapping windows respectively. views::cartesian_product handles N-ary products. std::generator makes it possible to write a lazy sequence as a coroutine, which is far more readable than implementing an iterator type manually.

C++23 also added std::ranges::range_adaptor_closure, a CRTP base that allows user-defined types to participate in the | pipe syntax without depending on implementation internals. Before this, writing a custom composable adaptor meant either reimplementing the pipe mechanism or using non-portable library facilities.

The fold algorithms, ranges::fold_left and ranges::fold_right, replaced the awkward relationship between std::accumulate and the ranges era. The original std::accumulate never got a constrained range version; the fold family is the proper replacement.

What C++26 Is Adding

C++26 continues the trajectory. views::concat concatenates heterogeneous-but-compatible ranges into a single view, addressing a gap that range-v3 users had long taken for granted. views::cache_latest solves a real performance problem: in a transform | filter pipeline, the filter predicate accesses the transformed element to test it, and if it passes, the downstream code accesses it again, causing the transform function to run twice. cache_latest inserts a single-element cache between the two stages.

views::to_input deliberately downgrades a range to single-pass input_range semantics, which is useful when you want to eliminate the overhead that higher iterator categories impose on adaptors that do not need them. std::optional also gains a view specialization in C++26, allowing it to participate in pipelines as a zero-or-one-element range.

A Comparison with Rust

Rust’s iterator model is worth considering here because it represents a different set of trade-offs. The Iterator trait requires exactly one method: next() returning Option<Self::Item>. That is the entire contract. Writing a custom iterator adapter in Rust is a single impl Iterator for T block. The simplicity is real.

What Rust gains in simplicity it gives up in expressiveness. There is no equivalent to C++‘s iterator categories, so there is no compile-time guarantee that a bidirectional operation is available on a given iterator. Rust’s ownership model provides the equivalent of borrowed_range at the language level, which is a stronger guarantee than C++‘s opt-in annotation, but it is also why the three iter()/iter_mut()/into_iter() variants exist as a fixed pattern rather than being derivable from generic properties.

Neither approach is strictly better. C++‘s complexity enables zero-cost abstractions that Rust’s simpler model sometimes cannot express without unsafe code. Rust’s simplicity means that almost anyone can write a correct custom iterator in an afternoon, while writing a correct C++ view type requires understanding a dozen concepts and their interactions.

Where Things Stand

The C++20 ranges design achieved its primary goals. Lazy composition, sentinel types, constrained algorithms with projections, and compile-time dangling detection are all delivering value. The ranges::to gap in C++20 was real and C++23 closed it. The const-iteration problem, though, has not been fixed and remains the most common source of confusion for developers new to the library.

Hauswedell’s retrospective is a useful reminder that standardization involves committing to a design before its full production cost is understood. The ranges library is getting better with each revision; the core vocabulary is now substantially more complete. The design debt from the caching decision is not going away, but the C++ community seems to have accepted it as the price of the O(1) complexity guarantee, and the broader library continues to grow around it.

Was this interesting?