Advanced Views: join, zip, cartesian_product & More
C++20 introduced the ranges library with foundational adaptors like filter, transform, and take. C++23 extended it substantially — adding two dozen new adaptors that cover multi-range operations, flattening, sliding windows, strided access, and chunking. This lesson covers all the important ones, grounded in the same lazy-evaluation model you already know.
The guiding principle is unchanged: views are non-owning windows into ranges. They compose with | and produce elements on demand. The C++23 additions simply expand the vocabulary of transformations you can express without writing a loop.
Tuple element views (C++20)
When a range contains tuple-like elements — pairs, tuples, or structs with structured bindings — three adaptors extract a specific element from each one. views::keys picks the first element, views::values picks the second, and views::elements<I> picks the I-th. This is especially useful with std::map and std::multimap, whose value type is std::pair<const Key, Value>.
#include <ranges>
#include <map>
#include <vector>
#include <tuple>
#include <print>
// --- keys / values on a map ---
std::map<std::string, int> scores{
{"Alice", 92}, {"Bob", 85}, {"Carol", 97}};
// iterate just the keys
for (const auto& name : scores | std::views::keys)
std::print("{} ", name); // Alice Bob Carol
// iterate just the values
for (int v : scores | std::views::values)
std::print("{} ", v); // 92 85 97
// --- elements<I> on a range of tuples ---
// tuple: Code3, Name, Code1, MolMass
using AATuple = std::tuple<std::string, std::string, char, double>;
std::vector<AATuple> aa_vec{ /* ... */ };
// view of the 3rd element (Code1, index 2) of each tuple
for (char code1 : std::views::elements<2>(aa_vec))
std::print("{} ", code1);views::elements<I> uses a compile-time index, so the type of the resulting view depends on the tuple's std::tuple_element at that position. All three adaptors produce lazy views — no copies are made.
views::zip (C++23)
views::zip takes two or more ranges and combines them element-by-element into a range of tuples. The i-th element of the zip view is a tuple containing the i-th element of each input range. The size of the resulting view is the size of the shortest input range — extra elements in longer ranges are silently ignored, which is intentional and consistent with the mathematical definition of a zip.
#include <ranges>
#include <vector>
#include <string>
#include <print>
#ifdef __cpp_lib_ranges_zip // C++23
std::vector<char> codes1 {'A','R','N','D','C'};
std::vector<std::string> codes3 {"Ala","Arg","Asn","Asp","Cys"};
std::vector<double> molmass{89.09, 174.20, 132.12, 133.10, 121.15};
// zip three ranges into a range of 3-element tuples
auto zipped = std::views::zip(codes1, codes3, molmass);
for (auto [c1, c3, mm] : zipped)
std::println("{:c} {:5s} {:7.3f}", c1, c3, mm);
// A Ala 89.094
// R Arg 174.203
// ...
// size is min of all inputs — safe even if lengths differ
std::vector<int> short_vec{1, 2};
auto safe_zip = std::views::zip(codes1, short_vec);
// safe_zip has 2 elements, not 5
#endifStructured bindings (auto [c1, c3, mm]) decompose each tuple element naturally. You can also use std::get<I>(element) for explicit index access.
views::zip_transform (C++23)
views::zip_transform does the same element-wise zipping as views::zip, but immediately applies a function to each tuple, yielding the function's return value rather than the tuple itself. This avoids building a tuple when you only need the computed result.
#include <ranges>
#include <numbers>
#include <vector>
#include <print>
#ifdef __cpp_lib_ranges_zip
// cylinder volumes from separate radii and heights vectors
auto cyl_vol = [](double r, double h) {
return std::numbers::pi * r * r * h;
};
std::vector<double> radii {1.5, 2.0, 3.0, 1.0};
std::vector<double> heights{4.0, 3.5, 2.0, 6.5};
// zip_transform: apply lambda to each (r, h) pair
auto volumes = std::views::zip_transform(cyl_vol, radii, heights);
for (double v : volumes)
std::println("{:8.3f}", v);
// 28.274 43.982 56.549 20.420
// Unlike views::zip | views::transform, no intermediate tuple allocation
#endifviews::enumerate (C++23)
Python programmers will recognise this immediately: views::enumerate annotates each element of a range with its zero-based position, producing a range of (index, value) pairs. The index type isstd::ptrdiff_t. The result can be piped into std::ranges::to<std::map>() to build a position-keyed map in one expression.
#include <ranges>
#include <vector>
#include <map>
#include <string>
#include <print>
#if defined(__cpp_lib_ranges_enumerate) && defined(__cpp_lib_ranges_to_container)
std::vector<std::string> names{"Alice", "Bob", "Carol", "Dave"};
// enumerate gives (index, value) pairs
for (const auto [i, name] : names | std::views::enumerate)
std::println("key: {:2d} value: {}", i, name);
// key: 0 value: Alice
// key: 1 value: Bob
// ...
// convert to std::map<ptrdiff_t, std::string> in one shot
auto name_map = names | std::views::enumerate
| std::ranges::to<std::map>();
#endifviews::adjacent<N> (C++23)
views::adjacent<N> produces a view where each element is an N-tuple of consecutive elements from the underlying range. For a range of size k, the result has k – N + 1 elements. When N = 2 it's equivalent to a sliding window of width two, letting you compute differences or ratios between consecutive items. views::pairwise is a convenient alias for the common adjacent<2> case.
#include <ranges>
#include <vector>
#include <string>
#include <print>
#ifdef __cpp_lib_ranges_zip // adjacent ships with the zip paper
std::vector<std::string> aa{"Ala","Arg","Asn","Asp","Cys","Gln"};
// adjacent<3>: groups of 3 overlapping elements
auto triplets = aa | std::views::adjacent<3>;
// size = 6 - 3 + 1 = 4
for (const auto [a, b, c] : triplets)
std::println("[{:3s} {:3s} {:3s}]", a, b, c);
// [Ala Arg Asn]
// [Arg Asn Asp]
// [Asn Asp Cys]
// [Asp Cys Gln]
// adjacent_transform: apply a function to each window
auto concat4 = [](const std::string& a, const std::string& b,
const std::string& c, const std::string& d){
return a+"|"+b+"|"+c+"|"+d;
};
auto joined4 = aa | std::views::adjacent_transform<4>(concat4);
// "Ala|Arg|Asn|Asp", "Arg|Asn|Asp|Cys", "Asn|Asp|Cys|Gln"
#endifviews::adjacent_transform<N> is to adjacent<N> what zip_transform is to zip — it fuses the windowing and the computation.
views::join — flattening a range of ranges
views::join flattens a range whose elements are themselves ranges into a single flat range. It is the lazy equivalent of writing a nested loop: the outer loop iterates sub-ranges, the inner loop iterates their elements, and join splices those iterations together transparently. No copies are made — the resulting view borrows elements directly from the sub-ranges.
#include <ranges>
#include <vector>
#include <string>
#include <print>
// flatten a vector of vectors
std::vector<std::vector<int>> matrix{
{1, 2, 3},
{4, 5},
{6, 7, 8, 9}
};
for (int n : matrix | std::views::join)
std::print("{} ", n); // 1 2 3 4 5 6 7 8 9
// flatten a vector of strings into individual characters
std::vector<std::string> words{"hello", " ", "world"};
for (char c : words | std::views::join)
std::print("{}", c); // hello world
// composing with transform: flatten then uppercase
auto upper = words | std::views::join
| std::views::transform([](char c){
return static_cast<char>(std::toupper(c));
});views::join_with (C++23)
views::join_with is views::join with a delimiter inserted between each consecutive pair of sub-ranges. The delimiter can be a single element or another range. This makes it the lazy equivalent of std::string::join in Python — without allocating a result string upfront.
#include <ranges>
#include <vector>
#include <string>
#include <print>
#ifdef __cpp_lib_ranges_join_with // C++23
using namespace std::string_literals;
std::vector<std::string> parts{"alpha", "beta", "gamma"};
// join with a pipe character between each part
for (char c : parts | std::views::join_with('|'))
std::print("{}", c); // alpha|beta|gamma
// join with a multi-char delimiter
for (char c : parts | std::views::join_with(", "s))
std::print("{}", c); // alpha, beta, gamma
// practical: rebuild a CSV row from a vector of field strings
std::vector<std::string> fields{"2024","Alice","92"};
std::string csv;
for (char c : fields | std::views::join_with(','))
csv += c; // "2024,Alice,92"
#endifThe difference from plain join is purely the delimiter — join_with(d) inserts d between sub-ranges but not after the last one. The result is a character range (when joining strings), not a std::string — use std::ranges::to<std::string>() to materialise.
views::split — the inverse of join
Where join flattens a range of ranges, split goes the other direction: it takes a single range and a delimiter, and produces a range of sub-ranges separated by that delimiter. This was redesigned in C++23 (paper P2210R2) to work cleanly with string inputs — the sub-ranges are now proper sub-views you can directly convert to strings. views::lazy_split preserves the original C++20 semantics for forward-only ranges.
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
#include <print>
std::string csv = "2024,Alice,92,New York";
// split by ',' — each element is a subrange view into csv
for (auto token : csv | std::views::split(',')) {
// convert the subrange to a string_view for printing
std::string_view sv{token.begin(), token.end()};
std::println("{}", sv);
}
// 2024
// Alice
// 92
// New York
// split on a multi-char delimiter
std::string log = "INFO: started :: WARN: low memory :: ERROR: disk full";
for (auto part : log | std::views::split(std::string_view{" :: "})) {
std::string_view sv{part.begin(), part.end()};
std::println("[{}]", sv);
}
// [INFO: started]
// [WARN: low memory]
// [ERROR: disk full]views::cartesian_product (C++23)
The Cartesian product of N ranges is the set of all N-tuples where the i-th element is drawn from the i-th range. For ranges of sizes s₁, s₂, …, sₙ, the result has s₁ × s₂ × … × sₙ elements. views::cartesian_product generates these lazily — it never materialises the full set in memory, making it practical even for very large combination spaces. The iteration order is: the last range varies fastest (right-to-left), matching the natural nested-loop order.
#include <ranges>
#include <vector>
#include <string>
#include <print>
#ifdef __cpp_lib_ranges_cartesian_product // C++23
// pizza configurator: all combinations of (size, crust, topping)
std::vector<std::string> sizes {"Small", "Medium", "Large"};
std::vector<std::string> crusts {"Thin", "Thick", "Stuffed"};
std::vector<std::string> toppings{"Cheese", "Pepperoni", "Veggie", "BBQ"};
// 3 × 3 × 4 = 36 combinations, generated lazily
auto combos = std::views::cartesian_product(sizes, crusts, toppings);
for (const auto& [sz, cr, tp] : combos)
std::println("{:6s} {:7s} {}", sz, cr, tp);
// Small Thin Cheese
// Small Thin Pepperoni
// ...
// Large Stuffed BBQ
// count without materialising
auto n = std::ranges::distance(combos); // 36
#endifThe laziness matters when the product is huge. If sizes had 100 elements and each nested range had 100, the full product would be a million tuples — fine to iterate lazily, catastrophic to allocate. Structured bindings decompose each tuple cleanly.
views::slide (C++23)
views::slide(n) produces a view whose elements are overlapping windows of width n over the underlying range. For a range of k elements, the result has k – n + 1 windows. Each window is itself a view, not a copy — it refers to a contiguous slice of the underlying range. This is the key tool for moving-average calculations, n-gram extraction, and any algorithm that needs to inspect several consecutive elements at once without managing indices manually.
#include <ranges>
#include <vector>
#include <numeric>
#include <print>
#ifdef __cpp_lib_ranges_slide // C++23
std::vector<int> prices{100, 102, 99, 105, 108, 103, 107};
// 3-day moving average
for (auto window : prices | std::views::slide(3)) {
double avg = std::accumulate(window.begin(), window.end(), 0.0)
/ window.size();
std::println("{:.1f}", avg);
}
// 100.3 102.0 104.0 105.3 106.0
// extract all bigrams from a string
std::string text = "ACGT";
for (auto bigram : text | std::views::slide(2)) {
for (char c : bigram) std::print("{}", c);
std::print(" ");
}
// AC CG GT
#endif| Property | views::slide(n) | views::adjacent<N> |
|---|---|---|
| Window width | Runtime value | Compile-time constant |
| Element type | Subrange view | std::tuple of N elements |
| Structured binding | No (range, not tuple) | Yes — auto [a, b, c] |
| Use when | Window size is dynamic, need iteration | Window size is fixed, need named elements |
views::stride (C++23)
views::stride(n) selects every n-th element starting from the first: elements[0], elements[n], elements[2n], and so on. It is the range-based equivalent of Python's seq[::n] slice syntax. A stride of 1 is the whole range; a stride of 2 selects every other element (e.g., only even-indexed items). The key distinction from views::filter is that stride operates on position, not value.
#include <ranges>
#include <vector>
#include <print>
#ifdef __cpp_lib_ranges_stride // C++23
std::vector<int> v{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
// every 3rd element starting from index 0
for (int n : v | std::views::stride(3))
std::print("{} ", n); // 0 3 6 9
// interleaved data: RGBRGBRGB — extract the R channel
std::vector<uint8_t> rgb{255, 0, 0, 128, 64, 32, 0, 255, 0};
auto reds = rgb | std::views::stride(3);
// reds yields: 255, 128, 0
// composable: every other even number in range [0,20)
auto result = std::views::iota(0, 20)
| std::views::filter([](int n){ return n % 2 == 0; })
| std::views::stride(2);
// 0 4 8 12 16
#endifviews::chunk (C++23)
views::chunk(n) partitions the underlying range into non-overlapping pieces of exactly n elements each. The last piece may be smaller if the range's size is not evenly divisible by n. Unlike views::slide, the windows do not overlap — each element appears in exactly one chunk. This makes it the right tool for batch processing, pagination, or splitting work across threads.
#include <ranges>
#include <vector>
#include <print>
#ifdef __cpp_lib_ranges_chunk // C++23
std::vector<int> data{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// split into chunks of 3
for (auto chunk : data | std::views::chunk(3)) {
std::print("[");
for (int n : chunk) std::print("{} ", n);
std::println("]");
}
// [1 2 3 ]
// [4 5 6 ]
// [7 8 9 ]
// [10 ] <- last chunk is smaller (10 % 3 = 1)
// batch HTTP requests: send 50 IDs at a time
std::vector<int> ids = /* ... */ {};
for (auto batch : ids | std::views::chunk(50)) {
send_batch(batch); // each batch is a view, not a copy
}
#endifviews::chunk_by (C++23)
Where views::chunk(n) uses a fixed size, views::chunk_by(pred) uses a predicate to decide where chunk boundaries fall. A new chunk starts whenever the predicate returns false for two consecutive elements. This is the range equivalent of std::ranges::equal_range extended to work lazily over any consecutive-grouping rule, not just equality.
#include <ranges>
#include <vector>
#include <print>
#ifdef __cpp_lib_ranges_chunk_by // C++23
// group consecutive equal elements
std::vector<int> rle{1, 1, 1, 2, 2, 3, 1, 1};
for (auto chunk : rle | std::views::chunk_by(std::equal_to<>{})) {
std::print("[");
for (int n : chunk) std::print("{} ", n);
std::print("] ");
}
// [1 1 1 ] [2 2 ] [3 ] [1 1 ]
// group consecutive ascending runs
std::vector<int> nums{1, 2, 4, 3, 5, 6, 2, 7};
for (auto run : nums | std::views::chunk_by(std::less_equal<>{})) {
std::print("[");
for (int n : run) std::print("{} ", n);
std::print("] ");
}
// [1 2 4 ] [3 5 6 ] [2 7 ]
// run-length encoding with chunk_by + transform
auto rle_encode = rle | std::views::chunk_by(std::equal_to<>{})
| std::views::transform([](auto chunk){
auto it = chunk.begin();
int val = *it;
int count = std::ranges::distance(chunk);
return std::pair{count, val};
});
// (3,1), (2,2), (1,3), (2,1)
#endifC++23 views at a glance
| Adaptor | Std | What it does | Feature macro |
|---|---|---|---|
| views::keys | C++20 | First element of each pair/tuple | — |
| views::values | C++20 | Second element of each pair/tuple | — |
| views::elements<I> | C++20 | I-th element of each tuple | — |
| views::join | C++20 | Flatten range-of-ranges | — |
| views::split | C++20/23 | Split range by delimiter into subranges | — |
| views::zip | C++23 | N ranges → range of N-tuples | __cpp_lib_ranges_zip |
| views::zip_transform | C++23 | N ranges + fn → range of fn(e₁,e₂,…) | __cpp_lib_ranges_zip |
| views::enumerate | C++23 | Range → (index, value) pairs | __cpp_lib_ranges_enumerate |
| views::adjacent<N> | C++23 | Overlapping N-tuples of consecutive elements | __cpp_lib_ranges_zip |
| views::adjacent_transform<N> | C++23 | adjacent<N> + apply function to each window | __cpp_lib_ranges_zip |
| views::join_with | C++23 | Flatten with delimiter between sub-ranges | __cpp_lib_ranges_join_with |
| views::cartesian_product | C++23 | All combinations of N ranges as tuples | __cpp_lib_ranges_cartesian_product |
| views::slide | C++23 | Overlapping windows of width n (runtime) | __cpp_lib_ranges_slide |
| views::stride | C++23 | Every n-th element | __cpp_lib_ranges_stride |
| views::chunk | C++23 | Non-overlapping chunks of size n | __cpp_lib_ranges_chunk |
| views::chunk_by | C++23 | Predicate-based consecutive grouping | __cpp_lib_ranges_chunk_by |
Feature test macros let you guard C++23-only code with #ifdef when compiling with mixed-standard toolchains. All C++23 views require -std=c++23 (GCC 13+, Clang 17+, MSVC 19.38+).
Putting it together: a real pipeline
The power of views comes from composition. The following example uses zip, filter, and transform to build a salary report: pair employee names with their salaries, keep only high earners, and format each entry — all lazily, with no intermediate containers.
#include <ranges>
#include <vector>
#include <string>
#include <format>
#include <print>
#ifdef __cpp_lib_ranges_zip
std::vector<std::string> names{"Alice","Bob","Carol","Dave","Eve"};
std::vector<double> salaries{120000, 85000, 142000, 95000, 78000};
// zip → filter high earners → format for display
auto report = std::views::zip(names, salaries)
| std::views::filter([](const auto& p){
return std::get<1>(p) >= 100'000.0;
})
| std::views::transform([](const auto& p){
return std::format("{:<10s} ${:>10,.0f}",
std::get<0>(p), std::get<1>(p));
});
for (const std::string& line : report)
std::println("{}", line);
// Alice $ 120,000
// Carol $ 142,000
#endifEach adaptor in the chain is constructed in O(1) time. The actual work — iterating names and salaries together, testing each salary, and formatting the result — happens only inside the final range-for loop, element by element.