C++20 introduced the std::ranges library, providing a modern, composable, and efficient way to process sequences. Central to this feature is the concept of views—lightweight, non-owning ranges that support lazy evaluation. Unlike algorithms that materialize intermediate results, views defer computation until elements are actually accessed, enabling high-level, declarative data processing pipelines with minimal overhead.
1. What Is Lazy Evaluation?
Lazy evaluation means deferred computation: operations are not executed when a view is constructed, but only when its elements are traversed (e.g., via a range-based for loop or explicit conversion to a container). This avoids unnecessary work, especially on large datasets or when only a subset of results is needed.
2. Core Characteristics of Lazy Views
2.1 Deferred Operation Trigger
Each view in the pipeline acts as a *behind-the-scenes generator*. Consider:
std::vector<int> data = {1,2,3,4,5};
auto pipeline = data
| std::views::filter([](int n){ return n % 2 == 0; })
| std::views::transform([](int n){ return n * 3; });
// No filtering or transformation occurs here
Only upon iteration—e.g., for(auto x : pipeline)—does the system pull values one-by-one through the pipeline: fetching an element, applying the predicate, optionally skipping, then applying the transformation, and finally yielding the result. This is on-demand, element-by-element processing.
2.2 Composable Without Extra Copies
Views form chains where each stage wraps the previous one. Internally, views hold iterators to the input range and implement custom begin()/end() that coordinate iteration. There are no intermediate containers; no temporaries allocate memory. For instance:
auto result = data
| std::views::take(7)
| std::views::filter(is_prime)
| std::views::reverse;
// Traversal now uses a nested iterator hierarchy:
// reverse_view → filter_view → take_view → vector
Each iterator type decouples the logic: reverse_view::iterator internally holds a filter_view::iterator, which holds a take_view::iterator, etc. No element is materialized until requested.
2.3 Resource Efficiency
Because views avoid copying data or precomputing results, they are ideal for I/O streams, mapped files, or huge containers where copying or full evaluation is prohibitively expensive. They effectively reduce both time complexity (fewer passes over the data) and space complexity (no per-stage buffers).
3. Safety Considerations
3.1 Lifetime Dependency
Views are *non-owning*: they store references (or iterator pairs) to the underlying range. Thus, their validity depends entirely on the lifetime of the source. Returning a view from a function that binds to a local temporary is unsafe:
auto bad_view() {
std::vector<int> local_vec = {1,2,3,4};
return local_vec | std::views::filter([](int x){ return x > 1; });
} // local_vec destroyed here
auto v = bad_view();
// using v is undefined behavior – dangling reference
Use a copy (e.g., std::ranges::to or explicit std::vector{...}) if ownership is needed:
std::vector<int> safe_copy = (local_vec | std::views::filter(...)).to_std();
3.2 Compiler Optimizations & costs
Although views avoid data movement, they still introduce iterator Abstraction Overhead. In tight inner loops with trivial operations, inlined lambdas and direct loops may outperform a view pipeline. Always profile when performance is critical.
4. Common View Adapters and Patterns
| Category | View | Description | Example |
|---|---|---|---|
| Transformation | std::views::enumerate | Yields (index, element) pairs | {10,20,30} | views::enumerate → (0,10), (1,20), (2,30) |
| Filtering | std::views::unique | Removes consecutive duplicates | {1,1,2,2,2,3} | views::unique → 1,2,3 |
| Alignment | std::views::zip | Merges ranges element-wise | zip(vec1, vec2) |
| Concatenation | std::views::join_with | Sequences flattened with delimiter | {{1,2},{3}} | views::join_with(99) → 1,2,99,3 |
5. Practical Example: Pipeline Construction
Given a list of exam scores, construct a new range containing:
- only scores ≥ 60,
- doubled to emphasize passing performance,
- only the first five results,
- in descending order.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> scores = {55, 70, 85, 40, 90, 65, 100, 61, 59, 99};
auto pipeline = scores
| std::views::filter([](int s){ return s >= 60; }) // keep pass
| std::views::transform([](int s){ return s * 2; }) // double
| std::views::take(5) // limit count
| std::views::reverse; // descending
for (int val : pipeline) {
std::cout << val << ' '; // 198 122 100 180 170
}
}
Internally, the iterator for reverse moves backward through the filtered/doubled elements, requesting them lazily via nested iterators. The original container is never modified.
6. Interoperability and Conversion
To exit the lazy world and obtain a concrete container, use std::ranges::to (C++23) or construct manually:
// C++23
auto passing_doubled = scores
| std::views::filter([](int s){ return s >= 60; })
| std::views::transform([](int s){ return s * 2; })
| std::ranges::to<std::vector>();
// C++20 compatible fallback
std::vector<int> result;
result.reserve(10); // optional heuristic
for (auto v : pipeline) result.push_back(v);