· 7 min read ·

C++ Range Adaptors at Five: What the Design Bought, and What It Still Costs

Source: isocpp

Hannes Hauswedell gave a retrospective talk at Meeting C++ 2025 examining the major design decisions in C++20 range adaptors and how they read five years later. He is well positioned to do this. As a maintainer of SeqAn3, the bioinformatics C++ library that was one of the earliest large-scale adopters of C++20 concepts and ranges in production, he has lived with these APIs longer than most. He also authored P2325R3, which removed the requirement that views be default-constructible, one of the early design constraints that created unnecessary friction.

Five years is enough time to see which bets paid off and which generated debt. The iterator-sentinel split and borrowed ranges look clearly correct. The const-iterability problem and compile time tax remain outstanding. C++23 paid down some of the ergonomic debt; other parts are probably permanent fixtures.

The Iterator-Sentinel Split Aged Well

The most structurally important decision in the C++20 ranges design was decoupling the iterator from its end sentinel. Classic STL required begin() and end() to return the same type. Ranges dropped that restriction, allowing sentinels of different types:

// Infinite sequence: no meaningful end() of the same type as begin()
auto naturals = std::views::iota(1);

// Null-terminated string range: sentinel checks *it == '\0', no length needed
auto words = std::views::lazy_split(str_view, ' ');

The practical consequences are significant. std::unreachable_sentinel_t always returns false for equality checks, signaling to the optimizer that the loop termination condition can be eliminated entirely. std::counted_iterator pairs with std::default_sentinel to produce counted ranges without requiring two iterators of the same type. Null-terminated strings become first-class ranges without padding or workarounds.

The alternative, forcing all ranges into begin()/end() same-type form via std::views::common, is available for legacy compatibility, but it costs expressiveness. Using it to feed old STL algorithms means losing the ability to represent infinite or sentinel-terminated sequences naturally. Five years in, this design decision reads as unambiguously correct. The range-v3 library that predated and inspired the standard made the same choice, and the experience there confirmed it before C++20 shipped.

Borrowed Ranges: Opt-In Safety That Works

std::ranges::borrowed_range is a concept that marks ranges whose iterators remain valid after the range object is destroyed. The mechanism is direct:

// Passing a temporary string to a range algorithm
auto pos = std::ranges::find(std::string("hello"), 'x');
// pos is std::ranges::dangling, not an iterator
// Attempting to use it as an iterator is a compile error

Types like std::string_view, std::span, and std::ranges::subrange opt in by specializing enable_borrowed_range<T>. This is compile-time checking, not runtime; the library substitutes std::ranges::dangling when the range is an rvalue that cannot guarantee iterator validity after the range is destroyed.

The mechanism is weaker than Rust’s borrow checker because it is manual and opt-in rather than inferred by the type system. But for C++ it catches an entire class of dangling iterator bugs without runtime overhead, and it does so at the call site where the mistake is made rather than at some distant point of use. It fires silently for correct code and loudly only when you are about to introduce a bug, which is the right behavior for a safety mechanism.

The Const-Iterability Problem Has Not Been Solved

Several range adaptors, most notably filter_view, cannot be iterated over a const reference. The cause is begin() caching.

filter_view::begin() must scan forward to find the first element matching the predicate. To avoid repeating that scan on every call, filter_view stores the result in a mutable member. That makes begin() const impossible, because iterating requires mutating the cache.

void process(const auto& r) {
    for (auto x : r) { /* ... */ }  // Compilation error if r is filter_view
}

auto filtered = vec | std::views::filter(pred);
process(filtered);  // Fails to compile

The caching decision is not wrong in isolation; amortized O(1) begin() is the right guarantee for a lazy filtered view. But it creates a concrete problem for generic code. The natural pattern of passing views by const& breaks for a common adaptor. Any function template that accepts a const auto& range parameter and tries to iterate it will fail silently when handed a filter_view.

C++23 added std::views::as_const, which prevents element modification, but it does not make filter_view const-iterable; it still requires non-const access to the view object itself. Nothing in C++26 resolves this either. It is a consequence of the laziness model, not a fixable API issue, and Hauswedell’s retrospective identifies it correctly as persistent friction rather than a known limitation on a path to resolution.

The Pipe Operator: The Best Available Option

The pipe operator is the most visible aspect of the ranges API and also the most ergonomically contested:

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

Each std::views::transform(fn) call without a range argument returns a range adaptor closure object that awaits a range; the | operator delivers it. These closure objects compose with each other, making pipelines first-class values:

auto pipeline = std::views::filter(pred) | std::views::transform(fn);
auto a = range_a | pipeline;
auto b = range_b | pipeline;

C++23 made std::ranges::range_adaptor_closure a public base class, so writing custom pipe-composable adaptors no longer requires touching implementation internals. That was a meaningful quality-of-life improvement for library authors.

The trade-off against Rust’s method chaining (.map().filter().take()) is real. After typing rng |, no IDE knows to suggest std::views::* candidates without special language server support. With method chaining, editors surface completions naturally because the methods are defined on the iterator type. This is not a minor ergonomic quibble; it materially affects discoverability when learning the API or reaching for less commonly used adaptors.

Rust’s approach works because combinators are defined on the Iterator trait and apply to any implementing type at no cost. C++ cannot add methods to existing types without modifying them, so the pipe approach is the right adaptation to that constraint. D’s uniform function call syntax would have solved this more cleanly, but UFCS proposals have not cleared the C++ committee. Given the language as it exists, the pipe design is the correct choice.

Compile Times Are a Structural Tax

Range pipelines produce deeply nested template instantiations. A three-adaptor pipeline over a std::vector<int> produces a type roughly equivalent to:

// std::ranges::take_view<
//   std::ranges::transform_view<
//     std::ranges::filter_view<
//       std::ranges::ref_view<std::vector<int>>,
//       lambda1>,
//     lambda2>,
//   int>
auto result = vec | std::views::filter(pred) | std::views::transform(fn) | std::views::take(10);

With -O2 and full inlining, compilers typically reduce this to assembly equivalent to a hand-written loop. Debug builds are a different matter: the abstraction layers stack up and iteration can run 5-20x slower than the scalar equivalent. Compilation itself is measurably slower; reports of 2-5x compile time increases for moderately complex pipelines versus equivalent hand-written code are consistent across the ecosystem, and this is a real concern in translation-unit-heavy codebases.

This is inherent to the C++ template model. Range-v3 and the standard library share the same constraint. There is no design fix available without language changes; it is the cost of the expressive power, not a correctable oversight.

What C++23 Actually Fixed

The most impactful C++23 addition was std::ranges::to<Container>(). The inability to materialize a range into a container was constant friction in C++20:

auto result = input
    | std::views::filter(pred)
    | std::views::transform(fn)
    | std::ranges::to<std::vector>();

The view additions (zip, enumerate, chunk, slide, cartesian_product, stride, chunk_by) filled the most obvious gaps in the original adaptor set. std::generator<T> brought coroutine-based range production into the standard, which is the most ergonomic path for writing complex custom range producers:

std::generator<int> evens_up_to(int n) {
    for (int i = 0; i < n; i += 2) co_yield i;
}

auto first_five = evens_up_to(100) | std::views::take(5) | std::ranges::to<std::vector>();

This avoids implementing a full iterator type with all its operators and handles the generator state machine transparently. For domains like bioinformatics, where sequence producers are common and frequently stateful, std::generator represents a genuine improvement over the C++20 baseline.

The removal of the default-constructibility requirement addressed a constraint that blocked legitimate view implementations without providing meaningful benefits. Views holding non-default-constructible members were unnecessarily restricted. That P2325R3 was needed at all reflects how some early design choices were more conservative than the design warranted.

The Remaining Gap

Range-v3 still has things the standard lacks. The actions subsystem provides in-place eager mutations via the same pipe syntax. cache_last avoids recomputing expensive transforms on repeated dereference. Most practically, any_view<T, Cat> provides type erasure for ranges, letting you return a any_view<int, ranges::category::random_access> from a virtual function or across an ABI boundary without exposing the concrete range type. No standardized equivalent exists, and none is likely in C++26. For library authors who need to return ranges from virtual interfaces or stable ABI surfaces, this is a real gap.

The ranges design made coherent trade-offs in 2020. Most of them held. The iterator-sentinel split, borrowed range checking, and the view concept with its O(1) guarantees are well-reasoned and age well. The const-iterability problem and compile time cost are known limitations without clean resolutions in sight. Hauswedell’s retrospective carries weight precisely because it comes from sustained production use across a domain that stress-tests these abstractions in ways that toy examples do not, and that context makes the assessment considerably more trustworthy than analysis from the sidelines.

Was this interesting?