Ranges Algorithms vs Classic Algorithms
C++20 ships two parallel algorithm worlds: the classic std:: algorithms from C++98, and a new std::ranges:: namespace where every algorithm is constrained by concepts and accepts a single range argument instead of an iterator pair. The ranges versions are not just syntactic sugar — they compose with views through the pipe operator, they verify iterator category at compile time, and they project elements before comparison without requiring a wrapper lambda. This page covers both the algorithm story and the view pipeline model that makes ranges genuinely different from what came before.
Range concepts — the capability ladder
In the classic STL, algorithm requirements are documented in prose ("requires a RandomAccessIterator") and enforced only when the wrong iterator type produces a cryptic substitution failure deep in the instantiation stack. The ranges library replaces those prose requirements with formal concepts. Each concept is a strict superset of the one below it: a bidirectional_range is automatically a forward_range, and so on. The compiler checks these at the point of the algorithm call and reports a clear error when a range does not satisfy the required concept.
| Concept | Iterator model | Key capability | Examples |
|---|---|---|---|
| input_range | input_iterator | Read once, single pass | istream_view, generator |
| forward_range | forward_iterator | Multi-pass, stable addresses | forward_list, unordered_map |
| bidirectional_range | bidirectional_iterator | Iterator can move backward (--) | list, map, set |
| random_access_range | random_access_iterator | O(1) index, iterator arithmetic | deque, string |
| contiguous_range | contiguous_iterator | Elements lie contiguous in memory | vector, array, string |
| sized_range | (any) | std::ranges::size() is O(1) | Most containers + arrays |
Standard containers satisfy the concepts their iterator supports. The table below maps common containers to the highest concept they satisfy.
| Container | Highest concept |
|---|---|
| vector, array, string | contiguous_range + sized_range |
| deque | random_access_range + sized_range |
| list | bidirectional_range + sized_range |
| forward_list | forward_range (no size()) |
| map, set, multimap, multiset | bidirectional_range + sized_range |
| unordered_map, unordered_set (and multi variants) | forward_range + sized_range |
Views — lazy, composable range adaptors
A view is a lightweight, non-owning range that wraps another range and transforms or filters its elements on demand. Nothing is computed when you construct a view pipeline — computation happens element by element as you iterate. This laziness means that a chain of three views over a million-element vector pays for only the elements you actually consume; there are no intermediate allocations. Views are composed with the pipe operator |, which reads left to right like a Unix shell pipeline.
#include <ranges>
#include <vector>
#include <print>
namespace rg = std::ranges;
namespace rv = std::views;
// Problem: print all prime numbers below 100 using view composition
auto is_prime = [](int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; ++i)
if (n % i == 0) return false;
return true;
};
// rv::iota(2) generates 2, 3, 4, 5, ... (infinite, lazy)
// rv::filter keeps only elements satisfying the predicate
// rv::take stops after 10 results
// Nothing is computed until the range-for loop iterates
for (int p : rv::iota(2) | rv::filter(is_prime) | rv::take(10))
std::print("{} ", p);
// Output: 2 3 5 7 11 13 17 19 23 29The standard library ships a rich set of views in std::views (abbreviated rv below). The most commonly used:
| View | Effect | Example |
|---|---|---|
| rv::iota(n) | Infinite sequence n, n+1, n+2, … | rv::iota(1) → 1 2 3 4 … |
| rv::iota(n, m) | Finite sequence [n, m) | rv::iota(1, 6) → 1 2 3 4 5 |
| rv::filter(pred) | Keep only elements where pred(e) is true | rv::filter(is_even) → 2 4 6 … |
| rv::transform(fn) | Apply fn to each element | rv::transform([](int x){ return x*x; }) |
| rv::take(n) | First n elements | rv::take(5) stops at 5 |
| rv::drop(n) | Skip first n elements, yield the rest | rv::drop(3) skips first 3 |
| rv::reverse | Iterate in reverse (requires bidirectional) | rv::reverse on vector |
| rv::keys / rv::values | First/second of pair-like elements | rv::keys on std::map |
| rv::enumerate (C++23) | Pairs each element with its index | (0,a), (1,b), (2,c), … |
std::vector<int> numbers { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Squares of even numbers, reversed
for (int x : numbers
| rv::filter([](int n) { return n % 2 == 0; })
| rv::transform([](int n) { return n * n; })
| rv::reverse)
std::print("{} ", x);
// Output: 100 64 36 16 4
// Drop first 3, then take 4
for (int x : numbers | rv::drop(3) | rv::take(4))
std::print("{} ", x);
// Output: 4 5 6 7How the pipe operator works
The pipe operator is not a language feature — it is a set of overloaded operator| calls defined on range adaptor objects. Understanding the equivalences makes it easy to read and debug pipelines. Given a range R and an adaptor A that takes additional arguments:
No extra arguments
R | A is equivalent to A(R)
numbers | rv::reverse // same as rv::reverse(numbers)
With extra arguments
R | A(args…) is equivalent to A(R, args…)
numbers | rv::take(5) // same as rv::take(numbers, 5)
Partial application
A(args…) returns a range adaptor closure that can be piped or called
auto first5 = rv::take(5); numbers | first5; // same as first5(numbers)
Chaining closures
Closures can be combined with | before receiving a range
auto pipe = rv::filter(is_even)
| rv::transform(square);
numbers | pipe; // apply bothConstrained algorithms in std::ranges
Every classic algorithm has a range-constrained counterpart in the std::ranges namespace. These differ from the classic versions in three important ways. First, they accept a single range argument instead of two iterators, eliminating the common bug of passing iterators from different containers. Second, every parameter is constrained by a concept, so errors appear at the call site rather than inside the algorithm implementation. Third, they accept an optional projection: a callable applied to each element before the comparison or predicate, so you can sort a vector of structs by a field without writing a full comparator lambda.
#include <algorithm>
#include <ranges>
#include <vector>
#include <print>
namespace rg = std::ranges;
std::vector<int> v { 5, 2, 8, 1, 9, 3 };
// Classic: iterator-pair, no projection
auto classic_max = std::max_element(v.begin(), v.end());
// Range: single range, returns element not iterator
int range_max = rg::max(v);
std::println("{}", range_max); // 9
// Sorting — ranges version checks random_access_range at compile time
rg::sort(v); // ascending
rg::sort(v, std::greater<>{}); // descending
std::println("{}", v[0]); // 9 (after descending sort)
// Projection: sort vector of pairs by second element only
std::vector<std::pair<std::string, int>> scores {
{"Alice", 92}, {"Bob", 87}, {"Carol", 95}
};
rg::sort(scores, {}, &std::pair<std::string,int>::second);
// {"Bob",87}, {"Alice",92}, {"Carol",95}
// count_if with a projection
struct Employee { std::string name; int salary; };
std::vector<Employee> team { {"Ana",60000}, {"Ben",80000}, {"Cal",75000} };
int senior = rg::count_if(team, [](int s){ return s >= 75000; }, &Employee::salary);
std::println("{} senior employees", senior); // 2Common constrained algorithms and their classic counterparts:
Classic (std::) | Ranges (std::ranges::) | Notes |
|---|---|---|
| sort(begin, end) | sort(range) / sort(range, comp, proj) | Requires random_access_range |
| find(begin, end, val) | find(range, val, proj) | Returns iterator into range |
| max_element(begin, end) | max(range, comp, proj) | Returns the value, not an iterator |
| copy(begin, end, out) | copy(range, out) | out is still an output iterator |
| count_if(begin, end, pred) | count_if(range, pred, proj) | Projection applied before pred |
| reverse(begin, end) | reverse(range) | Modifies in-place, bidirectional_range |
| transform(begin,end,out,fn) | transform(range, out, fn, proj) | proj applied before fn |
| for_each(begin, end, fn) | for_each(range, fn, proj) | Returns fn after application |
Classic vs ranges: a direct comparison
The ergonomic difference is most visible when combining filtering and sorting. With classic algorithms you need either a manual loop to populate a temporary container, or a two-iterator dance. Ranges let you express the intent directly.
Classic (C++17)
std::vector<int> data { 5,3,8,1,7,2,9,4,6 };
// Step 1: copy evens to temp vector
std::vector<int> evens;
std::copy_if(data.begin(), data.end(),
std::back_inserter(evens),
[](int n){ return n % 2 == 0; });
// Step 2: sort temp
std::sort(evens.begin(), evens.end());
// Step 3: print first 3
for (int i = 0; i < 3 && i < (int)evens.size(); ++i)
std::cout << evens[i] << ' ';
// Output: 2 4 6Ranges (C++20)
std::vector<int> data { 5,3,8,1,7,2,9,4,6 };
// Collect evens into a sorted vector in one shot
std::vector<int> evens;
rg::copy(data | rv::filter([](int n){ return n%2==0; }),
std::back_inserter(evens));
rg::sort(evens);
// Or lazily view the first 3 without materialising
for (int x : data
| rv::filter([](int n){ return n%2==0; })
| rv::take(3))
std::print("{} ", x);
// Output: 8 2 4 (lazy, order from data)range-v3 — C++17-compatible alternative
The C++20 ranges library is derived from Eric Niebler's range-v3 library, which works on C++14 and C++17 compilers. If you cannot yet use C++20 but want the same composable pipeline model, range-v3 is a drop-in starting point. The API is nearly identical; the primary differences are that views live in ranges::views (not std::views) and some C++23 views are available earlier. When your project moves to C++20, migrating away from range-v3 is typically a namespace rename.
// range-v3 (C++17 compatible)
#include <range/v3/all.hpp>
namespace rv = ranges::views;
std::vector<int> numbers { 1,2,3,4,5,6,7,8,9,10 };
for (int x : numbers | rv::filter([](int n){ return n%2==0; })
| rv::transform([](int n){ return n*n; }))
std::cout << x << ' ';
// Output: 4 16 36 64 100
// C++20 std::ranges — identical pipeline, different namespace
namespace rv = std::views;
for (int x : numbers | rv::filter([](int n){ return n%2==0; })
| rv::transform([](int n){ return n*n; }))
std::print("{} ", x);When to use ranges algorithms vs classic
Processing a full container with a single operation
Ranges: rg::sort(v) is shorter and safer than std::sort(v.begin(), v.end())
Composing filter → transform → take in one expression
Ranges + views: the pipe model is clearer than chained classic calls
Sorting by a struct field without a comparator
Ranges projection: rg::sort(v, {}, &MyStruct::field)
Operating on a sub-range defined by two iterators you already have
Classic algorithms: std::sort(first, last) is fine here
Algorithms on raw arrays or spans where you need raw pointer output
Either works; classic algorithms have slightly wider iterator support