Как стать автором
Обновить
717.44
OTUS
Цифровые навыки от ведущих экспертов

Обратный вызов operator delete: когда, как и зачем он вызывается

Уровень сложностиПростой
Время на прочтение4 мин
Количество просмотров2K

Привет, Хабр!

Сегодня разбираемся, когда, как и зачем рантайм вызывает обратный вызов operator delete, откуда берётся sized delete, почему компилятор подсовывает placement-delete, и когда стоит выкинуть всю эту ручную экзотику, заменив коллбэки на std::function_ref или шаблонные параметры.

Если динамическую память Вы всё-таки аллоцируете руками, то:

  1. Поведение delete-конструкции неочевидно. Деструктор ≠ освобождение памяти. И да, аллокатор может не увидеть размер объекта.

  2. Реальный вызов идёт через скрытые шестерёнки: компилятор выбирает конкретную перегрузку operator delete, иногда — даже прежде, чем начнётся конструирование объекта.

  3. С прибитыми руками. UB при двойном delete, delete-from-wrong-pool, делите указателя на incomplete-type (пока не запретили окончательно) — всё ещё не очень. P3144R1 двигается в эту сторону, но код живёт дольше стандартов.

Таблица: что именно зовётся и в какой момент

Ситуация

Что делает компилятор

Какая перегрузка вызовется

delete p;

1) p->~T() 2) ищет точно совпадающую operator delete(void*), если есть info о размере — ищет operator delete(void*, std::size_t)

void operator delete(void*) или void operator delete(void*, std::size_t)

Конструктор бросил исключение (обычный new)

память уже выделена, объект не создан > вызывается operator delete из той же области видимости

то же, что и при успешном new

Placement new (buf) T{} кинул исключение

вызывается placement-delete operator delete(void*, void*)

void operator delete(void*, void*)

Aligned new/delete (alignas(64))

ищет operator delete(void*, std::align_val_t) (+ sized вариант при /Zc:sizedDealloc)

void operator delete(void*, std::align_val_t, std::size_t)

Под всем этим находятся обычные перегрузки функций, но компилятор подбирает сигнатуру строже любой 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:

Узнайте, как работает память, что скрывается за delete, и почему современные подходы экономят ресурсы и нервы.

Теги:
Хабы:
+9
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS