C++ Ranges & Views: Lazy Pipelines with C++20 Complete Guide 2026
Table of Contents
Why Ranges Exist
The C++ Standard Template Library has powerful algorithms, but using them requires typing begin() and end() on everything. Sorting a vector means std::sort(v.begin(), v.end()). Finding an element means std::find(v.begin(), v.end(), value). Chaining multiple operations means storing intermediate results in temporary containers.
C++20 Ranges fix all of this. std::ranges::sort(v) replaces the begin/end pair. Views let you chain transformations with the pipe operator | without creating temporary containers. The result reads like Unix pipes: data flows left to right through composable, lazy operations.
If you’ve used STL algorithms like transform and accumulate, ranges are the natural evolution. Same operations, dramatically less boilerplate, and no unnecessary copies.
Ranges vs Classic STL Algorithms
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {5, 2, 8, 1, 9, 3, 7, 4, 6};
// CLASSIC STL
std::sort(v.begin(), v.end());
auto it = std::find(v.begin(), v.end(), 7);
bool has7 = (it != v.end());
// C++20 RANGES — same thing, cleaner
std::ranges::sort(v);
auto it2 = std::ranges::find(v, 7);
bool has7b = (it2 != v.end());
// Classic: sort by custom comparison
std::sort(v.begin(), v.end(), std::greater<int>());
// Ranges: same
std::ranges::sort(v, std::greater{});
// Classic: count elements matching condition
int count1 = std::count_if(v.begin(), v.end(),
[](int n){ return n > 5; });
// Ranges: cleaner
int count2 = std::ranges::count_if(v, [](int n){ return n > 5; });
std::cout << "Count > 5: " << count2 << "
";
}
Every algorithm in <algorithm> has a ranges version in std::ranges::. They accept containers directly (no begin/end), return richer result types, and use C++20 concepts for better error messages when you pass the wrong types.
Views — Lazy Transformations
Views are the real power of ranges. A view is a lightweight, non-owning wrapper that transforms or filters data on demand — it doesn’t copy or allocate. When you create a view, nothing happens. When you iterate the view, each element is computed one at a time.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Create a view of even numbers — NO allocation happens here
auto evens = nums | std::views::filter([](int n) {
return n % 2 == 0;
});
// Nothing computed yet. Now iterate:
for (int n : evens) {
std::cout << n << " "; // 2 4 6 8 10
}
std::cout << "
";
// The original vector is untouched
// evens is just a "recipe" — a lightweight view object
}
The Pipe Operator
The pipe operator | chains views together, creating readable data pipelines that flow left to right. This is where ranges truly shine.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Pipeline: filter even → square them → take first 3
auto result = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(3);
for (int n : result) {
std::cout << n << " "; // 4 16 36
}
std::cout << "
";
// Equivalent old-school code would need:
// 1. Copy even numbers to a new vector
// 2. Transform that vector into squares
// 3. Resize to first 3
// Three allocations vs zero!
}
Essential Views
views::filter
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<std::string> words = {
"cat", "elephant", "dog", "hippopotamus", "ant", "butterfly"
};
// Filter words longer than 4 characters
for (const auto& w : words | std::views::filter(
[](const std::string& s) { return s.size() > 4; })) {
std::cout << w << "
";
}
// elephant, hippopotamus, butterfly
}
views::transform
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
#include <cctype>
int main() {
std::vector<std::string> names = {"alice", "bob", "charlie"};
// Transform to uppercase first letter
auto capitalized = names | std::views::transform([](std::string s) {
if (!s.empty()) s[0] = std::toupper(s[0]);
return s;
});
for (const auto& n : capitalized) {
std::cout << n << "
"; // Alice, Bob, Charlie
}
// Transform to lengths
auto lengths = names | std::views::transform(
[](const std::string& s) { return s.size(); }
);
for (auto len : lengths) {
std::cout << len << " "; // 5 3 7
}
}
views::take and views::drop
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// First 3 elements
for (int n : v | std::views::take(3)) {
std::cout << n << " "; // 1 2 3
}
std::cout << "
";
// Skip first 7, take the rest
for (int n : v | std::views::drop(7)) {
std::cout << n << " "; // 8 9 10
}
std::cout << "
";
// take_while: take while condition holds
for (int n : v | std::views::take_while([](int n) { return n < 5; })) {
std::cout << n << " "; // 1 2 3 4
}
std::cout << "
";
// drop_while: skip while condition holds
for (int n : v | std::views::drop_while([](int n) { return n < 5; })) {
std::cout << n << " "; // 5 6 7 8 9 10
}
}
views::split and views::join
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
#include <iostream>
int main() {
// Split a string by delimiter
std::string csv = "Alice,30,Engineer";
for (auto part : csv | std::views::split(',')) {
std::string_view sv(part.begin(), part.end());
std::cout << "[" << sv << "] ";
}
std::cout << "
"; // [Alice] [30] [Engineer]
// Join: flatten nested ranges
std::vector<std::vector<int>> nested = {{1, 2}, {3, 4}, {5, 6}};
for (int n : nested | std::views::join) {
std::cout << n << " "; // 1 2 3 4 5 6
}
}
Generator Views
#include <ranges>
#include <iostream>
int main() {
// iota: generate sequence of incrementing values
for (int n : std::views::iota(1, 11)) {
std::cout << n << " "; // 1 2 3 4 5 6 7 8 9 10
}
std::cout << "
";
// Infinite iota + take
for (int n : std::views::iota(0) | std::views::take(5)) {
std::cout << n << " "; // 0 1 2 3 4
}
std::cout << "
";
// Practical: first 10 squares of odd numbers
auto odd_squares = std::views::iota(1)
| std::views::filter([](int n) { return n % 2 != 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(10);
for (int n : odd_squares) {
std::cout << n << " "; // 1 9 25 49 81 121 169 225 289 361
}
std::cout << "
";
// repeat (C++23) and empty
// std::views::repeat(42) | std::views::take(5); // 42 42 42 42 42
// std::views::empty<int>; // empty range
}
Projections
Ranges algorithms support projections — a function applied to each element before the algorithm processes it. This eliminates the need for custom comparators in many cases.
#include <algorithm>
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
struct Employee {
std::string name;
int age;
double salary;
};
int main() {
std::vector<Employee> team = {
{"Alice", 30, 75000},
{"Bob", 25, 65000},
{"Charlie", 35, 85000}
};
// Sort by salary — projection replaces custom comparator
std::ranges::sort(team, {}, &Employee::salary);
// {} = default comparison (less), projection = extract salary
for (const auto& e : team) {
std::cout << e.name << ": $" << e.salary << "
";
}
// Bob: $65000, Alice: $75000, Charlie: $85000
// Sort by name descending
std::ranges::sort(team, std::greater{}, &Employee::name);
// Find by name
auto it = std::ranges::find(team, "Alice", &Employee::name);
if (it != team.end()) {
std::cout << "Found: " << it->name << "
";
}
// Max salary
auto richest = std::ranges::max(team, {}, &Employee::salary);
std::cout << "Highest paid: " << richest.name << "
";
}
Composing Complex Pipelines
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
#include <algorithm>
#include <numeric>
#include <cctype>
struct LogEntry {
std::string level; // "INFO", "WARN", "ERROR"
std::string message;
int timestamp;
};
int main() {
std::vector<LogEntry> logs = {
{"INFO", "Server started", 1000},
{"ERROR", "Connection refused", 1001},
{"INFO", "Request received", 1002},
{"WARN", "High memory usage", 1003},
{"ERROR", "Disk full", 1004},
{"INFO", "Request completed", 1005},
{"ERROR", "Timeout exceeded", 1006},
};
// Pipeline: get error messages, most recent first, limit to 2
auto recent_errors = logs
| std::views::filter([](const LogEntry& e) {
return e.level == "ERROR";
})
| std::views::reverse
| std::views::take(2)
| std::views::transform([](const LogEntry& e) {
return e.message;
});
std::cout << "Recent errors:
";
for (const auto& msg : recent_errors) {
std::cout << " - " << msg << "
";
}
// Timeout exceeded, Disk full
// Pipeline: enumerate with index
for (auto [idx, entry] : logs | std::views::enumerate) {
// std::views::enumerate is C++23
// For C++20, use views::iota + views::zip or manual counter
}
// keys and values views for maps
#include <map>
std::map<std::string, int> scores = {{"A", 90}, {"B", 85}, {"C", 95}};
for (const auto& key : scores | std::views::keys) {
std::cout << key << " ";
}
for (int val : scores | std::views::values) {
std::cout << val << " ";
}
}
Converting Views to Containers
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even_squares = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
// C++23: ranges::to (the elegant way)
// auto result = even_squares | std::ranges::to<std::vector>();
// C++20: manual conversion
std::vector<int> result(even_squares.begin(), even_squares.end());
for (int n : result) {
std::cout << n << " "; // 4 16 36 64 100
}
}
Performance
Views have zero allocation overhead — they don't copy data or create temporary containers. Each view stores only the source range reference and the operation (predicate, transform function, count). When you iterate, each element passes through the pipeline one at a time.
However, views aren't always faster than materialized containers. Repeated iteration of a filtered view re-evaluates the predicate every time. If you iterate the same pipeline multiple times, converting to a vector first (one allocation) can be faster than re-filtering each time. For single-pass processing — the common case — views are optimal.
The pipe operator itself has zero cost. It constructs view objects at compile time. The resulting code often optimizes to the same machine instructions as hand-written loops, because the compiler can see through the view abstractions.
Common Pitfalls
#include <ranges>
#include <vector>
// PITFALL 1: Dangling views
auto bad_pipeline() {
std::vector<int> local = {1, 2, 3, 4, 5};
return local | std::views::filter([](int n) { return n > 2; });
// The view references 'local', which is destroyed!
// Undefined behavior when caller iterates
}
// FIX: Return an owning container instead
std::vector<int> good_pipeline() {
std::vector<int> local = {1, 2, 3, 4, 5};
auto view = local | std::views::filter([](int n) { return n > 2; });
return std::vector<int>(view.begin(), view.end());
}
// PITFALL 2: Views don't cache — each iteration re-computes
// If you iterate the same filter view 10 times, the predicate
// runs 10 times per element. Materialize first if reusing.
// PITFALL 3: Not all views are bidirectional
// views::filter is forward-only on forward ranges
// Calling .back() or reverse iteration may not compile
Practice Exercises
Exercise 1: Create a pipeline that takes a vector of integers, filters out negatives, doubles each value, and takes the first 5 results.
Exercise 2: Use std::views::iota to generate the first 20 Fibonacci-adjacent values: all numbers from 1 to 100 that are not divisible by 3 or 5.
Exercise 3: Given a vector of strings, build a pipeline that filters strings containing "error" (case-insensitive), transforms them to uppercase, and reverses the order.
Exercise 4: Write a function that takes a vector of Employee structs and uses ranges with projections to find the three highest-paid employees, returning their names as a vector of strings.
C++20 ranges transform how you work with collections. The pipe syntax makes complex data processing readable, and views eliminate unnecessary copies. Combined with structured bindings for unpacking and lambdas for inline logic, ranges complete modern C++'s functional programming toolkit. Next, we'll cover a fundamental skill every C++ programmer needs: reading and writing files.