C++ / Advanced / Lesson 14
Ranges, views, and borrowing
Compose C++20 range operations while keeping lazy evaluation, mutation, and the lifetime of underlying storage explicit.
On this page
Lesson goals
- Use ranges algorithms and lazy filter/transform views.
- Distinguish an owning container from a non-owning span or view.
- Avoid returning pipelines that borrow destroyed storage.
Operate on a range as a whole
A range provides a beginning and an ending sentinel. Ranges algorithms can accept the range directly and use concepts to state traversal and callable requirements. Not every sentinel has the same type as the iterator, and not every range is sized or multipass. A view is a lightweight range abstraction with specific semantic and complexity requirements; the word does not universally mean it owns no data. Some views own a range, while others borrow. Always inspect the particular adaptor and source rather than inferring lifetime from the presence of a pipe symbol.
A pipeline describes work before doing it
filter and transform views typically defer element processing until traversal. In the first example the lvalue vector outlives the entire pipeline, the predicate selects even numbers, and the projection squares each selected value. Constructing the pipeline does not create a vector of squares. Repeating traversal can repeat work, so keep predicates and projections free of hidden effects and do not rely on an exact callback count. All values are small, so squaring is safe. Materialize results deliberately if later consumers need an independent snapshot.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
const std::vector<int> values{1, 2, 3, 4};
auto squares = values
| std::views::filter([](int value) { return value % 2 == 0; })
| std::views::transform([](int value) { return value * value; });
int total{};
for (int value : squares) { std::cout << value << " "; total += value; }
std::cout << "total=" << total << "\n";
}
4 16 total=20
Two and four survive filtering and become four and sixteen. Their total is twenty, with no claim about predicate invocation counts.
A span borrows contiguous elements
std::span<T> is a non-owning view of contiguous elements with a count. Passing span<const int> lets a function read arrays or vectors without copying their elements or exposing a particular container. A span does not keep its source alive and does not prevent vector reallocation. Its operator[] still requires a valid index in C++20; carrying a size is not automatic checked access. The second program uses range iteration while the vector owner remains in scope and unchanged. A dynamic-extent span can represent an empty input naturally.
#include <iostream>
#include <span>
#include <vector>
int sum(std::span<const int> values) {
int total{};
for (int value : values) { total += value; }
return total;
}
int main() {
const std::vector<int> values{2, 3, 5};
const std::span<const int> borrowed{values};
std::cout << borrowed.size() << " " << sum(borrowed) << "\n";
}
3 10
The span sees three elements totaling ten. It never escapes the vector lifetime and does not acquire ownership.
Laziness makes lifetime dependencies persistent
Returning a filter view over a local vector leaves the returned pipeline borrowing destroyed storage. Return an owned vector, accept a caller-owned range with a documented lifetime, or design an owning range explicitly. Some range algorithms return std::ranges::dangling when called with unsuitable temporary ranges, which prevents certain accidental iterator escapes; it does not make every composed view safe. Do not structurally mutate a source during traversal. Filter views can cache traversal state, so changing predicate-relevant elements during reuse needs careful attention to the exact adaptor requirements.
Keep C++20 and later range features distinct
C++20 includes the ranges algorithms, filter and transform adaptors, and span used here. Facilities such as std::ranges::to belong to C++23, so do not silently use them in a C++20 solution. Collect a C++20 pipeline into an owning vector using a suitable loop or supported iterator-based construction after checking the iterator/sentinel requirements. Compiler language support and standard-library support remain separate checks. Prefer a clear explicit loop when a complex adaptor chain hides ownership, error handling, or the cost of repeated traversal. The abstraction should make the contract easier to inspect.
Common errors and fixes
A returned view reads dead storage.
- Why it happens
- The pipeline borrows a local container.
- How to fix it
- Return owned results or require a live caller-owned source.
- Prevent it next time
- Trace ownership through every adaptor boundary.
A C++20 build rejects ranges::to.
- Why it happens
- The code uses a C++23 facility.
- How to fix it
- Collect with a C++20 loop or change the documented baseline.
- Prevent it next time
- Check feature versions and library support tables.
A saved span breaks after appending.
- Why it happens
- The vector reallocated its borrowed elements.
- How to fix it
- Recreate the span after mutation and validate its extent.
- Prevent it next time
- Document structural operations that invalidate views.
Check your understanding
1. Change the pipeline predicate to select odd values. Predict all printed values and the final total, and state whether creating the pipeline performs the squares immediately.
Show worked solution
One and three survive, so the line is "1 9 total=10" followed by a newline. Constructing the pipeline describes lazy transformations; traversal obtains the transformed values. Keep the original vector alive and unchanged throughout. Do not use side effects to count exact predicate calls, because that is not the intended observable contract.
2. Design a function that filters a temporary vector and returns results safely in C++20. State its return type and ownership after return.
Show worked solution
Accept the vector by value, iterate its selected values into a local std::vector<int> result, and return result by value. The caller owns the returned elements independently after the input parameter is destroyed. A returned view borrowing the input parameter would be invalid. The extra materialization is explicit and should be measured only after correctness is established.
Key takeaways
- Lazy work still depends on live storage.
- Views and spans need explicit ownership reasoning.
- Use only features available in the declared standard.