std::views
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:
User
object with the Organisation
object that the user is part ofWe 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.
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)};
}
The main benefit of views is that they are, themselves, ranges. This makes them compatible with range-based techniques.
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,
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,
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
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
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:
#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,
|
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.
|
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.
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:
Some views can be created without requiring an underlying container. These are called range factories, as they can generate views (which are ranges)Â algorithmically.
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
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.
std::views::take
.std::views::filter
to create views that only include elements matching a predicate function.|
to build more complex data processing operations.std::views::iota
for generating sequences of numbers without a backing container.std::views::zip
for combining multiple ranges into a single view where elements are tuples of elements from each input range.Learn how to create and use views in C++ using examples from std::views
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.