Ranges (C++20)
"C++20's ranges library: constrained algorithms, lazy view pipelines, and composable adaptors that replace raw iterator-pair APIs."
Rangessince C++20The ranges library unifies any type exposing begin()/end() under a concept hierarchy, adds constrained algorithm overloads in std::ranges::, and introduces views β lazily-evaluated, composable range adaptors combined with the | pipe operator.
Overview
The range concept hierarchy
<ranges> defines a refinement chain of concepts that describe what operations a range supports:
| Concept | Adds over previous |
|---|---|
std::ranges::range<R> | begin() + end() (minimum) |
std::ranges::input_range<R> | single-pass readable elements |
std::ranges::forward_range<R> | multi-pass, copyable iterators |
std::ranges::bidirectional_range<R> | -- on iterators |
std::ranges::random_access_range<R> | +, -, [] on iterators |
std::ranges::contiguous_range<R> | elements contiguous in memory |
std::ranges::sized_range<R> | std::ranges::size(r) is O(1) |
std::ranges::borrowed_range<R> | iterators remain valid after range is destroyed |
Algorithm overloads are constrained by these concepts. std::ranges::sort requires random_access_range + sortable; std::ranges::find needs only input_range. Violations are caught at compile time with concept-backed diagnostics.
Sentinels
Ranges generalise the begin/end pair: the sentinel (end marker) need not share the iterator's type. This enables zero-overhead termination:
#include <ranges>
#include <cstring>
// Iterate exactly N elements from a raw pointer β no strlen
const char* p = "hello world";
for (char c : std::views::counted(p, 5)) { /* 'h','e','l','l','o' */ } // C++20
// Unbounded range with unreachable_sentinel β compiler assumes end is never hit
for (int n : std::ranges::subrange(p, std::unreachable_sentinel)) { // C++20
if (*p == '\0') break;
}std::unreachable_sentinel (C++20) lets the optimiser elide the end-check entirely, producing the same code as a hand-written pointer loop.
Syntax
Range algorithm overloads
Every <algorithm> function has a constrained counterpart in std::ranges::. Key differences:
#include <algorithm>
#include <ranges>
#include <vector>
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// C++20: take a range directly; no begin()/end() ceremony
std::ranges::sort(v);
std::ranges::sort(v, std::greater{});
// C++20: projections β callable applied to each element before comparison
std::ranges::sort(v, {}, [](int n){ return -n; }); // sort by negation
// C++20: richer return types
auto [lo, hi] = std::ranges::minmax_element(v); // pair of iterators
// C++20: remove returns a subrange of the tail, not a single iterator
auto tail = std::ranges::remove(v, 1);
v.erase(tail.begin(), tail.end()); // erase-remove still neededView pipeline syntax
namespace views = std::views; // std::ranges::views β C++20 alias
auto result = source_range
| views::filter(pred) // std::ranges::filter_view
| views::transform(func) // std::ranges::transform_view
| views::take(n); // std::ranges::take_viewEach | constructs a new view type wrapping the previous one. No iteration occurs until begin() is called on the outermost view. The entire chain is evaluated in a single pass over the source.
Examples
Realistic pipeline: structured filtering
#include <ranges>
#include <vector>
#include <print>
struct LogEntry {
int level; // 0=debug β¦ 4=critical
long long timestamp;
std::string message;
};
std::vector<LogEntry> logs = get_logs();
long long now = current_epoch_seconds();
// C++20: errors in last hour, most recent first, capped at 20 β zero allocations
auto recent_errors = logs
| std::views::filter([](const LogEntry& e){ return e.level >= 3; })
| std::views::filter([now](const LogEntry& e){ return now - e.timestamp < 3600; })
| std::views::reverse
| std::views::take(20);
for (const auto& e : recent_errors) {
std::println("[{}] {}", e.timestamp, e.message); // C++23 println
}Materialising a view into a container
std::vector<int> v = {1, 2, 3, 4, 5, 6};
// C++23: std::ranges::to β clearest and handles nested ranges correctly
auto evens = v
| std::views::filter([](int n){ return n % 2 == 0; })
| std::ranges::to<std::vector>(); // C++23
// C++20: explicit copy via back_inserter
std::vector<int> evens20;
std::ranges::copy(
v | std::views::filter([](int n){ return n % 2 == 0; }),
std::back_inserter(evens20)
);
// C++20: range constructor
auto r = v | std::views::filter([](int n){ return n % 2 == 0; });
std::vector<int> evens20b(r.begin(), r.end());Projections eliminate custom comparators
struct Employee {
std::string name;
int department;
double salary;
};
std::vector<Employee> staff = load_employees();
// C++20: sort by salary descending β no lambda comparator needed
std::ranges::sort(staff, std::greater{}, &Employee::salary);
// C++20: find first employee in department 3
auto it = std::ranges::find(staff, 3, &Employee::department);
// C++20: stable partition by department, projecting to int field
auto [dept3, rest] = std::ranges::partition(staff,
[](int d){ return d == 3; }, &Employee::department);Projections bind to any callable or member pointer and are applied before the comparator, eliminating the most common reason to write a custom comparator.
Infinite sequences and generators
// C++20: infinite square sequence β terminated by take
auto squares = std::views::iota(1) // 1, 2, 3, ...
| std::views::transform([](int n){ return n * n; }); // 1, 4, 9, ...
for (int s : squares | std::views::take(5)) {
std::print("{} ", s); // 1 4 9 16 25
}
// C++20: every third index, squared β single-pass, no temporaries
for (int v : std::views::iota(0, 30)
| std::views::filter([](int n){ return n % 3 == 0; })
| std::views::transform([](int n){ return n * n; })) {
std::print("{} ", v); // 0 9 36 81 144 225 324 441 576 625
}C++23 multi-range adaptors
#include <ranges>
std::vector<int> ids = {1, 2, 3};
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
// views::zip (C++23) β iterate multiple ranges in lockstep
for (auto [id, name] : std::views::zip(ids, names)) {
std::println("{}: {}", id, name);
}
// views::enumerate (C++23) β index + element
for (auto [i, name] : std::views::enumerate(names)) {
std::println("[{}] {}", i, name);
}
// views::chunk (C++23) β non-overlapping fixed-size windows
for (auto chunk : ids | std::views::chunk(2)) { /* {1,2}, {3} */ }
// views::slide (C++23) β overlapping windows
for (auto w : std::views::iota(1, 6) | std::views::slide(3)) {
// {1,2,3}, {2,3,4}, {3,4,5}
}Best Practices
Prefer std::ranges:: algorithms unconditionally. They require no begin()/end() boilerplate, accept projections, return richer types (subrange over raw iterator), and diagnose misuse at compile time through concepts.
Alias the namespace at function scope. namespace views = std::views; removes most of the verbosity. namespace rv = std::ranges::views; is an alternative favoured in some codebases.
Use std::ranges::to<std::vector>() (C++23) to materialise. It expresses intent and handles nested range types (e.g. split) correctly through the range constructor pathway.
Never store a view as a class member unless the underlying data outlives it. Views are non-owning. If the source container is moved or reallocated, any stored view produces undefined behaviour on iteration.
Compute distance before iterating, not during. std::ranges::distance on a filter_view is O(n). If you need both the count and the elements, materialise the range once.
Common Pitfalls
Dangling views from temporaries
// UNDEFINED BEHAVIOUR β underlying vector is destroyed before the view is iterated
auto bad() {
std::vector<int> v = {1, 2, 3, 4};
return v | std::views::filter([](int n){ return n > 1; });
} // v destroyed here; caller iterates dangling memoryThe type system catches the return-of-algorithm-result case: if you call std::ranges::find with a temporary (non-borrowed_range), the return type is std::ranges::dangling β a non-dereferenceable placeholder that prevents the iterator from being used.
auto it = std::ranges::find(std::vector{1, 2, 3}, 2);
// decltype(it) is std::ranges::dangling β dereferencing is a compile errorfilter_view caches begin() β beware mutation
filter_view (and drop_while_view) cache their begin() iterator for amortised O(1) access on bidirectional ranges. This has two consequences:
std::vector<int> v = {1, 2, 3, 4, 5};
auto evens = v | std::views::filter([](int n){ return n % 2 == 0; });
auto it = evens.begin(); // begin() computed and cached
v.insert(v.begin(), 0); // reallocates v β cached iterator is now invalid; UB on next useAdditionally, filter_view is not const-iterable β you cannot call begin() on a const filter_view. This makes it incompatible with functions that take const auto&.
Piping through filter drops random-access
std::vector<int> v = {3, 1, 4, 1, 5};
auto r = v | std::views::filter([](int n){ return n > 2; });
std::ranges::sort(r); // compile error: filter_view is bidirectional, not random_accessMaterialise into a vector before sorting filtered results.
Legacy std:: algorithms require a common range
Algorithms in std:: (not std::ranges::) require iterator and sentinel to share the same type. Most bounded views satisfy this, but views::iota(0) (unbounded) does not. Wrap with views::common when you must use a legacy algorithm β or better, switch to the std::ranges:: overload:
auto r = std::views::iota(0, 10) | std::views::filter([](int n){ return n % 3 != 0; });
// Legacy β may fail if sentinel type differs from iterator type
// std::copy(r.begin(), r.end(), out);
// Fix option A: views::common adapter (C++20)
std::copy((r | std::views::common).begin(), (r | std::views::common).end(), out);
// Fix option B: use the constrained algorithm (preferred)
std::ranges::copy(r, out);O(n) distance masquerading as O(1)
auto r = large_vec | std::views::filter(expensive_pred);
if (std::ranges::distance(r) > 100) { // iterates the ENTIRE range β O(n)
process(r); // then iterates AGAIN
}Restructure to std::ranges::count_if (single pass) or materialise once:
auto results = large_vec | std::views::filter(expensive_pred)
| std::ranges::to<std::vector>(); // C++23, single pass
if (results.size() > 100) { process(results); }Range Adaptor Chaining
Pipelines are just nested view constructors. Each | operator creates a new view type wrapping the previous one; the full expression is resolved entirely at compile time with zero virtual dispatch.
// C++20
#include <ranges>
#include <vector>
#include <string>
struct Product {
std::string name;
int category;
double price;
};
std::vector<Product> catalogue = load_catalogue();
// Chain: keep category 2, extract name, drop first 5, show next 10
auto page = catalogue
| std::views::filter([](const Product& p){ return p.category == 2; })
| std::views::transform(&Product::name) // member pointer projection
| std::views::drop(5)
| std::views::take(10);
for (const std::string& name : page) {
std::println("{}", name);
}Long pipelines compose left-to-right and are evaluated in a single pass over the source data. The compiler typically collapses the nested iterator calls into a tight loop with no heap allocation.
// C++20 β chaining transform into transform; both lambdas fused into one pass
auto result = std::views::iota(1, 101)
| std::views::filter([](int n){ return n % 2 != 0; }) // odd numbers
| std::views::transform([](int n){ return n * n; }) // square them
| std::views::transform([](int n){ return n % 100; }) // mod 100
| std::views::take(8);
// Iterate once; all four adaptors execute per element
for (int v : result) { std::print("{} ", v); }
// 1 9 25 49 69 89 9 29Adaptor objects (e.g. std::views::filter(pred)) are partial applications; storing them in variables lets you build pipelines programmatically before attaching a source.
// C++20 β reusable adaptor partial application
auto positive_squares = std::views::filter([](int n){ return n > 0; })
| std::views::transform([](int n){ return n * n; });
std::vector<int> a = {-3, 1, -1, 4, 2};
std::vector<int> b = {5, -2, 3};
for (int v : a | positive_squares) { std::print("{} ", v); } // 1 16 4
for (int v : b | positive_squares) { std::print("{} ", v); } // 25 9Owning Views vs. Borrowed Ranges
A view is a non-owning handle; the underlying data must outlive it. The std::ranges::borrowed_range concept tags types whose iterators remain valid even after the range object itself is destroyed β raw arrays, std::string_view, and std::span satisfy it; std::vector and std::string do not.
// C++20
#include <ranges>
#include <vector>
#include <span>
std::vector<int> v = {1, 2, 3, 4, 5};
// views::all wraps an lvalue range in a ref_view β zero-overhead, non-owning
auto ref = std::views::all(v); // type: std::ranges::ref_view<std::vector<int>>
static_assert(std::ranges::borrowed_range<decltype(ref)>);
// views::all on an rvalue moves the range into an owning_view (C++23)
auto owned = std::views::all(std::vector{1, 2, 3}); // owning_view β safe to storeThe standard library uses borrowed_range to produce std::ranges::dangling for unsafe iterator returns, preventing use-after-free at compile time.
// C++20 β dangling iterator detection
auto it1 = std::ranges::find(v, 3);
// OK β v is an lvalue; decltype(it1) is std::vector<int>::iterator
auto it2 = std::ranges::find(std::vector{1, 2, 3}, 2);
// COMPILE ERROR β rvalue vector is not borrowed_range
// decltype(it2) is std::ranges::dangling; operator* is deletedWhen building functions that return views, always accept the source by lvalue reference or accept a borrowed_range β never bind a view to a local container that will be destroyed on return.
// C++20 β safe: returned view borrows from caller-owned data
auto evens_of(std::span<const int> data) {
return data | std::views::filter([](int n){ return n % 2 == 0; });
}
// span satisfies borrowed_range; the filter_view's iterators remain validstd::ranges::to<> β Materialising Lazy Views (C++23)
std::ranges::to<C>() converts any range into a concrete container in a single readable expression. It handles nested range types (such as the subranges produced by views::split) correctly through the range constructor pathway, something a raw std::vector(r.begin(), r.end()) cannot do without extra work.
// C++23
#include <ranges>
#include <vector>
#include <set>
#include <string>
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5};
// Deduplicated sorted set β one expression
auto unique_sorted = v
| std::views::transform([](int n){ return n; })
| std::ranges::to<std::set<int>>(); // C++23
// Deduplication via sorted vector + unique_copy in one pipeline
auto deduped = std::views::iota(0, 10)
| std::views::filter([](int n){ return n % 3 != 0; })
| std::ranges::to<std::vector>(); // C++23 β deduces value_type
// Split a string into a vector of substrings
std::string csv = "foo,bar,baz";
auto parts = csv
| std::views::split(',')
| std::views::transform([](auto r){
return std::string(r.begin(), r.end());
})
| std::ranges::to<std::vector<std::string>>(); // C++23std::ranges::to also works as a free function: std::ranges::to<std::vector<int>>(view). Both forms reserve capacity when the source is a sized_range, avoiding redundant reallocations.
// C++23 β pipeline form vs. free-function form are equivalent
auto a = v | std::ranges::to<std::vector<int>>();
auto b = std::ranges::to<std::vector<int>>(v);For C++20 targets without std::ranges::to, the idiomatic alternative remains std::vector<T>(r.begin(), r.end()) or std::ranges::copy into a back_inserter.
Custom Range Adaptors with view_interface
std::ranges::view_interface<D> is the CRTP base that turns any type with begin()/end() into a first-class view. It synthesises empty(), size(), front(), back(), operator[], and data() from the iterator pair, so a custom view inheriting it participates fully in the range adaptor ecosystem.
// C++20 β minimal custom view: every N-th element of another range
#include <ranges>
#include <iterator>
template<std::ranges::input_range R>
class stride_view : public std::ranges::view_interface<stride_view<R>> {
public:
stride_view() = default;
stride_view(R base, std::ptrdiff_t step)
: base_(std::move(base)), step_(step) {}
auto begin() {
return iterator{std::ranges::begin(base_),
std::ranges::end(base_), step_};
}
auto end() { return std::default_sentinel; }
private:
R base_{};
std::ptrdiff_t step_{1};
struct iterator {
using value_type = std::ranges::range_value_t<R>;
using difference_type = std::ptrdiff_t;
using iterator_category = std::input_iterator_tag;
std::ranges::iterator_t<R> cur, end_it;
std::ptrdiff_t step;
value_type operator*() const { return *cur; }
iterator& operator++() {
for (std::ptrdiff_t i = 0; i < step && cur != end_it; ++i) ++cur;
return *this;
}
iterator operator++(int) { auto tmp = *this; ++*this; return tmp; }
bool operator==(std::default_sentinel_t) const { return cur == end_it; }
};
R base_store_{}; // owned copy when R is a view
};Inheriting view_interface is sufficient for most adaptor authorship. The standard library itself uses the same pattern β std::ranges::filter_view, std::ranges::transform_view, and friends all derive from it.
// C++20 β using the custom view directly and as a pipe source
std::vector<int> v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
stride_view sv{v, 3};
for (int x : sv) { std::print("{} ", x); } // 0 3 6 9
// Works with standard algorithm overloads because it models input_range
auto n = std::ranges::count_if(sv, [](int x){ return x > 4; }); // 2For production use, C++23 adds std::views::stride to the standard library, making custom stride views unnecessary β but the technique generalises to any domain-specific adaptor.
Range Algorithms with Projections
Projections are a second callable argument accepted by all std::ranges:: algorithms that replaces the need for custom comparators in the common case. A projection maps each element to the key the algorithm should operate on; the comparator then receives the projected keys, not the original elements.
// C++20
#include <ranges>
#include <algorithm>
#include <vector>
#include <string>
struct Student {
std::string name;
int grade;
double gpa;
};
std::vector<Student> roster = load_roster();
// Sort by GPA ascending β member pointer projection, no lambda comparator
std::ranges::sort(roster, {}, &Student::gpa);
// Sort by GPA descending β comparator + projection compose cleanly
std::ranges::sort(roster, std::greater{}, &Student::gpa);
// Sort by name length, then alphabetically β two-level sort using projection
std::ranges::sort(roster, {}, [](const Student& s){ return s.name.size(); });Projections compose with every constrained algorithm, not just sort. std::ranges::find, std::ranges::min_element, std::ranges::partition, std::ranges::unique, and std::ranges::lower_bound all accept the same projection argument.
// C++20 β projections across a variety of algorithms
// Find the student with the highest GPA
auto top = std::ranges::max_element(roster, {}, &Student::gpa);
// Partition: honour roll (gpa >= 3.5) first
auto [honour, rest] = std::ranges::partition(roster,
[](double g){ return g >= 3.5; }, &Student::gpa);
// Binary search on a pre-sorted range β project to the sort key
std::ranges::sort(roster, {}, &Student::name);
bool found = std::ranges::binary_search(roster, "Alice", {}, &Student::name);
// Count students in grade 11
auto n11 = std::ranges::count(roster, 11, &Student::grade);A projection can be any callable: a free function, a lambda, a std::function, or (most efficiently for struct fields) a member pointer. Member pointer projections have zero overhead β they compile to a single field access with no indirection.
// C++20 β projections in two-range algorithms
std::vector<Student> transfer_students = load_transfers();
// Check if every transfer student name already appears in roster
bool all_present = std::ranges::includes(
roster, transfer_students, {}, &Student::name, &Student::name
);
// Each algorithm that accepts two ranges accepts two independent projections,
// one per range β the comparator sees (proj1(a), proj2(b)).See Also
- Concepts β the constraint system underlying range requirements
- Coroutines β C++20 lazy generators; coroutines and ranges compose naturally
- Structured Bindings β used with
views::zipandviews::enumerate - Lambda Expressions β the primary way to pass predicates and projections