C++20 Ranges
Raw Loops to Ranges
Most C++ code does not begin with elegant abstractions. It begins with simple loops and a problem to solve. It starts where a great deal of C++ starts: a flat vector, a few nested loops, and a stack of intermediate containers that exist only to feed the next loop in line. As the code grows, patterns emerge, abstractions appear, and the language offers better ways to express intent. One such addition is C++20 ranges, which is what this post explores.
And there are many reasons why it took so long to become practical in production code. In any reasonably sized engineering organization, the choice of compiler is rarely driven by a single team. ABI compatibility requirements, toolchain support, third-party dependencies, and long-term maintenance concerns all have a say, and the lowest common denominator often wins. It is a shared contract. Every team, every internal library, and every third-party product linked into the build supports a particular set of compiler versions, and the binaries produced by all of them must continue to link and run together reliably.
For example, on the hybrid platforms we build at Cadence, models and IP from different sources are integrated into a single environment. Those components must work not only with each other, but also across multiple verification tools and hardware configurations that we support. Moving the entire ecosystem forward is therefore a coordinated effort, not a local decision.
These constraints are often what separate college-level software from production software that must survive and evolve over many years. That discipline is the reason systems built two decades ago still boot, still ship, and still pay the bills. Refusing to scatter bleeding-edge language features across a codebase that has to outlive any single engineer's tenure is not timidity. It is the kind of restraint that keeps large systems alive long enough to become load-bearing.
But tools do move, slowly and inevitably. Support for older operating systems and compilers eventually gets dropped, and those shifts often create both the need and the opportunity to move an entire ecosystem forward. As compilers, libraries, and tooling evolve together, the baseline rises. Being able to use C++20 in production code is now a practical reality for many of us, and that is what inspired this post
One Task, Three Ways
Let's look at a simple task. Given a list of people, each with a name and an age, we have to produce the names of everyone who is an adult. Filter, then extract. Two operations, and every C++ programmer has written some version of them a hundred times. The point of this section is to write it three ways, watching the same job get said in three progressively better vocabularies.
The first way is the raw loop. Walk the list, test each person, and for the ones who qualify, push the name into a result vector. It is the version most people write first, and there is nothing wrong with any single line of it.
It works, and it is perfectly clear. What it is not is declarative. The loop describes a procedure, walk, test, push, and leaves the reader to infer the goal from the mechanics. The filtering and the extraction are tangled together inside one loop body, so the two distinct operations are not visible as two operations. On this scale that costs nothing. The cost appears later, when there are three conditions instead of one and the loop body grows a thicket of nested tests.
The second way reaches for the classic standard library, the iterator-pair algorithms that predate ranges. The instinct is right, separate the filter from the extraction, but the tools fight back. std::copy_if filters, std::transform extracts, and neither composes, so each needs its own destination container and its own pair of iterators spelled out.
This is arguably worse than the loop, not better. The two operations are now named, which is progress, but naming them cost an entire intermediate vector, four iterator arguments, a back_inserter on each call, and twice the line count. The iterator-pair model cannot chain, so composing two operations means materializing the result of the first before the second can begin. The abstraction leaked, and the reader pays for it in ceremony.
The third way is ranges. The same two operations, filter and extract, composed with the pipe operator into a single expression that reads in the order the data flows.
Two operations, named, in order, with no intermediate container and no iterator bookkeeping. The filter keeps the adults, the transform pulls out the names, and the pipe threads one into the next. It reads as the sentence the task was stated in: from people, keep the adults, take their names. The loop hid the intent inside mechanics; the iterator version exposed the intent but taxed it heavily; the ranges version states it and stops.
There is one honest wrinkle, and it is worth meeting head-on rather than hiding. That last adults is not a vector. It is a lazy view, a description of work not yet done, and this is the single most important idea in all of ranges. Nothing has been filtered or extracted at the moment the expression is written. The next section is about what that means and why it is the source of nearly every advantage ranges have.
What Just Happened: Views, Pipes, and Laziness
The ranges version of the task named two operations and produced something that was not a vector. To see why that is the whole point rather than a curiosity, three pieces of machinery need names: what a range is, what the pipe was doing, and what it means that the result was lazy.
Start with the range itself, because the definition is smaller than the word suggests. A range is anything that can say where it begins and where it ends, anything with a begin() and an end(). A raw array qualifies. So does a std::vector, a std::string, a std::map. This is worth putting next to the model it replaces. For most of C++'s history the standard library talked about sequences as iterator pairs: an algorithm took two iterators, a begin and an end, and the caller spelled out both at every call site, which is exactly the ceremony that made the copy_if version in the last section so heavy. A range folds those two loose ends back into a single thing. The vector already knows both, so the vector itself is the argument.
Next, the two stages in the pipeline were view adaptors. A view adaptor takes a range and hands back a new range, without touching the original or copying its contents. std::views::filter takes a range and a predicate and yields a view of the elements that pass. std::views::transform takes a range and a function and yields a view where each element is that function applied. Two more round out the everyday set: std::views::take, the first n elements, and std::views::drop, all but the first n. Each takes a range and returns a range, and that single property is what lets them chain.
The chaining is the pipe. Writing range | filter(pred) | transform(fn) feeds the range into the filter and the filter's result into the transform, left to right, in the order the data flows. Anyone who has strung together shell commands with pipes already has the mental model, and the resemblance is not an accident.
Now the idea that makes all of it more than tidier syntax. None of those adaptors did any work when the expression was written. A view adaptor does not walk its input. It builds a small object that remembers what it was asked to do and waits. When the adults pipeline was assigned, nothing was filtered and no name was extracted. That is lazy evaluation, and it is the single thing that separates ranges from a hand-rolled chain of loops.
The work happens only when something pulls a value out the far end by iterating. Ask the pipeline for its first result and the request travels back up the chain: the transform asks the filter for an element, the filter asks the underlying range, the range yields one, the filter tests it and either passes it along or discards it and asks for the next, and eventually one value arrives at the transform to be reshaped and handed out. Ask for the second and the same walk happens again, one element deep, never building the whole intermediate sequence. Because nothing is ever collected in between, there is no intermediate container to allocate. The temporary vector the iterator version could not avoid simply has nowhere to come from.
The animation below shows this as a conveyor belt that only moves when the far end is pulled. Nothing upstream happens until something downstream demands it.
Laziness has one practical consequence worth stating now, because it will matter later: a view is a description, not a result. To turn it into a concrete container you have to walk it, which in C++20 means constructing a vector from its two iterators.
That one line is where the work actually runs, the filter and the transform executing together in a single pass as the vector fills. It is also the one slightly awkward spelling in the whole story, and a later section shows how C++23 smooths it away. For now the important thing is the shape of the idea: compose lazily, materialize once, allocate nothing in between.
Why It Matters
The three versions of the adults task were not just stylistic variations. The ranges version was better along four separate axes, and it is worth naming them, because each one shows up again in every pipeline worth writing.
The first is intent over mechanism. The raw loop described a procedure and left the goal to be inferred. The ranges version stated the goal and let the procedure disappear. That gap matters most to the person reading the code six months later, who is usually the person who wrote it.
The second is composition. The iterator version showed the problem in miniature: two operations meant one intermediate container and twice the ceremony, because iterator-pair algorithms do not chain. Add a third operation and it is a third temporary and a third pair of loops. Cost grows with every stage. Ranges keep it flat, because composition is what the pipe is for.
The third is the silent allocation tax. Every intermediate container is a heap allocation nobody asked for by name. One is invisible. Inside a loop that runs a multi-stage pipeline thousands of times, they pile up quietly and never announce themselves in the code. Lazy evaluation removes them by never creating the intermediates in the first place.
The fourth is vocabulary. Once a team reads filter | transform | take fluently, a pipeline is legible at a glance. Without that shared vocabulary, every pipeline has to be decoded one loop body at a time, which is the tax the very first version quietly charged.
None of these is dramatic on its own, and on a five-element list none of them matters at all. They matter because real code is not a five-element list. It is a large input run through several stages, many times, and that is precisely the shape the next section turns to.
A Real Pipeline
The adults task was small on purpose. Real pipeline code has more stages, a heavier element type, and runs at a scale where the allocation tax stops being theoretical. So here is a realistic one, the example this post will finish on. Picture a signal trace log, the kind that falls out of a simulation run or a hardware capture: a flat sequence of events, each a small record of something that happened on a signal at a moment in time.
The job is to pull one story out of the noise: every read on the bus, inside a chosen time window, reduced to how much the value moved from one bus event to the next. Four stages. Keep the bus transactions. Keep the events inside the window. Extract the value from each. Then take the delta between consecutive values. It is the adults task again, filter then extract, but with two filters instead of one and a final stage that needs each element paired with its predecessor.
Written the old way, that is four loops and three intermediate vectors, each allocated only to feed the next. Written as a ranges pipeline, the first three stages compose into one lazy expression that allocates nothing until it is materialized.
The two filters read as exactly what they are, the transform pulls out the value field, and the pipe threads them in the order the data flows, the same shape as the adults pipeline scaled up by one stage. The delta step stays a manual loop, and that is worth being honest about: deltas need each element paired with the one before it, which wants a pairwise-adjacent view that C++20 does not ship. The caveats section returns to it. The toggle below puts the fully manual version and the ranges version side by side, on the same data, so the contrast can be read in place.
// Stage 1: keep only bus transactions std::vector<TraceEvent> bus; for (const auto& e : trace) if (e.is_bus_transaction) bus.push_back(e); // Stage 2: keep only events in the window std::vector<TraceEvent> windowed; for (const auto& e : bus) if (e.timestamp >= t_lo && e.timestamp <= t_hi) windowed.push_back(e); // Stage 3: extract the value field std::vector<int> values; for (const auto& e : windowed) values.push_back(e.value); // Stage 4: deltas between consecutive values std::vector<int> deltas; for (std::size_t i = 1; i < values.size(); ++i) deltas.push_back(values[i] - values[i - 1]); return deltas;
Toggle the highlight and the three intermediate vectors in the old version light up, the exact allocations the ranges version never makes. Both compute the same result from the same trace: bus values of 47, 51, and 58 inside the window, giving deltas of 4 and 7.
What the Compiler Sees
Ranges make a performance claim, and it is worth stating precisely, because it is narrower than the enthusiasm around it sometimes suggests. The claim is not that ranges are faster. It is that lazy evaluation removes a specific, measurable cost the manual pipeline pays: the intermediate heap allocation after every stage.
The manual trace pipeline allocated three vectors, and copied the surviving data twice as it moved down the chain, each intermediate existing only to feed the next stage before being discarded. The ranges pipeline allocates none of that. The composed view holds no elements. It is walked once, at the single materialization, and the intermediates are never created because there are no intermediate results to hold.
The honest qualification: removing allocations is not the same as being faster in every case. A view's iteration has its own overhead, a compiler's ability to inline a chain of adaptors varies, and for a short pipeline run once on small input the difference is usually noise. Where it matters, and matters a lot, is the shape this post keeps returning to: a multi-stage pipeline run many times over a large input, inside a test bench loop or a signal-processing pass. There the allocation the old way pays on every iteration is pure overhead the ranges version never incurs, and it hides from a casual reading because no line of code names it.
The visualizer makes the scaling visible. Toggle stages on and off. The manual approach's allocation count climbs one per stage; the ranges approach stays flat at zero, no matter how long the chain.
Beyond Views: Algorithms and Projections
Views are the part of ranges that gets attention, but they are only half of what C++20 added. The other half is a full set of range-aware algorithms in the std::ranges namespace, mirroring the classic ones. Instead of std::sort(v.begin(), v.end()) there is std::ranges::sort(v), and the same for std::ranges::find_if, std::ranges::any_of, std::ranges::count_if, and the rest. Each takes a range directly, so the iterator-pair ceremony disappears at every call site, not just in pipelines.
The quiet standout is projections. Most range algorithms accept an extra argument that says which part of each element to look at. Rather than writing a comparator that reaches into a field, the field is named directly. Sorting the trace by timestamp shows it plainly.
The second form says what it means. The empty braces are the default less-than, and the pointer-to-member names the field to compare on. There is no lambda body to read, and no lambda body in which to accidentally invert the comparison or compare the wrong field. A whole small category of bugs is removed by not writing the code that would contain it. Projections work across the algorithm set, so finding the first bus transaction or counting events on one signal reads just as directly.
None of this is dramatic, and that is the appeal. Projections quietly delete a recurring source of error and compose with everything else. Together with views, most of the everyday work of filtering, transforming, searching, and sorting can be written in terms of what is wanted rather than how to walk the container to get it.
The Caveats (Be Honest)
Everything so far has been a case for ranges. A fair account has to include where the C++20 version bites, because it does, and knowing the edges in advance is the difference between reaching for ranges confidently and getting burned on the first try.
The first and most notorious is the error messages. When a range expression violates a concept constraint, the compiler does not point at the mistake in a sentence. It unspools a wall of nested template names, often hundreds of lines, with the actual cause buried in the middle. A misplaced const, a lambda returning the wrong type, a view used where a stricter concept was required, any of these can fill the screen with types nobody wrote by hand. Newer compilers improve this, but on a C++20 toolchain it remains the sharpest edge.
Closely related is the cost of auto. Range expressions lean on it heavily, because the real type of a composed view is an unspeakable nest of templates. That keeps the code readable, but it also means the type is invisible at the call site, and when something goes wrong the debugger shows the same nest the error message did. auto is what makes ranges pleasant to write and, occasionally, unpleasant to diagnose.
There are sharper functional edges too. Some views are not copyable, which surprises people who expect to store one or pass it around like a value. And ranges can be infinite: std::views::iota with no upper bound is legitimate and useful, right up until it flows into an algorithm that walks to the end. Forget to cap it with take and the program does not error, it simply runs forever.
The last caveat showed up in this post's own code: the awkward two-iterator line used to materialize a view into a vector. C++20 gave the views and the algorithms but no clean way to pour a view back into a container. C++23 fixes exactly this with std::ranges::to.
None of this is a reason to avoid ranges. It is a reason to go in with clear eyes. This is version 1.0 of a genuinely good idea, shipped a little rough at the edges, with the roughest already being sanded down in the next standard. Knowing where it bites just means the bites do not come as a surprise.
Where to Reach First
So where does this leave a codebase that has run fine for twenty years? Not facing a rewrite campaign. Nothing working needs to be torn out and re-expressed in ranges, and doing so would buy little while risking plenty.
The place ranges earn their keep is new code, and specifically code shaped like the pipeline in this post: data flowing through a chain of filters and transforms. That shape turns up constantly. Log filtering pipelines. Event stream processing. Test bench data wrangling, where a large trace gets sliced and reshaped on every iteration of a sweep. Signal analysis utilities that pull one series out of many. These are exactly where the readability holds up and the eliminated allocations add up.
Not everything, and not all at once. Just the next pipeline, written as a pipeline instead of as four loops and three throwaway vectors. The discipline that kept the compiler floor low for so long was never the enemy; it was the reason the systems lasted long enough for the floor to rise underneath them. Now that it has, the sensible move is the small one: reach for ranges the next time the shape of the problem calls for them, and let the old code keep doing what it has done reliably for two decades.
The Full Program
Everything comes together in one file. It defines TraceEvent, builds a synthetic trace, runs the pipeline, and prints the surviving values alongside the deltas. It compiles with nothing more than a C++20 compiler and the standard library, and prints the same 47, 51, 58 and the deltas 4 and 7 the post has carried throughout.
Bonus Reads: Ranges in the Wild
The pipeline here is one shape ranges take, but the toolbox is wider. For readers who would rather learn from code than prose, five short, self-contained programs, each compilable on its own, each demonstrating one capability this post only brushed against. They are meant to be read and run, so the commentary is one line each.
- Lazy infinite sequences. An unbounded integer stream, filtered to primes and capped with
take. Proof that "nothing computes until you pull" is useful, not just safe. (C++20) - Splitting and joining strings. A CSV line taken apart with
views::splitand stitched back withviews::join_with, no index arithmetic. (C++20) - Indexed iteration. Index and element together by zipping against
iota, the patternviews::enumerateformalizes in C++23. (usesviews::zip, C++23) - Windowed processing. A moving average over consecutive elements with
views::slide, the pairwise-adjacent view the delta stage wished it had. (C++23) - Pipelines as values. A
filter | transformchain stored in a variable and reused across inputs, making "ranges as vocabulary" concrete. (C++23)
Browse the examples on GitHub →
Here is an extensive explained video on Ranges with 20 examples exploring the uses and application of Ranges.
Thanks for reading. Happy coding..
#C++20 #CPP Ranges #Coding
...

Comments
Post a Comment