Subranges and Range Interoperability
Bridge the gap between iterators and views using std::ranges::subrange to turn any pair of iterators into a composable, safe C++20 View.
In the previous lessons, we successfully transitioned from raw loops to views and pipes. We saw how easy it is to take a container like std::vector, pipe it through std::views::filter(), and process the results lazily. But there is a catch.
The standard library views like transform(), filter(), and take() assume we are starting with a range - usually a container like std::vector or another view. But what if we aren't?
What if we are working with a legacy C API that gives us a raw pointer and a size? What if we are using an older algorithm that returns a std::pair of iterators? What if we are implementing a custom search algorithm that identifies a specific slice of some dataset?
In these scenarios, you have the raw materials of a range (a start and an end), but we don't have a range object. We have two separate variables.
To solve this, C++20 introduced the universal adaptor: std::ranges::subrange. It takes any pair of iterators (or an iterator and a sentinel) and wraps them into a lightweight object that satisfies the view concept. It allows us to bridge the gap between low-level pointer manipulation and high-level pipeline composition.
The API Return Type Problem
The most common place you will encounter this problem is when designing function return types.
Suppose we are writing a financial application. We have a massive ledger of transactions, sorted by date. We want to write a function GetTransactionsForDate() that returns all transactions for a specific day.
What type of data should we return here?
Option 1: Return by Value (The Performance Killer)
This is the "safe" approach:
std::vector<Transaction> GetTransactionsForDate(Date d) {
std::vector<Transaction> result;
// ... copy matching transactions ...
return result;
}However, from a performance perspective, it is a disaster. We are allocating heap memory and copying data just to read it. If the day has 10,000 transactions, we are triggering a massive allocation and 10,000 copy operations. This violates our core principle: don't pay for what you don't use.
Option 2: Return Iterators (The Usability Killer)
This is fast. It returns two small iterators (likely 16 bytes total). No memory is allocated:
std::pair<Iterator, Iterator> GetTransactionsForDate(Date d) {
// Find the range in O(log N)
auto start = std::lower_bound(...);
auto end = std::upper_bound(...);
return {start, end};
}But the usability is poor. The caller asked for a list of transactions, and they have received a std::pair. Once they figure out what that pair represents, they then need to access .first and .second members. And even then, they still don't have a cohesive list - they have two iterators. They cannot directly use it in a range-based for loop, and they cannot easily do further processing on it, such as pipe it into std::views::transform().
Option 3: std::ranges::subrange
This is the modern solution. A subrange is mechanically identical to the pair (it holds two iterators), but it exposes the begin() and end() interface required by the ranges library.
#include <vector>
#include <ranges>
#include <algorithm>
struct Transaction {
int id;
int date; // Simple integer date for example
};
using Iterator = std::vector<Transaction>::iterator;
// Return a View
std::ranges::subrange<Iterator> GetTransactionsForDate(
std::vector<Transaction>& ledger, int targetDate
) {
auto start = std::ranges::lower_bound(
ledger, targetDate, {}, &Transaction::date
);
auto end = std::ranges::upper_bound(
ledger, targetDate, {}, &Transaction::date
);
// Wrap the iterators in a subrange
return std::ranges::subrange(start, end);
}
void Process(std::vector<Transaction>& ledger) {
// Now it works in a loop
for (const auto& t : GetTransactionsForDate(ledger, 2026)) {
// ...
}
}By returning a subrange, we get the best of both worlds: the performance of raw pointers (zero-copy) and the usability of a container (iterable, pipeable).
Composition inside Pipes
The real power of subrange unlocks when we want to inject custom logic into a view pipeline.
Standard views like filter and transform are useful, but sometimes you need algorithmic logic that doesn't fit those patterns. For example, maybe you need to perform a binary search to find a starting point, and then take the next 10 items.
If your custom logic returns iterators, you can't chain it. If it returns a subrange, you can.
Let's look at how we can compose our GetTransactionsForDate() function with other views.
void AnalyzeDay(std::vector<Transaction>& ledger, int date) {
// 1. Get the subrange (Lazy)
auto transactions = GetTransactionsForDate(ledger, date);
// 2. Pipe it immediately
auto ids = transactions
| std::views::transform(&Transaction::id) // Extract IDs
| std::views::take(5); // Only first 5
for (int id : ids) {
std::cout << id << "\n";
}
}Because subrange satisfies the view concept, it can be the source of a pipeline.
GetTransactionsForDate()does a binary search and finds two iterators. It wraps them in astd::ranges::subrangetransform()wraps that view.take(5)wraps the transform view.- The loop pulls data through the chain.
We have composed a custom search algorithm with standard stream processing tools, with zero overhead.
Handling Raw Memory (C-Interop)
C++ programmers frequently have to interact with C libraries or operating system APIs. These classic APIs almost always receive their collections as a pair of arguments: a pointer and a size.
// Legacy C API
void get_audio_buffer(float** buffer_out, size_t* size_out);If we want to process this buffer using modern C++ tools, we need to convert it into a range.
We have two tools for this job: std::span and std::ranges::subrange. They are very similar, but they have distinct roles.
Using std::span
The std::span view is designed specifically for contiguous memory. It essentially holds a T* and a size_t. It is the preferred type for function parameters when you just want to receive "an array of things" and don't really care if it's a C-style array, a std::array, a std::vector, or any other contiguous container.
void ProcessArray(std::span<int> data) {
// Works with any contiguous memory of ints
for (int i : data) {
// ...
}
}
int main() {
int c_array[5] = {1, 2, 3, 4, 5};
std::vector<int> vec = {1, 2, 3};
std::array<int, 3> arr = {1, 2, 3};
// std::span automatically wraps all of these
// No copying happens. It just grabs the pointer and size.
ProcessArray(c_array);
ProcessArray(vec);
ProcessArray(arr);
}Using std::ranges::subrange
Meanwhile, std::ranges::subrange is the generalized version. It holds an Iterator and a Sentinel. It works for contiguous memory, but it also works for linked lists, trees, filtered views, or any other weird iterator you can imagine.
When wrapping a raw C-array, std::span is often the better choice because it is simpler and more explicit about the contiguous nature of what it is viewing. However, subrange is more powerful if we are building a generic template that needs to handle any kind of range.
Here is how we wrap a raw pointer and size using subrange:
#include <ranges>
#include <iostream>
#include <algorithm>
void ProcessAudioBuffer(float* buffer, size_t size) {
// Convert Pointer+Size -> Pointer+Pointer
float* end = buffer + size;
// Create the view
auto range = std::ranges::subrange(buffer, end);
// Now we can use algorithms
// Clip audio samples that are too loud
std::ranges::for_each(range, [](float& sample) {
sample = std::clamp(sample, -1.0f, 1.0f);
});
}By doing this immediately at the API boundary, we stop doing unsafe pointer arithmetic like buffer[i] and start using checked, safe iterators and algorithms.
Dangling Safety
Views are non-owning. They are references. This creates a risk: what if the thing we are viewing is destroyed while we are still looking at it?
auto GetBadView() {
std::vector<int> temp{1, 2, 3};
// DANGER: Returning a view into a local variable
// 'temp' will be destroyed when this function returns
return std::ranges::subrange(temp);
}Historically, doing something like this would be a segmentation fault waiting to happen. The returned object would contain pointers to stack memory that no longer exists.
C++20 introduces the concept of borrowed ranges. The compiler understands that std::vector owns its memory. If we try to construct a subrange from an rvalue (a temporary) vector, the compiler knows that the resulting iterators will dangle.
The standard library algorithms protect us against this. If we try to call std::ranges::find() on a temporary vector, it won't return an iterator (which would dangle). It returns a special opaque type called std::ranges::dangling.
This allows the compiler to detect our misuse of this return value, and notify us with an error:
std::vector<int> GetTempVector() { return {1, 2, 3}; }
void Oops() {
// 'it' is not an iterator. It is of type std::ranges::dangling
auto it = std::ranges::find(GetTempVector(), 2);
// Compile Error: 'dangling' does not support dereference (*)
std::cout << *it;
}The std::ranges::subrange type can also participate in this safety system:
auto GetSafeView() {
// String views are "borrowed ranges" (safe to copy)
std::string_view sv = "Hello World";
return std::ranges::subrange(sv); // OK
}
auto GetUnsafeView() {
// Vectors are NOT borrowed ranges as iterators die
// with the container
return std::ranges::subrange(std::vector<int>{1, 2, 3});
}However, if we manually construct a subrange from raw pointers (as in the C-API example), the compiler assumes we know what we are doing.
Capabilities and Sizing
One of the most important features of subrange is that it is a chameleon. It changes its capabilities based on the iterators we give it.
If we construct a subrange from std::vector iterators, the subrange is random access. We can call .size() on it, and we can use the [] operator to access elements by index.
std::vector<int> vec{10, 20, 30, 40, 50};
// Create a subrange from vector iterators
auto rng = std::ranges::subrange(vec.begin(), vec.end());
// Because the underlying iterators are powerful,
// the subrange exposes powerful features:
std::cout << rng.size(); // Prints 5
std::cout << rng[2]; // Prints 30If we construct a subrange from std::forward_list (singly linked list) iterators, the subrange loses these abilities.
It effectively "downgrades" itself. It will not have a [] operator and it won't have a .size() method by default.
This is all implemented in a type-safe way, so any misuse is flagged at compile time:
std::forward_list<int> list{10, 20, 30};
// Create a subrange from list iterators
auto rng = std::ranges::subrange(list.begin(), list.end());
// The compiler removes features that would be slow
// Compile Error - No [] operator)
std::cout << rng[2];
// Compile Error - No .size() method
std::cout << rng.size();We can still get the size of a linked list, or a subrange based on a linked list, using std::distance():
auto size = std::distance(rng.begin(), rng.end());A subrange derived from a linked list does not provide this as the .size() method as it would be an operation - we need to walk the list to get its size.
The ranges library philosophy is that properties like .size() should generally be . The design dislikes providing a slow function under an API that makes it seem like it would be fast.
Forcing the Size
However, sometimes we know the size. In the following networking example, we might receive a packet header that includes some metadata, such as the size of the packet. So even if we are using a forward iterator to read the stream, we know the count.
We can manually construct a sized subrange to include this information:
#include <ranges>
#include <forward_list>
#include <iostream>
void ProcessStream(
std::forward_list<char>& stream, int known_count
) {
// Standard construction - No .size() method
// because forward_list is not random access
auto unsized = std::ranges::subrange(
stream.begin(), stream.end()
);
// unsized.size(); // <--- Compile Error
// Sized construction:
// We explicitly tell it the size is 'known_count'
auto sized = std::ranges::subrange(
stream.begin(), stream.end(),
known_count
);
// Now we can query size in O(1)
std::cout << "Bytes: " << sized.size();
}If we know the size, adding it to our subrange is generally worthwhile, even if we don't directly use it.
Behind the scenes, many algorithms can be implemented in a more optimal way if they know the size of the input they're working with. By including this metadata into the subrange, we can unlock those optimizations.
Summary
In this lesson, we added the subrange to our toolkit. It is the glue that binds the old world of iterators to the new world of views. Here are the key points:
- The Adapter:
std::ranges::subrangeturns any pair of iterators (or iterator + sentinel) into a full-fledged view. - API Design: We learned that returning a
subrangeis superior to returning a pair of iterators (usability) or an entire container (performance). - Composition: We saw how
subrangeallows custom algorithms to act as the source for pipeable views. - Raw Memory: We compared
subrangetostd::span. Usespanfor contiguous buffers; usesubrangefor everything else. - Capability Propagation: We learned that
subrangeinherits the power of its underlying iterators, but we can manually override properties (like size) when we have external information.