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

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

  • Essential for copy, move, forwarding.
  1. Lvalue — expression with persistent identity (has a name, addressable). E.g., a variable.
  2. Rvalue — temporary without persistent identity. E.g., literal, function return, 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
  • 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:
  1. T& — lvalues only ("things with a name").
  2. T&& — rvalues only ("temporaries about to disappear").
  3. const T&both lvalues and rvalues. Why const 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)
  • 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);  // 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

  • Overwrites already-constructed object with a copy.
class Buffer {
public:
    Buffer& operator=(const Buffer& other);  // copy assignment
};

Buffer a, b;
a = b;              // copy assignment (NOT initialization)

Conventional impl:

  1. Guard against self-assignment (if (this == &other) return *this;).
  2. Release existing resources.
  3. Allocate fresh, copy other's contents.
  4. 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_;       // 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.
  • 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;             // 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
  • 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);  // 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 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;             // no copy
    FileHandle& operator=(const FileHandle&) = delete;  // no copy assign

    FileHandle(FileHandle&&) noexcept;                  // OK to move
    FileHandle& operator=(FileHandle&&) noexcept;       // OK to move
};
  • 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:
  1. Lvalue of type int passed → T = int&, T&& collapses to int&.
  2. Rvalue of type int passed → T = int, T&& stays int&&.
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, 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) {
    // 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

  1. T = int& (lvalue passed) → std::forward<int&>(arg) returns int& (lvalue).
  2. 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)...));
}

// 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

  • 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;             // 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>
  • 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);            // 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 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];   // int&
std::vector<int>::const_reference   r2 = v[0];   // const int&
std::vector<bool>::reference        rb = vb[0];  // proxy type, NOT bool&
  • 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];        // proxy reference, NOT bool!
v.push_back(false);   // vector reallocates, x dangles

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;            // 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+)
}
  1. Unnamed RVO (returning temporary) — mandatory since C++17. Temporary constructed directly into caller's storage.
  2. 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;            // 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
}

Things that prevent NRVO:

  1. Multiple return locals — different vars on different paths.
  2. Returning a function parameter — lives in caller's frame.
  3. 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

  1. Return by value is cheap — don't write output params / pointer outputs to avoid copies. Return by value, let RVO/move handle it.
  2. return std::move(local); usually wrong — defeats NRVO, forces a (cheaper) move where (free) elision could have happened.
  3. Don't rely on NRVO for correctness — only on Buffer{...} (mandatory unnamed elision) for guaranteed semantics.