|
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 |
3 | 2 |
|
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": |
5 | 4 |
|
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: |
7 | 32 |
|
8 | | -**Example:** |
9 | 33 | ```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; } |
16 | 45 | }; |
| 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. |
17 | 88 |
|
18 | | -MyClass createObject() { |
19 | | - MyClass obj; |
20 | | - return obj; // RVO applies here |
| 89 | +```cpp |
| 90 | +Tracer make() { |
| 91 | + return Tracer{}; // prvalue |
21 | 92 | } |
22 | 93 |
|
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 |
25 | 116 | } |
26 | 117 | ``` |
27 | 118 |
|
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. |
29 | 120 |
|
30 | | -### 2. Named Return Value Optimization (NRVO) |
| 121 | +## 4.1. When NRVO fails |
31 | 122 |
|
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: |
33 | 124 |
|
34 | | -**Example:** |
35 | 125 | ```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 |
43 | 141 | } |
44 | 142 | ``` |
45 | 143 |
|
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. |
47 | 145 |
|
48 | | -### 3. Elision of Copy/Move Constructors in Initialization |
| 146 | +## 4.2. The `return std::move(x)` anti-pattern |
49 | 147 |
|
50 | | -Copy elision can also occur when an object is initialized directly from a temporary object. |
| 148 | +A surprisingly common mistake: |
51 | 149 |
|
52 | | -**Example:** |
53 | 150 | ```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 | +} |
55 | 155 | ``` |
56 | 156 |
|
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. |
58 | 177 |
|
59 | | -### 4. Elision in Throwing and Catching Exceptions |
| 178 | +## 5.1. Returning immovable types |
60 | 179 |
|
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: |
62 | 223 |
|
63 | | -**Example:** |
64 | 224 | ```cpp |
65 | 225 | 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); |
69 | 229 | } |
70 | 230 | ``` |
71 | 231 |
|
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 | +``` |
73 | 312 |
|
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. |
75 | 314 |
|
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. |
79 | 319 |
|
| 320 | +# 9. See Also |
80 | 321 |
|
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). |
82 | 326 |
|
| 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