В какой момент тип, которым владеет std::unique ptr, обязан быть полным?

В какой момент тип, которым владеет std::unique_ptr, обязан быть полным?

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

Краткий ответ

Для std::unique_ptr<T> тип T может оставаться неполным в объявлении класса, например при использовании идиомы pImpl. Но к моменту уничтожения указателя или операции, которая может удалить объект, T должен быть полным, если используется стандартный удалитель std::default_delete<T>.

Поэтому деструктор класса-владельца обычно объявляют в заголовочном файле, а определяют в исходном файле после определения T. Иначе компилятор может потребовать полное определение типа там, где оно ещё недоступно.

Исторический контекст

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

Сырой указатель хорошо поддерживает такую схему, но не выражает владение и не освобождает ресурс автоматически. std::unique_ptr добавляет единоличное владение, сохраняя возможность объявить указатель на ещё не определённый тип.

Постановка проблемы

Рассмотрим класс, который хранит указатель на скрытую реализацию. В заголовке известна только предварительная декларация Impl; полное определение находится в .cpp-файле.

Если деструктор Widget неявно генерируется или определяется внутри заголовка, компилятор может инстанцировать деструктор std::unique_ptr<Impl> в точке, где Impl ещё неполон. Для стандартного удалителя это приводит к ошибке компиляции, поскольку операция удаления должна корректно вызвать деструктор Impl.

Подробное решение

Тип T считается полным, когда компилятор видит его определение, а не только объявление class T;. Объявления функций и полей типа std::unique_ptr<T> обычно допустимы при неполном T, поскольку сам размер std::unique_ptr не зависит от размера объекта T.

Однако уничтожение объекта через std::default_delete<T> требует корректно сформированного удаления. Поэтому деструктор класса-владельца определяют после полного определения Impl:

#include <memory> class Impl; class Widget { public: Widget(); ~Widget(); Widget(Widget&&) noexcept; Widget& operator=(Widget&&) noexcept; private: std::unique_ptr<Impl> impl_; }; class Impl { int value = 0; }; Widget::~Widget() = default; Widget::Widget(Widget&&) noexcept = default; Widget& Widget::operator=(Widget&&) noexcept = default;

Деструктор и перемещающие операции здесь определены после Impl. Перемещающее присваивание особенно важно рассматривать отдельно: оно сначала освобождает ресурс, которым уже владеет левый объект, поэтому для стандартного удалителя также может потребоваться полный тип.

Пользовательский удалитель может изменить требования. Если он не выполняет удаление через delete T в месте уничтожения и сам не требует полного определения T, unique_ptr способен работать с неполным типом при других условиях. Это не отменяет необходимости проверить контракт конкретного удалителя.

У std::shared_ptr требования отличаются: его деструктор обычно может уничтожать объект, не видя полного типа, потому что удалитель хранится в контрольном блоке. Но создание shared_ptr из сырого указателя со стандартным удалителем требует корректного удаления и потому обычно выполняется только при полном типе; std::make_shared<T> также требует полного определения T в месте вызова.

Ситуация из практики

Библиотека предоставляет заголовок с классом Database, внутри которого находится скрытый объект реализации. Разработчик оставляет деструктор неявным, чтобы сократить код. Пользователь библиотеки получает ошибку компиляции при подключении заголовка, хотя сам Impl ему вообще не нужен.

Вариант с сырым указателем устраняет ошибку, но возвращает ручное управление памятью: нужно самостоятельно написать деструктор, копирование и перемещение, а исключение или ранний выход могут привести к утечке. Вариант с shared_ptr скрывает часть проблемы, но добавляет контрольный блок, подсчёт ссылок и возможность ошибочно создать разделяемое владение там, где достаточно одного владельца.

Выбранное решение — std::unique_ptr<Impl> с объявленным в заголовке и определённым в .cpp деструктором. Это сохраняет единоличное владение без ручного delete, скрывает реализацию и не требует раскрывать Impl пользователям библиотеки. В результате интерфейс остаётся стабильным, а освобождение ресурса происходит в исходном файле, где тип полностью определён.

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

  1. Достаточно ли определить деструктор после объявления Impl, но до его полного определения?

Нет. Предварительной декларации недостаточно: в точке инстанцирования стандартного удалителя компилятор должен видеть полное определение типа. Поэтому деструктор размещают после определения Impl, обычно в .cpp-файле.

  1. Можно ли объявить std::unique_ptr<Impl> в классе, если Impl вообще нигде не определяется в текущей единице трансляции?

Объявить поле можно, но уничтожить объект такого класса с обычным std::default_delete<Impl> нельзя корректно. Полное определение должно быть доступно в той единице трансляции, где формируется операция уничтожения; обычно это достигается определением деструктора после Impl.

  1. Почему перемещающее присваивание может предъявлять более строгие требования, чем перемещающий конструктор?

Перемещающий конструктор получает пустой или новый объект и передаёт ему владение, не освобождая прежний ресурс целевого объекта. Перемещающее присваивание сначала должно уничтожить ресурс, уже принадлежащий целевому объекту, а затем принять новый. Поэтому его определение также следует размещать там, где тип объекта полный, если используется стандартный удалитель.