Skip to content
C++

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.

ConceptIterator modelKey capabilityExamples
input_rangeinput_iteratorRead once, single passistream_view, generator
forward_rangeforward_iteratorMulti-pass, stable addressesforward_list, unordered_map
bidirectional_rangebidirectional_iteratorIterator can move backward (--)list, map, set
random_access_rangerandom_access_iteratorO(1) index, iterator arithmeticdeque, string
contiguous_rangecontiguous_iteratorElements lie contiguous in memoryvector, 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.

ContainerHighest concept
vector, array, stringcontiguous_range + sized_range
dequerandom_access_range + sized_range
listbidirectional_range + sized_range
forward_listforward_range (no size())
map, set, multimap, multisetbidirectional_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 29

The standard library ships a rich set of views in std::views (abbreviated rv below). The most commonly used:

ViewEffectExample
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 truerv::filter(is_even) → 2 4 6 …
rv::transform(fn)Apply fn to each elementrv::transform([](int x){ return x*x; })
rv::take(n)First n elementsrv::take(5) stops at 5
rv::drop(n)Skip first n elements, yield the restrv::drop(3) skips first 3
rv::reverseIterate in reverse (requires bidirectional)rv::reverse on vector
rv::keys / rv::valuesFirst/second of pair-like elementsrv::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 7

How 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 both

Constrained 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);  // 2

Common 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 6

Ranges (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

Sign in to track progress