C++ Reference, Copy, Move and Forwarding
- Description: A note on the C++ references vs pointers, value categories, copy and move semantics, the special member functions, and perfect forwarding
- My Notion Note ID: K2A-B1-5
- Created: 2018-09-27
- Updated: 2026-02-28
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. Reference vs Pointer
- 2. Value Categories: Lvalues and Rvalues
- 3. Lvalue Reference (
&) vs Rvalue Reference (&&) - 4. Copy Semantics
- 5. Move Semantics
- 6. The Special Member Functions
- 7. Reference Collapsing Rules
- 8. Forwarding References (Universal References)
- 9.
std::forwardand Perfect Forwarding - 10.
std::movevsstd::forward - 11.
std::reference_wrapperand Container Reference Types - 12. RVO and Copy Elision
1. Reference vs Pointer
Both references and pointers allow indirect access to objects, but they have different semantics and constraints.
| Aspect | Reference | Pointer |
|---|---|---|
| What it is | An alias (another name) for an existing object | An object that stores a memory address |
| Initialization | Must be initialized at declaration | Can be uninitialized (but shouldn't be) |
| Reassignment | Cannot be rebound to another object | Can point to different objects |
| Null | Cannot be null | Can be null |
| Memory | Does not create a new object | Is itself an object (occupies memory) |
int x = 10;
int& ref = x; // ref is an alias for x
ref = 20; // x is now 20
int* ptr = &x; // ptr stores the address of x
*ptr = 30; // x is now 30
ptr = nullptr; // valid; ref = nullptr would not compile
2. Value Categories: Lvalues and Rvalues
Understanding value categories is essential for copy, move, and forwarding.
- Lvalue — An expression with a persistent identity (has a name, can take its address). Example: a variable.
- Rvalue — A temporary value without persistent identity. Example: a literal, a function return value, the result of
std::move.
int x = 42; // x is an lvalue
int y = x + 1; // (x + 1) is an rvalue
std::string s = std::string("hello"); // std::string("hello") is an rvalue
The formal C++11 taxonomy is more nuanced — xvalues, prvalues, glvalues — but the lvalue/rvalue distinction is enough for everyday code.
3. Lvalue Reference (&) vs Rvalue Reference (&&)
C++11 introduced rvalue references (T&&) alongside the existing lvalue references (T&). The difference is what they bind to:
T&(lvalue reference) — binds to lvalues only ("things with a name").T&&(rvalue reference) — binds to rvalues only ("temporaries about to disappear").const T&— binds to both lvalues and rvalues. This is why a parameter of typeconst T&accepts any argument.
void f(int& x) { /* called for lvalues */ }
void f(int&& x) { /* called for rvalues */ }
void g(const int& x) { /* called for both */ }
int a = 1;
f(a); // int& (a is an lvalue)
f(std::move(a)); // int&& (std::move casts to rvalue)
f(42); // int&& (42 is a temporary)
This single distinction is the foundation of move semantics: you can write two overloads of the same operation — one for lvalues (which copies) and one for rvalues (which moves).
4. Copy Semantics
A copy produces an independent duplicate of an object. After the copy, both objects exist independently — modifying one does not affect the other.
4.1 Copy Constructor
The copy constructor takes a const T& and produces a new T. The compiler calls it whenever you initialize a new object from an existing one.
class Buffer {
public:
Buffer(const Buffer& other); // copy constructor
};
Buffer a;
Buffer b = a; // copy ctor: a is an lvalue
Buffer c(a); // copy ctor (direct initialization)
Buffer d{a}; // copy ctor (uniform initialization)
4.2 Copy Assignment
Copy assignment overwrites an already-constructed object with a copy of another.
class Buffer {
public:
Buffer& operator=(const Buffer& other); // copy assignment
};
Buffer a, b;
a = b; // copy assignment (NOT initialization)
The conventional implementation:
- Guard against self-assignment (
if (this == &other) return *this;). - Release any existing resources owned by
*this. - Allocate fresh resources and copy
other's contents. - Return
*thisby reference.
The copy-and-swap idiom is a popular alternative that combines the copy ctor and a swap method, automatically getting strong exception safety.
4.3 Default Behavior and When It Goes Wrong
If you don't write a copy ctor or copy assignment, the compiler synthesizes one that performs member-wise copy — each member is copied independently using its own copy operations.
That's correct when every member is copy-correct on its own (std::string, std::vector, std::shared_ptr, primitive types). It is wrong when a member is a raw pointer to an owned resource:
class BadBuffer {
int* data_; // owned, but compiler will just copy the pointer
public:
BadBuffer() : data_(new int[10]) {}
~BadBuffer() { delete[] data_; }
// No copy ctor written — compiler generates one that copies the pointer
};
BadBuffer a;
BadBuffer b = a; // both a.data_ and b.data_ point to the same array.
// When both go out of scope: double-delete -> crash.
This is the central reason for the Rule of Five: once a class owns a non-RAII resource, you must write all the special member functions consistently.
5. Move Semantics
Move semantics (C++11) allow transferring resources from one object to another without copying, dramatically improving performance for types that own heap memory, file handles, or other expensive resources.
5.1 Move Constructor and Move Assignment
A move constructor steals the resources from an rvalue source and leaves the source in a valid-but-unspecified state. Move assignment does the same on an existing object.
class Buffer {
public:
Buffer(Buffer&& other) noexcept; // move constructor
Buffer& operator=(Buffer&& other) noexcept; // move assignment
};
Buffer make_buffer();
Buffer a = make_buffer(); // move ctor (return value is rvalue)
Buffer b;
b = make_buffer(); // move assignment
The conventional implementation steals the source's pointer/handle and resets the source to an empty state. Because nothing is allocated, moves are typically O(1) — much faster than copies.
Why noexcept? Containers like std::vector only use move (instead of copy) during reallocation if your move operations are noexcept. Without it, you silently lose performance on vector::push_back reallocations.
5.2 std::move
std::move does not actually move anything. It is an unconditional cast to an rvalue reference (T&&), signaling that the object can be safely moved from.
#include <utility>
#include <string>
#include <iostream>
std::string a = "hello";
std::string b = std::move(a); // a's contents are transferred to b
// a is now in a valid but unspecified state (typically empty)
std::cout << b << std::endl; // "hello"
std::cout << a << std::endl; // "" (typically)
Key rule: After moving from an object, you can only perform operations that do not depend on its value (assign to it, destroy it, check if empty).
5.3 Move-Only Types
Some types make sense to move but never to copy: std::unique_ptr, std::thread, std::future, file handles. These declare the copy ctor and copy assignment as =delete:
class FileHandle {
public:
FileHandle(const FileHandle&) = delete; // no copy
FileHandle& operator=(const FileHandle&) = delete; // no copy assign
FileHandle(FileHandle&&) noexcept; // OK to move
FileHandle& operator=(FileHandle&&) noexcept; // OK to move
};
Move-only types are the canonical way to express unique ownership in modern C++.
6. The Special Member Functions
Every class has six special member functions. The compiler generates them implicitly when needed; you can =default or =delete them, or write them by hand.
| Function | Signature | When called |
|---|---|---|
| Default constructor | T() |
T x; |
| Destructor | ~T() |
end of x's lifetime |
| Copy constructor | T(const T&) |
T y = x; (where x is an lvalue) |
| Copy assignment | T& operator=(const T&) |
y = x; (where x is an lvalue) |
| Move constructor | T(T&&) |
T y = std::move(x); |
| Move assignment | T& operator=(T&&) |
y = std::move(x); |
Implicit deletion rules. Declaring any of the five non-default special members can suppress others. Most importantly, declaring a custom destructor suppresses the implicit move ctor and move assignment — the class will fall back to copies even on rvalues. That is why the "Rule of Five" exists: once you write one, you should consider all five together.
For when to write all five (the Rule of Five) versus letting the compiler generate them all (the Rule of Zero), and for a worked Buffer example, see K2A-B1-6 § 5.
7. Reference Collapsing Rules
When references to references are formed (through templates or typedefs), they collapse according to these rules:
| Form | Result |
|---|---|
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
Mnemonic: An lvalue reference anywhere in the chain makes the result an lvalue reference. Only && && produces an rvalue reference.
This is the mechanism that makes perfect forwarding work: when T is deduced as int&, T&& becomes int& &&, which collapses to int&.
8. Forwarding References (Universal References)
A forwarding reference is T&& where T is a deduced template parameter. It can bind to both lvalues and rvalues, deducing T accordingly:
- If an lvalue of type
intis passed,Tis deduced asint&, andT&&collapses toint&. - If an rvalue of type
intis passed,Tis deduced asint, andT&&remainsint&&.
template <typename T>
void f(T&& arg); // arg is a forwarding reference (not an rvalue reference)
int x = 42;
f(x); // T = int&, arg type = int& (lvalue)
f(42); // T = int, arg type = int&& (rvalue)
f(std::move(x)); // T = int, arg type = int&& (rvalue)
Note: auto&& is also a forwarding reference, following the same deduction rules.
9. std::forward and Perfect Forwarding
std::forward conditionally casts its argument to an rvalue reference, preserving the original value category. It is used inside template functions to pass arguments through exactly as they were received.
#include <utility>
#include <iostream>
void process(int& x) { std::cout << "lvalue: " << x << std::endl; }
void process(int&& x) { std::cout << "rvalue: " << x << std::endl; }
template <typename T>
void wrapper(T&& arg) {
// Without forward: arg is always an lvalue (it has a name)
// With forward: preserves the original value category
process(std::forward<T>(arg));
}
int main() {
int x = 42;
wrapper(x); // Calls process(int&) -- lvalue preserved
wrapper(42); // Calls process(int&&) -- rvalue preserved
wrapper(std::move(x)); // Calls process(int&&) -- rvalue preserved
return 0;
}
9.1 How std::forward Works
- When
Tisint&(lvalue was passed):std::forward<int&>(arg)returnsint&(lvalue). - When
Tisint(rvalue was passed):std::forward<int>(arg)returnsint&&(rvalue).
This is exactly what reference collapsing gives us, making the forwarding "perfect."
9.2 Practical Use: Factory Function
A common pattern is using perfect forwarding in factory functions or constructors to avoid unnecessary copies:
#include <memory>
#include <utility>
template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
// Essentially what std::make_unique does
10. std::move vs std::forward
| Aspect | std::move |
std::forward |
|---|---|---|
| Purpose | Unconditionally cast to rvalue | Conditionally preserve value category |
| Use when | You know you want to move | Forwarding template arguments |
| Takes | Any expression | A forwarding reference argument |
| Result | Always an rvalue reference | Lvalue or rvalue, depending on original |
11. std::reference_wrapper and Container Reference Types
Standard containers store values, not references — std::vector<int&> is illegal. To store references in containers, or to pass references through APIs that decay them (std::thread, std::bind, std::make_pair), use std::reference_wrapper<T>.
#include <functional>
#include <vector>
int a = 1, b = 2, c = 3;
std::vector<std::reference_wrapper<int>> v{a, b, c};
v[0].get() = 10; // modifies a
std::cout << a; // 10
// Helpers: std::ref(x), std::cref(x)
std::vector<std::reference_wrapper<int>> w{std::ref(a), std::ref(b)};
auto cw = std::cref(a); // reference_wrapper<const int>
A reference_wrapper<T> is implicitly convertible to T&, so most range-for and algorithm uses just work. Use .get() when you need an explicit reference.
Why pass-through APIs need it
void worker(int& x) { x = 42; }
int n = 0;
std::thread t1(worker, n); // BUG: n is COPIED (decay), worker writes to the copy
std::thread t2(worker, std::ref(n)); // OK: passes by reference
std::thread, std::bind, std::make_tuple, and similar facilities decay their arguments by default — references are lost unless you wrap them in std::ref or std::cref.
Container const_reference typedef
Each STL container has a value_type and matching reference / const_reference typedefs:
std::vector<int>::reference r1 = v[0]; // int&
std::vector<int>::const_reference r2 = v[0]; // const int&
std::vector<bool>::reference rb = vb[0]; // proxy type, NOT bool&
vector<bool> is the famous outlier — its reference is a proxy, not a real bool&, because it packs bits. Be careful with auto on vector<bool>:
std::vector<bool> v{true, false};
auto x = v[0]; // proxy reference, NOT bool!
v.push_back(false); // vector reallocates, x dangles
12. RVO and Copy Elision
Returning a local variable by value used to be expensive — a copy of the local into the caller's destination. Modern compilers elide this copy (and the corresponding move) under specific rules.
12.1 Named RVO and Unnamed RVO
Buffer make_buffer_named() {
Buffer b; // local, "named" return
b.fill(0);
return b; // NRVO: constructed directly in the caller's slot
}
Buffer make_buffer_unnamed() {
return Buffer{}; // unnamed return — guaranteed elision (C++17+)
}
- Unnamed RVO (returning a temporary) is mandatory since C++17. The temporary is constructed directly into the caller's storage.
- Named RVO (returning a local variable) is allowed but not required. Modern compilers do it for trivial cases.
12.2 What Defeats Elision
Buffer bad() {
Buffer b;
if (cond) return b;
Buffer c;
return c; // NRVO impossible — two return points with different locals
}
Buffer also_bad(Buffer b) {
return b; // NRVO impossible — b is a parameter, lives in caller's frame
// BUT: implicit move applies; the parameter is moved into the return slot
}
Compilers can't always do NRVO. Things that prevent it:
- Multiple return locals (different variables on different paths).
- Returning a function parameter (lives in the caller's frame).
- Returning a different local than the one named in some paths.
When NRVO isn't possible, the compiler falls back to an implicit move of the local into the return slot — still much faster than a copy. The implicit move is mandated even when the local is non-const.
12.3 Practical Implications
- Returning by value is cheap. Don't write output parameters or pointer outputs to avoid copies; return by value and let RVO/move handle it.
return std::move(local);is usually wrong. It defeats NRVO and forces a (cheaper) move where the (free) elision could have happened.- Don't rely on NRVO for correctness — only on
Buffer{...}(mandatory unnamed elision) for guaranteed semantics.