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.
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 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 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 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 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>();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 itViews 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 pipelineWhen 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.
Given two arrays of different types and sizes, produce their element-wise sum (truncated to the shorter one).
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.
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 truncationBetter, but fragile: you must ensure the second range is at least as long as the first.
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:
zipstops at the shorter range — no manualstd::min - No intermediate allocation:
cis a lazy view, computed on demand - Clear intent: the pipeline reads as "zip, then sum each pair"
- Type safety: promotion from
int + double → doubleis handled naturally
auto c = std::views::zip_transform(std::plus<>{}, a, b);One line. No lambda. No apply. The intent is the code.
| 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 |
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);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.
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.
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.
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.
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.
| 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+ |
- "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++.