Introducing subvector and view_wrapper for modern C++

Igor Machado
5 min readMay 14, 2024

Views and Ranges are a fundamental part of modern C++ programming, specially with the launch of <ranges> library in C++20, with the help of <concepts> library. This post talks about views and ranges, also presenting some proposed features of the thirdparty library view_wrapper, including subvector class.

Introducing views and ranges for modern C++

A view is a lightweight data structure with stack-based allocation, but that behaves like reference-type. For example, consider a std::string that is needed for reading in some function, but do not need to write. One may pass a const std::string&, but using std::string_view class (introduced in C++17) it may be passed as copy and still behave like a reference. This is very useful to create substrings and every other form of slicing of such structures, without the need of performing a full copy. Basically, a view can execute operations in constant time O(1), as it only handles begin and end pointers, when the real data is on a remote concrete object (such as std::string).

Other class that behaves like a view is the std::span class (introduced in C++20) that represents any sort of contiguous sequence of data. For example, a std::span<int> can represent a C array int[], a C++ std::array<int>, or even a complete or a partial std::vector<int>, since it’s internally represented as an initial pointer and a size. Differently from std::string_view, a std::span allows read-write access on the remote data structure, so it also behaves like a range.

A range is any structure capable of defining some sort of iterator, allow it to sequentially explore data, where several of these were defined in C++20 <ranges> library, including lightweight ranges called range views. Anything that includes a begin() and end() iterator can be explored as a range, although there are many other possible implementations in <ranges> library. A short example from cppreference including pipe operator for view composition:

auto const ints = {0, 1, 2, 3, 4, 5};
auto even = [](int i) { return 0 == i % 2; };
auto square = [](int i) { return i * i; };

for (int i : ints | std::views::filter(even) | std::views::transform(square))
std::cout << i << ' ';
// 0 4 16

Presenting view_wrapper

In order to help new users (and my students) to understand views and ranges, and to easily use them in a safer manner, we present the view_wrapper library (disclaimer, I’m the author!): https://github.com/igormcoelho/view_wrapper/

The idea is to directly provide access to classic views and ranges from the standard library, but using template specialization from the proposed View and Range classes (these are in CamelCase to prevent name conflicts with other possible view and range classes or concepts elsewhere). So, one can just do View<std::string> and get a std::string_view behavior; or even use a View<std::vector<X>> and get a const std::span behavior, see example below, by providing some print_view method, compatible with both view types:

using view_wrapper::View;

template <typename T>
void print_view(View<T> sv) {
std::cout << "size=" << sv->size() << ": ";
for (auto& x : *sv) std::cout << x << ";";
std::cout << std::endl;
}

And then, one may easily apply any View<> type into this method:

// View<std::string> sv("abcd"); // ERROR: const lvalue&
std::string s = "abcd";
View<std::string> sv(s);
print_view(sv); // size=4: a;b;c;d;
std::vector<int> v = {1, 2, 3, 4};
View<std::vector<int>> vv(v);
print_view(vv); // size=4: 1;2;3;4;

Note that this prevents const lvalue& to be passed, trying to avoid unintentional dangling from a lifetime-extended object (this eventually happens with std::string_view if users are not quite used with the workings of the library). I personally find this dangerous, so I disabled this behavior, but I’m aware that this may force users to write one line more, instead of just passing lifetime-extended data directly to the view.

A interesting capability of View<> is that it forces the view to be read-only, so in the case of View<vector<T>>, the corresponding std::span will the const. The changes are naturally reflected on the view:

std::vector<int> v = {1, 2};
View<std::vector<int>> vv(v);
print_view(vv); // size=2: 1;2;
v[0] = 3;
print_view(vv); // size=2: 3;2;
// (*vv)[0] = 5; // NOT ALLOWED

If users want the read-write behavior on the view, then they should use the Range<> class instead, so if one passes a Range<std::vector<T>> they will get an object of a range view class that we called subvector.

Introducing subvector

A very special range class is the subvector, also available on the view_wrapper project, but quite independent from the project itself, so it can be used as a standalone and C++14 compatible header (differently from the View<> and Range<> classes that are C++20 onwards). A subvector is a lightweight range view structure that carries only a reference to to a remote std::vector, and two begin and end numeric bounds. So, one may just inform that a subvector vv1 is the first two elements of vector v, in the following way (including a printv helper for printing):

using view_wrapper::subvector;

void printv(subvector<int> v) {
std::cout << "size=" << v.size() << ": ";
for (auto& x : v) std::cout << x << " ";
std::cout << std::endl;
}

// ...

std::vector<int> v = {1, 2, -1, 4, 5, 6};
subvector<int> vv1(v, 0, 2);
printv(vv1); // size=2: 1 2

The fixed bounds constructor for subvector can be quite useful for simple read-only operations, but the real magic comes from the dynamic bounds. For example, one may simply get a full view of a vector and still perform operations over it! The example below shows that the size of the view vv increases automatically when dynamic bound constructors are used:

std::vector<int> v = {1, 2, -1, 4, 5, 6};
subvector<int> vv(v); // automatically using dynamic bounds
vv.push_back(-2);
printv(vv); // size=7: 1 2 -1 4 5 6 -2

This example shows how the default view constructor of subvector automatically register a lambda function that grabs the size of the remote vector whenever a write operation occurs (such as push_back operation). This also allows multiple views to work in a synchonized and natural manner. The example below shows 4 different views, where:

vv1 is a whole view on vector v

vv2 is a view to first two elements of v

vv3 is a view to all elements after first -1 element in v

vv4 is a view to four elements of v

std::vector<int> v = {1, 2, -1, 4, 5, 6};
subvector<int> vv1(v);
printv(vv1); // sz=6: 1 2 -1 4 5 6
subvector<int> vv2(v, 0, 2);
printv(vv2); // sz=2: 1 2
subvector<int> vv3(v, [](const std::vector<int>& v) {
auto it1 = std::find(v.begin(), v.end(), -1);
auto idx1 = std::distance(v.begin(), it1);
return std::make_pair(idx1 + 1, v.size());
});
printv(vv3); // sz=3: 4 5 6
subvector<int> vv4(v, 1, 5);
printv(vv4); // size=4: 2 -1 4 5

Then, the magic happens after a push_back operation in vv2:

vv2.push_back(3);
printv(vv1); // sz=7: 1 2 3 -1 4 5 6
printv(vv2); // sz=3: 1 2 3
printv(vv3); // sz=3: 4 5 6
printv(vv4); // size=4: 2 3 -1 4

Take some time to understand the effects on each view, and the possible applications where a possibly unsafe reference std::vector<T>& is used.

Final words

This post presented the capabilities of view_wrapper classes View<> and Range<>, also presenting the subvector, which allows read-write access on a remote std::vector container. The view_wrapper project is open-source and it is easily extensible (why not some range-based subvector alternative for std::queue, std::list, and so on), so feel free to leave comments and to use it on your projects: https://github.com/igormcoelho/view_wrapper/

Good luck!

(*) Thanks Fellipe Pessanha for the advices and improvements on the explanations and examples.

(**) A general approach for handling optional references in C++17 have been previously discussed in optional_view project: https://igormcoelho.medium.com/using-optional-view-to-manage-optional-references-in-c-1368abea30bb

Igor Machado Coelho is computer science researcher and professor in Fluminense Federal University, Brazil.

--

--