type-punning and strict aliasing rule for array of objects

164 Views Asked by At

There is already a lot of posts about strict aliasing rule and type-punning but I couldn't find an explanation that I could understand regarding array of objects. My goal is to have a memory pool non-template class that is used to store arrays of objects. Basically I need to know the actual type only at access time: it can be seen as a non template vector whose iterators would be template. The design I thought of rises several questions so I will try to split them into several SO questions.

My question (which is the first one, see below) is does the following code breaks the strict-aliasing-rule (is it UB?) and, if not, why (I suspect it to be correct but strict-aliasing-rule discussions made me cautious).

#include <cassert>
#include <iostream>
#include <type_traits>

// type that support initialisation from a single double value
using test_t = float;

// just for the sake of the example: p points to at least a sequence of 3 test_t
void load(test_t* p) {
    std::cout << "starting load\n";
    p[0] = static_cast<test_t>(3.14);
    p[1] = static_cast<test_t>(31.4);
    p[2] = static_cast<test_t>(314.);
    std::cout << "ending load\n";
}

// type-punning buffer
// holds a non-typed buffer (actually a char*) that can be used to store any
// types, according to user needs
struct Buffer {
    // buffer address
    char* p = nullptr;
    // number of stored elements
    size_t n = 0;
    // buffer size in bytes
    size_t s = 0;
    // allocates a char buffer large enough for N object of type T and
    // default-construct them
    // calling it on a previously allocated buffer without adequate call to
    // Deallocate is UB
    template <typename T>
    T* DefaultAllocate(const size_t N) {
        size_t RequiredSize =
            sizeof(std::aligned_storage_t<sizeof(T), alignof(T)>) * N;
        n = N;
        T* tmp;
        if (s < RequiredSize) {
            if (p) {
                delete[] p;
            }
            s = RequiredSize;
            std::cout << "Requiring " << RequiredSize << " bytes of storage\n";
            p = new char[s];
            // placement array default construction
            tmp = new (p) T[N];
            // T* tmp = reinterpret_cast<T*>(p);
            // // optional for arithmetic types and also for trivially
            // destructible
            // // types when we don't care about default values
            // for (size_t i = 0; i < n; ++i) {
            //     new (tmp + i) T();
            // }
        } else {
            // placement array default construction
            tmp = new (p) T[N];
            // T* tmp = reinterpret_cast<T*>(p);
            // // optional for arithmetic types and also for trivially
            // destructible
            // // types when we don't care about default values
            // for (size_t i = 0; i < n; ++i) {
            //     new (tmp + i) T();
            // }
        }
        return tmp;
    }
    // deallocate objects in buffer but not the buffer itself
    template <typename T>
    void Deallocate() {
        T* tmp = reinterpret_cast<T*>(p);
        // Delete elements in reverse order of creation
        // optional for default destructible types
        for (size_t i = 0; i < n; ++i) {
            tmp[n - 1 - i].~T();
        }
        n = 0;
    }
    ~Buffer() {
        if (p) {
            delete[] p;
        }
    }
};

int main() {
    constexpr std::size_t N = 3;
    Buffer B;
    test_t* fb = B.DefaultAllocate<test_t>(N);
    load(fb);
    std::cout << fb[0] << '\n';
    std::cout << fb[1] << '\n';
    std::cout << fb[2] << '\n';
    std::cout << alignof(test_t) << '\t' << sizeof(test_t) << '\n';
    B.Deallocate<test_t>();
    return 0;
}

Live
Live more complex

NB: I'm using C++14 but I'm interested also in how it would be done in more recent standard versions.

link to question 2
link to question 3

[EDIT] this answer to question 3 shows that my C++14 snippet above might not be properly aligned: here is a proposed better version inspired from the referenced answer.
subsidiary question: why does gcc issues a warning in godbolt version? I nether required inlining of default constructor?

I leave it also below for the record.
The interesting part, IMHO, is the use of std::function with lambda to type-erased the destructors and deleters.
My question regarding the correctness of this code is still valid:

  • is there an UB?
  • if the strict aliasing rule is not violated, why?
  • btw: what, in the standard, describes the strict aliasing rule?
#include <cstddef>
#include <functional>
#include <iostream>

// Object constructible from a double
// forcing alignment
struct alignas(16) SFloat {
    float val = 0.f;
    SFloat() { std::cout << "Constructing a SFloat with default value\n"; };
    SFloat(const double v) : val(static_cast<float>(v)) {
        std::cout << "Constructing a SFloat from " << v << '\n';
    };
    SFloat& operator=(SFloat&& f) {
        val = f.val;
        std::cout << "Move-assigning from a SFloat " << f.val << '\n';
        return *this;
    }
    ~SFloat() { std::cout << "Destructing a SFloat holding " << val << '\n'; }
};
// Serialization of Float objects
std::ostream& operator<<(std::ostream& o, SFloat const& f) {
    return o << f.val;
}

// just for the sake of the example: p points to at least a sequence of 3 T
// probably not the best implem, but compiles without conversion warning with
// SFloat and float.
template <typename T>
void load(T* p) {
    std::cout << "starting load\n";
    p[0] = static_cast<T>(3.14);
    p[1] = static_cast<T>(31.4);
    p[2] = static_cast<T>(314.);
    std::cout << "ending load\n";
}

// type-punning reusable buffer
// holds a non-typed buffer (actually a char*) that can be used to store any
// types, according to user needs
struct Buffer {
    // destructing functor storage
    // required to call the correct object destructors
    std::function<void()> Destructors = [] {};
    // freeing functor storage
    // required to call the correct buffer deleter
    std::function<void()> FreeBuf = [] {};
    // buffer address
    char* p = nullptr;
    // number of stored elements
    size_t n = 0;
    // buffer size in bytes
    size_t s = 0;
    // allocates a char buffer large enough for N object of type T and
    // default-construct them
    // calling it on a previously allocated buffer without adequate call to
    // Deallocate is UB
    template <typename T>
    T* DefaultAllocate(const size_t N) {
        // Destroy previously stored objects
        Destructors();

        size_t RequiredSize =
            sizeof(std::aligned_storage_t<sizeof(T), alignof(T)>) * N;
        if ((s < RequiredSize) ||
            (reinterpret_cast<std::size_t>(p) % alignof(T) != 0)) {
            // not enough room or misaligned
            FreeBuf();
            s = RequiredSize;
            std::cout << "Requiring " << RequiredSize << " bytes of storage\n";
            // using new and std::aligned_storage_t to provide correct alignment
            p = reinterpret_cast<char*>(
                new std::aligned_storage_t<sizeof(T), alignof(T)>[N]);
            // create/update free functor
            FreeBuf = [this] {
                std::aligned_storage_t<sizeof(T), alignof(T)>* ToFree =
                    reinterpret_cast<
                        std::aligned_storage_t<sizeof(T), alignof(T)>*>(p);
                delete[] ToFree;
            };
        }
        // placement array default construction
        T* tmp = new (p) T[N];
        // update nb of objects
        n = N;
        // create destructors functor
        Destructors = [this] {
            T* ToDestruct = reinterpret_cast<T*>(p);
            // Delete elements in reverse order of creation
            while (n > 0) {
                --n;
                ToDestruct[n].~T();
            }
        };
        return tmp;
    }
    // deallocate objects in buffer but not the buffer itself
    template <typename T>
    void Deallocate() {
        Destructors();
    }
    ~Buffer() {
        Destructors();
        FreeBuf();
    }
};

int main() {
    constexpr std::size_t N0 = 7;
    constexpr std::size_t N1 = 3;
    Buffer B;
    std::cout << "Test on Float\n";
    SFloat* Fb = B.DefaultAllocate<SFloat>(N0);
    load(Fb);
    std::cout << Fb[0] << '\n';
    std::cout << Fb[1] << '\n';
    std::cout << Fb[2] << '\n';
    std::cout << alignof(SFloat) << '\t' << sizeof(SFloat) << '\n';
    std::cout << "Test on float\n";
    // reallocating, possibly using existing storage
    float* fb = B.DefaultAllocate<float>(N1);
    load(fb);
    std::cout << fb[0] << '\n';
    std::cout << fb[1] << '\n';
    std::cout << fb[2] << '\n';
    std::cout << alignof(float) << '\t' << sizeof(float) << '\n';
    return 0;
}
0

There are 0 best solutions below