C++ Ranges, Views, and Span
- Description: A note on C++20 Ranges and views composition, range concepts, projections,
std::span, and the C++23 additions (ranges::to,chunk,zip,std::generator) - My Notion Note ID: K2A-B1-16
- Created: 2021-06-15
- Updated: 2026-02-28
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. What Is a Range?
- 2. Range Algorithms (
std::ranges::*) - 3. Views
- 4. Range and View Concepts
- 5.
std::span - 6. C++23 Additions
- 7. When to Use Ranges
1. What Is a Range?
A range is anything you can iterate over — informally, a pair of iterators (begin() and end()) or anything that yields one. The C++20 <ranges> library builds a vocabulary of range-aware algorithms and lazy adaptors on top of this idea.
#include <ranges>
#include <vector>
std::vector<int> v = {1, 2, 3};
// v is a range — std::ranges::sort(v) works directly without v.begin()/v.end()
Ranges replace most everyday uses of begin/end pairs in modern C++. They also enable lazy view pipelines (section 3) and unified algorithm signatures (section 2).
2. Range Algorithms (std::ranges::*)
Almost every algorithm in <algorithm> and <numeric> got a range-based overload in std::ranges.
2.1 Algorithm Overloads
#include <ranges>
#include <algorithm>
#include <vector>
std::vector<int> v = {3, 1, 4, 1, 5};
std::ranges::sort(v); // takes a whole range
auto it = std::ranges::find(v, 5); // search whole range
bool any_neg = std::ranges::any_of(v,
[](int x){ return x < 0; });
auto [mn, mx] = std::ranges::minmax(v); // pair, structured-binding-friendly
These call the same algorithms but accept a single range argument. They also enforce range concepts via constraints, so error messages are dramatically clearer than with iterator-pair algorithms.
2.2 Projections
Most range algorithms take an optional projection — a callable that extracts a key from each element before comparison. This replaces the common pattern of writing custom lambdas just to compare on a member.
struct Person {
std::string name;
int age;
};
std::vector<Person> people = { /* ... */ };
// Sort by age ascending — no comparator needed, just a projection
std::ranges::sort(people, {}, &Person::age);
// Find the oldest
auto oldest = std::ranges::max_element(people, {}, &Person::age);
// Custom comparator AND projection
std::ranges::sort(people, std::greater<>{}, &Person::age); // age descending
The signature is (range, comparator, projection). {} means default comparator (std::ranges::less).
A projection can be any callable: a member pointer (&Person::age), a member function pointer, a free function, or a lambda ([](const Person& p){ return p.name.size(); }).
3. Views
A view is a lightweight, non-owning, lazy range adaptor. Views don't allocate, don't store elements; they wrap an underlying range and produce values on demand as you iterate.
3.1 Common Views
| View | What it does |
|---|---|
views::filter(pred) |
Keep elements where pred(x) is true |
views::transform(fn) |
Apply fn to each element |
views::take(n) |
First n elements |
views::drop(n) |
Skip first n elements |
views::take_while(pred) |
Until pred is false |
views::drop_while(pred) |
Skip until pred is false |
views::reverse |
Iterate in reverse |
views::iota(n) |
Infinite sequence n, n+1, n+2, … |
views::iota(a, b) |
Bounded sequence a, a+1, ..., b-1 |
views::keys / views::values |
Project pair::first / pair::second (e.g. for maps) |
views::join |
Flatten a range of ranges |
views::split(delim) |
Split a range by a delimiter |
views::elements<N> |
Project the Nth element of each tuple |
views::common |
Force begin() and end() to have the same type (for legacy algorithms) |
views::all |
Wrap a range in a view (the conversion that | does implicitly) |
3.2 View Composition with |
Views compose with the pipe operator |, building a pipeline that's evaluated lazily on iteration.
#include <ranges>
#include <vector>
#include <iostream>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto pipeline = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(3);
for (int x : pipeline) std::cout << x << " ";
// Output: 4 16 36
The pipe is just sugar — v | views::filter(p) is equivalent to views::filter(v, p). Use whichever reads better.
3.3 Lazy Evaluation
Views don't compute anything until you iterate. The pipeline above doesn't allocate a new vector, doesn't filter then transform then take in sequence — it pulls one element at a time through the chain.
This means:
- Multiple views over the same data are cheap — no intermediate containers are built.
- Infinite ranges are usable —
views::iota(0) | views::take(5)is valid and returns{0, 1, 2, 3, 4}. - Side effects in
transform/filtercallables run during iteration, not during pipeline construction. - Each iteration re-runs the pipeline. Iterating a
transform_viewtwice calls the transform function twice per element. Materialize withranges::to<vector>(C++23) if you want to iterate repeatedly.
auto squares_under_100 = std::views::iota(1)
| std::views::transform([](int x) { return x * x; })
| std::views::take_while([](int x) { return x < 100; });
for (int x : squares_under_100) std::cout << x << " ";
// 1 4 9 16 25 36 49 64 81
4. Range and View Concepts
<ranges> defines a hierarchy of concepts that constrain ranges. They show up in error messages and when writing your own range adaptors.
std::ranges::range— hasbegin()andend().std::ranges::sized_range— knows its size in O(1).std::ranges::input_range/forward_range/bidirectional_range/random_access_range/contiguous_range— match the iterator categories.std::ranges::view— non-owning, cheap to copy. Implements theview_interface.std::ranges::common_range—begin()andend()have the same type (lets you use it with classic STL algorithms expecting iterator pairs).std::ranges::borrowed_range— safe to use the iterators after the range expression goes out of scope.std::ranges::viewable_range— can be turned into a view (viaviews::all).
If you constrain a function template with one of these, you get sharp errors when callers pass the wrong shape of range.
5. std::span
std::span<T> (C++20, <span>) is a non-owning view of a contiguous sequence — a T* plus a length. It's the idiomatic way to write a "view of an array" parameter without committing to a specific container type.
#include <span>
#include <vector>
#include <array>
#include <iostream>
void print(std::span<const int> s) {
for (int x : s) std::cout << x << " ";
}
std::vector<int> v = {1, 2, 3};
std::array<int, 3> a = {4, 5, 6};
int raw[] = {7, 8, 9};
print(v); // works
print(a); // works
print(raw); // works (decays via deduction guide)
print({v.data(), 2}); // explicit (T*, size_t): first 2 elements of v
A span has two flavors:
- Dynamic extent —
std::span<T>(orstd::span<T, std::dynamic_extent>). Length is a runtime value. - Static extent —
std::span<T, N>. Length is a compile-time constant; the span is one pointer wide instead of two.
std::span vs ranges views:
std::span<T>is contiguous and bounded — a fancy(T*, size_t).- Ranges views are lazy and may be non-contiguous (e.g., a
transform_view). - Use
std::spanfor "give me a contiguous slice"; use ranges views for "compose lazy operations."
A span is itself a range, so you can pipe it through views: print(v | std::views::take(3)).
6. C++23 Additions
C++23 expanded the ranges library considerably.
6.1 std::ranges::to
Finally easy to materialize a range into a container.
#include <ranges>
#include <vector>
auto v = std::views::iota(1, 6)
| std::views::transform([](int x) { return x * x; })
| std::ranges::to<std::vector>();
// v is std::vector<int>{1, 4, 9, 16, 25}
// You can also specify the container type explicitly
auto m = std::views::zip(keys, values)
| std::ranges::to<std::map<std::string, int>>();
Before C++23, you had to write std::vector<int>(rng.begin(), rng.end()) (and many views weren't common_ranges, so even that didn't work without views::common).
6.2 New Views
views::chunk(n)— split into chunks of sizen. Last chunk may be smaller.views::slide(n)— sliding window of sizenover the range.views::chunk_by(pred)— split wherepred(prev, curr)is false.views::stride(n)— everynth element.views::adjacent<N>— sliding window of fixed compile-time sizeN, returning tuples.views::zip(r1, r2, ...)— element-wise tuple of multiple ranges.views::zip_transform(fn, r1, r2, ...)— likezipbut appliesfnto each tuple.views::enumerate— pairs(index, element).views::cartesian_product(r1, r2, ...)— Cartesian product as a range of tuples.views::repeat(value)/views::repeat(value, n)— infinite or bounded repetition.
std::vector<int> a = {1, 2, 3};
std::vector<std::string> b = {"one", "two", "three"};
for (auto [n, s] : std::views::zip(a, b)) {
std::cout << n << "=" << s << " ";
}
// 1=one 2=two 3=three
6.3 std::generator (Coroutine-Based Range)
std::generator<T> is a coroutine return type — the easiest way to write your own range from scratch. Inside a generator function, you co_yield values one at a time.
#include <generator>
#include <ranges>
#include <iostream>
std::generator<int> fibs() {
int a = 0, b = 1;
while (true) {
co_yield a;
int next = a + b;
a = b;
b = next;
}
}
for (int x : fibs() | std::views::take(10)) std::cout << x << " ";
// 0 1 1 2 3 5 8 13 21 34
This is much simpler than writing a custom iterator class with begin(), end(), and an iterator type. It's also the natural representation for ranges that compute lazily but don't fit neatly into existing view adaptors.
6.4 Folds
std::ranges::fold_left / fold_right are reductions like std::accumulate, but with cleaner signatures: constrained via concepts, the accumulator type is deduced, and the _first / _last variants make the initial value optional and return std::optional.
#include <ranges>
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
int sum = std::ranges::fold_left(v, 0, std::plus{}); // 15
int product = std::ranges::fold_left(v, 1, std::multiplies{}); // 120
7. When to Use Ranges
- Default to ranges for new code in C++20+ projects — they're more readable and make intent explicit.
- Use views for transformation pipelines. Avoid materializing intermediate
std::vectors; pipe through views and only materialize at the end (withranges::toin C++23, or by passing the view straight to whatever consumes it). - Use
std::spanfor non-owning array parameters. Replaces(T*, size_t)andT[]parameters in API signatures. - Be careful about view lifetime. Views borrow the underlying range; they must not outlive it. Storing a view in a class member is usually a bug (use
borrowed_rangeconstraints to express this). - Falling back to iterators is fine when you need fine control, when working with C++17 code, or when the ranges library doesn't have the adaptor you need.
- Avoid
filter_viewif iterating multiple times. Filter has to re-scan from the beginning each time you callbegin(). For repeated iteration, materialize first.