The Ranges Library (C++20)

Reviewed & published by Brayan K

For decades, STL algorithms meant typing v.begin(), v.end() on every call and nesting transformations inside-out. C++20 ranges change that. You pass a whole container directly, and you build readable, left-to-right pipelines with the pipe | operator. By the end of this lesson you'll sort with std::ranges::sort, compose lazy views like filter, transform, take, and iota, and understand why none of it allocates intermediate containers.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of a ranges pipeline as a factory conveyor belt. Each view adaptor — filter, transform, take — is a station the items pass through, in order. Crucially, an item isn't processed until it actually reaches a station (lazy evaluation), and there are no warehouses between stations stockpiling half-finished goods (no intermediate containers). The pipe | is just the belt connecting the stations in reading order, so data | filter | transform | take describes the journey exactly as it happens.

1. Ranges Algorithms: No More begin()/end()

Classic STL algorithms take a pair of iterators. It works, but the boilerplate adds up and it's easy to mismatch begin/end from different containers. Here's the familiar std::sort:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> v = {5, 2, 8, 1, 9, 3};

    // The CLASSIC way: pass a pair of iterators (begin, end).
    sort(v.begin(), v.end());

    for (int x : v) cout << x << ' ';   // 1 2 3 5 8 9
    cout << endl;
    return 0;
}

// ✅ Expected output:
//    1 2 3 5 8 9

The std::ranges versions in <ranges> take the whole range directly. The call shrinks to std::ranges::sort(v) — clearer, and impossible to pass mismatched iterators.

#include <iostream>
#include <vector>
#include <ranges>      // ranges algorithms & views
#include <algorithm>
using namespace std;

int main() {
    vector<int> v = {5, 2, 8, 1, 9, 3};

    // The RANGES way: pass the whole range -- no begin()/end().
    ranges::sort(v);

    for (int x : v) cout << x << ' ';   // 1 2 3 5 8 9
    cout << endl;
    return 0;
}

// ✅ Expected output:
//    1 2 3 5 8 9

Your turn. Replace the blank with a single ranges call that sorts the whole vector:

#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm>
using namespace std;

int main() {
    vector<int> v = {4, 1, 7, 3, 9, 2};

    // 🎯 YOUR TURN — sort the whole vector with the RANGES algorithm.
    // (No begin()/end() needed -- pass the container directly.)
    ___;                       // 👉 ranges::sort(v);

    for (int x : v) cout << x << ' ';
    cout << endl;
    // ✅ Expected output:  1 2 3 4 7 9
    return 0;
}

2. Views: Lazy, Composable Pipelines

A view is a lightweight, non-owning range adaptor that evaluates lazily: it produces each element only when you ask for it. The standard adaptors live in std::views — filter keeps elements that match, transform maps each element, and take yields only the first n. You connect them with the pipe | operator, reading left to right.

#include <iostream>
#include <vector>
#include <ranges>
using namespace std;

int main() {
    vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};

    // Build a LAZY pipeline with the pipe operator |:
    //   keep evens -> square them -> take the first 3.
    auto pipeline = v
        | views::filter([](int n){ return n % 2 == 0; })
        | views::transform([](int n){ return n * n; })
        | views::take(3);

    // Nothing was computed until THIS loop runs (lazy):
    for (int x : pipeline) cout << x << ' ';   // 4 16 36
    cout << endl;
    return 0;
}

// ✅ Expected output:
//    4 16 36

Views can even generate data. std::views::iota produces a sequence of numbers on demand without any container behind it. Pair it with take to slice an otherwise unbounded sequence safely.

#include <iostream>
#include <ranges>
using namespace std;

int main() {
    // views::iota generates numbers on demand -- no container needed.
    // iota(1, 6) is half-open: it yields 1, 2, 3, 4, 5.
    for (int n : views::iota(1, 6)) cout << n << ' ';   // 1 2 3 4 5
    cout << endl;

    // Combine with take to slice an UNBOUNDED sequence safely:
    auto firstFive = views::iota(10) | views::take(5);  // 10,11,12,13,14
    for (int n : firstFive) cout << n << ' ';
    cout << endl;
    return 0;
}

// ✅ Expected output:
//    1 2 3 4 5
//    10 11 12 13 14

🧩 Reorder Challenge

These lines should build a pipeline that keeps evens, doubles them, and takes two. Put them in the right order:

A. auto result = v
B.     | views::filter([](int n){ return n % 2 == 0; })
C.     | views::transform([](int n){ return n * 2; })
D.     | views::take(2);

Correct order: A → B → C → D

Start from the source (A), filter to evens (B), transform by doubling (C), then take the first two (D). The pipe order matches the data flow.

3. Projections: Sort by a Member, No Comparator

Many ranges algorithms accept an optional projection: a function applied to each element before the algorithm inspects it. Want to sort people by age? Pass &Person::age as the projection — no hand-written comparator needed. The signature is ranges::sort(range, comparator, projection); pass {} for the default comparator.

#include <iostream>
#include <vector>
#include <string>
#include <ranges>
#include <algorithm>
using namespace std;

struct Person { string name; int age; };

int main() {
    vector<Person> people = {
        {"Mia", 30}, {"Leo", 22}, {"Ada", 27}
    };

    // PROJECTION: sort by the .age member, no comparator needed.
    // Signature: ranges::sort(range, comparator, projection)
    ranges::sort(people, {}, &Person::age);

    for (auto& p : people)
        cout << p.name << "(" << p.age << ") ";   // Leo(22) Ada(27) Mia(30)
    cout << endl;
    return 0;
}

// ✅ Expected output:
//    Leo(22) Ada(27) Mia(30)

🔎 Deep Dive: why pipelines don't allocate

A naive approach to "filter then transform" builds a temporary vector after each step. Ranges avoid this because each view is lazy and non-owning: it stores only a reference to its source and the operation to apply. When you iterate the final view, it pulls one element through the entire chain before moving to the next — so no stage ever materialises a whole intermediate container.

// Conceptually, for each element the pipeline does:
//   take? -> transform(filter-passes? element)
// one element at a time, on demand. No temp vectors.
auto p = v | views::filter(isEven)
           | views::transform(square)
           | views::take(3);
// Work happens HERE, lazily, as you iterate:
for (int x : p) use(x);

That is also why views::take can sit on top of an unbounded views::iota — the pipeline simply stops pulling once it has enough.

Pro Tips

🧠 Quick Recall

1. What does views::iota(1, 5) produce?

1, 2, 3, 4 — it is half-open, so the upper bound 5 is excluded.

2. What does this print?

vector<int> v = {1,2,3,4,5,6};
auto r = v | views::filter([](int n){return n%2==0;})
           | views::take(2);
for (int x : r) cout << x << ' ';

2 4 — keep evens (2, 4, 6), then take the first two.

3. Which call needs no begin()/end()?

std::ranges::sort(v) — it takes the whole range directly.

Common Errors (and the fix)

📋 Quick Reference

TaskCode
Sort a whole rangestd::ranges::sort(v);
Keep matching elementsv | std::views::filter(pred)
Map each elementv | std::views::transform(fn)
First n elementsv | std::views::take(n)
Generate numbersstd::views::iota(0, 10)
Sort by a memberranges::sort(v, {}, &T::field)
Header#include <ranges>

Mini-Challenge: a lazy number pipeline

Build a single pipeline from views::iota that keeps odd numbers, cubes them, and takes the first three — all lazily, with no intermediate containers.

#include <iostream>
#include <vector>
#include <ranges>
using namespace std;

// 🎯 MINI-CHALLENGE: a lazy number pipeline
// 1. Start from  views::iota(1, 11)  (the numbers 1..10).
// 2. Pipe it through views::filter to keep ODD numbers.
// 3. Pipe that through views::transform to cube each (n * n * n).
// 4. Pipe that through views::take(3).
// 5. Print the results separated by spaces.
//
// ✅ Expected output:
//    1 27 125

int main() {
    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

What header provides the C++20 ranges library?

  • <ranges>
  • <views>
  • <algorithm>
  • <iterator>

Answer: <ranges>. The ranges library, including views and range algorithms, lives in the <ranges> header.

How do classic algorithms like std::sort specify the data to operate on?

  • With an index and a length
  • With a container reference only
  • With a pair of iterators (begin, end)
  • With a single range object

Answer: With a pair of iterators (begin, end). Classic STL algorithms take an iterator pair; ranges algorithms can take a whole range directly.

What does std::ranges::sort(v) accept that std::sort does not?

  • A lambda by value only
  • A whole range (the container itself) instead of two iterators
  • A comparison only
  • A C-style array length

Answer: A whole range (the container itself) instead of two iterators. std::ranges::sort(v) takes the range directly, so you no longer write v.begin(), v.end().

What is a view in the ranges library?

  • A thread that processes the range
  • A deep copy of the container
  • A sorted snapshot of the data
  • A lazy, non-owning adaptor over a range that computes elements on demand

Answer: A lazy, non-owning adaptor over a range that computes elements on demand. A view is a lightweight, non-owning range adaptor; it does not copy data and evaluates lazily.

What does 'lazy evaluation' mean for views?

  • Elements are computed only when you iterate, not up front
  • The view runs on a background thread
  • The container is sorted lazily
  • Nothing is ever computed

Answer: Elements are computed only when you iterate, not up front. A view does no work until iterated; each element is produced on demand as you walk it.

Which operator composes range adaptors into a pipeline?

  • The double colon ::
  • The pipe operator |
  • The plus operator +
  • The arrow operator ->

Answer: The pipe operator |. Range adaptors compose left-to-right with the pipe operator, e.g. v | filter(...) | transform(...).

What does std::views::iota(1, 5) produce?

  • The numbers 1, 2, 3, 4, 5
  • A single value 5
  • A random sequence
  • The numbers 1, 2, 3, 4

Answer: The numbers 1, 2, 3, 4. iota(1, 5) is a half-open range: it yields 1, 2, 3, 4 (5 is the past-the-end bound).

What does std::views::take(3) do in a pipeline?

  • Sorts and keeps the top 3
  • Skips the first 3 elements
  • Yields only the first 3 elements of the range
  • Repeats each element 3 times

Answer: Yields only the first 3 elements of the range. take(n) is a view that yields at most the first n elements of the range it adapts.

What is a projection in a ranges algorithm?

  • A parallel execution policy
  • A function applied to each element before the algorithm inspects it
  • A way to render the range to screen
  • A copy of the range

Answer: A function applied to each element before the algorithm inspects it. A projection transforms each element for comparison purposes, e.g. sorting people by their .age member.

Why can views::filter and views::transform be chained efficiently?

  • They are lazy and non-owning, so no intermediate containers are created
  • They run on the GPU
  • They sort the data first
  • They build one intermediate vector per stage

Answer: They are lazy and non-owning, so no intermediate containers are created. Because each view is lazy and non-owning, a pipeline streams elements through without allocating temporaries.

Continue this course

Frequently asked questions

What is the difference between a range and a view?

A range is anything you can iterate from begin to end, including ordinary containers like std::vector. A view is a special, lightweight range that does not own its elements and evaluates lazily. Views are cheap to copy and are meant to be composed into pipelines with the pipe operator.

How are ranges algorithms different from the classic STL ones?

Classic algorithms such as std::sort take a pair of iterators, so you write std::sort(v.begin(), v.end()). The ranges versions in std::ranges take the whole range directly: std::ranges::sort(v). They also support projections, letting you tell the algorithm which member to use without writing a custom comparator.

What does lazy evaluation buy me?

A view does no work until you iterate it, and it produces each element on demand. Chaining filter, transform, and take therefore creates no intermediate containers: elements stream through the whole pipeline one at a time. With take, you can even work with conceptually infinite sequences and stop early.

What is the pipe operator for?

The pipe operator composes range adaptors left to right, so v | std::views::filter(pred) | std::views::transform(fn) reads in the same order the data flows. It is syntactic sugar that makes a chain of view adaptors far easier to read than deeply nested function calls.

What is a projection?

Many ranges algorithms accept an optional projection: a function applied to each element before the algorithm looks at it. For example, std::ranges::sort(people, {}, &Person::age) sorts by the age member without you writing a comparator. The projection keeps the algorithm generic and the call site short.

Related lessons