Embercode
✕
The STLLesson 17 of 17

45 min read

Streams and ranges

Why this matters

Many programs do the same three jobs again and again. They keep some items, change each kept item, and combine the results into one answer. A shop total is an example. Keep the paid orders, take each order's amount, and add the amounts up.

You can write that with a loop and a few variables. Java calls the same idea streams, and its code reads like orders.stream().filter(...).map(...).reduce(...). C++20 added ranges, a set of tools in the <ranges> header that let you write the same steps as a chain of named stages. Some of the older tools in <algorithm> and <numeric> also take part.

A chain is not always better than a loop. A short chain reads like a sentence and is easy to check. A chain that hides counters or changes outside variables inside its steps is harder to check than a loop. This lesson shows both sides with the same data, so you can choose for each task.

The lesson uses the begin and end pairs from the iterators lesson. A range here means anything with a begin and an end, such as a vector.

Three words: filter, map and reduce

Three words name the steps of a pipeline.

  • Filter keeps the items that pass a test. Given 12, 7 and 30 and the test "is even", filter keeps 12 and 30.
  • Map changes each item into something else. In C++ this step is called transform. Map turns 12 into 24.
  • Reduce combines all items into one value, such as a sum. In C++ the common tool is std::accumulate, which is in <numeric>.

Many of the steps need a small function that you write in place. C++ calls it a lambda. A lambda is an unnamed function written where it is used. The form is [](int a) { return a * 2; }. The square brackets at the start say which outside variables the lambda may use. An empty pair of brackets means it uses none. [&total] means it may use total and change it.

The first example compares a loop with a pipeline for the same task. Orders are amounts in dollars. The task is to keep the even amounts that are at least 10, double each one, and add them up.

#include <algorithm>
#include <iostream>
#include <iterator>
#include <numeric>
#include <ranges>
#include <vector>
 
int main() {
    std::vector<int> orders = {12, 7, 30, 4, 18, 9};
 
    // Loop version
    int total = 0;
    for (int amount : orders) {
        if (amount % 2 == 0 && amount >= 10) {
            total += amount * 2;
        }
    }
    std::cout << "loop total: " << total << '\n';
 
    // Pipeline version
    auto kept = orders
              | std::views::filter([](int a) { return a % 2 == 0 && a >= 10; })
              | std::views::transform([](int a) { return a * 2; });
    int pipe_total = std::accumulate(kept.begin(), kept.end(), 0);
    std::cout << "pipeline total: " << pipe_total << '\n';
 
    std::vector<int> collected;
    std::ranges::copy(kept, std::back_inserter(collected));
    std::cout << "collected:";
    for (int x : collected) std::cout << ' ' << x;
    std::cout << '\n';
}

Compile and run it with:

g++ -std=c++20 -Wall -o orders orders.cpp && ./orders

The output is:

loop total: 120
pipeline total: 120
collected: 24 60 36

The pipeline reads in order. Start with orders. Keep the amounts that are even and at least 10. Double each one. The | symbol joins the stages, in the same way that a shell pipe joins commands. The text std::views::filter names the stage, and the lambda inside it is the test.

Two details matter here. First, kept does not hold numbers. It holds the recipe. You will see why in the next section. Second, std::accumulate takes a start value, and the start value sets the type of the sum. The 0 makes the sum an int. The common mistakes section shows what goes wrong when that type is wrong.

The line with std::ranges::copy runs the pipeline and stores the results in a vector. Use it when you need the results in a container, not just a sum.

A pipeline is a recipe

The word view names a light object that describes how to read another range. A view does not copy the vector. It keeps a reference to the vector and the list of steps. The numbers are computed only when a loop asks for them. This is called lazy evaluation: work happens late, on demand.

The next example shows the lazy behaviour. It counts how many times the filter's test runs. It also uses std::views::iota, which makes a range of counting numbers. Here std::views::iota(1) starts at 1 and has no end, so the source is infinite. The std::views::take(3) stage stops it after three items.

#include <iostream>
#include <ranges>
 
int main() {
    int calls = 0;
    auto squares_of_evens = std::views::iota(1)
        | std::views::filter([&calls](int n) { ++calls; return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; })
        | std::views::take(3);
 
    std::cout << "built the pipeline, calls so far: " << calls << '\n';
    for (int s : squares_of_evens) std::cout << s << ' ';
    std::cout << "\ncalls after the loop: " << calls << '\n';
 
    std::cout << "second loop over the same view:";
    for (int s : squares_of_evens) std::cout << ' ' << s;
    std::cout << "\ncalls after the second loop: " << calls << '\n';
}

The output is:

built the pipeline, calls so far: 0
4 16 36 
calls after the loop: 8
second loop over the same view: 4 16 36
calls after the second loop: 14

Read the output in three parts.

The first line shows zero calls. Building the pipeline did no work. It only wrote down the recipe.

The loop prints 4, 16 and 36, the squares of 2, 4 and 6. The filter's test ran 8 times, not 6. The test ran for 1 and 2 to find the first match, then for 3 and 4, then for 5 and 6. After 36, the loop moves the pipeline one step further. The filter keeps testing until it finds the next even number, 8, and only then does take stop the loop. The extra work is real, so count it when you reason about cost.

The second loop prints the same three squares, but the count rises by only 6. The filter view remembers where it found its first match during the first loop, so the second loop starts from that point. It does not test 1 again. The test runs for the rest of the numbers, and that adds up to 6 calls.

Two lessons follow from this. A view can be read again, but each read may run the test and the transform again, so a test with a side effect is a bad idea. A view over an infinite source is safe only when a stage such as take or take_while ends it. A loop over std::views::iota(1) with no take never ends.

Java streams differ here. A Java stream can be used once. A second use of the same stream raises an error. A C++ view can be read again, as the example shows, so you must think about the cost of each read.

Records: a bigger example

Real data usually has several fields. The next example uses a struct with a region name, a number of units and a price per unit. The task is to total the revenue for the region "west", counting only sales with at least one unit. A second pipeline lists the regions of sales with at least three units.

#include <algorithm>
#include <iostream>
#include <numeric>
#include <ranges>
#include <string>
#include <vector>
 
struct Sale {
    std::string region;
    int units;
    double price;
};
 
int main() {
    std::vector<Sale> sales = {
        {"west", 3, 2.50},
        {"east", 5, 1.20},
        {"west", 0, 9.99},
        {"west", 4, 2.25},
        {"north", 2, 7.00},
    };
 
    // Loop version
    double west_loop = 0.0;
    for (const Sale& s : sales) {
        if (s.region == "west" && s.units > 0) {
            west_loop += s.units * s.price;
        }
    }
    std::cout << "west revenue (loop): " << west_loop << '\n';
 
    // Pipeline version
    auto west = sales | std::views::filter([](const Sale& s) {
                    return s.region == "west" && s.units > 0;
                });
    double west_pipe = std::accumulate(west.begin(), west.end(), 0.0,
        [](double acc, const Sale& s) { return acc + s.units * s.price; });
    std::cout << "west revenue (pipeline): " << west_pipe << '\n';
 
    // Regions that had a sale of at least 3 units
    auto big = sales
             | std::views::filter([](const Sale& s) { return s.units >= 3; })
             | std::views::transform([](const Sale& s) { return s.region; });
    std::cout << "big sale regions:";
    for (const std::string& r : big) std::cout << ' ' << r;
    std::cout << '\n';
}

The output is:

west revenue (loop): 16.5
west revenue (pipeline): 16.5
big sale regions: west east west

The west total is 3 times 2.50, which is 7.50, plus 4 times 2.25, which is 9.00. The sale with zero units is left out. Both versions give 16.5.

The reduce step here uses a lambda with two inputs. std::accumulate passes the running total, called acc, and the next item. The lambda returns the new running total. The start value 0.0 makes the total a double, which is needed for the prices.

The last pipeline has a transform stage that returns s.region, a string. The loop at the end receives each region name. A transform stage can return a different type from the one it receives, and the loop does not need to know the source type.

Notice that the pipeline for the big sales is written as one chain and read top to bottom. Each stage has one job. A loop for the same task would also be short, so either form is fine here.

Ending a pipeline

A pipeline must end in a step that uses it. A view by itself does nothing when it is only assigned to a name. Four endings cover most cases.

  • A range-based for loop (for (int x : items)), as in the examples above, when you want to print or act on each item.
  • std::ranges::copy into a container, such as std::back_inserter(collected), when you need a vector of the results.
  • std::accumulate when you need one value that combines all items, such as a sum.
  • std::ranges::distance when you need the number of items. It is in <iterator>. It counts the items that the view produces, so it also runs the filter.

Put this into practice with a count. The next line counts the words in a list that have five or more letters. The result is 3, because kettle, saucer and spoon each have at least five letters.

std::vector<std::string> words = {"tea", "kettle", "cup", "saucer", "spoon"};
auto long_words = words | std::views::filter([](const std::string& w) { return w.size() >= 5; });
std::cout << std::ranges::distance(long_words) << '\n';

The output is:

3

The order of stages

Stages run in the order you write them, for each item. Put a filter before an expensive transform. The transform then runs only on the items that the filter keeps. Take the example of a transform that reads a file. If the filter comes first, the file is read only for the kept names. If the transform comes first, the file is read for every name, including the ones the filter throws away.

Some work cannot be a stage at all. Sorting is the common case. std::ranges::sort moves items around inside a range, so it needs a range that allows jumping to any position. A filter view only steps forward or backward one item at a time, so std::ranges::sort cannot sort it. To sort the kept items, first copy them into a vector with std::ranges::copy, then sort the vector.

When a plain loop is clearer

Pipelines work well when each stage is a test or a change that uses only its own item. They fit badly when a step must remember what happened to earlier items, or when you need an index, or when one pass must produce two results.

Here is a task that needs a loop. A bank account starts with a balance. Each change is a deposit (positive) or a withdrawal (negative). The task is to find the position of the first change that makes the balance below zero, or report that it never does.

#include <cstddef>
#include <iostream>
#include <optional>
#include <vector>
 
// Plain loop: the index and the running balance are both needed.
std::optional<std::size_t> first_overdraft(const std::vector<int>& changes, int start) {
    int balance = start;
    for (std::size_t i = 0; i < changes.size(); ++i) {
        balance += changes[i];
        if (balance < 0) return i;
    }
    return std::nullopt;
}
 
int main() {
    std::vector<int> changes = {50, -20, -70, 10, -5};
    auto a = first_overdraft(changes, 30);
    std::cout << "loop: " << (a ? static_cast<long>(*a) : -1L) << '\n';
}

The output is:

loop: 2

Starting from 30, the balance goes to 80, then 60, then -10 at index 2. The loop keeps the balance in one variable and returns the index the moment the balance drops below zero. The function returns std::optional, which holds either a value or nothing. The std::nullopt value means no overdraft.

A pipeline version is possible with std::ranges::find_if. The test lambda must change balance as it goes.

int balance = start;
auto it = std::ranges::find_if(changes, [&balance](int c) {
    balance += c;
    return balance < 0;
});

This works, and it finds the same index, 2. But the test now changes a variable outside itself. A reader has to know that the test runs once for each item, in order, and that balance only makes sense in that order. A loop shows this order on the page. The pipeline hides it inside the test, and a future change to the chain could break it without any visible error.

A rule of thumb helps.

  • Use a pipeline when every stage is a pure test or change, meaning it uses only its own item, and the result is a list, a count, or a sum.
  • Use a loop when you need an index, when you carry a value from one item to the next, when you need to stop early and return several values, or when one pass must produce two results, such as the smallest and the largest value.

For the last case, a loop finds both values in one pass. A pipeline would need two separate passes, one for std::ranges::min_element and one for std::ranges::max_element. Two passes are fine on small data, but they read the data twice.

Common mistakes

The outputs shown below come from g++ 15.2.0 from Ubuntu. The last two mistakes are described, not run.

Returning a view over a local vector

A view holds a reference to its source. If the source is a local variable inside a function, the source is destroyed when the function returns, and the view now points at nothing. This mistake is easy to make because the code compiles.

#include <iostream>
#include <ranges>
#include <vector>
 
auto evens_of(int n) {
    std::vector<int> local;
    for (int i = 1; i <= n; ++i) local.push_back(i);
    return local | std::views::filter([](int x) { return x % 2 == 0; });
}
 
int main() {
    auto v = evens_of(6);
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';
}

Build it with the address sanitizer, a debugging option that checks memory reads, and run it:

g++ -std=c++20 -Wall -fsanitize=address -g -o dangle dangle.cpp && ./dangle

The compiler gives no warning with -Wall. The sanitizer reports the error when the loop runs. The first lines of its report are below. The addresses are shortened with ....

ERROR: AddressSanitizer: stack-use-after-return on address 0x6c28580f0068 ...
READ of size 8 at 0x6c28580f0068 thread T0
    #6 0x... in main .../dangle.cpp:13

The fix is to return the data, not the view. Collect the results into a vector inside the function, as the exercise answer below does.

Assigning a view to a vector

A view is not a vector. A vector variable cannot take a view directly. This code does not compile:

std::vector<int> nums = {1, 2, 3, 4};
std::vector<int> out = nums | std::views::filter([](int n) { return n > 2; });

The error message from g++ 15.2.0 starts like this, shortened with ...:

error: conversion from 'std::ranges::filter_view<...>' to non-scalar type 'std::vector<int>' requested

The message says a view was given where a vector was expected. Copy the results with std::ranges::copy into std::back_inserter(out), as in the first example.

Using an int start value for a double sum

std::accumulate uses its start value as the type of the running total. If you pass 0 for a list of doubles, the running total is an int, and each step throws away the fraction.

std::vector<double> prices = {1.5, 2.25};
int wrong = std::accumulate(prices.begin(), prices.end(), 0);
double right = std::accumulate(prices.begin(), prices.end(), 0.0);
std::cout << "int init: " << wrong << '\n';
std::cout << "double init: " << right << '\n';

The output is:

int init: 3
double init: 3.75

The total of 1.5 and 2.25 is 3.75. The int version gives 3, because 1.5 becomes 1 after the first step, and 1 plus 2.25 becomes 3. The fix is to write 0.0. Check the start value's type first whenever you reduce numbers that have a fraction.

Side effects in a lambda

The second example in this lesson showed that a test runs again when a view is read again. A test that prints, adds to a counter, or changes a variable outside itself will produce different results each time. In that example the counter rose from 8 to 14. Keep tests and changes free of side effects, or use a loop when a side effect is the point of the task.

An infinite range with no end stage

std::views::iota(1) has no end. A loop over it with no take, take_while, or a break inside the loop never stops. The program does not crash. It keeps running and uses more time until you stop it.

Practice

Each exercise asks for a pipeline or a fix. The answer follows under the words "Show the answer". The code in each answer was compiled and run.

Exercise 1

Find the sum of the squares of all odd numbers from 1 to 99. Use std::views::iota, std::views::filter, std::views::transform and std::accumulate. Start from std::views::iota(1, 100).

Show the answer

auto odd_squares = std::views::iota(1, 100)
                 | std::views::filter([](int n) { return n % 2 == 1; })
                 | std::views::transform([](int n) { return n * n; });
std::cout << "ex1: " << std::accumulate(odd_squares.begin(), odd_squares.end(), 0) << '\n';

The output is ex1: 166650. The sum of the first 50 odd squares is 166650.

Exercise 2

Count the words in {"tea", "kettle", "cup", "saucer", "spoon"} that have at least five letters. Use a pipeline and std::ranges::distance.

Show the answer

std::vector<std::string> words = {"tea", "kettle", "cup", "saucer", "spoon"};
auto long_words = words | std::views::filter([](const std::string& w) { return w.size() >= 5; });
std::cout << "ex2: " << std::ranges::distance(long_words) << '\n';

The output is ex2: 3.

Exercise 3

The vector {3, 8, 15, 22, 41, 57, 60} is sorted. Print the numbers below 50, and stop as soon as a number is 50 or more. Use std::views::take_while in a range-based for loop.

Show the answer

std::vector<int> sorted = {3, 8, 15, 22, 41, 57, 60};
std::cout << "ex3:";
for (int x : sorted | std::views::take_while([](int x) { return x < 50; })) {
    std::cout << ' ' << x;
}
std::cout << '\n';

The output is ex3: 3 8 15 22 41. The take_while stage ends at 57, the first value that fails the test.

Exercise 4

Find the average of the positive values in {4, -2, 7, 0, 10}. Use a pipeline for the sum and for the count. Use 0.0 as the start value, and divide as a double.

Show the answer

std::vector<int> readings = {4, -2, 7, 0, 10};
auto positive = readings | std::views::filter([](int r) { return r > 0; });
double sum = std::accumulate(positive.begin(), positive.end(), 0.0);
long count = static_cast<long>(std::ranges::distance(positive));
std::cout << "ex4 average: " << sum / static_cast<double>(count) << '\n';

The output is ex4 average: 7. The positive values are 4, 7 and 10. Their sum is 21, and the count is 3.

Exercise 5

The function evens_of in the common mistakes section returns a view over a local vector, so the view points at nothing after the function returns. Rewrite it so that it returns a std::vector<int> with the even numbers from 1 to n.

Show the answer

std::vector<int> evens_up_to(int n) {
    std::vector<int> local;
    for (int i = 1; i <= n; ++i) local.push_back(i);
    std::vector<int> out;
    std::ranges::copy(local | std::views::filter([](int x) { return x % 2 == 0; }),
                      std::back_inserter(out));
    return out;
}

Calling evens_up_to(10) and printing the result gives 2 4 6 8 10. The function returns a vector, which owns its numbers, so the caller can keep them after the function ends.

Summary

  • A pipeline chains filter, transform and reduce stages. Each stage has one job, and the chain reads in order.
  • A view is a recipe. Building it does no work, and each loop over it runs the stages again.
  • A view must not outlive the container it reads. Return a vector, not a view, from a function that owns its data.
  • Check the start value of std::accumulate against the type of the data, so that a double sum does not become an int.
  • Use a plain loop when you need an index, a running value carried between items, an early exit, or two results from one pass.
Check yourself: Pipelines (6 questions) →Practice this topic: coding problems for The STL →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Grouping with maps of lists