A pipeline can describe “take in-stock products, then return their names” without building an intermediate vector after every step. C++20 views make that composition possible. The pipeline usually performs work as it is traversed, and a view over a live container can observe that container's values. It is not automatically a stored snapshot.
This guide requires C++20. You will compose filter and transform views, keep the owner alive and copy a result when later code needs stable owned data. The examples avoid C++23 ranges::to so their baseline remains clear. They also keep predicates free of external effects, because lazy traversal should not hide actions such as sending messages.
A range supplies traversal, not necessarily storage
A range supports obtaining a beginning and an ending boundary under its requirements. The end can be a sentinel rather than the same type as the iterator. Range-based algorithms can accept the range directly, reducing the chance of mixing boundaries from different containers.
A range need not be sized, randomly accessible or repeatable under every operation. Concepts describe additional capabilities. A filtered range generally cannot report its number of matches without traversal merely because the underlying vector has a cheap size. Use the weakest suitable requirement for a generic operation, and do not assume every pipeline supports indexing or sorting. The available operations follow the actual range capabilities.
Views compose behavior under explicit lifetime rules
A view is a range abstraction with specified structural requirements. Many familiar pipelines over lvalue containers borrow their storage, while some view types own values or generate them. Therefore “view” does not universally mean non-owning, but it also does not imply a deep copied snapshot.
For the common borrowing case, the source must remain alive and must not undergo invalidating mutations while the view is used. A vector reallocation can invalidate the underlying access. Lambdas stored in a view also have lifetimes: capturing a local threshold by reference creates a dependency on that local variable. Capturing the threshold by value keeps its configured value in the predicate. Decide whether live configuration or a fixed configuration is intended.
Lazy evaluation changes when work happens
Constructing a filter-transform pipeline describes operations. Traversal applies them to the elements. A source value changed before traversal can therefore affect the result, as the main example demonstrates. A transformed value may be computed when dereferenced rather than stored permanently.
Repeated traversal can repeat computation. Some views cache selected traversal information, so avoid relying on an exact number or order of predicate calls beyond the contract. Do not use a predicate to charge a payment or count an externally significant action. Keep predicates and transformations predictable, and materialize a bounded result when the requirement is to evaluate once and retain that result.
Mutation during filtered traversal needs care
Changing an element so it no longer satisfies a filter predicate through a filter iterator can violate the view's contract and lead to undefined behavior under relevant rules. Independently erasing or reallocating the source can also invalidate traversal. Keep mutation phases separate from observation pipelines unless the exact operation is designed and verified for it.
If the goal is to modify selected records, consider an ordinary controlled loop or collect stable identifiers before the mutation phase. Do not assume a filter view is an immutable selection list. Its lazy and cached behavior can differ from recomputing a fresh query after every mutation. Rebuild a view or produce a snapshot under a deliberate policy when source changes are part of the workflow.
Borrowed range does not mean immortal storage
Borrowed-range machinery helps algorithms avoid returning an ordinary usable iterator when an rvalue range's lifetime makes that result unsafe. A dangling marker can prevent a mistaken dereference at compile time. It does not make every stored view safe, and it does not extend the lifetime of an arbitrary owner.
A function returning a pipeline over its local vector creates an unsafe owner relationship unless it uses a suitable owning design. The details of viewable rvalue ranges have evolved through standards and defect reports, so prefer a simple explicit owner under a C++20 teaching baseline. An owning materialized result is often the clearest boundary when the caller must retain the data.
Test values, limits and ownership
Test no matches, all matches, one match and boundary stock values. Verify whether a report should be live or a snapshot, then test a source change before or after materialization accordingly. Test lambda capture semantics explicitly when configuration can change.
Bound the source and output size for untrusted data. Lazy evaluation can avoid intermediate allocations, but traversing an unbounded sequence or materializing every result can still consume unlimited work. Avoid claiming a view is always faster than a loop. Choose the readable representation and measure a realistic pipeline only when performance matters. The essential correctness argument is when work happens and which object owns every referenced value.
Official references
C++ Ranges, Views and Lazy Pipelines Examples
Each example shows the code, the output it produces and what each part does.
A live source and a captured threshold
The complete program captures the configured threshold by value. It changes one source element before the first traversal, then observes the lazy result.
The predicate retained threshold two, while the borrowed source now contains six. No reallocation or erasure occurs. Changing the threshold variable later does not change the value captured in this predicate.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> values{1, 2, 3, 4};
int threshold = 2;
auto selected = values
| std::views::filter([threshold](int value) {
return value >= threshold && value % 2 == 0;
})
| std::views::transform([](int value) {
return value * 10;
});
threshold = 100;
values[1] = 6;
for (int value : selected)
std::cout << value << '\n';
}60
40C++ Ranges, Views and Lazy Pipelines in Real Projects
Worked exercise: materialize an owned product report
The task is to select names of products with positive stock and retain an alphabetically sorted report after the product source is cleared. The pipeline returns name values, and ranges::copy writes them into an owning vector. Sorting occurs on the materialized report, not on the filtered source.
#include <algorithm>
#include <iostream>
#include <iterator>
#include <ranges>
#include <string>
#include <vector>
struct Product {
std::string name;
int stock;
};
int main() {
std::vector<Product> products{
{"Nut", 4}, {"Drill", 0}, {"Bolt", 2}
};
auto available_names = products
| std::views::filter([](const Product& product) {
return product.stock > 0;
})
| std::views::transform([](const Product& product) {
return product.name;
});
std::vector<std::string> report;
std::ranges::copy(available_names, std::back_inserter(report));
std::ranges::sort(report);
products.clear();
for (const std::string& name : report)
std::cout << name << '\n';
}Bolt
NutThe report owns its strings. The view still exists syntactically after clear, but the program never traverses it again. Returning references or string views into products would not provide the same retained-output contract.
C++ Ranges, Views and Lazy Pipelines FAQ
Is a view automatically a copy of the source?
No. A common pipeline over an lvalue vector borrows its storage and applies operations during traversal. Other view types can own values or generate them, so inspect the actual type's contract. If the caller needs a stable independent result, materialize into an owning collection. Copying the view object alone does not generally copy every source element.
Why capture a threshold by value?
It freezes that configured value inside the predicate and removes a reference-lifetime dependency. A reference capture can intentionally observe a live variable, but the variable must remain alive and its changes must fit the view's semantic requirements. The main example distinguishes a captured setting from borrowed source values. Choose the capture from the feature's promised behavior.
Can I sort a filter view directly?
Ordinary sorting needs appropriate writable random-access capabilities, which a filter view generally does not provide. Sort the underlying container under a valid mutation plan or materialize selected values into a sortable collection. The worked exercise sorts owned names after selection. Do not force the operation with casts or assume every range preserves all capabilities of its source.
Does lazy evaluation eliminate memory and work limits?
No. It can avoid intermediate storage and stop producing values when the consumer stops, but each step still has a cost. An unbounded traversal can run indefinitely, and materialization can allocate an excessive result. Define source, output and execution bounds. A pipeline's compact syntax should not hide expensive work or external effects in a predicate.
C++ Ranges, Views and Lazy Pipelines Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
A predicate captures threshold by value, then the original variable changes. What happens?
C++ Ranges, Views and Lazy Pipelines Quiz
2 questions to check what you learned.
When does a typical filter-transform view compute its selected values?
- As the view is traversed under its contract.
- Every value is copied during pipeline construction.
- After automatically storing every transformed value in an owning result container.
- Only during construction, even if source elements later change.
A report must survive destruction of its source products. Which result fits?
- A returned view into a local vector.
- An owning vector of copied or moved report values.
- A lambda retaining a reference to a destroyed threshold.
- An iterator into the cleared source.