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 |
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:
- The loop asks
take(3)for an item. take(3)askstransformfor an item.transformasksfilterfor an item.filterpulls frominputuntil it finds an even number.- 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 16Hardware 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 nsIn 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 nsOur 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.
- Readable Syntax: By using
|, code reads left-to-right, matching the logical flow of data processing. - 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.
- 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.
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.