C++ Ranges, Views, and Span


  • Description: A note on C++20 Ranges and views composition, range concepts, projections, std::span, and the C++23 additions (ranges::to, chunk, zip, std::generator)
  • My Notion Note ID: K2A-B1-16
  • Created: 2021-06-15
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. What Is a Range?

A range is anything you can iterate over — informally, a pair of iterators (begin() and end()) or anything that yields one. The C++20 <ranges> library builds a vocabulary of range-aware algorithms and lazy adaptors on top of this idea.

#include <ranges>
#include <vector>

std::vector<int> v = {1, 2, 3};
// v is a range — std::ranges::sort(v) works directly without v.begin()/v.end()

Ranges replace most everyday uses of begin/end pairs in modern C++. They also enable lazy view pipelines (section 3) and unified algorithm signatures (section 2).


2. Range Algorithms (std::ranges::*)

Almost every algorithm in <algorithm> and <numeric> got a range-based overload in std::ranges.

2.1 Algorithm Overloads

#include <ranges>
#include <algorithm>
#include <vector>

std::vector<int> v = {3, 1, 4, 1, 5};

std::ranges::sort(v);                                // takes a whole range
auto it = std::ranges::find(v, 5);                   // search whole range
bool any_neg = std::ranges::any_of(v,
                   [](int x){ return x < 0; });
auto [mn, mx] = std::ranges::minmax(v);              // pair, structured-binding-friendly

These call the same algorithms but accept a single range argument. They also enforce range concepts via constraints, so error messages are dramatically clearer than with iterator-pair algorithms.

2.2 Projections

Most range algorithms take an optional projection — a callable that extracts a key from each element before comparison. This replaces the common pattern of writing custom lambdas just to compare on a member.

struct Person {
    std::string name;
    int         age;
};

std::vector<Person> people = { /* ... */ };

// Sort by age ascending — no comparator needed, just a projection
std::ranges::sort(people, {}, &Person::age);

// Find the oldest
auto oldest = std::ranges::max_element(people, {}, &Person::age);

// Custom comparator AND projection
std::ranges::sort(people, std::greater<>{}, &Person::age);   // age descending

The signature is (range, comparator, projection). {} means default comparator (std::ranges::less).

A projection can be any callable: a member pointer (&Person::age), a member function pointer, a free function, or a lambda ([](const Person& p){ return p.name.size(); }).


3. Views

A view is a lightweight, non-owning, lazy range adaptor. Views don't allocate, don't store elements; they wrap an underlying range and produce values on demand as you iterate.

3.1 Common Views

View What it does
views::filter(pred) Keep elements where pred(x) is true
views::transform(fn) Apply fn to each element
views::take(n) First n elements
views::drop(n) Skip first n elements
views::take_while(pred) Until pred is false
views::drop_while(pred) Skip until pred is false
views::reverse Iterate in reverse
views::iota(n) Infinite sequence n, n+1, n+2, …
views::iota(a, b) Bounded sequence a, a+1, ..., b-1
views::keys / views::values Project pair::first / pair::second (e.g. for maps)
views::join Flatten a range of ranges
views::split(delim) Split a range by a delimiter
views::elements<N> Project the Nth element of each tuple
views::common Force begin() and end() to have the same type (for legacy algorithms)
views::all Wrap a range in a view (the conversion that | does implicitly)

3.2 View Composition with |

Views compose with the pipe operator |, building a pipeline that's evaluated lazily on iteration.

#include <ranges>
#include <vector>
#include <iostream>

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

auto pipeline = v
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; })
    | std::views::take(3);

for (int x : pipeline) std::cout << x << " ";
// Output: 4 16 36

The pipe is just sugar — v | views::filter(p) is equivalent to views::filter(v, p). Use whichever reads better.

3.3 Lazy Evaluation

Views don't compute anything until you iterate. The pipeline above doesn't allocate a new vector, doesn't filter then transform then take in sequence — it pulls one element at a time through the chain.

This means:

  1. Multiple views over the same data are cheap — no intermediate containers are built.
  2. Infinite ranges are usableviews::iota(0) | views::take(5) is valid and returns {0, 1, 2, 3, 4}.
  3. Side effects in transform / filter callables run during iteration, not during pipeline construction.
  4. Each iteration re-runs the pipeline. Iterating a transform_view twice calls the transform function twice per element. Materialize with ranges::to<vector> (C++23) if you want to iterate repeatedly.
auto squares_under_100 = std::views::iota(1)
    | std::views::transform([](int x) { return x * x; })
    | std::views::take_while([](int x) { return x < 100; });

for (int x : squares_under_100) std::cout << x << " ";
// 1 4 9 16 25 36 49 64 81

4. Range and View Concepts

<ranges> defines a hierarchy of concepts that constrain ranges. They show up in error messages and when writing your own range adaptors.

  1. std::ranges::range — has begin() and end().
  2. std::ranges::sized_range — knows its size in O(1).
  3. std::ranges::input_range / forward_range / bidirectional_range / random_access_range / contiguous_range — match the iterator categories.
  4. std::ranges::view — non-owning, cheap to copy. Implements the view_interface.
  5. std::ranges::common_rangebegin() and end() have the same type (lets you use it with classic STL algorithms expecting iterator pairs).
  6. std::ranges::borrowed_range — safe to use the iterators after the range expression goes out of scope.
  7. std::ranges::viewable_range — can be turned into a view (via views::all).

If you constrain a function template with one of these, you get sharp errors when callers pass the wrong shape of range.


5. std::span

std::span<T> (C++20, <span>) is a non-owning view of a contiguous sequence — a T* plus a length. It's the idiomatic way to write a "view of an array" parameter without committing to a specific container type.

#include <span>
#include <vector>
#include <array>
#include <iostream>

void print(std::span<const int> s) {
    for (int x : s) std::cout << x << " ";
}

std::vector<int>     v = {1, 2, 3};
std::array<int, 3>   a = {4, 5, 6};
int                  raw[] = {7, 8, 9};

print(v);                  // works
print(a);                  // works
print(raw);                // works (decays via deduction guide)
print({v.data(), 2});      // explicit (T*, size_t): first 2 elements of v

A span has two flavors:

  1. Dynamic extentstd::span<T> (or std::span<T, std::dynamic_extent>). Length is a runtime value.
  2. Static extentstd::span<T, N>. Length is a compile-time constant; the span is one pointer wide instead of two.

std::span vs ranges views:

  1. std::span<T> is contiguous and bounded — a fancy (T*, size_t).
  2. Ranges views are lazy and may be non-contiguous (e.g., a transform_view).
  3. Use std::span for "give me a contiguous slice"; use ranges views for "compose lazy operations."

A span is itself a range, so you can pipe it through views: print(v | std::views::take(3)).


6. C++23 Additions

C++23 expanded the ranges library considerably.

6.1 std::ranges::to

Finally easy to materialize a range into a container.

#include <ranges>
#include <vector>

auto v = std::views::iota(1, 6)
       | std::views::transform([](int x) { return x * x; })
       | std::ranges::to<std::vector>();
// v is std::vector<int>{1, 4, 9, 16, 25}

// You can also specify the container type explicitly
auto m = std::views::zip(keys, values)
       | std::ranges::to<std::map<std::string, int>>();

Before C++23, you had to write std::vector<int>(rng.begin(), rng.end()) (and many views weren't common_ranges, so even that didn't work without views::common).

6.2 New Views

  1. views::chunk(n) — split into chunks of size n. Last chunk may be smaller.
  2. views::slide(n) — sliding window of size n over the range.
  3. views::chunk_by(pred) — split where pred(prev, curr) is false.
  4. views::stride(n) — every nth element.
  5. views::adjacent<N> — sliding window of fixed compile-time size N, returning tuples.
  6. views::zip(r1, r2, ...) — element-wise tuple of multiple ranges.
  7. views::zip_transform(fn, r1, r2, ...) — like zip but applies fn to each tuple.
  8. views::enumerate — pairs (index, element).
  9. views::cartesian_product(r1, r2, ...) — Cartesian product as a range of tuples.
  10. views::repeat(value) / views::repeat(value, n) — infinite or bounded repetition.
std::vector<int>         a = {1, 2, 3};
std::vector<std::string> b = {"one", "two", "three"};

for (auto [n, s] : std::views::zip(a, b)) {
    std::cout << n << "=" << s << " ";
}
// 1=one 2=two 3=three

6.3 std::generator (Coroutine-Based Range)

std::generator<T> is a coroutine return type — the easiest way to write your own range from scratch. Inside a generator function, you co_yield values one at a time.

#include <generator>
#include <ranges>
#include <iostream>

std::generator<int> fibs() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        int next = a + b;
        a = b;
        b = next;
    }
}

for (int x : fibs() | std::views::take(10)) std::cout << x << " ";
// 0 1 1 2 3 5 8 13 21 34

This is much simpler than writing a custom iterator class with begin(), end(), and an iterator type. It's also the natural representation for ranges that compute lazily but don't fit neatly into existing view adaptors.

6.4 Folds

std::ranges::fold_left / fold_right are reductions like std::accumulate, but with cleaner signatures: constrained via concepts, the accumulator type is deduced, and the _first / _last variants make the initial value optional and return std::optional.

#include <ranges>
#include <vector>

std::vector<int> v = {1, 2, 3, 4, 5};
int sum = std::ranges::fold_left(v, 0, std::plus{});           // 15
int product = std::ranges::fold_left(v, 1, std::multiplies{}); // 120

7. When to Use Ranges

  1. Default to ranges for new code in C++20+ projects — they're more readable and make intent explicit.
  2. Use views for transformation pipelines. Avoid materializing intermediate std::vectors; pipe through views and only materialize at the end (with ranges::to in C++23, or by passing the view straight to whatever consumes it).
  3. Use std::span for non-owning array parameters. Replaces (T*, size_t) and T[] parameters in API signatures.
  4. Be careful about view lifetime. Views borrow the underlying range; they must not outlive it. Storing a view in a class member is usually a bug (use borrowed_range constraints to express this).
  5. Falling back to iterators is fine when you need fine control, when working with C++17 code, or when the ranges library doesn't have the adaptor you need.
  6. Avoid filter_view if iterating multiple times. Filter has to re-scan from the beginning each time you call begin(). For repeated iteration, materialize first.