The C++ Textbook

Part 4 · The standard library

Ranges and views

Composable, lazy pipelines over sequences.

By the end of this chapter you can

  • Compose a pipeline of views
  • Explain what makes a view lazy and cheap to copy
  • Identify when a view dangles

Chapter 4.5 used std::ranges:: algorithms because they take a container instead of two iterators. That is the small half of what ranges added. The larger half is views: sequences you can compose into a pipeline that does the work lazily, as you read it.

The problem views solve

Combining algorithms means allocating a container for each step:

Three passes and two temporaries
#include <algorithm>
#include <iostream>
#include <vector>

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

    std::vector<int> evens;
    std::ranges::copy_if(input, std::back_inserter(evens), [](int x) { return x % 2 == 0; });

    std::vector<int> squared;
    std::ranges::transform(evens, std::back_inserter(squared), [](int x) { return x * x; });

    std::vector<int> first_three(squared.begin(), squared.begin() + 3);

    for (int x : first_three) std::cout << x << ' ';
    std::cout << "\n(two temporary vectors, three passes)\n";
}

The same thing as a pipeline:

One pass, no temporaries
#include <iostream>
#include <ranges>
#include <vector>

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

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

    for (int x : pipeline) std::cout << x << ' ';
    std::cout << "\n(no temporaries, and only as much work as take(3) needed)\n";
}

| is the composition operator. It reads left to right in the order the data flows, which is the order you think in.

Lazy means nothing happens until you read

A view does no work when constructed. It stores what to do, and does it one element at a time as you iterate — which is why the take(3) above stops the whole pipeline after three results rather than filtering all ten:

Watching the laziness
#include <iostream>
#include <ranges>
#include <vector>

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

    auto pipeline = input
                  | std::views::filter([](int x) {
                        std::cout << "  testing " << x << '\n';
                        return x % 2 == 0;
                    })
                  | std::views::transform([](int x) {
                        std::cout << "    squaring " << x << '\n';
                        return x * x;
                    })
                  | std::views::take(2);

    std::cout << "pipeline built — nothing has run yet\n\n";

    for (int x : pipeline) std::cout << "got " << x << "\n";

    std::cout << "\nnote it stopped at 4, never touching 5 through 8\n";
}

Nothing printed until the loop started, and the pipeline stopped as soon as take(2) was satisfied. That is the property you cannot get from chained algorithms: work you do not need is never done.

It also makes infinite sequences usable:

An infinite range, taken finitely
#include <iostream>
#include <ranges>

int main() {
    // iota with no upper bound is infinite. Laziness makes that harmless.
    auto squares = std::views::iota(1)
                 | std::views::transform([](int n) { return n * n; })
                 | std::views::filter([](int n) { return n % 3 == 1; })
                 | std::views::take(5);

    for (int x : squares) std::cout << x << ' ';
    std::cout << '\n';
}

Views are cheap to copy

A view holds a reference to the underlying range plus whatever it needs to do its job. It does not own elements, so copying one is copying a couple of pointers:

What a view actually contains
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> big(1'000'000, 7);

    auto view = big | std::views::filter([](int x) { return x > 0; });

    std::cout << "vector holds " << big.size() * sizeof(int) / 1024 << " KB\n";
    std::cout << "sizeof(view) = " << sizeof(view) << " bytes\n";

    auto copy = view;                    // copies the view, not the million ints
    std::cout << "copying the view is free; first element " << *copy.begin() << '\n';
}

This is why views are passed by value. std::span follows the same rule, and so does std::string_view — which is a view in exactly this sense.

The views worth knowing

A tour
#include <iostream>
#include <ranges>
#include <string>
#include <vector>

int main() {
    const std::vector<int> v{5, 3, 8, 1, 9, 2};

    auto show = [](const char* label, auto&& range) {
        std::cout << label;
        for (auto&& x : range) std::cout << x << ' ';
        std::cout << '\n';
    };

    show("filter:   ", v | std::views::filter([](int x) { return x > 3; }));
    show("transform:", v | std::views::transform([](int x) { return x * 10; }));
    show("take:     ", v | std::views::take(3));
    show("drop:     ", v | std::views::drop(3));
    show("reverse:  ", v | std::views::reverse);

    // take_while / drop_while stop at the first element failing the test.
    show("take_while<8:", v | std::views::take_while([](int x) { return x < 8; }));

    // iota generates a range of values.
    show("iota 1..5:", std::views::iota(1, 6));

    // enumerate pairs each element with its index (C++23).
    std::cout << "keys/values on a map, and split on a string, are the other two\n";
}

Two more that come up constantly with real data:

split and join
#include <iostream>
#include <ranges>
#include <string>
#include <string_view>

int main() {
    const std::string csv = "alpha,beta,gamma";

    for (auto field : csv | std::views::split(',')) {
        // Each field is a subrange; construct a string_view over it.
        std::string_view text{field.begin(), field.end()};
        std::cout << "field: " << text << '\n';
    }
}

When a view dangles

A view usually borrows, so the obvious guess is that a view over a temporary dangles. Check that guess:

A view over a temporary container
#include <iostream>
#include <ranges>
#include <vector>

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

int main() {
    // The temporary is piped straight into the pipeline.
    auto view = make_data() | std::views::filter([](int x) { return x % 2; });

    for (int x : view) std::cout << x << ' ';
    std::cout << "\n";

    std::cout << "the pipe produced an owning_view: " << std::boolalpha
              << std::is_same_v<decltype(std::views::all(make_data())),
                                std::ranges::owning_view<std::vector<int>>>
              << "\n";
}

That is correct, and it is not luck. Piping an rvalue container into a view produces a std::ranges::owning_view, which moves the container in and owns it. The temporary is not destroyed at the end of the statement — it lives inside the view, for as long as the view does.

So the widely repeated advice that “a view over a temporary dangles” is wrong for the most common case. The real rule is narrower and less intuitive:

A view over an lvalue holds a reference to it. A view over an rvalue container owns it.

Which means the dangerous case is the one that looks safest — a named local:

The case that actually dangles
#include <iostream>
#include <ranges>
#include <vector>

auto make_view() {
    std::vector<int> local{1, 2, 3, 4, 5};

    // `local` is an lvalue, so this stores a reference to it — not the vector.
    return local | std::views::filter([](int x) { return x % 2; });
}

int main() {
    auto view = make_view();
    for (int x : view) std::cout << x << ' ';   // the vector is long gone
    std::cout << "\n";
}

AddressSanitizer reports stack-use-after-return, and no warning was issued at compile time. Note how the two samples differ: the safe one pipes the function’s result directly, and the dangerous one names it first. Adding a variable made it worse.

The library does help where it can. Algorithms that return an iterator into their argument return std::ranges::dangling instead when handed a temporary, turning a run-time disaster into a compile error:

The library refusing to hand you a dangling iterator
#include <ranges>
#include <vector>

std::vector<int> make_data() { return {1, 2, 3}; }

int main() {
    // find returns ranges::dangling here, because the range was a temporary.
    auto it = std::ranges::find(make_data(), 2);
    return *it;      // error: no operator* on std::ranges::dangling
}

Getting a container back

A pipeline is not a container. When you need one — to return it, store it, or sort it — you have to materialise:

Materialising a pipeline
#include <iostream>
#include <ranges>
#include <vector>

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

    auto pipeline = input | std::views::filter([](int x) { return x % 2 == 0; })
                          | std::views::transform([](int x) { return x * x; });

    // Construct from the pipeline's iterators.
    std::vector<int> result(pipeline.begin(), pipeline.end());

    for (int x : result) std::cout << x << ' ';
    std::cout << "\n(now an owning container, safe to return)\n";
}

That two-iterator constructor works here, but it is not the general answer. It requires a common range — one whose begin() and end() have the same type. Many pipelines are not common, and take over an unbounded range is the usual way to meet one:

When the two-iterator constructor does not apply
#include <algorithm>   // std::ranges::copy
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>

int main() {
    auto pipeline = std::views::iota(1)
                  | std::views::filter([](int x) { return x % 3 == 0; })
                  | std::views::take(4);

    // std::vector<int> bad(pipeline.begin(), pipeline.end());
    //   ^ does not compile: end() is a sentinel of a different type

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

    for (int x : result) std::cout << x << ' ';
    std::cout << "\n(ranges::copy works for any range, common or not)\n";
}

std::ranges::copy into a back_inserter is the form that always works, and a plain for loop pushing each element is just as good. C++23 adds std::ranges::to<std::vector>(), which appends to the pipeline directly — input | views::filter(...) | std::ranges::to<std::vector>() — and handles both cases.

Is it faster?

Sometimes, and the reason is the temporaries, not magic:

Pipeline versus intermediate containers
#include <algorithm>
#include <chrono>
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> input(1'000'000);
    for (std::size_t i = 0; i < input.size(); ++i) input[i] = static_cast<int>(i);

    using clock = std::chrono::steady_clock;
    using ms = std::chrono::milliseconds;

    auto start = clock::now();
    long long a = 0;
    {
        std::vector<int> evens;
        std::ranges::copy_if(input, std::back_inserter(evens), [](int x) { return x % 2 == 0; });
        std::vector<int> scaled;
        std::ranges::transform(evens, std::back_inserter(scaled), [](int x) { return x * 3; });
        for (int x : scaled) a += x;
    }
    auto mid = clock::now();

    long long b = 0;
    for (int x : input | std::views::filter([](int x) { return x % 2 == 0; })
                       | std::views::transform([](int x) { return x * 3; })) {
        b += x;
    }
    auto finish = clock::now();

    std::cout << "with temporaries: " << std::chrono::duration_cast<ms>(mid - start).count() << " ms\n";
    std::cout << "pipeline:         " << std::chrono::duration_cast<ms>(finish - mid).count() << " ms\n";
    std::cout << "(same answer: " << std::boolalpha << (a == b) << ")\n";
}

The pipeline avoids two allocations and two passes over a million elements. Where a hand-written loop would be just as fast, the pipeline is not faster than the loop — it is clearer, and no slower. Chapter 7.4 checks that claim against the generated assembly.

Check yourself

Practice