Move Semantics and Rvalue References
When a senior engineer at a Bangalore-based startup reviews your C++ code, one of the first things they check is whether you are needlessly copying large objects. In systems that handle millions of network packets, image buffers, or JSON documents per second, unnecessary copying kills performance. C++11 introduced move semantics — a way to transfer resources from one object to another instead of copying them — and it is one of the most impactful improvements the language has ever received.
This chapter explains the underlying concepts (lvalues, rvalues, and references to each), the mechanics of move constructors and move assignment, and how to apply them through a Buffer class that avoids expensive deep copies.
Lvalues and Rvalues — A Mental Model
Every expression in C++ has a value category. The two fundamental ones are:
| Category | Description | Example |
|---|---|---|
| lvalue | Has a persistent, addressable location in memory. Can appear on the left of =. | int x = 5; x is an lvalue |
| rvalue | Temporary; no stable address. Cannot appear on the left of =. | 5, x + 1, std::string("hello") |
Think of an lvalue as something with a name and a home address, and an rvalue as a temporary value that exists only long enough to be used.
int a = 10; // a is an lvalue; 10 is an rvalue
int b = a + 3; // a + 3 is an rvalue (temporary result)
int* p = &a; // OK: a is an lvalue, has an address
// int* q = &(a + 3); // Error: rvalue has no address
Lvalue References and Rvalue References
A regular (lvalue) reference binds to lvalues:
int x = 42;
int& ref = x; // OK
// int& ref2 = 42; // Error: cannot bind lvalue ref to rvalue
C++11 introduced the rvalue reference (&&), which binds only to rvalues (temporaries):
int&& rref = 42; // OK: rvalue reference to a temporary
int&& rref2 = x + 1; // OK: x + 1 is a temporary
// int&& rref3 = x; // Error: x is an lvalue
The purpose of rvalue references is to let you detect temporaries and steal their resources rather than copying them.
The Problem with Copying
Consider a class that manages a heap-allocated buffer:
#include <iostream>
#include <cstring>
class Buffer {
public:
int* data;
size_t size;
Buffer(size_t n) : size(n), data(new int[n]) {
std::cout << "Constructed buffer of size " << n << "\n";
}
// Copy constructor — expensive deep copy
Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
std::memcpy(data, other.data, size * sizeof(int));
std::cout << "Copied buffer of size " << size << "\n";
}
~Buffer() {
delete[] data;
std::cout << "Destroyed buffer\n";
}
};
Buffer makeBuffer() {
return Buffer(1024); // temporary rvalue
}
int main() {
Buffer b = makeBuffer(); // triggers copy in C++03
}
In C++03, makeBuffer() returns a temporary Buffer, which is then copied into b. That means new int[1024] runs twice and memcpy copies 4 KB of data — all for a temporary that is immediately discarded. Move semantics solve this.
The Move Constructor
A move constructor takes an rvalue reference to its own type and steals (transfers) the resources, leaving the source in a valid but unspecified state (usually a null/empty state):
// Move constructor
Buffer(Buffer&& other) noexcept
: size(other.size), data(other.data) // steal pointers/values
{
other.data = nullptr; // leave source in a safe state
other.size = 0;
std::cout << "Moved buffer of size " << size << "\n";
}
No heap allocation, no memcpy — just pointer assignment. The destructor of the moved-from object will then call delete[] nullptr, which is a no-op.
The Move Assignment Operator
Similarly, move assignment transfers resources from a temporary to an existing object:
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this; // self-assignment guard
delete[] data; // release our current resource
data = other.data; // steal
size = other.size;
other.data = nullptr;
other.size = 0;
std::cout << "Move-assigned buffer of size " << size << "\n";
return *this;
}
The Rule of Five
If your class manages a resource (raw pointer, file handle, socket, etc.), you almost certainly need all five special member functions:
| Special Member | Purpose |
|---|---|
| Destructor | Releases the resource |
| Copy constructor | Deep copy for lvalue sources |
| Copy assignment operator | Deep copy for lvalue assignment |
| Move constructor | Transfer for rvalue sources |
| Move assignment operator | Transfer for rvalue assignment |
If you define any one of these, the compiler may not generate the others correctly. Define all five explicitly — this is the Rule of Five.
Here is the complete, production-quality Buffer class:
#include <iostream>
#include <cstring>
#include <utility>
class Buffer {
public:
int* data;
size_t size;
// 1. Constructor
explicit Buffer(size_t n = 0)
: size(n), data(n ? new int[n]() : nullptr) {
std::cout << "[Construct] size=" << size << "\n";
}
// 2. Destructor
~Buffer() {
delete[] data;
std::cout << "[Destroy] size=" << size << "\n";
}
// 3. Copy constructor
Buffer(const Buffer& other)
: size(other.size), data(other.size ? new int[other.size] : nullptr) {
if (data) std::memcpy(data, other.data, size * sizeof(int));
std::cout << "[Copy] size=" << size << "\n";
}
// 4. Copy assignment
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
delete[] data;
size = other.size;
data = size ? new int[size] : nullptr;
if (data) std::memcpy(data, other.data, size * sizeof(int));
std::cout << "[CopyAssign] size=" << size << "\n";
return *this;
}
// 5. Move constructor
Buffer(Buffer&& other) noexcept
: size(other.size), data(other.data) {
other.data = nullptr;
other.size = 0;
std::cout << "[Move] size=" << size << "\n";
}
// 6. Move assignment
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
std::cout << "[MoveAssign] size=" << size << "\n";
return *this;
}
};
std::move — Casting to an Rvalue
std::move does not move anything — it is a cast that turns an lvalue into an rvalue reference, signalling "I am done with this object; you may steal its resources."
#include <iostream>
#include <vector>
int main() {
Buffer a(512);
// Without std::move: would call copy constructor
Buffer b = std::move(a); // calls MOVE constructor
std::cout << "a.size after move: " << a.size << "\n"; // 0
std::cout << "b.size: " << b.size << "\n"; // 512
}
After std::move(a), the object a is in a moved-from state. You can still assign to it or destroy it, but you must not use its former contents.
std::move with STL Containers
STL containers use move semantics extensively. Inserting a temporary string into a vector is free (no copy):
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> names;
std::string temp = "Aditya Kumar";
names.push_back(std::move(temp)); // steals temp's buffer
std::cout << "temp is now: '" << temp << "'\n"; // empty
std::cout << "names[0]: " << names[0] << "\n"; // Aditya Kumar
}
Perfect Forwarding with std::forward
When writing a template function that forwards arguments to another function, you want to preserve whether the argument was an lvalue or an rvalue. This is called perfect forwarding, and it uses std::forward:
#include <iostream>
#include <utility>
void process(Buffer& b) { std::cout << "lvalue process\n"; }
void process(Buffer&& b) { std::cout << "rvalue process\n"; }
template<typename T>
void relay(T&& arg) {
// Without forward: always calls lvalue overload
// With forward: preserves value category
process(std::forward<T>(arg));
}
int main() {
Buffer b(10);
relay(b); // lvalue process
relay(std::move(b)); // rvalue process
relay(Buffer(20)); // rvalue process
}
T&& in a template context is a forwarding reference (also called a universal reference), not an rvalue reference. std::forward<T> restores the original value category.
Copy Elision — RVO and NRVO
In many cases the compiler can completely eliminate the copy or move, constructing the object directly in its final location. This is called copy elision:
- RVO (Return Value Optimization): The compiler builds the returned temporary directly in the caller's return slot.
- NRVO (Named Return Value Optimization): Same, but for named local variables returned by value.
Buffer makeBuffer(size_t n) {
Buffer b(n); // NRVO: b is constructed directly in caller's slot
return b; // No copy, no move in practice
}
int main() {
Buffer result = makeBuffer(256); // Only one construction
}
Since C++17, RVO is guaranteed for prvalues. Do not std::move a return value — it prevents NRVO:
// WRONG: prevents NRVO, forces a move instead of elision
return std::move(b);
// CORRECT: let the compiler elide
return b;
Common Pitfalls
1. Using a moved-from object
After std::move(x), the object x is in a valid but indeterminate state. Do not read its value; only destroy it or reassign it.
2. Forgetting noexcept on move operations
STL containers (e.g., std::vector) will only use your move constructor during reallocation if it is marked noexcept. Without noexcept, the vector falls back to copying for strong exception safety. Always mark move constructor and move assignment as noexcept when they cannot throw.
3. std::move on a return statement prevents elision
Writing return std::move(localVar) is a pessimisation. The compiler applies NRVO automatically; std::move interferes with it.
4. Copying instead of moving large containers
Passing a std::vector by value to a function that does not need a copy should use std::move at the call site, or the function should take an rvalue reference or a value with the expectation that the caller moves in.
5. Shallow move leaving double-free bugs
If your move constructor transfers a pointer but forgets to null the source pointer, both objects will delete[] the same memory. Always null the source after stealing.
Practice Exercises
-
Add a
fill(int val)method to theBufferclass that sets every element toval. Write a program that creates aBuffer(8), fills it with42, moves it into a secondBuffer, and prints the first element of the second buffer. -
Implement a
Matrixclass withrows * colsheap-allocateddoublestorage. Provide all five special member functions. Verify with amakeMatrix()factory function that only one construction occurs (use print statements). -
Write a template function
makeUnique(Args&&... args)that perfectly forwards its arguments to construct aTon the heap and return a raw pointer. (This is a simplified version ofstd::make_unique.) -
Explain why
std::vector<Buffer>uses the move constructor (not copy) when it resizes, and what happens ifBuffer's move constructor is notnoexcept. -
Create a
swapfunction forBufferthat exchanges two buffers without any heap allocation (hint: usestd::movetwice or three times).
Summary
- An lvalue has a named, addressable location; an rvalue is a temporary with no stable address.
- Rvalue references (
&&) bind to temporaries and enable resource theft. - The move constructor and move assignment operator transfer heap resources instead of copying them, turning O(n) copies into O(1) pointer swaps.
- The Rule of Five: if you define a destructor, copy constructor, or copy assignment, also define the move constructor and move assignment.
std::movecasts an lvalue to an rvalue reference — it does not move anything by itself.std::forwardin templates preserves the original value category (perfect forwarding).- Copy elision / RVO / NRVO can eliminate copies and moves entirely; never write
return std::move(local)as it prevents elision. - Always mark move operations
noexceptso STL containers can use them during reallocation.