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.