C++20 Course

Build a C++ Ranges Pipeline: C++20 Views, Then C++23, With the Compiler Matrix

One small program, built twice. First in C++20, with filter, transform, drop, and take over a named owner container. Then in C++23, with enumerate, zip, chunk_by, slide, stride, and cartesian_product, ending every pipeline in ranges::to. Every listing was compiled and run on GCC 13, GCC 14, GCC 16, and Clang 18, and the two shapes that fail are left in with their exact output.

What you will build

A pipeline over log records. Each record has a level, a message, and a numeric code. The program answers small questions about those records: which error codes appeared, in what order, grouped by level, in moving windows, sampled, and paired with a second sequence.

The same program is built twice. The C++20 build uses filter, transform, drop, and take, and materializes results the C++20 way. The C++23 build adds enumerate, zip, chunk_by, slide, stride, and cartesian_product, and ends in ranges::to. Nothing in the C++23 half is used where the C++20 half already does the job.

Two facts set the shape of this guide. First, the incumbent C++ tutorial site lists both topics as unwritten: LearnCpp's B.4 — Introduction to C++20 still carries "Ranges (no tutorial yet)" and "Views (no tutorial yet)", confirmed on this page's build day. Second, the compiler matrix has a surprise in it: every C++23 view taught here already runs on GCC 13 and Clang 18 at -std=c++23. Only the two materialization forms have version floors. The matrix section below shows the numbers.

A view is lazy. It describes a computation over a range; it does not hold the results. That is the whole design, and it is also where the bugs live: a view borrows its owner, so the owner has to outlive the view. The last section runs the two failing shapes and quotes what actually happened.

The records

Eight log records, kept in a named std::vector. Every pipeline in this guide starts from this owner, and the owner stays alive for the whole program. That is the rule the dangling section will come back to.

#include <algorithm>
#include <iostream>
#include <numeric>
#include <ranges>
#include <string>
#include <vector>
#include <version>

namespace rv = std::ranges::views;
namespace rg = std::ranges;

struct Log {
  std::string level;
  std::string msg;
  int code;
};

static std::vector<Log> sample() {
  return {{"INFO", "boot", 100},      {"ERROR", "disk full", 201},
          {"INFO", "retry", 102},     {"ERROR", "timeout", 202},
          {"ERROR", "timeout", 203},  {"WARN", "slow", 301},
          {"INFO", "ok", 103},        {"ERROR", "disk full", 204}};
}

int main() {
  std::vector<Log> logs = sample();
  // ...each stage below runs here, in order...
  return 0;
}

The #include <version> line is not decoration. The feature-test macros in the matrix section only show up reliably when <version> is included. A probe run without it reported __cpp_lib_ranges as absent on GCC 14 and GCC 16, which was false: the feature was there, the macro was not visible. Include <version> before testing any macro.

The C++20 build: filter, transform, drop, take

The question: the error codes, skipping the first error, taking the next three. filter keeps the records whose level is ERROR. Per the cppreference filter_view page, a filter view is a view of the underlying sequence without the elements that fail the predicate. transform turns each surviving record into its code. drop(1) skips the first, take(3) keeps the next three. All four are C++20 adapters, listed on the cppreference <ranges> header page.

auto err_codes = logs | rv::filter([](const Log& l) { return l.level == "ERROR"; }) |
                 rv::transform([](const Log& l) { return l.code; });
std::vector<int> c20;
rg::copy(err_codes | rv::drop(1) | rv::take(3), std::back_inserter(c20));

Output, identical on all four compilers at both -std=c++20 and -std=c++23:

c++20 drop1-take3 error codes: 202 203 204

The error codes in order are 201, 202, 203, 204. Dropping one leaves 202, 203, 204, and taking three keeps all of them.

Two things to notice. First, err_codes is a view, not a container. It holds no codes; it reads them from logs when iterated. Second, the result is materialized with ranges::copy into a named std::vector. That is the C++20 way to get a container out of a pipeline: copy the view into something that owns its elements.

The other C++20 materialization form is the iterator-pair constructor. It works here because the filtered, transformed view over a vector is a common range, so begin and end have the same type. Verified on all four compilers at -std=c++20:

auto err = logs | rv::filter([](const Log& l) { return l.level == "ERROR"; }) |
           rv::transform([](const Log& l) { return l.code; });
std::vector<int> v(err.begin(), err.end());  // 201 202 203 204

Either form is fine. The rule that matters is the same for both: the owner logs must still exist when the view is read. filter_view caches the position of its first matching element, so reading the view after the owner is gone is not just wrong, it is wrong in a way that depends on what the cache held.

The C++23 upgrade: enumerate and zip

enumerate pairs each element with its zero-based index. Per the cppreference enumerate_view page, the element at index i is a tuple holding that index and a reference to the underlying element. It is a C++23 view, feature-test macro __cpp_lib_ranges_enumerate, value 202302L.

for (auto [i, l] : logs | rv::enumerate) {
  std::cout << "  " << i << ' ' << l.level << ' ' << l.msg << "\n";
}
enumerate:
  0 INFO boot
  1 ERROR disk full
  2 INFO retry
  3 ERROR timeout
  4 ERROR timeout
  5 WARN slow
  6 INFO ok
  7 ERROR disk full

zip pairs corresponding elements from two sequences. Per the cppreference zip_view page, the size of the produced view is the minimum of the sizes of the adapted views: it stops at the shortest input. Here both inputs have eight elements, so all eight pairs appear. It is a C++23 view, macro __cpp_lib_ranges_zip, value 202110L.

auto levels = logs | rv::transform([](const Log& l) { return l.level; });
auto codes = logs | rv::transform([](const Log& l) { return l.code; });
for (auto [lv, c] : rv::zip(levels, codes)) std::cout << "  " << lv << '=' << c << "\n";
zip level/code:
  INFO=100
  ERROR=201
  INFO=102
  ERROR=202
  ERROR=203
  WARN=301
  INFO=103
  ERROR=204

Both enumerate and zip produce tuples of references, not copies. Mutating through a zipped element mutates the underlying record. In this program the loop only reads, so that does not come up, but it is the same borrowing rule as everywhere else in this guide: the view does not own anything.

Grouping with chunk_by

chunk_by splits a view into smaller ranges wherever a predicate over adjacent elements returns false. Per the cppreference chunk_by_view page, the first element of each such pair belongs to the previous chunk and the second to the next. It is a C++23 view, macro __cpp_lib_ranges_chunk_by, value 202202L.

The catch is the word "adjacent". chunk_by groups neighbors, not equals anywhere in the sequence. The records have to be sorted by level first, or the ERROR records split into several chunks with INFO and WARN records between them.

std::vector<Log> sorted = logs;
rg::sort(sorted, std::less<>{}, &Log::level);
for (auto chunk : sorted | rv::chunk_by([](const Log& a, const Log& b) { return a.level == b.level; })) {
  std::cout << "  " << chunk.front().level << " x" << rg::distance(chunk) << "\n";
}
chunk_by level (sorted):
  ERROR x4
  INFO x3
  WARN x1

Two limits from the same cppreference page are worth knowing before reaching for this view. chunk_by_view never models borrowed_range or sized_range: you cannot ask it how many chunks there are without iterating, and an algorithm will not return an iterator into it from a temporary. And begin() caches its result inside the view object, so modifying the underlying range after the first call to begin() can behave unexpectedly. Sort first, then chunk, then do not touch the input until the chunks are consumed.

Moving windows with slide

slide produces a view whose element at position m is a window over elements m through m + n - 1 of the original view. Per the cppreference slide_view page, a view of size s gives s - n + 1 windows when s >= n, and an empty view otherwise. It is a C++23 view, macro __cpp_lib_ranges_slide, value 202202L.

std::vector<int> nums{100, 201, 102, 202, 203, 301, 103, 204};
for (auto w : nums | rv::slide(3)) {
  std::cout << "  [";
  bool first = true;
  for (int i : w) {
    if (!first) std::cout << ',';
    std::cout << i;
    first = false;
  }
  std::cout << "]\n";
}
slide(3) over codes:
  [100,201,102]
  [201,102,202]
  [102,202,203]
  [202,203,301]
  [203,301,103]
  [301,103,204]

Eight codes, window size three, six windows: 8 - 3 + 1. The windows overlap; each window is itself a view over the same nums vector, not a copy. One hard rule from the same page: the window size n must be greater than zero, or the behavior is undefined. There is no diagnostic for slide(0).

Sampling with stride, combining with cartesian_product

stride keeps every element whose index is a multiple of n, starting at the first. Per the cppreference stride_view page, the element at position m of the result is the element at position n * m of the original. It is a C++23 view, macro __cpp_lib_ranges_stride, value 202207L. Like slide, the stride must be greater than zero or the behavior is undefined.

std::vector<int> out;
rg::copy(nums | rv::stride(2), std::back_inserter(out));  // 100 102 203 103
stride(2) over codes: 100 102 203 103

Indices 0, 2, 4, 6 of the eight codes. This is the sampling step of the pipeline: every second code.

cartesian_product combines two or more ranges into every combination. Per the cppreference cartesian_product_view page, the size of the result is a multiple of the sizes of the provided ranges, and each element is a tuple of references. It is a C++23 view, macro __cpp_lib_ranges_cartesian_product, value 202207L.

for (auto [lv, n] : rv::cartesian_product(std::vector<std::string>{"ERROR", "WARN"},
                                          std::vector<int>{1, 2})) {
  std::cout << "  " << lv << ',' << n << "\n";
}
cartesian_product levels x {1,2}:
  ERROR,1
  ERROR,2
  WARN,1
  WARN,2

Two levels times two numbers gives four pairs. This is the one view in the guide used for a genuinely combinatorial job — pairing each level with each retry count — rather than for reshaping a single sequence. The size of a Cartesian product grows fast: two ranges of eight would already be sixty-four pairs, which is why the example keeps both inputs small.

One detail from the same page: the first range passed to cartesian_product_view is treated specially and only needs to be an input range, while the rest must be forward ranges. In this program both inputs are vectors, so the distinction does not bite, but it matters if the first input is a single-pass range.

Ending every pipeline in ranges::to

ranges::to is the C++23 way to materialize a view into a container. Per the cppreference ranges::to page, it constructs a new non-view object from an input range, trying in order: a constructor taking the range, a std::from_range-tagged constructor, an iterator-pair constructor, and back-insertion element by element. When the source is a sized range and the container can reserve, it reserves first. It is a C++23 function, macro __cpp_lib_ranges_to_container, value 202202L.

The same error-code pipeline as the C++20 build, ending in ranges::to instead of ranges::copy:

auto errs = logs | rv::filter([](const Log& l) { return l.level == "ERROR"; }) |
            rv::transform([](const Log& l) { return l.code; }) | rg::to<std::vector<int>>();

On GCC 14 and GCC 16 at -std=c++23:

ranges::to error codes: 201 202 203 204

On GCC 13 and Clang 18 the macro is absent and the line does not compile. The C++20 forms from the first section are the fallback there.

The pipe form has one trap, stated on the same cppreference page: the parentheses are mandatory. ranges::to<std::vector<int>> without () is not a range adapter closure, it is an unresolved overload, and the pipe fails. Verified on GCC 16:

auto v = std::views::iota(1, 5)
       | std::views::transform([](int n) { return n * 2; })
       | std::ranges::to<std::vector<int>>();  // 2 4 6 8

Without the parentheses, the same program fails with:

error: no match for 'operator|' (operand types are 'std::ranges::transform_view<std::ranges::iota_view<int, int>, main()::<lambda(int)> >' and '<unresolved overloaded function type>')

The second materialization form is the from_range constructor, tagged with std::from_range (namespace std, not std::ranges — that mistake cost a compile in the probe run). It is the constructor ranges::to prefers when it is available:

auto code_view = logs | rv::transform([](const Log& l) { return l.code; });
std::vector<int> from_range_vec(std::from_range, code_view);

On GCC 16 at -std=c++23:

from_range ctor codes: 100 201 102 202 203 301 103 204

On the other three compilers the macro __cpp_lib_containers_ranges is absent and the constructor does not exist. The full ladder, from most portable to least: iterator-pair constructor and ranges::copy work everywhere here; ranges::to needs GCC 14 or newer of the compilers tested; the from_range constructor needs GCC 16.

The compiler matrix

The same program — the C++20 stage plus every C++23 stage, each guarded by its feature-test macro — was compiled with -Wall -Wextra and run on four compilers at -std=c++20 and -std=c++23. At -std=c++20, __cpp_lib_ranges reads 202110 on all four, every C++23 macro reads absent, and only the C++20 stage runs. At -std=c++23, the macros read (values in GCC 13 / GCC 14 / GCC 16 / Clang 18 order):

  • __cpp_lib_ranges: 202202 / 202211 / 202302 / 202202
  • __cpp_lib_ranges_chunk (chunk_by, slide): 202202 / 202202 / 202202 / 202202
  • __cpp_lib_ranges_stride (cartesian_product, repeat): 202207 / 202207 / 202207 / 202207
  • __cpp_lib_ranges_enumerate: 202302 / 202302 / 202302 / 202302
  • __cpp_lib_ranges_zip: 202110 / 202110 / 202110 / 202110
  • __cpp_lib_ranges_join_with: 202202 / 202202 / 202202 / 202202
  • __cpp_lib_ranges_as_const: 202207 / 202311 / 202311 / 202207
  • __cpp_lib_ranges_to_container (ranges::to): ABSENT / 202202 / 202202 / ABSENT
  • __cpp_lib_containers_ranges (from_range constructors): ABSENT / ABSENT / 202202 / ABSENT

The program output is identical on all four compilers at -std=c++23: the C++20 stage prints 202 203 204, enumerate lists all eight records, zip pairs all eight, chunk_by groups four ERROR records, three INFO, one WARN, slide(3) prints six windows, stride(2) prints 100 102 203 103, and cartesian_product prints four pairs. The only difference is the last two lines: ranges::to prints its codes on GCC 14 and GCC 16 and reports absent on GCC 13 and Clang 18; the from_range constructor prints its codes on GCC 16 only.

The surprise is how little differs. GCC 13 and Clang 18 already ship the whole taught C++23 views set. Clang 18 on this box uses the system libstdc++ 13 headers, which is why its column matches GCC 13's. The version floors that matter are the two materialization forms, not the views. A guide that claims ranges::to needs a newer compiler for the views themselves would be wrong on this evidence.

Not tested here: MSVC, libc++, and GCC 15. The Clang-against-newer-libstdc++ combination was also tried in the probe run and does not work on this box: Clang 18 against the GCC 16 headers fails to compile even a trivial program, with incomplete-type errors inside libstdc++ 16. Do not mix them.

One more boundary to keep straight: the <ranges> header page lists concat, cache_latest, as_input, and indices as C++26 entries. They are not C++23, they are not taught here, and none of them compiled in this program.

Dangling: the shapes that fail

A view borrows its owner. The cppreference borrowed_range page states the rule as a concept: a range is a borrowed_range when iterators obtained from it can be safely returned without danger of dangling. An lvalue range qualifies automatically. A temporary container does not — unless the view itself takes ownership. The three shapes below were all executed on all four compilers at -Wall -Wextra.

The piped temporary is safe

This is the shape most people expect to fail, and it does not. A temporary vector piped into filter prints the right answer on every compiler:

auto v = std::vector<int>{1, 2, 3, 4, 5} |
         rv::filter([](int i) { return i % 2 == 0; });
// temp-pipe evens: 2 4

The pipe keeps the temporary alive. views::all on an rvalue non-view range produces an owning_view, which moves the temporary into the view itself. Per the cppreference owning_view page, an owning_view has unique ownership of a range and stores it within itself; it is move-only. So the temporary is not destroyed at the end of the full expression — it is owned by the view. Do not present this shape as a bug. The bugs are the next two.

The returned view: compiles silently, fails at run time

A function that builds a view over a local vector and returns it compiles on all four compilers with no warning at -Wall -Wextra. The vector is destroyed when the function returns, and the view dangles. What the program prints after that is undefined, and it differs per compiler:

auto first_two() {
  std::vector<int> v{10, 20, 30};
  return v | rv::take(2);
}

int main() {
  std::cout << "returned-view:";
  for (int i : first_two()) std::cout << ' ' << i;
  std::cout << "\n";
  return 0;
}

Output from this guide's verification run:

GCC 13:   returned-view: -1045023232 32764 -1045023240 32764 646671688 23578 -1045023240 32764 -1045023184 32764
GCC 14:   returned-view:
GCC 16:   returned-view: 1233906148 6
Clang 18: returned-view: -129660600 -129660088 549749576 ... (hundreds of garbage integers)
          Segmentation fault (core dumped), run rc=139

GCC 13 printed ten garbage integers — take(2) ran past the end of a two-element view because the underlying storage was already freed. GCC 14 printed nothing at all. GCC 16 printed two garbage integers. Clang 18 printed hundreds of garbage integers and then segfaulted. The exact garbage changes from run to run; the numbers above are from one run, not a stable result. The stable fact is the shape: the compiler will not save you here. No diagnostic fires, and the failure mode is run-varying garbage or a segfault, never an error message.

The fix is to return something that owns its elements. Either materialize before returning:

std::vector<int> first_two() {
  std::vector<int> v{10, 20, 30};
  return v | rv::take(2) | rg::to<std::vector<int>>();  // needs ranges::to; see the matrix
}

or take the owner by reference and return a view over the caller's data, with the caller keeping the owner alive:

auto first_two(const std::vector<int>& v) {
  return v | rv::take(2);  // a ref_view over the caller's vector
}

The algorithm over a temporary returns dangling

The standard library does protect this shape — at compile time, for algorithms. std::ranges::find over a temporary vector returns std::ranges::dangling, not an iterator. Per the cppreference <ranges> header page, ranges::dangling is a placeholder type indicating that an iterator or a subrange should not be returned since it would be dangling. A static_assert proves the return type on all four compilers:

auto it = std::ranges::find(std::vector<int>{1, 2, 3}, 2);
static_assert(std::is_same_v<decltype(it), std::ranges::dangling>,
              "find over a temporary must return dangling");

And dereferencing it fails to compile on all four:

GCC:   error: no match for 'operator*' (operand type is 'std::ranges::dangling')
Clang: error: indirection requires pointer operand ('borrowed_iterator_t<vector<int, allocator<int>>>' (aka 'std::ranges::dangling') invalid)

This is the asymmetry to remember: algorithms return dangling and the misuse fails to compile, but a view returned by value over a local owner compiles silently and fails at run time. The protection exists where the library can see the temporary at the call site. A returned view hides the temporary behind a function boundary, and nothing checks it.

The fixes, executed

Three ways to hold a view safely, all verified on all four compilers:

std::vector<int> named{1, 2, 3, 4, 5};
auto r = rv::all(named) | rv::filter([](int i) { return i % 2 == 0; });
// ref_view evens: 2 4

auto o = rg::owning_view{std::vector<int>{6, 7, 8, 9, 10}} |
         rv::filter([](int i) { return i % 2 == 1; });
// owning_view odds: 7 9

std::vector<int> moved{11, 12, 13};
auto m = rv::all(std::move(moved)) | rv::take(2);
// all(move) take2: 11 12

views::all on a named lvalue gives a ref_view: per the cppreference ref_view page, a ref_view wraps a reference to the range, and it is unconditionally a borrowed_range. The view borrows, the named vector owns, and the vector outlives the view. An explicit owning_view moves a temporary into the view, so the view owns it. views::all(std::move(v)) does the same thing through the pipe: the moved-from vector is empty afterward, and the view holds the elements.

The rule in one sentence: a view is safe when its owner is either named and still alive, or moved into the view. Every other shape needs a second look.

What breaks, and the fix

| Error or output | Cause | Fix | | error: 'to' is not a member of 'std::ranges' | ranges::to on GCC 13 or Clang 18 (libstdc++ 13 has no ranges::to) | Materialize with ranges::copy or the iterator-pair constructor, or move to GCC 14 or newer | | error: no matching function for call to 'std::vector<int>::vector(const std::from_range_t&, ...)' | from_range constructor on GCC 14 or older | Use ranges::to on GCC 14 or newer, or the iterator-pair constructor | | error: no match for 'operator|' with <unresolved overloaded function type> | ranges::to used in a pipe without parentheses | Write ranges::to<std::vector<int>>() | | error: no match for 'operator*' (operand type is 'std::ranges::dangling') | Dereferencing an algorithm result over a temporary owner | Keep the owner in a named variable first, then run the algorithm | | Garbage integers, empty output, or a segfault, with no warning | A view returned over a local owner | Return the materialized container, or take the owner by reference | | A feature-test macro reads absent even though the feature compiles | <version> not included | Include <version> before testing macros |

Frequently asked questions

Can I use the C++23 views at -std=c++20?

No. At -std=c++20 every C++23 macro reads absent on all four compilers tested here, and the C++23 sections of the program do not compile. The C++20 stage — filter, transform, drop, take, materialized with ranges::copy or the iterator-pair constructor — runs at both standards.

Why does ranges::to need a newer compiler when the views do not?

Because the views and the materialization forms shipped in different library releases. The views in this guide are present in libstdc++ 13, which GCC 13 and Clang 18 use here. ranges::to arrived in libstdc++ 14, and the from_range constructors in libstdc++ 16. The standard version and the library version are not the same thing.

Is a view a container?

No. A view is a lazy description of a computation over a range. It holds no elements, and copying a view is cheap because it copies the description, not the data. If you need the results to outlive the owner, materialize them into a container.

What about MSVC, libc++, or GCC 15?

Not tested here. Every matrix cell in this guide comes from running code on the four compilers named in it. Do not read a cell for a compiler that is not in the table.

Should I sort before chunk_by?

Yes, if you want groups by value rather than groups by adjacency. chunk_by only looks at neighboring pairs. Unsorted input gives you as many chunks as there are runs of equal neighbors, which is usually not what you meant.

What comes next in this course?

The next guide covers the C++20 spaceship operator: one defaulted <=> replacing six comparison operators, with the three traps that break it.

Sources

Facts verified 2026-11-05. Every code listing on this page was compiled with -Wall -Wextra and run on GCC 13.3.0, GCC 14.2.0, GCC 16.2.0, and Clang 18.1.3 on Ubuntu 24.04, at -std=c++20 and -std=c++23 as noted. MSVC, libc++, and GCC 15 were not tested. Failures are quoted verbatim from the actual compiler output.

Keep Exploring

Keep exploring the docs, samples, and free tools to continue your evaluation.