C++20 Ranges and Views: Build Lazy Data Pipelines Step by Step

Build and trace one C++20 view pipeline, then learn how adaptor order, explicit materialisation, source lifetime, and repeated traversal affect its behaviour.

KnowledgeGate Team

Exam prep & CS education

Updated 5 Nov 20266 min read
C++20 Ranges and Views: Build Lazy Data Pipelines Step by Step

Filtering values, transforming the survivors, and stopping after a few results takes one loop, yet iterator variables and temporary containers can hide the algorithm. C++20 ranges and views make it a left-to-right pipeline. The pipeline's composition, materialisation, lifetimes, and costs determine how it behaves. Lazy means iteration drives the work, not that values are cached or automatically faster. If lambdas, auto, vectors, iterators, or range-based loops are unfamiliar, use the C++ Tutorial: Complete Learning Path first.

C++20 ranges and views: the mental model before pipe syntax

A range is a sequence described by begin and end. An iterator identifies the current position; a sentinel marks where traversal stops. A view is also a range, designed to represent lightweight traversal or transformation instead of eagerly constructing a result container.

Not every view is non-owning. The dependable point is that applying filter or transform does not create a vector of all resulting elements.

In source | adaptor | adaptor, the source supplies values, adaptors create views, and a loop or algorithm consumes them. Pipeline construction alone does not visit every element. Include <ranges>, use std::views, and name the composed type with auto, because its concrete type is deliberately detailed. The Coding & DSA Courses for Placements page provides the broader programming route.

Worked C++20 view: filter, square, and take three values

This C++20 program keeps even values, squares them, and prints the first three outputs:

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

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

    auto first_three_even_squares =
        values
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(3);

    for (int x : first_three_even_squares) {
        std::cout << x << ' ';
    }
}

Trace the source in order. 9 fails; 2 passes and gives 2 * 2 = 4; 7 fails; 4 gives 4 * 4 = 16; 1 fails; and 8 gives 8 * 8 = 64. Three outputs now exist, so take(3) stops at 4 16 64. Although 6 is the fourth even source value, its square is not requested as another output.

Creating the view allocates neither an evens vector nor a squares vector, and it does not run the transformation. The loop pulls one output at a time. The whole program is not allocation-free because the source vector owns storage.

Pipeline diagram tracing source 9, 2, 7, 4, 1, 8, 6, 3 through filter and transform to the take(3) outputs 4, 16 and 64.

C++20 view adaptor order changes the result

Adaptor order is part of the algorithm. std::views::iota(1, 11) represents 1 through 10, with 11 excluded. Then drop(2) starts at 3, filtering multiples of 3 keeps 3, 6, 9, multiplying by 10 gives 30, 60, 90, and take(2) emits 30, 60.

Compare two pipelines on that source:

Pipeline after iota(1, 11)

Work described

Output

take(4)|filter(even)

Restrict to 1, 2, 3, 4, then keep evens

2, 4

filter(even)|take(4)

Find the first four even values in the source

2, 4, 6, 8

Place a cheap, selective filter before an expensive transform when the meaning permits it. Put take where the real stopping condition belongs. The library does not promise to rearrange arbitrary user operations.

A lazy C++20 view is not a result container

filter, transform, and take return ranges for later use. A loop, std::ranges::find_if, or std::ranges::copy consumes a range now. On the worked view, find_if with x > 10 sees 4, then finds 16.

When the caller needs ownership, materialise explicitly:

cpp
#include <algorithm>
#include <iterator>

std::vector<int> result;
std::ranges::copy(first_three_even_squares,
                  std::back_inserter(result));

The copy stores each output, so result == std::vector<int>{4, 16, 64}. This destination is the materialisation point. Do not use std::ranges::to<std::vector>() here because ranges::to is not a C++20 facility.

Compose reusable C++20 range adaptor closures

Adaptors can be composed once and then applied to different sources:

cpp
auto positive_even_squares =
    std::views::filter([](int x) { return x > 0 && x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; });

std::vector<int> first{-3, -2, 0, 1, 2, 4, 5};
std::vector<int> second{6, 7, 8};

For first, positive evens 2 and 4 produce 4, 16. For second, 6 and 8 produce 36, 64. Composition describes a reusable operation; piping in a range creates its view.

Keep callables small and pure where practical. A named is_positive_even is easier to test than a capture-heavy lambda. A view stores its callable, but a reference capture still depends on the referenced object's lifetime.

C++20 view lifetime and invalidation traps

A view must not outlive a source it references. Here the local vector is destroyed on return:

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

The returned view dangles. Keep the source alive for every use, or return a materialised owning result.

Iterator invalidation still applies. If push_back reallocates a vector during traversal, active iterators or references can become invalid. A view does not protect its source from mutation.

A view is not a snapshot. Change values[1] from 2 to 10 before a fresh traversal: 10 * 10 = 100, 4 * 4 = 16, and 8 * 8 = 64, so the output becomes 100, 16, 64.

Three panels: a safe view over a live values vector, a filter view left dangling by bad_view, and push_back invalidating a view.

C++20 ranges in interviews and assessments

Typical tasks ask you to evaluate adaptor order, compare take placements, detect a dangling source, or distinguish a view from a vector. filtered[2] is not a valid assumption even when the source is a vector, because reaching the next survivor may require testing several elements.

Three performance corrections matter:

  1. Lazy is not memoised. Two traversals compute the three squares twice.

  2. Removing intermediates can save storage and transformations, but predicates and iteration still cost work.

  3. Big-O follows source traversal. A filter may inspect many inputs for few outputs.

The definitions and adaptor semantics come from WG21's 2020 C++ Draft International Standard N4860, especially its clauses for ranges, views, adaptor objects, filter_view, transform_view, and take_view. It promises neither one object layout nor one optimiser result. For broader C++ and competitive-coding practice, Coding for Placements is a natural next step.

C++20 ranges and views: the short version and next step

Keep this checklist beside your first few pipelines:

  • Keep the source alive while its view is used.

  • Read adaptors left to right.

  • Remember that adaptors build views, not result vectors.

  • Stream once when that is enough.

  • Materialise when ownership or stable reuse is required.

  • Test with a tiny input you can solve by hand.

The anchor source [9, 2, 7, 4, 1, 8, 6, 3] becomes [4, 16, 64]. On 1 through 10, taking four before filtering gives [2, 4]; filtering first gives [2, 4, 6, 8].

Use Modern C++ Features: C++11 to C++20 Tutorial with Runnable Examples for a cross-version survey of lambdas, smart pointers, structured bindings, and a compact range example. That tutorial owns the feature overview; the worked traces here own lazy evaluation, adaptor order, materialisation, and repeated traversal. Use C++ Iterators Tutorial with Examples: Traversal, Algorithms and Invalidation for iterator categories and safe erase loops; this pipeline focuses on how a view consumes and can outlive a source. Now replace one temporary filtered vector with a view, materialising only if the caller needs ownership.