Почему return из функции не вызывает move-семантику в C++11? Проблема с rvalue-ссылками
Ключевое понимание: copy elision и move-семантика
Ваш вопрос затрагивает одну из самых тонких тем C++11 — взаимодействие copy elision (в частности, NRVO — Named Return Value Optimization) и move-семантики. Давайте разберем по порядку.
Когда вы пишете return v; в функции, компилятор рассматривает v как lvalue (именованная переменная). Однако стандарт C++11 содержит специальное правило: при возврате локальной переменной компилятор сначала пытается применить copy elision (пропустить копирование/перемещение), а если это невозможно, то он обрабатывает v как rvalue и вызывает move-конструктор. Это правило называется "implicit move".
Вот как это работает на практике:
std::vector<int> createVector() {
std::vector<int> v = {1, 2, 3, 4, 5};
return v; // 1. Сначала попытка NRVO
// 2. Если NRVO не сработала — v трактуется как rvalue
// 3. Вызывается move-конструктор vector
}
Почему std::move может быть вреден?
Когда вы пишете return std::move(v);, вы принудительно превращаете v в rvalue. Это отключает возможность copy elision (NRVO). Почему? Потому что copy elision применяется только к именованным переменным, а не к результату std::move. В результате:
- Компилятор не может применить NRVO
- Он обязан вызвать move-конструктор
- Но move-конструктор — это все равно операция (хоть и дешевая), а NRVO — это полное устранение копирования/перемещения
Таким образом, return std::move(v); гарантированно хуже, чем return v; в плане производительности, если компилятор мог бы применить NRVO.
Как гарантировать move?
Лучшая стратегия — полагаться на компилятор:
// Хорошо: компилятор сам решит
std::vector<int> good() {
std::vector<int> v;
// ... заполняем v
return v; // NRVO или move
}
// Плохо: принудительный move отключает NRVO
std::vector<int> bad() {
std::vector<int> v;
return std::move(v);
}
// Отлично: инициализация прямо в return (RVO)
std::vector<int> best() {
return std::vector<int>{1, 2, 3, 4, 5}; // Гарантированная RVO
}
Когда std::move в return оправдан?
Есть случаи, когда return std::move(...) полезен:
- Когда вы возвращаете не локальную переменную, а, например, член класса или глобальную переменную
- Когда вы возвращаете результат вызова функции, который сам по себе не является rvalue
// Пример: возврат члена класса
class Wrapper {
std::vector<int> data;
public:
std::vector<int> getData() && {
return std::move(data); // data — член класса, не локальная переменная
}
};
Практический совет
В 99% случаев пишите просто return variable;. Компиляторы (GCC, Clang, MSVC) отлично оптимизируют такой код. Если вы видите, что копирование все равно происходит (например, в отладочной сборке), проверьте:
- Не заблокирован ли NRVO сложной логикой (множественные return с разными переменными)
- Не возвращаете ли вы ссылку на локальную переменную (это UB)
- Не используете ли вы типы, у которых нет move-конструктора (например, старые типы из C)
Запомните главное правило: доверяйте компилятору. Он умнее, чем кажется, и C++11 дал ему все инструменты для оптимизации.
<<Дополнение: как отлаживать move-семантику
Если вы хотите убедиться, что move-семантика действительно срабатывает, можно добавить отладочный вывод в пользовательский тип:
struct Test {
Test() { std::cout << "Constructor\n"; }
Test(const Test&) { std::cout << "Copy\n"; }
Test(Test&&) noexcept { std::cout << "Move\n"; }
};
Test create() {
Test t;
return t; // Что выведет?
}
int main() {
Test result = create();
return 0;
}
В режиме оптимизации (например, -O2) вы увидите только "Constructor", потому что сработает NRVO. В режиме без оптимизации может быть "Constructor" + "Move". Это нормально.
Еще один важный момент: если ваш тип не является noexcept-перемещаемым, компилятор может отказаться от move в пользу copy. Помечайте move-конструкторы как noexcept, особенно для стандартных контейнеров — это ускорит многие операции, включая reallocation в vector.
И последнее: в C++17 появилась "гарантированная copy elision" для временных объектов. Это значит, что return std::vector<int>(); вообще не создает временных объектов — компилятор строит результат прямо на месте вызова.