Creating Views using std::ranges::subrange
This lesson introduces std::ranges::subrange, allowing us to create non-owning ranges that view some underlying container
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.
Creating Subranges
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};
}
Using Subranges
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,
Subranges are a View
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,
Sized Ranges
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:
Algorithm Analysis and Big O Notation
An introduction to algorithms - the foundations of computer science. Learn how to design, analyze, and compare them.
Data Structures and Algorithms
This lesson introduces the concept of data structures beyond arrays, and why we may want to use alternatives.
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
Concepts in C++20
Learn how to use C++20 concepts to constrain template parameters, improve error messages, and enhance code readability.
Size Hints
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.
Using 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:
Iterators
This lesson provides an in-depth look at iterators in C++, covering different types like forward, bidirectional, and random access iterators, and their practical uses.
The empty()
method
In 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
Traversal
In this section, we cover some of the main ways to traverse through our subranges.
Using 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,
Structured Binding
This lesson introduces Structured Binding, a handy tool for unpacking simple data structures
The advance()
Method
We 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.
The next()
Method
The 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,
Negative offsets and 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
Object Access
In this section, we cover the main ways we can access the objects within our container, through the subrange that is viewing it.
Iterator Techniques
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
The front()
Method
When 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
The back()
Method
If 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
The []
Operator
When 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
The data()
method
Where 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,
C-Style Arrays
A detailed guide to working with classic C-style arrays within C++, and why we should avoid them where possible
Summary
In this lesson, we've learned how std::ranges::subrange
gives us a simple way to create views of some underlying container. We covered:
- The
std::ranges::subrange
type, available within the<ranges>
library. - How to create subranges from containers and use them to view data slices.
- Using subrange methods like
begin()
,end()
,size()
, andempty()
to manipulate views. - Applying subranges with algorithms like
std::ranges::for_each
andstd::ranges::sort
. - Understanding the concept of sized ranges and how to use size hints.
- Techniques for advancing, retreating, and accessing elements within subranges.
8 Key Standard Library Algorithms
An introduction to 8 more useful algorithms from the standard library, and how we can use them alongside views, projections, and other techniques