Creating Views using std::ranges::subrange

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

Ryan McCombe
Updated

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(), 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.
Next Lesson
Lesson 83 of 128

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

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Handling Out-of-Bounds Errors in std::ranges::subrange
How do I handle out-of-bounds errors when accessing elements in a std::ranges::subrange?
Difference between std::ranges::subrange and std::span
What is the difference between std::ranges::subrange and std::span?
Converting std::ranges::subrange to Original Container Type
How can I convert a std::ranges::subrange back to its original container type?
Using std::ranges::subrange with Non-Standard Containers
Can I use std::ranges::subrange with non-standard containers?
Using std::ranges::subrange with Multi-threaded Code
How does std::ranges::subrange interact with multi-threaded code?
Ensuring Lifetime Management in std::ranges::subrange
How do I ensure the lifetime of the container outlives the std::ranges::subrange?
Advantages of std::ranges::subrange Over Raw Pointers
What are the advantages of using std::ranges::subrange over raw pointers?
Creating std::ranges::subrange from Multiple Containers
Can I use std::ranges::subrange to view data from multiple containers simultaneously?
Modifying Containers through std::ranges::subrange
Can std::ranges::subrange be used with standard library algorithms that modify the container?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant