case dependent mutability of wrapper classes in C++ (e.g. uint8_t array wrapper)

Viewed 132

How can I define a wrapper for a referenced object (as in association, not composition) that

  • is or behaves as const if the referenced object itself is const
  • is mutable if the referenced object is also mutable?

My concrete issue: I am writing a function that internally deals with POD uint8_t[] arrays, but is supposed to interface to the outside world with a wrapper class like

class BufferWrapper
{
public:
    BufferWrapper(uint8_t* pau8, size_t ui) : m_pau8{pau8}, m_uiSize{ui} {}

    uint8_t& operator[](size_t ui) { return m_pau8[ui]; }
    const uint8_t& operator[](size_t ui) const { return m_pau8[ui]; }

    size_t length() const { return m_uiSize; }

    /* other convenience functions ... */
private:
    uint8_t* m_pau8;
    size_t   m_uiSize;
};

I have written a convenience conversion template function from uint8_t[SIZE] to BufferWrapper. (I know it only works for arrays where the size is known at compile time.)

template<typename T> BufferWrapper wrapArray(T& t)
{
    return BufferWrapper(t, sizeof(t));
}

This works well as long as the arrays are mutable, but obviously fails to compile if the actual data source is a const uint8_t[] as calling the BufferWrapper constructor would cast away the constness of the source array.

What I would like to have is a const BufferWrapper object that references a const uint8_t[], but which should not be implicitly changeable to non-const.

I came up with code that compiles by overloading the function with a const T& parameter type and using const_cast inside.

template<typename T> const BufferWrapper wrapArray(const T& t)
{
    return BufferWrapper(const_cast<T&>(t), sizeof(t));
}

However, this is a bad solution because the const return type is dropped when copy-constructing another object, like in

BufferWrapper newObject = wrapArray(my_const_uint8_array);

which compiles, even though it should not.

I have found two different solutions for similar problems:

Do you have any better solution?

Here is a self-contained working example

#include <cinttypes>
#include <cstddef>
#include <cstdio>
#include <cstring>

class BufferWrapper
{
public:
    BufferWrapper(uint8_t* pau8, size_t ui) : m_pau8{pau8}, m_uiSize{ui} {}
    void fill(uint8_t u8) { memset(m_pau8, u8, m_uiSize); }

    uint8_t& operator[](size_t ui) { return m_pau8[ui]; }
    const uint8_t& operator[](size_t ui) const { return m_pau8[ui]; }

    size_t length() const { return m_uiSize; }

    bool operator==(const BufferWrapper& rcco)
    {
        return (m_uiSize == rcco.m_uiSize) // size equality
        && (0 == memcmp(m_pau8, rcco.m_pau8, m_uiSize)); // and content equality
    }
private:
    uint8_t* m_pau8;
    size_t   m_uiSize;
};

// example for data consumer that accepts a BufferWrapper object
void readDataFromBuffer(const BufferWrapper& rcco)
{
    for(size_t ui=0; ui<rcco.length(); ++ui)
    {
        printf("%02x", rcco[ui]);
    }
    printf("\n");
}

// convenience function to capture length of arrays
// (only works on arrays, not on pointers -- I know)
template<typename T> BufferWrapper wrapArray(T& t)
{
    printf("BufferWrapper, ptr=%p, size=%zu\n", &t, sizeof(t));
    return BufferWrapper(t, sizeof(t));
}

template<typename T> const BufferWrapper wrapArray(const T& t)
{
    printf("const BufferWrapper, ptr=%p, size=%zu\n", &t, sizeof(t));
    return BufferWrapper(const_cast<T&>(t), sizeof(t));
}

int main()
{
    uint8_t au8[]            = { 0xde, 0xad, 0xbe, 0xef };
    constexpr uint8_t cau8[] = { 0xba, 0xaa, 0xad, 0xc0, 0xde };
    
    readDataFromBuffer(wrapArray(au8));
    readDataFromBuffer(wrapArray(cau8));

    // this should _not_ compile, as it casts away the const of the Buffer object
    BufferWrapper coFoo = wrapArray(cau8); 
    coFoo[0] = 0xde;
    coFoo[1] = 0xee;
    readDataFromBuffer(coFoo);

    return 0;
}

Output (it can be seen that the contents of an actually const variable was changed):

$ clang -Wall toy_example.cpp 
$ ./a.out 
BufferWrapper, ptr=0x7fff647544d4, size=4
deadbeef
const BufferWrapper, ptr=0x7fff647544cc, size=5
baaaadc0de
const BufferWrapper, ptr=0x7fff647544cc, size=5
deeeadc0de
2 Answers

What is usually done, is to have separate types for a non-const wrapper and const. In your case, perhaps the names might be BufferWrapper and ConstBufferWrapper where the latter contains a pointer to const instead of pointer to non-const. The former class can be made implicitly convertible to the latter. Using a template, there wouldn't even need to be any repetition.


P.S. %02x is an invalid format specifier for std::uint8_t, so behaviour of your program is undefined (besides the UB from modifying const buffer). Likewise %p is an invalid format specifier for const unsigned char (*)[N].


P.P.S. The standard has a generic wrapper such as your BufferWrapper since C++20 by the name std::span. It does however share the propererty of BufferWrapper that it doesn't propagate constness to the pointed object.

As such, you could use a pre-existing span implementation:

void readDataFromBuffer(std::span<const std::uint8_t> rcco) {
    for(unsigned u : rcco) { // note the correct type for %02x
        std::printf("%02x", u);
    }
    std::printf("\n");
}

// ...

readDataFromBuffer(au8);  // OK
readDataFromBuffer(cau8); // OK

Both of these will fail to compile as desired:

std::span<const std::uint8_t> coFoo = cau8; // OK
coFoo[0] = 0xde;                            // does not compile

// alternative
std::span<std::uint8_t> coFoo = cau8;       // does not compile
coFoo[0] = 0xde;                            // would have been OK

eerorika's answer in fact already solves the issue -- I'd use std::span from C++20 as well.

For reference, I still worked on updating my sample code, first to show a full working code, and second to address the following issues:

  • automatic conversion from non-const to const referenced type using the overloaded cast operator operator TArrayWrapper<const U>()
  • The BufferWrapper class I was using had some convenience functions (here: fill()) or the to-const type conversion operator that only makes sense for non-const referenced types. I was not sure whether it works to have template classes where some types only support a subset of the given methods. This is why I wanted to try out eran's suggestion of using std::enable_if for conditional inclusion of certain methods.
  • (to have something that works on compilers without C++20 support)

As a side note: I replaced the name BufferWrapper by TArrayWrapper (and memcmp/memset by explicit loops such that it allows generic utilization for other types too) to indicate that it would work on arrays of types other than uint8_t as well.

#include <cinttypes>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <type_traits>

template<typename T> class TArrayWrapper
{
public:
    TArrayWrapper(T* pT, size_t ui) : m_pT{pT}, m_uiSize{ui} {}

    template<typename U = T, typename std::enable_if<std::is_const<U>::value == false, int>::type = 0>
    void fill(T t)
    {
        for(size_t ui=0; ui<m_uiSize; ++ui)
        {
            m_pT[ui] = t;
        }
    }

    template<typename U = T, typename std::enable_if<std::is_const<U>::value == false, int>::type = 0>
    T& operator[](size_t ui) { return m_pT[ui]; }

    const T& operator[](size_t ui) const { return m_pT[ui]; }

    template<typename U = T, typename std::enable_if<std::is_const<U>::value == false, int>::type = 0>
    operator TArrayWrapper<const U>() { return TArrayWrapper<const U>(m_pT, m_uiSize); }

    size_t length() const { return m_uiSize; }

    bool operator==(const TArrayWrapper<T>& rcco)
    {
        if(m_uiSize != rcco.m_uiSize)
        {
            return false;
        }
        for(size_t ui=0; ui<m_uiSize; ++ui)
        {
            if (m_pT[ui] != rcco.m_pT[ui])
            {
                return false;
            }
        }
        return true;
    }
private:
    T*     m_pT;
    size_t m_uiSize;
};

using ConstBufferWrapper = TArrayWrapper<const uint8_t>;
using BufferWrapper      = TArrayWrapper<uint8_t>;

void readDataFromBuffer(const ConstBufferWrapper& rcco)
{
    for(size_t ui=0; ui<rcco.length(); ++ui)
    {
        printf("%02" PRIx8, rcco[ui]);
    }
    printf("\n");
}

// convenience function to capture length of arrays
template<typename T> TArrayWrapper<typename std::remove_extent<T>::type> wrapArray(T& t)
{
    printf("TArrayWrapper, ptr=%p, size=%zu\n", static_cast<const void*>(&t), sizeof(t));
    return TArrayWrapper<typename std::remove_extent<T>::type>(t, sizeof(t));
}

int main()
{
    uint8_t au8[]            = { 0xde, 0xad, 0xbe, 0xef };
    constexpr uint8_t cau8[] = { 0xba, 0xaa, 0xad, 0xc0, 0xde };

    readDataFromBuffer(wrapArray(au8));
    readDataFromBuffer(wrapArray(cau8));

    BufferWrapper coTest = wrapArray(au8);
    coTest.fill(0xff);
    readDataFromBuffer(coTest);

    ConstBufferWrapper coFoo = wrapArray(cau8);
    //coFoo[0] = 0xde; // does not compile, as desired:
    //coFoo[1] = 0xee; // coFoo shall not allow write access to referenced buffer
    readDataFromBuffer(coFoo);

    return 0;
}
Related