When C++20 shipped its Ranges library, it represented the most significant overhaul of the standard library’s iteration model since the original STL. Eric Niebler’s range-v3 had been proving the concept since 2014, and the committee had years of real-world feedback to work from. Five years later, Hannes Hauswedell’s retrospective at MeetingC++ 2025 gives us a structured look back at those design choices and how they hold up.
The short version: the foundational abstractions were sound, the pipe operator was the right call, and borrowed ranges did real safety work. But several decisions created friction that took until C++23 to partially resolve, and some problems are still open.
The Pipe Model Held Up
The composable pipe syntax is the most visible feature of the Ranges library, and it aged well. You write:
auto result = data
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(5);
Each step produces a lightweight view object that stores a reference to the previous stage and the operation to apply. Nothing executes until you iterate. The pipeline reads in the direction of data flow, which is a genuine ergonomic improvement over the iterator-pair algorithms it complements.
The key design insight was separating range adaptors from range algorithms. Adaptors (in std::views) are lazy and composable. Algorithms (in std::ranges) are eager and terminal. This separation is clean, and it maps well onto the mental model most programmers already have from functional languages or Rust’s iterator chains.
Range-v3 had the same split, and the standard essentially inherited it. That continuity was valuable: codebases already using range-v3 could migrate with minimal conceptual overhead, even if the exact API differed.
Borrowed Ranges: Subtle Safety, Non-Trivial Cost
One of the less-discussed but more consequential design decisions was the borrowed range concept. The problem it solves is real:
auto it = std::ranges::find(std::vector<int>{1, 2, 3}, 2);
// 'it' is now a dangling iterator -- the temporary vector is gone
Pre-ranges, this compiled silently. With C++20’s borrowed range machinery, the compiler detects that std::vector is not a borrowed range (it owns its data) and substitutes std::ranges::dangling as the return type. Attempting to use that return value is a compile error.
A type opts into being a borrowed range by specializing std::ranges::enable_borrowed_range:
template<>
inline constexpr bool std::ranges::enable_borrowed_range<MyView> = true;
Views like std::string_view, std::span, and the standard view adaptors are all borrowed ranges because they don’t own the underlying data. The mechanism is effective. The ergonomic cost is that library authors now carry the obligation to correctly classify their types, and the error messages when you get it wrong are not always enlightening.
Five years in, this design looks correct in intent but underspecified in tooling. The concept is sound; the documentation and diagnostics still lag.
Niebloids: The Right Call for the Wrong-Feeling Reason
The std::ranges algorithms are not free functions in the traditional sense. They are function objects — instances of implementation-defined types — commonly called niebloids after Niebler. The pattern looks like:
namespace std::ranges {
namespace detail {
struct find_fn {
template<input_range R, class T>
constexpr auto operator()(R&& r, const T& value) const { ... }
};
}
inline constexpr detail::find_fn find{};
}
The reason for this is ADL (Argument-Dependent Lookup) control. If std::ranges::find were a plain function template, a user’s using namespace std::ranges; combined with a type from their own namespace could cause unintended ADL hits or overload resolution surprises. Making the algorithms non-callable-via-ADL sidesteps this entirely.
This was controversial. It feels baroque — a workaround for a language limitation rather than a clean design. But the alternative is a class of subtle bugs that are genuinely hard to debug. Retrospectively, the tradeoff looks worth it, even if the implementation detail leaks into error messages and documentation in uncomfortable ways.
Rust sidesteps this problem entirely because iterator methods live on the Iterator trait, not in a global namespace. The adapter methods are part of the type system. C++ doesn’t have that luxury with its existing ecosystem, so the niebloid pattern is effectively load-bearing compatibility work.
Views::filter’s Predicate Problem
Not every design decision aged as gracefully. std::views::filter has a footgun that surprises many developers: the predicate may be invoked more than once per element, and in some configurations, the number of invocations is not bounded by the number of elements.
The issue arises from how filter_view implements begin(). The first call to begin() must advance to the first element satisfying the predicate. Subsequent calls to begin() on the same view repeat this scan if the view hasn’t cached the result. The C++20 specification requires that begin() on a non-forward_range be amortized O(1), but for forward ranges, each call to begin() is allowed to rescan.
In practice, caching is implementation-defined and varies. If you store a filter_view and call begin() on it in a loop, the behavior is well-defined but the performance may not be what you expect:
auto fv = data | std::views::filter(expensive_predicate);
for (int i = 0; i < 10; ++i) {
// Each call to begin() may re-invoke the predicate on leading elements
auto it = fv.begin();
}
This is a case where the abstraction’s cost model is invisible at the call site. Range-v3 had the same issue. It’s not a bug in specification, but it’s a friction point in real codebases, and it’s contributed to skepticism about using views in performance-sensitive code.
Views::join and the Complexity Ceiling
std::views::join flattens a range of ranges into a single range. The implementation is conceptually simple but the iterator semantics are not. Making join_view bidirectional requires storing iterators into both the outer and inner range, and correctly implementing operator-- involves re-entering the outer range when the inner iterator reaches its begin. This creates dependencies between the outer iterator’s state and the inner iterator’s state that force implementation details to be visible across iterator types.
The C++20 version of join_view settled for being at most a forward range in most cases. It can be bidirectional only under strict conditions. This was a pragmatic retreat from a mathematically cleaner design, and it shows. Users who expected join to work symmetrically with split found that the two adaptors have meaningfully different iterator category guarantees.
C++23 added views::join_with, which handles the delimiter-between-ranges case. It doesn’t resolve the bidirectionality question but at least covers a common use case that join alone couldn’t express cleanly.
The C++23 Course Corrections
Several things conspicuously missing from C++20 Ranges shipped in C++23, and their absence had been a persistent complaint.
std::ranges::to is the most important. Converting a lazy pipeline into a concrete container required writing boilerplate in C++20:
// C++20
std::vector<int> result;
std::ranges::copy(data | views::filter(pred), std::back_inserter(result));
// C++23
auto result = data | views::filter(pred) | std::ranges::to<std::vector>();
The pipe-compatible to makes the terminal materialization step as composable as the lazy steps. Its absence from C++20 was always jarring given how complete the rest of the pipeline felt.
C++23 also added views::zip, views::chunk, views::slide, views::stride, views::repeat, and views::cartesian_product. These are the adaptors that range-v3 users had been reaching for since before C++20 shipped. Their inclusion in C++23 substantially closes the gap between the standard library and range-v3 for most practical use cases.
Performance: The Compiler Does Most of the Work
The common concern about range adaptors is performance. Chaining views creates nested template instantiations; iterating through them involves calling operator++ and dereferencing on each layer. In theory, this adds overhead. In practice, with -O2 or -O3, modern compilers inline aggressively enough that a chain of views often compiles down to code indistinguishable from a hand-written loop.
The caveat is that this depends heavily on the specific adaptors in the chain and whether the compiler can see through all the indirection. views::transform with a simple lambda inlines cleanly. views::filter with an opaque function pointer does not. The performance characteristics are optimizer-dependent in ways that make it difficult to reason about without profiling.
Benchmarks from the range-v3 era (and replicated since) show that tightly constrained pipelines with visible lambdas routinely match handwritten loops at -O3. The overhead appears primarily in debug builds or in cases where the optimizer loses track of the call chain, which can happen with deeply nested adaptors or non-inlineable predicates.
The State of Things in C++26
C++26 continues the pattern of filling gaps. views::concat, which concatenates multiple ranges of the same element type into a single range, was a notable omission from both C++20 and C++23. views::enumerate, which pairs each element with its index, addresses another routine need. Neither required fundamental rethinking of the Ranges model; they are straightforward extensions.
The more interesting C++26 work is in making the ranges machinery more usable from user-defined types and in improving compile-time and diagnostic quality. The latter matters because ranges error messages remain genuinely terrible. A type mismatch in a pipeline produces cascading template errors that require significant expertise to interpret.
What the Retrospective Reveals
Looking across five years of the Ranges library, the design’s underlying abstractions were correct. The concept hierarchy (input, forward, bidirectional, random access, contiguous), the separation of adaptors from algorithms, the borrowed range safety model, and the pipe composition syntax all held up under production use.
The friction has been in the details: filter’s predicate semantics, join’s iterator category limitations, the absence of ranges::to at launch, and the persistent problem of diagnostic quality. These are not failures of the core design, but they are real costs that accumulated in codebases that adopted ranges early.
Range-v3 remains a relevant benchmark. It still offers a richer set of adaptors and, for some users, better documentation and error messages than the standard library. The gap has narrowed substantially with C++23, but range-v3 shows what a more complete version of this design looks like when unconstrained by standardization timelines.
The C++20 Ranges library was an ambitious, largely successful attempt to bring principled functional composition to a language that was not designed for it. Five years of use has clarified which compromises were necessary and which were premature. The direction of C++23 and C++26 confirms that the committee knows what still needs work.