Skip to content

Commit dbe254c

Browse files
committed
Expand copy elision doc with C++17 guaranteed elision and std::move
Reframes the doc around the two complementary mechanisms — copy elision when the standard mandates or permits it, std::move when it can't. Adds: - C++17 mandatory elision (P0135) with an immovable-type factory example - Pre-C++17 vs C++17 mandatory/permitted table - NRVO failure modes and the return std::move(x) anti-pattern - std::move section with O(1) ownership transfer of a large vector - Tracer class example printing from each special member to make elision visible - Verification with -fno-elide-constructors and -Wpessimizing-move Numbered headings, runnable examples, cross-links to references.md and copy_constructor_move_constructor.md.
1 parent 3d49b4e commit dbe254c

1 file changed

Lines changed: 293 additions & 45 deletions

File tree

docs/copy_elision.md

Lines changed: 293 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,330 @@
1-
# Copy elision
2-
Copy elision in C++ is a compiler optimization technique that reduces the overhead of copying and moving objects. It is especially important in C++ because it can significantly improve performance by eliminating unnecessary copy and move operations. Here's a deeper dive into the concept, along with various examples:
1+
# Copy Elision and std::move
32

4-
### 1. Return Value Optimization (RVO)
3+
When a function returns a `std::vector<int>` of a million elements, two things *could* happen: the compiler copies the entire array into the caller, or it doesn't. Modern C++ has two distinct mechanisms for "doesn't":
54

6-
RVO is a form of copy elision that occurs when a function returns a local object. The compiler can construct the return value directly in the memory space allocated for it in the caller's context, avoiding the need to copy or move the object.
5+
- **Copy elision** — the compiler skips the copy/move entirely by constructing the value directly in the caller's storage. **Free at runtime.** Either the standard *requires* elision (since C++17) or *allows* it.
6+
- **`std::move`** — when elision can't happen, the next-best thing: turn the would-be copy into a move, transferring ownership of internal resources (heap pointers, file handles) instead of duplicating them.
7+
8+
This doc covers both. They solve overlapping problems but are not interchangeable.
9+
10+
- [1. Why It Matters: A Concrete Example](#1-why-it-matters-a-concrete-example)
11+
- [2. Value Categories Recap](#2-value-categories-recap)
12+
- [3. Return Value Optimization (RVO)](#3-return-value-optimization-rvo)
13+
- [4. Named Return Value Optimization (NRVO)](#4-named-return-value-optimization-nrvo)
14+
- [4.1. When NRVO fails](#41-when-nrvo-fails)
15+
- [4.2. The `return std::move(x)` anti-pattern](#42-the-return-stdmovex-anti-pattern)
16+
- [5. C++17 Guaranteed Copy Elision](#5-c17-guaranteed-copy-elision)
17+
- [5.1. Returning immovable types](#51-returning-immovable-types)
18+
- [5.2. Pre-C++17 vs C++17 — what's mandatory](#52-pre-c17-vs-c17--whats-mandatory)
19+
- [6. Copy Elision in Exception Handling](#6-copy-elision-in-exception-handling)
20+
- [7. std::move — When Elision Can't Help](#7-stdmove--when-elision-cant-help)
21+
- [7.1. Transferring ownership of a large array](#71-transferring-ownership-of-a-large-array)
22+
- [7.2. Move semantics in one paragraph](#72-move-semantics-in-one-paragraph)
23+
- [7.3. Rules of thumb](#73-rules-of-thumb)
24+
- [8. Verifying What the Compiler Does](#8-verifying-what-the-compiler-does)
25+
- [9. See Also](#9-see-also)
26+
27+
---
28+
29+
# 1. Why It Matters: A Concrete Example
30+
31+
The example class below has copy/move constructors that print, so we can *see* what runs:
732

8-
**Example:**
933
```cpp
10-
class MyClass {
11-
public:
12-
MyClass() {}
13-
MyClass(const MyClass&) {
14-
std::cout << "Copy constructor called" << std::endl;
15-
}
34+
#include <iostream>
35+
#include <vector>
36+
37+
struct Tracer {
38+
std::vector<int> data;
39+
40+
Tracer() : data(1'000'000) { std::cout << " default\n"; }
41+
Tracer(const Tracer& o) : data(o.data) { std::cout << " copy ctor\n"; }
42+
Tracer(Tracer&& o) noexcept : data(std::move(o.data)) { std::cout << " move ctor\n"; }
43+
Tracer& operator=(const Tracer& o) { data = o.data; std::cout << " copy assign\n"; return *this; }
44+
Tracer& operator=(Tracer&& o) noexcept { data = std::move(o.data); std::cout << " move assign\n"; return *this; }
1645
};
46+
```
47+
48+
Three differently-coded "produce a Tracer and bind it to `t`" patterns:
49+
50+
```cpp
51+
Tracer make_a() { return Tracer{}; } // ① RVO — prvalue return
52+
Tracer make_b() { Tracer t; return t; } // ② NRVO — named local return
53+
Tracer make_c(Tracer src) { return src; } // ③ neither — function parameter
54+
55+
Tracer t1 = make_a(); // ① "default" only — no copy or move
56+
Tracer t2 = make_b(); // ② usually "default" only (NRVO)
57+
Tracer t3 = make_c(Tracer{}); // ③ "default" + "move ctor" (param consumed)
58+
```
59+
60+
Output on a typical compiler with default optimization (`-O0` is enough for the C++17-mandated cases):
61+
62+
```
63+
① default
64+
② default
65+
③ default (the temp passed in)
66+
move ctor (return statement: param → return slot)
67+
```
68+
69+
The `1'000'000`-element `std::vector` allocation never duplicates — neither for ① nor ②. ③ pays for one move (which is cheap: it transfers the vector's heap pointer; no element-by-element copy).
70+
71+
That's copy elision and `std::move` doing their respective jobs.
72+
73+
# 2. Value Categories Recap
74+
75+
The C++ standard splits expressions into **value categories** that determine what can be copied, moved, or elided:
76+
77+
- **lvalue** — has a name and identifiable address (`int x; x;`).
78+
- **prvalue** ("pure rvalue") — a temporary or literal (`42`, `Tracer{}`, `make_a()`'s return value).
79+
- **xvalue** ("expiring") — an lvalue someone said was OK to move from (`std::move(x)` produces one).
80+
81+
The core C++17 change: a **prvalue is no longer a temporary object**. It's a *recipe* for constructing one. The recipe is only "executed" (the object materialized) at the point it's bound to a name or reference. Until then, the compiler can route the same recipe directly into the caller's storage — that's the mechanism behind C++17's mandatory elision. See [§5](#5-c17-guaranteed-copy-elision).
82+
83+
For more on lvalue/rvalue references and `std::move`, see [references.md §3](references.md#3-lvalue-and-rvalue-references).
84+
85+
# 3. Return Value Optimization (RVO)
86+
87+
RVO applies when a function returns a **prvalue** — a temporary built right at the `return` statement.
1788

18-
MyClass createObject() {
19-
MyClass obj;
20-
return obj; // RVO applies here
89+
```cpp
90+
Tracer make() {
91+
return Tracer{}; // prvalue
2192
}
2293

23-
int main() {
24-
MyClass myObject = createObject(); // No copy is made due to RVO
94+
Tracer x = make(); // C++17: guaranteed no copy or move
95+
```
96+
97+
The compiler constructs the `Tracer` directly in the storage that `x` will occupy. Even before C++17, every mainstream compiler did this; since C++17 it's required by the standard.
98+
99+
This works for *any* prvalue return, including conditional ones:
100+
101+
```cpp
102+
Tracer make(bool flag) {
103+
return flag ? Tracer{1} : Tracer{2}; // both branches yield prvalues
104+
}
105+
```
106+
107+
# 4. Named Return Value Optimization (NRVO)
108+
109+
NRVO applies when a function returns a **named local variable** instead of an unnamed temporary.
110+
111+
```cpp
112+
Tracer make() {
113+
Tracer local;
114+
// ... maybe modify local ...
115+
return local; // NRVO target
25116
}
26117
```
27118

28-
In this example, without RVO, you would expect the copy constructor to be called when returning `obj` from `createObject()`. However, with RVO, the compiler can optimize away this copy.
119+
The compiler can construct `local` *itself* in the caller's return slot — no copy, no move. This is **permitted** but **not required** by the standard, even in C++17.
29120

30-
### 2. Named Return Value Optimization (NRVO)
121+
## 4.1. When NRVO fails
31122

32-
NRVO is similar to RVO but applies when the returned object has a name.
123+
Modern compilers apply NRVO aggressively but it can be defeated. Common patterns that disable it:
33124

34-
**Example:**
35125
```cpp
36-
MyClass createObject(bool condition) {
37-
MyClass obj1, obj2;
38-
if (condition) {
39-
return obj1; // NRVO can apply here
40-
} else {
41-
return obj2; // NRVO can also apply here
42-
}
126+
// ❌ Multiple return paths returning different named locals
127+
Tracer make(bool flag) {
128+
Tracer a, b;
129+
return flag ? a : b; // compiler can't pre-pick a single slot
130+
}
131+
132+
// ❌ Returning a function parameter
133+
Tracer make(Tracer src) {
134+
return src; // src isn't a local — it's a parameter slot
135+
} // → compiler emits a move, not elision
136+
137+
// ❌ Returning a member of an aggregate
138+
Tracer make() {
139+
struct Holder { Tracer t; } h;
140+
return h.t; // not a "complete" local
43141
}
44142
```
45143
46-
In this case, the compiler may directly construct `obj1` or `obj2` in the return value's space, depending on the condition.
144+
In all three the compiler falls back to a move (cheap) or, if the type is non-movable, a copy. None of these are *wrong* — they just don't get the free elision.
47145
48-
### 3. Elision of Copy/Move Constructors in Initialization
146+
## 4.2. The `return std::move(x)` anti-pattern
49147
50-
Copy elision can also occur when an object is initialized directly from a temporary object.
148+
A surprisingly common mistake:
51149
52-
**Example:**
53150
```cpp
54-
MyClass obj = MyClass(); // Copy/move constructor may be elided
151+
Tracer make() {
152+
Tracer local;
153+
return std::move(local); // ⚠️ disables NRVO, forces a move
154+
}
55155
```
56156

57-
Here, the compiler can construct the temporary `MyClass()` object directly in the space allocated for `obj`, eliminating the need for a copy or move.
157+
`std::move(local)` is an xvalue, not the named lvalue `local`. The compiler is no longer in NRVO territory — it must perform an actual move. You've turned "free elision" into "guaranteed move," which is strictly worse.
158+
159+
Without the `std::move`:
160+
161+
```cpp
162+
Tracer make() {
163+
Tracer local;
164+
return local; // ✅ NRVO eligible — possibly zero ops
165+
}
166+
```
167+
168+
Compilers usually warn (`-Wpessimizing-move`) when they spot this, but it still slips into many codebases.
169+
170+
> **Rule:** never write `return std::move(local_variable);`. Returning a named local is already optimal.
171+
>
172+
> The exception: when the function returns a *different* type than the local, you may need an explicit conversion that requires `std::move`. Even then, prefer `return Other{std::move(local)};` so NRVO can still target the constructed `Other`.
173+
174+
# 5. C++17 Guaranteed Copy Elision
175+
176+
C++17's [P0135R1](https://wg21.link/P0135R1) reframed value categories so that returning or initializing from a **prvalue** is *guaranteed* not to copy or move — even with optimizations off, even in debug mode.
58177

59-
### 4. Elision in Throwing and Catching Exceptions
178+
## 5.1. Returning immovable types
60179

61-
When an exception is thrown and caught, the compiler may elide the copy construction of the exception object.
180+
The most striking practical consequence: you can return types that have **deleted** copy *and* move constructors, as long as you return them as prvalues.
181+
182+
```cpp
183+
struct Immovable {
184+
int value;
185+
Immovable() : value(42) {}
186+
187+
Immovable(const Immovable&) = delete;
188+
Immovable(Immovable&&) = delete;
189+
Immovable& operator=(const Immovable&) = delete;
190+
Immovable& operator=(Immovable&&) = delete;
191+
};
192+
193+
Immovable make() {
194+
return Immovable{}; // ✅ C++17: legal, no copy or move needed
195+
}
196+
197+
int main() {
198+
Immovable x = make(); // ✅ also legal — direct-construction
199+
std::cout << x.value;
200+
}
201+
```
202+
203+
Pre-C++17 this was a hard error (the return statement formally required an accessible move constructor, even if elision would skip the actual call). C++17 changed the rule: when initializing from a prvalue, no constructor *call* is part of the model at all — the object is constructed in place.
204+
205+
This unlocks factory functions for types like `std::lock_guard`, `std::scoped_lock`, mutexes, file streams, RAII handles — anything you genuinely don't want copyable or movable.
206+
207+
NRVO does **not** get the same treatment: returning a named local of an immovable type is still ill-formed, because the standard models that as an implicit move (which is then deleted).
208+
209+
## 5.2. Pre-C++17 vs C++17 — what's mandatory
210+
211+
| Pattern | Pre-C++17 | C++17+ |
212+
|---|---|---|
213+
| `T x = T{};` (init from prvalue) | Permitted, not required | **Mandatory** — no constructor call |
214+
| `return T{};` (RVO from prvalue) | Permitted, not required | **Mandatory** |
215+
| `return local;` (NRVO from named local) | Permitted | Permitted (still not mandatory) |
216+
| `throw T{};` / `catch (T x)` | Permitted | Permitted |
217+
218+
"Permitted" means a conforming compiler may insert the copy/move; "mandatory" means it can't even pretend to. The practical difference is that C++17-mandatory cases work without `-O2` and with deleted copy/move constructors.
219+
220+
# 6. Copy Elision in Exception Handling
221+
222+
When you throw and catch a typed exception, the standard permits eliding the copy of the exception object:
62223
63-
**Example:**
64224
```cpp
65225
try {
66-
throw MyClass(); // Copy may be elided
67-
} catch (MyClass obj) {
68-
// ...
226+
throw Tracer{}; // (1) prvalue exception object
227+
} catch (Tracer e) { // (2) catch-by-value — initializes e from the thrown object
228+
use(e);
69229
}
70230
```
71231

72-
In the above scenario, the compiler can optimize by constructing the `MyClass` exception object directly in the exception handling area.
232+
Two elisions are possible:
233+
- The construction of the exception object at the throw site (the compiler may build it directly in the runtime's exception storage).
234+
- The initialization of the catch parameter `e` from the exception storage.
235+
236+
Both are *permitted*, neither is mandated. Catching by reference is the conventional C++ practice and sidesteps the question entirely:
237+
238+
```cpp
239+
catch (const Tracer& e) { // no copy, no elision needed
240+
use(e);
241+
}
242+
```
243+
244+
Catch by reference unless you genuinely need to mutate the local.
245+
246+
# 7. std::move — When Elision Can't Help
247+
248+
Elision needs a return statement (or a prvalue initialization). Many real situations have neither: you're handing an object to another function, storing it in a container, or taking ownership of a parameter mid-function.
249+
250+
That's where `std::move` fits.
251+
252+
## 7.1. Transferring ownership of a large array
253+
254+
```cpp
255+
#include <vector>
256+
#include <utility>
257+
258+
class Buffer {
259+
std::vector<int> data_; // could be megabytes
260+
public:
261+
explicit Buffer(std::vector<int> v) : data_(std::move(v)) {}
262+
// ^^^^^^^^^^^^^^^^^^
263+
// If we wrote `data_(v)` we'd copy every element.
264+
// std::move turns v into an xvalue, the vector picks the move ctor,
265+
// and only the heap pointer is transferred — O(1) instead of O(N).
266+
};
267+
268+
std::vector<int> huge(1'000'000);
269+
Buffer b(std::move(huge)); // huge is now empty but still valid
270+
```
271+
272+
Two things to internalize:
273+
274+
- **`std::move` doesn't move anything.** It's a `static_cast` to rvalue reference. It changes the *value category* of the expression so that the *next* operation (constructor, assignment) chooses the move overload instead of the copy overload.
275+
- **The moved-from object is in a "valid but unspecified" state.** You can destroy it, assign to it, or call functions with no preconditions — but reading its value is meaningless.
276+
277+
For a `std::vector<int>` of a million elements:
278+
- Copy: allocates a million ints, copies them all. Microseconds, plus a heap allocation.
279+
- Move: swaps three pointers (`begin`, `end`, `capacity`). Nanoseconds, no allocation.
280+
281+
## 7.2. Move semantics in one paragraph
282+
283+
A class that owns heap (or any external) resources should have a **move constructor** that *steals* the resource pointer from the source and a **move assignment** that does the same. The compiler synthesizes both for free if every member is moveable, which is why types built from standard containers + smart pointers "just work" without you writing anything.
284+
285+
```cpp
286+
struct Owns {
287+
std::unique_ptr<int> p;
288+
std::vector<int> v;
289+
// implicit move ctor: transfers p, transfers v. Nothing else needed.
290+
};
291+
```
292+
293+
When you *do* write a move constructor by hand, mark it `noexcept``std::vector` and other containers will use the move only if it's `noexcept`, falling back to copy otherwise (the strong exception guarantee).
294+
295+
## 7.3. Rules of thumb
296+
297+
- **Function return** of a local — return by value, no `std::move`. NRVO/RVO handles it.
298+
- **Function parameter** by value, then storing it — `std::move` it into the member: `member_(std::move(param))`.
299+
- **Container insert** of an existing object you're done with — `vec.push_back(std::move(obj));`.
300+
- **Don't `std::move` a `const` object** — the cast is silently undone, and you get a copy. You lose move ergonomics without warning.
301+
- **Don't `std::move` a return value of a non-move-aware type** — for trivially copyable `int`/`double`/etc., `std::move` is a no-op.
302+
- **Don't write `T x = std::move(T{});`**`T{}` is already a prvalue; `std::move` here just disables elision.
303+
304+
# 8. Verifying What the Compiler Does
305+
306+
To actually see when elision happens, turn it off and watch what would otherwise have been called:
307+
308+
```
309+
g++ -std=c++17 -fno-elide-constructors prog.cpp -o prog
310+
clang -std=c++17 -fno-elide-constructors prog.cpp -o prog
311+
```
73312

74-
### Important Notes:
313+
With the flag, the C++17-mandatory cases still elide (the standard requires them) but the *permitted-but-not-required* ones (NRVO especially) will emit their move/copy constructors. Run your `Tracer`-instrumented program with and without the flag — the diff is exactly the set of optional elisions your compiler chose.
75314

76-
- Copy elision is permitted by the C++ standard, but not guaranteed. It depends on the compiler and its optimization settings.
77-
- Since C++17, the standard mandates certain cases of copy elision, particularly in returning and throwing situations, making it more predictable.
78-
- It's important to write code that doesn't rely on copy elision for correctness, even though it can be used for performance optimization.
315+
Also useful:
316+
- `-Wpessimizing-move` — flags `return std::move(local)`-style mistakes.
317+
- `-Wreturn-std-move` (Clang) — suggests `std::move` for cases where it *would* help (rare).
318+
- `-fno-elide-constructors` does not affect mandatory C++17 elision — that's by design.
79319

320+
# 9. See Also
80321

81-
[code](../src/RVO_NRVO_copy_elision.cpp)
322+
- **[references.md §3](references.md#3-lvalue-and-rvalue-references)** — lvalue/rvalue references, the basis of move semantics.
323+
- **[copy_constructor_move_constructor.md](copy_constructor_move_constructor.md)** — writing your own copy/move ctors.
324+
- **[class_special_member_functions.md](class_special_member_functions.md)** — Rule of Five, Rule of Zero.
325+
- **Source:** [`src/RVO_NRVO_copy_elision.cpp`](../src/RVO_NRVO_copy_elision.cpp).
82326

327+
References:
328+
- [P0135R1 — Wording for guaranteed copy elision through simplified value categories](https://wg21.link/P0135R1)
329+
- [cppreference: copy elision](https://en.cppreference.com/w/cpp/language/copy_elision)
330+
- [C++ Core Guidelines F.45 — Don't return an `rvalue ref`](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#f45-dont-return-a-t)

0 commit comments

Comments
 (0)