Range adaptors are lazy, composable transformations applied to ranges via the pipe operator |. Each adaptor returns a view --- a lightweight object that refers to underlying elements without Owning them. This section covers the standard adaptors, lazy evaluation semantics, pipe-based Composition, and practical data processing pipelines.
Range adaptors are lazy, composable transformations applied to ranges via the pipe operator | [N4950 §26.5.2]. Each adaptor returns a view --- a lightweight object that refers to the Underlying elements without owning them. Views satisfy std::ranges::view [N4950 §26.5.2] and have O ( 1 ) O(1) O ( 1 ) construction and destruction.
stack. The entire pipeline in the example above likely compiles to a tight loop with no heap Allocations.The pipe operator | is the standard way to compose range adaptors [N4950 §26.5.2]. Each adaptor is A callable object that returns a closure type when called with arguments. The pipe operator is Defined via std::ranges::views::adaptor internally.
// Example 1: Process a string into words longer than 3 characters
std :: string text = " the quick brown fox jumps over the lazy dog " ;
| std :: views :: transform ([]( auto&& rng ) {
return std :: string (rng. begin (), rng. end ());
| std :: views :: filter ([]( const std :: string & s ) {
std :: cout << " Long words: " ;
for ( const auto& word : long_words) {
std :: cout << word << " " ;
// Output: Long words: quick brown jumps over lazy
// Example 2: Extract map keys and sort
std :: map < std :: string, int> scores = {
{ " alice " , 95 }, { " bob " , 87 }, { " charlie " , 92 }
auto names = std :: views :: keys (scores);
for ( const auto& name : names) {
std :: cout << name << " " ;
// Output: Names: alice bob charlie
// Example 3: Infinite range with take
auto first_ten_squares = std :: views :: iota ( 1 )
| std :: views :: transform ([]( int x ) { return x * x; })
std :: cout << " Squares: " ;
for ( int x : first_ten_squares) {
// Output: Squares: 1 4 9 16 25 36 49 64 81 100
// views::zip: combine multiple ranges [N4950 §26.5.7]
std :: vector < std :: string > names = { " Alice " , " Bob " , " Charlie " };
std :: vector <int> ages = { 30 , 25 , 35 };
std :: vector <double> scores = { 95.5 , 87.3 , 92.1 };
std :: cout << " People: \n " ;
for ( auto&& [name, age, score] : std :: views :: zip (names, ages, scores)) {
std :: cout << " " << name << " , age= " << age << " , score= " << score << " \n " ;
// views::join: flatten a range of ranges [N4950 §26.5.7]
std :: vector < std :: vector <int>> matrix = {
std :: cout << " Flattened: " ;
for ( int x : matrix | std :: views :: join) {
// Output: Flattened: 1 2 3 4 5 6 7 8 9
// views::enumerate (C++23) [N4950 §26.5.7]
std :: vector < std :: string > fruits = { " apple " , " banana " , " cherry " , " date " };
std :: cout << " Indexed fruits: \n " ;
for ( auto&& [idx, fruit] : std :: views :: enumerate (fruits)) {
std :: cout << " [ " << idx << " ] " << fruit << " \n " ;
// Compose: take every other element with stride
std :: vector <int> data = { 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 };
| std :: views :: stride ( 2 ); // C++23: every 2nd element
std :: cout << " Every other: " ;
for ( int x : every_other) {
// Output: Every other: 0 2 4 6 8
// Chunk: split into fixed-size subranges
auto chunks = data | std :: views :: chunk ( 3 );
std :: cout << " Chunks: \n " ;
for ( auto&& chunk : chunks) {
for ( int x : chunk) std :: cout << x << " " ;
Understanding the internal structure of a view adaptor clarifies the lazy evaluation model. Consider views::filter:
// Conceptual implementation (simplified):
template < std :: ranges :: view V , std :: indirect_unary_predicate< std :: ranges :: iterator_t < V >> Pred >
class filter_view : public std :: ranges :: view_interface < filter_view < V , Pred >> {
V base_; // the underlying range
Pred pred_; // the predicate (copied into the view)
// The iterator skips elements that don't satisfy pred_
std :: ranges :: iterator_t < V > current_;
const filter_view * parent_;
while (current_ != std :: ranges :: end (parent_ -> base_)
&& ! std :: invoke (parent_ -> pred_, * current_)) {
satisfy (); // skip non-matching elements
auto operator * () const { return * current_; }
Key observations:
The filter_view stores the source range and predicate by value. Construction is O ( 1 ) O(1) O ( 1 ) . The iterator::satisfy() method is where the predicate is invoked. It runs only when the iterator is advanced, not at construction. Each operator++ may advance the underlying iterator multiple times (for consecutive elements that fail the predicate), but this is O ( 1 ) O(1) O ( 1 ) amortized per yielded element. std :: vector < Transaction > parse_csv ( const std :: string & csv ) {
std :: vector < Transaction > result;
std :: istringstream stream (csv);
while ( std :: getline (stream, line)) {
if (line. empty ()) continue ;
std :: istringstream line_stream (line);
std :: string date, desc, amount_str, category;
std :: getline (line_stream, date, ' , ' );
std :: getline (line_stream, desc, ' , ' );
std :: getline (line_stream, amount_str, ' , ' );
std :: getline (line_stream, category, ' , ' );
std :: string csv = R"( 2026-01-15,Coffee Shop,-4.50,Food
2026-01-15,Salary,5000.00,Income
2026-01-16,Grocery Store,-85.30,Food
2026-01-16,Gas Station,-45.00,Transport
2026-01-17,Freelance Work,1200.00,Income
2026-01-17,Restaurant,-32.50,Food
2026-01-18,Electric Bill,-120.00,Utilities
2026-01-18,Book Store,-25.00,Entertainment
2026-01-19,Gym Membership,-50.00,Health
2026-01-19,Online Shopping,-67.80,Shopping )" ;
auto transactions = parse_csv (csv);
// Pipeline 1: All expenses (negative amounts), sorted by amount
auto expenses = transactions
| std :: views :: filter ([]( const Transaction & t ) { return t.amount < 0 ; })
| std :: views :: transform ([]( const Transaction & t ) {
return std :: make_pair (t.description, t.amount);
std :: cout << " === All Expenses === \n " ;
for ( const auto& [desc, amt] : expenses) {
std :: cout << std :: fixed << std :: setprecision ( 2 );
std :: cout << " " << std :: setw ( 20 ) << std :: left << desc
<< " $ " << std :: setw ( 8 ) << std :: right << amt << " \n " ;
// Pipeline 2: Unique categories with spending
std :: vector < std :: string > categories;
for ( const auto& t : transactions) {
categories. push_back (t.category);
auto unique_cats = categories
| std :: ranges :: to < std :: vector >()
std :: ranges :: sort (unique_cats);
std :: ranges :: unique (unique_cats). begin (),
std :: cout << " \n === Expense Categories === \n " ;
for ( const auto& cat : unique_cats) {
for ( const auto& t : transactions) {
if (t.category == cat && t.amount < 0 ) {
std :: cout << std :: fixed << std :: setprecision ( 2 );
std :: cout << " " << std :: setw ( 15 ) << std :: left << cat
<< " $ " << std :: setw ( 8 ) << std :: right << total << " \n " ;
// Pipeline 3: Income transactions only
auto income = transactions
| std :: views :: filter ([]( const Transaction & t ) { return t.amount > 0 ; });
double total_income = 0.0 ;
for ( const auto& t : income) {
total_income += t.amount;
std :: cout << " \n Total income: $ " << std :: fixed << std :: setprecision ( 2 ) << total_income << " \n " ;
Views are non-owning : they hold references or iterators into the underlying range. If the Underlying range is destroyed or modified while a view refers to it, the view dangles. This is the Most common source of bugs with ranges [N4950 §26.5.2]:
std :: ranges :: filter_view < std :: vector <int>& , decltype ([]( int x ) { return x > 3 ; }) >
std :: vector <int> v = { 1 , 2 , 3 , 4 , 5 };
return v | std :: views :: filter ([]( int x ) { return x > 3 ; });
// WARNING: v is destroyed here, but the view references v's storage
// This compiles but is UNDEFINED BEHAVIOR:
// The view returned by make_dangling_view() refers to a destroyed vector
// auto bad = make_dangling_view();
// for (int x : bad) std::cout << x << "\n"; // UB: dangling
// Safe pattern: ensure the source outlives the view
std :: vector <int> data = { 1 , 2 , 3 , 4 , 5 };
auto safe = data | std :: views :: filter ([]( int x ) { return x > 3 ; });
for ( int x : safe) std :: cout << x << " " ;
The key rule: a view must not outlive the range it references . This is analogous to the rule for References and pointers. Owning ranges (std::vector``std::string) are safe as sources; temporary Ranges created inline are dangerous because their lifetime ends at the semicolon.
Some views can safely outlive their source range. A range models std::ranges::borrowed_range if Iteration does not require ownership of the range [N4950 §26.5.2]. This concept is critical for Composability: functions that return views can return views over borrowed ranges safely.
A range R models borrowed_range if and only if:
R is an lvalue reference, ORstd::ranges::enable_borrowed_range<std::remove_cvref_t<R>> is trueStandard borrowed ranges include:
Range type Borrowed? Reason std::string_viewYes Non-owning, copyable std::span<T>Yes Non-owning, copyable std::ranges::ref_view<T>Yes Explicitly holds a reference std::ranges::iota_viewYes No underlying storage std::ranges::empty_view<T>Yes No underlying storage std::ranges::single_view<T>Yes Owns element inline, copyable std::vector<int>& (lvalue reference)Yes Lvalue reference is always borrowed std::vector<int> (prvalue)No Owning; destroyed at end of expr std::ranges::filter_viewNo Holds reference to source std::ranges::transform_viewNo Holds reference to source
// SAFE: string_view is a borrowed_range
std :: ranges :: take_view < std :: string_view > safe_view () {
std :: string_view sv = " hello world " ;
return sv | std :: views :: take ( 5 );
// sv is copied into the view; the view owns a copy of the string_view,
// which points to static storage. This is safe.
// UNSAFE: filter_view holds a reference to a temporary vector
// std::vector<int> v = {1, 2, 3};
// return v | std::views::filter([](int x) { return x > 1; });
// // v is destroyed, view dangles
for ( char c : v) std :: cout << c;
views::take(n) wraps the source range and a count. Its sentinel compares the count against n:
// Conceptual implementation (simplified):
template < std :: ranges :: view V >
class take_view : public std :: ranges :: view_interface < take_view < V >> {
std :: ranges :: range_difference_t < V > count_;
std :: ranges :: sentinel_t < V > end_;
std :: ranges :: range_difference_t < V > count_;
bool operator == ( const iterator & it ) const {
return it.count_ == 0 || it.current_ == end_;
Each operator++ on the iterator decrements the count. When the count reaches zero, the iterator Equals the sentinel and iteration stops. This means take(n) consumes at most n elements from the Source, and the remaining elements are never visited.
views::drop(n) is the dual: its begin() advances the source iterator n times (or to the end, Whichever comes first). After that, iteration proceeds normally over the remaining elements.
// Conceptual implementation (simplified):
template < std :: ranges :: view V , std :: movable F >
class transform_view : public std :: ranges :: view_interface < transform_view < V , F >> {
std :: ranges :: iterator_t < V > current_;
F * func_; // pointer to the stored function
return std :: invoke ( * func_, * current_);
Key observation: operator* applies the function lazily. The function func_ is invoked only when The element is dereferenced. If the view is never iterated (e.g., the element is skipped by views::filter), the function is never called. This is the essence of lazy evaluation in range Pipelines.
views::reverse requires the underlying range to satisfy std::ranges::bidirectional_range because It needs to start from end() and move backwards. Applying views::reverse to a single-pass range (e.g., views::istream<int>) is a compile-time error.
std :: vector <int> v = { 1 , 2 , 3 , 4 , 5 };
// OK: vector is bidirectional
auto reversed = v | std :: views :: reverse;
for ( int x : reversed) std :: cout << x << " " ;
// COMPILE ERROR: istream is input-only (single-pass), not bidirectional
// auto bad = std::views::istream<int>(std::cin) | std::views::reverse;
views::zip(r1, r2, ...) produces tuples from the zipped ranges and stops when the shortest Range is exhausted [N4950 §26.5.7]:
std :: vector <int> nums = { 1 , 2 , 3 , 4 , 5 };
std :: vector < std :: string > names = { " one " , " two " , " three " };
// Stops at 3 elements (shorter range)
for ( auto&& [n, name] : std :: views :: zip (nums, names)) {
std :: cout << n << " = " << name << " \n " ;
1. Iterating a view multiple times: Some views (like views::filter and views::transform) are Reusable --- you can iterate them multiple times. However, some views consume their source. For Example, views::istream<int> reads from a stream and the elements are consumed on each Iteration. Always check whether the view is a single-pass or multi-pass range.
2. Materializing a range into a container: Use std::ranges::to<Container>(range) (C++23) or Construct the container directly: std::vector<int>(range.begin(), range.end()). Do not store Views long-term --- they are meant for immediate consumption.
3. Views on temporaries: auto v = std::vector{1,2,3} | std::views::take(2); --- the vector Temporary is destroyed at the end of the full-expression, but v still references it. Bind the Source to a named variable first.
4. Composing views on move-only types: views::transform with a lambda that captures by move Works, but the resulting view may be move-only itself. If you need to store the view, ensure the Source and all closure objects remain valid.
5. Infinite views in algorithms: views::iota(0) produces an infinite range. Passing it to an Algorithm without a bound (e.g., std::ranges::for_each) hangs forever. Always compose with views::take(n) or views::take_while(pred) before consuming.
6. Dangling from views::filter + temporary container: This subtle case creates a dangling view Even though it looks safe:
// DANGLING: the vector temporary lives until the semicolon,
// but 'v' is a view that references it. After the semicolon,
// the vector is destroyed.
auto v = std :: vector{ 1 , 2 , 3 , 4 , 5 } | std :: views :: filter ([]( int x ) { return x > 3 ; });
// SAFE: bind the container to a named variable first
auto data = std :: vector{ 1 , 2 , 3 , 4 , 5 };
auto safe = data | std :: views :: filter ([]( int x ) { return x > 3 ; });
Range adaptors are closure objects --- unnamed class types generated by the compiler with an operator() that captures the adaptor’s arguments [N4950 §26.5.2]. The pipe operator | overloaded For ranges invokes the closure:
// A closure object: captures the predicate
auto is_even = std :: views :: filter ([]( int x ) { return x % 2 == 0 ; });
auto squared = std :: views :: transform ([]( int x ) { return x * x; });
// Compose closures into a reusable pipeline
auto pipeline = is_even | squared;
std :: vector <int> v1 = { 1 , 2 , 3 , 4 , 5 };
std :: vector <int> v2 = { 10 , 11 , 12 , 13 , 14 };
// Reuse the same pipeline on different ranges
for ( int x : v1 | pipeline) std :: cout << x << " " ;
for ( int x : v2 | pipeline) std :: cout << x << " " ;
Each adaptor (e.g., std::views::filter(pred)) returns a closure type that stores pred. When Piped with |The closure’s operator() is called with the range, producing the view. This design Allows adaptors to be named, stored, composed, and reused independently of any specific range --- a Form of partial application at the type level.
The type-erased composition is achieved through operator| overloads. Given a range r and a Closure c``r | c is equivalent to c(r). Given two closures c1 and c2``c1 | c2 produces a New closure that, when applied to a range rEvaluates as c2(c1(r)) --- function composition in The standard mathematical sense [N4950 §26.5.2].
views::split splits a range based on a delimiter. The delimiter can be a single element or a range Of elements. Each element of the resulting view is itself a range (a sub-view of the input):
// Split by single character
std :: string csv = " red,green,blue,yellow " ;
auto colors = csv | std :: views :: split ( ' , ' )
| std :: views :: transform ([]( auto&& rng ) {
return std :: string (rng. begin (), rng. end ());
for ( const auto& color : colors) {
std :: cout << color << " \n " ;
// Split by whitespace (using find + substr via views)
std :: string text = " the quick brown fox " ;
// views::split with a single space splits on every space,
// producing empty subranges for consecutive delimiters
auto words = text | std :: views :: split ( ' ' )
| std :: views :: transform ([]( auto&& rng ) {
return std :: string (rng. begin (), rng. end ());
| std :: views :: filter ([]( const std :: string & s ) {
return ! s. empty (); // filter out empty strings from consecutive spaces
for ( const auto& word : words) {
std :: cout << " [ " << word << " ] " ;
// Output: [the] [quick] [brown] [fox]
Unlike views::take(n) and views::drop(n) which count elements, views::take_while(pred) and views::drop_while(pred) use a predicate to determine when to stop taking or start yielding:
std :: vector <int> v = { 1 , 2 , 3 , 10 , 20 , 30 , 4 , 5 };
// take_while: take elements while they satisfy the predicate
auto leading_small = v | std :: views :: take_while ([]( int x ) { return x < 10 ; });
std :: cout << " Leading small: " ;
for ( int x : leading_small) std :: cout << x << " " ;
// Output: Leading small: 1 2 3
// drop_while: skip elements while they satisfy the predicate
auto after_large = v | std :: views :: drop_while ([]( int x ) { return x < 10 ; });
std :: cout << " After large: " ;
for ( int x : after_large) std :: cout << x << " " ;
// Output: After large: 10 20 30 4 5
// Combined: extract the middle "large" section
| std :: views :: drop_while ([]( int x ) { return x < 10 ; })
| std :: views :: take_while ([]( int x ) { return x >= 10 ; });
std :: cout << " Large section: " ;
for ( int x : large_section) std :: cout << x << " " ;
// Output: Large section: 10 20 30
These adaptors are particularly useful when working with maps or ranges of tuples:
// Extract values from a map
std :: map < std :: string, int> scores = {{ " alice " , 95 }, { " bob " , 87 }, { " charlie " , 92 }};
| std :: views :: filter ([]( int s ) { return s >= 90 ; });
std :: cout << " Top scores: " ;
for ( int s : top_scores) std :: cout << s << " " ;
// Output: Top scores: 95 92
// Extract elements from a range of tuples
std :: vector < std :: tuple <int , std :: string, double>> records = {
auto names = records | std :: views :: elements < 1 > ;
for ( const auto& name : names) std :: cout << name << " " ;
// Output: Names: alice bob charlie
auto gpas = records | std :: views :: elements < 2 > ;
for ( double gpa : gpas) std :: cout << gpa << " " ;
// Output: GPAs: 3.5 2.8 3.9
Range adaptors are like LEGO blocks for data processing: Instead of writing a loop to filter, transform, and take elements, you compose range adaptors like LEGO blocks: views::filter(pred) | views::transform(fn) | views::take(n). Each adaptor is a small, reusable piece that does one thing well. The pipeline (|) chains them together, and the result is a lazy view that computes values on-demand. It’s like building a data processing pipeline from composable parts, instead of writing a monolithic loop.
Why it matters: Range adaptors enable composable, lazy data processing. Instead of writing nested loops with temporary vectors, you compose a pipeline that processes elements on-demand. This is more readable, more efficient (no temporary allocations), and more maintainable (each step is independent). The C++20 ranges library provides dozens of adaptors: filter, transform, take, drop, join, split, and more.
The key insight: Range adaptors compose with | to create lazy data processing pipelines — each adaptor does one thing well, and they chain together logically.
Mixing up Big O, Big Ω \Omega Ω , and Big Θ \Theta Θ notation. Big O is an upper bound, not necessarily tight.
Forgetting edge cases in algorithm design (e.g., empty input, single element, already sorted data).
Writing pseudocode that is too language-specific rather than using standard algorithmic constructs.
Neglecting to normalise database designs, leading to data redundancy and update anomalies.
The key principles covered in this topic are linked in the sub-pages above. Focus on understanding the definitions, applying the formulas or frameworks, and evaluating strengths and limitations of each approach.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.