Skip to content

C++20 Ranges: A New Paradigm

Discover C++20 ranges — a modern redesign of the STL algorithm interface. Learn range concepts, range-based algorithms, projections, and the pipe operator for composable data transformations.

Why Ranges?

C++20 Ranges are a fundamental upgrade to the STL's algorithm model. The traditional STL requires passing iterator pairs to every algorithm, which is verbose and error-prone (you can accidentally pass iterators from different containers). Ranges solve this by allowing you to pass the container itself — or any object that defines begin() and end().

Key improvements over traditional algorithms:

1. Pass containers directlystd::ranges::sort(vec) instead of std::sort(vec.begin(), vec.end())
2. Projections — transform elements before comparing, without modifying them
3. Concepts — algorithms have clear compile-time requirements
4. Composability — chain operations with the pipe operator |
5. Lazy evaluation — views compute elements on demand, avoiding intermediate copies

Range Concepts

C++20 defines a hierarchy of range concepts in :

- std::ranges::range — has begin() and end()
- std::ranges::sized_range — has size() in O(1)
- std::ranges::input_range — can be read once (like input iterators)
- std::ranges::forward_range — multi-pass forward traversal
- std::ranges::bidirectional_range — forward + backward
- std::ranges::random_access_range — bidirectional + O(1) jumps
- std::ranges::contiguous_range — random access + contiguous memory
- std::ranges::common_rangebegin() and end() return the same type
- std::ranges::viewable_range — can be converted to a view

These concepts are used to constrain range-based algorithms, giving you clear error messages when you pass an incompatible range.

Range-Based Algorithms

Range-based algorithms live in std::ranges:: and accept entire containers. They also return richer result types instead of bare iterators.

range_algorithms.cpp
#include <algorithm>
#include <ranges>
#include <vector>
#include <string>
#include <iostream>

struct Employee {
    std::string name;
    int age;
    double salary;
};

int main() {
    std::vector<int> nums = {5, 3, 1, 4, 1, 5, 9, 2, 6};

    // Pass the container directly — no begin/end needed
    std::ranges::sort(nums);
    // nums: {1, 1, 2, 3, 4, 5, 5, 6, 9}

    // find returns a borrowed iterator (safe with temporary ranges)
    auto it = std::ranges::find(nums, 4);
    if (it != nums.end()) {
        std::cout << "Found: " << *it << '\n';
    }

    // count_if with ranges
    auto even_count = std::ranges::count_if(nums, [](int x) { return x % 2 == 0; });
    std::cout << "Even count: " << even_count << '\n';  // 3

    // min/max on entire range
    auto [min_it, max_it] = std::ranges::minmax_element(nums);
    std::cout << "Min: " << *min_it << ", Max: " << *max_it << '\n';

    // Sorting employees by salary (descending)
    std::vector<Employee> team = {
        {"Alice", 30, 95000},
        {"Bob", 25, 72000},
        {"Charlie", 35, 110000},
    };
    std::ranges::sort(team, std::ranges::greater{}, &Employee::salary);
    // Charlie (110k), Alice (95k), Bob (72k)

    for (const auto& e : team) {
        std::cout << e.name << ": $" << e.salary << '\n';
    }
}

Projections — Transform Before Comparing

Projections are a unique feature of range-based algorithms. They let you specify how to "view" each element for comparison or processing, without modifying the element itself.

projections.cpp
#include <algorithm>
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
#include <cctype>

int main() {
    // Sort strings case-insensitively using a projection
    std::vector<std::string> words = {"Banana", "apple", "Cherry", "date"};

    std::ranges::sort(words, {}, [](const std::string& s) {
        std::string lower = s;
        std::transform(lower.begin(), lower.end(), lower.begin(),
                       [](char c) { return std::tolower(c); });
        return lower;
    });
    // words: {"apple", "Banana", "Cherry", "date"}
    // Original strings preserved, sorted by lowercase version

    for (const auto& w : words) std::cout << w << ' ';
    std::cout << '\n';

    // Using member pointer as projection
    struct Point { int x, y; };
    std::vector<Point> points = {{3, 1}, {1, 5}, {2, 3}};

    // Sort by x coordinate
    std::ranges::sort(points, {}, &Point::x);
    // points: {{1,5}, {2,3}, {3,1}}

    // Find max by y coordinate
    auto max_y = std::ranges::max_element(points, {}, &Point::y);
    std::cout << "Max y: (" << max_y->x << ", " << max_y->y << ")\n";  // (1, 5)

    // Binary search with projection
    bool found = std::ranges::binary_search(points, 2, {}, &Point::x);
    std::cout << std::boolalpha << "Has x=2: " << found << '\n';  // true
}

The Pipe Operator for Composition

The pipe operator | is the hallmark of C++20 ranges. It lets you chain range adaptors (views) into a pipeline that reads left-to-right, like a Unix shell pipe:

``cpp
auto result = numbers
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(5);
``

This reads naturally: "take numbers, keep the even ones, square them, take the first 5." The pipeline is lazy — no computation happens until you iterate over result. This means no intermediate vectors are allocated.

We will explore views in depth in the next lesson.

Pitfall

Range algorithms protect against dangling iterators from temporary ranges. If you pass a temporary container, the returned iterator would dangle because the temporary is destroyed at the semicolon:

``cpp
auto it = std::ranges::find(get_vector(), 42);
// Returns std::ranges::dangling instead of an iterator!
// Attempting to dereference it won't compile.
``

This is a compile-time safety feature — traditional std::find would silently return a dangling iterator, leading to undefined behavior at runtime. The std::ranges::dangling sentinel type makes this a compile error.

Best Practice

Prefer std::ranges:: algorithms in new code — they are safer (no mismatched iterators, dangling protection), more readable (pass containers directly), and more powerful (projections, concepts).

Use traditional std:: algorithms when:
- Your codebase targets pre-C++20 compilers
- You need a specific overload that ranges don't provide
- You work with iterator pairs from legacy APIs

Note: std::ranges:: algorithms are not drop-in replacements. Some return different types (e.g., std::ranges::sort returns the end iterator of the sorted range). Check the documentation when migrating.

Key Takeaways
  • C++20 ranges let you pass containers directly to algorithms — no more begin()/end() boilerplate
  • Projections transform elements for comparison without modifying the original data
  • Range concepts (input_range, random_access_range, etc.) provide clear compile-time constraints
  • The pipe operator | enables composable, left-to-right data processing pipelines
  • std::ranges::dangling prevents dangling iterator bugs at compile time
  • Prefer std::ranges:: algorithms in new C++20 code for safety, readability, and power

Quiz — Test Your Knowledge

(20 XP)

1. What is a projection in the context of C++20 range algorithms?

2. What does `std::ranges::find` return when called with a temporary container?

3. What does `std::ranges::sort(vec, std::ranges::greater{}, &Employee::salary)` do?