Creating Views using std::ranges::subrange

This lesson introduces std::ranges::subrange, allowing us to create non-owning ranges that view some underlying container
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
3D Vehicle Concept Art
Ryan McCombe
Ryan McCombe
Posted

When we want to create a non-owning view of some underlying container, the std::ranges::subrange type within <ranges> is what we typically use.

This lesson will provide a thorough overview of this type - how to create them, how to use them, and how to combine them with many of the other features we’ve introduced earlier in the chapter.

Creating Subranges

The std::ranges::subrange template is available within the <ranges> header. The simplest constructor it 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 in which we need to use a subrange involve using 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:

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

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 is viewing 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:

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 we 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,

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 similarily to advance(). However, rather than modifing our subrange in place, next() instead 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,

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(), and empty() to manipulate views.
  • Applying subranges with algorithms like std::ranges::for_each and std::ranges::sort.
  • Understanding the concept of sized ranges and how to use size hints.
  • Techniques for advancing, retreating, and accessing elements within subranges.

Was this lesson useful?

Next Lesson

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
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Posted
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Iterators and Ranges
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 124 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

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
3D Character Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved