← Back to blog

C++ Move Semantics and Rvalue References: Why They Matter for Performance

C++ is famous for giving you control over performance. But for years, one of the biggest sources of accidental slowness was copying: returning large objects by value, resizing containers, or passing temporary objects could trigger expensive deep copies and repeated allocations.

C++11 introduced move semantics and rvalue references to fix that: when an object is expiring, C++ can transfer ownership of its resources (like heap memory) instead of duplicating them.

TL;DR / takeaway

  • Moves often happen automatically: standard library types are move-enabled, and many returns are optimized via copy elision (including guaranteed copy elision in C++17 in some cases).
  • Use std::move(x) when you have a named object (an lvalue) and you’re done with it, so it can be treated as an expiring value.
  • Use std::forward<T>(x) only in templates with deduced T (forwarding references) to preserve whether the caller passed an lvalue or rvalue.

Terminology (quick mental model)

  • copy = duplicate ownership (allocate + copy bytes/resources)
  • move = transfer ownership (steal pointer/handle; source remains valid but unspecified)
  • xvalue = “expiring value” (the main target of move semantics)
  • forwarding reference = deduced T&& that can bind to lvalues or rvalues

The core problem: copying is often the wrong default

Consider a simple type that owns a heap buffer:

  • Copying it typically means allocating a new buffer and copying bytes.
  • Moving it can often be implemented as “steal the pointer; null out the source”.

That difference shows up everywhere:

  • Returning a std::vector from a function
  • Pushing elements into containers
  • Concatenating strings
  • Building complex objects from temporaries

With move semantics, these patterns become fast without sacrificing safety.

Figure: Copy vs move cost model (O(n) deep copy vs O(1) pointer/handle transfer for resource-owning types).

Value categories: lvalues, prvalues, xvalues (and why T&& exists)

The old shorthand “lvalue vs rvalue” is useful, but modern C++ splits “rvalue” into two categories:

  • lvalue: has identity (a stable object you can take the address of), e.g. a named variable.
  • prvalue (“pure rvalue”): a temporary value, e.g. std::string("hi") or 42.
  • xvalue (“expiring value”): an object whose resources can be reused, e.g. std::move(s).

Move semantics primarily targets xvalues: expressions that refer to an object that is about to be treated as expiring.

Rvalue references are written as T&&. In non-template code, T&& is (as the name suggests) an rvalue reference type that binds to rvalues (prvalues/xvalues), enabling move operations.

Example:

std::string a = "hello";      // a is an lvalue
std::string b = a;            // copy (a still needed)
std::string c = std::move(a); // move (a becomes an xvalue)

Important: std::move does not move by itself. It’s just a cast that says: “treat this expression as an xvalue so move operations are permitted.”

What a move actually does (Rule of Five… and why you often want Rule of Zero)

For resource-owning types, you’ll often hear the Rule of Five: if you manually manage a resource, you typically define (or explicitly default/delete):

  • destructor
  • copy constructor
  • copy assignment
  • move constructor
  • move assignment

A minimal example (illustrative, not production-ready):

#include <cstring>
#include <utility>

struct Buffer {
    char* data = nullptr;
    size_t size = 0;

    Buffer(size_t n) : data(new char[n]), size(n) {}

    ~Buffer() { delete[] data; }

    // Copy ctor
    Buffer(const Buffer& other) : data(new char[other.size]), size(other.size) {
        std::memcpy(data, other.data, size);
    }

    // Move ctor
    Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // Copy assign (NOTE: see exception-safety notes below)
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;
        delete[] data;
        data = new char[other.size];
        size = other.size;
        std::memcpy(data, other.data, size);
        return *this;
    }

    // Move assign
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
        return *this;
    }
};

Key points:

  • Moving is typically O(1) (swap/steal pointers/handles).
  • Copying is typically O(n) (allocate + copy bytes).
  • Mark move operations noexcept when you can.

Production concerns (what the example omits on purpose)

  • Prefer the Rule of Zero: in real code, you usually want std::vector<char>, std::string, or std::unique_ptr<char[]> so the compiler can generate correct moves/copies automatically.
  • Exception safety for copy assignment: the shown operator= deletes first; if allocation throws, the object is left empty/broken. Prefer allocate-before-delete or copy-and-swap.
  • Self-move assignment: x = std::move(x) is rare but possible; robust types should tolerate it (often by swapping or checking).
  • Generated moves can be inhibited: user-declaring a destructor/copy operations can prevent implicit move generation. When you can, default special members (e.g., Buffer(Buffer&&) = default;) and let member types manage resources.

Why noexcept matters: std::move_if_noexcept and vector reallocation

Standard containers (notably std::vector) must relocate elements when they grow. During reallocation, std::vector typically uses std::move_if_noexcept:

  • It will move elements if the move constructor is noexcept.
  • Otherwise, it will copy to preserve strong exception guarantees—unless copying isn’t available, in which case it must move.

So the precise rule of thumb is:

  • If your type is meant to live in std::vector, making its move constructor noexcept can be a major performance lever.

Figure: std::vector reallocation uses std::move_if_noexcept: move if noexcept (or copy is unavailable), otherwise copy for strong exception safety.

Move semantics in everyday code

Now that the mechanics are clear, here’s how it shows up in normal code—often without you writing any move operations.

Returning by value is usually fast (copy elision vs NRVO vs move)

This used to be a performance red flag; now it’s idiomatic:

std::vector<int> make() {
    std::vector<int> v;
    v.push_back(1);
    v.push_back(2);
    return v;
}

What can happen:

  • NRVO (Named Return Value Optimization): v is constructed directly in the caller (not guaranteed, but commonly done).
  • If NRVO doesn’t apply, the return can use a move.
  • Separately, C++17 guaranteed copy elision applies in some return forms (notably returning a prvalue like return std::vector<int>{1,2};), eliminating moves/copies entirely.

A concrete before/after scenario (why this matters)

Suppose you build large vectors and store them in a std::vector<std::vector<int>>:

std::vector<std::vector<int>> all;

std::vector<int> big = /* 1e6 ints */;
all.push_back(big);            // copies big (allocates + copies 1e6 ints)
all.push_back(std::move(big)); // moves big (steals pointer; big becomes valid-but-unspecified)

The difference is typically:

  • copy: new allocation + 1e6 element copies
  • move: pointer/size transfer (O(1)); no per-element copying

Passing and storing temporaries efficiently (push_back vs emplace_back)

std::vector<std::string> names;

names.push_back(std::string("alice")); // constructs a temporary string, then moves it
names.push_back("bob");                // constructs a temporary std::string from const char*, then moves
names.emplace_back("carol");           // constructs the std::string in-place (can avoid a temporary)

One-line difference: push_back("alice") typically creates a temporary std::string then moves it; emplace_back("alice") can build the std::string directly in the vector’s storage.

Avoiding accidental copies (depends on the function signature)

Whether use(v) copies depends entirely on how use is declared:

void use_by_value(std::vector<int> v);
void use_by_cref(const std::vector<int>& v);
void use_by_rref(std::vector<int>&& v);

And the call site:

auto v = make();

use_by_cref(v);            // no copy
use_by_value(v);           // copies (v is an lvalue)
use_by_value(std::move(v)); // moves
use_by_rref(std::move(v));  // binds to && overload

This is the common “why didn’t it move?” surprise: a named variable is an lvalue, even if you feel like it’s temporary.

API design with moves: pass-by-value + move into members

Once you’re comfortable with call sites, the next step is designing APIs that are both ergonomic and efficient.

A common modern pattern:

  • Take a parameter by value
  • Move it into your member/storage
class Widget {
    std::string name;
public:
    explicit Widget(std::string n) : name(std::move(n)) {}
};

Why this works well:

  • Caller passes an lvalue → one copy into n (expected).
  • Caller passes an rvalue → move into n.
  • Inside the constructor, you unconditionally move into the member.

It avoids boilerplate overload sets (const T& and T&&) for many types.

Templates: when T&& is a forwarding reference (and reference collapsing)

So far, T&& meant “rvalue reference”. There’s an important exception:

  • If T is deduced (template type deduction), then T&& becomes a forwarding reference.

High-level rule:

  • If the caller passes an lvalue of type X, then T deduces to X&, and T&& becomes X& &&, which collapses to X&.
  • If the caller passes an rvalue of type X, then T deduces to X, and T&& stays X&&.

That “reference collapsing” is why forwarding references can bind to both lvalues and rvalues.

Typical use: forward arguments to another function/constructor without losing whether they were lvalues or rvalues.

#include <utility>
#include <memory>

template <class T, class... Args>
std::unique_ptr<T> make_unique_like(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

Notes:

  • This is for demonstrating forwarding; in real code, prefer std::make_unique.
  • Historically, std::make_unique uses forwarding for exactly this reason: to pass constructor arguments through without accidental copies.

Figure: Forwarding reference deduction (T deduced) + reference collapsing (X& &&X&) + std::forward preserving value category.

Common pitfalls (and how to avoid them)

1) Using an object after std::move

After moving from an object, it must remain valid, but its value is unspecified.

  • You can destroy it.
  • You can assign a new value.
  • Don’t rely on its contents.

Container/invariant note: a moved-from std::string is valid, but you must not assume it becomes empty (even if it often does in practice).

2) Moving from const (why it usually copies)

Move operations typically take T&& because they need to modify the source.

If you do this:

const std::string s = "x";
auto t = std::move(s);

std::move(s) produces a const std::string&&. A typical move constructor is std::string(std::string&&), which cannot bind to const std::string&&, so overload resolution usually selects the copy constructor instead.

3) Overusing std::move

Don’t sprinkle std::move everywhere.

  • Returning locals: prefer return x; (enables NRVO/copy elision patterns).
  • Moving from something you still need later is a logic bug.

4) Forgetting noexcept on moves

If your type is intended for containers, noexcept moves can determine whether reallocation is fast (move) or expensive (copy via move_if_noexcept).

When move semantics delivers the biggest wins (and when not to over-optimize)

Move semantics shines when:

  • Objects own heap memory (std::vector, std::string, std::unique_ptr)
  • Objects manage OS resources (file handles, sockets) via RAII wrappers
  • Containers reallocate and relocate elements
  • Code is heavy on temporaries (composition, builders)

When not to optimize for moves:

  • Tiny trivially copyable types (int, small POD structs): copying is already cheap.
  • std::shared_ptr: moving is cheap, but copying is also relatively cheap (it’s an atomic refcount bump); prioritize clarity.
  • If adding move-aware overloads harms readability more than it helps.

Practical checklist

  • Prefer RAII types (std::vector, std::string, std::unique_ptr) so moves are cheap.
  • Prefer the Rule of Zero; if you must own resources manually, implement or explicitly default/delete the special members.
  • Mark move operations noexcept when possible.
  • Use std::move only when you are done with an object.
  • In templates, use std::forward with forwarding references to preserve value categories.

Closing thoughts (and how to verify what’s happening)

Move semantics and rvalue references are a cornerstone of modern C++ performance: they let you write clean, value-oriented code (return by value, compose operations, use temporaries) while still getting near “manual optimization” efficiency.

To build intuition, make the behavior observable:

  • Use Compiler Explorer to inspect generated code.
  • Temporarily compile with -fno-elide-constructors (where supported) to see moves/copies during learning.
  • Turn on warnings and sanitizers (-Wall -Wextra, ASan/UBSan) to catch lifetime/ownership mistakes early.
  • Add lightweight logging in special members (copy/move ctor) in toy types to confirm when they fire.