У меня есть иерархия классов, которую я храню в std::vector<std::unique_ptr<Base>>
. В этот вектор часто добавляются и удаляются, поэтому я хотел поэкспериментировать с пользовательским распределением памяти, чтобы избежать всех вызовов new
и delete
. Я хотел бы использовать только инструменты STL, поэтому я пробую std::pmr::unsynchronized_pool_resource
для выделения, а затем добавляю пользовательский удалитель в unique_ptr
.
Вот что я придумал до сих пор:
#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;
}
Это компилируется, и я почти уверен, что это не протекает (пожалуйста, скажите мне, если я ошибаюсь!). Но я не слишком доволен классом Deleter
, который должен принимать дополнительные аргументы для размера и выравнивания.
Сначала я попытался сделать его шаблоном, чтобы я мог автоматически определить размер и выравнивание:
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;
};
Но тогда unique_ptrs
для каждого типа несовместимы, и вектор их не удержит.
Затем я попытался освободиться через базовый класс:
mr.get().deallocate(a, sizeof(Base), alignof(Base));
Но это явно плохая идея, так как освобожденная память имеет другой размер и выравнивание по сравнению с тем, что было выделено.
Итак, как мне освободить базовый указатель без сохранения размера и выравнивания во время выполнения? delete
вроде справляется, значит, и здесь должно быть возможно.
new
также хранит дополнительную информацию, которую вы не видите, которую delete
использует. Компилятор может оказать некоторую помощь, которая сократит накладные расходы, но это ни здесь, ни там.
После написания моего ответа я бы порекомендовал вам придерживаться своего кода.
Позволить unique_ptr
управлять хранилищем совсем неплохо, оно выделяется в стеке, если unique_ptr
само по себе, оно безопасно, и нет дополнительных накладных расходов во время освобождения. Последнее неверно для std::shared_ptr
, который использует удаление типа для своих средств удаления.
Я думаю, что это самый чистый и простой способ достижения цели. Насколько я могу судить, с вашим кодом все в порядке.
Насколько мне известно, большинство распределителей выделяют дополнительное пространство для хранения любых данных, которые им нужны для освобождения, непосредственно рядом с указателем, который они вам возвращают. Мы можем сделать то же самое с блобом aPtr
:
// 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);
}
Всю информацию, необходимую для освобождения, мы храним в структуре Header
.
Выделение как T
, так и заголовка в одном большом двоичном объекте не так просто, как может показаться — см. ниже. Нам нужно как минимум sizeof(T)+sizeof(Header)
байт, но мы также должны учитывать alignof(T)
. Поэтому мы позволяем компилятору разобраться с этим через Storage
.
Таким образом, мы можем правильно выделить T
и вернуть указатель на &storage->type
пользователю. Теперь проблема заключается в том, что между deleter
и Storage
может быть некоторое неизвестное количество отступов в header
, поэтому функция type
не сможет восстановить deleter
только из указателя &storage->header
.
У меня есть два предложения по этому поводу:
Хотя дополнительное заполнение в &storage->type
маловероятно, потому что Storage
выровнено по 8 байтам в обычных 64-битных системах, чего обычно должно быть достаточно для всех Header
, в C++ нет такой гарантии выравнивания. Указатель T
делает это еще менее гарантированным ИМХО, и тот факт, что vtable
предлагает некоторый контроль пользователя над выравниванием, увеличивая его, в частности, например. векторные инструкции тоже не помогают. Поэтому, чтобы быть в безопасности, мы можем просто использовать alignas(N)
, и если появится какой-либо «странный» тип, код не скомпилируется и останется в безопасности.
Если это произойдет, можно вручную добавить дополнительное дополнение к static_assert
и изменить величину вычитания. Стоимостью будет дополнительная память для этого заполнения для всех распределений.
Другой вариант заключается в том, что мы просто игнорируем элемент Storage
и сами пишем заголовок непосредственно перед storage->header
, возможно, в области заполнения. Это требует использования type
, потому что мы не можем просто разместитьmemcopy
его там из-за возможного -new
несоответствия. То же самое и в самом alignof(Header)
, потому что в deleter
нет объекта Header
, простой ptr-sizeof(Header)
нарушил бы строгое правило алиасинга.
// 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);
}
Я знаю, что это решение безопасно в отношении строгого псевдонима, времени жизни объекта и выделения reinterpret_cast<Header*>(ptr-sizeof(header))
. В чем я не уверен на 100%, так это в том, разрешено ли компилятору хранить что-либо, относящееся к T
, внутри потенциальных байтов заполнения, которые, таким образом, будут перезаписаны написанным вручную заголовком.
Большинство распределителей хранят дополнительную информацию (в возвращаемой памяти или в другом месте).