C++ / MOVE SEMANTICS AND SPECIAL MEMBER FUNCTIONS
Copy elision and return value optimisation
Predict how many objects a by-value return really creates, separate mandatory C++17 prvalue elision from optional NRVO, and stop writing return std::move(x).
What you will learn
- Return T{args} directly so the caller's variable is the only object built.
- Delete std::move from return statements over local names; it blocks NRVO.
- Keep copy or move constructors callable: an elided call must still be legal.
- Tell mandatory C++17 prvalue elision apart from optional, compiler-dependent NRVO.
Understanding Copy elision and return value optimisation
Two different mechanisms hide behind the phrase copy elision, and only one of them is a guarantee. Since C++17 a prvalue such as the Widget{3} in `return Widget{3};` is not an object at all but a pending initialization that gets applied to whatever object eventually needs it, so `Widget w = make();` builds exactly one Widget, directly in w. There is nothing for the compiler to optimise away here, because no second object and no constructor call ever entered the picture; that is why a type with a deleted copy and move constructor can still be returned this way.
Returning a named local is a different story. The local is a real object with its own address, and the return statement is specified as initializing the function's result object from it, so overload resolution has to pick a constructor — since C++11 it first treats the local as an rvalue, which is why a move constructor wins when one exists and why you never need to write that cast yourself. The compiler is then permitted, but never obliged, to place the local directly in the caller's storage and skip the constructor call; that is NRVO. Because the call is skipped rather than removed from the rules, the chosen constructor must still be accessible and non-deleted, and because the standard explicitly allows eliding it even when the constructor prints or counts, this is the one optimisation allowed to change your program's observable output.
The practical rule follows from the mechanics: construct the object in the return statement where you can, and where you need a named local, return its bare name. Wrapping that name in std::move turns the return expression into an xvalue rather than the name of a local, which disqualifies NRVO, so you buy a guaranteed move construction plus a hollowed-out local to destroy; GCC and Clang both diagnose this with -Wpessimizing-move. std::move on a return is only useful when the expression is not simply a local's name, for example `return std::move(pair.second);`, where elision was impossible from the start.
<iostream>
<utility>
struct Noisy {
int id;
static int total;
Noisy() : id(++total) { std::cout << "construct #" << id << '\n'; }
Noisy(const Noisy& o) : id(++total) { std::cout << "copy #" << o.id << " -> #" << id << '\n'; }
Noisy(Noisy&& o) noexcept : id(++total) { std::cout << "move #" << o.id << " -> #" << id << '\n'; }
};
int Noisy::total = 0;
Noisy fromPrvalue() { return Noisy{}; } // mandatory: no copy exists
Noisy fromNamedLocal() { Noisy local; return local; } // NRVO: allowed, not required
Noisy fromMovedLocal() { Noisy local; return std::move(local); } // NRVO disqualified
int main() {
std::cout << "-- return Noisy{} --\n";
Noisy a = fromPrvalue();
std::cout << "-- return local --\n";
Noisy b = fromNamedLocal();
std::cout << "-- return std::move(local) --\n";
Noisy c = fromMovedLocal();
std::cout << "a=#" << a.id << " b=#" << b.id << " c=#" << c.id << '\n';
std::cout << "objects constructed: " << Noisy::total << '\n';
}
Returning a prvalue means no second object ever existed to copy, whereas eliding a named local's copy is an optimisation the compiler may decline.
Worked examples
Returning a type that cannot be copied or moved
Shows that prvalue elision is a language rule, not an optimisation, by returning a type whose copy and move constructors are deleted.
<iostream>
struct Handle {
int fd;
explicit Handle(int fd) : fd(fd) { std::cout << "open " << fd << '\n'; }
Handle(const Handle&) = delete;
Handle(Handle&&) = delete;
~Handle() { std::cout << "close " << fd << '\n'; }
};
Handle open_handle(int fd) { return Handle{fd}; }
void use(Handle h) { std::cout << "use " << h.fd << '\n'; }
int main() {
Handle h = open_handle(3);
use(open_handle(7));
std::cout << "still holding " << h.fd << '\n';
}
Example explained
Line 1`return Handle{fd};` applies the initialization straight to the caller's object, so no copy or move constructor is ever named.
Line 2`Handle h = open_handle(3);` therefore constructs one Handle inside h, which is why deleting both constructors is harmless.
Line 3`use(open_handle(7))` initializes the by-value parameter from the same prvalue, so the handle is built inside the parameter itself.
Line 4Compiled as C++14 this program is ill-formed: before C++17 the deleted move constructor still had to be viable even though the call would be elided.
A chain of prvalue returns still makes one object
Demonstrates that forwarding a prvalue outward through several returns never materialises an intermediate object.
<iostream>
struct Tag {
Tag() { std::cout << "built\n"; }
Tag(const Tag&) { std::cout << "copied\n"; }
Tag(Tag&&) { std::cout << "moved\n"; }
~Tag() { std::cout << "destroyed\n"; }
};
Tag innermost() { return Tag{}; }
Tag middle() { return innermost(); }
Tag outer() { return middle(); }
int main() {
Tag t = outer();
std::cout << "t is the only Tag\n";
}
Example explained
Line 1`return Tag{};` yields a prvalue, so nothing is materialised inside innermost.
Line 2`return innermost();` initializes middle's result object from that prvalue, and middle's result is itself a prvalue in its caller, so the initialization is simply passed further out.
Line 3Materialisation happens once, at `Tag t = outer();`, so "copied" and "moved" never print however deep the chain gets.
Line 4The output is identical at -O0 and -O2, because this is the meaning of the code rather than a pass the optimiser runs.
Proving NRVO by address, and losing it to std::move
Compares the address of the local inside the function with the address of the caller's variable, with and without a std::move on the return.
<iostream>
<string>
<utility>
const void* seen_inside = nullptr;
struct Buffer { std::string bytes = std::string(64, 'x'); };
Buffer elided() {
Buffer b;
seen_inside = &b;
return b;
}
Buffer pessimised() {
Buffer b;
seen_inside = &b;
return std::move(b);
}
int main() {
Buffer x = elided();
std::cout << "return local: same object? "
<< (seen_inside == static_cast<const void*>(&x)) << '\n';
Buffer y = pessimised();
std::cout << "return std::move(local): same object? "
<< (seen_inside == static_cast<const void*>(&y)) << '\n';
}
Example explained
Line 1`seen_inside = &b;` records where the local lives, so the caller can ask whether its own variable is literally that same object.
Line 2With `return b;` GCC and Clang construct b in x's storage from the start, so the addresses match and the string's heap buffer is never touched.
Line 3`return std::move(b);` makes the return expression an xvalue rather than a name, so the move constructor runs and the local is a separate object whose address must differ.
Line 4The second line is always 0; the first is 1 only because NRVO was applied, so MSVC at /Od prints 0 there.
Important notes
Mandatory elision covers only initialization from a prvalue of the same type; NRVO of a named local stays optional and is never permitted when the local is a function parameter, a data member, or of a different type than the return type.
In C++17 mode -fno-elide-constructors disables just the optional elision, so `return T{...};` still builds a single object no matter the flags.
Common mistakes
Writing `return std::move(local);` to "help the compiler": it disqualifies NRVO, so every call now pays a move construction plus destruction of the emptied local, and for a type whose move is a copy, such as std::array<double, 1024>, it costs a full copy.
Assuming a returned named local needs no accessible copy or move constructor because the call will be elided: the program fails to compile, since the elided call must still be a valid, accessible overload.
Asserting an exact copy-constructor call count in a test: elision is explicitly allowed to change that count and NRVO is optional, so the same source passes on one compiler and fails on another.
Try it yourself
Change, predict, then run
Add `~Noisy() { std::cout << "destroy #" << id << '\n'; }` to the Noisy class, then add `Noisy pick(bool b) { Noisy x; Noisy y; return b ? x : y; }` and call it. Write down the full predicted output for `Noisy d = pick(true);` first, and say whether the extra object arrives by copy or by move and why.
Open the C++ workspaceCheck your understanding
A class has both its copy and move constructor deleted. In C++17, `return T{5};` compiles inside a function returning T, but `T local{5}; return local;` does not. Why?
- A prvalue return applies its initialization directly to the caller's object, so no constructor call is named, while returning a named local is defined as a copy or move that the compiler may elide but must still be able to call.
- Deleted constructors are excluded from overload resolution inside return statements, so the named local falls back to memberwise initialization.
- The named version compiles too, as long as optimisations are enabled so that NRVO removes the call.
- T{5} creates a temporary that is memcpy'd into the caller's storage, which needs no constructor at all.
Show answer
Option 0 names the real distinction: the prvalue case has no copy or move to elide because only one object is ever initialized, whereas `return local;` initializes the result object from an existing object, and that constructor must be viable and accessible even when the call is skipped. Option 2 is the tempting one, since NRVO does usually happen even at -O0, but well-formedness is decided by the language rules before any optimisation runs, so a deleted constructor rejects the program under every flag.