C++20 Ranges and Views Explained

C++20 introduced the Ranges library, a major evolution of the Standard Template Library (STL) that fundamentally changes how developers interact with sequences of data. This article explores the concepts of ranges and views, explains how they replace verbose iterator pairs with cleaner abstractions, highlights their support for lazy evaluation and functional-style composition using the pipe operator, and demonstrates how they improve both code readability and performance.

Understanding Ranges

In pre-C++20 standards, algorithms in the <algorithm> header required pairs of iterators to operate on a sequence:

std::vector<int> numbers = {5, 2, 8, 1, 9};
std::sort(numbers.begin(), numbers.end());

A range abstracts this concept. Conceptually, a range is any object that refers to an iterable sequence of elements—formally, any type that provides a begin() iterator and an end() sentinel.

With the <ranges> header, standard algorithms now accept ranges directly:

std::ranges::sort(numbers);

All standard containers (such as std::vector, std::list, and std::string) satisfy the std::ranges::range concept, eliminating redundant code and the risk of mismatched iterator pairs.

Understanding Views

A view is a lightweight, non-owning range that provides a specific window into an underlying sequence. Views are defined in the std::ranges::views (or simply std::views) namespace.

Key characteristics of views include:

  • Non-owning: Views do not own the data they reference; they only view elements owned by another container.
  • Constant time operations: Creating, copying, moving, and destroying views are guaranteed \(O(1)\) operations.
  • Lazy evaluation: Views do not transform or filter elements when created. Computations occur on-the-fly only when an element is accessed during iteration.

The Pipe Operator and Composability

Views can be composed using the bitwise OR operator (|), functioning similarly to Unix shell pipes. This allows complex data transformations to be chained sequentially without creating intermediate containers.

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

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

    // Filter even numbers and square them
    auto result = numbers 
                | std::views::filter([](int n) { return n % 2 == 0; })
                | std::views::transform([](int n) { return n * n; });

    for (int n : result) {
        std::cout << n << " "; // Outputs: 4 16 36 64 100
    }
}

In this example, no new vectors are allocated in memory. The filter and transform logic executes dynamically as the for loop requests the next value.

Key Differences: Containers vs. Views

Feature Containers (e.g., std::vector) Views (e.g., std::views::take)
Ownership Owns the underlying elements Borrows/references elements
Memory Allocation Allocates heap/stack memory Zero dynamic allocations
Copy Complexity \(O(N)\) \(O(1)\)
Evaluation Eager (processed immediately) Lazy (processed on demand)

Summary

Ranges and views modernize C++ by combining the performance of iterator-based algorithms with the clean, readable semantics of functional programming. By avoiding unnecessary allocations and supporting pipeline syntax, they make sequence manipulation more expressive, less error-prone, and highly efficient.