В C++17 стандарт ввел гарантированную элиминацию копий (обязательную элиминацию копий), что кардинально изменяет способ материализации prvalue (чистых rvalue). Когда prvalue класса инициализирует объект того же типа — например, при возврате из функции по значению или передаче временного объекта в функцию — объект создается непосредственно в конечном хранилище. Следовательно, конструктор копирования или конструктор перемещения не вызываются, и, что важно, ни их доступность (публичный или приватный) не требуется, ни их наличие (при условии, что класс завершен и уничтожаем) для корректности операции. Это резко контрастирует с предыдущими стандартами, где элиминация была всего лишь необязательной оптимизацией, которая все же требовала доступных и присутствующих конструкторов для компиляции.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK в C++17: никаких перемещений/копий не вызвано } void consume(Immovable x); // Параметр инициализируется непосредственно из prvalue
Наша команда разрабатывала драйвер в режиме ядра, где дескрипторы ресурсов, оборачивающие аппаратные контексты, не могли быть дублированы или перемещены в памяти из-за зарегистрированных адресов ядра. Нам нужна была фабричная функция для создания этих дескрипторов по значению для управления RAII, но дескрипторы явно удаляли как конструкторы копирования, так и перемещения, чтобы предотвратить случайную недействительность карт ядра. До C++17 этот дизайн был несовместим с возвратом по значению, потому что даже с NRVO компилятор концептуально требовал, чтобы конструктор перемещения был доступен, что приводило к ошибкам компиляции.
Решение 1: Выделение памяти через std::unique_ptr
Мы рассматривали возможность обернуть дескриптор в std::unique_ptr, что позволило бы перемещать указатель, в то время как подлежащий объект оставался закрепленным. Этот подход обеспечивал безопасность и работал в C++14.
Плюсы: Стандартное управление памятью, предотвращает утечки, широко поддерживается в старом коде.
Минусы: Вводит накладные расходы на динамическое выделение и косвенные указатели, что неприемлемо в контекстах ядра, где требуется детерминированная низкая задержка; также фрагментирует кэш процессора и требует учета обработки исключений при сбоях выделения.
Решение 2: Инициализация параметра из выходного параметра
Передача ссылки на объект, выделенный вызывающим, в фабрику для инициализации на месте.
Плюсы: Гарантия нулевой копии независимо от версии стандарта C++; отсутствие выделения кучи; совместимость с неподвижными типами.
Минусы: Уничтожает стиль цепочного API (auto h = create(); превращается в Handle h; create(h);); увеличивает риск использования до инициализации и плохо комбинируется со стандартными алгоритмами и циклами for, основанными на диапазонах.
Решение 3: Использование гарантированной элиминации копий C++17
Мы переработали фабрику, чтобы вернуть неподвижный тип по значению, полагаясь на обязательную элиминацию для создания prvalue непосредственно в хранении вызывающего.
Плюсы: Исключает использование кучи; сохраняет семантику значений; обеспечивает нулевую стоимость абстракции на этапе компиляции; конструкторы перемещения/копирования могут не существовать или быть доступны.
Минусы: Строго применяется только к чистым rvalue (нельзя вернуть существующие именованные переменные); требует компилятора с поддержкой C++17; необходимо понимать тонкие различия в обработке исключений при конструировании.
Мы выбрали Решение 3, потому что фабрика создавала свежие временные объекты, которые были чистыми prvalues, что идеально соответствовало сценарию гарантированной элиминации. Это позволило дескрипторам оставаться строго неподвижными, одновременно сохраняя эргономичную семантику значений и совместимость с объявлением auto.
Драйвер был выпущен с инициализацией на микро-секундном уровне для тысяч одновременных подключений. Проверка сборки подтвердила, что дескриптор был создан непосредственно в стековом фрейме вызывающего без какого-либо кода перемещения или копирования. Система типов обеспечивала безопасность ресурсов на этапе создания, и мы полностью исключили конкуренцию за кучу из горячего пути.
Применяется ли гарантированная элиминация копий к именованным возвращаемым значениям (lvalues) внутри функции, или она строго ограничена prvalues?
Гарантированная элиминация копий применяется исключительно к prvalues (чистым rvalues), таким как временные объекты, созданные в операторе возврата без имени. Оптимизация именованного возвращаемого значения (NRVO) остается необязательной оптимизацией компилятора; хотя она широко реализуется, она не предоставляет тех же гарантий относительно доступности конструкторов или побочных эффектов. Если кандидат пытается вернуть именованную локальную переменную и предполагает, что это вызовет гарантированную элиминацию, даже если конструктор перемещения удален, программа будет некорректной, потому что именованные переменные являются lvalues и требуют операций перемещения/копирования, если компилятор не применяет необязательную NRVO, что не обязательно.
Может ли класс с явно удаленными конструкторами копирования и перемещения быть возвращен по значению из функции в соответствии с правилами гарантированной элиминации копий?
Да. В C++17, если возвращаемое выражение является prvalue (например, return MyClass{};), конструкторы копирования и перемещения никогда не рассматриваются для инициализации. Поскольку объект создается непосредственно в хранилище вызывающего, удаленные конструкторы не используются и не вызывают ошибок компиляции. Однако попытка вернуть именованную переменную такого типа приведет к ошибке, так как эта операция концептуально требует перемещения lvalue в слоте возврата, что вызвало бы удаленный конструктор перемещения и привело бы к некорректной программе.
Как гарантированная элиминация копий взаимодействует с безопасностью исключений, особенно относительно времени жизни временного prvalue во время разрушения стека?
При гарантированной элиминации копий отдельный временный объект не создается до начала срока службы целевого объекта. prvalue материализуется непосредственно в своем конечном назначении. Следовательно, если возникает исключение во время создания prvalue, механизм разрушения стека не сталкивается с отдельным временным объектом, который требует разрушения; вместо этого он видит частично созданный целевой объект. Это означает, что с точки зрения вызывающего объект либо полностью создан, либо его нет вообще, упрощая гарантии безопасности исключений и обеспечивая отсутствие двойного разрушения или утечки ресурсов из-за оставленного временного объекта во время обработки исключений до официального начала срока службы целевого объекта.