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 second one, see below) is can the placement new (lines 45 and 55) and the corresponding destructor loop (in Deallocate()) be omitted in other case than arithmetic types?.

#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

For the sake of clarity, for arithmetic types, is it safe to remove altogether the placement new (replacing it by a mere reinterpret_cast<T*>(p)) and the destructor loop? Are there other kind of types for which it would also be possible?

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 1
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.
see also question 1 for some additional material.

Oersted
  • 769
  • 16
  • @TedLyngmo I made an update at the end, hoping it's clearer. Regarding delete I missed the fact that deleting nullptr was not UB (I thought it was at some point in time, I'm pretty sure I saw segfault on this). Then this example works indeed only on default constructible types but the snippet was complex enough and making it more general was meaningless for my interrogations. – Oersted Aug 21 '23 at 14:27
  • Got it! I thought this was the first of the three questions until you added the links to number 1 and 3 :-) – Ted Lyngmo Aug 21 '23 at 14:36
  • 1
    Not related to the question but I made a small adjustment to make the allocator destroy the objects when it goes out of scope: [An idea](https://godbolt.org/z/MqKvfc44K). The class `foo` is just a test class I plugged in to see how your `Buffer` behaves. – Ted Lyngmo Aug 21 '23 at 15:11
  • @TedLyngmo Wahoo, nice technics! I will take time to fully understand it. You're using ```std::function``` as an erasure type around a lambda, declared in a template? – Oersted Aug 21 '23 at 15:15
  • Yes. I've never done that before so I wasn't sure how it'd work out :-) I see that I left an unnecessary variable in the `for` loop. [cleaned up](https://godbolt.org/z/578fj1W4e) – Ted Lyngmo Aug 21 '23 at 15:17
  • 1
    You shouldn't care much. Always use creation/destruction calls. If they can be optimised out, they will be optimised out. Check the generated assembly to verify. – n. m. could be an AI Aug 21 '23 at 15:42

1 Answers1

4

can the placement new (lines 45 and 55) and the corresponding destructor loop (in Deallocate()) be omitted in other case than arithmetic types?

No. The lifetime of an object starts with the constructor and ends with the destructor. Without those explicit calls, you'll just have memory, no objects.

Ted Lyngmo
  • 93,841
  • 5
  • 60
  • 108
  • 1
    The idea is correct, but the wording is missing some details. None-class-types don't have constructors, maybe the term initialization is better. Though, that can include default initialization, which confusingly doesn't initialize anything but still counts as starting an ojbect's lifetime (like for `int i;`). And some objects (including some class types) can have their lifetime started implicitly by certain functions like `memcpy` where no constructor is called : https://en.cppreference.com/w/cpp/language/object#Object_creation – François Andrieux Aug 21 '23 at 18:25
  • 1
    There is also the notion of [Trivial Destructors](https://en.cppreference.com/w/cpp/language/destructor#Trivial_destructor) which are okay to not call before deallocating storage. Causing an objects destructor to be called does end its lifetime, but an object's lifetime can also be ended without involving its destructor, such as when its storage is released. – François Andrieux Aug 21 '23 at 18:29
  • 1
    @FrançoisAndrieux You are correct in that the answer is a bit broad and that there are exceptions. I may copy something about implicit lifetime types from the standard into the answer when I get a chance. However, calling `new[]` and the destructors will work in _all_ situations and most likely be optimized away if not needed so that's the most important thing I want to get through. – Ted Lyngmo Aug 21 '23 at 18:41