0

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;
}
Oersted
  • 769
  • 16
  • In `Deallocate()`: `T* tmp = reinterpret_cast(p);` should be `T* tmp = std::launder(reinterpret_cast(p));`. – Fareanor Aug 21 '23 at 14:24
  • I think the destructor should call `Deallocate()` **before** `delete[] p;`. – Fareanor Aug 21 '23 at 14:28
  • @Fareanor Thanks. ```std::launder``` is C++>=17. What should I do in C++14? I'll update my questions accordingly. – Oersted Aug 21 '23 at 14:29
  • @Fareanor in this use case the destructor does not know the actual type and cannot call automatically ```Deallocate``` (which is template), it's up to the user to do that (in my use case, the user is actually not a coder but another library, limiting the risks of misusage) – Oersted Aug 21 '23 at 14:31
  • regarding ```std::launder```, I found this question: https://stackoverflow.com/questions/54259889/stdlaunder-alternative-pre-c17 My understanding: it's impossible to have the equivalent in C++14, it's not mandatory but better in C++17 (gives opportunities for optimisation?) – Oersted Aug 21 '23 at 14:55
  • Yes, actually it was added because there was no way to do it before. You may be interested by [this thread](https://stackoverflow.com/questions/39382501/what-is-the-purpose-of-stdlaunder). Actually it's more a way to avoid optimizations that should/may not be applied. – Fareanor Aug 21 '23 at 15:18
  • 1
    Unfortunately, `tmp = new (p) T[N];` is unusable as size required is not necessary `sizeof(T)*N`. It might have (implementation dependent) bookkeeping... and no way to ask for that size... – Jarod42 Aug 21 '23 at 20:34
  • 1
    [OT]: `// placement array default construction` can be factorized outside the if block (so `if` only handles capacity) – Jarod42 Aug 21 '23 at 20:48
  • 1
    See [can-placement-new-for-arrays-be-used-in-a-portable-way](https://stackoverflow.com/questions/15254/can-placement-new-for-arrays-be-used-in-a-portable-way). – Jarod42 Aug 21 '23 at 20:56
  • @Fareanor: I find it weird that after the Standard's failure to recognize constructs which were widely used in C++ before the Standard was written, the authors decided to fix things by inventing new constructs and pretending the actions in possible had never been possible, rather than recognizing the existence of constructs which had been part of the language from the beginning, and which many compilers had always supported. – supercat Aug 21 '23 at 21:15
  • @supercat I'm not very familiar with memory laundering and co. I didn't even know that there were alternatives constructs for that purpose. I'd be very insterested (out of pure curiosity) if you could give me some names or links, I'm sure OP will also be glad to see that since he can't use `std::launder`. – Fareanor Aug 22 '23 at 08:56
  • @Fareanor: The "alternative construct" for reusing storage was to simply convert the pointers as appropriate, and have compilers refrain from blindly assuming that a construct like `*(unsinged*)someFloatPtr += 0x08000000;` would only be used in which `someFloatPtr` held the address of an `unsigned` even if it was of type `float*`. – supercat Aug 22 '23 at 14:10
  • I'm currently reading more about std::launder and the more I read, the more I'm dubious of the fact that this situation of type punning is a use-case for std::launder. Do you all think it might be eligible for another question? (most reading are about placement new on memory location of objects with const member(s)). – Oersted Aug 23 '23 at 08:54
  • @Jarod42 Thanks; at what kind of bookkeeping are you thinking? ```tmp``` cannot be used for deletion for instance. Can you provide an example? (In the meantime, I'll read your other link) – Oersted Aug 23 '23 at 15:11
  • it seems that the question https://stackoverflow.com/questions/15254/can-placement-new-for-arrays-be-used-in-a-portable-way was first motivated by observations on old version of msvc compiler and not really standard definition. But I have the impression that array form of placement new is ill defined, at least up to C++17 included. I'm opening a new question: https://stackoverflow.com/questions/76963038/what-does-exactly-the-standard-array-form-placement-new-expression – Oersted Aug 23 '23 at 15:53

0 Answers0