C++ПрограммированиеСтарший C++ разработчик

Что отличает поддержку пользовательских деструкторов в **std::unique_ptr** от поддержки в **std::shared_ptr** с точки зрения стирания типов и влияния на размер объекта?

Проходите собеседования с ИИ помощником Hintsage

Ответ на вопрос

C++11 представил std::unique_ptr и std::shared_ptr как замену небезопасному std::auto_ptr. Оба поддерживают пользовательские деструкторы для управления не-оперативными ресурсами, такими как дескрипторы файлов или соединения с базами данных. Тем не менее, их архитектурные подходы кардинально различаются из-за моделей владения и требований к производительности.

std::unique_ptr реализует исключительное владение и хранит свой деструктор как часть своего типа (второй параметр шаблона). Если деструктор имеет состояние, он занимает пространство в самом объекте unique_ptr наряду с управляемым указателем. std::shared_ptr реализует совместное владение через контрольный блок, выделяемый в куче, где деструктор имеет стираемый тип и хранится отдельно от объекта shared_ptr.

Это архитектурное различие приводит к различным характеристикам размера. std::unique_ptr с безсостояльным деструктором занимает точно столько же места, сколько и сырой указатель, благодаря Empty Base Optimization. В свою очередь, std::shared_ptr сохраняет постоянный размер (обычно два указателя) независимо от размера или сложности деструктора, поскольку деструктор находится в отдельно выделенном контрольном блоке.

#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // unique_ptr с безсостояльным деструктором: размер == размер указателя (8 байт на 64-битной системе) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr: постоянный размер (16 байт) независимо от деструктора std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "Unique (stateless): " << sizeof(up) << " bytes "; std::cout << "Shared (any deleter): " << sizeof(sp) << " bytes "; // unique_ptr с состоянием деструктора: больший размер (16 байт: указатель + int + выравнивание) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "Unique (stateful): " << sizeof(up2) << " bytes "; std::cout << "Shared (stateful): " << sizeof(sp2) << " bytes "; }

Ситуация из жизни

Команде разработчиков необходимо было управлять старыми дескрипторами соединений с базой данных (void*), возвращаемыми C API. Эти дескрипторы требовали специфической очистки через db_disconnect(), а не delete. Приложение создавало тысячи дескрипторов в секунду в плотных циклах, что делало критически важными объем памяти и производительность выделения.

Первый подход заключался в создании пользовательского класса-обертки RAII ConnectionGuard, который хранил дескриптор и вызывал db_disconnect() в своем деструкторе. К плюсам относились полный контроль над интерфейсом и возможность добавления методов, специфичных для соединения. Минусами были значительный объем шаблонного кода для каждого типа ресурса, изобретение семантики указателей и несовместимость с алгоритмами стандартной библиотеки, разработанными для смарт-указателей.

Второе решение использовало std::shared_ptr<void> с лямбда-деструктором, захватывающим функцию отключения. Плюсами были немедленная доступность с использованием стандартных компонентов и возможность совместного владения в будущем, если это будет необходимо. Минусами были обязательное выделение в куче для контрольного блока, накладные расходы на атомное подсчет ссылок, неподходящие для частого уникального владения, и фиксированный размер объекта в 16 байт независимо от легковесной природы дескриптора.

Третий подход использовал std::unique_ptr<void, decltype(&db_disconnect)> с делегатом-функцией или, предпочтительно, безсостояльным функцией. Плюсами были отсутствие накладных расходов при использовании безсостояльных функций благодаря Empty Base Optimization (соответствие размеру сырого указателя в 8 байт), отсутствие выделений в куче и идеальное выражение семантики исключительного владения. Минусами были многословие сигнатуры типа и невозможность изменения деструкторов в рантайме.

Команда выбрала третье решение с безсостояльным делегатом-функцией. Этот выбор полностью устранит выделение в куче, снизит размер обертки до 8 байт и уберет накладные расходы на атомные операции, сохранив автоматическую очистку.

Результатом стало снижение потребления памяти на 40% и значительное увеличение производительности в системе пула соединений, достижение безопасности при исключениях без ущерба для производительности.

Что кандидаты часто упускают


Почему std::unique_ptr требует полного типа в момент разрушения при использовании деструктора по умолчанию, тогда как std::shared_ptr этого не требует?

Ответ: std::unique_ptr с деструктором по умолчанию вызывает delete для управляемого указателя. Стандарт C++ требует, чтобы delete для указателя на T имел T, определенный как полный тип, чтобы вызвать деструктор и вычислить размер для освобождения памяти. Если деструктор unique_ptr инстанцируется там, где T только предварительно объявлен, компиляция завершится неудачей. std::shared_ptr захватывает деструктор (который знает, как уничтожить T) в момент создания в контрольном блоке. Поскольку деструктор имеет стираемый тип и хранится отдельно, shared_ptr может быть разрушен позже, когда T неполный. Это различие имеет решающее значение для идиомы Pimpl (Указатель на Реализацию): shared_ptr позволяет скрывать детали реализации в исходных файлах, в то время как unique_ptr требует либо полных типов, либо явных пользовательских деструкторов, определенных там, где видна реализация.


Почему std::make_unique не поддерживает пользовательские деструкторы, и какое рекомендуемое решение?

Ответ: std::make_unique (введен в C++14) предоставляет безопасное выделение памяти, но возвращает только std::unique_ptr<T> или std::unique_ptr<T[]>, которые используют std::default_delete. Функция не может вывести тип деструктора из аргументов, поскольку тип деструктора должен быть частью сигнатуры шаблона unique_ptr, и фабричные функции не могут неявно выводить пользовательские типы деструкторов без явных параметров шаблона. Рекомендуемым альтернативным решением является прямая конструкция: std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...}). Этот подход явно указывает тип деструктора в шаблоне, позволяя кастомную логику очистки ресурсов, хотя он требует ручной обработки исключений или аккуратного порядка конструкции для поддержки гарантий безопасности при исключениях.


Как Empty Base Optimization влияет на макет памяти std::unique_ptr при использовании безсостояльных деструкторов, и почему это невозможно для std::shared_ptr?

Ответ: std::unique_ptr наследует от своего класса-деструктора, когда деструктор является типом класса. Если деструктор не содержит членов данных (безсостояльный), C++ применяет Empty Base Optimization (EBO), позволяя пустому базовому подзавису занимать ноль байт. В результате sizeof(std::unique_ptr<T, StatelessDeleter>) равен sizeof(T*), что достигает абстракции без накладных расходов. std::shared_ptr не может использовать EBO, потому что он должен поддерживать стирание типов: любой shared_ptr одного и того же T должен иметь одинаковый размер независимо от деструктора. Поэтому shared_ptr хранит деструктор в контрольном блоке, выделяемом в куче, а не внутри самого объекта shared_ptr. Этот дизайн обеспечивает полиморфизм деструкторов во время выполнения, но требует выделения в куче и предотвращает оптимизацию пространства стека, которую получает unique_ptr.