C++ RVO and NRVO: Copy Elision and Why return std::move(local) Backfires
Key takeaways
A practical guide to C++ RVO and NRVO: when the compiler omits copies on return, what C++17 guarantees, how NRVO differs, why return std::move(local) is usually wrong, and how to measure the difference.
Introduction: “Should I use std::move on return?”
In C++, copy elision on return is often explained in terms of RVO (Return Value Optimization).
// ❌ std::move is unnecessary and often harmful here
std::string foo() {
std::string result = "Hello";
return std::move(result); // ❌ blocks NRVO
}
// ✅ Prefer a plain return
std::string foo() {
std::string result = "Hello";
return result; // ✅ copy/move may be elided (NRVO)
}
This article covers:
- RVO and NRVO
- Mandatory copy elision in C++17 (for prvalues)
- Common
std::movemistakes - Benchmarking
How returning by value actually works
The reason elision is possible at all is an ABI detail. When a function returns a class type by value, the caller reserves space for the result in its own stack frame and passes the function a hidden pointer to that space. The function can then construct its return value directly in the caller’s slot. If it does, there is nothing to copy or move: the object the function built is the object the caller receives. RVO and NRVO are the names for “construct directly in the return slot”, once for a temporary and once for a named local.
So the question is never “is a copy fast?” but “can the compiler know, at the point where it creates the object, that this object is the one that will be returned?”. For return Data(); the answer is trivially yes. For Data result; ... return result; it is yes when every return statement returns that same result. For return std::move(result); the expression is no longer the plain name of a local, the rules for elision no longer apply, and the compiler has to construct result separately and then move it into the return slot.
This matters in code review because return std::move(x); looks like an optimization and is usually the opposite. Clang warns about it with -Wpessimizing-move (and about harmless-but-pointless cases with -Wredundant-move); GCC 9 and later have the same warnings, enabled by -Wall and -Wextra respectively.
What is RVO?
RVO (Return Value Optimization)
RVO is an optimization that elides copy/move operations when values are returned from functions.
struct Data {
std::vector<int> vec;
Data() {
std::cout << "Constructor\n";
}
Data(const Data&) {
std::cout << "Copy Constructor\n";
}
Data(Data&&) noexcept {
std::cout << "Move Constructor\n";
}
};
// RVO example
Data createData() {
return Data(); // return a temporary
}
int main() {
Data d = createData();
// Typical output: Constructor only (no copy/move in the return path)
}
RVO in practice:
- Applies when returning a temporary (a prvalue) of the function’s return type.
- Under C++17, mandatory copy elision applies in the standard cases for such returns.
Strictly speaking, since C++17 this is not an optimization any more. The standard redefined prvalues: Data() is no longer “a temporary that gets copied into the result” but an initializer that has not yet been given a location. The object is created only once, when that prvalue finally initializes something, here d in main. That is why no copy or move constructor is called, why the GCC/Clang flag -fno-elide-constructors has no effect on this case in C++17 mode, and why it works even for types that cannot be moved (section 3). With -std=c++14 -fno-elide-constructors, the same program prints Constructor followed by two Move Constructor lines: one into the return value, one from it into d.
What is NRVO?
NRVO (Named Return Value Optimization)
NRVO elides copy/move operations when you return a named local variable.
// NRVO example
Data createData() {
Data result; // named local
result.vec.push_back(42);
return result; // NRVO may apply
}
int main() {
Data d = createData();
// Often: Constructor only (no copy/move on return)
}
NRVO expectations:
- You return a named local object.
- All return paths that participate in NRVO return the same variable (simplified rule of thumb).
- Even in C++17, NRVO is not guaranteed; it remains a quality-of-implementation optimization where permitted.
In practice GCC, Clang and MSVC all perform NRVO in optimized builds for simple cases like this one, and GCC and Clang do it even at -O0. MSVC historically applied NRVO only with optimizations enabled (/O2), so the same code may print Move Constructor in a Debug build and not in Release; newer MSVC versions also enable it under /permissive- in C++20 mode or with /Zc:nrvo. When NRVO does not happen, the result is still not a copy: since C++11, return result; on a local treats result as an rvalue first, so the move constructor is used, and a copy happens only if the type has no usable move constructor. That automatic move is the safety net that makes plain return local; correct in every case.
Copy elision guarantees in C++17
Before C++17: optional
// C++14
Data createData() {
return Data(); // RVO possible (not guaranteed)
}
// If the compiler does not elide:
// 1. Move constructor
// 2. If no move, copy constructor
C++17 onward: prvalue returns are covered by the rules
// C++17
Data createData() {
return Data(); // mandatory elision in the standard cases for this pattern
}
// Move/copy constructors may be unnecessary for this return path
struct NonMovable {
NonMovable() = default;
NonMovable(const NonMovable&) = delete;
NonMovable(NonMovable&&) = delete;
};
NonMovable createNonMovable() {
return NonMovable(); // ✅ well-formed in C++17 with mandatory elision
}
The NonMovable example is the practical payoff. In C++14 this function does not compile, because even when the compiler elides the move, the move constructor must still exist and be accessible; GCC reports use of deleted function 'NonMovable::NonMovable(NonMovable&&)'. In C++17 it compiles, and NonMovable n = createNonMovable(); works too. The guarantee does not extend to a named local: NonMovable x; return x; is still an error in C++17, because that is NRVO territory and a move or copy constructor must be available in case the optimization is not applied.
std::move mistakes
Mistake 1: std::move on return
// ❌ Typically prevents NRVO
std::string foo() {
std::string result = "Hello";
return std::move(result); // expression is an xvalue, not a named return in the NRVO sense
}
// ✅ Prefer this
std::string foo() {
std::string result = "Hello";
return result; // NRVO may apply
}
Why: std::move produces an rvalue reference to the object. You are no longer returning the name result in the way NRVO is specified to recognize; you are returning a different kind of expression, so optimizers usually treat this path less favorably than a plain return result;.
Mistake 2: Multiple return names
// ❌ NRVO generally does not apply across different names
std::string foo(bool flag) {
std::string a = "A";
std::string b = "B";
if (flag) {
return a;
} else {
return b; // different variable
}
}
// NRVO-friendly shape: one name returned on all paths (when possible), or accept move fallback
The compiler cannot construct both a and b in the single return slot, and it does not know which one will be returned until flag is evaluated, so it builds them as ordinary locals and moves the chosen one out. For std::string that move costs a few pointer copies and is rarely worth restructuring code over. It matters for types whose move is as expensive as a copy, such as std::array<double, 1000> or a struct of plain values: there, restructuring to one named result (std::string result = flag ? "A" : "B"; return result;) turns a full copy into nothing.
Mistake 2b: Returning a member, a parameter taken by reference, or a subobject
struct Holder { std::string name; };
std::string getName(Holder h) {
return h.name; // copies: h.name is not a local variable, so no implicit move
}
std::string getName2(Holder h) {
return std::move(h.name); // here std::move is correct: h is ours to consume
}
The implicit move on return applies only to local variables and by-value parameters themselves, not to their members or to objects reached through references. This is the one place where return std::move(...) is the right call. C++20 and C++23 widened the implicit-move rules (for example to rvalue reference parameters), but members of locals still need an explicit move.
Mistake 3: Returning a reference to a local
// ❌ Undefined behavior: dangling reference
std::string& foo() {
std::string result = "Hello";
return result;
}
// ✅ Return by value
std::string foo() {
std::string result = "Hello";
return result; // NRVO may apply
}
Compilers warn about the first version (warning: reference to local variable 'result' returned), but it is only a warning, and the program often appears to work because the stack memory has not been reused yet. Returning by value is the fix, and thanks to the elision rules above it costs nothing extra. Fear of copies is usually what leads people to return references in the first place, which is exactly the misconception this article is about.
Performance measurement
Benchmark sketch (Google Benchmark)
#include <benchmark/benchmark.h>
struct Data {
std::vector<int> vec;
Data() : vec(1000000, 42) {}
};
// Elision-friendly return (prvalue)
static void BM_RVO(benchmark::State& state) {
for (auto _ : state) {
Data d = [] { return Data(); }();
benchmark::DoNotOptimize(d);
}
}
BENCHMARK(BM_RVO);
// std::move on a named local (typically blocks NRVO)
static void BM_Move(benchmark::State& state) {
for (auto _ : state) {
Data d = [] {
Data result;
return std::move(result);
}();
benchmark::DoNotOptimize(d);
}
}
BENCHMARK(BM_Move);
Do not expect a dramatic difference from this particular benchmark. Both versions are dominated by allocating and filling a million-element vector; the extra move in BM_Move copies three pointers and nulls out the source, which is lost in the noise. That is the honest lesson: for types with cheap moves (std::vector, std::string, std::unique_ptr), return std::move(local); is a small pessimization and a style problem, not a performance disaster. The cost becomes real for types where a move is a full copy, such as std::array, large aggregates of numbers, or classes that declare a copy constructor but no move constructor; change Data to hold std::array<int, 100000> and the difference between the two benchmarks becomes easy to see.
Exact numbers depend on type, size, compiler, and flags; treat this as motivation to measure, not as a universal constant. For quick checks, printing from the copy and move constructors, as in the first example, tells you more than timing does.
Summary
RVO vs NRVO
| Topic | RVO | NRVO |
|---|---|---|
| What is returned | Temporary (prvalue) | Named local |
| C++17 mandatory elision | Yes (for the standard prvalue cases) | No (still optional) |
| Rule of thumb | return Type(args); | return name; on eligible paths |
Core rules
- Avoid
return std::move(local)for local objects you could return by name. - Prefer a single returned name on all paths when you rely on NRVO.
- Rely on C++17 rules for prvalue returns; understand NRVO remains optional.
- Implement move operations so that when elision does not fire, the fallback stays cheap.
Checklist
- No
std::moveon plain local returns? - Return paths structured for NRVO where it matters?
- No reference return from locals?
- Move (and copy) constructors sane if elision fails?
Closing
RVO and related elisions are among the most important return-path optimizations in C++.
Principles:
- Do not slap
std::moveon every return. - Return one local name consistently when you want NRVO-friendly shapes.
- C++17 tightens the language rules for prvalue returns; NRVO is still best-effort.
return std::move(local) on a local you could name usually hurts the optimization story compared with return local;.
Next step: go deeper with the C++ move semantics guide.
Frequently Asked Questions (FAQ)
Q. Can I return a type that is neither copyable nor movable?
A. Since C++17, yes, as long as you return a prvalue such as return T{args}; or the result of another function returning T by value, because guaranteed copy elision means no copy or move constructor is needed. Returning a named local (NRVO) still requires an accessible copy or move constructor, even when the compiler elides the call. This is what makes factory functions for types holding a std::mutex or std::atomic possible.
Related Articles
- RVO vs NRVO: When C++ Compilers Elide Copies
- C++ Move Errors
- C++ move semantics —
std::moveguide - C++ rvalue references and value categories
- C++ move constructor