Skip to content
C++

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)

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

#endif

Structured 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)

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

#endif

views::enumerate (C++23)

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>();

#endif

views::adjacent<N> (C++23)

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"

#endif

views::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)

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"

#endif

The 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)

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

#endif

The 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)

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
views::slide vs views::adjacent<N>
Propertyviews::slide(n)views::adjacent<N>
Window widthRuntime valueCompile-time constant
Element typeSubrange viewstd::tuple of N elements
Structured bindingNo (range, not tuple)Yes — auto [a, b, c]
Use whenWindow size is dynamic, need iterationWindow size is fixed, need named elements

views::stride (C++23)

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

#endif

views::chunk (C++23)

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
}

#endif

views::chunk_by (C++23)

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)

#endif

C++23 views at a glance

AdaptorStdWhat it doesFeature macro
views::keysC++20First element of each pair/tuple
views::valuesC++20Second element of each pair/tuple
views::elements<I>C++20I-th element of each tuple
views::joinC++20Flatten range-of-ranges
views::splitC++20/23Split range by delimiter into subranges
views::zipC++23N ranges → range of N-tuples__cpp_lib_ranges_zip
views::zip_transformC++23N ranges + fn → range of fn(e₁,e₂,…)__cpp_lib_ranges_zip
views::enumerateC++23Range → (index, value) pairs__cpp_lib_ranges_enumerate
views::adjacent<N>C++23Overlapping N-tuples of consecutive elements__cpp_lib_ranges_zip
views::adjacent_transform<N>C++23adjacent<N> + apply function to each window__cpp_lib_ranges_zip
views::join_withC++23Flatten with delimiter between sub-ranges__cpp_lib_ranges_join_with
views::cartesian_productC++23All combinations of N ranges as tuples__cpp_lib_ranges_cartesian_product
views::slideC++23Overlapping windows of width n (runtime)__cpp_lib_ranges_slide
views::strideC++23Every n-th element__cpp_lib_ranges_stride
views::chunkC++23Non-overlapping chunks of size n__cpp_lib_ranges_chunk
views::chunk_byC++23Predicate-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

#endif

Each 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.

Sign in to track progress