C++STL

C++ Algorithms & Ranges: Views, Pipelines, Projections & std::ranges (C++20/23)

TT
TopicTrick Team
C++ Algorithms & Ranges: Views, Pipelines, Projections & std::ranges (C++20/23)

C++ Algorithms & Ranges: Views, Pipelines, Projections & std::ranges (C++20/23)


Table of Contents


Ranges Concepts: Range, View, and Viewable Range

A View is a range that:

  1. Copies in O(1) — it's a non-owning reference, like std::string_view
  2. Computes lazily — no data is processed until iterated
  3. Composes with other views via |

std::ranges Algorithms: Drop the Iterator Pairs

Every std:: algorithm has a std::ranges:: equivalent that accepts the whole container:

cpp
#include <algorithm>
#include <ranges>
#include <vector>
#include <string>

std::vector<int>    nums = {5, 2, 8, 1, 9, 3};
std::vector<std::string> words = {"banana", "apple", "cherry"};

// === SORTING ===
std::sort(nums.begin(), nums.end());         // Old: iterator pair
std::ranges::sort(nums);                     // New: entire range
std::ranges::sort(nums, std::greater{});     // Descending

// === FINDING ===
auto it  = std::find(nums.begin(), nums.end(), 8);    // Old
auto it2 = std::ranges::find(nums, 8);                // New — same iterator
bool has = std::ranges::contains(nums, 8);            // C++23: boolean directly

// === COUNTING ===
auto count = std::ranges::count(nums, 3);
auto cond  = std::ranges::count_if(nums, [](int n){ return n > 5; });

// === PARTITIONING & REMOVING ===
auto part = std::ranges::partition(nums, [](int n){ return n % 2 == 0; });

// Erase-remove idiom — C++20 replaces it:
std::erase_if(nums, [](int n){ return n < 3; }); // C++20: one call!

// === COPYING & TRANSFORMING ===
std::vector<int> doubled;
std::ranges::transform(nums, std::back_inserter(doubled), [](int n){ return n * 2; });

// === CHECKING PREDICATES ===
bool all_pos = std::ranges::all_of(nums, [](int n){ return n > 0; });
bool any_big = std::ranges::any_of(nums, [](int n){ return n > 7; });
bool none_neg = std::ranges::none_of(nums, [](int n){ return n < 0; });

// === MIN/MAX ===
auto [min_it, max_it] = std::ranges::minmax_element(nums);
int min_val = *min_it, max_val = *max_it;

Projections: Searching and Sorting by Member

Projections are one of the best quality-of-life features in std::ranges. Instead of writing a custom comparator lambda, you pass a member pointer or callable as the projection:

cpp
struct Player {
    std::string name;
    int         score;
    int         level;
};

std::vector<Player> players = {
    {"Alice", 95, 10},
    {"Bob",   82, 8},
    {"Carol", 78, 12},
};

// Sort by score (descending) using projection:
std::ranges::sort(players, std::greater{}, &Player::score);
// Without projection (old way):
std::sort(players.begin(), players.end(),
          [](const Player& a, const Player& b){ return a.score > b.score; });

// Find by name using projection:
auto it = std::ranges::find(players, "Alice", &Player::name);
if (it != players.end()) std::cout << it->score << '\n'; // 95

// min/max by level:
auto& low_level  = *std::ranges::min_element(players, {}, &Player::level);
auto& high_level = *std::ranges::max_element(players, {}, &Player::level);

// Check if sorted by score:
bool sorted_by_score = std::ranges::is_sorted(players, {}, &Player::score);

Views: Lazy Evaluation Pipelines

Views are lazy: they define how to process data but only do the work when you iterate:

cpp
#include <ranges>
#include <vector>

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

// Each view creates a "recipe" — no data copied, no computation yet:
auto pipeline = data
    | std::views::filter([](int n){ return n % 2 == 0; })  // Evens only
    | std::views::transform([](int n){ return n * n; })      // Square them
    | std::views::take(3);                                   // First 3

// Computation happens HERE — only when iterated:
for (int x : pipeline) { std::cout << x << ' '; }
// Output: 4 16 36 (squares of 2, 4, 6)
// ONLY 6 elements evaluated from data (stopped at take(3))

// Memory: ZERO intermediate vectors allocated!
// Equivalent loop the compiler generates:
// int count = 0;
// for (int n : data) {
//     if (count == 3) break;
//     if (n % 2 == 0) { print(n * n); count++; }
// }

Essential Views Reference

cpp
#include <ranges>

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

// === Filtering ===
auto evens = v | std::views::filter([](int n){ return n % 2 == 0; });

// === Transformation ===
auto squares = v | std::views::transform([](int n){ return n * n; });

// === Slicing ===
auto first3  = v | std::views::take(3);          // First 3: {1,2,3}
auto last3   = v | std::views::take_while([](int n){ return n <= 3; });
auto skip3   = v | std::views::drop(3);          // Skip first 3: {4,5,...}
auto slice   = v | std::views::drop(2) | std::views::take(4); // {3,4,5,6}

// === Reversal ===
auto rev = v | std::views::reverse;

// === Keys/Values (map) ===
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 82}};
for (auto& key   : scores | std::views::keys)   { std::cout << key << ' '; }
for (auto& value : scores | std::views::values)  { std::cout << value << ' '; }

// === Enumerate: index + value (C++23) ===
for (auto [i, val] : v | std::views::enumerate) {
    std::cout << i << ": " << val << '\n';
}

// === Zip: parallel iteration (C++23) ===
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
for (auto [name, score] : std::views::zip(names, v)) {
    std::println("{}: {}", name, score);
}

// === Split and join ===
std::string csv = "a,b,c,d";
for (auto part : csv | std::views::split(',')) {
    std::string s(part.begin(), part.end());
    std::cout << s << ' ';  // "a" "b" "c" "d"
}

// === Chunk (C++23) ===
auto chunks = v | std::views::chunk(3); // {{1,2,3}, {4,5,6}, {7,8,9}, {10}}

std::ranges::to: Materializing Views (C++23)

Views are lazy — to get a concrete std::vector, use std::ranges::to (C++23):

cpp
#include <ranges>
#include <vector>

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

// C++23: materialize view into vector
auto evens = data
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::ranges::to<std::vector>();  // Creates std::vector<int>{2,4,6,8,10}

// Into other containers:
auto even_set = data
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::ranges::to<std::set<int>>();

// Pre-C++23: manual collection
std::vector<int> collected;
for (int n : data | std::views::filter([](int n){ return n % 2 == 0; }))
    collected.push_back(n);

Parallel Algorithms: std::execution Policies

Standard algorithms support parallel execution via execution policies (requires TBB or compiler parallelism support):

cpp
#include <algorithm>
#include <execution>
#include <vector>

std::vector<int> big_data(10'000'000);
std::iota(big_data.begin(), big_data.end(), 0);

// Sequential (default, guaranteed serial):
std::sort(std::execution::seq, big_data.begin(), big_data.end());

// Parallel (may use multiple threads):
std::sort(std::execution::par, big_data.begin(), big_data.end());

// Parallel + SIMD vectorized:
std::sort(std::execution::par_unseq, big_data.begin(), big_data.end());

// Parallel transform:
std::transform(std::execution::par_unseq,
    big_data.begin(), big_data.end(), big_data.begin(),
    [](int n){ return n * n; }); // Vectorized, parallelized!

// reduce (parallel sum) — not accumulate! accumulate is always serial
auto total = std::reduce(std::execution::par_unseq,
                         big_data.begin(), big_data.end(), 0LL);

// WARNING: par/par_unseq algorithms must use thread-safe lambdas —
// no shared mutable state without synchronization!

Frequently Asked Questions

Do range views incur overhead compared to manual loops? No — the compiler fuses the entire view pipeline into a single optimized loop. The generated assembly for filter | transform | take is identical to a handwritten for loop with inline conditions. Views add zero runtime overhead vs manual loops; their only cost is slightly longer compile times.

Can I use ranges with my custom container? Yes — any container with begin() and end() returning iterators is automatically a range. For the full std::ranges feature set (projections, etc.), your iterators should model the std::input_iterator concept (or stronger). std::views::iota and std::views::generate create ranges from arbitrary generators without needing a container.

What is the difference between std::ranges::sort and std::sort? Both sort in-place. std::ranges::sort accepts the whole container (no begin/end needed), supports projections, requires std::random_access_range, and returns the sorted subrange. std::sort requires explicit iterator pairs and doesn't support projections. In new code, always prefer std::ranges::sort.


Key Takeaway

The std::ranges library is the biggest productivity and correctness improvement to C++ data processing since C++11 containers. Lazy views eliminate intermediate allocations. Projections eliminate comparator lambdas. Range algorithms eliminate begin()/end() boilerplate. Parallel execution policies add multi-core performance with a single argument change. Together, they enable Python-level expressiveness at C++ performance.

Read next: Safety First: std::span and Bounds Checking →


*Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineer