The Ranges Library (C++20)
Reviewed & published by Brayan K
For decades, STL algorithms meant typing v.begin(), v.end() on every call and nesting transformations inside-out. C++20 ranges change that. You pass a whole container directly, and you build readable, left-to-right pipelines with the pipe | operator. By the end of this lesson you'll sort with std::ranges::sort, compose lazy views like filter, transform, take, and iota, and understand why none of it allocates intermediate containers.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Call ranges algorithms like std::ranges::sort on whole containers
- Understand views: lazy, non-owning range adaptors
- Use std::views::filter, transform, take, and iota
- Compose adaptors with the pipe | operator
- See why lazy evaluation avoids intermediate containers
- Use projections to sort by a member without a comparator
💡 Real-World Analogy
Think of a ranges pipeline as a factory conveyor belt. Each view adaptor — filter, transform, take — is a station the items pass through, in order. Crucially, an item isn't processed until it actually reaches a station (lazy evaluation), and there are no warehouses between stations stockpiling half-finished goods (no intermediate containers). The pipe | is just the belt connecting the stations in reading order, so data | filter | transform | take describes the journey exactly as it happens.
1. Ranges Algorithms: No More begin()/end()
Classic STL algorithms take a pair of iterators. It works, but the boilerplate adds up and it's easy to mismatch begin/end from different containers. Here's the familiar std::sort:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
vector<int> v = {5, 2, 8, 1, 9, 3};
// The CLASSIC way: pass a pair of iterators (begin, end).
sort(v.begin(), v.end());
for (int x : v) cout << x << ' '; // 1 2 3 5 8 9
cout << endl;
return 0;
}
// ✅ Expected output:
// 1 2 3 5 8 9The std::ranges versions in <ranges> take the whole range directly. The call shrinks to std::ranges::sort(v) — clearer, and impossible to pass mismatched iterators.
#include <iostream>
#include <vector>
#include <ranges> // ranges algorithms & views
#include <algorithm>
using namespace std;
int main() {
vector<int> v = {5, 2, 8, 1, 9, 3};
// The RANGES way: pass the whole range -- no begin()/end().
ranges::sort(v);
for (int x : v) cout << x << ' '; // 1 2 3 5 8 9
cout << endl;
return 0;
}
// ✅ Expected output:
// 1 2 3 5 8 9Your turn. Replace the blank with a single ranges call that sorts the whole vector:
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm>
using namespace std;
int main() {
vector<int> v = {4, 1, 7, 3, 9, 2};
// 🎯 YOUR TURN — sort the whole vector with the RANGES algorithm.
// (No begin()/end() needed -- pass the container directly.)
___; // 👉 ranges::sort(v);
for (int x : v) cout << x << ' ';
cout << endl;
// ✅ Expected output: 1 2 3 4 7 9
return 0;
}2. Views: Lazy, Composable Pipelines
A view is a lightweight, non-owning range adaptor that evaluates lazily: it produces each element only when you ask for it. The standard adaptors live in std::views — filter keeps elements that match, transform maps each element, and take yields only the first n. You connect them with the pipe | operator, reading left to right.
#include <iostream>
#include <vector>
#include <ranges>
using namespace std;
int main() {
vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};
// Build a LAZY pipeline with the pipe operator |:
// keep evens -> square them -> take the first 3.
auto pipeline = v
| views::filter([](int n){ return n % 2 == 0; })
| views::transform([](int n){ return n * n; })
| views::take(3);
// Nothing was computed until THIS loop runs (lazy):
for (int x : pipeline) cout << x << ' '; // 4 16 36
cout << endl;
return 0;
}
// ✅ Expected output:
// 4 16 36Views can even generate data. std::views::iota produces a sequence of numbers on demand without any container behind it. Pair it with take to slice an otherwise unbounded sequence safely.
#include <iostream>
#include <ranges>
using namespace std;
int main() {
// views::iota generates numbers on demand -- no container needed.
// iota(1, 6) is half-open: it yields 1, 2, 3, 4, 5.
for (int n : views::iota(1, 6)) cout << n << ' '; // 1 2 3 4 5
cout << endl;
// Combine with take to slice an UNBOUNDED sequence safely:
auto firstFive = views::iota(10) | views::take(5); // 10,11,12,13,14
for (int n : firstFive) cout << n << ' ';
cout << endl;
return 0;
}
// ✅ Expected output:
// 1 2 3 4 5
// 10 11 12 13 14🧩 Reorder Challenge
These lines should build a pipeline that keeps evens, doubles them, and takes two. Put them in the right order:
A. auto result = v
B. | views::filter([](int n){ return n % 2 == 0; })
C. | views::transform([](int n){ return n * 2; })
D. | views::take(2);Correct order: A → B → C → D
Start from the source (A), filter to evens (B), transform by doubling (C), then take the first two (D). The pipe order matches the data flow.
3. Projections: Sort by a Member, No Comparator
Many ranges algorithms accept an optional projection: a function applied to each element before the algorithm inspects it. Want to sort people by age? Pass &Person::age as the projection — no hand-written comparator needed. The signature is ranges::sort(range, comparator, projection); pass {} for the default comparator.
#include <iostream>
#include <vector>
#include <string>
#include <ranges>
#include <algorithm>
using namespace std;
struct Person { string name; int age; };
int main() {
vector<Person> people = {
{"Mia", 30}, {"Leo", 22}, {"Ada", 27}
};
// PROJECTION: sort by the .age member, no comparator needed.
// Signature: ranges::sort(range, comparator, projection)
ranges::sort(people, {}, &Person::age);
for (auto& p : people)
cout << p.name << "(" << p.age << ") "; // Leo(22) Ada(27) Mia(30)
cout << endl;
return 0;
}
// ✅ Expected output:
// Leo(22) Ada(27) Mia(30)🔎 Deep Dive: why pipelines don't allocate
A naive approach to "filter then transform" builds a temporary vector after each step. Ranges avoid this because each view is lazy and non-owning: it stores only a reference to its source and the operation to apply. When you iterate the final view, it pulls one element through the entire chain before moving to the next — so no stage ever materialises a whole intermediate container.
// Conceptually, for each element the pipeline does:
// take? -> transform(filter-passes? element)
// one element at a time, on demand. No temp vectors.
auto p = v | views::filter(isEven)
| views::transform(square)
| views::take(3);
// Work happens HERE, lazily, as you iterate:
for (int x : p) use(x);That is also why views::take can sit on top of an unbounded views::iota — the pipeline simply stops pulling once it has enough.
Pro Tips
- 💡 Read pipelines left to right: the data flows in the same order you wrote the | stages.
- 💡 Put filter before transform when you can, so you transform fewer elements.
- 💡 Use take to bound infinite sources like views::iota(0) and stop early.
- 💡 Prefer projections over comparators for "sort by member X" — ranges::sort(v, {}, &T::field).
🧠 Quick Recall
1. What does views::iota(1, 5) produce?
1, 2, 3, 4 — it is half-open, so the upper bound 5 is excluded.
2. What does this print?
vector<int> v = {1,2,3,4,5,6};
auto r = v | views::filter([](int n){return n%2==0;})
| views::take(2);
for (int x : r) cout << x << ' ';2 4 — keep evens (2, 4, 6), then take the first two.
3. Which call needs no begin()/end()?
std::ranges::sort(v) — it takes the whole range directly.
Common Errors (and the fix)
- Forgetting #include <ranges>: views and range algorithms live there. Include it.
- Expecting a view to own its data: a view references its source. If the underlying container is destroyed, iterating the view is undefined behaviour.
- Confusing iota bounds: views::iota(1, 5) yields 1..4, not 1..5 — the upper bound is past-the-end.
- Iterating an unbounded view without take: views::iota(0) never ends; bound it with take(n).
- Compiling without C++20: ranges need -std=c++20. Older standards lack the library.
📋 Quick Reference
| Task | Code |
|---|---|
| Sort a whole range | std::ranges::sort(v); |
| Keep matching elements | v | std::views::filter(pred) |
| Map each element | v | std::views::transform(fn) |
| First n elements | v | std::views::take(n) |
| Generate numbers | std::views::iota(0, 10) |
| Sort by a member | ranges::sort(v, {}, &T::field) |
| Header | #include <ranges> |
Mini-Challenge: a lazy number pipeline
Build a single pipeline from views::iota that keeps odd numbers, cubes them, and takes the first three — all lazily, with no intermediate containers.
#include <iostream>
#include <vector>
#include <ranges>
using namespace std;
// 🎯 MINI-CHALLENGE: a lazy number pipeline
// 1. Start from views::iota(1, 11) (the numbers 1..10).
// 2. Pipe it through views::filter to keep ODD numbers.
// 3. Pipe that through views::transform to cube each (n * n * n).
// 4. Pipe that through views::take(3).
// 5. Print the results separated by spaces.
//
// ✅ Expected output:
// 1 27 125
int main() {
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Ranges algorithms like std::ranges::sort(v) take a whole range — no begin()/end()
- ✅ A view is a lazy, non-owning range adaptor
- ✅ filter, transform, take, and iota are the everyday adaptors
- ✅ The pipe | composes adaptors in left-to-right reading order
- ✅ Laziness means pipelines stream elements without intermediate containers
- ✅ Projections sort by a member without a hand-written comparator
Practice quiz
What header provides the C++20 ranges library?
- <ranges>
- <views>
- <algorithm>
- <iterator>
Answer: <ranges>. The ranges library, including views and range algorithms, lives in the <ranges> header.
How do classic algorithms like std::sort specify the data to operate on?
- With an index and a length
- With a container reference only
- With a pair of iterators (begin, end)
- With a single range object
Answer: With a pair of iterators (begin, end). Classic STL algorithms take an iterator pair; ranges algorithms can take a whole range directly.
What does std::ranges::sort(v) accept that std::sort does not?
- A lambda by value only
- A whole range (the container itself) instead of two iterators
- A comparison only
- A C-style array length
Answer: A whole range (the container itself) instead of two iterators. std::ranges::sort(v) takes the range directly, so you no longer write v.begin(), v.end().
What is a view in the ranges library?
- A thread that processes the range
- A deep copy of the container
- A sorted snapshot of the data
- A lazy, non-owning adaptor over a range that computes elements on demand
Answer: A lazy, non-owning adaptor over a range that computes elements on demand. A view is a lightweight, non-owning range adaptor; it does not copy data and evaluates lazily.
What does 'lazy evaluation' mean for views?
- Elements are computed only when you iterate, not up front
- The view runs on a background thread
- The container is sorted lazily
- Nothing is ever computed
Answer: Elements are computed only when you iterate, not up front. A view does no work until iterated; each element is produced on demand as you walk it.
Which operator composes range adaptors into a pipeline?
- The double colon ::
- The pipe operator |
- The plus operator +
- The arrow operator ->
Answer: The pipe operator |. Range adaptors compose left-to-right with the pipe operator, e.g. v | filter(...) | transform(...).
What does std::views::iota(1, 5) produce?
- The numbers 1, 2, 3, 4, 5
- A single value 5
- A random sequence
- The numbers 1, 2, 3, 4
Answer: The numbers 1, 2, 3, 4. iota(1, 5) is a half-open range: it yields 1, 2, 3, 4 (5 is the past-the-end bound).
What does std::views::take(3) do in a pipeline?
- Sorts and keeps the top 3
- Skips the first 3 elements
- Yields only the first 3 elements of the range
- Repeats each element 3 times
Answer: Yields only the first 3 elements of the range. take(n) is a view that yields at most the first n elements of the range it adapts.
What is a projection in a ranges algorithm?
- A parallel execution policy
- A function applied to each element before the algorithm inspects it
- A way to render the range to screen
- A copy of the range
Answer: A function applied to each element before the algorithm inspects it. A projection transforms each element for comparison purposes, e.g. sorting people by their .age member.
Why can views::filter and views::transform be chained efficiently?
- They are lazy and non-owning, so no intermediate containers are created
- They run on the GPU
- They sort the data first
- They build one intermediate vector per stage
Answer: They are lazy and non-owning, so no intermediate containers are created. Because each view is lazy and non-owning, a pipeline streams elements through without allocating temporaries.
Continue this course
- Previous: Concepts & Constrained Templates (C++20)
- Next: Coroutines (C++20) — co_await, co_yield and co_return for lazy generators and async code
- Quick reference: C++ cheat sheet
Frequently asked questions
What is the difference between a range and a view?
A range is anything you can iterate from begin to end, including ordinary containers like std::vector. A view is a special, lightweight range that does not own its elements and evaluates lazily. Views are cheap to copy and are meant to be composed into pipelines with the pipe operator.
How are ranges algorithms different from the classic STL ones?
Classic algorithms such as std::sort take a pair of iterators, so you write std::sort(v.begin(), v.end()). The ranges versions in std::ranges take the whole range directly: std::ranges::sort(v). They also support projections, letting you tell the algorithm which member to use without writing a custom comparator.
What does lazy evaluation buy me?
A view does no work until you iterate it, and it produces each element on demand. Chaining filter, transform, and take therefore creates no intermediate containers: elements stream through the whole pipeline one at a time. With take, you can even work with conceptually infinite sequences and stop early.
What is the pipe operator for?
The pipe operator composes range adaptors left to right, so v | std::views::filter(pred) | std::views::transform(fn) reads in the same order the data flows. It is syntactic sugar that makes a chain of view adaptors far easier to read than deeply nested function calls.
What is a projection?
Many ranges algorithms accept an optional projection: a function applied to each element before the algorithm looks at it. For example, std::ranges::sort(people, {}, &Person::age) sorts by the age member without you writing a comparator. The projection keeps the algorithm generic and the call site short.