Skip to content
C++
Language
since C++20
Advanced

Master Ranges and Views for Lazy, Composable Data Pipelines

Build lazy, composable data pipelines in C++20 using ranges and views, replacing verbose iterator pairs with readable, zero-overhead transformations.

By the end of this page, you will understand what a range and a view are, compose multi-step data pipelines using the | operator, avoid the most common lifetime and evaluation-order pitfalls, and replace iterator-pair boilerplate with readable, zero-overhead transformations.

What and Why

Before C++20, transforming a sequence required either raw loops or passing paired iterators everywhere:

cpp
#include <algorithm>
#include <vector>

std::vector<int> evens;
std::copy_if(v.begin(), v.end(), std::back_inserter(evens),
             [](int x){ return x % 2 == 0; });

This allocates, copies, and forces you to name an intermediate container you never wanted. Worse, if you need three transformations you create three temporaries.

C++20 ranges give the standard library a unified concept of "something you can iterate." A view is a lightweight, non-owning object that describes a transformation lazily β€” nothing is computed until you actually iterate the view. The pipeline operator | wires views together left-to-right, producing code that reads like a sentence.

The key insight: a view does not hold elements. It holds only the logic to produce elements on demand. Constructing a five-stage pipeline costs O(1) time and O(1) space regardless of input size.

Step by Step

Step 1 β€” Your first range algorithm

Range algorithms live in std::ranges:: and accept a single range object instead of an iterator pair.

cpp
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {5, 3, 1, 4, 2};
    std::ranges::sort(v);                        // no .begin()/.end() needed

    for (int x : v) std::cout << x << ' ';      // 1 2 3 4 5
}

std::ranges::sort is constrained by concepts β€” if you pass the wrong type you get a readable error, not a wall of template noise.

Step 2 β€” Your first view

cpp
#include <ranges>
#include <vector>
#include <iostream>

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

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

    for (int x : evens) std::cout << x << ' ';  // 2 4 6
}

evens is not a std::vector. It is a lightweight adaptor wrapping v. No heap allocation occurs. Elements are filtered one at a time as the for loop advances the iterator.

Step 3 β€” Chaining views

Views compose with | exactly like Unix pipes.

cpp
#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto pipeline = v
        | std::views::filter([](int x){ return x % 2 == 0; })  // keep evens
        | std::views::transform([](int x){ return x * x; })    // square them
        | std::views::take(3);                                  // first three

    for (int x : pipeline) std::cout << x << ' ';  // 4 16 36
}

Each | returns a new view object; no element is produced until the final range-for iterates it.

Step 4 β€” Materialising a view into a container

When you do need a concrete container, use std::ranges::to (C++23) or std::ranges::copy:

cpp
#include <ranges>
#include <vector>
#include <algorithm>
#include <iterator>

int main() {
    std::vector<int> src = {1, 2, 3, 4, 5};

    // C++23 β€” cleanest
    // auto result = src | std::views::transform([](int x){ return x * 2; })
    //             | std::ranges::to<std::vector>();

    // C++20 β€” explicit copy
    std::vector<int> result;
    auto doubled = src | std::views::transform([](int x){ return x * 2; });
    std::ranges::copy(doubled, std::back_inserter(result));
    // result == {2, 4, 6, 8, 10}
}

Common Patterns

Pattern 1 β€” Filtering and projecting with range algorithms

Range algorithms accept a projection β€” a callable applied to each element before comparison β€” eliminating the need for awkward lambdas over member access.

cpp
#include <algorithm>
#include <ranges>
#include <string>
#include <vector>
#include <iostream>

struct Employee { std::string name; int salary; };

int main() {
    std::vector<Employee> staff = {
        {"Alice", 90000}, {"Bob", 60000}, {"Carol", 75000}
    };

    std::ranges::sort(staff, std::less<>{}, &Employee::salary);

    for (auto& e : staff) std::cout << e.name << ' ';  // Bob Carol Alice
}

The third argument to std::ranges::sort is the projection. No lambda wrapping needed β€” it reads like plain English.

Pattern 2 β€” Generating sequences with iota and building pipelines

std::views::iota produces an infinite (or bounded) sequence without any storage.

cpp
#include <ranges>
#include <iostream>

int main() {
    // Sum of squares of first ten odd numbers
    long long total = 0;
    for (int x : std::views::iota(1)
               | std::views::filter([](int n){ return n % 2 != 0; })
               | std::views::transform([](int n){ return n * n; })
               | std::views::take(10))
    {
        total += x;
    }
    std::cout << total << '\n';  // 1+9+25+...+361 = 1330
}

The infinite iota(1) is safe here because take(10) short-circuits iteration.

Pattern 3 β€” Splitting and joining strings

cpp
#include <ranges>
#include <string_view>
#include <iostream>

int main() {
    std::string_view csv = "apple,banana,cherry";

    for (auto token : csv | std::views::split(',')) {
        std::cout << std::string_view(token) << '\n';
    }
    // apple
    // banana
    // cherry
}

std::views::split returns a range of ranges. Each sub-range is non-owning and refers into the original string.

What Can Go Wrong

Dangling views from temporaries

A view does not own its elements. If the underlying container is destroyed before the view is iterated, you have undefined behaviour.

cpp
// WRONG β€” temporary destroyed before loop runs
auto bad_view = std::vector<int>{1, 2, 3} | std::views::filter([](int x){ return x > 1; });
for (int x : bad_view) { /* UB: vector is gone */ }

// CORRECT β€” keep the source alive
std::vector<int> data = {1, 2, 3};
auto safe_view = data | std::views::filter([](int x){ return x > 1; });
for (int x : safe_view) std::cout << x << ' ';

The rule: a view must not outlive its source range.

Mutating the source while iterating a cached view

Some views (notably std::views::filter) cache the begin iterator on first access for performance. Inserting or erasing from the source container after constructing the view invalidates that cache silently.

cpp
std::vector<int> v = {1, 2, 3, 4};
auto view = v | std::views::filter([](int x){ return x % 2 == 0; });

v.push_back(6);          // may invalidate internal cache
for (int x : view) { }  // potentially incorrect results

Reconstruct the view after mutating the source.

Expecting operator[] on every view

Not all views are random-access. A filter view is bidirectional at best; indexing it requires std::ranges::advance or materialising to a vector first.

cpp
auto filtered = v | std::views::filter([](int x){ return x > 2; });
// filtered[2];   // compile error β€” filter_view does not support operator[]

Check the range category (random_access_range, bidirectional_range, etc.) when you need indexed access.

Quick Reference

View / AlgorithmWhat it doesCategory
std::views::filter(pred)Keep elements satisfying predAdaptor
std::views::transform(fn)Apply fn to each elementAdaptor
std::views::take(n)First n elementsAdaptor
std::views::drop(n)Skip first n elementsAdaptor
std::views::iota(start)Infinite integer sequenceFactory
std::views::split(delim)Split range on delimiterAdaptor
std::views::reverseReverse traversalAdaptor
std::ranges::sort(r, cmp, proj)Sort with optional projectionAlgorithm
std::ranges::copy(r, out)Materialise view to outputAlgorithm
std::ranges::to<C>()Materialise to container C (C++23)Adaptor

Lifetime rule: views are non-owning β€” the source must outlive every view built on it.

Laziness rule: no work happens until you iterate; chaining views is free.

Projection rule: range algorithms accept a projection as a trailing argument, eliminating boilerplate member-access lambdas.

What's Next