C++20 Range Adaptors: Composing Reusable Pipelines Without Fighting the Types
Key takeaways
A range adaptor is an object that turns a range into a view, and a partially applied adaptor is itself a value you can store, pass, and compose. This guide focuses on that object view of adaptors: how composition works, why every pipeline has its own type, and what that means for functions that build pipelines.
What are range adaptors?
Function objects that transform a range into a view (C++20).
#include <ranges>
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
// Adaptor: range -> view
auto filtered = v | std::views::filter([](int x) { return x > 2; });
C++20 views covers what views are and how they evaluate lazily. This article looks at the other side: the adaptors that create views, treated as objects in their own right. std::views::filter is a global object. Calling it with a range and a predicate gives you a filter_view. Calling it with only the predicate gives you a range adaptor closure: a partially applied adaptor that is waiting for a range. That closure is an ordinary value you can store in a variable, pass to a function, and combine with other closures. Most of the practical techniques below follow from that.
Function-call and pipe syntax
namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5};
// Apply adaptor (functional style)
auto view1 = vws::filter(v, [](int x) { return x > 2; });
// Pipeline
auto view2 = v | vws::filter([](int x) { return x > 2; });
Both lines produce the same view. The pipe form is just a different spelling: v | vws::filter(pred) calls the closure vws::filter(pred) with v. The pipe form reads left to right in the order the data flows, which is why it dominates in practice, but the call form is occasionally clearer when a single adaptor is applied to an expression that is already long.
Composing, naming, and choosing pipelines
Composing a filter-transform-take pipeline
#include <iostream>
#include <ranges>
#include <vector>
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Several adaptors
auto pipeline = numbers
| std::views::filter([](int x) { return x % 2 == 0; }) // evens
| std::views::transform([](int x) { return x * x; }) // squares
| std::views::take(3); // first 3
for (int x : pipeline) {
std::cout << x << " "; // 4 16 36
}
The type of pipeline is roughly take_view<transform_view<filter_view<ref_view<vector<int>>, Lambda1>, Lambda2>>. Each adaptor wraps the previous view, and every lambda has its own unique type. That nesting is why auto is effectively mandatory, and why a compiler error inside a pipeline can span several screens. When that happens, the useful part is usually the first line that mentions a concept (for example, “the constraint bidirectional_range<...> was not satisfied”). It tells you which adaptor had a requirement the input did not meet.
Naming reusable adaptors
#include <ranges>
#include <vector>
// Store adaptors
auto evenFilter = std::views::filter([](int x) { return x % 2 == 0; });
auto square = std::views::transform([](int x) { return x * x; });
std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2 = {6, 7, 8, 9, 10};
// Reuse
auto result1 = v1 | evenFilter | square;
auto result2 = v2 | evenFilter | square;
Naming adaptors this way is the ranges equivalent of extracting a function. It helps most when the same condition appears in several pipelines, because the name (evenFilter, activeUsers, nonEmptyLines) documents intent better than a repeated lambda. The closures copy the lambda they hold, so capturing by reference inside them ([&threshold]) ties their validity to the captured variable. Capture by value if the closure outlives the scope.
Composing adaptors without data
namespace vws = std::views;
// Pipeline of adaptor objects, no range yet
auto firstEvenSquares = vws::filter([](int x) { return x % 2 == 0; })
| vws::transform([](int x) { return x * x; })
| vws::take(2);
// Apply to data later
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (int x : v | firstEvenSquares) {
std::cout << x << " "; // 4 16
}
Piping one closure into another, with no range on the left, produces a new closure that applies both in order. This is what lets you build a named processing step out of smaller ones and apply it to different inputs. It already works in C++20. What C++20 lacks is a standard way to make your own function object pipeable. C++23 adds std::ranges::range_adaptor_closure<Derived>: inherit from it, implement operator()(Range&&), and your object works with | just like the standard ones. Before C++23, libraries such as range-v3 provided the same facility.
Choosing a pipeline at runtime
#include <ranges>
#include <vector>
// ❌ Does not compile: the two branches return different types
template<typename Range>
auto conditionalFilter(Range&& r, bool applyFilter) {
if (applyFilter) {
return std::forward<Range>(r)
| std::views::filter([](int x) { return x > 5; }); // filter_view<...>
} else {
return std::forward<Range>(r) | std::views::all; // ref_view<...>
}
}
// ✅ One pipeline type; the runtime choice moves into the predicate
template<typename Range>
auto conditionalFilter2(Range&& r, bool applyFilter) {
return std::forward<Range>(r)
| std::views::filter([applyFilter](int x) { return !applyFilter || x > 5; });
}
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (int x : conditionalFilter2(v, true)) {
std::cout << x << " "; // 6 7 8 9 10
}
}
The first version is a natural thing to write, and it fails with “inconsistent deduction for auto return type”. Because every pipeline has its own type, a function with auto return type cannot return one pipeline in one branch and a different one in another, even when both produce ints. There are three ways out, each with a cost. Move the runtime decision inside the view, as in conditionalFilter2. The pipeline type is always the same, and the flag is checked per element, which is usually negligible. Materialize into a std::vector<int> in both branches, which costs an allocation and a copy. Or type-erase the view (for example with range-v3’s any_view), which costs an indirect call per element.
I hit this wall regularly when converting loop-based code with configuration flags (“skip hidden items if the option is set”) to ranges. The first instinct is to build the pipeline step by step with if statements, which does not work, since each step changes the type. Folding the condition into the predicate is almost always the simplest fix, and it keeps the code lazy.
The adaptors you will use most
namespace vws = std::views;
// Filtering
vws::filter(pred)
vws::take(n)
vws::drop(n)
vws::take_while(pred)
vws::drop_while(pred)
// Transform
vws::transform(func)
vws::reverse
// Split/join
vws::split(delimiter)
vws::join
// Generation (range factories, not adaptors)
vws::iota(start)
vws::iota(start, end)
// Other
vws::all // any viewable range as a view
vws::counted(it, n) // iterator + count as a view (not pipeable)
vws::common // make begin/end the same type, for legacy algorithms
Two entries are easy to misuse. views::split in C++20 produces subranges that are awkward to turn into strings. A later fix (P2210, applied retroactively to C++20 as a defect report) changed split to work better with contiguous ranges such as strings and moved the old behavior to views::lazy_split, so split code written against early C++20 compilers can behave differently after a compiler upgrade. views::common exists because pre-C++20 algorithms and constructors (such as std::vector’s iterator-pair constructor) require begin() and end() to have the same type, which many views do not guarantee. If std::vector<int> v(view.begin(), view.end()) does not compile, try view | views::common first.
Types, ordering, lifetime, and const traps
Pipeline types you cannot spell
// ❌ Verbose type
std::vector<int> v = {1, 2, 3};
std::ranges::filter_view<std::ranges::ref_view<std::vector<int>>, /* ... */> filtered =
v | std::views::filter([](int x) { return x > 1; });
// ✅ auto
auto filtered = v | std::views::filter([](int x) { return x > 1; });
You cannot even write the full type when a lambda is involved, because a lambda’s type has no name. When a function parameter needs to accept “any pipeline of ints”, use a constrained template instead: void print(std::ranges::input_range auto&& r), or with a stricter element-type check, requires std::same_as<std::ranges::range_value_t<R>, int>.
Order, re-evaluation and const: the evaluation traps
Three traps come from how the views produced by adaptors evaluate rather than from the adaptors themselves, and they are worked through with examples in C++20 Views: How Lazy Pipelines Evaluate. In short: adaptor order is part of the meaning (reverse | take(3) gives the last three, take(3) | reverse the first three backwards), and a transform placed before a filter runs its function twice for every element that passes. A view stores the recipe rather than the results, so every loop over it redoes all the work; materialize into a container when a pipeline is walked several times. And filter_view caches its first position, so its begin() is non-const.
That last point matters most when you write functions that accept pipelines. A parameter of type const auto& rejects a filter view outright, with a diagnostic about the range not satisfying std::ranges::range. Take ranges by forwarding reference instead, void print(std::ranges::input_range auto&& r), which is what the standard algorithms do.
Views that outlive their container
// ❌ Local container destroyed
auto getView() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; });
// v destroyed
}
// ✅ The caller owns the container
auto getView(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x > 1; });
}
The second version is safe only as long as the caller’s container lives longer than the returned view. auto view = getView(std::vector<int>{1, 2, 3}); binds the parameter to a temporary vector, which is destroyed at the end of that statement, and the view dangles again, with no compiler warning. If you write functions that return views, consider deleting the rvalue overload (auto getView(std::vector<int>&&) = delete;) so that this call does not compile.
There is a third option for the first version that newer compilers support: move the local container into the pipeline. return std::move(v) | std::views::filter(pred); wraps the vector in an owning_view (added by P2415 as a defect report against C++20, available from GCC 12 and in recent Clang/libc++ and MSVC releases), so the returned view carries the data with it. The view becomes move-only, which is usually fine for a return value. On older compilers the same line fails to compile, because the original C++20 rules only allowed views::all on lvalues or on rvalues that are already views.
FAQ
Q1: What is a range adaptor?
A: An object that turns a range into a view. Given only its extra arguments, it returns a closure that waits for the range.
Q2: How do pipelines work?
A: range | closure applies the closure to the range. closure | closure composes them into a new closure.
Q3: When does evaluation happen?
A: Only when you iterate the resulting view.
Q4: Can I store and reuse adaptors?
A: Yes. Named closures are a good way to give a filtering or mapping step a descriptive name.
Q5: Why can’t I return different pipelines from one function?
A: Each pipeline has a distinct type. Move the runtime choice into a predicate, materialize into a container, or use a type-erased view.