Compiler Requirements: GCC 12+ or Clang 14+ recommended. GCC 10–11 has partial support (missing some views/algorithms). GCC 9.4 does NOT support ranges at all.
The classic STL algorithm interface has a fundamental usability problem:
// Classic: pass TWO iterators — easy to mismatch
std::sort(v.begin(), v.end()); // OK
std::sort(v.begin(), w.end()); // COMPILES! UB at runtime.
std::sort(v.begin(), v.begin() + 100); // Out of bounds? No compile error.
// Ranges: pass the RANGE itself
std::ranges::sort(v); // Can't mismatch. Period.
Iterator pairs are verbose, error-prone, and don’t compose. Ranges fix all three.
A range is anything with begin() and end(). The <ranges> header defines a hierarchy of concepts:
| Concept | Meaning | Example |
|---|---|---|
std::ranges::range |
Has begin() + end() |
Any STL container |
std::ranges::input_range |
Single-pass readable | istream_view |
std::ranges::forward_range |
Multi-pass readable | forward_list |
std::ranges::bidirectional_range |
Can go backward | list, set |
std::ranges::random_access_range |
O(1) index | vector, deque |
std::ranges::contiguous_range |
Elements contiguous in memory | vector, array, span |
std::ranges::sized_range |
O(1) size() |
vector, array |
std::ranges::view |
Cheap to copy/move, O(1) | All view adaptors |
std::ranges::borrowed_range |
Safe to return iterators from temporaries | span, string_view |
Each concept refines the previous — contiguous_range ⊂ random_access_range ⊂ bidirectional_range ⊂ forward_range ⊂ input_range ⊂ range.
| Property | Container (vector) |
View (filter_view) |
|---|---|---|
| Owns elements | Yes | No (references source) |
| Construction | O(N) — allocates + copies | O(1) — stores reference + predicate |
| Evaluation | Eager | Lazy — computed on iteration |
| Copyable | Yes (deep copy) | Yes (shallow, O(1)) |
| Modifiable | Yes | Usually read-only |
Key insight: Views don’t DO anything when you create them. They describe a computation. Work only happens when you iterate.
// This does NOTHING — no filtering happens yet
auto expensive = data | views::filter(costly_predicate);
// Work happens HERE, one element at a time
for (auto& x : expensive) { ... }
|)The pipe operator composes views left-to-right, like Unix pipes:
// Read as: "take data, filter evens, square them, take first 5"
auto result = data
| views::filter([](int x) { return x % 2 == 0; })
| views::transform([](int x) { return x * x; })
| views::take(5);
Without pipes (equivalent but harder to read):
auto result = views::take(
views::transform(
views::filter(data, pred),
square),
5);
Pipes compose — you can store partial pipelines:
auto clean_and_scale = views::filter(is_valid) | views::transform(scale);
auto result = sensor_data | clean_and_scale | views::take(100);
| Adaptor | What It Does |
|---|---|
views::filter(pred) |
Keep elements where pred is true |
views::take(N) |
First N elements |
views::drop(N) |
Skip first N elements |
views::take_while(pred) |
Take while predicate holds |
views::drop_while(pred) |
Drop while predicate holds |
| Adaptor | What It Does |
|---|---|
views::transform(fn) |
Apply fn to each element |
views::reverse |
Reverse order (needs bidirectional) |
views::elements<I> |
Extract Ith element from tuple-like |
views::keys |
Equivalent to elements<0> |
views::values |
Equivalent to elements<1> |
| Adaptor | What It Does |
|---|---|
views::split(delim) |
Split into subranges |
views::join |
Flatten nested ranges |
views::join_with(delim) |
Flatten with delimiter (C++23) |
| Adaptor | What It Does |
|---|---|
views::all |
Wrap any range into a view |
views::common |
Force matching begin/end types |
views::counted(it, n) |
View of N elements from iterator |
// Infinite sequence: 0, 1, 2, 3, ...
auto naturals = views::iota(0);
// Bounded: 1, 2, ..., 100
auto one_to_hundred = views::iota(1, 101);
// Single element
auto just_42 = views::single(42);
// Empty
auto nothing = views::empty<int>;
// C++23: repeat N times
// auto fives = views::repeat(5, 10); // ten 5s
Range algorithms live in std::ranges:: and accept ranges directly:
std::vector<int> v = {3, 1, 4, 1, 5};
// Classic
std::sort(v.begin(), v.end());
// Ranges — cleaner, safer
std::ranges::sort(v);
struct Waypoint { double x, y; int priority; };
std::vector<Waypoint> wps = { ... };
// Sort by priority — no lambda needed
std::ranges::sort(wps, {}, &Waypoint::priority);
// Find max distance from origin
auto farthest = std::ranges::max_element(wps, {},
[](const Waypoint& w) { return w.x*w.x + w.y*w.y; });
// Partition: high priority first
std::ranges::partition(wps, [](int p) { return p > 5; }, &Waypoint::priority);
Projections let you “project” elements before comparison, eliminating verbose lambdas:
// WITHOUT projections — compare by member
std::sort(robots.begin(), robots.end(),
[](const Robot& a, const Robot& b) {
return a.battery_level < b.battery_level;
});
// WITH projections — just name the member
std::ranges::sort(robots, std::less{}, &Robot::battery_level);
// Projections work with ANY callable — not just member pointers
std::ranges::sort(robots, {}, [](const Robot& r) {
return r.x * r.x + r.y * r.y; // sort by distance from origin
});
Projection applies to EACH element independently before comparison. The {} means “default comparator” (std::less{}).
Why this matters: Projections are composable and reusable. You write the “what to compare” once, and the algorithm handles the boilerplate.
Classic STL requires begin() and end() to return the same type. Ranges relax this — end() can return a sentinel (a different type):
// Sentinel: "stop when you hit null"
struct NullSentinel {
bool operator==(const char* p) const { return *p == '\0'; }
};
// Now "end" is not a position — it's a CONDITION
// This enables null-terminated string iteration without strlen()
Sentinels enable infinite ranges (no end iterator — sentinel says “always continue”) and lazy termination conditions.
Use view_interface CRTP base to get free member functions:
template <std::ranges::input_range R>
class stride_view : public std::ranges::view_interface<stride_view<R>> {
R base_;
std::size_t stride_;
public:
stride_view(R base, std::size_t stride) : base_(std::move(base)), stride_(stride) {}
auto begin() { /* custom iterator */ }
auto end() { /* sentinel or iterator */ }
};
view_interface gives you empty(), operator bool(), front(), back(), operator[], and size() — all for free — based on what your begin()/end() support.
auto get_data() { return std::vector{1, 2, 3}; }
// DANGER: view over a temporary
auto v = get_data() | views::filter([](int x) { return x > 1; });
// The vector is DEAD. v holds a dangling reference.
// Safe: ranges::find on a temporary returns ranges::dangling
auto it = std::ranges::find(get_data(), 42);
// it is std::ranges::dangling — won't compile if you dereference it
Some views (like istream_view) are single-pass. Iterating twice gives nothing the second time:
auto is = std::istringstream{"1 2 3"};
auto v = std::ranges::istream_view<int>(is);
// First loop: 1, 2, 3
// Second loop: EMPTY — stream is consumed
std::vector<int> data = {1, 2, 3};
auto v = data | views::take(2);
data.push_back(4); // Invalidates iterators → v is now UB
views::filter Caches begin()Calling begin() on a filter_view the first time is O(N) (finds first matching element). Subsequent calls return the cached result. This means filter_view is not const-iterable after first use.
Views are zero-overhead abstractions. The compiler inlines everything.
Godbolt proof: a pipeline like v | filter(pred) | transform(fn) | take(5) compiles to the SAME assembly as a hand-written loop. The view objects are optimized away entirely.
Why: Views store references + function objects. Iteration is just pointer chasing + function calls, all inlineable. No heap allocation. No virtual dispatch.
Micro-benchmark pattern:
Eager (intermediate vectors): ~3.2ms for 1M elements
Lazy (view pipeline): ~1.1ms for 1M elements (no alloc overhead)
The lazy version wins because it avoids allocating intermediate vectors.
// Filter valid laser scan ranges without copying
auto valid_ranges = scan.ranges
| views::filter([](float r) { return r > 0.01f && r < 30.0f; })
| views::transform([](float r) { return r * 1000.0f; }); // m → mm
// No allocation — processes lazily as you iterate
// Extract Z-values > ground plane from point cloud
auto above_ground = points
| views::filter([](const Point& p) { return p.z > 0.05; })
| views::transform(&Point::z);
// Process only reliable line-sensor frames
auto reliable_frames = all_frames
| views::filter(&Line-SensorFrame::is_reliable)
| views::transform([](const auto& f) { return f.reflectance; });
| Feature | C++20 Ranges | ranges-v3 | Boost.Range |
|---|---|---|---|
| Standard | Yes | No (library) | No (library) |
| Views | Yes | Yes (more) | Limited |
| Pipe syntax | Yes | Yes | No |
| Projections | Yes | Yes | No |
ranges::to |
C++23 | Yes | No |
| Actions (eager) | No | Yes | No |
| Compiler support | GCC 10+, Clang 14+ | GCC 9+, Clang 6+ | GCC 5+, Clang 3+ |
ranges-v3 by Eric Niebler was the prototype for C++20 Ranges. C++20 adopted a subset. ranges-v3 still has more features (actions, views::zip pre-C++23, etc.).
#include <ranges>
#include <algorithm>
namespace views = std::views; // shorthand
namespace ranges = std::ranges; // shorthand
// Compose views with |
auto pipeline = data | views::filter(pred) | views::transform(fn);
// Range algorithms
ranges::sort(v);
ranges::sort(v, std::greater{}); // descending
ranges::sort(v, {}, &Struct::member); // projection
auto it = ranges::find_if(v, pred);
auto [min, max] = ranges::minmax(v);
// Factories
auto seq = views::iota(0, 100); // [0, 100)
// Materialize a view into a container
std::vector<int> vec(pipeline.begin(), pipeline.end());
// C++23: auto vec = pipeline | ranges::to<std::vector>();