4

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
miwe
  • 43
  • 3
  • Does it have to be a single class? _Two_ classes (say, `BufferWrapper` and `BufferWrapper`) could solve your problem easily. – Igor G Feb 03 '21 at 14:47
  • 1
    if `BufferWrapper` can be made a template then the non-const `operator[]` can be disabled using `std::enable_if` in case a non-const byte array is used - no need to create two classes. – Eran Feb 03 '21 at 15:03

2 Answers2

1

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
  • 232,697
  • 12
  • 197
  • 326
  • Thanks for your detailed answer. As for the format specifiers: for `uint8_t` it should be `%02hhx` -- I understand that this can lead to UB depending on the calling conventions (it may be that one byte is pushed onto the stack whereas `printf()` expects `sizeof(unsigned int)`. I'd like to learn why `%p` should be UB for `const unsigned char (*)[N]`-- technically, a pointer is passed, and the length of addresses is not dependent on how the contents of this address shall be interpreted. Or is UB for `%p` just a lack of formal approval by the standard? – miwe Feb 05 '21 at 11:17
  • Still considering the format specifier sub-topic, I stumbled upon https://stackoverflow.com/questions/22844360/what-default-promotions-of-types-are-there-in-the-variadic-arguments-list which says that shorter integers are promoted; I guess this would mean that `%02x` is still correct? -- Anyway, do you have recommendations for proper selection of format specifiers when the types are given? – miwe Feb 05 '21 at 11:21
  • @miwe The issue with `%02x` is one of signedness. `uint8_t` promotes to `int` (except on some exotic architectures) which is not allowed with `%x` which requires `unsigned int`. `the length of addresses is not dependent on how the contents of this address shall be interpreted` You assume wrongly. The type of the pointer may affect the size of the pointer. Besides, the standard is stricter than requiring merely the size be correct. `%p` requires `void*`. – eerorika Feb 05 '21 at 11:44
  • @miwe `for proper selection of format specifiers when the types are given?` The correct format specifiers are listed in the C standard. Use `unsigned int ... %x`, `void* ... %p`, `uint8_t ... PRIx8` – eerorika Feb 05 '21 at 11:48
0

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;
}
miwe
  • 43
  • 3