Skip to content

02 · Move Semantics & Rvalue References

Every constructor and assignment you've written so far has implicitly copied. Copying a std::vector<std::string> with a million entries means allocating a new buffer and duplicating every string. Often, though, the source object is a temporary that's about to be destroyed anyway — copying it is pure waste. Move semantics let the compiler recognize that case and steal the source's internals instead of duplicating them.

lvalues and rvalues, briefly

Every expression in C++ is either an lvalue (has a name, a persistent address you could take & of) or an rvalue (a temporary, about to disappear).

int x = 5;        // x is an lvalue -- it has a name and an address
int y = x + 1;    // (x + 1) is an rvalue -- a temporary, gone after this line

std::string makeGreeting() { return "hi"; }

std::string a = makeGreeting();   // the return value is an rvalue -- a temporary
std::string b = a;                // 'a' is an lvalue -- has a name, still needed after

This distinction is what move semantics is built on: an rvalue is a value nobody else can reference again, so it's always safe to cannibalize it.

Rvalue references — T&&

A regular reference T& can only bind to an lvalue. T&& is a new kind of reference that binds only to rvalues, and it's how a function says "I promise to only touch things nobody else needs anymore."

#include <iostream>

void inspect(const std::string& s) { std::cout << "lvalue ref: " << s << std::endl; }
void inspect(std::string&& s)      { std::cout << "rvalue ref: " << s << std::endl; }

int main() {
    std::string name = "Ada";
    inspect(name);               // name is an lvalue -> calls the T& overload
    inspect("Ada");              // calls T&& -- a string literal converted to a temporary
    inspect(std::string("Bob")); // calls T&& -- an explicit temporary
}
// lvalue ref: Ada
// rvalue ref: Ada
// rvalue ref: Bob

Overload resolution picks the && version whenever the argument is a temporary, and the & version whenever it's a named object. This is exactly how the standard library picks a move constructor over a copy constructor.

Move constructor and move assignment

A class that owns a resource (heap memory, a file handle, a socket) can define a move constructor and move assignment operator that transfer ownership instead of duplicating it.

#include <iostream>
#include <cstring>

class Buffer {
public:
    explicit Buffer(std::size_t size) : size_(size), data_(new int[size]) {
        std::cout << "Allocated " << size_ << " ints" << std::endl;
    }

    // Copy constructor -- duplicates the heap buffer. Expensive.
    Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
        std::memcpy(data_, other.data_, size_ * sizeof(int));
        std::cout << "Copied " << size_ << " ints" << std::endl;
    }

    // Move constructor -- steals the pointer, leaves 'other' empty. Cheap: O(1).
    Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;         // 'other' no longer owns anything
        std::cout << "Moved buffer" << std::endl;
    }

    // Move assignment -- release our own resource, then steal the source's.
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = other.data_;
            other.size_ = 0;
            other.data_ = nullptr;
        }
        return *this;
    }

    ~Buffer() { delete[] data_; }

    std::size_t size() const { return size_; }

private:
    std::size_t size_;
    int* data_;
};

Buffer makeBuffer() {
    Buffer b(1000);
    return b;    // moved (or elided entirely) into the caller -- not copied
}

int main() {
    Buffer a(10);
    Buffer b = std::move(a);    // explicit move: 'a' is emptied, 'b' now owns the memory
    std::cout << "a.size()=" << a.size() << " b.size()=" << b.size() << std::endl;

    Buffer c = makeBuffer();    // typically constructed directly in c's storage (RVO)
}
// Allocated 10 ints
// Moved buffer
// a.size()=0 b.size()=1000
// Allocated 1000 ints

The move constructor is marked noexcept deliberately: std::vector checks this at compile time, and if a type's move constructor might throw, vector falls back to the slower copy constructor when it needs to relocate elements during a resize — because a throw mid-move would leave the vector in a half-moved, unrecoverable state. Always mark move operations noexcept when they genuinely cannot throw.

std::move doesn't move anything

This is the single most common misunderstanding. std::move is a cast — it does zero work at runtime. It just tells the compiler "treat this named lvalue as an rvalue," which makes the && overloads eligible.

#include <string>
#include <utility>

std::string s = "hello";
std::string t = std::move(s);   // std::move(s) is just static_cast<std::string&&>(s)
// The actual "move" happens inside std::string's move constructor,
// which runs because the cast made it a viable candidate.

After a move, the source is in a "valid but unspecified" state. For standard library types this typically means empty, but the exact state isn't guaranteed beyond "safe to destroy or reassign." Never read from a moved-from object before reassigning it:

std::string s = "hello";
std::string t = std::move(s);
std::cout << s.size() << std::endl;   // legal, but don't rely on a specific value
s = "reset";                          // fine -- reassignment always works

The Rule of Five (and Zero)

If a class needs a custom destructor, it almost certainly needs to think about all five special member functions together — the Rule of Five (the old Rule of Three, extended for move):

Function Purpose
Destructor Release the owned resource
Copy constructor Duplicate the resource
Copy assignment Release ours, duplicate theirs
Move constructor Steal the resource
Move assignment Release ours, steal theirs

Declaring any one of these suppresses the compiler's implicitly generated move constructor/assignment (though copy operations are still generated in some cases, deprecated since C++11 — don't rely on it). Better yet: follow the Rule of Zero. Store resources in std::vector, std::string, std::unique_ptr, etc. instead of raw pointers, and let the compiler generate all five members for free, correctly, because each member already knows how to move and copy itself.

// Rule of Zero: no destructor, no copy/move ops written by hand --
// the compiler generates all five correctly because every member
// (string, vector, unique_ptr) already manages its own resource.
class Report {
public:
    Report(std::string title) : title_(std::move(title)) {}
private:
    std::string title_;
    std::vector<int> scores_;
};

Perfect forwarding — std::forward and forwarding references

A function template parameter written T&& (not a fixed type's &&) is a forwarding reference: it can bind to either an lvalue or an rvalue, deducing T differently in each case. std::forward<T> then passes it along preserving whichever it originally was.

#include <iostream>
#include <utility>

void handle(const std::string&) { std::cout << "handle(lvalue)" << std::endl; }
void handle(std::string&&)      { std::cout << "handle(rvalue)" << std::endl; }

// T&& here is a forwarding reference, not a plain rvalue reference,
// because T is a template parameter deduced at the call site.
template <typename T>
void relay(T&& arg) {
    handle(std::forward<T>(arg));   // forwards as whatever category it was received as
}

int main() {
    std::string name = "Ada";
    relay(name);              // handle(lvalue) -- name is an lvalue
    relay(std::string("x"));   // handle(rvalue) -- a temporary
}
// handle(lvalue)
// handle(rvalue)

Without std::forward, arg inside relay would always be treated as an lvalue (it has a name!), so the rvalue overload of handle would never be selected — the "moveability" of the original argument would be silently lost. This is exactly how std::make_unique, std::vector::emplace_back, and other factory functions forward constructor arguments without an extra copy.

Cheat sheet

Syntax Meaning
T& lvalue reference — binds to named objects
const T& binds to anything (lvalue or rvalue), read-only
T&& (fixed T) rvalue reference — binds only to temporaries/std::moved values
T&& (T is a deduced template param) forwarding reference — binds to either
std::move(x) cast x to an rvalue reference; moves nothing itself
std::forward<T>(x) inside a template, forward x preserving its original value category

Traps

Returning std::move(localVar) from a function usually hurts. The compiler's Return Value Optimization already avoids the copy/move entirely for a local variable returned by value; wrapping it in std::move can disable that optimization and force an actual move instead of zero operations.

Buffer makeBuffer() {
    Buffer b(1000);
    return b;              // best: RVO, likely zero copies/moves at all
    // return std::move(b);   // worse: forces a move, defeats RVO
}

A moved-from object is still a live object. It will still be destroyed, and it can still be assigned to — but calling ordinary methods on it before reassigning is asking for surprises, even if it technically compiles.

How It Actually Works

An rvalue reference (T&&) is a distinct type the overload-resolution mechanism uses to detect "this argument is a temporary, safe to cannibalize" versus an lvalue reference (T&), which means "this argument is a named object someone else still needs." The compiler determines this purely from the expression's category at the call site — a temporary returned by value, or the explicit result of std::move(), binds to T&& overloads in preference to T&; a named variable binds to T& unless explicitly cast. Critically, std::move does not move anything by itself — it's a static_cast to an rvalue reference type, purely a compile-time signal that tells overload resolution "treat this named object as movable," and it's entirely up to the selected move constructor/assignment to actually act on that.

A move constructor for something like std::vector<int> doesn't copy any elements at all: it copies the source's three internal pointers (begin, end, capacity-end — see Level 1 Module 5) into the new object and sets the source's pointers to nullptr. That's the entire operation — three pointer copies and three null-outs, versus a copy constructor's O(n) allocate-and- duplicate-every-element. This is why moving a vector of a million strings is essentially free while copying it is not: ownership of the same heap buffer transfers to the new object, and the old object is left in a valid but unspecified ("moved-from") state — specifically empty, for a well-behaved vector, precisely so its destructor (which will still run) safely does nothing rather than double-freeing memory the new object now owns.

Perfect forwarding (std::forward) solves a narrower problem inside templates: a function parameter T&& in a template context is a "forwarding reference" that can bind to either an lvalue or rvalue, but once it has a name inside the function body, it's itself an lvalue (named things are always lvalues) — so passing it onward naively would always copy. std::forward<T>(x) conditionally casts it back to an rvalue only if T was deduced as a non-reference type, preserving whichever value category the original caller passed in, all the way through a chain of forwarding calls.

Exercise

Give the Buffer class above a Buffer(const Buffer&) = delete; (make it move-only, like std::unique_ptr), keeping the move constructor and move assignment. Confirm Buffer b2 = b1; now fails to compile with a clear "use of deleted function" error, while Buffer b2 = std::move(b1); still works. Then write a template <typename T> void logAndForward(T&& value) that prints whether it received an lvalue or rvalue (hint: std::is_lvalue_reference<T> inside the template — T deduces to U& for lvalues and U for rvalues) and forwards value into a std::vector<Buffer>::push_back.