Какие инварианты должен обеспечить перемещающий конструктор собственной RAII обёртки, чтобы после перемещен...

Какие инварианты должен обеспечить перемещающий конструктор собственной RAII-обёртки, чтобы после перемещения ресурс освобождался ровно один раз?

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

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

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

Ключевой инвариант таков: в каждый момент времени ресурсом владеет ровно один объект-обёртка либо ресурс уже освобождён. Копирование такой обёртки обычно запрещают, если оно не реализует осмысленное глубокое копирование.

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

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

RAII объединил ресурс с временем жизни объекта, а перемещающая семантика C++11 позволила передавать владение без копирования самого ресурса. Такой подход используется в std::unique_ptr, потоках, файловых дескрипторах, сокетах и других ресурсных обёртках.

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

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

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

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

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

Перемещающий конструктор обычно выполняет три действия:

  1. переносит дескриптор ресурса из исходного объекта;
  2. переносит или корректно сохраняет состояние освобождающего объекта;
  3. записывает в исходный объект значение, означающее отсутствие владения.

Пример минимальной обёртки:

#include <utility> void release_resource(int handle) noexcept {} class Handle { int handle_ = -1; public: explicit Handle(int h) : handle_(h) {} Handle(const Handle&) = delete; Handle& operator=(const Handle&) = delete; Handle(Handle&& other) noexcept : handle_(std::exchange(other.handle_, -1)) {} ~Handle() { if (handle_ != -1) release_resource(handle_); } };

Значение -1 здесь является состоянием «ресурсом не владею». После перемещения деструктор исходного объекта ничего не освобождает, а деструктор нового объекта освобождает дескриптор ровно один раз.

Перемещённый объект должен оставаться корректным объектом: его можно уничтожить, присвоить ему новое значение или использовать операции, явно разрешённые интерфейсом. Стандарт обычно допускает для перемещённого объекта состояние, зависящее от реализации, но для RAII-обёртки практически важно обеспечить проверяемое пустое состояние.

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

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

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

В сервере файловые дескрипторы оборачиваются в класс, чтобы закрытие происходило автоматически при выходе из функции. Функция создаёт дескриптор и возвращает обёртку вызывающему коду.

Рассматривались следующие варианты:

  • Сырой дескриптор — прост и не требует класса, но не выражает владение и легко приводит к утечке при раннем выходе.
  • Копирование обёртки — опасно, если обе копии закрывают один дескриптор; глубокая копия для файлового дескриптора обычно не является нужной семантикой.
  • Общее владение — усложняет модель и не требуется, если дескриптор должен иметь одного владельца.
  • Перемещаемая RAII-обёртка — явно передаёт единственное владение и автоматически закрывает ресурс.

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

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

  1. Достаточно ли просто скопировать дескриптор в перемещающий конструктор?

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

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

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

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

  1. Почему перемещённый объект нельзя считать уничтоженным?

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

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