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

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.

  1. Lvalue — An expression with a persistent identity (has a name, can take its address). Example: a variable.
  2. 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:

  1. T& (lvalue reference) — binds to lvalues only ("things with a name").
  2. T&& (rvalue reference) — binds to rvalues only ("temporaries about to disappear").
  3. const T& — binds to both lvalues and rvalues. This is why a parameter of type 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)

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:

  1. Guard against self-assignment (if (this == &other) return *this;).
  2. Release any existing resources owned by *this.
  3. Allocate fresh resources and copy other's contents.
  4. Return *this by 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:

  1. If an lvalue of type int is passed, T is deduced as int&, and T&& collapses to int&.
  2. If an rvalue of type int is passed, T is deduced as int, and T&& remains 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, 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

  1. When T is int& (lvalue was passed): std::forward<int&>(arg) returns int& (lvalue).
  2. When T is int (rvalue was passed): std::forward<int>(arg) returns int&& (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+)
}
  1. Unnamed RVO (returning a temporary) is mandatory since C++17. The temporary is constructed directly into the caller's storage.
  2. 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:

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

  1. 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.
  2. return std::move(local); is usually wrong. It defeats NRVO and forces a (cheaper) move where the (free) elision could have happened.
  3. Don't rely on NRVO for correctness — only on Buffer{...} (mandatory unnamed elision) for guaranteed semantics.