---
title: Ranges
source: 'https://academia.sh/en/courses/cpp-standard-library/ranges'
course: 'Resource Management and the Standard Library'
language: en
updated: '2026-09-26T03:02:47+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a view does not copy the underlying container and leaves it unchanged'
    - 'Shows that a view chain is evaluated lazily, results are produced only while traversing'
    - 'Explains how views::filter and views::transform together build a pipeline'
    - 'Measures that a view constructed from an rvalue takes the source into an owning view through the move constructor and leaves the source empty'
---

# Ranges

The measurement of how a view constructed from an lvalue leaves the underlying container unchanged, and how a view chain is evaluated lazily.

The previous lesson showed that a lambda capturing by value embeds a
copy of the variable inside itself and leaves the outer source
**unchanged**. This lesson moves to C++20's ranges library
(`std::ranges`) and asks a similar question: when a **view** is
constructed from an underlying container, does it copy that container,
or does it become only a window looking into it?

## A View Does Not Copy the Container

`std::views::filter` produces a view that shows only the elements of a
given range that satisfy a condition. This view does not hold its own
elements; every time it is traversed it looks at the underlying range.

```cpp
// view-does-not-copy.cpp — does filter copy the underlying vector
#include <cstdio>
#include <ranges>
#include <vector>

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

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

    numbers.push_back(8);

    std::printf("elements in view:");
    for (int value : evens) {
        std::printf(" %d", value);
    }
    std::printf("\n");
    std::printf("source vector size %zu\n", numbers.size());
    return 0;
}
```

```text
elements in view: 2 4 6 8
source vector size 7
```

**SL33.** `8` was added to `numbers` **after** the `evens` view was
created, and when the view was traversed this new element also entered
the list (`8` is even, so it passed the filter). This proves that
`evens` did not take a copy of `numbers` — if it had taken a copy, the
change made after `push_back` would not appear in the view. `numbers`
itself came out of this view-construction operation **unchanged**: the
`views::filter` call never wrote to the source, it only built a
definition that looks at it. `push_back` was a separate operation and is
not the "view construction" step this lesson measures.

## A View Chain Is Evaluated Lazily

Multiple views can be chained with the `|` operator. `std::views::transform`
produces a view that applies a function to every element. When a chain
is built, no element is processed immediately — the processing happens
only while the chain is traversed, element by element.

```cpp
// lazy-view-chain.cpp — when do filter and transform actually run
#include <cstdio>
#include <ranges>
#include <vector>

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

    auto chain = numbers
        | std::views::filter([](int value) { return value % 2 == 0; })
        | std::views::transform([](int value) {
              std::printf("transforming: %d\n", value);
              return value * 10;
          });

    std::printf("chain built\n");

    for (int value : chain) {
        std::printf("produced value %d\n", value);
    }
    return 0;
}
```

```text
chain built
transforming: 2
produced value 20
transforming: 4
produced value 40
transforming: 6
produced value 60
```

**SL34.** When the line "chain built" was printed, the `printf` inside
`transform` had not run at all — the `filter` and `transform` calls had
not processed a single element up to that point, they had only built a
definition. The "transforming" line first appeared exactly when the
`for` loop began traversing the chain, at the point when the first
element's turn came, and each "transforming" line appeared right before
its own "produced value" line, one at a time — one element was never
moved to the next before being processed and printed. This order of
output (first "chain built," then element-by-element interleaved
"transforming"/"produced value" pairs) shows when the chain actually
runs: not at construction time, but during traversal, element by
element. The source `numbers` did not change at any step of this chain
— **unchanged**.

## `filter` and `transform`: A Pipeline

Chaining two views builds a pipeline: `filter` decides first which
elements will pass, `transform` is applied only to the elements that
passed. This order matters — if the order changes, the result the chain
produces can also change.

```cpp
// filter-transform-pipeline.cpp — how order affects the produced result
#include <cstdio>
#include <ranges>
#include <vector>

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

    auto filter_then_transform = numbers
        | std::views::filter([](int value) { return value % 2 == 0; })
        | std::views::transform([](int value) { return value * value; });

    std::printf("filter then transform:");
    for (int value : filter_then_transform) {
        std::printf(" %d", value);
    }
    std::printf("\n");
    return 0;
}
```

```text
filter then transform: 4 16
```

**SL35.** The chain first selected the even numbers (`2, 4`), then
squared them (`4, 16`). `numbers` itself never changed throughout this
pipeline — `numbers` is still `{1, 2, 3, 4, 5}`; **unchanged**. The name
`filter_then_transform` states explicitly the order in which the chain
should be read (left to right, `filter` first); this is a habit that
prevents confusion when reading range chains — as a chain grows longer,
which step runs first should be read from the chain's own order, not
from the name.

## What Constructing a View Leaves in the Source

The "Systems Programming with C" course (`c-systems-programming`) asked
whether a change **reaches** the other side. Views immediately bring
that question to mind: a view does not hold its own elements, it reads
from the underlying container every time it is traversed. But this
lesson does not use the question "does the view see a change made
afterward" as its measure. What is measured here is what
**constructing** the view leaves in the source: `views::filter`,
`views::transform`, and `views::take` constructed from an lvalue
container do not write to the source, the source stays **unchanged**.
As we will see in the bounding measurement, the answer is different for
a view constructed from an rvalue.

## `views::take`: The First N Elements of a Range

`std::views::take` produces a view that shows only the first `n`
elements of a given range. Like other views, this one also does not
copy the underlying container; it only builds a bound that stops
traversal after `n` elements.

```cpp
// views-take.cpp — does take copy the underlying container
#include <cstdio>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers{10, 20, 30, 40, 50};

    auto first_three = numbers | std::views::take(3);

    std::printf("first three:");
    for (int value : first_three) {
        std::printf(" %d", value);
    }
    std::printf("\n");
    std::printf("source vector size %zu\n", numbers.size());
    return 0;
}
```

```text
first three: 10 20 30
source vector size 5
```

**SL36.** The `first_three` view showed only the first three elements,
but `numbers` still carries five elements at the end of the operation —
its size did not change. `views::take` falls into the same kind as
`views::filter`: **unchanged**. The difference is that `filter` decides
which elements will pass with a condition, while `take` decides only how
many elements will pass with a number. Both build a different window
looking at the underlying `numbers` without writing to it — this is a
shared property of range views: all of them leave the source unchanged,
they only change which part of that source, or in which form, becomes
visible.

## `views::drop`: Skipping the First N Elements of a Range

The opposite of `std::views::take` is `std::views::drop`: it produces a
view that skips the first `n` elements of a given range and shows the
rest. Neither copies the underlying container; both only build a bound
that determines where traversal starts or stops.

```cpp
// views-drop.cpp — does drop copy the underlying container
#include <cstdio>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers{10, 20, 30, 40, 50};

    auto last_two = numbers | std::views::drop(3);

    std::printf("last two:");
    for (int value : last_two) {
        std::printf(" %d", value);
    }
    std::printf("\n");
    std::printf("source vector size %zu\n", numbers.size());
    return 0;
}
```

```text
last two: 40 50
source vector size 5
```

**SL37.** The `last_two` view skipped the first three elements (`10, 20,
30`) and showed the remaining two elements (`40, 50`); `numbers` still
carries five elements at the end of the operation. `views::take` and
`views::drop` are two complementary views: one says "show the first n,"
the other says "skip the first n," and both leave the source
**unchanged**. If `numbers | std::views::take(3)` and `numbers |
std::views::drop(3)` are traversed in succession, two complementary but
non-overlapping pieces are obtained (`10, 20, 30` and `40, 50`) — two
different views of the same source that do not overlap with each other.

## `std::ranges::sort`: A Range Directly, Instead of an Iterator Pair

The `std::sort` seen in the previous lesson asks for an iterator pair
(`begin()`, `end()`). C++20's `std::ranges::sort` does the same job
directly with a range object — there is no need to write
`numbers.begin()`/`numbers.end()`. The result is the same: the container
is reordered in place.

```cpp
// ranges-sort.cpp — ranges::sort does not ask for an iterator pair
#include <algorithm>
#include <cstdio>
#include <vector>

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

    std::ranges::sort(numbers);

    std::printf("sorted:");
    for (int value : numbers) {
        std::printf(" %d", value);
    }
    std::printf("\n");
    return 0;
}
```

```text
sorted: 1 2 3 4 5
```

**SL38.** The call `std::ranges::sort(numbers)` reordered `numbers` in
place — the kind of this operation is **changed**, just like `std::sort`
in the previous lesson. The difference is a convenience in the call:
`std::ranges::sort` accepts a range object directly, making the
`begin()`/`end()` calls internally on its own. This is a shared property
of the algorithms in the `std::ranges` namespace — the same work is
expressed with less code using range objects; the underlying complexity
and the kind of the result (**changed**/**unchanged**) do not change.

## The Complexity of Constructing a View: O(1); the Complexity of Traversal: Variable

Building a view chain (`numbers | std::views::filter(...) |
std::views::transform(...)`) is constant time — O(1). When this line
runs, no element is visited, no memory is allocated; what is built is
only a definition looking at the source, a series of lambdas, and a few
bound values. The actual work begins when the chain is traversed, and
there the complexity varies by adapter.

Moving to the next element (`operator++`) is O(1) for both
`views::transform` and `views::filter` — but `filter`'s O(1) is an
"amortized" O(1): if the predicate rejects an element, the inner loop
keeps turning until the next accepted element is reached. In the worst
case (if no element passes the predicate), a single `operator++` call
can take O(n); but when the chain is traversed in full, the total cost
is still O(n), because every element is visited at most once.
`views::take` and `views::drop`, on the other hand, are truly O(1):
`take` counts how many elements remain and stops when it reaches that
count; `drop`, on a random-access range (such as `vector`), moves the
starting point forward by `n` steps directly, it does not step through
one by one.

This distinction shows that which adapter is added to a chain has an
invisible effect on performance: construction cost is always O(1), but
traversal cost depends on the adapter's type and the predicate's
selectivity. This lesson's measurement — that the source stays
**unchanged** — is independent of this complexity difference; whether a
view is traversed with O(1) or amortized O(1), it never changes the
underlying container in any case. The complexity difference only affects
how long it takes, not which kind the source falls into.

## The Bounding Measurement

The central measurement of views in this lesson is **unchanged**: a view
constructed from an lvalue never changes the underlying container at any
stage. Repeating the same construction with a small change — giving the
source not as an lvalue but as an **rvalue** — changes the kind.
`std::move(v)` produces a `std::vector<int>&&`; sending this to
`std::views::transform` brings C++20's owning view
(`std::ranges::owning_view`) into play: the adapter takes the rvalue
given to it into itself **through the move constructor**. The buffer `v`
held is not copied, it passes into the owning view's own member; this
transfer is constant time and does not touch elements one by one. As a
result, `v` remains a moved-from object — the known behavior of
`vector`'s move constructor is to leave behind an empty `vector`, that
is, `v.empty()` now gives `true`. The source's kind drops to **empty**;
whereas if the same chain had been constructed from an lvalue, the kind
would have stayed **unchanged**.

```cpp
// owning-view-from-rvalue.cpp — an rvalue vector is moved into owning_view
#include <cstdio>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    auto folded = std::move(v) | std::views::transform([](int value) { return value; });

    std::printf("view:");
    for (int value : folded) {
        std::printf(" %d", value);
    }
    std::printf("\n");

    std::printf("is v empty: %s\n", v.empty() ? "true" : "false");
    return 0;
}
```

```text
view: 10 20 30
is v empty: true
```

The view still gives `10 20 30`, because the elements were never copied
anywhere; only who owns them changed. Even though `v` is now empty, the
owning view continues to hold the buffer it moved into itself, and
traversal runs through it. The basis for this emptying is the **move
constructor** — the same result must not be assumed for a move
**assignment**, because assignment already writes over an existing
target, and whether it empties the source is a separate guarantee. The
owning view, as its name says, ties the lifetime of the object it
carries to its own lifetime; this is why the example above is safe and
can be run as code. In contrast, a situation where a view holds *a
reference looking at a container* and that container's lifetime ends
before the view's produces undefined behavior — such an example is not
written or run here, because undefined behavior itself is not this
lesson's subject, only knowing that it exists matters. C++20's
`owning_view` was designed exactly to eliminate this risk: a view
constructed from an rvalue now owns the source itself by carrying it,
not by holding a reference to it.

## Summary

- A view (`std::views::filter`, `std::views::transform`) does not copy
  the underlying container; the source container comes out of the
  view-construction operation **unchanged**.
- A view chain is evaluated lazily: elements are produced one at a time
  during traversal, not when the chain is built.
- `filter` and `transform` together build a pipeline; the order in the
  chain determines which step is applied first.
- The kind of this lesson's central measurement is **unchanged**: a view
  constructed from an lvalue never changes the underlying container at
  any stage.
- The bounding measurement: when the same construction is done with an
  rvalue instead of an lvalue, the kind drops to **empty** —
  `std::move(v) | std::views::transform(...)` builds an owning view
  (`owning_view`), takes `v` into itself through the move constructor,
  and `v.empty()` becomes `true`; the view still gives `10 20 30`.

## Next Step

The line seen in this lesson, `auto chain = numbers |
std::views::filter(...) | ...`, never wrote `chain`'s actual type — that
type is an unnamed view type nesting `filter` and `transform`. The next
lesson is this course's closing lesson: how `auto` and `decltype` deduce
types like this, and what type deduction leaves in the source.
