Standard Library Views

Learn how to create and use views in C++ using examples from std::views
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
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

When we have a collection of data, it is often useful to be able to create and work on subsets or derivatives of that data. For example, let's imagine we have a large collection of User objects in a std::vector.

Often, the tasks our program needs to perform will require filtering or processing the collection in some way. For example, we might need to act on:

  • Only the subset of users that are online right now
  • A random sample of users
  • A collection that combines each User object with the Organisation object that the user is part of

We could create these aggregations using loops and other techniques we’ve already covered. But, if we do that, our aggregations fall out of date as soon as something changes in our original collection of users.

Instead, it is preferable to create an object that maintains its connection to the original data structure. Our new object just presents that collection differently. In C++ this new object would be a view.

Creating Views

The standard library views are available within the <ranges> header:

#include <ranges>

One of the most basic standard library views is std::views::take(), which shows the initial objects in a range. We pass it two arguments: the range we want to view and how many objects we want to view from that range.

Below, we create a view of the first 3 objects:

#include <ranges>
#include <vector>

int main() {
  std::vector Numbers{1, 2, 3, 4, 5};
  auto View{std::views::take(Numbers, 3)};  
}

Views are Ranges

The main benefit of views is that they are, themselves, ranges. This makes them compatible with range-based techniques.

Example: Range Based For Loop

Because views are ranges, we can use them with range-based for loops. Below, we use a view from std::views::take() to iterate through the first three elements of our range:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
  using std::views::take;
  std::vector Numbers{1, 2, 3, 4, 5};

  for (const auto& Num : take(Numbers, 3)) {  
    std::cout << Num << ", ";     
  }                               
}
1, 2, 3,

Example: Ranged-Based Algorithm

Views are also compatible with range-based algorithms. Below, we use std::views::reverse() to create a view of our collection in reverse. We then pass this view to std::ranges::for_each() - a range-based algorithm:

#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>

void Log(int x) {
  std::cout << x << ", ";
}

int main() {
  std::vector Numbers{1, 2, 3, 4, 5};

  std::ranges::for_each(
    std::views::reverse(Numbers), Log);
}
5, 4, 3, 2, 1,

Views are "Non-Owning"

The second important concept to understand about views is that they do not "own" the data they’re accessing. When we access an object through a view, we are accessing the object within the memory address managed by the container the view is based on.

This has two implications. First, views are fast to create, as they do not need to copy any of the underlying data.

Secondly, changes we make through the view will affect the original container. Below, we sort the first 3 objects in our container using a view:

#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>

int main() {
  std::vector Numbers{4, 1, 3, 2, 5};

  std::ranges::sort(
    std::views::take(Numbers, 3));

  for (const auto& Num : Numbers) {
    std::cout << Num << ", ";
  }
}
1, 3, 4, 2, 5,

The opposite is also true - if something changes in our original collection, that change will be reflected in any views that use the container:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
  std::vector Numbers{4, 1, 3, 2, 5};
  auto View{std::views::take(Numbers, 3)};

  std::cout << "View[0]: " << View[0];
  Numbers[0] = 100;
  std::cout << "\nView[0]: " << View[0];
}
View[0]: 4
View[0]: 100

Filtering Objects using a View

One of the most common use cases for views is to create a range that only includes a subset of the original collection based on some predicate function.

The standard library’s std::views::filter() function can help us with this. We pass our range as the first argument and a predicate function as the second. Every object for which the predicate returns true is included in the view.

In this example, we create a view of only the odd numbers:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
  std::vector Numbers { 1, 2, 3, 4, 5 };

  auto FilteredView {
    std::views::filter(Numbers, [](int i){
      return i % 2 == 1;
    })
  };

  for (const auto& Num : FilteredView) {
    std::cout << Num << ", ";
  }
}
1, 3, 5,

Below, we have a Party class, acting as a container for Player objects. Our class implements a DeadPlayers() method to give convenient access to just a subset of its elements:

#include <vector>
#include <iostream>
#include <ranges>
#include <print>

class Player {/*...*/} class Party { public:
void Log() {/*...*/} void Revive() { for (auto& P : DeadPlayers()) { P.Revive(); } std::cout << '\n'; } private: auto DeadPlayers() { return std::views::filter( Players, &Player::isDead); } std::vector<Player> Players{ Player{"Legolas", 0}, Player{"Gimli", 100}, Player{"Gandalf", 0} }; }; int main() { Party Players; Players.Log(); Players.Revive(); Players.Log(); }
[0] Legolas
[100] Gimli
[0] Gandalf

Reviving Legolas
Reviving Gandalf

[1] Legolas
[100] Gimli
[1] Gandalf

Composition and Pipelines

Because views are ranges, and views are also created from ranges, this allows them to be composed. That is, the output of one view can become the input of another view.

Below, we create a view composed of three simpler views:

  1. We filter the collection to only contain the odd numbers
  2. We take the first 3 results returned by view 1
  3. We reverse the results of view 2
#include <iostream>
#include <ranges>
#include <vector>

bool Filter(int x) {
  return x % 2 == 1;
}

int main() {
  std::vector Numbers { 1, 2, 3, 4, 5, 6, 7 };

  auto View {
    std::views::reverse(
      std::views::take(
        std::views::filter(Numbers, Filter),
      3)
    )
  };

  for (const auto& Num : View) {
    std::cout << Num << ", ";
  }
}
5, 3, 1,

Creating Pipelines using the Pipe Operator: |

The ranges library provides an alternative syntax to make the composition more succinct. The objects returned from views overload the | operator specifically for composition.

As such, the previous example could be written like this:

#include <iostream>
#include <ranges>
#include <vector>

bool Filter(int x) {
  return x % 2 == 1;
}

int main() {
  std::vector Numbers { 1, 2, 3, 4, 5, 6, 7 };

  auto View {
    std::views::filter(Numbers, Filter) |
    std::views::take(3) |
    std::views::reverse
  };

  for (const auto& Num : View) {
    std::cout << Num << ", ";
  }
}
5, 3, 1,

Something to note when creating pipelines is that the API has been set up to be as succinct as possible, reducing the amount of syntax we need. Therefore, only the first function in the sequence is provided with the original range. Any subsequent function in the pipeline uses the return value of the previous function as its input.

For example, std::views::take() would normally receive two arguments - the range and the quantity to take. But above, it is used as the right operand of the previous view’s | operator. In this context, the API is set up so the range it will view is coming from that left operand, so we only need to provide the integer argument - 3, in this example.

Equally, std::views::reverse() would normally require us to provide the range but, within a pipeline, it’s coming from the previous function. Therefore, std::views::reverse() in this context requires no arguments at all.

Additionally, the API has been set up such that if a view requires no arguments, we don’t even need to use the () operator.

Isn’t | a bitwise operator?

In the previous course, we introduced | as the "bitwise OR" operator. However, we’ve also seen how types are free to associate any behavior they want with any operator. Therefore, which operator a specific behavior is implemented under is more about API design, rather than any technical constraint.

Function composition is a fairly common requirement, where the output of one function becomes the input of another. For example:

FunctionC(
  FunctionB(
    FunctionA(Input)
  )
);

Many functional programming languages have the concept of a pipeline operator, which makes code like this easier to read and write.

Pipeline operators often use |> as their syntax. In many functional programming languages, such as Elixir, R, and F#, the previous expression could be written instead as:

Input
|> FunctionA
|> FunctionB
|> FunctionC

The views library was inspired by this design. However, the C++ language doesn’t support this syntax and doesn’t have a |> operator to overload.

The closest this design could be implemented without updating the language involved having the functions return a type that overloads the | operator, thereby enabling syntax like this:

FunctionA(Input)
| FunctionB
| FunctionC

There are proposals to add a pipeline operator to the C++ language in the future.

Zip Views

std::views::zip() allows us to combine multiple ranges (including views) into a single view. Each element in the zipped view is a tuple, containing parallel elements from each of the input views.

Below, we zip three ranges to create a view called TranslationView. Every element of TranslationView is a tuple containing an element from each of the input ranges.

In this case, each tuple has an integer from Numbers, a string from English, and another string from French:

#include <iostream>
#include <ranges>
#include <vector>

int main(){
  std::vector Numbers{1, 2, 3, 4, 5, 6, 7};
  std::vector English{
    "Monday", "Tuesday", "Wednesday",
    "Thursday",
    "Friday", "Saturday", "Sunday"};
  std::vector French{
    "Lundi", "Mardi", "Mercredi", "Jeudi",
    "Vendredi", "Samedi", "Dimanche"};

  auto TranslationView{std::views::zip(
    Numbers, English, French)};

  for (const auto& Tuple : TranslationView) {
    std::cout << std::get<0>(Tuple) << ". "
              << std::get<1>(Tuple) << ": "
              << std::get<2>(Tuple) << '\n';
  }
}
1. Monday: Lundi
2. Tuesday: Mardi
3. Wednesday: Mercredi
4. Thursday: Jeudi
5. Friday: Vendredi
6. Saturday: Samedi
7. Sunday: Dimanche

We cover tuples in more detail here:

Range Factories

Some views can be created without requiring an underlying container. These are called range factories, as they can generate views (which are ranges) algorithmically.

Example: std::views::iota()

Iota is the 9th letter of the Greek alphabet, $\iota$, and in programming contexts, is sometimes used to refer to a sequence of incrementing integers, e.g., $1, 2, 3, 4, 5$

One of the most useful range factories is std::view::iota(), which simply creates a view of incrementing integers.

std::views::iota() accepts two arguments - the first and last numbers in the sequence. The view then contains every integer from the first argument, up to (but not including) the second argument:

#include <iostream>
#include <ranges>

int main(){
  for (int x : std::views::iota(1, 11)) {
    std::cout << x << ", ";
  }
}
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,

The second argument is optional, thereby creating an unbounded view.

#include <iostream>
#include <ranges>

int main() {
  // Infinite loop
  for (int x : std::views::iota(1)) {
    std::cout << x << ", ";
  }
}

Infinite sequences are not practically useful, so they are only used in scenarios where they’re being constrained in some other way. A simple example of this is given below, where we limit the output using std::views::take():

#include <iostream>
#include <ranges>

int main(){
  using std::views::iota, std::views::take;
  for (int x : iota(1) | take(10)) {
    std::cout << x << ", ";
  }
}
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,

Below, we use an unbounded iota with std::views::zip() to create a numbered list, based on a different collection:

#include <iostream>
#include <ranges>
#include <vector>

int main(){
  using std::views::iota, std::views::zip;
  std::vector Nums{"One", "Two", "Three"};

  for (const auto& Tuple : zip(iota(1), Nums)) {
    std::cout << std::get<0>(Tuple) << ": ";
    std::cout << std::get<1>(Tuple) << '\n';
  }
}
1: One
2: Two
3: Three

Summary

In this lesson, we explored how views offer a powerful, efficient way to work with data ranges without owning or copying them. Through various examples, we demonstrated how views can be used to create subsets, perform transformations, and compose complex data processing pipelines with minimal overhead.

Main Points Learned

  • The concept of views in C++ and their advantage of not owning data, allowing for efficient data manipulation.
  • How to create views using standard library utilities such as std::views::take.
  • Understanding that views are ranges themselves and can be used with range-based for loops and algorithms.
  • The non-owning nature of views means changes to the original data or through the view are reflected across both.
  • The use of std::views::filter to create views that only include elements matching a predicate function.
  • Composition of views using the pipe operator | to build more complex data processing operations.
  • Introduction to range factories like std::views::iota for generating sequences of numbers without a backing container.
  • The concept of zip views with std::views::zip for combining multiple ranges into a single view where elements are tuples of elements from each input range.

Was this lesson useful?

Next Lesson

Creating Views using std::ranges::subrange

This lesson introduces std::ranges::subrange, allowing us to create non-owning ranges that view some underlying container
3D Vehicle Concept Art
Ryan McCombe
Ryan McCombe
Updated
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
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:

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

Creating Views using std::ranges::subrange

This lesson introduces std::ranges::subrange, allowing us to create non-owning ranges that view some underlying container
3D Vehicle Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved