More on C++ Views

In my last post I explored the special relationship of views and constness. If you have not read it yet, I suggest doing so in order to better understand this post. For example, it explains that "view" refers to classes that do not own the data they refer to, as opposed to e.g. std::view. This post is about writing versatile custom view classes.

Rules Revisited

In the last post, some principles have been found:

  • Create const views only.
    Views behave like references and should be handled as such. A reference cannot be re-bound to a different object - neither should views. If a different view is required, create a new one. The compiler will optimize the copy.
  • Pass views by value or const reference.
    Since all views are const, views must be passed by const reference or by value. If the analogy with references is strictly followed, they must be passed by value, since no references to references exist. It also makes it more obvious for the programmer that they do not need to worry about lifetime. On the other hand, passing by const reference is more efficient when not using LTO and only the declaration of the function being called is present in the current compilation unit.
  • Store views by value.
    Views handle like references. When passing a reference to a function, there is no need to worry about the lifetime of the reference itself (only the lifetime of the object the reference refers to). Similarly, the caller of a function accepting a reference to a view should never have to worry about the lifetime of their view object. This is especially true when the called function is a constructor.
  • Views live on the stack.
    A view object is indended to be lightweight. Often, it is used to represent a "heavy" object that lives on the heap. As such, the view should not need to allocate dynamic memory, because that would invalidate its purpose. Using dynamic memory would also change the move semantics with regard to views in a way that is unexpected by the programmer. See below.

One might feel that all of this is rubbish. The language allows to create non-const views, so why not do it? After all, why not pass mutable references to views? The language also allows usage of output parameters, which is still considered bad style. The sole reason for passing a mutable reference to a view is to change it, making the parameter an output parameter. Instead, pass by const reference or by value and return the modified view. If the function already returns something else then it is probably doing too much already and needs to be refactored.

Custom View Classes

The last post outlines that there are four different ways to create views. These differ in constness. Following the rules, only two are allowed to be used. For sake of completeness all of them will be included below, however.

This section is about making sure that the custom view class works in all possible scenarios. There are four ways to create views that differ by constness. Regarding initialization, there are assignment and copy construction. These can again be distinguished based on whether they accept rvalue or lvalue references. There is also initialization from view-defining data, which might be a pointer and a size. Not all of them are allowed by the language, e.g. a view<T> can never be initialized by a view<const T>. This is because the view's internal pointer would lose its constness during construction, which is a compile time error. This is good - when a view<const T> is used, the T is usually const for a reason and it should stay that way.

Result type Initialization mechanism Initialization value Works?
view<T> pointer, size... T* Yes
view<T> pointer, size... const T* No
view<T> copy view<T>& Yes
view<T> copy view<T> const& Yes
view<T> copy view<const T>& No
view<T> copy view<const T> const& No
view<T> move view<T>&& Yes
view<T> move view<const T>&& No
view<T> copy assignment view<T>& Yes
view<T> copy assignment view<T> const& Yes
view<T> copy assignment view<const T>& No
view<T> copy assignment view<const T> const& No
view<T> move assignment view<T>&& Yes
view<T> move assignment view<const T>&& No
view<const T> pointer, size... T* Yes
view<const T> pointer, size... const T* Yes
view<const T> copy view<T>& Yes
view<const T> copy view<T> const& Yes
view<const T> copy view<const T>& Yes
view<const T> copy view<const T> const& Yes
view<const T> move view<T>&& Yes
view<const T> move view<const T>&& Yes
view<const T> copy assignment view<T>& Yes
view<const T> copy assignment view<T> const& Yes
view<const T> copy assignment view<const T>& Yes
view<const T> copy assignment view<const T> const& Yes
view<const T> move assignment view<T>&& Yes
view<const T> move assignment view<const T>&& Yes

Note:

  • A view<const T> is more versatile - it can be constructed from anything (if the class is carefully written).
  • Construction by move is not special - it usually performs the same function as construction by lvalue reference. This is because views do not own the data they refer to and the bookkeeping data (i.e. the size, or other data accompanying the pointer) is usually allocated on the stack.
    If the view allocates dynamic memory (which it should not) then the move is special and a moved-from object will not retain its state. However, one should never count on a move to keep the original object untouched (even if it is a view).

The above table is not helpful when it comes to passing views by reference. Again, there are several cases, depending on constness and whether an lvalue or rvalue reference is passed.

Argument type Mechanism Passed value Works?
view<T>& reference copy view<T>& Yes
view<T>& reference copy view<T> const& No
view<T>& temporary value view<T>&& No
view<T> const& reference copy view<T>& Yes
view<T> const& reference copy view<T> const& Yes
view<T> const& temporary value view<T>&& Yes
view<const T>& temporary value view<T>& No*
view<const T>& temporary value view<T> const& No
view<const T>& temporary value view<T>&& No
view<const T> const& temporary value view<T>& Yes
view<const T> const& temporary value view<T> const& Yes
view<const T> const& temporary value view<T>&& Yes
view<const T>& reference copy view<const T>& Yes
view<const T>& reference copy view<const T> const& No
view<const T>& temporary value view<const T>&& No
view<const T> const& reference copy view<const T>& Yes
view<const T> const& reference copy view<const T> const& Yes
view<const T> const& temporary value view<const T>&& Yes

Note:

  • Passing by view<const T> const& is most versatile.
  • Passing by value is even better if you feel that you need mutable views.

In the second table, the cases where initialization of a view<T> from a view<const T> is attempted were omitted right away. All of those also fail in the first table. What also fails is initialization of mutable references from const references. Another reason for failure is initialization of a mutable reference with a temporary. A temporary object is created when accepting an lvalue reference, and an rvalue reference is passed. The language specifies that such a temporary is only acceptable if the reference is const.

This leaves one case in the second table which is marked by an asterisk. Note that this only happens when disregarding the main rule of passing views: i.e. when the view is passed by mutable reference. The function call fails. In order to obtain a view<const T>& from a view<T>&, a temporary view<const T> must be created. As explained above, such a temporary can only be bound to a const reference. This is the only case that can be "solved" using the dirty hack outlined in the forbidden section of the last post. Really, you should never have to use it. It's a sign of bad code and bad architechture.

As a purely temporary fix, until the architecture is fixed, a view<const T> temporary can be created manually and passed to the function. Afterwards, the original view can be adapted using the state of the temporary after the function call. It will not be possible to change the pointer of the original view by assigning from the temporary, however - the temporary has a const T pointer. Now it is possible to use pointer arithmetic to find out how much the pointer of the temporary was changed... Obviously, these are extreme hacks. Avoid them at all costs.

Example Class

Below, an example of a custom view is shown. Additionally, examples (in order) for each of the cases in both tables are shown.


#include <type_traits>
#include <cstdint>
#include <algorithm>

struct Region {
    std::uint8_t u8{};
    std::uint16_t u16{};
    std::uint32_t u32{};
};

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

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

    constexpr pointer const& data() const noexcept {return m_ptr;}
    constexpr pointer& data() noexcept {return m_ptr;}

    constexpr Region const& region() const {return m_region; }
    constexpr Region& region() {return m_region;}

    explicit constexpr view(pointer ptr, Region r) : m_ptr{ptr}, m_region{std::move(r)} {}
    
    template <typename U>
    constexpr view(view<U> const& v) : view(v.data(), v.region()) {}

    template <typename U>
    constexpr view(view<U>&& v) : view(v.data(), v.region()) {}

    constexpr view(view const&) = default;
    constexpr view(view&&) = default;

    constexpr view& operator=(view const&) & = default;
    constexpr view& operator=(view&&) & = default;

private:
    pointer m_ptr{nullptr};
    Region m_region;
};

void accept_int_mutable_ref(view<int> &);
void accept_int_const_ref(view<int> const&);
void accept_const_int_mutable_ref(view<const int>&);
void accept_const_int_const_ref(view<const int> const&);

// Calls are equivalent to initialization by reference or move.
void accept_int_value(view<int>);
void accept_const_int_value(view<const int>);


int main(int, char*[]) {
    Region r{1,2,3};
    int i{10};
    const int j{11};

    // input objects
    view<int> vi{&i, r};
    view<const int> vci{&i, r};
    const view<int> cvi{&i, r};
    const view<const int> cvci{&i, r};

    // pointer, size...
    view<int> v1{&i, r};
    // view<int> v2{&j, r}; // Initialization from const int

    // copy
    view<int> v3{vi};
    view<int> v4{cvi};
    // view<int> v5{vci}; // Initialization from const int
    // view<int> v6{cvci}; // Initialization from const int

    // move
    view<int> v7{std::move(vi)};
    // view<int> v8{std::move(vci)}; // Initialization from const int

    // copy assignment
    view<int> v9 = vi;
    view<int> v10 = cvi;
    // view<int> v11 = vci; // Initialization from const int
    // view<int> v12 = cvci; // Initialization from const int

    // move assignment
    view<int> v13 = std::move(vi);
    // view<int> v15 = std::move(vci); // Initialization from const int

    view<const int> w1{&i, r};
    view<const int> w2{&j, r};

    // copy
    view<const int> w3{vi};
    view<const int> w4{cvi};
    view<const int> w5{vci};
    view<const int> w6{cvci};

    // move
    view<const int> w7{std::move(vi)};
    view<const int> w8{std::move(vci)};

    // copy assignment
    view<const int> w9 = vi;
    view<const int> w10 = cvi;
    view<const int> w11 = vci;
    view<const int> w12 = cvci;

    // move assignemnt
    view<const int> w13 = std::move(vi);
    view<const int> w14 = std::move(vci);

    /***** function calls *****/
    accept_int_mutable_ref(vi);
    // accept_int_mutable_ref(cvi); // Const value for non-const reference
    // accept_int_mutable_ref(std::move(vi)); // Cannot bind temporary to non-const reference
    accept_int_const_ref(vi);
    accept_int_const_ref(cvi);
    accept_int_const_ref(std::move(vi));

    // accept_const_int_mutable_ref(vi); // Cannot bind temporary to non-const reference
    // accept_const_int_mutable_ref(cvi); // Const value for non-const reference
    // accept_const_int_mutable_ref(std::move(vi)); // Cannot bind temporary to non-const reference
    accept_const_int_const_ref(vi);
    accept_const_int_const_ref(cvi);
    accept_const_int_const_ref(std::move(vi));

    accept_const_int_mutable_ref(vci);
    // accept_const_int_mutable_ref(cvci); // Const value for non-const reference
    // accept_const_int_mutable_ref(std::move(vci)); // Cannot bind temporary to non-const reference
    accept_const_int_const_ref(vci);
    accept_const_int_const_ref(cvci);
    accept_const_int_const_ref(std::move(vci));

    return 0;
}

Note:

  • The template constructors are required to enable copy and move construction from views of T with different constness of T. For the case of mutable T, they are selected when trying to construct from view<const T>, which will fail since it would involve an assignment of T* = const T*. For the case of const T, they are selected when trying to construct from view<T>, which works fine. In either case, the regular (defaulted) copy/move constructors take precedence when copying/moving from an object with same constness of T, because regular functions take precedence over template functions in overload resolution. There are other, invalid ways, to implement constructors that allow construction from objects with different constness of T which seem to work at first and break down later. I am also not sure if the way I did it, with two templates, is ideal - however it seems to work as expected in all cases outlined above.
  • Function calls with value-arguments behave identical to construction by copy or move and are therefore not shown. The only difference is that these calls might involve more overhead when the callee is not in the same compilation unit and LTO is not used.
  • All member functions of the view are constexpr. This allows for compile-time unit testing, which is a very powerful technique in my opinion. More on this in a later post, maybe.
  • You can find the code on Godbolt here.
© 2010-2021 Stefan Birgmeier
sbirgmeier@21er.org