Skip to content

Instantly share code, notes, and snippets.

@olibartfast
Created February 12, 2026 15:24
Show Gist options
  • Select an option

  • Save olibartfast/175ebc537761e9852bea8a308b3ce6aa to your computer and use it in GitHub Desktop.

Select an option

Save olibartfast/175ebc537761e9852bea8a308b3ce6aa to your computer and use it in GitHub Desktop.
C++ Ranges and the Evolution of Modern Algorithms

C++ Ranges and the Evolution of Modern Algorithms

A concise reference on how C++ algorithms evolved from raw loops to composable, lazy pipelines — and why ranges represent the most significant leap in expressive power since the STL itself.


The Timeline: From Loops to Pipelines

Pre-C++11: The Dark Ages

Before C++11, C++ lacked move semantics, lambdas, auto, and range-based for. Algorithms existed in <algorithm>, but using them required verbose iterator pairs and standalone functors.

// Sorting a vector circa C++03
struct Compare {
    bool operator()(int a, int b) const { return a < b; }
};
std::sort(vec.begin(), vec.end(), Compare());

Iterating meant index loops or explicit iterator declarations:

for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
    // ...
}

C++11/14: The Foundation

C++11 introduced the building blocks that made algorithms usable in practice: lambdas, auto, range-based for, and move semantics.

std::sort(vec.begin(), vec.end(), [](int a, int b){ return a < b; });

for (auto const& x : vec) {
    // ...
}

This was a massive quality-of-life improvement, but the algorithm model remained iterator-pair based. Every algorithm required .begin() and .end(), and composing multiple operations meant storing intermediate results or writing nested function calls.

C++17: Incremental Steps

C++17 brought structured bindings, std::optional, if constexpr, and parallel execution policies for algorithms:

std::sort(std::execution::par, vec.begin(), vec.end());

Useful, but the fundamental algorithm interface remained unchanged.

C++20: The Ranges Revolution

C++20 introduced <ranges> — a complete reimagining of how algorithms compose. Instead of iterator pairs, algorithms operate on ranges (anything with begin() and end()), and views allow lazy, composable transformations.

namespace sv = std::views;

auto result = vec
    | sv::filter([](int x){ return x > 0; })
    | sv::transform([](int x){ return x * x; });

No intermediate containers. No iterator boilerplate. The pipeline reads top-to-bottom as a description of intent.

C++23: Refinement and Completion

C++23 added critical missing pieces: views::zip, views::zip_transform, views::enumerate, views::chunk, views::slide, views::join_with, ranges::to<> for materialization, and more.

auto v = some_view | std::ranges::to<std::vector>();

Core Concepts

Views Are Non-Owning

A view does not own its data. It is a lightweight, non-owning reference to a range with a specific transformation applied. Views are cheap to copy and move — typically just a pointer/iterator pair plus the transformation state.

std::vector<int> data = {1, 2, 3, 4, 5};
auto even = data | std::views::filter([](int x){ return x % 2 == 0; });
// `even` does not copy `data` — it references it

Lazy Evaluation

Views are evaluated lazily. Elements are computed on demand, not upfront. This means:

  • No memory allocated for intermediate results
  • Computation stops as soon as the consumer stops requesting elements
  • Pipelines of multiple transformations fuse into a single pass
auto result = data
    | std::views::transform([](int x){ return x * x; })
    | std::views::filter([](int x){ return x > 10; })
    | std::views::take(3);
// Nothing has been computed yet — `result` is a lazy pipeline

Materialization

When you need an owning container, materialize with ranges::to (C++23):

auto vec = result | std::ranges::to<std::vector>();

This clearly separates what you compute from how you store it.


Practical Example: Summing Arrays of Different Sizes

The Problem

Given two arrays of different types and sizes, produce their element-wise sum (truncated to the shorter one).

C++03 Approach

std::vector<int> a = {1, 2, 3};
std::vector<double> b = {4.1, 5.2, 6.3, 7.4};
std::vector<double> c;

size_t n = std::min(a.size(), b.size());
for (size_t i = 0; i < n; ++i) {
    c.push_back(a[i] + b[i]);
}

Manual size management. Explicit indexing. Type promotion is implicit and hidden.

C++11 Approach (with <algorithm>)

std::vector<double> c;
std::transform(a.begin(), a.end(), b.begin(), std::back_inserter(c), std::plus<>{});
// ⚠ UB if b is shorter than a — no automatic truncation

Better, but fragile: you must ensure the second range is at least as long as the first.

C++20/23 Ranges Approach

import std;

auto main() -> int {
    const auto a = std::vector{1,   2,   3       };
    const auto b = std::vector{4.1, 5.2, 6.3, 7.4};

    // Lazy view: zip pairs elements, transform sums them
    auto c = std::views::zip(a, b)
           | std::views::transform([](auto const& vv) {
                 return std::apply(std::plus<>{}, vv);
             });

    // Materialize into an owning container
    auto v = c | std::ranges::to<std::vector>();

    std::println("sum: {}", c);  // lazy view
    std::println("sum: {}", v);  // owning vector
}

Key advantages:

  • Automatic size handling: zip stops at the shorter range — no manual std::min
  • No intermediate allocation: c is a lazy view, computed on demand
  • Clear intent: the pipeline reads as "zip, then sum each pair"
  • Type safety: promotion from int + double → double is handled naturally

Even Cleaner with zip_transform (C++23)

auto c = std::views::zip_transform(std::plus<>{}, a, b);

One line. No lambda. No apply. The intent is the code.


Common Views and Adaptors

View Standard Purpose
filter C++20 Keep elements matching a predicate
transform C++20 Apply a function to each element
take / drop C++20 First N / skip first N elements
take_while / drop_while C++20 Predicate-based take/drop
reverse C++20 Reverse iteration
elements<N> C++20 Extract Nth element from tuple-like
keys / values C++20 Shorthand for elements<0> / elements<1>
split / lazy_split C++20 Split range by delimiter
join C++20 Flatten nested ranges
common C++20 Adapt sentinel-based range to iterator pair
zip C++23 Pair elements from multiple ranges
zip_transform C++23 Zip + transform in one step
enumerate C++23 Pair each element with its index
chunk C++23 Group into fixed-size chunks
slide C++23 Sliding window over elements
stride C++23 Take every Nth element
cartesian_product C++23 All combinations from multiple ranges
join_with C++23 Flatten with separator
repeat C++23 Infinite or bounded repetition
ranges::to<C> C++23 Materialize view into container C

Range Algorithms vs Classic Algorithms

C++20 also introduced range-based overloads of classic algorithms in the std::ranges namespace. These accept ranges directly instead of iterator pairs:

// Classic
std::sort(vec.begin(), vec.end());
std::find(vec.begin(), vec.end(), 42);

// Ranges
std::ranges::sort(vec);
std::ranges::find(vec, 42);

These also support projections — a cleaner alternative to writing lambdas for member access:

struct Person { std::string name; int age; };
std::vector<Person> people = { /* ... */ };

// Classic: needs a lambda
std::sort(people.begin(), people.end(),
    [](auto const& a, auto const& b){ return a.age < b.age; });

// Ranges: projection
std::ranges::sort(people, {}, &Person::age);

Addressing Common Concerns

"Ranges are slow"

For the vast majority of application code, range pipelines compile down to the same machine code as hand-written loops. The optimizer sees through view composition. Profile before assuming overhead — and if you find a hot path where views don't optimize well, write the manual loop there, not everywhere.

"Compile times are worse"

This is a real trade-off. Range-heavy code pulls in substantial template machinery. Mitigations include precompiled headers, modules (import std;), and limiting view composition depth in headers. The compile-time cost is a tooling problem, not a design flaw.

"Hard to debug"

Stepping through deeply nested view types in a debugger is not pleasant today. This is improving with better IDE support and custom visualizers. Again, a tooling gap — not a reason to avoid the paradigm.

"My team doesn't know ranges"

This is the strongest argument against adoption — and the easiest to solve. Ranges are learnable. The pipe syntax is intuitive for anyone who has used Unix pipes, LINQ, Java Streams, or Rust iterators. Investment in learning compounds.


The Philosophy

Ranges represent a shift in how C++ expresses computation:

  • From mechanics to intent: Instead of describing how to iterate, describe what transformation you want.
  • From eager to lazy: Compute only what you need, when you need it.
  • From owning to viewing: Separate data ownership from data transformation.
  • From monolithic to composable: Build complex operations by snapping simple views together.

This is the same trajectory that functional programming languages followed decades ago. C++ is catching up — with zero-cost abstractions.


Compiler Support (as of 2025)

Feature GCC Clang MSVC
C++20 <ranges> core 10+ 15+ 19.29+
C++23 views::zip 13+ 17+ 19.37+
C++23 ranges::to 14+ 17+ 19.37+
C++23 zip_transform 13+ 17+ 19.37+
import std; (modules) 15+ 18+ 19.38+

Recommended Reading

  • "C++20: The Complete Guide" — Nicolai Josuttis
  • "Functional Programming in C++" — Ivan Čukić
  • cppreference.com/w/cpp/ranges — exhaustive reference
  • Eric Niebler's range-v3 — the library that inspired std::ranges
  • Barry Revzin's blog — deep dives on ranges proposals and edge cases

Modernization means moving forward. Ranges are not optional syntax sugar — they are the algorithmic evolution of C++.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment