Ranges Library
The C++20 ranges library provides composable, lazy sequences with concept-constrained algorithms and view adaptors via the pipe operator.
Ranges Librarysince C++20The <ranges> library introduces a unified model of element sequences built on concepts, providing lazy view adaptors that compose via operator| and constrained algorithm overloads in the std::ranges namespace.
Overview
Pre-C++20 STL algorithms operate on iterator pairs, which is expressive but verbose and error-prone β passing mismatched begin/end pairs from different containers is undefined behaviour with no diagnostic. The ranges library addresses this by treating a range as the primary abstraction: any type that exposes ranges::begin() and ranges::end().
Three things come with the library:
- Concepts β a hierarchy of named constraints (
range,sized_range,input_range,forward_range,bidirectional_range,random_access_range,contiguous_range) that replace informal iterator category conventions with enforced requirements. - Constrained algorithms β every
<algorithm>has astd::ranges::counterpart that accepts a range directly and supports projections. - Views β lightweight, lazily-evaluated adaptors in
std::views(alias forstd::ranges::views) that can be chained viaoperator|.
Views are the most distinctive feature. A view does not own elements; it represents a transformed window over some underlying range. Composition is declarative and evaluates only when iterated, so chaining ten adaptors doesn't produce ten temporary containers.
Syntax
#include <ranges> // views, range concepts, range utilities
#include <algorithm> // std::ranges:: algorithm overloads
namespace rv = std::views; // std::views is an alias for std::ranges::views (C++20)Core concept definitions (simplified from the standard):
// C++20 β from <ranges>
template<class T>
concept range = requires(T& t) {
std::ranges::begin(t);
std::ranges::end(t);
};
template<class T>
concept sized_range = std::ranges::range<T> && requires(T& t) {
std::ranges::size(t);
};
template<class T>
concept random_access_range =
std::ranges::bidirectional_range<T> &&
std::random_access_iterator<std::ranges::iterator_t<T>>;Examples
Range algorithms with projections
std::ranges:: algorithms accept a range directly and an optional projection β a callable applied to each element before comparison. This eliminates the need for custom comparators that unwrap wrapper types:
#include <algorithm>
#include <ranges>
#include <string>
#include <vector>
struct Employee {
std::string name;
int salary;
};
int main() {
std::vector<Employee> staff = {
{"Alice", 95000}, {"Bob", 72000}, {"Carol", 110000}
};
// C++20: sort by salary via projection, no lambda comparator needed
std::ranges::sort(staff, std::less{}, &Employee::salary);
// C++20: find by name projection
auto it = std::ranges::find(staff, "Bob", &Employee::name);
}Composing views with the pipe operator
Views are lazily evaluated and compose left-to-right via operator|. No intermediate containers are created:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// C++20: filter even numbers, square them, take the first 3
auto pipeline = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(3);
for (int v : pipeline)
std::cout << v << ' '; // 4 16 36
}Generating sequences with iota
std::views::iota produces an infinite or bounded sequence without storing it:
#include <ranges>
#include <vector>
// C++20: generate [0, 100) stride 5, collect to vector
// C++23: std::views::stride and std::ranges::to<>
auto stepped = std::views::iota(0, 100)
| std::views::stride(5) // C++23
| std::ranges::to<std::vector>(); // C++23
// stepped == {0, 5, 10, ..., 95}Flattening nested ranges
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<std::string> words = {"hello", " ", "world"};
// C++20: join_view flattens a range-of-ranges
for (char c : words | std::views::join)
std::cout << c; // hello world
}C++23 sliding-window adaptors
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector vec = {1, 2, 3, 4, 5};
// C++23: adjacent<N> produces tuples of N consecutive elements
for (auto [a, b] : vec | std::views::adjacent<2>)
std::cout << '(' << a << ',' << b << ") "; // (1,2) (2,3) (3,4) (4,5)
std::cout << '\n';
// C++23: adjacent_transform applies a function across each window
for (auto s : vec | std::views::adjacent_transform<2>(std::plus{}))
std::cout << s << ' '; // 3 5 7 9
}Constraining a template on a range concept
#include <ranges>
// C++20: accepts only random-access ranges β enforced at compile time
template<std::ranges::random_access_range R, typename Comp = std::less<>>
void quicksort(R&& r, Comp cmp = {}) {
if (std::ranges::size(r) < 2) return;
// ...
}Best Practices
Prefer std::ranges:: algorithms over raw iterator algorithms. The constrained overloads catch type mismatches at compile time and accept projections without helper lambdas. The interface is strictly better where available.
Use namespace rv = std::views; in implementation files. The full std::ranges::views:: prefix is verbose. In headers, qualify fully to avoid polluting client namespaces.
Treat views as cheap to copy, not to hold long-term without the underlying range. A view is a non-owning reference. Storing a filter_view after the source vector is destroyed yields dangling iterators.
Use std::ranges::to<std::vector>() (C++23) to materialise a pipeline. Before C++23, you need to pipe into std::back_inserter explicitly. In C++23, | std::ranges::to<std::vector>() is both cleaner and potentially more efficient.
Prefer std::views::iota over hand-rolled index loops when you only need a numeric sequence. It composes naturally with the rest of the pipeline, whereas a raw loop requires breaking out of the declarative chain.
Common Pitfalls
Dangling views over temporaries. auto v = std::string{"hello"} | std::views::reverse; binds a view to a destroyed temporary. Clang and GCC warn in some cases, but not all. Always ensure the source range outlives its views.
filter_view invalidates size information. Unlike transform_view, a filtered range cannot report its size cheaply. Code that calls std::ranges::size() on a filter_view will fail to compile β filter_view is not a sized_range. Cache the size explicitly if you need it.
Mutable elements through transform_view. transform_view returns rvalues from its iterator dereference. You cannot write *it = 42 through a transform view. Use std::ranges::for_each or a for loop over the source range if you need mutation.
O(n) begin() on filter_view and drop_while_view. These views must scan forward to find the first matching element every time begin() is called on a non-cached instance. Iterating the same filtered view twice in a loop body incurs the scan cost twice. Assign the view to a local variable so begin() is computed once.
Not all range algorithms are constrained the same way. std::ranges::sort requires random_access_range; std::ranges::stable_sort additionally requires sized_range. Passing a filter_view to sort is a hard compile error, which is intentional β it prevents accidental O(nΒ²) iterator advancement β but the error message can be dense.
std::views::stride and std::ranges::to are C++23. Projects targeting C++20 must implement stride manually (e.g., filter on index modulo) and materialise pipelines via std::vector<T>(begin, end).
Range Concepts Hierarchy (C++20)
The concepts in <ranges> form a strict refinement hierarchy. Each level adds a capability on top of the previous one. Knowing which concept a range satisfies tells you which algorithms can accept it and which view adaptors can compose with it.
Traversal concepts (ordered weakest to strongest)
| Concept | Iterator requirement | Key capability |
|---|---|---|
range | any iterator + sentinel | basic iteration via begin/end |
input_range | input_iterator | single-pass read |
forward_range | forward_iterator | multi-pass read, stable references |
bidirectional_range | bidirectional_iterator | iteration in both directions |
random_access_range | random_access_iterator | O(1) index access, sortable |
contiguous_range | contiguous_iterator | elements sequential in memory, pointer arithmetic valid |
Every stronger concept subsumes the weaker ones. A std::vector satisfies all six simultaneously. A std::list satisfies up to bidirectional_range but not random_access_range. A std::forward_list goes up to forward_range. An istream_view satisfies only input_range.
Supporting concepts
sized_range β models range and additionally exposes ranges::size(t) in O(1) amortised time. std::vector is a sized_range; filter_view is not, because the filtered count is unknown without traversal.
view β a range with O(1) move construction, move assignment, and destruction (and copy, if present). Views are cheap to chain because the compiler can copy them into pipeline types at zero runtime cost. All std::views:: adaptors produce types that model view.
viewable_range β a range that can be safely wrapped by views::all(). Concretely: any lvalue range, any borrowed_range, or any type that already models view. The pipe operator uses this to decide whether to wrap the left-hand side.
borrowed_range β a range whose iterators remain valid after the range object is destroyed. Lvalue references to containers are borrowed_range by definition. string_view and span are also borrowed_range. An rvalue std::vector is not β its iterators dangle as soon as the temporary dies.
common_range β a range where begin() and end() return the same type. Required by legacy algorithms that accept (first, last) iterator pairs. Use views::common to adapt non-common ranges when needed.
#include <ranges>
#include <vector>
#include <list>
#include <forward_list>
// C++20: concept checks at compile time
static_assert(std::ranges::contiguous_range<std::vector<int>>);
static_assert(std::ranges::bidirectional_range<std::list<int>>);
static_assert(std::ranges::forward_range<std::forward_list<int>>);
// sized_range vs not-sized_range
static_assert(std::ranges::sized_range<std::vector<int>>);
// filter_view is NOT a sized_range β uncomment to see compile error:
// static_assert(std::ranges::sized_range<
// decltype(std::vector<int>{} | std::views::filter([](int){ return true; }))>);
// viewable_range: lvalue containers are safe to pipe
std::vector<int> v = {1, 2, 3};
auto view1 = v | std::views::filter([](int n) { return n > 1; }); // OK β lvalue
// borrowed_range: iterators survive beyond the range object
static_assert(std::ranges::borrowed_range<std::string_view>);The practical impact: algorithm constraints are enforced through these concepts. std::ranges::sort requires random_access_range + sized_range. std::ranges::stable_sort adds the same. std::ranges::reverse requires bidirectional_range. Passing the wrong category is a compile error, not undefined behaviour.
C++23 New Views
C++23 expanded the std::views catalogue with eleven new adaptors. They are available in <ranges> alongside the C++20 adaptors.
std::views::zip and std::views::zip_transform
zip combines two or more ranges and yields tuples of references to corresponding elements. Iteration stops at the shortest input range. zip_transform additionally applies a callable to each tuple before yielding the result.
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<int> ids = {1, 2, 3, 4};
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
// C++23: zip stops at the shorter range (3 elements here)
for (const auto& [id, name] : std::views::zip(ids, names))
std::cout << id << ": " << name << '\n';
// 1: Alice
// 2: Bob
// 3: Carol
// zip_transform: apply a function element-wise across ranges
std::vector<int> prices = {100, 200, 150};
std::vector<int> costs = {10, 20, 50};
auto profit = std::views::zip_transform(
[](int p, int c) { return p - c; },
prices, costs
);
for (int p : profit)
std::cout << p << ' '; // 90 180 100
}std::views::enumerate
enumerate pairs each element with its zero-based index, yielding (index, value) tuples. It replaces the common pattern of maintaining a manual index variable or piping through iota and zip.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<std::string> words = {"zero", "one", "two", "three"};
// C++23: enumerate gives (index, value) pairs
for (const auto& [i, word] : std::views::enumerate(words))
std::cout << i << ": " << word << '\n';
// 0: zero
// 1: one
// 2: two
// 3: three
}std::views::chunk and std::views::chunk_by
chunk(n) divides a range into non-overlapping, consecutive subranges of exactly n elements (the last chunk may be smaller). chunk_by(pred) splits on adjacent element pairs for which the binary predicate returns false β grouping runs where the predicate holds.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7};
// C++23: chunk(n) β non-overlapping groups of size n
for (const auto& chunk : v | std::views::chunk(3)) {
for (int x : chunk) std::cout << x << ' ';
std::cout << "| ";
}
// 1 2 3 | 4 5 6 | 7 |
std::cout << '\n';
// C++23: chunk_by β group while predicate holds between adjacent elements
std::vector<int> u = {1, 2, 2, 3, 0, 4, 5, 2};
for (const auto& group : u | std::views::chunk_by(std::ranges::less_equal{})) {
for (int x : group) std::cout << x << ' ';
std::cout << "| ";
}
// 1 2 2 3 | 0 4 5 | 2 |
}std::views::slide
slide(n) produces overlapping windows of width n over the input range. Each window is itself a view over n consecutive elements. This is the classic sliding-window pattern without manual pointer arithmetic.
#include <ranges>
#include <vector>
#include <numeric>
#include <iostream>
int main() {
std::vector<double> temps = {18.0, 21.0, 19.5, 23.0, 20.0, 22.5};
// C++23: slide(3) β 3-day moving average
for (const auto& window : temps | std::views::slide(3)) {
double avg = std::accumulate(window.begin(), window.end(), 0.0) / 3.0;
std::cout << avg << ' ';
}
// 19.5 21.1667 20.8333 21.8333
}The key difference from adjacent<N>: slide takes the window size as a runtime value; adjacent<N> takes it as a compile-time template argument (yielding typed tuples rather than sub-views).
std::views::cartesian_product
cartesian_product takes one or more ranges and yields all ordered tuples from their cross product. The total number of elements is the product of the individual sizes. The first range argument varies slowest (outermost loop).
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> xs = {1, 2, 3};
std::vector<char> ys = {'A', 'B'};
// C++23: cartesian_product β all (x, y) pairs, 3 * 2 = 6 elements
for (const auto& [x, y] : std::views::cartesian_product(xs, ys))
std::cout << '(' << x << ',' << y << ") ";
// (1,A) (1,B) (2,A) (2,B) (3,A) (3,B)
}std::views::repeat
repeat(value) or repeat(value, n) produces an infinite (or length-n) sequence of the same value. The bounded form requires | std::views::take(n) or use of the two-argument overload. Useful for generating fill patterns without allocating storage.
#include <ranges>
#include <iostream>
int main() {
// C++23: bounded repeat β 5 copies of 42
for (int x : std::views::repeat(42, 5))
std::cout << x << ' '; // 42 42 42 42 42
std::cout << '\n';
// unbounded repeat composed with take
for (int x : std::views::repeat(0) | std::views::take(3))
std::cout << x << ' '; // 0 0 0
}std::ranges::to β Materialising Views (C++23)
Lazy pipelines are efficient to build, but sometimes you need a concrete container: to pass to a legacy API, to cache results, or to sort (since sort requires random access). Before C++23, materialisation required explicit boilerplate:
// C++20: verbose materialisation into vector
auto pipeline = data | std::views::filter(pred) | std::views::transform(fn);
std::vector<int> result(pipeline.begin(), pipeline.end()); // ok but wordyC++23 adds std::ranges::to<Container>() as a pipe adaptor (or free-function call) that collects any range into a concrete container:
#include <ranges>
#include <vector>
#include <map>
#include <string>
#include <set>
int main() {
std::vector<int> source = {5, 1, 4, 1, 5, 9, 2, 6};
// C++23: collect filtered view into vector
auto evens = source
| std::views::filter([](int n) { return n % 2 == 0; })
| std::ranges::to<std::vector>();
// evens == {4, 2, 6}
// collect into set β deduplicates and sorts automatically
auto unique_sorted = source | std::ranges::to<std::set>();
// unique_sorted == {1, 2, 4, 5, 6, 9}
// build a map from a range of pairs
std::vector<std::string> words = {"hello", "world", "ranges"};
auto word_lengths = words
| std::views::transform([](const std::string& w) {
return std::pair{ w, w.size() };
})
| std::ranges::to<std::map<std::string, std::size_t>>();
// word_lengths == {"hello"->5, "ranges"->6, "world"->5}
// stride + to: collect every third element
auto stepped = std::views::iota(0, 20)
| std::views::stride(3)
| std::ranges::to<std::vector>();
// stepped == {0, 3, 6, 9, 12, 15, 18}
}std::ranges::to can also be called as a function rather than piped: std::ranges::to<std::vector>(pipeline). It supports any container whose value type is constructible from the range's element type, including std::string, std::deque, std::set, std::map, and std::unordered_* variants. The implementation is allowed to use reserve() when the source is a sized_range, avoiding reallocations.
Before C++23, the standard workaround was:
// C++20 workaround β requires knowing the element type explicitly
auto pipeline = data | std::views::transform(fn);
std::vector<ResultType> v;
std::ranges::copy(pipeline, std::back_inserter(v));std::ranges::to removes the need to name the element type and integrates naturally into the pipe chain.
See Also
std::viewsβ the full catalogue of view adaptors available in C++20 and C++23- Range concepts (
range,sized_range,view,input_range, etc.) β the concept hierarchy that underpins the design std::ranges::algorithm overloads β constrained counterparts to every<algorithm>function