Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 9 of 12

Every algorithm so far took two iterators. C++20 lets them take the container:

std::sort(v.begin(), v.end());     // classic
std::ranges::sort(v);              // C++20

That is the small win. The large one is views: lazily composed pipelines that build no intermediate containers.

The basics

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

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

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

    // The whole container, not two iterators.
    std::ranges::sort(v);
    std::cout << "sorted: ";
    for (int x : v) std::cout << x << " ";
    std::cout << "\n";

    std::cout << "count > 4: " << std::ranges::count_if(v, [](int x) { return x > 4; }) << "\n";

    // Views: lazy, composable, no intermediate containers.
    auto evens_squared = v
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; });

    std::cout << "even squares: ";
    for (int x : evens_squared) std::cout << x << " ";
    std::cout << "\n";

    // take, drop, reverse
    std::cout << "first 3: ";
    for (int x : v | std::views::take(3)) std::cout << x << " ";
    std::cout << "\n";
    std::cout << "drop 3:  ";
    for (int x : v | std::views::drop(3)) std::cout << x << " ";
    std::cout << "\n";
    std::cout << "reverse: ";
    for (int x : v | std::views::reverse) std::cout << x << " ";
    std::cout << "\n";

    // iota generates without a container existing at all.
    std::cout << "iota 1..5 squared: ";
    for (int x : std::views::iota(1, 6) | std::views::transform([](int i) { return i * i; }))
        std::cout << x << " ";
    std::cout << "\n";

    // Projections replace most comparators.
    std::vector<Employee> staff = {{"Dara", 90}, {"Bopha", 70}, {"Kosal", 85}};
    std::ranges::sort(staff, {}, &Employee::salary);
    std::cout << "by salary: ";
    for (const auto& e : staff) std::cout << e.name << " ";
    std::cout << "\n";

    auto top = std::ranges::max_element(staff, {}, &Employee::salary);
    std::cout << "top earner: " << top->name << "\n";
    return 0;
}
sorted: 1 2 3 5 7 8 9 
count > 4: 4
even squares: 4 64 
first 3: 1 2 3 
drop 3:  5 7 8 9 
reverse: 9 8 7 5 3 2 1 
iota 1..5 squared: 1 4 9 16 25 
by salary: Bopha Kosal Dara 
top earner: Dara

Four features in that program.

std::ranges::sort(v) takes the container. Less noise, and it is impossible to accidentally pass v.begin() with w.end() — a real bug the classic form allows.

The | pipeline composes views left to right, in the order the data flows. Compare that to nested calls, which read inside-out.

std::views::iota(1, 6) produces 1 through 5 with no container anywhere. There is nothing to allocate and nothing to free.

Projections — std::ranges::sort(staff, {}, &Employee::salary) — replace the comparator with a member pointer. {} means "the default comparator", and &Employee::salary says which field to apply it to. This is the single most useful thing in the ranges library for everyday code.

Laziness, measured

A view computes nothing until you iterate it, and then computes only what you consume:

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

int calls_filter = 0;
int calls_transform = 0;

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

    auto pipeline = v
        | std::views::filter([](int x) { ++calls_filter; return x % 2 == 0; })
        | std::views::transform([](int x) { ++calls_transform; return x * 10; });

    std::cout << "after building the pipeline: filter called " << calls_filter
              << ", transform called " << calls_transform << "\n";

    // Lazy: nothing has run yet. Taking only the first 2 does only the work needed.
    int taken = 0;
    for (int x : pipeline | std::views::take(2)) {
        std::cout << "got " << x << "\n";
        ++taken;
    }

    std::cout << "after taking " << taken << ": filter called " << calls_filter
              << ", transform called " << calls_transform << "\n";
    return 0;
}
after building the pipeline: filter called 0, transform called 0
got 20
got 40
after taking 2: filter called 6, transform called 2

Building the pipeline ran nothing. Both counters are zero — pipeline is a description of work, not the work.

Taking two elements transformed exactly two. Elements 7 through 10 were never touched at all.

The filter count is the interesting one: 6, not 4. Finding the first two evens needs checks on 1, 2, 3, 4 — that is 4. The extra two come from how filter_view advances: after yielding 4, the iterator's ++ eagerly scans forward for the next matching element, checking 5 and finding 6, before take decides the loop is over.

That is worth knowing because it means a filter predicate runs one element further than you might expect. If your predicate is expensive, or has an observable cost, budget for the extra call.

The eager-copy alternative would have filtered all 10 and transformed all 5. Laziness saved real work here, and it saves the entire intermediate std::vector that a chain of copy_if and transform would have needed.

The views worth knowing

| View | Does | |---|---| | filter(pred) | keep elements matching | | transform(f) | apply f to each | | take(n) / take_while(pred) | first n / while true | | drop(n) / drop_while(pred) | skip n / skip while true | | reverse | back to front | | iota(a, b) / iota(a) | generate a..b-1 / unbounded | | join | flatten a range of ranges | | split(delim) | split into subranges | | elements<N> / keys / values | the Nth of each tuple; map keys or values | | zip (C++23) | pair up two ranges | | enumerate (C++23) | index alongside value | | chunk / slide (C++23) | fixed groups / sliding windows |

std::views::keys and std::views::values are the ones you will reach for constantly on a map:

// for (const auto& name : ages | std::views::keys) { ... }
// auto total = std::ranges::fold_left(ages | std::views::values, 0, std::plus{});   // C++23

Views do not own, which is the trap

A view refers to its source. If the source dies, the view dangles — the same hazard as lesson 8.5, in new clothing:

// auto bad = get_vector() | std::views::filter(is_even);
//
// get_vector() returns a temporary. It is destroyed at the end of the
// statement, and `bad` refers to freed memory. Iterating it afterwards is
// a use-after-free, and ASan reports it as one.
//
// auto v = get_vector();                        // keep the container alive
// auto good = v | std::views::filter(is_even);  // now the view is safe

C++20 has partial protection: some views refuse to be constructed from an rvalue container. It is not complete, and it does not cover every combination. Treat a view like a reference — because that is what it is.

The same applies to a view stored in a class member or returned from a function. If you need to return the result, materialise it into a container.

Materialising

To turn a view into a container before C++23:

// auto view = v | std::views::filter(is_even);
// std::vector<int> result(view.begin(), view.end());
//
// or:
// std::vector<int> result;
// std::ranges::copy(view, std::back_inserter(result));

C++23 adds std::ranges::to, which is what you want:

// auto result = v | std::views::filter(is_even)
//                 | std::ranges::to<std::vector>();

GCC 14 and Clang 17 support it; GCC 13 — the compiler these lessons are checked with — does not, so the two-iterator constructor is the portable form today.

Constrained by concepts

std::ranges algorithms are constrained (lesson 12.7), which is why their errors are readable. Lesson 12.7 measured it: std::sort on a non-comparable type produced 78 lines of libstdc++ internals, std::ranges::sort produced 34 lines naming sortable as the failed requirement.

That difference alone is a reason to prefer the ranges versions while learning.

When not to use ranges

Three honest limits.

Compile times. Range pipelines are heavily templated and measurably slower to compile than the equivalent loop. On a large codebase this is noticeable.

Debugging. Step into a view pipeline in GDB (lesson 11.2) and you land in layers of ranges::__adaptor machinery. A plain loop steps cleanly.

Not everything is lazy-friendly. sort cannot be a view — sorting needs the whole range at once. The algorithms that produce views are the element-wise ones.

For a simple loop over a container, a range-for is still the clearest thing you can write. Reach for views when you are composing three or four transformations, or when laziness saves real work.

Try it

Given a vector of temperature readings, build a pipeline that drops the first 2 (warm-up), keeps only readings above freezing, converts Celsius to Fahrenheit, and takes the first 5. Write it with views and again with classic loops, then count how many conversions each performs.

Checkpoint

  • std::ranges::sort(v) takes a container, so you cannot mismatch begin() and end().
  • Views compose with |, reading left to right in the order data flows.
  • A view is lazy: building the pipeline runs nothing; iterating runs only what you consume.
  • Measured: taking 2 of 10 elements ran the transform twice, not five times.
  • filter_view advances eagerly, so its predicate runs one element beyond what you consume.
  • Views build no intermediate containers — the measured exercise saved two vectors and 4 calls.
  • A view does not own its source; a view over a temporary dangles like any other reference.
  • std::views::keys and values are the everyday map helpers.
  • std::ranges::to (C++23) materialises a view; before that, use the two-iterator constructor.
  • Ranges algorithms are concept-constrained, so their errors name the failed requirement.
  • Costs: slower compiles, harder debugging, and not everything can be lazy.

Questions about this lesson

Log in to ask a question.