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

При каких условиях фабрика может вернуть объект, у которого запрещены копирование и перемещение, начиная с C++17?

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

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

Начиная с C++17, фабрика может вернуть некопируемый и неперемещаемый объект, если возвращаемое выражение является временным объектом нужного типа, например результатом создания объекта непосредственно в операторе возврата. В этом случае срабатывает гарантированное устранение копирования: промежуточный объект не создаётся, поэтому конструкторы копирования и перемещения не требуются.

#include <string> class Token { public: explicit Token(std::string value) : value_(std::move(value)) {} Token(const Token&) = delete; Token(Token&&) = delete; private: std::string value_; }; Token create_token() { return Token{"abc"}; }

В этом примере Token{"abc"} создаётся сразу в объекте, который получает вызывающая сторона. Код корректен в режиме C++17 и новее, если деструктор типа доступен.

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

До C++17 временные объекты и возвращаемые значения описывались через последовательность потенциальных копирований или перемещений. Компилятор мог устранить такие операции с помощью copy elision, но для большинства случаев это было разрешённое оптимизационное преобразование, а не обязательное правило языка.

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

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

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

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

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

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

Гарантированное устранение копирования применяется, в частности, когда функция возвращает prvalue того же типа, что и её возвращаемый тип, без необходимости преобразования. Типичный случай — непосредственное создание объекта в операторе return.

Вместо схемы «создать временный объект, затем переместить его в результат» стандартная модель C++17 сразу инициализирует объект результата выражением Token{"abc"}. Конструктор копирования и конструктор перемещения в этой цепочке отсутствуют и потому не проверяются как необходимые операции.

Есть несколько существенных ограничений:

  • правило относится к конкретным контекстам языка, а не ко всем возможным оптимизациям;
  • возврат именованной локальной переменной — это случай NRVO, который не гарантирован стандартом;
  • возвращаемый тип должен соответствовать требованиям выбранного контекста, включая доступность деструктора;
  • наличие побочных эффектов в конструкторах копирования или перемещения нельзя использовать как способ определить, произошло ли копирование: при гарантированном устранении эти конструкторы вообще не вызываются;
  • std::move над локальной переменной не превращает возврат в гарантированное устранение копирования и часто, наоборот, мешает применению NRVO.

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

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

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

Рассматривались два варианта. Первый — разрешить перемещение и реализовать обновление всех связанных ссылок; это сохраняло бы возможность возвращать именованные локальные переменные, но усложняло инварианты и увеличивало риск ошибок при исключениях. Второй — создавать Connection непосредственно в возвращаемом выражении и опираться на гарантированное устранение копирования C++17.

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

Если бы требовалось сначала создать именованную локальную переменную, нельзя было бы обещать такую же гарантию: NRVO необязателен. В этом случае пришлось бы либо разрешить перемещение, либо изменить интерфейс фабрики, например передавать объект для конструирования наружу.

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

1. Гарантируется ли устранение копирования при возврате именованной локальной переменной?

Нет. Для локальной переменной применяется NRVO, и компилятор может его выполнить, но стандарт не требует этого во всех случаях. Если копирование и перемещение удалены, полагаться только на NRVO нельзя: программа должна оставаться корректной без выполнения необязательной оптимизации.

2. Что изменится, если явно применить std::move к локальной переменной при возврате?

Выражение с std::move становится xvalue и обычно препятствует применению NRVO. Тогда компилятор должен использовать перемещение, а при его удалённости возврат станет некорректным. Для обычного возврата локальной переменной предпочтительнее не писать std::move без конкретной причины.

3. Вызывается ли конструктор перемещения при передаче результата фабрики в объект вызывающего кода?

В типичном случае C++17 нет: prvalue инициализирует конечный объект непосредственно. Это относится к гарантированным контекстам, где объект создаётся из prvalue соответствующего типа. Однако правило не означает, что любые промежуточные объекты или любые преобразования исчезают: например, другой тип результата, пользовательское преобразование или возврат именованной переменной могут привести к иным требованиям.