C++20 Ranges Library
Before C++20, STL algorithms required explicit iterator pairs — verbose, error-prone (mismatched begin/end), and impossible to compose without intermediate containers. Ranges (C++20) fix all three problems: you pass the container directly, compose operations with the pipe (|) operator, and views evaluate lazily — no intermediate allocations.
Use ranges when chaining filters, transforms, or slicing on sequences.
Prerequisites: See 04_stl_containers/ and 05_algorithms/ first.
Lazy Evaluation in Ranges Deep Dive
A view pipeline like filter | transform | take does NOT create
three separate loops or three intermediate containers. Instead,
each view wraps the previous one, forming a chain of lightweight
adaptor objects.
When you iterate the final view (e.g., in a range-for loop),
requesting the next element triggers a pull-based chain:
take asks transform for an element
-> transform asks filter for an element
-> filter asks the source container, skipping elements
that don't match the predicate
<- filter returns the first matching element to transform
<- transform applies its function and returns the result
<- take yields the result to the caller
This is "pull-based" iteration: the consumer (take) pulls one
element at a time through the entire pipeline. No element is
processed until it is actually requested.
Because of this design, take(3) on an INFINITE source (like
views::iota(1)) works perfectly — it only ever pulls 3 elements,
never touching the rest. The infinite source never fully
materializes. 1. Ranges vs traditional iterator pairs
Traditional: algorithm(begin, end, predicate) Ranges: algorithm(container, predicate) Eliminates mismatched iterator bugs and is shorter to write. not std::. The two namespaces have different overloads.
Watch out: range-based algorithms are in std::ranges::,
Views vs Containers Deep Dive
A container OWNS its elements. std::vector allocates memory and stores the actual data. Copying a container copies every element. A view DESCRIBES a transformation — it stores only a reference (or iterator pair) to the source range plus the operation (predicate, function, count, etc.). It does not allocate memory for results. Views are cheap to copy — O(1) — because they are just a pointer plus some lightweight state (a lambda, an integer). Multiple views can refer to the same underlying container. Watch out: modifying the container (inserting, erasing) while a view refers to it invalidates the view, just like it would invalidate iterators. Formally, views satisfy the std::ranges::view concept: they are semiregular (movable, default-constructible in most cases) and have O(1) move and destruction.
2. View composition with the pipe operator (|)
Views are lightweight, non-owning wrappers that describe a transformation. They do NOT store results — each element is computed on demand during iteration. If the source is destroyed, the view dangles.
Watch out: a view holds a reference to its source range.
3. Lazy evaluation — views compute on demand
A pipeline like filter | transform | take does not loop through the data three times. Each element flows through the entire pipeline before the next element is requested.
4. Common view adaptors
take(n) — first n elements drop(n) — skip first n elements reverse — iterate in reverse keys/values — project pairs (e.g., from a map) Forward-only ranges (like views::iota) cannot be reversed.
Watch out: views::reverse requires a bidirectional range.
5. views::iota — generate a sequence of values
iota(start) produces an infinite sequence: start, start+1, ... iota(start, end) produces [start, end). Useful for index generation and replacing manual loops.
6. Range algorithms — pass the container, not iterators
std::ranges::sort, std::ranges::find, std::ranges::count_if, etc. These accept a range directly and support projections (member pointers or callables that extract a sort key).
7. Projections — sort/compare by a computed key
A projection is an extra argument that transforms each element before the comparator sees it. Avoids writing custom comparators.
Projections Deep Dive
A projection is simply a callable that the algorithm applies
to each element BEFORE passing it to the comparator or
predicate. Internally, the algorithm does:
compare(projection(a), projection(b))
instead of:
compare(a, b)
Example:
std::ranges::sort(students, std::ranges::greater{}, &Student::grade);
is equivalent to sorting with:
compare(a.grade, b.grade) using greater-than
Member pointers like &Student::grade are valid callables
because std::invoke (which range algorithms use internally)
knows how to call a member pointer on an object: it evaluates
std::invoke(&Student::grade, s) as s.grade.
You can use any callable as a projection:
- member pointers: &Student::grade
- lambdas: [](const Student& s) { return s.grade; }
- function pointers: a free function that takes Student
The default projection is std::identity{}, which returns the
element unchanged. Key Takeaways
- •Views are lazy and non-owning — they compose without allocating.
- •Use | to chain view adaptors into readable pipelines.
- •Prefer std::ranges:: algorithms over std:: — they accept whole containers.
- •Use projections to sort or compare by a specific field/key.
- •Never let a view outlive the range it refers to.
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// ---- 1. Traditional vs Ranges ----
std::cout << "--- Traditional vs Ranges ---\n";
// Traditional: iterator pairs, manual loop
std::cout << "Traditional even squares: ";
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
if (*it % 2 == 0) {
std::cout << (*it * *it) << ' ';
}
}
std::cout << '\n';
// Ranges: composable and declarative
std::cout << "Ranges even squares: ";
auto even_squares = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int n : even_squares) {
std::cout << n << ' ';
}
std::cout << '\n';
// ---- 3. Lazy evaluation demo ----
std::cout << "\n--- Lazy Evaluation ---\n";
auto pipeline = numbers
| std::views::filter([](int n) {
std::cout << std::format(" filtering {}\n", n);
return n > 5;
})
| std::views::transform([](int n) {
std::cout << std::format(" transforming {}\n", n);
return n * 2;
})
| std::views::take(3);
// Nothing has been computed yet — the pipeline is just a description.
// Computation happens element-by-element only when we iterate:
std::cout << "Taking first 3 numbers > 5, doubled:\n";
for (int n : pipeline) {
std::cout << std::format(" got: {}\n", n);
}
// ---- 4. Common view adaptors ----
std::cout << "\n--- Common Views ---\n";
std::cout << "take(5): ";
for (int n : numbers | std::views::take(5)) {
std::cout << n << ' ';
}
std::cout << "\ndrop(7): ";
for (int n : numbers | std::views::drop(7)) {
std::cout << n << ' ';
}
std::cout << "\nreverse: ";
for (int n : numbers | std::views::reverse) {
std::cout << n << ' ';
}
std::cout << '\n';
// ---- 5. views::iota ----
std::cout << "\n--- views::iota ---\n";
std::cout << "iota(1, 6): ";
for (int n : std::views::iota(1, 6)) {
std::cout << n << ' ';
}
std::cout << '\n';
// Combine iota with other views — no container needed at all
std::cout << "First 5 squares: ";
for (int n : std::views::iota(1)
| std::views::transform([](int n) { return n * n; })
| std::views::take(5)) {
std::cout << n << ' ';
}
std::cout << '\n';
// ---- 6. Range algorithms ----
std::cout << "\n--- Range Algorithms ---\n";
std::vector<int> data = {5, 1, 4, 2, 3};
// std::ranges::sort — pass the container directly
std::ranges::sort(data);
std::cout << "Sorted: ";
for (int n : data) std::cout << n << ' ';
std::cout << '\n';
// std::ranges::find
auto it = std::ranges::find(data, 3);
if (it != data.end()) {
std::cout << std::format("Found 3 at index {}\n",
std::distance(data.begin(), it));
}
// ---- 7. Projections ----
std::cout << "\n--- Projections ---\n";
struct Student {
std::string name;
int grade;
};
std::vector<Student> students = {
{"Alice", 92}, {"Bob", 85}, {"Charlie", 98}, {"Diana", 88}
};
// Sort by grade descending using a projection — no custom comparator
std::ranges::sort(students, std::ranges::greater{}, &Student::grade);
std::cout << "Students by grade (descending):\n";
for (const auto& s : students) {
std::cout << std::format(" {} - {}\n", s.name, s.grade);
}
// Find student by name using a projection
auto found = std::ranges::find(students, "Bob", &Student::name);
if (found != students.end()) {
std::cout << std::format("Found {}: grade {}\n",
found->name, found->grade);
}
return 0;
}