- 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
| 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 = 20;
int* ptr = &x;
*ptr = 30;
ptr = nullptr;
2. Value Categories: Lvalues and Rvalues
- Essential for copy, move, forwarding.
- Lvalue — expression with persistent identity (has a name, addressable). E.g., a variable.
- Rvalue — temporary without persistent identity. E.g., literal, function return, result of
std::move.
int x = 42;
int y = x + 1;
std::string s = std::string("hello");
- Formal C++11 taxonomy more nuanced — xvalues, prvalues, glvalues — but lvalue/rvalue is enough for everyday code.
3. Lvalue Reference (&) vs Rvalue Reference (&&)
- C++11 added
T&& rvalue refs alongside T& lvalue refs. Difference = what they bind to:
T& — lvalues only ("things with a name").
T&& — rvalues only ("temporaries about to disappear").
const T& — both lvalues and rvalues. Why const T& accepts any argument.
void f(int& x) { }
void f(int&& x) { }
void g(const int& x) { }
int a = 1;
f(a);
f(std::move(a));
f(42);
- Foundation of move semantics — write 2 overloads: lvalue (copies) + rvalue (moves).
4. Copy Semantics
- Copy = independent duplicate. After copy, both objects exist independently — modifying one doesn't affect the other.
4.1 Copy Constructor
- Takes
const T&, produces new T. Called when initializing new object from existing.
class Buffer {
public:
Buffer(const Buffer& other);
};
Buffer a;
Buffer b = a;
Buffer c(a);
Buffer d{a};
4.2 Copy Assignment
- Overwrites already-constructed object with a copy.
class Buffer {
public:
Buffer& operator=(const Buffer& other);
};
Buffer a, b;
a = b;
Conventional impl:
- Guard against self-assignment (
if (this == &other) return *this;).
- Release existing resources.
- Allocate fresh, copy
other's contents.
- Return
*this by reference.
- Copy-and-swap idiom — combines copy ctor +
swap method. Automatic strong exception safety.
4.3 Default Behavior and When It Goes Wrong
- No user-written copy ctor/assign → compiler synthesizes member-wise copy (each member copied via its own copy ops).
- Correct when every member self-copy-correct (
string, vector, shared_ptr, primitives).
- Wrong when a member is a raw pointer to an owned resource:
class BadBuffer {
int* data_;
public:
BadBuffer() : data_(new int[10]) {}
~BadBuffer() { delete[] data_; }
};
BadBuffer a;
BadBuffer b = a;
- Central reason for Rule of Five: own a non-RAII resource → write all 5 special members consistently.
5. Move Semantics
- Move (C++11) = transfer resources without copying. Big perf win for types owning heap memory, file handles, etc.
5.1 Move Constructor and Move Assignment
- Move ctor — steals resources from rvalue source; leaves source in valid-but-unspecified state.
- Move assignment — same on existing object.
class Buffer {
public:
Buffer(Buffer&& other) noexcept;
Buffer& operator=(Buffer&& other) noexcept;
};
Buffer make_buffer();
Buffer a = make_buffer();
Buffer b;
b = make_buffer();
- Conventional impl: steal source's pointer/handle, reset source to empty. Typically O(1) — much faster than copy.
- Why
noexcept: containers like std::vector use move (vs copy) on realloc only when moves are noexcept. Forgetting silently degrades perf on push_back realloc.
5.2 std::move
std::move doesn't actually move. Unconditional cast to rvalue ref (T&&) — signals object is safe to move from.
#include <utility>
#include <string>
#include <iostream>
std::string a = "hello";
std::string b = std::move(a);
std::cout << b << std::endl;
std::cout << a << std::endl;
- Key rule: after move, only do ops that don't depend on object value — assign, destroy, check if empty.
5.3 Move-Only Types
- Some types only make sense to move, not copy:
unique_ptr, thread, future, file handles.
- Declare copy ctor + copy assign as
=delete:
class FileHandle {
public:
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&&) noexcept;
FileHandle& operator=(FileHandle&&) noexcept;
};
- Canonical way to express unique ownership in modern C++.
6. The Special Member Functions
- Every class has 6 special members. Compiler generates implicitly when needed. Can
=default, =delete, or hand-write.
| 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: declaring any of the 5 non-default special members can suppress others.
- Custom dtor suppresses implicit move ctor + move assignment — class falls back to copies even on rvalues. Why Rule of Five exists.
- When to write all 5 (Rule of Five) vs let compiler generate (Rule of Zero), with worked Buffer example — see K2A-B1-6 § 5.
7. Reference Collapsing Rules
- References-to-references (via templates or typedefs) collapse:
| Form |
Result |
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
- Mnemonic: lvalue ref anywhere → result is lvalue ref. Only
&& && → rvalue ref.
- This is the mechanism making perfect forwarding work — when
T deduces to int&, T&& becomes int& && → collapses to int&.
8. Forwarding References (Universal References)
- Forwarding reference =
T&& where T is a deduced template parameter. Binds to both lvalues + rvalues, deducing T accordingly:
- Lvalue of type
int passed → T = int&, T&& collapses to int&.
- Rvalue of type
int passed → T = int, T&& stays int&&.
template <typename T>
void f(T&& arg);
int x = 42;
f(x);
f(42);
f(std::move(x));
- Note:
auto&& is also a forwarding reference, same deduction rules.
9. std::forward and Perfect Forwarding
std::forward — conditional cast to rvalue ref, preserving original value category.
- Used in template fns to pass args through exactly as 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) {
process(std::forward<T>(arg));
}
int main() {
int x = 42;
wrapper(x);
wrapper(42);
wrapper(std::move(x));
return 0;
}
9.1 How std::forward Works
T = int& (lvalue passed) → std::forward<int&>(arg) returns int& (lvalue).
T = int (rvalue passed) → std::forward<int>(arg) returns int&& (rvalue).
- Exactly what reference collapsing gives → forwarding is "perfect."
9.2 Practical Use: Factory Function
- Common: perfect forwarding in factories/ctors 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)...));
}
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
- STL containers store values, not refs —
std::vector<int&> illegal.
- For refs in containers, or refs through decaying APIs (
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;
std::cout << a;
std::vector<std::reference_wrapper<int>> w{std::ref(a), std::ref(b)};
auto cw = std::cref(a);
reference_wrapper<T> implicitly convertible to T& → most range-for + algorithm uses just work. Use .get() for explicit reference.
Why pass-through APIs need it
void worker(int& x) { x = 42; }
int n = 0;
std::thread t1(worker, n);
std::thread t2(worker, std::ref(n));
std::thread, std::bind, std::make_tuple etc. decay args by default → refs lost unless wrapped in std::ref / std::cref.
Container const_reference typedef
- Each STL container has
value_type + matching reference / const_reference:
std::vector<int>::reference r1 = v[0];
std::vector<int>::const_reference r2 = v[0];
std::vector<bool>::reference rb = vb[0];
vector<bool> outlier — reference is a proxy, not real bool& (packs bits). Be careful with auto:
std::vector<bool> v{true, false};
auto x = v[0];
v.push_back(false);
12. RVO and Copy Elision
- Returning local by value used to be expensive (copy into caller's slot).
- 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;
b.fill(0);
return b;
}
Buffer make_buffer_unnamed() {
return Buffer{};
}
- Unnamed RVO (returning temporary) — mandatory since C++17. Temporary constructed directly into caller's storage.
- Named RVO (returning local) — allowed, 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;
}
Buffer also_bad(Buffer b) {
return b;
}
Things that prevent NRVO:
- Multiple return locals — different vars on different paths.
- Returning a function parameter — lives in caller's frame.
- Returning a different local than the one named on some paths.
- When NRVO impossible → compiler falls back to implicit move of local into return slot (much faster than copy). Mandated even when local is non-
const.
12.3 Practical Implications
- Return by value is cheap — don't write output params / pointer outputs to avoid copies. Return by value, let RVO/move handle it.
return std::move(local); usually wrong — defeats NRVO, forces a (cheaper) move where (free) elision could have happened.
- Don't rely on NRVO for correctness — only on
Buffer{...} (mandatory unnamed elision) for guaranteed semantics.