2Brute2Best
🔥0
M2.4

Copy Elision (RVO / NRVO) & Object Lifetime Traps

Medium Pro OnlyMandatory RVONRVOreturn std::move Anti-PatternTemporary Lifetime Extension
⚡ 15-Second Mental ModelM2.4 · Instant Intuition
Single-Read Mastery
The Real-World Physical Analogy (Core Intuition)

In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.

Rule to Remember: Make illegal states impossible to compile or run.
3 Golden Facts to Remember
Fact #1
What is the difference between RVO and NRVO in C++?

RVO (Return Value Optimization) applies when returning an unnamed prvalue (e.g. return Widget();). Since C++17, this is mandatory guaranteed copy elision: the language specification dictates that no temporary object is ever created; the object is constructed directly in the caller's storage. NRVO (Named Return Value Optimization) applies when returning a named local variable (e.g. Widget w; return w;). It is an optional compiler optimization where the compiler unifies the local variable's stack offset with the caller's return slot.

Fact #2
Why is 'return std::move(local);' considered a pessimization in modern C++?

Because returning a named local variable allows the compiler to perform NRVO (0 copies, 0 moves). Writing std::move(local) casts the variable to an rvalue reference (T&&). The C++ standard explicitly prohibits NRVO when returning an rvalue reference. The compiler is then forced to construct the object locally, call the move constructor to transfer it into the caller's slot, and call the local destructor. Furthermore, if NRVO is impossible, the compiler automatically moves local variables anyway.

Fact #3
How does the C++ ABI implement returning large objects from functions?

Objects that are too large for CPU registers (or types with non-trivial copy/move/destructor operations) use the hidden return pointer calling convention. The caller allocates space on its own stack frame and passes a hidden pointer to this uninitialized memory in the first argument register (rdi on Linux/macOS, rcx on Windows). The callee writes directly into this memory, avoiding any copy back to the caller.

01

Interviewer's lens

What separates a strong C++ candidate from a textbook answer

Instead of cooking a meal in the kitchen, packing it in a box, driving it across town, and unpacking it on the dining table, the chef simply walks into the dining room and cooks directly on the table. That is copy elision.

Interviewers use copy elision to separate developers who write code by superstition from engineers who understand the CPU calling convention and return slots. Weak candidates write return std::move(local); thinking they are optimizing code, completely blind to the fact that they just broke compiler NRVO. Senior engineers stand out by mastering three mechanical realities:

  • The ABI Hidden Return Slot: Callers reserve the memory slot upfront and pass its address in register rdi/rcx; the function constructs the object directly inside that caller slot.
  • C++17 Guaranteed Elision: Prvalues are not objects—they are initializers. Zero copies and zero moves are guaranteed by the standard, allowing return of types with deleted copy/move constructors.
  • NRVO vs Pessimizing Move: Returning a named variable allows the compiler to overlay its storage onto the return slot. Wrapping it in std::move disables NRVO and forces an extra move construction and destructor call.
02

Under the hood & memory layout

Hardware & memory representation

Under standard 64-bit calling conventions (System V AMD64 and Windows x64), non-trivial objects cannot be returned in CPU registers. Instead, the caller reserves memory on its own stack frame and passes a hidden pointer to this uninitialized memory in register rdi (Linux) or rcx (Windows). The callee writes directly into this caller slot.

Three compiler mechanisms govern this process:

  1. C++17 Guaranteed Copy Elision (Prvalues): Returning a prvalue (return Widget(42);) is not an optimization—it is a core language invariant. The prvalue acts as an in-place recipe, constructing the object directly in [rdi] with zero copies and zero moves.
  2. Named Return Value Optimization (NRVO): When returning a named local variable (Widget w; return w;), the compiler assigns w's local stack address directly to [rdi], eliminating all copies and moves across a unified return path.
  3. The return std::move Anti-Pattern: Writing return std::move(w); casts the variable to an xvalue (Widget&&). The C++ standard explicitly forbids NRVO on rvalue references! The compiler is forced to allocate separate stack space for w, call the move constructor to populate [rdi], and run w's destructor on return.
Caller Return Slot (Hidden Pointer)

The caller stack frame reserves storage for the return object and passes its address (0x7ffd20) secretly in register rdi / rcx. Callee writes directly to [rdi].

Prvalue in C++17 (URVO)

Prvalues have no storage of their own. return Widget(42); initializes the memory at [rdi] directly. No intermediate object is created anywhere on the stack.

NRVO Storage Merging

When returning Widget w; return w;, the compiler's backend assigns the local variable w to memory address 0x7ffd20 (the caller's return slot). Zero moves or copies occur.

Temporary Lifetime Extension

Binding a temporary directly to const T& or T&& extends the temporary's lifetime to the reference's scope. Crucially, lifetime extension is not transitive; chaining a member function (make().getName()) destroys the parent temporary at the semicolon.

03

Core concepts & syntax traps

01

Mandatory Copy Elision in C++17 (Guaranteed RVO)

Since C++17, copy elision for prvalues is no longer an optional optimization—it is a language guarantee.

  1. Prvalues Are Initializers: Expressions like Widget(1, 2) or makeWidget() do not create a temporary object that gets copied or moved; they are instructions on how to initialize the caller's target slot.
  2. Zero Moves or Copies: The object is constructed directly in-place inside the destination memory address.
  3. Returning Immovable Types: Because no copy or move constructor is ever invoked, you can safely return types whose copy and move constructors are both explicitly = deleted!
C++23 snippet
struct Immovable {
    int data;
    explicit Immovable(int d) : data(d) {}
    Immovable(const Immovable&) = delete; // No copy
    Immovable(Immovable&&) = delete;      // No move
};

Immovable makeImmovable() {
    return Immovable(42); // Guaranteed elision in C++17: compiles cleanly!
}
Immovable obj = makeImmovable(); // Constructed directly in 'obj' slot!
Interview takeaway:

C++17 prvalue return values construct directly in the caller's target slot, even for non-movable non-copyable types.

02

Named Return Value Optimization (NRVO)

When a function returns a named local variable (Widget w; ... return w;), the compiler performs Named Return Value Optimization (NRVO).

  1. Address Aliasing: Instead of giving w its own local stack frame slot and copying it out, the compiler assigns w's memory address to match the caller's return slot.
  2. Zero Overhead: Construction happens directly inside the caller's memory from the start, achieving zero copies and zero moves at -O2.
  3. Unified Return Path: NRVO succeeds best when all return statements return the exact same local variable.
C++23 snippet
Widget createWidget() {
    Widget local(100); // Constructed directly in caller's return slot
    local.modify();
    return local;      // NRVO: 0 copies, 0 moves!
}
Interview takeaway:

Return named local variables directly; modern optimizing compilers construct them directly in the caller's destination.

03

The return std::move Anti-Pattern (Pessimizing Copy Elision)

One of the most widespread performance bugs in C++ is writing return std::move(local); under the false belief that it speeds up returns.

Why it actively ruins performance in 4 steps:

  1. Type Degradation: It changes the return expression from a named variable (Widget) to an rvalue reference (Widget&&).
  2. NRVO Disabled: The C++ standard explicitly prohibits NRVO when returning an rvalue reference.
  3. Forced Move & Destructor: The compiler is forced to allocate local stack space for local, execute a move constructor into the caller's slot, and then execute local's destructor.
  4. The Clean Fix: Simply write return local;. NRVO eliminates both the move and the destructor entirely!
C++23 snippet
// PESSIMIZING ANTI-PATTERN (DO NOT DO THIS!):
Widget bad() {
    Widget w;
    return std::move(w); // FORCES a move and destructor call; disables NRVO!
}

// CLEAN & OPTIMAL:
Widget good() {
    Widget w;
    return w; // NRVO constructs directly in caller: 0 moves, 0 extra dtors!
}
Interview takeaway:

Never write return std::move(local); it disables NRVO and pessimizes performance.

04

When NRVO Cannot Happen & Implicit Move Fallback

There are two situations where the compiler cannot perform NRVO:

  1. Multiple Distinct Return Variables: if (flag) return a; else return b;. The compiler cannot alias two distinct objects onto a single return slot simultaneously.
  2. Returning a Parameter: Widget fn(Widget w) { return w; }. The parameter's memory layout is governed by the caller's argument passing rules.

The Automatic Safety Net: Even when NRVO is impossible, C++ automatically performs an implicit move. If NRVO cannot be applied, the compiler automatically treats return local; as an rvalue move without requiring std::move!

C++23 snippet
Widget select(bool cond) {
    Widget a(1), b(2);
    if (cond) return a; // Cannot NRVO both 'a' and 'b' to the same slot!
    return b;           // Compiler falls back to IMPLICIT MOVE (no std::move needed)
}
Interview takeaway:

Even when NRVO fails, C++ automatically moves local return variables; manual std::move is still unnecessary.

05

Temporary Lifetime Extension Rules & The Dangling Member Trap

Binding a temporary object directly to a const T& or T&& extends the temporary's lifetime to match the lifetime of the reference.

However, lifetime extension is not transitive:

  1. Direct Binding (Safe): const Data& ref = makeData(); extends the temporary until ref leaves scope.
  2. Member Chaining (Deadly Trap): Writing const std::string& name = makePerson().getName(); destroys the temporary makePerson() object at the semicolon! The returned reference name is left dangling into dead memory, causing crashes or silent memory corruption.
  3. Modern Fix: Use modern C++ ref-qualifiers (const & for lvalues, and && returning by value for rvalues) to prevent chaining references from expiring temporaries.
C++23 snippet
// SAFE: Directly bound temporary is extended
const Data& safeRef = makeData(); // Temporary lives until safeRef goes out of scope!

// DEADLY TRAP: Lifetime extension does not apply to member function returns!
const std::string& badRef = makePerson().getName();
// makePerson() is DESTROYED AT THE SEMICOLON! badRef is now dangling!
Interview takeaway:

Lifetime extension only applies to the directly bound temporary, never to references returned from its member functions.

Brute2Best Pro Content

Unlock Live Implementation & Interview Scenarios

The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.

All 440 visual dry runs & algorithms
Interactive step-by-step playback with pointer & array state transitions
52 comprehensive LLD chapters
Interactive UML diagrams, real-world stories (Food Delivery, Trading, RPG), and practice solutions
40 C++ basics labs & hardware memory models
Vtables, byte layouts, assembly insights, and multi-threading race conditions
Production-ready C++23 source code
Modern idioms, clean contracts, time/space complexity derivations, and interview takeaways

First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.

Next In Your Mastery Journey

Apply C++ Internals to LLD & Algorithmic Patterns

Full-stack systems engineering