Skip to content
C++

Return Value Optimization (RVO / NRVO)

When a function returns an object by value, your first instinct might be that a copy is made. In practice, the compiler nearly always eliminates that copy entirely — constructing the object directly in the caller's memory. This technique is called copy elision, and its two most important forms are RVO and NRVO. Understanding how it works helps you write better return types and avoid a common pitfall that actively disables the optimization.

The core idea: construct in place

Normally, a function returning by value would create a local object, then copy (or move) it into the caller-provided storage slot. Copy elision short-circuits this: instead of creating the object locally and then moving it, the compiler arranges for the object to be constructed directly in the memory slot reserved for the return value. The local variable and the return slot become the same object — no copy or move is performed.

There are two variants. RVO (Return Value Optimization) applies when the returned expression is an unnamed temporary (a prvalue). NRVO (Named Return Value Optimization) applies when a named local variable is returned. RVO was permitted by the standard for decades; since C++17 it is mandatory for prvalues. NRVO remains optional but is applied by every major compiler in any optimization mode.

// RVO: returning a temporary (unnamed prvalue)
Widget make_widget_rvo() {
    return Widget{};   // temporary is constructed directly in caller's slot
}                      // guaranteed copy elision in C++17 — no move needed

// NRVO: returning a named local variable
Widget make_widget_nrvo() {
    Widget w;          // w IS the return slot — not a separate object
    w.configure();
    return w;          // no copy, no move — NRVO elides this entirely
}

Widget a = make_widget_rvo();    // zero copies, zero moves
Widget b = make_widget_nrvo();   // zero copies, zero moves (on any real compiler)

How the compiler arranges memory

When a function returns by value, the calling convention typically reserves a hidden slot — a region of the caller's stack frame (or a register, for small types) — for the return value. Normally the callee constructs its local object, then moves it into that slot. With NRVO, the compiler passes the address of that slot to the callee, and the callee constructs the local variable at that address from the very beginning. The caller's variable and the function's local become one and the same object, living at the same address throughout.

// What you write:
Widget make() {
    Widget w;       // looks like a separate allocation
    setup(w);
    return w;       // looks like a move
}
Widget result = make();

// What the compiler actually does (conceptually):
// 1. Caller allocates storage for 'result' on its stack frame.
// 2. Caller passes &result as a hidden parameter to make().
// 3. make() constructs 'w' directly at *that address* — no separate w.
// 4. setup() modifies *(&result) in place.
// 5. make() returns. 'result' is already fully constructed. No move needed.

Guaranteed copy elision in C++17

Before C++17, RVO for unnamed temporaries was permitted but not required. A compiler could, in principle, choose to copy instead — and the type needed a copy or move constructor for the code to be legal even if elision was expected. C++17 changed this fundamentally through a concept called prvalue materialization: a prvalue does not create a temporary object until it is actually used. When a function returns a prvalue and the call site initializes a variable of the same type, there is simply never a temporary — the object is constructed directly in its final location.

Prvalue return — C++17 mandatory

return Widget{};

The temporary never materializes. Direct construction at call site. No copy/move constructor needed.

Named local return — optional (NRVO)

return w; // w is a named local

Permitted but not required by the standard. All major compilers apply it. Implicit-move fallback if not.

By-value parameter — implicit move, not RVO

Widget make(Widget w) { return w; }

Parameters don't qualify for RVO/NRVO. But the standard requires the compiler treat w as an rvalue.

// C++17: type doesn't even need a move constructor for prvalue returns
struct NonMovable {
    NonMovable() = default;
    NonMovable(const NonMovable&) = delete;
    NonMovable(NonMovable&&) = delete;
};

NonMovable make() {
    return NonMovable{};  // OK in C++17: guaranteed copy elision
}                         // ERROR in C++14: needs (deleted) move constructor

The anti-pattern: return std::move(local)

A very common mistake is applying std::move to a local variable in a return statement. The intent is usually "I'm returning this — surely making it an rvalue will help?" In fact, it does the opposite. As Scott Meyers explains in Effective Modern C++, return std::move(w) does not return w; it returns a reference to w — the result of std::move, which is an rvalue reference. That reference is not the local object itself, so condition (2) for NRVO fails and the compiler is forced to move instead of elide.

// ✓ GOOD: NRVO applies. Zero copies, zero moves.
Widget make() {
    Widget w;
    setup(w);
    return w;               // w is the local object — NRVO eligible
}

// ✗ BAD: std::move returns a reference to w.
//         The returned expression is Widget&&, not Widget.
//         NRVO condition (2) fails: not returning the local object itself.
//         Compiler is forced to move w into the return slot — worse than above.
Widget make_pessimized() {
    Widget w;
    setup(w);
    return std::move(w);    // disables NRVO — costs a move operation
}

The practical consequence: return w; gives you either NRVO (no operation at all) or implicit-move (one move operation). return std::move(w); gives you at best one explicit move. You can never do better by applying std::move here, and you might do worse if the type's move constructor is non-trivial.

The only correct use of std::move in a return statement is when the returned expression is an rvalue reference parameter — because parameters are never eligible for RVO and the compiler would otherwise copy them.

// Correct use of std::move in a return: rvalue reference parameter
Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
    lhs += rhs;
    return std::move(lhs);  // lhs is a parameter — no NRVO, must move explicitly
}

// Universal reference parameter: forward it
template <typename T>
T transform_and_return(T&& value) {
    process(value);
    return std::forward<T>(value);  // move if rvalue, copy if lvalue
}

When NRVO applies

NRVO requires two conditions to be satisfiable: the type of the local object must match the function's return type (cv-unqualified), and the returned expression must be the bare name of a local variable — not a cast, not a member access, not an expression involving the variable. When control flow can return different variables from different paths, most compilers fall back to implicit-move since they cannot determine at compile time which object to alias the return slot with.

// ✓ NRVO applies: one local, returned by name, matching type
Widget make_a() {
    Widget w;
    configure(w);
    return w;      // NRVO eligible
}

// △ Compilers differ: multiple possible return paths
Widget make_b(bool use_a) {
    Widget a, b;
    if (use_a) return a;   // some compilers apply NRVO for one path
    return b;              // most disable NRVO entirely — implicit-move fallback
}

// ✗ NRVO never applies: returned value is a data member, not a local
struct Factory {
    Widget make_member() {
        widget_.configure();
        return widget_;    // member, not local — implicit copy (unless moved)
    }
    Widget widget_;
};
// The implicit-move guarantee (C++11 and later)
// If NRVO is not applied but conditions 1 and 2 are met:
// the compiler must treat the returned local as an rvalue.
// So you get a move, not a copy.

struct HeavyObject {
    HeavyObject(HeavyObject&&) { std::cout << "moved\n"; }   // called if NRVO fails
    HeavyObject(const HeavyObject&) { std::cout << "copied\n"; } // never called
};

HeavyObject make() {
    HeavyObject obj;
    return obj;  // Either: NRVO (nothing printed) or move (not copy)
}
// Output: nothing  (NRVO applied by all real compilers)
// If NRVO were somehow disabled: "moved" (never "copied")

Summary: what to do

Just write return local_var;

The compiler will apply NRVO (zero cost) or implicit-move (one move). Either is optimal.

Never write return std::move(local_var);

Disables NRVO. At best costs a move instead of nothing. Never helps for locally declared variables.

Do use std::move for rvalue reference parameters

Parameters don't qualify for NRVO. return std::move(param); is correct when param is T&&.

Return temporaries directly for prvalue RVO

return Widget{args...}; is guaranteed copy elision in C++17. No move constructor needed.

Avoid returning different named locals on different paths

if (a) return x; return y; — compilers fall back to moves. Restructure to return one variable if performance is critical.

Sign in to track progress