Привет, Хабр!
Сегодня разбираемся, когда, как и зачем рантайм вызывает обратный вызов operator delete
, откуда берётся sized delete, почему компилятор подсовывает placement-delete, и когда стоит выкинуть всю эту ручную экзотику, заменив коллбэки на std::function_ref
или шаблонные параметры.
Если динамическую память Вы всё-таки аллоцируете руками, то:
Поведение delete-конструкции неочевидно. Деструктор ≠ освобождение памяти. И да, аллокатор может не увидеть размер объекта.
Реальный вызов идёт через скрытые шестерёнки: компилятор выбирает конкретную перегрузку
operator delete
, иногда — даже прежде, чем начнётся конструирование объекта.С прибитыми руками. UB при двойном delete, delete-from-wrong-pool, делите указателя на incomplete-type (пока не запретили окончательно) — всё ещё не очень. P3144R1 двигается в эту сторону, но код живёт дольше стандартов.
Таблица: что именно зовётся и в какой момент
Ситуация | Что делает компилятор | Какая перегрузка вызовется |
---|---|---|
| 1) |
|
Конструктор бросил исключение (обычный new) | память уже выделена, объект не создан > вызывается | то же, что и при успешном new |
Placement | вызывается placement-delete |
|
Aligned new/delete ( | ищет |
|
Под всем этим находятся обычные перегрузки функций, но компилятор подбирает сигнатуру строже любой SFINAE. Документация MSVC говорит: если включён /Zc:sizedDealloc
, при известном размере предпочтут sized версию.
Минималистичный диагностирующий аллокатор
Напишем код, который покажет когда именно дернулась каждая перегрузка на живом классе-манекене.
struct Tracker {
static void* operator new(std::size_t n) {
std::cout << "[new] size=" << n << "\n";
return std::malloc(n);
}
static void operator delete(void* p) noexcept {
std::cout << "[delete unsized]\n";
std::free(p);
}
static void operator delete(void* p, std::size_t n) noexcept {
std::cout << "[delete sized] size=" << n << "\n";
std::free(p);
}
Tracker() { std::cout << "ctor OK\n"; }
~Tracker() { std::cout << "dtor\n"; }
};
int main() {
try {
auto *t = new Tracker; // happy path
delete t; // sized vs unsized?
new (t) Tracker(); // placement new
// выбросим исключение из next ctor, чтобы увидеть delete-on-failure
new Tracker[1000000]; // вероятно bad_alloc
} catch (...) {}
}
Компилируем -std=c++23 -O0 -D_GLIBCXX_USE_SIZED_DEALLOC=1
(GCC) или /Zc:sizedDealloc /std:c++23
(MSVC) — и наблюдаем, что:
На обычный delete
GCC 14 зовёт operator delete(void*, std::size_t)
. Если отключить sized-delete флаг, упадём на версию без второго параметра. При exception из конструктора в ту же минуту приходит вызов того же operator delete
. Весьма симпатично: можно логировать и даже метрики собирать.
Ловим двойной delete и не падаем
В продакшене нельзя писать аллокаторы «для блога» — валидация пачки UB-сценариев обязательна. Короткий паттерн:
void operator delete(void* p, std::size_t n) noexcept {
if (!p) return;
Header* h = static_cast<Header*>(p) - 1;
if (h->magic != kMagicAlive) {
std::fprintf(stderr, "Double delete or corrupt block\n");
std::abort();
}
h->magic = kMagicFreed;
std::free(h);
}
Header — это префикс, кладём туда size, канарейку, stack-trace id.
Когда operator delete вообще не зовётся
PMR-арены (std::pmr::monotonic_buffer_resource
) уничтожают буфер оптом. Вы никогда не увидите operator delete
на отдельный объект — и это фича: сокращает синхронизацию.
Small-Object оптимизация (EA STL, LLVM BumpPtr) — когда объект складируется в preallocated pool; вызов delete превращается в pool.free(offset)
; глобальный delete не нужен.
TLS allocators — часто inline-маршрутизатор по потокам. Если custom TLS знает размер заранее, стягивать operator delete
в output-бинари бессмысленно.
Шаг вбок: «callback-delete» vs std::function_ref
Вы можете спросить:
«Зачем мне эти delete-коллбэки, если можно прокинуть кастомный освобождающий объект в контейнер (a-la
unique_ptr<T, Deleter>
) и забыть?»
Добавленный в C++23 std::function_ref
— не владеет callable-объектом, а держит reference-wrapper + тривиальный function-pointer thunk. Его размер — два указателя, ни аллокаций, ни type-erasure-state. Концепция зашла из P0792 и наконец-то добралась до стандарта.
void with_items(std::span<Item> items,
std::function_ref<void(Item&)> fn)
{
for (auto& it : items) fn(it);
}
Дешевле std::function
, но так же лаконично. В embedded экономим не только аллокацию, но и EH-frame, примеряя коды под микроконтроллеры без -fno-exceptions
.
Инста-производительность даёт обычный template parameter:
template <typename F>
void with_items(std::span<Item> items, F&& fn)
{
for (auto& it : items) fn(it);
}
Zero-overhead: вызов инлайнится, функции поднимаются до O2 без трейсинга. Но: каждая новая лямбда это новый инстанс, а далее код размер растёт.
Небольшой бенч:
static void BM_function_ref(benchmark::State& st) {
int x = 0;
auto inc = [&](int& v){ ++v; };
for (auto _ : st)
with_items_ref(arr, inc);
}
static void BM_template(benchmark::State& st) {
int x = 0;
auto inc = [&](int& v){ ++v; };
for (auto _ : st)
with_items_tpl(arr, inc);
}
Clang-17 -O3, x86-64:
Тест | ns/итерация | Байт кода |
---|---|---|
template | 2.3 | 42 |
function_ref | 2.9 | 32 |
std::function | 18.4 | 120 |
function_ref
укладывается между чистой шаблонной скоростью и компактным бинарём. В hot-loop берём шаблон; в публичном API — function_ref
.
Косим delete-коллбэком RAII-ресурсы
Часто нужна обратная связь: «объект ушёл — закрыть дескриптор/сокет». Можно сделать так:
template <typename T, typename Closer>
class unique_resource {
T* res_;
Closer closer_;
public:
unique_resource(T* r, Closer c) : res_(r), closer_(c) {}
~unique_resource() {
if (res_) closer_(res_);
}
// …
};
auto fd = unique_resource<int, function_ref<void(int*)>>(
::open("/tmp/data", O_RDONLY),
[](int* fd){ ::close(*fd); });
Мы не зависим от глобального operator delete
, но в лямбду можем сунуть хоть sys-call, хоть подсчёт метрик после закрытия.
Хотите заглянуть в профессиональную кухню C++? Преподаватели-практики Otus проведут два открытых урока в рамках курса C++ Developer. Professional:
Фичи C++20/C++23 и будущие стандарты — 27 мая
Узнайте, как работает память, что скрывается за delete, и почему современные подходы экономят ресурсы и нервы.