Copy Elision and NRVO
C++17 guarantees prvalue copy elision. NRVO elides named-local returns. Never std::move a local in return — it disables NRVO.
Copy Elisionsince C++17An optimization — mandatory for prvalue initializers since C++17, optional for named returns — where the compiler constructs an object directly in its final storage location, eliding any copy or move entirely.
Overview
Copy elision existed as a permitted-but-optional optimization since C++98. C++17 changed the semantics fundamentally: for prvalue initializers, elision is now a language rule, not an optimization. The compiler is not permitted to emit a copy or move — even if you observe construction through side effects.
The mechanism is a reworked value-category model. Before C++17, a prvalue was a temporary object that happened to live somewhere. In C++17, a prvalue is an initialization instruction — a recipe with no identity and no storage. It is not materialized into an object until the language actually requires one (binding to a reference, applying &, discarding with a comma operator, etc.). At the point of materialization, the object is constructed in-place where it is needed.
This means Widget{} in a return expression is not "a Widget being copied into the return slot" — it directly is the initialization of the return slot. There is no intermediate object to copy or move.
Two distinct mechanisms fall under "copy elision":
- RVO (Return Value Optimization): returning a prvalue. Guaranteed since C++17.
- NRVO (Named Return Value Optimization): returning a named local variable. Optional, but universally applied by GCC, Clang, and MSVC under optimization.
Examples
Guaranteed Elision — Prvalue Returns (C++17)
struct Widget {
Widget() { std::println("ctor"); } // std::println: C++23
Widget(const Widget&) { std::println("copy ctor"); }
Widget(Widget&&) noexcept { std::println("move ctor"); } // noexcept: C++11
};
Widget make() {
return Widget{}; // prvalue — guaranteed copy elision (C++17)
}
Widget w = make();
// Output: "ctor" — exactly one construction; no copy, no moveBecause this is a language rule rather than an optimization, it works even with deleted copy and move constructors:
struct Immovable {
Immovable() = default;
Immovable(const Immovable&) = delete;
Immovable(Immovable&&) = delete;
};
Immovable make_immovable() {
return Immovable{}; // C++17: well-formed — prvalue never materializes as temporary
}
Immovable x = make_immovable(); // C++17: OK
// C++14: ill-formed — required a movable or copyable typeThe same prvalue rule applies to function arguments:
void sink(Widget w);
sink(Widget{}); // Widget{} constructed directly as parameter 'w' — C++17 guaranteedNamed Return Value Optimization
NRVO fires when a function returns a named local variable of the same type as the return type, and the compiler can determine at compile time that only one candidate variable exists. The local is constructed directly in the caller's return-value storage:
std::vector<int> build_primes(int limit) {
std::vector<int> primes; // NRVO candidate: named local, same return type
for (int n = 2; n <= limit; ++n)
if (is_prime(n)) primes.push_back(n);
return primes; // NRVO: primes lives in caller's storage from the start
}
auto p = build_primes(1000);
// With NRVO: one construction of the vector — zero copies, zero moves
// Without NRVO: one construction + one move constructionTo observe the difference, disable elision at the compiler level: -fno-elide-constructors (GCC/Clang) or /Zc:nrvo- (MSVC; on by default under /O2 and /std:c++20 or later).
Conditions That Block NRVO
// Multiple named candidates — compiler cannot choose one at compile time
Widget make_either(bool cond) {
Widget a, b;
return cond ? a : b; // two candidates: NRVO cannot apply
// Fallback: implicit move (C++11), one move ctor call
}
// Returning a base subobject — type mismatch, NRVO blocked
struct Base {};
struct Derived : Base { int extra; };
Base slice_me() {
Derived d;
return d; // slicing: type mismatch, not eligible for NRVO
}
// Volatile locals — NRVO suppressed by all major compilers
Widget volatile_local() {
volatile Widget w;
return w; // volatile: not a valid NRVO candidate
}Implicit Move Fallback (C++11 through C++23)
When NRVO does not apply, the standard requires the compiler to treat the named return expression as an rvalue — applying an implicit move rather than a copy. This rule has been refined across standards:
// C++11: implicit move from named local variables in return
Widget fallback_move() {
Widget w;
/* ... condition prevents NRVO ... */
return w; // C++11: implicit move; same effect as return std::move(w)
}
// C++20: implicit move extended to rvalue-reference locals and throw expressions
void rethrow_extended(std::exception_ptr eptr) {
try { std::rethrow_exception(eptr); }
catch (std::runtime_error&& e) {
throw e; // C++20: implicit move from caught rvalue ref
}
}
// C++23: implicit move from function parameters (eliminates last explicit std::move)
std::vector<int> append_c23(std::vector<int> v, int x) {
v.push_back(x);
return v; // C++23: parameter implicitly moved — no explicit std::move needed
// C++11-C++20: needed return std::move(v) to avoid a copy
}Best Practices
Never add std::move to a return of a local variable. It converts the expression from an NRVO candidate (lvalue) to a move candidate (xvalue), explicitly disabling NRVO. The resulting code is always at least as expensive as writing return local and often more so:
// Wrong: std::move disables NRVO, forces move ctor
std::string wrong() {
std::string s = assemble_large_string();
return std::move(s); // NRVO blocked — move ctor executes
}
// Right: NRVO fires, or implicit move fires if NRVO is blocked
std::string right() {
std::string s = assemble_large_string();
return s; // never worse than std::move version; usually better
}Structure functions to have a single named return path. NRVO is most reliable when the compiler can identify one unambiguous candidate:
// Harder for NRVO — two independently constructed objects
std::string format_v1(bool upper) {
std::string lo = build_lower();
std::string hi = build_upper();
return upper ? hi : lo; // two candidates, NRVO blocked
}
// Easier for NRVO — one named result, mutated in-place
std::string format_v2(bool upper) {
std::string result;
if (upper) result = build_upper();
else result = build_lower();
return result; // single NRVO candidate
}Use the IIFE lambda pattern to preserve NRVO for complex initialization:
// NRVO applies within the lambda body, result materialized at call site (C++17)
const auto table = [&]() -> std::unordered_map<std::string, int> {
std::unordered_map<std::string, int> m;
m.reserve(entries.size());
for (auto& e : entries) m.emplace(e.key, e.value);
return m;
}();Common Pitfalls
Expecting NRVO in debug builds. MSVC disables NRVO at /Od (the debug default). GCC and Clang may also skip it at -O0. If you're measuring constructor calls in a debug build and seeing an extra move, that's expected — the implicit move fallback ensures correctness regardless. Enable at least -O1 to observe realistic elision behavior.
Treating std::move in return as an optimization. This misconception is widespread. Compilers emit a warning for exactly this case: Clang's -Wpessimizing-move fires when std::move in a return prevents copy elision. Trust the compiler; add std::move in return only when returning an rvalue-reference parameter in C++11–C++20 code.
Returning parameters expecting NRVO (pre-C++23). Function parameters are not eligible for NRVO. In C++11–C++20, omitting std::move when returning a parameter results in a copy, not a move:
// C++11-C++20: must std::move to avoid copying the parameter
std::vector<int> with_extra(std::vector<int> v, int x) {
v.push_back(x);
return std::move(v); // C++11-C++20: necessary
// return v; // C++23: implicit move from parameter — same cost
}Relying on throw/catch copy elision. When an exception object is thrown and caught by value, copy elision is permitted but not guaranteed. In practice, compilers elide it for simple throw expressions, but the behavior is less predictable than return-value elision. Catch by const& unless you need to modify the exception.
try {
throw std::runtime_error("detail"); // may construct directly in catch slot
} catch (const std::runtime_error& e) { // prefer const ref — elision irrelevant
handle(e);
}See Also
- Move Semantics — the implicit move fallback that fires when NRVO cannot apply
- Value Categories — prvalue, xvalue, lvalue; the foundation of C++17 guaranteed elision
- Structured Bindings — C++17 multi-value returns that benefit from guaranteed prvalue elision
- Perfect Forwarding — why
std::forwardin return differs fromstd::movein return