Skip to main content
C++ advanced Lesson 14 of 23

Move Semantics and Perfect Forwarding in C++

Understand lvalues and rvalues, std::move, std::forward, and how to write efficient move constructors.

Before C++11, passing large objects meant copying them — even when you were done with the original. A function returning a std::vector<int> with a million elements would copy all million integers. Move semantics solves this: instead of copying the data, you transfer it. The move constructor “steals” the source’s heap buffer, leaving the source in a valid-but-empty state. This turns O(n) copies into O(1) transfers for strings, vectors, and any heap-owning type — and it happens automatically in many contexts without you writing a single extra line.

Value Categories

Every expression in C++ has a value category. The two you interact with most are:

  • lvalue — has an identity; you can take its address. Named variables, dereferenced pointers, function return values stored in a variable.
  • rvalue — temporary; no persistent address. Literals, the result of arithmetic, objects returned from functions (when not bound to a named variable).

Understanding value categories is what makes move semantics make sense: rvalues are temporaries that nobody else holds a reference to, so it is safe to “steal” their resources.

int x = 10;
int& lref = x;        // lvalue reference — binds to lvalues only
int&& rref = 42;      // rvalue reference — binds to rvalues (temporaries)
// int&& bad = x;     // compile error — can't bind rvalue ref to named variable

Rvalue references (T&&) are the mechanism that enables move semantics: they let you write overloads that are chosen only when the caller is passing a temporary or an explicitly-moved object.

std::move — Casting to Rvalue

std::move is unconditional: it casts its argument to an rvalue reference regardless of what it is. It does not move data — it just enables the move constructor or move assignment to be selected by making the argument look like a temporary. The actual transfer of resources happens in the move constructor.

#include <utility>
#include <string>
#include <iostream>

int main() {
    std::string a = "hello";
    std::string b = std::move(a);  // move constructor: b takes ownership of a's buffer
    // a is now in a valid-but-unspecified state (typically empty)
    std::cout << b << "\n";         // "hello"
    std::cout << a.empty() << "\n"; // likely 1 — a gave up its buffer
}

Writing Move Constructors and Move Assignment

For a class that directly owns a resource, the Rule of Five applies: define all five special members. Move operations must be noexcept — standard containers like std::vector only use your move constructor during reallocation if it is noexcept; otherwise they fall back to slower copies.

#include <cstddef>
#include <utility>
#include <algorithm>

class Buffer {
public:
    explicit Buffer(std::size_t size)
        : data_(new char[size]), size_(size) {}

    ~Buffer() { delete[] data_; }

    // Copy constructor — deep copy: allocate new storage, copy contents
    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // Copy assignment — copy-and-swap for exception safety
    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            Buffer tmp(other);
            swap(tmp);
        }
        return *this;
    }

    // Move constructor — steal the pointer, leave other in a valid empty state
    // noexcept: required for std::vector to use moves during reallocation
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;  // source no longer owns the memory
        other.size_ = 0;
    }

    // Move assignment — same steal-and-null pattern
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;      // release current resource
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    void swap(Buffer& other) noexcept {
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
    }

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

Return Value Optimization (RVO and NRVO)

The compiler is allowed — and in C++17, required in many cases — to construct a return value directly in the caller’s storage, skipping any copy or move entirely. This is called copy elision. Understanding it prevents a common mistake: adding std::move to a return statement, which actually disables elision and forces a move instead.

std::string make_greeting(std::string name) {
    return "Hello, " + name;  // NRVO likely applies — constructed directly in caller's storage
}

// DO NOT write std::move on a return of a local variable:
std::string bad_return(std::string name) {
    return std::move(name);  // defeats NRVO — forces a move when elision was possible
}

Only use std::move on a return value when returning a parameter or a member — not a local variable that the compiler can elide.

Forwarding References and std::forward

A function template parameter written as T&& where T is deduced is a forwarding reference (also called a universal reference). It collapses to an lvalue reference when passed an lvalue, and an rvalue reference when passed an rvalue. This lets a single template function accept both.

The problem is that inside the function, arg has a name — which makes it an lvalue, regardless of how it was passed. std::forward<T>(arg) restores the original value category, enabling the correct overload to be selected downstream.

#include <utility>

// Without forwarding — arg is always an lvalue inside the function, always copies
template<typename T>
void wrapper_bad(T&& arg) {
    sink(arg);  // arg is an lvalue here — always calls the lvalue overload
}

// With perfect forwarding — preserves the caller's value category
template<typename T>
void wrapper(T&& arg) {
    sink(std::forward<T>(arg));  // forwards as lvalue or rvalue depending on how arg was passed
}

void sink(std::string& s)  { /* lvalue overload — called for named variables */ }
void sink(std::string&& s) { /* rvalue overload — called for temporaries */ }

int main() {
    std::string s = "hello";
    wrapper(s);             // calls sink(std::string&)  — s is an lvalue
    wrapper(std::move(s));  // calls sink(std::string&&) — std::move makes it an rvalue
    wrapper("world");       // calls sink(std::string&&) — literal is a temporary
}

Forwarding Reference vs Rvalue Reference

The syntax looks identical but the semantics differ based on whether T is a deduced template parameter:

template<typename T>
void f(T&& x);         // forwarding reference — T is deduced, x can bind to lvalue or rvalue

void g(std::string&& x);  // rvalue reference — not a template, only binds to rvalues

template<typename T>
void h(std::vector<T>&& x); // rvalue reference — T is deduced but the parameter
                              // type is not a plain T&&, so this is NOT a forwarding reference

Practical Example: Emplace vs Push

The emplace_back family uses perfect forwarding to construct objects in place, avoiding a temporary. This is the most common place you benefit from understanding forwarding without writing any forwarding code yourself.

#include <vector>
#include <string>

struct Point {
    int x, y;
    Point(int x, int y) : x(x), y(y) {}
};

int main() {
    std::vector<Point> pts;
    pts.push_back(Point{1, 2});   // constructs a temporary Point, then moves it in
    pts.emplace_back(3, 4);       // forwards (3, 4) directly to Point's constructor — zero overhead
}

Move-Only Types

Some types are intentionally move-only: std::unique_ptr, std::thread, std::fstream. Disabling copy enforces single ownership at the type system level — you cannot accidentally share a file handle or a thread by copying it. When you need to transfer a move-only type, you must be explicit with std::move.

#include <memory>
#include <vector>

std::vector<std::unique_ptr<int>> pool;

void add(std::unique_ptr<int> p) {
    pool.push_back(std::move(p));  // must move into the vector — copy is deleted
}

int main() {
    auto p = std::make_unique<int>(42);
    add(std::move(p));  // transfer ownership explicitly
    // p is null here — ownership is gone
}

Understanding move semantics is what separates C++ code that accidentally copies gigabytes of data from code that runs at the speed the hardware allows.

Frequently Asked Questions

Does std::move actually move anything?
No, std::move is just a cast to an rvalue reference. The actual move happens in the move constructor or move assignment operator.
When should I use std::forward?
In template functions that take forwarding references (T&&). It preserves the value category of the argument when forwarding to another function.