Some Thoughts on C++ Views and Constness

C++17 adds the std::string_view, which provides access to a constant string (as in "character array"). C++20 adds templates like std::span<T>, which does something similar for arbitrary types T, with the notable difference that they do not need to be const.

Why use a std::span<T> or a view if you can just pass a reference to the original object? Sometimes, the object is not what is needed - one might want to perform an operation on a part of the data, e.g. when modifying part of an image or sound samples. Often it is possible to use iterators, however at least two are required (the begin and end of the range), which is error-prone and leads to complicated function signatures. Views also enable more complicated operations than representing part of a range. It is possible to create a custom view class that accesses just every nth element of the underlying container.

A note regarding notation: When I talk about "views", this includes std::span<T>, std::string_view and similar containers that do not own the data they refer to. Maybe this is an abuse of terminology, but it keeps things short here.

Edit: I learned that C++ has "reserved" most words that one would use to describe such classes, i.e. it has its own, very specific definition. For example, in C++20 there is std::view. When writing "view" here, I am referring to data structures that do not own the data they refer to. If there is a better word, let me know. Maybe for C++ it is 'range'?

References vs. Views

Views behave like references: they provide access to some data (a "view") without owning the data. When a reference goes out of scope, the data lives on - the same is true for a std::string_view and a std::span<T>. Similarly, deleting the data while a reference to it or view of it still exists leads to undefined behavior, namely "dangling references". The data stored in a reference is also similar to that of a view: both contain a pointer to the data. This is where the similarity ends, though. Firstly, a reference is not required to be implemented as a pointer or in fact anything at all. It might not take up any memory. Secondly, a view stores at least two pieces of data: a pointer (or reference) to the data and a size. More complicated views might store more, e.g. a region of an image can be represented by a row, a column, width and height of the section.

There is another difference, namely that of constness, which is what this post is about. A reference is always const in that, after it has been bound to an object (i.e. it refers to the object) it cannot ever be bound to a different object. Also, a reference must always be initialized. It is not possible to "just declare" a reference (except as a class member variable). In any case, once the reference exists, it must refer to an object. Furthermore, it is impossible to create references that refer to a reference. Initializing a reference with another reference is equivalent to initializing it with the original object. They all refer to the same object.


int main(int, char*[]) {
    // int &i; // error: 'i' declared as reference but not initialized
    int j = 2;
    int &k = j;
    k = 3;
    int &n = k; // refers to j

    int m = 0;
    // k = &m; // error: invalid conversion from 'int*' to 'int'

    return j; // returns 3
}

Views are different in this respect because they are regular types defined by the programmer or the standard library and not built-into the language like the reference. Both std::string_view and std::span<T> have default constructors that create views which do not refer to any data. Furthermore, while the std::string_view always refers to a const object, it is not const itself. It is possible to assign a new view to an existing one, thus changing the data it refers to.

A std::span<T> is more flexible still. Unless T is const, the data the span refers to can be modified through the span. This is analogous to modifying data through a reference, which is possible as long as the reference does not refer to a const type ("the reference is not const").


int main(int, char*[]) {
    int j = 2;

    // same as
    // const int &k = j;
    int const &k = j;
    
    // k = 3; // error: assignment of read-only reference 'k'

    return k; // returns 2
}

The Good?

The fact that views are not built-into the language gives rise to some behavior that might at first be surprising and, later on, annoying. The surprise lingers in the constness. Given a type T, there are four ways to create a std::span<T>:


#include <span>

int main(int, char*[]) {
    std::span<int> s1;
    std::span<const int> s2;
    const std::span<int> s3;
    const std::span<const int> s4;

    return 0;
}

What is happening here?

  • s1 refers to a sequence of ints. These ints can be read and written to. The span itself can also be changed: it is possible to assign a different span to it.
  • s2 refers to a sequence of const ints. These ints can be read but not written. The span itself can be changed, however - it is possible to assign a different span to it.
  • s3 refers to a sequence of ints which can be read and written to, just like the ints referred to by s1. The span itself is however const, i.e. it is not possible to assign a different span to it.
  • s4 refers to a sequence of const ints and just like s2, these can be read but not written. The span itself is also const.

Compared to references, there are four ways to create views that differ in constness. At this point, one question might come to mind:

If a view and a reference are so similar, why not just handle views like references?

References cannot be rebound to a different object after creation. This implies that all views should be created const, i.e. like s3 or s4:


#include <span>

int main(int, char*[]) {
    const std::span<int> s3;
    const std::span<const int> s4;

    return 0;
}

Would that be a big problem? Maybe not - C++ programmers are used to references and to not being able to rebind them. Need a span referring to something else? Make one. To be absolutely consequent, one should never create a reference to a view either, because it is impossible to create a reference to a reference. Thus, views should not be passed by reference but by value. Modifying the data that a view points to still modifies the same data as the view it was copied from:


#include <span>

void do_something(const std::span<int> span) {
    span[0] = 3;
}

int main(int, char*[]) {
    int array[8] = {0};
    const std::span<int> s3{array};

    do_something(s3);

    return array[0]; // returns 3
}

The Bad

Of course there is a cost to copying instead of passing by reference. After all, a view contains two pieces of information: a pointer to the data and a size, while a reference contains just a pointer to the data. On 64bit systems, a span might therefore be 16 bytes in size while a reference takes up eight bytes. In many cases the compiler is able to optimize the operation, however. Just like it might not create a reference in memory, it might skip the creation of the new view and generate code that performs the modifications caused by the function directly on the object.

This is only possible when the function resides in the same compilation unit (or when using LTO), however. If the function is not present in the current compilation unit, the compiler only knows its signature and has to call it with matching arguments, i.e. copy 16 bytes. This makes it tempting to pass a view by reference, especially if the view is const. Another reason to pass by reference is when the view is large: a view representing a section of a multi-dimensional array must contain the positions and sizes along each axis, along with a pointer to the data. Copying all of this is a lot more overhead than eight bytes versus 16 (which is already 100%).

We might hope that the algorithms applied to a view are computationally much slower than copying some bytes, but this might not always be the case. Imagine a function that is applied to each pixel of an image, which is laid out as a sequence of bytes. Four bytes represent the colors and possibly a transparency channel. The operation on each pixel is simple, but the function needs to be called for each pixel. Granted, this example is horribly constructed, and such code could probably easily be optimized, but not all situations are like that, and passing a view by reference might bring real-world performance improvements.

The Ugly

At this point, another difference between references and views rears its ugly head: implicit conversions, namely changes in constness. Equivalent to that is the missing const-propagation. The following is completely legal code:


int main(int, char*[]) {
    int i = 0;
    int &j = i;
    const int &k = j;
    j = 2;
    return k; // returns 2
}

Note that the reference k refers to a const int, while the reference j refers to a non-const int. Still, it is perfectly fine to initialize k from j. When trying something similar with views like std::span<T>, everything seems to work fine at first:


#include <span>

int do_something(std::span<const int> s) {
    return s[0] + 1;
}

int main(int, char*[]) {
    int array[4]{};
    const std::span<int> s3{array};
    const std::span<const int> s4{s3}; // works

    const std::span<int> s5(s4); // error: no matching function for call to 'std::span::span(const std::span&)'

    return do_something(s3); // returns 1
}

When initializing s4, it is initialized from a different type - std::span<int> and std::span<const int> are two different types and the compiler does not magically convert between them. The reason the initialization works is that there is an appropriate constructor for std::span<T>. This constructor does not allow construction of s5 from s4, however: it would create a span of writable ints from a span of const ints, i.e. discard constness, which is not allowed. This is good, because views of const data are usually const for a reason.

It is also possible to call do_something using a std::span<int>, even though it accepts a different type - std::span<const int>. For the same reason it is possible to construct s3 from s4: an appropriate constructor exists. Up to now, everything is fine and std::span<T> behaves like a reference, except that some additional copying might be involved at some points.

    +------------------------------------+
    |                                    |
    |                                    v
    T&                             const T&
    ^                                    |
    |                                    |
    +-----------------X------------------+

 

    +------------------------------------+
    |                                    |
    |                                    v
std::span<T>                         std::span<const T>
    ^                                    |
    |                                    |
    +-----------------X------------------+

I mentioned above that views are not const-propagating: a const view<T> can still be used to modify the contained Ts. References are const-propagating: a const T& cannot be used to modify the T. This is somewhat annoying, because it is not possible to write functions accepting a const view and being sure that they do not modify the data. Functions that do not modify data must accept a const view<const T>.

But My View Is Fat

...and I want to pass it by reference!

Well yes, but also no. It would be nice to not pay for the copy - passing by reference is one possible solution. This solution has a small problem with views: it does not compile in all cases where you would think it should:


#include <span>

int do_something(std::span<const int> &s) {
    return s[0];
}

int main(int, char*[]) {
    int array[8];
    std::span<int> s1{array};

    return do_something(s1); // error: cannot bind non-const lvalue reference of type 'std::span<const int>&' to an rvalue of type 'std::span<const int>'
}

It is not possible to pass a std::span<int> to a function accepting a std::span<const int>&! But it worked before?! Well no it did not - we passed the span by value - this time, the function accepts it per reference. What about the cryptic error message? Basically, what the compiler does is the following: it creates a temporary std::span<const int> and tries to bind it to the reference. It is however illegal to bind a temporary to a non-const reference, therefore the code fails to compile.

With a small change it compiles:


#include <span>

int do_something(std::span<const int> const &s) {
    return s[0];
}

int main(int, char*[]) {
    int array[8];
    const std::span<int> s1{array};

    return do_something(s1); // works!
}

The reference is now const and the temporary can be bound to it. When generating non-optimized code (-O0) it is possible to observe the creation of the temporary view, however this disappears when compiling with optimizations turned on (-O1 is sufficient).

The "downside" of course is that it is not possible to modify the original view in do_something(). You might have wanted to do exactly that. Maybe your function prepares a special view, and you would like to do it in-place. This is not C++-idiomatic though - the argument becomes an input-output argument. In C++, it is frowned upon to use output arguments.

The takeaway is: if you do not intend to change the original view (and you really should not) and you do not want to pay for the copy - then pass by const reference. The creation of the temporary will most likely be optimized away by the compiler. If you - later on in your function - want to change the view, create a new one.

There is of course the corner case that the old view is not needed anymore at the call site. It would be nice to re-use it instead of creating a new view. Especially if the view is a large object.

The Forbidden Section

This section is a preparation for the next section, which covers a tiny corner case: accepting a non-const reference to a view<const T>. Before going there, however: this problem does not exist for views of non-const T. It is always possible to write the function such that it accepts a non-const reference to view<T>:


#include <span>

int global_array[]{10,20,30,40};

void do_something(std::span<int> const &s) {
    s[0] = 1;
}

void do_other_thing(std::span<int> &s) {
    s = std::span<int>{global_array};
}

void do_nothing(std::span<int> const& s) {
    s[0] = 2;
}

int main(int, char*[]) {
    int array[8]{};
    std::span<int> s1{array};
    std::span<const int> s2{s1};

    // do_nothing(s2); // error: invalid initialization of reference of type 'const std::span<int>&' from expression of type 'std::span<const int>'

    do_something(s1);
    do_other_thing(s1);
    return s1[0]; // returns 10
}

Here, do_something() changed the data that the std::span<int> refers to. In do_other_thing(), the span is changed to point to completely different data! This is extremely awkward and should be avoided at all costs. It makes it hard to understand program flow. The underlying mistake is that a non-const view is created for do_other_thing(). As outlined above, views should always be const.

The other case is easily dismissed: it is not allowed to pass a view<const T> to a function accepting any view<T>, with non-const T. It does not matter if the view is passed per value or per reference - there is simply never a valid reason to change something that is const to a non-const type. Therefore, the compilation fails for the call to do_nothing(s2).

It is of course also allowed to write do_someting() and do_other_thing() as accepting values instead of references. Doing so might cause additional copying overhead, but some might argue it is more C++-idiomatic. Personally, I think that as long as a view<T> with non-const T is itself const, it is fine - whether it is a reference or not does not matter. Chose one style and stick to it.


// Choose one style.
void do_one(const std::span<int> s);
void do_one(std::span<int> const &s);

Pigs Can Now Fly

There is the elephant in the room - it is still not clear how to work around initializing non-const references to view<const T>, with view<T>. First off - if the view is a standard library type: pass it by value. None of the standard library views are large enough to cause performance problems when they are copied. If copying is out of question, check if it is possible to pass it by const reference. The creation of the temporary can be optimized out in most cases.

There is a way to make things work as long as the view is a custom class that can be changed - but before looking at the following code, consider these options:

  • Are you using output parameters? Don't. Accept a const reference and return a new view. Use LTO or put the function into a header file and declare it inline.
  • Do you want to re-use an existing view in the name of saving stack/heap space? Don't pass by non-const reference - pass by const reference, make a new const view with your changes and use LTO. Alternatively, declare the function inline and put it into a header file.
  • Some other reason. Your program structure likely sucks or you do need to read up again on the difference between const view<T> and view<const T>.

For the extra stubborn, as long as the view is a custom class (please do not try and modify the standard library), here is a little trick that might sometimes work:


template <typename T>
class view final {
public:
    using element_type = T;
    using pointer = element_type*;

    view() = default;
    ~view() = default;

    pointer data() const noexcept {return m_ptr;}

    view(pointer ptr) : m_ptr{ptr} {}

    operator view<const element_type>&() & noexcept { return reinterpret_cast<view<const element_type>&>(*this); }
    operator view<const element_type> const&() const & noexcept { return reinterpret_cast<view<const element_type> const&>(*this); }

private:
    pointer m_ptr{nullptr};
};

int do_something(view<const int> const &v) {
    return *v.data();
}

int do_other_thing(view<const int> &v) {
    return *v.data();
}

int main(int, char*[]) {
    int i{10};
    view<int> v1{&i};

    return do_other_thing(v1);
}

This code of course has huge problems: non-const views are created, views are passed per non-const reference such that the views could be modified. It is also not super cleaned up, it would require more type_traits magic to make it work for more types. Feel free to send me suggestions for any of the code on this page. I will probably refuse to create an elaborate view class that allows for implicit conversion to reference of view<const T> - it is not meant to be used.

Also, pigs can now fly. Probably. Do not use reinterpret_cast. And if you enjoy this little trick, do not tell anyone where you got it from.

© 2010-2021 Stefan Birgmeier
sbirgmeier@21er.org