Ranges & Views

C++ · Lesson 23 of 32 6 min read Updated

In the last lesson you used std::ranges algorithms on whole containers. C++20 also introduced views: lightweight objects that present the elements of another range filtered, transformed, reversed or sliced, without copying anything. You can join views with the pipe symbol | into readable pipelines, much like Unix shell commands or Java streams.

In this lesson you will build pipelines with the most useful views, see that views are lazy, use the C++23 additions enumerate, zip and std::ranges::to, and learn the one big rule for using views safely.

Ranges and your first pipeline

A range is anything you can loop over with begin() and end(): vectors, arrays, strings, maps. A view is a range that does not own its elements; it looks at another range and adapts it. Views live in the std::views namespace, in the <ranges> header.

C++
#include <print>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> marks = {45, 78, 92, 33, 60, 88};

    auto boosted = marks
        | std::views::filter([](int m) { return m >= 40; })     // keep passes
        | std::views::transform([](int m) { return m + 5; });   // add grace marks

    for (int m : boosted) {
        std::print("{} ", m);
    }
    std::println("");
    std::println("Original first mark: {}", marks[0]);
}
Output
50 83 97 65 93
Original first mark: 45

Read the pipeline from top to bottom: take marks, keep only the marks of 40 or more, then add 5 to each. The 33 disappears, and the original vector is unchanged, because a view never modifies or copies the data it looks at.

Views are lazy

Building a pipeline does no work. Each element is computed only when the loop asks for it, one at a time. This example proves it by printing inside the transformation:

C++
#include <print>
#include <ranges>

int main() {
    auto pipeline = std::views::iota(1, 100)
        | std::views::transform([](int n) {
              std::println("computing {}", n);
              return n * 10;
          })
        | std::views::take(3);

    std::println("pipeline built, nothing computed yet");
    for (int value : pipeline) {
        std::println("got {}", value);
    }
}
Output
pipeline built, nothing computed yet
computing 1
got 10
computing 2
got 20
computing 3
got 30

std::views::iota(1, 100) produces the numbers 1 to 99 on demand, without storing them. Because take(3) stops after three elements, only three of the 99 numbers are ever transformed. Laziness also means you can work with infinite ranges: std::views::iota(1) counts upwards forever, and that is fine as long as something like take stops the loop.

Useful views

ViewWhat it produces
std::views::filter(pred)Only the elements for which pred returns true
std::views::transform(f)f(element) for each element
std::views::take(n), drop(n)The first n elements, or all except the first n
std::views::reverseThe elements in reverse order
std::views::iota(a, b)The numbers a, a + 1, ... up to b - 1
std::views::keys, valuesThe first or second part of each pair, as in a map
C++
#include <map>
#include <print>
#include <ranges>
#include <string>
#include <vector>

int main() {
    auto squares = std::views::iota(1)
        | std::views::transform([](int n) { return n * n; })
        | std::views::take(5);
    for (int s : squares) { std::print("{} ", s); }
    std::println("");

    std::vector<std::string> line = {"Aarav", "Isha", "Kabir", "Meera", "Rohan"};
    for (const auto& name : line | std::views::drop(2) | std::views::reverse) {
        std::print("{} ", name);
    }
    std::println("");

    std::map<std::string, int> stock = {{"Pens", 120}, {"Rulers", 40}, {"Erasers", 75}};
    for (const auto& item : stock | std::views::keys) { std::print("{} ", item); }
    int total = 0;
    for (int qty : stock | std::views::values) { total += qty; }
    std::println("| total items: {}", total);
}
Output
1 4 9 16 25
Rohan Meera Kabir
Erasers Pens Rulers | total items: 235

drop(2) skips Aarav and Isha, then reverse walks the rest backwards. The map keys come out sorted because std::map keeps them in order.

New in C++23: enumerate, zip and ranges::to

C++23 added several views that remove common hand-written loops, and a way to turn a view back into a real container.

C++
#include <print>
#include <ranges>
#include <string>
#include <tuple>
#include <vector>

int main() {
    std::vector<std::string> names = {"Aarav", "Priya", "Kabir"};
    std::vector<int> scores = {82, 95, 77};

    for (auto [index, name] : std::views::enumerate(names)) {
        std::println("{}. {}", index + 1, name);
    }

    for (auto [name, score] : std::views::zip(names, scores)) {
        std::println("{} scored {}", name, score);
    }

    auto toppers = std::views::zip(names, scores)
        | std::views::filter([](const auto& entry) { return std::get<1>(entry) >= 80; })
        | std::views::transform([](const auto& entry) { return std::get<0>(entry); })
        | std::ranges::to<std::vector>();

    std::println("Toppers: {} and {}", toppers[0], toppers[1]);
}
Output
1. Aarav
2. Priya
3. Kabir
Aarav scored 82
Priya scored 95
Kabir scored 77
Toppers: Aarav and Priya
  • std::views::enumerate gives pairs of (index, element), so you no longer need a separate counter.
  • std::views::zip walks several ranges side by side, giving one tuple per position and stopping at the shortest range.
  • std::ranges::to<std::vector>() runs the pipeline and stores the results in a new container. Here the element type, std::string, is deduced.

The big rule: a view must not outlive its data

A view only refers to the original elements. If that original container is destroyed, the view dangles, exactly like a std::string_view:

C++
auto passed_marks() {
    std::vector<int> marks = {45, 78, 33};
    return marks | std::views::filter([](int m) { return m >= 40; });  // dangles!
}

marks is destroyed when the function returns, so the returned view points at freed memory. Return a real container instead, for example by ending the pipeline with std::ranges::to<std::vector>(). Also avoid changing the underlying container while you are looping over a view of it.

Common mistakes

  • Returning views of local containers. The view dangles; return a container.
  • Expecting a view to store results. A view recomputes on every loop; if a transformation is expensive and you loop twice, store the results with std::ranges::to.
  • Forgetting #include <ranges>. Views are not available without it.
  • Using the pipe on algorithms. | works with views and std::ranges::to, not with algorithms like std::ranges::sort, which you call normally.

Key points

  • A view adapts another range without copying; join views with | into a pipeline.
  • Views are lazy: elements are computed only when needed, so infinite ranges like std::views::iota(1) are fine with take.
  • Common views: filter, transform, take, drop, reverse, iota, keys and values.
  • C++23 adds enumerate, zip and std::ranges::to to collect results into a container.
  • A view must never outlive the container it looks at.

Exercise

Given std::vector<int> prices = {499, 1299, 89, 2499, 349, 999};, build a pipeline that keeps prices above 300, applies a 10 percent discount (integer arithmetic: p * 90 / 100), and keeps only the first three results. Print them, then collect the same pipeline into a std::vector<int> with std::ranges::to and print its size.

Show solution
C++
#include <print>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> prices = {499, 1299, 89, 2499, 349, 999};

    auto deals = prices
        | std::views::filter([](int p) { return p > 300; })
        | std::views::transform([](int p) { return p * 90 / 100; })
        | std::views::take(3);

    for (int d : deals) {
        std::print("{} ", d);
    }
    std::println("");

    auto saved = deals | std::ranges::to<std::vector>();
    std::println("Stored {} deals", saved.size());
}
Output
449 1169 2249
Stored 3 deals

The filter removes 89, the discount turns 499 into 449, 1299 into 1169 and 2499 into 2249, and take(3) stops before 349 and 999.

Quick Quiz

1. What does a view do with the data it looks at?

2. When are the elements of a view pipeline computed?

3. Which C++23 facility turns a view into a std::vector?

4. Why is returning local_vector | std::views::filter(...) from a function a bug?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call