How Pipe Syntax Works in C++ Ranges
The C++20 ranges library introduces the pipe syntax (|)
to enable clean, declarative, and readable sequence processing. This
article explains how the pipe operator operates under the hood to
compose operations, examining the role of Range Adaptor Closure Objects
(RACOs), operator overloading, mathematical functional composition, and
lazy evaluation.
The Motivation: Eliminating Nested Calls
Before C++20, applying multiple algorithms sequentially required either creating intermediate containers or nesting function calls deeply. Nested functional calls evaluate from the inside out, making code difficult to read and maintain:
auto result = std::views::transform(std::views::filter(numbers, is_even), square);The pipe syntax inverts this structure into a left-to-right data pipeline that mirrors the logical flow of data:
auto result = numbers | std::views::filter(is_even)
| std::views::transform(square);Range Adaptor Closure Objects (RACO)
The foundational mechanism behind range composition is the Range Adaptor Closure Object. In the C++ ranges library, range adaptors can be invoked in two ways:
- Direct call: Passing both the range and arguments
simultaneously, such as
std::views::filter(range, predicate). - Partial call: Passing only the transformation
arguments without the range, such as
std::views::filter(predicate).
When a range adaptor is called without a range argument, it does not immediately execute any logic. Instead, it returns a RACO. This closure object stores the arguments (e.g., the predicate function) and waits to be paired with an input range.
Overloading the Bitwise OR
Operator (|)
The pipe syntax works by overloading operator| for range
adaptors. The standard library defines two primary overloads to handle
composition:
1. Range to Adaptor:
Range | AdaptorClosure
When an input range is piped into an adaptor closure,
operator| acts as an invoker. It passes the range on the
left-hand side into the closure on the right-hand side:
template <std::ranges::viewable_range R, typename AdaptorClosure>
auto operator|(R&& range, const AdaptorClosure& adaptor) {
return adaptor(std::forward<R>(range));
}This transforms numbers | std::views::filter(is_even)
directly into std::views::filter(numbers, is_even).
2. Adaptor
to Adaptor: AdaptorClosure | AdaptorClosure
The pipe operator is also overloaded to combine two distinct closures without needing a range present:
template <typename ClosureA, typename ClosureB>
auto operator|(const ClosureA& a, const ClosureB& b) {
return [=]<typename R>(R&& range) {
return b(a(std::forward<R>(range)));
};
}This allows the creation of reusable pipelines:
auto process_evens = std::views::filter(is_even) | std::views::transform(square);
auto result = numbers | process_evens;Lazy Evaluation Model
Range composition via pipe syntax does not execute computations immediately, nor does it allocate memory for intermediate containers.
When a range is piped through adaptors, the result is a chain of
lightweight wrapper views (such as std::ranges::filter_view
wrapping std::ranges::ref_view). Each wrapper implements
its own begin() and end() member functions.
Actual filtering, transforming, and calculation occur lazily on demand
when the resulting view is traversed by an iterator.