Search code examples
c++c++20c++-concepts

C++ concept for read and writable containers with const safety while writing


I would like to create a concept which allows me to define a single function for each read and write from/to a data container. The data container should have a .data() and a .size() methods, so that the size can be checked before reading/writing. Additionally, for the writing function, the datatype should be marked const, so that I cannot change the variable which is pointing to, or its content by mistake.

As example I would like to support datatypes like std::vector and if possible std::span if I only have the pointer and the size separated.

To check if a container has both methods, I came up with

template <typename T>
concept HasDataAndSize =
    requires(T t) { t.size(); } && requires(T t) { t.data(); } &&
    requires(T t) { std::is_same<decltype(t.size()), std::size_t>::value; } &&
    requires(T t) { std::is_pointer<decltype(t.data())>::value; };

The problem is, if I create a function

template<HasDataAndSize T> void write(const T& data)

the data is writable for a std::span object.

Additionally, if I create a reading function

template<HasDataAndSize T> void read(T read)

std::span works, however containers like std::vector are copied and the change accessible outside the function. If I use a T & std::vector works, but I cannot use std::span<> from a temporary like read(std::span{data_ptr, data_size})

Currently I think I only can allow containers, which do not allow changing the ptr inside it, like std::span can. However, my concept still lets std::span through, and I don't know how to stop it.

Or is there a better solution? / reason why I should use another design?

I created a small example to play around with: https://godbolt.org/z/nT7jWve91

To clarify, I am writing on a library which, among other things, has to write data from different data containers into a file or read the content from the file into the data.


Solution

  • Let's start with this:

    template <typename T>
    concept HasDataAndSize =
        requires(T t) { t.size(); } && requires(T t) { t.data(); } &&
        requires(T t) { std::is_same<decltype(t.size()), std::size_t>::value; } &&
        requires(T t) { std::is_pointer<decltype(t.data())>::value; };
    

    First and foremost, there is no need to separate everything into its own requires-expression, this adds a lot of syntax with no benefit. This is equivalent to:

    template <typename T>
    concept HasDataAndSize =
        requires(T t) {
            t.size();
            t.data();
            std::is_same<decltype(t.size()), std::size_t>::value; // (1)
            std::is_pointer<decltype(t.data())>::value;           // (2)
        };
    

    Next, the lines marked (1) and (2) above don't... actually do what you think they do. Indeed, they don't do anything at all. What you're checking is that the expression std::is_same<T, U>::value is a valid expression - which of course it is, regardless of T and U. You're not actually checking that the types are the same.

    In order to do that, you need to either prefix the expression with requires or use the compound requirement syntax:

    template <typename T>
    concept HasDataAndSize =
        requires(T t) {
            // t.size() needs to be size_t
            { t.size() } -> std::same_as<size_t>;
    
            // t.data() needs to be valid and have pointer type
            t.data();
            requires std::is_pointer_v<decltype(t.data())>;
        };
    

    Once we get there, the you have this other problem

    template<HasDataAndSize T> void write(const T& data)
    

    This is checking that T has data() and size(), but that's not really the relevant question here, since data isn't a T, it's a T const. Here, you need to do:

    template <class T>
        requires HasDataAndSize<T const>
    void write(const T& data)
    

    or introduce a helper concept:

    template <class T> concept HasConstDataAndSize = HasDataAndSize<T const>;
    template<HasConstDataAndSize T> void write(const T& data)
    

    or use a forwarding reference (which will ensure that T is deduced in a way that's useful for concepts):

    template<HasDataAndSize T> void write(T&& data)
    

    Lastly, this is more of an API issue, but it's probably better to eschew this entirely and just write:

    void write(std::span<std::byte> );
    void read(std::span<std::byte const> );
    

    Although generally I don't really understand what the API here is supposed to do.