std::ranges::subrange
When we want to create a non-owning view of some underlying container, we typically use the std::ranges::subrange
type from the <ranges>
header.
This lesson will provide a thorough overview of this type—how to create it, how to use it, and how to combine it with many of the other features introduced earlier in the chapter.
The std::ranges::subrange
template is available within the <ranges>
header. The simplest constructor accepts a source range to base the view upon. Below, we create a view of our Nums
range:
#include <vector>
#include <ranges>
int main() {
std::vector Nums{1, 2, 3, 4, 5};
std::ranges::subrange View{Nums};
}
As always, we can define our range as an iterator-sentinel pair instead. Here, we create a view of the middle three elements of our range:
#include <ranges>
#include <vector>
int main() {
std::vector Nums{1, 2, 3, 4, 5};
std::ranges::subrange View{
Nums.begin() + 1, Nums.end() - 1};
}
Like other views, subranges are themselves ranges. Most of the ways we use a subrange involve range-based techniques. Below, we use a range-based for loop:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector Nums{1, 2, 3, 4, 5};
std::ranges::subrange View{Nums};
for (auto x : View) {
std::cout << x << ", ";
}
}
1, 2, 3, 4, 5,
In the following example, we apply a range-based algorithm to our subrange:
#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Nums{1, 2, 3, 4, 5};
std::ranges::subrange View{Nums};
std::ranges::for_each(View, Log);
}
1, 2, 3, 4, 5,
We can use our subrange to create another view, using std::views::reverse
for example:
#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Nums{1, 2, 3, 4, 5};
std::ranges::subrange View{Nums};
std::ranges::for_each(
std::views::reverse(View), Log);
}
5, 4, 3, 2, 1,
Like other views, subranges are non-owning types. When accessing objects through a view, we are accessing memory locations owned by some other container.
This makes subranges fast to create and copy, but also means changes made through the subrange impact the underlying container. Below, we sort our subrange and then log the contents of our original container to show the effect:
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Nums{3, 1, 5, 2, 4};
std::cout << "Nums: ";
std::ranges::for_each(Nums, Log);
std::ranges::subrange View{Nums};
std::ranges::sort(View);
std::cout << "\nNums: ";
std::ranges::for_each(Nums, Log);
}
Nums: 3, 1, 5, 2, 4,
Nums: 1, 2, 3, 4, 5,
Similarly, changes made to the source range are reflected in the view:
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Nums{3, 1, 5, 2, 4};
std::ranges::subrange View{Nums};
std::cout << "View: ";
std::ranges::for_each(View, Log);
std::ranges::sort(Nums);
std::cout << "\nView: ";
std::ranges::for_each(View, Log);
}
View: 3, 1, 5, 2, 4,
View: 1, 2, 3, 4, 5,
Below, we compose a std::ranges::subrange
with other views into a pipeline:
#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>
bool isEven(int x) { return x % 2 == 0; }
void Log(int x) { std::cout << x << ", "; }
int main() {
using std::ranges::subrange;
std::vector Nums{1, 2, 3, 4, 5, 6, 7};
auto Pipeline{
subrange(Nums.begin() + 1, Nums.end() - 1)
| std::views::filter(isEven)
| std::views::reverse
};
std::ranges::for_each(Pipeline, Log);
}
6, 4, 2,
Often, we can determine the number of objects in our subrange view using the size()
method:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector Nums{1, 2, 3};
std::ranges::subrange Subrange{Nums};
std::cout << "Size: " << Subrange.size();
}
Size: 3
However, this is not always available. For example, if we attempt to retrieve the size()
of a subrange based on a std::forward_list
, we will generate a compiler error:
#include <iostream>
#include <forward_list>
#include <ranges>
int main() {
std::forward_list Nums{1, 2, 3};
std::ranges::subrange Subrange{Nums};
std::cout << "Size: " << Subrange.size();
}
error C7500: 'size': no function satisfied its constraints
Specifically, the size()
method requires that our subrange must be able to determine its size in constant time.
Ranges generated from an array like std::vector
will often be able to do this, but it’s less likely for ranges based on linked lists, for example.
If ideas like "constant time" and the differences between arrays and linked lists are unfamiliar, we covered these topics earlier in the course:
We can check if our range meets the requirements using the std::ranges::sized_range
concept:
#include <iostream>
#include <vector>
#include <forward_list>
#include <ranges>
template <typename T>
void LogSized(T Range) {
if constexpr (std::ranges::sized_range<T>) {
std::cout << "\nThat range is sized: "
<< Range.size();
} else {
std::cout << "\nThat range is not sized";
}
}
int main() {
std::vector NumsA{1, 2, 3};
LogSized(std::ranges::subrange{NumsA});
std::forward_list NumsB{1, 2, 3};
LogSized(std::ranges::subrange{NumsB});
}
That range is sized: 3
That range is not sized
If we know the size our subrange will be when we create it, we can optionally provide that to the subrange constructor. This is referred to as a size hint, and it allows our subrange to be a sized_range
even if the container it views is not sized.
Below, we pass a size hint of 3
:
#include <forward_list>
#include <iostream>
#include <ranges>
void LogSized(T Range) {/*...*/}
int main() {
std::forward_list Range{1, 2, 3};
LogSized(Range);
std::ranges::subrange Subrange{Range, 3};
LogSized(Subrange);
}
That range is not sized
That range is sized: 3
The compiler assumes the size hint we provide is accurate. If the value we provide is incorrect, the behaviour becomes undefined.
std::distance()
We can still determine the size of any range by passing the iterator and sentinel to the std::distance()
function. This is available even if the range is not a sized_range
:
#include <forward_list>
#include <iostream>
int main() {
std::forward_list Range{1, 2, 3};
std::cout << "Size: " <<
std::distance(Range.begin(), Range.end());
}
Size: 3
However, unlike the size()
method (or std::size()
), this approach is not guaranteed to be a fast, constant-time operation. Calculating the distance may require traversal through the range:
We covered std::distance()
and similar functions in our introductory lesson on iterators:
empty()
methodIn most scenarios, we can determine if the size of our range is 0
by calling the more descriptive empty()
method:
#include <vector>
#include <iostream>
int main() {
std::vector Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
if (!Subrange.empty()) {
std::cout << "Subrange is not empty";
}
}
Subrange is not empty
This is also available simply by using the range as a bool
:
#include <vector>
#include <iostream>
int main() {
std::vector Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
if (Subrange) {
std::cout << "Subrange is not empty";
}
}
Subrange is not empty
In this section, we cover some of the main ways to traverse through our subranges.
begin()
and end()
The subrange’s iterator and sentinel are available through the begin()
and end()
methods respectively. This allows us to use all the usual iterator-based techniques on our range.
Below, we use these with std::for_each()
, an iterator based algorithm:
#include <iostream>
#include <vector>
#include <algorithm>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange A{Range};
std::for_each(A.begin(), A.end(), Log);
}
1, 2, 3, 4, 5,
The std::ranges::subrange
type also supports structured binding, giving us an alternative way to access the objects that represent the start and end of the subrange:
#include <algorithm>
#include <iostream>
#include <vector>
void Log(int x) { std::cout << x << ", "; }
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange A{Range};
auto [begin, end]{A};
std::for_each(begin, end, Log);
}
1, 2, 3, 4, 5,
advance()
MethodWe can advance the iterator within our subrange using the advance()
method, which accepts an argument for how many steps we want to move the iterator forward. Below, we advance our subrange by 2
steps:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange Subrange{Range};
std::cout << "Subrange: ";
for (auto x : Subrange) {
std::cout << x << ", ";
}
Subrange.advance(2);
std::cout << "\nAfter advancing: ";
for (auto x : Subrange) {
std::cout << x << ", ";
}
}
Subrange: 1, 2, 3, 4, 5,
After advancing: 3, 4, 5,
The advance()
method will not advance our iterator past the end of our subrange. Below, we attempt to advance 100
steps, but we only advance as far as the sentinel:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange Subrange{Range};
Subrange.advance(100);
if (Subrange.begin() == Subrange.end()) {
std::cout << "We advanced to the end"
<< "\nSubrange Size: " << Subrange.size();
}
}
We advanced to the end
Subrange Size: 0
If our range is at least bidirectional, we can pass a negative offset to advance()
:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange Subrange{
Range.begin() + 3, Range.end()};
std::cout << "Subrange: ";
for (auto x : Subrange) {
std::cout << x << ", ";
}
Subrange.advance(-3);
std::cout << "\nAfter advancing backwards: ";
for (auto x : Subrange) {
std::cout << x << ", ";
}
}
Subrange: 4, 5,
After advancing backwards: 1, 2, 3, 4, 5,
Unlike when advancing forward, there are no checks preventing us from "advancing" too far. For example, we should ensure our call to advance()
doesn’t move the iterator backwards beyond the beginning of the container.
next()
MethodThe next()
method works similarly to advance()
. However, rather than modifying our subrange in place, next()
returns a new subrange.
This new subrange will have its pointer advanced, whilst the subrange we called the method on will not be modified:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange A{Range};
std::ranges::subrange B{A.next(2)};
std::cout << "A: ";
for (auto x : A) {
std::cout << x << ", ";
}
std::cout << "\nB: ";
for (auto x : B) {
std::cout << x << ", ";
}
}
A: 1, 2, 3, 4, 5,
B: 3, 4, 5,
prev()
As with advance()
, if our subrange is bidirectional, we can pass a negative offset to the next()
method. This causes the function to return a view that contains more objects than the subrange it was called upon:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange A{
Range.begin() + 2, Range.end()};
// Not recommended - see note below
std::ranges::subrange B{A.next(-2)};
std::cout << "A: ";
for (auto x : A) {
std::cout << x << ", ";
}
std::cout << "\nB: ";
for (auto x : B) {
std::cout << x << ", ";
}
}
A: 3, 4, 5,
B: 1, 2, 3, 4, 5,
However, if we know we’re moving the iterator backwards (that is, the argument we’re passing to next()
will be negative) we should prefer the prev()
method with a positive argument instead:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3, 4, 5};
std::ranges::subrange A{
Range.begin() + 2, Range.end()};
std::ranges::subrange B{A.prev(2)};
std::cout << "A: ";
for (auto x : A) {
std::cout << x << ", ";
}
std::cout << "\nB: ";
for (auto x : B) {
std::cout << x << ", ";
}
}
A: 3, 4, 5,
B: 1, 2, 3, 4, 5,
This makes our intent clearer, and enables additional compile-time checks. For example, prev()
will ensure our range supports reverse traversal, by checking that it is bidirectional. If it’s not, we get a compiler error rather than a potential bug:
#include <forward_list>
int main() {
std::forward_list Range{1, 2, 3, 4, 5};
std::ranges::subrange Subrange{
std::next(Range.begin(), 2), Range.end()};
Subrange.prev(2);
}
error: 'prev': no function satisfied its constraints
the concept 'std::bidirectional_iterator' evaluated to false
In this section, we cover the main ways we can access the objects within our container, through the subrange that is viewing it.
As with any iterators, we can access the object they’re pointing at by dereferencing them. Below, we access the first element using the iterator returned from begin()
:
#include <forward_list>
#include <iostream>
int main() {
std::forward_list Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "First: " << *Subrange.begin();
}
First: 1
We can also the begin()
method alongside iterator techniques such as std::next()
to access objects based on an offset from the front:
#include <forward_list>
#include <iostream>
int main() {
std::forward_list Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "Third: "
<< *std::next(Subrange.begin(), 2);
}
Third: 3
When our view is at least a bidirectional range, we can use std::prev()
to create an iterator by moving backwards through the range:
#include <list>
#include <iostream>
int main() {
std::list Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "First: "
<< *std::prev(Subrange.end(), 3);
}
First: 1
front()
MethodWhen we explicitly want to access the object at the start of our view, we can use the front()
method:
#include <iostream>
#include <forward_list>
int main() {
std::forward_list Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "Front: " << Subrange.front();
}
Front: 1
back()
MethodIf our view is at least a bidirectional range, we can access the object at the end of the range using the back()
method:
#include <iostream>
#include <list>
int main() {
std::list Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "Back: " << Subrange.back();
}
Back: 3
[]
OperatorWhen our range supports random access, we can access the object at any position using the []
operator:
#include <iostream>
#include <vector>
int main() {
std::vector Range{1, 2, 3};
std::ranges::subrange Subrange{Range};
std::cout << "Index 1: " << Subrange[1];
}
Index 1: 2
data()
methodWhere our subrange is viewing a contiguous container such as an array, the raw memory address the iterator is pointing it is available through the data()
method:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector Nums{1, 2, 3};
std::ranges::subrange Subrange{Nums};
std::cout << "Pointer: " << Nums.data();
}
Pointer: 000001855EE84030
This is primarly used to let our subrange interact with systems that expect more basic types, such as c-style arrays:
#include <iostream>
#include <vector>
#include <ranges>
void Log(int Array[], std::size_t Size) {
for (std::size_t i{0}; i < Size; ++i) {
std::cout << Array[i] << ", ";
}
}
int main() {
std::vector Nums{1, 2, 3};
std::ranges::subrange Subrange{Nums};
Log(Subrange.data(), Subrange.size());
}
1, 2, 3,
In this lesson, we’ve learned how std::ranges::subrange
gives us a simple way to create views of some underlying container. We covered:
std::ranges::subrange
type, available within the <ranges>
library.begin()
, end()
, size()
, and empty()
to manipulate views.std::ranges::for_each
and std::ranges::sort
.std::ranges::subrange
This lesson introduces std::ranges::subrange, allowing us to create non-owning ranges that view some underlying container
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.