Skip to content
academia.sh

Lesson 10 / 11

Ranges

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

Published Updated

In this lesson

  • 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
Contents

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.

// 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;
}
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.

// 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;
}
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.

// 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;
}
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.

// 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;
}
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.

// 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;
}
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.

// 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;
}
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.

// 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;
}
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close