Home > Software design >  How to custom deallocate an object from a base class pointer?
How to custom deallocate an object from a base class pointer?

Time:01-02

I have a class hierarchy that I'm storing in a std::vector<std::unique_ptr<Base>>. There is frequent adding and removing from this vector, so I wanted to experiment with custom memory allocation to avoid all the calls to new and delete. I'd like to use STL tools only, so I'm trying std::pmr::unsynchronized_pool_resource for the allocation, and then adding a custom deleter to the unique_ptr.

Here's what I've come up with so far:

#include <memory_resource>
#include <vector>
#include <memory>


// dummy classes

struct Base
{
    virtual ~Base() {}
};

struct D1 : public Base
{
    D1(int i_) : i(i_) {}
    int i;
};

struct D2 : public Base
{
    D2(double d_) : d(d_) {}
    double d;
};


// custom deleter: this is what I'm concerned about

struct Deleter
{
    Deleter(std::pmr::memory_resource& m, std::size_t s, std::size_t a) :
        mr(m), size(s), align(a) {}

    void operator()(Base* a)
    { 
        a->~Base();
        mr.get().deallocate(a, size, align);
    }

    std::reference_wrapper<std::pmr::memory_resource> mr;
    std::size_t size, align;
};


template <typename T>
using Ptr = std::unique_ptr<T, Deleter>;


// replacement function for make_unique

template <typename T, typename... Args>
Ptr<T> newT(std::pmr::memory_resource& m, Args... args)
{
    auto aPtr = m.allocate(sizeof(T), alignof(T));

    return Ptr<T>(new (aPtr) T(args...), Deleter(m, sizeof(T), alignof(T)));
}

// simple construction of vector

int main()
{
    auto pool = std::pmr::unsynchronized_pool_resource();
     
    auto vec = std::vector<Ptr<Base>>();

    vec.push_back(newT<Base>(pool));
    vec.push_back(newT<D1>(pool, 2));
    vec.push_back(newT<D2>(pool, 4.0));

    return 0;
}

This compiles, and I'm pretty sure that it doesn't leak (please tell me if I'm wrong!) But I'm not too happy with the Deleter class, which has to take extra arguments for the size and alignment.

I first tried making it a template, so that I could work out the size and alignment automatically:

template <typename T>
struct Deleter
{
    Deleter(std::pmr::memory_resource& m) :
        mr(m) {}

    void operator()(Base* a)
    { 
        a->~Base();
        mr.get().deallocate(a, sizeof(T), alignof(T));
    }

    std::reference_wrapper<std::pmr::memory_resource> mr;
};

But then the unique_ptrs for each type are incompatible, and the vector won't hold them.

Then I tried deallocating through the base class:

mr.get().deallocate(a, sizeof(Base), alignof(Base));

But this is clearly a bad idea, as the memory that's deallocated has a different size and alignment from what was allocated.

So, how do I deallocate through the base pointer without storing the size and alignment at runtime? delete seems to manage, so it seems like it should be possible here as well.

CodePudding user response:

After writing my answer, I would recommend you stick with your code.

Letting unique_ptr handle the storage is not bad at all, it is allocated on stack if unique_ptr itself is, it is safe, and there is no additional overhead at deallocation time. The latter is not true for std::shared_ptr which uses type-erause for its deleters.

I think it is the cleanest and simplest way how to achieve the goal. And there's nothing wrong with your code as far as I can tell.


Most allocators to my knowledge allocate extra space for storing any data they need for deallocation directly next to the pointer they return to you. We can do the same to the aPtr blob:

// Extra information needed for deallocation
struct Header {
    std::size_t s;
    std::size_t a;
    std::pmr::memory_resource* res;
};

// Deleter is now just a free function
void deleter(Base* a) {
    // First delete the object itself.
    a->~Base();
    // Obtain the header
    auto* ptr = reinterpret_cast<unsigned char*>(a);
    Header* header = reinterpret_cast<Header*>(ptr - sizeof(Header));
    // Deallocate the allocated blob.
    header->res->deallocate(ptr, header->s, header->a);
};

// Use the new custom function.
template <typename T>
using Ptr = std::unique_ptr<T, decltype(&deleter)>;

template <typename T, typename... Args>
Ptr<T> newT(std::pmr::memory_resource& m, Args... args) {
    // Let the compiler calculate the correct way how to store `T` and `H`
    // together.
    struct Storage {
        Header header;
        T type;
    };
    Header h = {sizeof(Storage), alignof(Storage)};

    auto aPtr = m.allocate(h.s, h.a);
    // Use dummy header.
    Storage* storage = new (aPtr) Storage{h, T(args...)};
    static_assert(sizeof(Storage) == (sizeof(Header)   sizeof(T)),
                  "No padding bytes allowed in Storage.");

    return Ptr<T>(&storage->type, deleter);
}

We store all information necessary for deallocation in Header structure.

Allocating both T and the header in a single blob is not straight forward as it might seem - see below. We need at least sizeof(T) sizeof(Header) bytes but must also respect the alignof(T). So we let the compiler figure it out via Storage.

This way we can allocate T properly and return a pointer to &storage->type to the user. The issue now is that there might be some to-deleter-unknown amount of padding in Storage between header and type, thus the deleter function would not be able to recover &storage->header only from &storage->type pointer. I have two proposals for this:

  • Just assert the padding amount to 0.
  • Manually write the header at the known place, albeit I cannot guarantee 100% safe.

Restricting to known padding

Although the extra padding in Storage is unlikely because Header is aligned to 8 bytes on normal 64-bit systems which should be generally enough for all Ts, there is no such alignment guarantee in C . vtable pointer makes this even less guaranteed IMHO and the fact that alignas(N) offers some user-control over the alignment, increasing it in particular for e.g. vector instructions, doesn't help either. So to be safe, we can just use static_assert and if any "weird" type comes along, the code will not compile and remain safe.

If that happens, one can manually add extra padding to Storage and modify the subtraction amount. The cost would be extra memory for that padding for all allocations.

Writing the header manually

Another option is that we just ignore storage->header member and write the header ourselves directly before type, potentially into the padding area. This requires the use of memcopy because we cannot just placement-new it there because of possible alignof(Header) mismatch. Same in deleter itself because there is no Header object at ptr-sizeof(Header), simple reinterpret_cast<Header*>(ptr-sizeof(header)) would break the strict aliasing rule.

// Extra information needed for deallocation
struct Header {
    std::size_t s;
    std::size_t a;
    std::pmr::memory_resource* res;
};

// Deleter is now just a free function
void deleter(Base* a) {
    // First delete the object itself.
    a->~Base();
    // Obtain the header
    auto* ptr = reinterpret_cast<unsigned char*>(a);
    Header header;
    std::memcpy(&header, ptr - sizeof(Header), sizeof(Header));
    // Deallocate the allocated blob.
    header.res->deallocate(ptr, header.s, header.a);
};

// Use the new custom function.
template <typename T>
using Ptr = std::unique_ptr<T, decltype(&deleter)>;

template <typename T, typename... Args>
Ptr<T> newT(std::pmr::memory_resource& m, Args... args) {
    // Let the compiler calculate the correct way how to store `T` and `H`
    // together.
    struct Storage {
        Header header;
        // Padding???
        T type;
    };
    Header h = {sizeof(Storage), alignof(Storage)};

    auto aPtr = m.allocate(h.s, h.a);
    // Use dummy header.
    Storage* storage = new (aPtr) Storage{{0, 0}, T(args...)};

    // Write our own header at the known -sizeof(Header) offset.
    auto* ptr = reinterpret_cast<unsigned char*>(storage);
    std::memcpy(ptr - sizeof(Header), &h, sizeof(Header));

    return Ptr<T>(&storage->type, deleter);
}

I know this solution is safe w.r.t strict aliasing, object lifetime and allocating T. What I am not 100% certain about is whether the compiler is allowed to store anything relevant to T inside the potential padding bytes, which would thus be overwritten by the manually-written header.

  • Related