· 7 min read ·

Five Years of C++ Range Adaptors: The Design Tensions That C++23 Quietly Resolved (And the Ones It Did Not)

Source: isocpp

Hannes Hauswedell’s retrospective talk at Meeting C++ 2025 lands at an interesting moment. Five years is long enough to separate the promise from the delivery. C++20 Ranges were the most anticipated language feature in a generation, built on years of production testing via Eric Niebler’s range-v3 library. The design inherited a pipe-operator composition model, a strict concept hierarchy, and a commitment to zero-overhead abstraction. Whether it delivered on all three is now a concrete question with concrete answers.

The short answer is: mostly yes, with notable exceptions. The longer answer involves understanding where the design made deliberate tradeoffs, where those tradeoffs were mistakes, and what C++23 had to clean up.

The Core Promise

Range adaptors in the std::views namespace are lazy transformations over sequences. Chaining filter and transform does not compute anything until you iterate:

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

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

for (int x : result) {
    std::cout << x << '\n'; // 4, 16, 36
}

The pipe operator works through range adaptor closure objects: std::views::filter(pred) returns an object that, when combined with a range via |, calls std::views::filter(range, pred). Nothing runs until the for loop drives the iterator.

The concept hierarchy lets the standard express what operations are valid on what kinds of ranges. random_access_range implies bidirectional_range implies forward_range implies input_range. An adaptor that needs to reverse a sequence requires bidirectional_range. An algorithm needing O(1) indexing requires random_access_range. This static dispatch is why range pipelines can compile to the same machine code as hand-written loops at -O2.

The sentinel concept, which allows begin() and end() to return different types, was another foundational architectural choice that has held up well. It enables null-terminated string ranges, infinite sequences like std::views::iota(0), and efficient termination conditions that do not require carrying the full iterator state in the sentinel. No serious criticism of this decision has emerged.

The const Problem

The most persistent design tension involves filter_view and const. It is also the one Hauswedell and others have documented extensively from real library code.

filter_view::begin() has to scan from the front to find the first element satisfying the predicate. To avoid rescanning on every call, the standard mandates that filter_view cache the result of the first begin() call inside the view object itself. This is necessary for performance, since begin() must be O(1) amortized for a type to model forward_range.

The consequence: calling begin() mutates the view. A const filter_view cannot be iterated. A function accepting const auto& rng will not compile if rng is a filter_view.

void print_all(const auto& rng) {
    for (const auto& x : rng) { // ERROR if rng is a filter_view
        std::cout << x << '\n';
    }
}

auto filtered = v | std::views::filter(pred);
print_all(filtered); // Does not compile

This breaks a basic idiom in C++. You cannot pass a filter_view to a function that takes its argument by const reference, which covers most of the standard library and most user-written generic code. The workaround is to take by forwarding reference instead, but that shifts the problem to the call site and produces confusing errors when people get it wrong.

The join_view has an analogous caching problem for similar reasons. These are not implementation bugs. They are consequences of a deliberate design decision, and changing them would require either accepting O(n) begin() calls or abandoning the concept refinement hierarchy.

Hauswedell maintains SeqAn3, a genomics library built heavily on ranges, and has documented how this limitation forced awkward API designs. When your library is built on range composition and a core adaptor cannot be passed by const reference, the effects propagate through every abstraction built on top of it.

The split_view Lesson

The split_view redesign between C++20 and C++23 is the clearest case study in the cost of shipping a design before the common use cases were fully explored.

C++20’s views::split was designed to work with lazy, potentially infinite ranges. As a result, its interface was awkward for the obvious application: splitting strings. The inner ranges it produces are lazy forward_range views, not substrings. Converting them to std::string_view required extra work. The delimiter matching semantics were designed for the general case, not the string case.

The community pushback was immediate and sustained. The result in C++23 was a partial rename: the original C++20 split_view became views::lazy_split, and a new views::split was added with behavior that actually works for splitting strings:

// C++23 views::split - finally useful for strings
std::string s = "one,two,three";
for (auto part : s | std::views::split(',')) {
    std::string_view sv(part.begin(), part.end());
    std::cout << sv << '\n';
}

This is not a subtle refinement. The C++20 split_view was essentially unusable for the most common purpose a function named split would serve. The redesign required breaking the existing name. That the committee was willing to make this correction is reassuring; that it shipped in this state is a fair criticism.

What C++23 Actually Fixed

The C++23 additions to ranges were substantial. The most requested missing features from C++20 were: views::zip, views::enumerate, views::chunk, views::slide, views::stride, views::adjacent, and std::ranges::to. C++23 shipped all of them.

std::ranges::to deserves particular mention because it was the most glaring omission. Without it, the idiom for collecting a range pipeline into a container required either a manual loop or verbose algorithm calls:

// C++20 - awkward
std::vector<int> result;
std::ranges::copy(v | std::views::transform(f), std::back_inserter(result));

// C++23 - what it should have been from the start
auto result = v | std::views::transform(f) | std::ranges::to<std::vector>();

C++23 also standardized std::ranges::range_adaptor_closure as a CRTP base for writing custom adaptors that participate in the pipe syntax, via P2387. In C++20, there was no supported way to write a user-defined range adaptor that composed with | without replicating internal implementation details. This was a significant gap for library authors.

The views::adjacent<N> adaptor, which produces overlapping N-tuples from a single range, also arrived in C++23 alongside views::chunk for non-overlapping groups and views::enumerate for indexed iteration. These had been in range-v3 for years, and their absence from C++20 was widely noticed by anyone doing practical range-based data processing.

The Rust Comparison

Rust’s Iterator trait covers the same design space with a different foundational choice. Instead of a sentinel-based model where begin() and end() can be different types, Rust iterators expose a single next() method returning Option<Item>. There is no separate sentinel concept; the end condition is expressed by returning None.

This sidesteps the const-iterability problem entirely. A Rust Filter iterator is a struct that wraps another iterator and calls next() on demand. It does not need to cache anything because next() advances state rather than finding a position. The ownership model means the iterator owns its traversal state directly, and there is no const-vs-mutable distinction at the iterator level.

let v = vec![1, 2, 3, 4, 5, 6];
let result: Vec<i32> = v.iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| x * x)
    .collect();

The .collect() method is Rust’s equivalent of ranges::to, and it has existed since Rust 1.0. C++ needed an extra standard revision to get there.

The tradeoff Rust makes is expressiveness. C++‘s sentinel model allows expressing things that Rust’s single-method model handles less cleanly, such as bidirectional iteration, random access through adaptor chains, and ranges where the sentinel type carries no state at all. For most practical use cases, Rust’s simpler model covers the same ground with fewer footguns. The C++ design is more general, and that generality has a maintenance cost.

D’s ranges, which directly inspired the C++20 design through Andrei Alexandrescu’s work, took yet another approach: the range itself is the iteration state, using empty, front, and popFront rather than begin/end pairs. This avoids the iterator-pair abstraction entirely but introduces different surprises around value semantics and copying ranges.

What Remains Unresolved

Five years in, the gaps that remain are structural rather than incidental.

Output range pipelines do not exist in the standard. There is no way to pipe into a range and write through a transformation into a backing buffer. The ranges::transform(in, out, f) two-range algorithm form exists, but it does not compose with the pipe syntax. The output range side of the Ranges TS was dropped before C++20 due to unresolved design issues, and it has not returned.

The const-iterability problem with filter_view and join_view is not fixed in C++23 and is unlikely to be fixed without a breaking change to the concept requirements. Library authors working around it have to choose between restricting their APIs or accepting forwarding-reference parameter overloads everywhere.

Compile-time overhead is real. A pipeline of six or seven adaptors generates deep template instantiation stacks. Compiler diagnostics for concept violations in range pipelines can span hundreds of lines. The experience of debugging a type mismatch inside a composed range expression is meaningfully worse than debugging the equivalent manual loop. This is a structural problem with the template-based implementation strategy, and it has no near-term fix.

The borrowed-range and std::ranges::dangling story remains confusing for developers not already familiar with the lifetime model. Returning dangling from ranges::find on a temporary container is the right behavior, but the resulting error message gives no indication of what went wrong or how to fix it.

None of these are arguments against using range adaptors. For transformation pipelines, the code is cleaner and the generated assembly at optimization levels is equivalent to hand-written loops. The C++23 additions made the library substantially more complete. But Hauswedell’s retrospective framing is accurate: the design made deliberate choices that traded usability in specific scenarios for generality, and the bill for some of those choices is still being paid in const restrictions, compile times, and error message quality. C++26 will inherit these constraints rather than resolve them.

Was this interesting?