Copying a std::vector with a million elements allocates and copies a million
elements. Moving it copies three pointers. Move semantics is the language
machinery that lets a type say "if nobody needs the source any more, steal its
resources instead of duplicating them". The skill is knowing when an
expression may be moved from, writing move operations that are correct and
noexcept, and knowing when the compiler removes copies and moves
entirely.
1. Value categories: who can be moved from?
Every expression has a type and a value category. Three categories matter:
| Category | Meaning | Examples |
|---|---|---|
| lvalue | has an identity you can name again later | x, v[0], *p, s.name, a function returning T& |
| prvalue | a pure value, not yet an object anywhere | 42, x + 1, std::string("hi"), a function returning T |
| xvalue | an object with an identity, marked "expiring" | std::move(x), static_cast<T&&>(x), a function returning T&& |
lvalues and xvalues together are glvalues (they refer to an object); prvalues and xvalues together are rvalues (they may be moved from). The rule that drives everything:
std::move(x) — are both.Overload resolution sends rvalues to
T&¶meters and lvalues toconst T¶meters. A move constructor is just a constructor takingT&&.
#include <print>
#include <string>
#include <utility>
void take(const std::string&) { std::println("lvalue overload: copy if you keep it"); }
void take(std::string&&) { std::println("rvalue overload: free to steal"); }
std::string make() { return "temp"; }
int main() {
std::string s = "named";
take(s); // lvalue
take(make()); // prvalue
take(std::move(s)); // xvalue
take(s + "!"); // prvalue: the result of operator+
}One surprise: a named rvalue reference is an lvalue. Inside
void f(std::string&& p), the expression p has a name, so it's an lvalue.
To move from it you must say std::move(p) again. Names are lvalues, always.
2. std::move doesn't move
std::move(x) is a cast to T&& — it turns an lvalue into an xvalue and does
nothing else. The move happens later, if and when the result is used to
initialise or assign an object and overload resolution picks a move operation.
#include <print>
#include <string>
#include <utility>
int main() {
std::string a = "hello";
std::string&& r = std::move(a); // just a reference; nothing moved
std::println("a is still '{}'", a);
std::string b = std::move(a); // now the move constructor runs
std::println("b is '{}'", b);
}Two consequences:
std::moveon aconstobject givesconst T&&. A move constructor takesT&&, which can't bind to const, so overload resolution falls back to the copy constructor.std::moveof a const object silently copies.- If a type has no move operations (only copies),
std::movecopies too. It is a request, not a command.
3. Writing the move operations
A class owning a heap buffer:
#include <cstddef>
#include <print>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(new char[n]{}), size_(n) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other) : data_(new char[other.size_]), size_(other.size_) {
for (std::size_t i = 0; i < size_; ++i) data_[i] = other.data_[i];
}
// Move: take the pointer, leave the source empty but valid.
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_; // release what we own
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
}
return *this;
}
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer copy(other); // may throw; *this untouched
*this = std::move(copy); // noexcept
}
return *this;
}
std::size_t size() const { return size_; }
private:
char* data_;
std::size_t size_;
};
int main() {
Buffer a(1024);
Buffer b = std::move(a);
std::println("a.size() = {}, b.size() = {}", a.size(), b.size());
}a's destructor deletes nullptr, a no-op.Line by line:
std::exchange(obj, new_value)setsobjtonew_valueand returns the old value: "take it and leave this behind" in one expression.- The source must end in a state its destructor can handle — here
nullptr(delete[] nullptris a no-op). Forgetting to null the source means two objects own the same buffer: a double free. - Move assignment must release what
*thisalready owns first, or it leaks. if (this != &other)guards self-move-assignment (x = std::move(x)). Without it, we'd delete our own buffer and then take the dangling pointer.- Copy assignment builds the copy first, then moves it in. If allocation
throws,
*thisis unchanged (the strong exception guarantee), and self-assignment is naturally safe.
4. Moved-from objects
After a move the source is in a valid but unspecified state: its invariants hold, its destructor runs fine, you may assign to it — but don't assume its value. Standard types are more specific:
std::unique_ptr,std::shared_ptr: guaranteed null after a move.std::vector,std::string: valid but unspecified. In practice usually empty, but the standard only promises you can use operations without preconditions (size(),clear(), assignment). Callingsize()is fine; callingfront()is not, because it requires non-empty.
So reading a moved-from std::string's size is fine; dereferencing a moved-from
std::unique_ptr is a null dereference — undefined behaviour.
5. Rule of zero, rule of five
If your class declares any of destructor, copy constructor, copy assignment, move constructor, move assignment, think about all five ("rule of five"), because the compiler's implicit versions interact:
- Declaring a destructor or a copy operation suppresses the implicit move operations. Your class then silently copies whenever it "moves".
- Declaring a move operation makes the implicit copy operations deleted.
Better: the rule of zero. Hold resources in members that already manage
them (std::vector, std::string, std::unique_ptr) and declare none of the
five. The implicit versions are correct, noexcept where possible, and
memberwise.
#include <memory>
#include <print>
#include <string>
#include <vector>
struct Document { // rule of zero: no special members
std::string title;
std::vector<std::string> lines;
std::unique_ptr<int> handle; // makes Document move-only
};
int main() {
Document d{"notes", {"a", "b"}, std::make_unique<int>(7)};
Document e = std::move(d); // memberwise move, noexcept
std::println("{} lines, handle={}, old handle null: {}",
e.lines.size(), *e.handle, d.handle == nullptr);
}6. Why noexcept matters: std::vector growth
When a vector reallocates it must move its elements to the new buffer. If a
move throws halfway through, some elements are in the new buffer and some in
the old — the vector can't offer the strong guarantee. So std::vector uses
std::move_if_noexcept: it moves only if the move constructor is
noexcept (or the type can't be copied), and copies otherwise.
#include <print>
#include <vector>
struct Noisy {
Noisy() = default;
Noisy(const Noisy&) { std::println("copy"); }
Noisy(Noisy&&) { std::println("move"); } // not noexcept!
};
int main() {
std::vector<Noisy> v(1);
v.reserve(2); // reallocates the one existing element: copies, not moves
}std::move_if_noexcept picks per type: a move that might throw would leave the vector half-moved with no way back, so it copies instead and keeps the strong guarantee.Add noexcept to the move constructor and the same program prints move.
Mark move operations noexcept whenever they can't throw — which, for
pointer-stealing moves, is always.
7. Copy elision: no copy, no move at all
Since C++17, initialising an object from a prvalue of the same type doesn't create a temporary at all: the prvalue initialises the target directly. This is guaranteed copy elision — no copy or move constructor is called, and the type doesn't even need one.
#include <print>
struct Noisy {
Noisy() { std::println("construct"); }
Noisy(const Noisy&) { std::println("copy"); }
Noisy(Noisy&&) noexcept { std::println("move"); }
};
Noisy make() { return Noisy{}; } // returns a prvalue
int main() {
Noisy n = make(); // prints only "construct"
}main reserves n's storage and passes its address; return Noisy{}; constructs straight into it. No temporary, so no copy or move to elide.Returning a named local (Noisy x; ...; return x;) is different: the
compiler may construct x directly in the caller's storage (NRVO), but
isn't required to. If it doesn't, the return is treated as an rvalue and the
local is moved — never copied (when a move constructor exists).
That's why return std::move(local); is a pessimization: it turns the
return expression into an xvalue, which disables NRVO and forces a move
where there might have been nothing. Likewise return std::move(Noisy{});
turns a prvalue (guaranteed elision) into an xvalue (a real move). Write
return local; and return Noisy{};.
8. Worked example: a move-only handle
Some resources must have exactly one owner — a file descriptor, a socket, a
lock. Delete the copy operations and provide moves. C++26 lets = delete
carry a reason that appears in the compiler error:
#include <print>
#include <utility>
class Handle {
public:
explicit Handle(int fd) : fd_(fd) {}
~Handle() { if (fd_ >= 0) std::println("closing fd {}", fd_); }
Handle(const Handle&) = delete("a Handle owns its fd; move it instead");
Handle& operator=(const Handle&) = delete("a Handle owns its fd; move it instead");
Handle(Handle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
Handle& operator=(Handle&& o) noexcept {
if (this != &o) {
reset();
fd_ = std::exchange(o.fd_, -1);
}
return *this;
}
void reset() {
if (fd_ >= 0) std::println("closing fd {}", fd_);
fd_ = -1;
}
int fd() const { return fd_; }
private:
int fd_;
};
int main() {
Handle a(3);
Handle b = std::move(a); // a.fd() == -1 now
std::println("a={} b={}", a.fd(), b.fd());
} // prints "closing fd 3" exactly onceTrying to copy one now fails with your message:
#include <utility>
class Handle {
public:
explicit Handle(int fd) : fd_(fd) {}
Handle(const Handle&) = delete("a Handle owns its fd; move it instead");
Handle(Handle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
private:
int fd_;
};
int main() {
Handle a(3);
Handle b = a; // error: use of deleted function ... a Handle owns its fd; move it instead
}Faded example — your turn
Here's a move-only Lease that holds an int* slot it must return by setting
*slot = 0 in the destructor (if non-null). Fill in the move constructor and
move assignment. What must the moved-from object look like so its destructor
doesn't release the slot a second time?
Lease(Lease&& o) noexcept : slot_(/* ??? */) {}
Lease& operator=(Lease&& o) noexcept {
if (this != &o) {
/* ??? */
}
return *this;
}Answer
Lease(Lease&& o) noexcept : slot_(std::exchange(o.slot_, nullptr)) {}
Lease& operator=(Lease&& o) noexcept {
if (this != &o) {
release(); // give back our own slot first
slot_ = std::exchange(o.slot_, nullptr);
}
return *this;
}The moved-from Lease holds nullptr, which its destructor ignores.
9. Forwarding references and std::forward (briefly)
In a template, T&& where T is deduced is a forwarding reference: it
binds to anything, and T remembers whether the argument was an lvalue
(T = U&) or an rvalue (T = U). std::forward<T>(x) re-applies that
category: it moves rvalues and leaves lvalues alone. Use std::move on rvalue
references, std::forward on forwarding references.
#include <print>
#include <string>
#include <utility>
#include <vector>
template <class T>
void add(std::vector<std::string>& v, T&& s) {
v.push_back(std::forward<T>(s)); // copies lvalues, moves rvalues
}
int main() {
std::vector<std::string> v;
std::string keep = "kept";
add(v, keep); // copy: keep is still usable
add(v, std::string("moved")); // move
std::println("{} {} | keep='{}'", v[0], v[1], keep);
}Summary
- lvalue: has a name. prvalue: a pure value. xvalue:
std::move(x). Rvalues bind toT&&, and that's how move operations get chosen. std::moveis a cast. Moving from aconstobject copies.- Move operations steal resources with
std::exchange, leave the source destructible, release the old resource in move assignment, guard self-move, and arenoexcept— otherwisestd::vectorcopies instead. - Prefer the rule of zero; if you write one special member, think about all five.
- Prvalue initialisation is guaranteed elision; returning a named local may
use NRVO or a move. Never
return std::move(local). - Move-only types:
= delete("reason")the copies (C++26).