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).
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