Projections in Modern C++ Ranges Explained

Projections in modern C++ ranges algorithms are callable objects that transform or extract data from an element before the algorithm processes it. Introduced in C++20 as part of the <ranges> and <algorithm> libraries, projections fundamentally streamline element access by removing boilerplate comparator code, reducing verbose lambdas, and separating the mechanics of data retrieval from core algorithmic logic.

The Problem Before Projections

Prior to C++20, operating on specific attributes of complex objects required custom predicates or comparators. For instance, sorting a collection of objects by a specific member variable demanded a custom lambda:

struct User {
    int id;
    std::string name;
};

std::vector<User> users = /* ... */;

// Pre-C++20: Custom lambda defines the comparison logic
std::sort(users.begin(), users.end(), [](const User& a, const User& b) {
    return a.id < b.id;
});

This pattern forced developers to rewrite binary comparison logic (<, ==, etc.) repeatedly, increasing the risk of subtle bugs such as violating strict weak ordering.

What is a Projection?

A projection is an invocable entity—such as a lambda, function pointer, or pointer-to-member—passed as an optional argument to a constrained algorithm in std::ranges. The algorithm evaluates the projection on each element before applying its predicate, comparator, or action:

Algorithm Operation -> Predicate(Projection(Element))

In algorithms with binary operations (like std::ranges::sort), the projection is applied to both operands individually before they are evaluated by the comparison function.

How Projections Transform Element Access

Projections dramatically simplify how elements and their internal attributes are accessed during algorithmic passes:

1. Direct Pointer-to-Member Syntax

C++20 ranges treat member pointers as callable projections via std::invoke. You no longer need to write a lambda just to access an object's member variable or getter function:

// Sort users by ID using standard std::less{} and a member pointer projection
std::ranges::sort(users, {}, &User::id);

// Sort users by name in descending order
std::ranges::sort(users, std::ranges::greater{}, &User::name);

2. Decoupling Access from Operation

Projections decouple what attribute is being evaluated from how it is evaluated. The comparator handles ordering logic, while the projection handles property extraction:

// Check if any user has an empty name
bool has_empty = std::ranges::any_of(users, &std::string::empty, &User::name);

In this example, &User::name extracts the string member, and &std::string::empty evaluates whether that extracted string is empty.

3. Modifying Views Without Modifying Data

Projections allow on-the-fly transformations without allocating temporary containers or altering the underlying data. For instance, calculating distances, casting types, or hashing elements can be projected directly into algorithms such as std::ranges::lower_bound or std::ranges::min_element:

auto shortest_name = std::ranges::min_element(users, {}, [](const User& u) {
    return u.name.length();
});

Performance and Guarantees

Projections introduce no runtime performance overhead compared to handwritten lambdas. Constrained algorithms forward projected arguments using std::invoke, allowing modern compilers to completely inline member accesses and functional mappings. Projections provide an expressive, type-safe, and zero-cost abstraction for manipulating data collections in modern C++.