Views, Lazy Evaluation & Composition
Master C++20 views — lightweight, lazy range adaptors that compose with the pipe operator. Learn filter, transform, take, drop, split, join, iota, and how to build powerful data pipelines without intermediate allocations.
What Are Views?
A view is a lightweight range that is:
1. Non-owning — it does not own the elements it references
2. O(1) copy and move — views are cheap to pass around
3. Lazily evaluated — elements are computed on demand during iteration, not upfront
Views are the building blocks of range pipelines. Instead of creating intermediate containers at each step, views compose into a single pass over the data:
```cpp
// Traditional: creates 2 intermediate vectors
auto evens = filter(data, is_even); // allocation
auto squared = transform(evens, sq); // allocation
// Views: zero intermediate allocations
auto result = data | views::filter(is_even) | views::transform(sq);
// Nothing computed yet — result is just a view descriptor
for (int x : result) { ... } // computed element-by-element
```
Views live in std::views:: (a namespace alias for std::ranges::views::).
filter and transform — The Fundamental Views
views::filter keeps elements matching a predicate. views::transform applies a function to each element. Together, they replace the majority of explicit loops.
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Filter even numbers, then square them
auto even_squares = numbers
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
// Nothing has been computed yet — even_squares is a view
for (int x : even_squares) {
std::cout << x << ' '; // 4 16 36 64 100
}
std::cout << '\n';
// Views work with any range — including strings
std::string text = "Hello, World!";
auto uppercase = text
| std::views::filter([](char c) { return std::isalpha(c); })
| std::views::transform([](char c) { return std::toupper(c); });
for (char c : uppercase) {
std::cout << c; // HELLOWORLD
}
std::cout << '\n';
// Collect view results into a vector (C++23: std::ranges::to)
std::vector<int> collected;
for (int x : even_squares) {
collected.push_back(x);
}
// C++23: auto collected = even_squares | std::ranges::to<std::vector>();
}take, drop, and Slicing Views
views::take(n) yields the first n elements. views::drop(n) skips the first n elements. Together they enable efficient slicing without copying.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// take — first N elements
for (int x : data | std::views::take(3)) {
std::cout << x << ' '; // 1 2 3
}
std::cout << '\n';
// drop — skip first N elements
for (int x : data | std::views::drop(7)) {
std::cout << x << ' '; // 8 9 10
}
std::cout << '\n';
// Combine: elements 3-7 (0-indexed: drop 3, take 4)
for (int x : data | std::views::drop(3) | std::views::take(4)) {
std::cout << x << ' '; // 4 5 6 7
}
std::cout << '\n';
// take_while / drop_while — predicate-based
std::vector<int> sorted_data = {1, 3, 5, 7, 2, 4, 6};
auto ascending_prefix = sorted_data | std::views::take_while(
[prev = 0](int x) mutable {
bool ok = x > prev;
prev = x;
return ok;
});
for (int x : ascending_prefix) {
std::cout << x << ' '; // 1 3 5 7
}
std::cout << '\n';
// Pagination example: page 2, page_size = 3
int page = 2, page_size = 3;
auto page_view = data
| std::views::drop((page - 1) * page_size)
| std::views::take(page_size);
for (int x : page_view) {
std::cout << x << ' '; // 4 5 6
}
std::cout << '\n';
}split, join, and String Processing Views
views::split divides a range by a delimiter. views::join flattens nested ranges. These are powerful for string and data processing.
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
#include <iostream>
int main() {
// split — divide a string by delimiter
std::string csv = "Alice,30,Engineer,95000";
for (auto field : csv | std::views::split(',')) {
// Each 'field' is a subrange — convert to string_view
std::string_view sv(field.begin(), field.end());
std::cout << '[' << sv << "] ";
}
std::cout << '\n'; // [Alice] [30] [Engineer] [95000]
// join — flatten nested ranges
std::vector<std::vector<int>> matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
for (int x : matrix | std::views::join) {
std::cout << x << ' '; // 1 2 3 4 5 6 7 8 9
}
std::cout << '\n';
// Combine: split lines, then process each line
std::string multiline = "hello world\nfoo bar\nbaz qux";
int line_num = 1;
for (auto line : multiline | std::views::split('\n')) {
std::string_view line_sv(line.begin(), line.end());
std::cout << line_num++ << ": " << line_sv << '\n';
}
// Enumerate (C++23)
// for (auto [i, val] : data | std::views::enumerate) { ... }
}views::iota — Generating Ranges
views::iota generates a sequence of incrementing values. It can be bounded (iota(0, 10) for 0..9) or unbounded (iota(0) for 0, 1, 2, ...). Combined with take, it replaces many counting loops.
#include <ranges>
#include <iostream>
#include <vector>
#include <numeric>
int main() {
// Bounded iota — replaces for(int i = 0; i < 10; ++i)
for (int x : std::views::iota(0, 10)) {
std::cout << x << ' '; // 0 1 2 3 4 5 6 7 8 9
}
std::cout << '\n';
// Unbounded iota + take — first 5 squares
auto first_5_squares = std::views::iota(1)
| std::views::transform([](int x) { return x * x; })
| std::views::take(5);
for (int x : first_5_squares) {
std::cout << x << ' '; // 1 4 9 16 25
}
std::cout << '\n';
// Generate Fibonacci-like sequence with views
// (iota as index generator)
auto indices = std::views::iota(0, 20);
auto even_indices = indices | std::views::filter([](int i) { return i % 2 == 0; });
for (int i : even_indices | std::views::take(5)) {
std::cout << i << ' '; // 0 2 4 6 8
}
std::cout << '\n';
// Use iota to create a vector of sequential values
auto range_vec = std::views::iota(1, 11); // 1 to 10
int sum = 0;
for (int x : range_vec) sum += x;
std::cout << "Sum 1..10: " << sum << '\n'; // 55
}Building Complete Data Pipelines
The true power of views emerges when you compose multiple adaptors into a single pipeline. The entire pipeline evaluates lazily in a single pass.
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
#include <algorithm>
struct LogEntry {
std::string level; // "INFO", "WARN", "ERROR"
std::string message;
int timestamp;
};
int main() {
std::vector<LogEntry> logs = {
{"INFO", "Server started", 1000},
{"ERROR", "Connection refused", 1001},
{"INFO", "Request received", 1002},
{"WARN", "Slow query detected", 1003},
{"ERROR", "Timeout expired", 1004},
{"INFO", "Request completed", 1005},
{"ERROR", "Disk full", 1006},
{"WARN", "Memory usage high", 1007},
};
// Pipeline: get recent error messages, formatted
auto recent_errors = logs
| std::views::filter([](const LogEntry& e) {
return e.level == "ERROR";
})
| std::views::drop(1) // skip first error
| std::views::transform([](const LogEntry& e) {
return "[" + std::to_string(e.timestamp) + "] " + e.message;
})
| std::views::take(5); // at most 5 results
std::cout << "Recent errors (after first):\n";
for (const auto& msg : recent_errors) {
std::cout << " " << msg << '\n';
}
// [1004] Timeout expired
// [1006] Disk full
// Reverse view — iterate backward without copying
auto reversed = logs | std::views::reverse | std::views::take(3);
std::cout << "\nLast 3 entries (reversed):\n";
for (const auto& e : reversed) {
std::cout << " [" << e.level << "] " << e.message << '\n';
}
}Views are non-owning — they reference the underlying range. If the source range is destroyed, the view becomes dangling:
``cpp``
auto make_view() {
std::vector
return local | std::views::filter([](int x) { return x > 1; });
// BUG: local is destroyed, view dangles!
}
Also beware of views over temporaries created mid-pipeline:
``cpp``
// WRONG: the string is a temporary that gets destroyed
auto words = std::string("hello world")
| std::views::split(' '); // dangling!
Always ensure the source data outlives all views that reference it. Store the data in a variable with sufficient lifetime before creating views over it.
Views are lazy — no work happens until iteration. This means:
1. Building a pipeline is essentially free (just stores function pointers and parameters)
2. You can create views over very large or even infinite ranges
3. Each element flows through the entire pipeline before the next element is processed
4. Short-circuiting views like take(n) stop the entire pipeline after n elements
To materialize view results into a container, iterate and collect:
```cpp
std::vector
for (int x : some_view) results.push_back(x);
// C++23 adds std::ranges::to for cleaner materialization:
auto results = some_view | std::ranges::to
```
Prefer keeping data as views as long as possible — only materialize when you need random access, persistence, or to pass to APIs that require containers.
- Views are non-owning, O(1) copyable, lazily evaluated range adaptors
views::filterandviews::transformare the fundamental building blocks for data pipelinesviews::take(n)andviews::drop(n)provide slicing without copyingviews::iotagenerates sequences — bounded or unbounded — replacing counting loops- The pipe operator
|composes views into readable left-to-right pipelines with zero intermediate allocations - Views must not outlive their source data — always ensure the underlying range has sufficient lifetime
Quiz — Test Your Knowledge
(20 XP)1. When does a C++20 view compute its elements?
2. What is the result of `std::views::iota(1) | std::views::take(3)`?
3. Why is it dangerous to create a view over a local variable and return it from a function?