View Composition and Pipes

Learn how to build readable, lazy-evaluated data pipelines that execute in a single pass, and chain C++20 views together using the pipe operator |

Ryan McCombe
Published

In the previous lesson, we introduced the concept of views - lightweight, non-owning wrappers that allow us to transform data lazily. We saw that std::views::transform() generates a proxy iterator that calculates values on demand, saving us from allocating temporary vectors.

Without ranges, we have to nest function calls or manage temporary containers.

#include <vector>
#include <algorithm>
#include <iostream>

void UsingIterators(const std::vector<int>& input) {
  std::vector<int> temp;

  // Pass 1: Filter
  std::copy_if(
    input.begin(), input.end(),
    std::back_inserter(temp),
    [](int i){ return i % 2 == 0; }
  );

  // Pass 2: Transform
  for(auto& i : temp) i = i * i;

  // Pass 3: Take 3
  int count = 0;
  for(int i : temp) {
    if(count++ >= 3) break;
    // ...
  }
}

This code is terrible for performance. It allocates a temporary vector temp on the heap (slow). It iterates over the data multiple times (bad temporal locality). And it processes all the even numbers, even though we only need the first 3 (wasted work).

Using Views

Views can save us from this but, if we use the syntax we're accustomed to, we would have to nest the function calls inside each other.

// The "Onion" Anti-Pattern
auto result = std::views::take(
  std::views::transform(
    std::views::filter(input, is_even), // Innermost logic
    square // Middle logic
  ),
  3 // Outermost logic
);

Humans read top-to-bottom, but this code executes inside-out. To understand what is happening, you have to dig to the center of the nest (filter) and then mentally unwind the stack to the outside (take).

Worse, this syntax makes it difficult to safely modify the pipeline. Adding a new step requires inserting code in the middle of a bracket soup and balancing the closing parentheses at the end.

To solve this, C++20 adopted one of the most beloved features of Unix shells: the pipe operator |.

Using the Pipe Operator |

With the | operator, we can describe a pipeline in a much friendlier way. We can just describe our algorithm top to bottom - filter, then transform, then take:

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

void UsingViews(const std::vector<int>& input) {
  // Define the pipeline
  auto pipeline = input
    | std::views::filter([](int i){ return i % 2 == 0; })
    | std::views::transform([](int i){ return i * i; })
    | std::views::take(3);

  // Execute the pipeline
  for (int i : pipeline) {
    // ...
  }
}

Our previous implementation using <algorithm> was a traditional top-down, "push" mechanism.

Even though we still read our new <ranges> logic from top-to-bottom, if we want to understand what's going on behind the scenes, we should interpret it as a bottom-up, "pull" mechanism:

  1. The loop asks take(3) for an item.
  2. take(3) asks transform for an item.
  3. transform asks filter for an item.
  4. filter pulls from input until it finds an even number.
  5. The loop asks for take(3) for the next item...

Once take(3) has received 3 items, it uses the sentinal mechanism to signal the loop to stop. If input had 1,000,000 items, we would stop touching memory after finding the 3rd valid match.

So, aside from being faster/safer to write and easier to read, our modern approach also saves massive amounts of CPU time and memory bandwidth.

Saving and Reusing Views

Our previous example defined the entire pipeline as a series of custom views. However, views can also be saved and shared like any other variable. Below, we define filter, transform, and take as dedicated objects, sharable and composable as needed:

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

void UsingViews(const std::vector<int>& input) {
  // Create reusable views
  auto isEven = std::views::filter([](int i){ return i % 2 == 0; });
  auto square = std::views::transform([](int i){ return i * i; });
  auto take3 = std::views::take(3);
  
  // Compose some views:
  auto take3squares = square | take3;

  // Compose and execute:
  for (int i : input | isEven | take3squares) {
    // ...
  }
}

Infinite Ranges

Because of lazy evaluation of views, we can define ranges that are theoretically infinite. The std::views::iota(0) view is an example of this. Every time we ask for a number, it generates the next number in the infinite sequence 0, 1, 2, 3...

We can't store an infinite sequence in a container - we don't have infinite memory. But as a lazy-evaluated view, we don't need to store it - the next number in the sequence can just be generated on demand:

#include <ranges>
#include <iostream>

int main() {
  // Generate infinite numbers starting at 0
  auto infinite_numbers = std::views::iota(0);

  // Pipe them through logic
  auto squares = infinite_numbers
    | std::views::transform([](int i) { return i * i; })
    | std::views::take(5);

  // Prints: 0 1 4 9 16
  for (int i : squares) {
    std::cout << i << " ";
  }
}
0 1 4 9 16

Hardware Implications

It is natural to worry that all these layers - Views, Adaptors, Sentinels, Lambdas - add overhead.

However, modern compilers are incredibly good at "devirtualizing" these abstractions. Because the types are all known at compile time (templates), the compiler can inline the entire pipeline.

When you compile a view-based pipeline, the compiler effectively rewrites it into the raw loop you might have written by hand. You get the safety and readability of high-level abstractions with the raw mechanical speed of low-level C.

This is also still improving over time - <ranges> is a relatively recent addition to the language, with many optimizations still to be found by those working on standard library implementations.

Benchmarking

Remember, if we're working on anything where performance is a concern, we should test our algorithms and theories.

Below, we test three versions of our filter transform take 3 algorithm in the environment we created in our .

We use the iterator/standard library algorithm approach UsingIterators and the view-based approach UsingViews from before. We also attempt an implementation using a raw, abstraction-free loop:

benchmarks/main.cpp

#include <benchmark/benchmark.h>
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm>
#include <numeric>

int UsingViews(const std::vector<int>& input) {
  int sum = 0;
  auto pipeline = input 
    | std::views::filter([](int i){ return i % 2 == 0; }) 
    | std::views::transform([](int i){ return i * i; }) 
    | std::views::take(3);
  
  for (int i : pipeline) {
    sum += i;
  }
  return sum;
}

int UsingIterators(const std::vector<int>& input) {
  std::vector<int> temp;
  temp.reserve(input.size() / 2);
  
  // Filter
  std::copy_if(
    input.begin(), input.end(),
    std::back_inserter(temp),
    [](int i){ return i % 2 == 0; }
  );
  
  // Transform
  for(auto& i : temp) i = i * i;
  
  // Take 3 and sum
  int sum = 0;
  int count = 0;
  for(int i : temp) {
    if(count++ >= 3) break;
    sum += i;
  }
  return sum;
}

int UsingLoop(const std::vector<int>& input) {
  int count = 0;
  int sum = 0;
  for (int i : input) {
    if (i % 2 == 0) {
      sum += i * i;
      if (++count == 3) {
        break;
      }
    }
  }
  return sum;
}

static constexpr int N = 10000;

static void BM_UsingViews(benchmark::State& state) {
  std::vector<int> l(N, 1);
  
  for (auto _ : state) {
    int result = UsingViews(l);
    benchmark::DoNotOptimize(result);
  }
}
BENCHMARK(BM_UsingViews);

static void BM_UsingIterators(benchmark::State& state) {
  std::vector<int> l(N, 1);
  
  for (auto _ : state) {
    int result = UsingIterators(l);
    benchmark::DoNotOptimize(result);
  }
}
BENCHMARK(BM_UsingIterators);

static void BM_UsingLoop(benchmark::State& state) {
  std::vector<int> l(N, 1);
  
  for (auto _ : state) {
    int result = UsingLoop(l);
    benchmark::DoNotOptimize(result);
  }
}
BENCHMARK(BM_UsingLoop);

We've filled our input with odd numbers (1) creating the worst case scenario. Our algorithms will be forced to search through the entire array hunting for even numbers that never come.

In this scenario, the abstractions are even better than zero cost on my environment. The C++20 UsingViews variation is comfortably ahead, but both of the standard library implementations have optimized to something less expensive than we implemented with our raw loop:

------------------------------
Benchmark                  CPU
------------------------------
BM_UsingViews          2431 ns
BM_UsingIterators      4757 ns
BM_UsingLoop           6975 ns

In the best case scenario where the first 3 elements are all even and our algorithm can complete quickly, our raw loop takes the lead, but it's close:

------------------------------
Benchmark                  CPU
------------------------------
BM_UsingViews          6.14 ns
BM_UsingIterators     12277 ns
BM_UsingLoop           4.39 ns

Our eager-evaluated UsingIterators algorithm is now wasting time squaring 10,000 even numbers even though the next step in the algorithm is going to discard all but three of them.

Summary

In this lesson, we saw how the pipe operator transforms C++ code from a tangled mess of nested calls into a clean, declarative pipeline.

  1. Readable Syntax: By using |, code reads left-to-right, matching the logical flow of data processing.
  2. The "Pull" Model: We visualized how the pipeline works backwards. The consumer pulls data from the end, triggering a chain reaction up the pipe to fetch the next valid item.
  3. Infinite Ranges: We demonstrated that because views are lazy, we can express concepts like "all numbers starting from 0" (std::views::iota) without running out of RAM.

In the next lesson, we will introduce std::ranges::subrange. We will learn how this universal adaptor allows us to wrap legacy raw pointers and iterator pairs into modern views, enabling us to use our flexible, view-based pipelines anywhere we want.

Next Lesson
Lesson 5 of 5

Subranges and Range Interoperability

Bridge the gap between iterators and views using std::ranges::subrange to turn any pair of iterators into a composable, safe C++20 View.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate