C++ · lesson 14 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Move Semantics and Perfect Forwarding in C++
Move semantics run for real: value categories, what std::move does to a string, copy vs move timed, copy elision, pessimizing moves, and std::forward.
By SkillByExample · Updated
Before C++11, handing a large object to someone else meant copying it, even when you were done with the original. Move semantics lets a type transfer its resources instead — a vector’s heap buffer changes owner in O(1) — and the compiler picks copy or move from the argument’s value category.
Value Categories
An lvalue has an identity you can name or take the address of; an rvalue is a temporary about to disappear. Overloads on &, const& and && show which is which:
#include <iostream>
#include <string>
void take(std::string&) { std::cout << "lvalue ref\n"; }
void take(const std::string&) { std::cout << "const lvalue ref\n"; }
void take(std::string&&) { std::cout << "rvalue ref\n"; }
std::string make() { return "temp"; }
int main() {
std::string s = "named";
const std::string cs = "const";
take(s); // named variable: lvalue
take(cs); // const lvalue
take(make()); // function's returned temporary: rvalue
take(s + "!"); // result of an expression: rvalue
take(std::move(s)); // cast to rvalue
std::string&& rref = make(); // an rvalue reference binds to the temporary...
take(rref); // ...but rref itself has a name: lvalue!
}
$ g++ -std=c++20 -Wall -Wextra -o categories categories.cpp
$ ./categories
lvalue ref
const lvalue ref
rvalue ref
rvalue ref
rvalue ref
lvalue ref
A named variable is an lvalue; a function’s returned temporary and the result of s + "!" are rvalues; std::move(s) turns a name into an rvalue. The last line is the one that trips everyone up: rref has type std::string&&, but it has a name, so using it is an lvalue expression. Type and value category are different things. An rvalue reference also refuses to bind to an lvalue directly:
int main() {
int x = 10;
int&& bad = x; // an rvalue reference can't bind to an lvalue
return bad;
}
$ g++ -std=c++20 -Wall -Wextra -o rref_fail rref_fail.cpp 2>&1 | grep error
rref_fail.cpp:3:17: error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'
std::move Is a Cast
std::move changes nothing by itself; it lets a move constructor be chosen. You can watch whether the heap buffer changed hands by comparing data() pointers:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a(100, 'x'); // too long for the small-string buffer
const char* buffer = a.data();
std::string b = std::move(a); // move constructor
std::cout << std::boolalpha << "b got a's buffer: " << (b.data() == buffer)
<< ", a.size() = " << a.size() << "\n";
std::string small = "hi"; // fits in the object itself (SSO)
const char* sbuf = small.data();
std::string t = std::move(small);
std::cout << "short string buffer transferred: " << (t.data() == sbuf) << "\n";
const std::string c(100, 'y');
const char* cbuf = c.data();
std::string d = std::move(c); // const: can't be moved from
std::cout << "const source moved: " << (d.data() == cbuf)
<< ", c.size() = " << c.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o std_move std_move.cpp
$ ./std_move
b got a's buffer: true, a.size() = 0
short string buffer transferred: false
const source moved: false, c.size() = 100
For the 100-character string, b took over a’s exact buffer and a was left empty. The short string’s buffer did not transfer: libstdc++ stores strings of up to 15 characters inside the object itself (the small-string optimization), so “moving” it copies two bytes — still cheap, just not a pointer handoff. The third case is a real trap: std::move on a const object produces a const std::string&&, which can’t bind to the move constructor’s std::string&&, so the copy constructor runs silently. c keeps its 100 characters and d got a fresh buffer.
What a move saves is easiest to see with a big container, built with -O2:
#include <chrono>
#include <iostream>
#include <utility>
#include <vector>
int main() {
using clock = std::chrono::steady_clock;
std::vector<int> original(10'000'000, 7);
auto t0 = clock::now();
std::vector<int> copied = original; // copies 40 MB
auto t1 = clock::now();
std::vector<int> moved = std::move(original); // takes over the buffer
auto t2 = clock::now();
auto us = [](auto d) { return std::chrono::duration<double, std::micro>(d).count(); };
std::cout << "copy: " << us(t1 - t0) << " us\n";
std::cout << "move: " << us(t2 - t1) << " us\n";
std::cout << "sizes: copied " << copied.size() << ", moved " << moved.size()
<< ", original " << original.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o move_cost move_cost.cpp
$ ./move_cost
copy: 24034.7 us
move: 0.054 us
sizes: copied 10000000, moved 10000000, original 0
Copying ten million ints allocated 40 MB and wrote every element — tens of milliseconds on this run. Moving swapped three pointers and took well under a microsecond. Exact times vary between runs; the thousands-fold gap doesn’t. The moved-from original reports size 0.
Writing Move Operations
A class that owns a raw resource steals it in its move constructor and leaves the source empty but valid. std::exchange does the take-and-reset in one expression.
#include <cstddef>
#include <iostream>
#include <utility>
class Buffer {
char* data_;
std::size_t size_;
public:
explicit Buffer(std::size_t n) : data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer&) = delete; // move-only, to keep this short
Buffer& operator=(const Buffer&) = delete;
Buffer(Buffer&& o) noexcept
: data_(std::exchange(o.data_, nullptr)), // take the pointer, leave nullptr
size_(std::exchange(o.size_, 0)) {}
Buffer& operator=(Buffer&& o) noexcept {
if (this != &o) {
delete[] data_; // release what we had
data_ = std::exchange(o.data_, nullptr);
size_ = std::exchange(o.size_, 0);
}
return *this;
}
std::size_t size() const { return size_; }
};
int main() {
Buffer a(64), b(8);
Buffer c = std::move(a); // move construct
std::cout << "after construct: a=" << a.size() << " c=" << c.size() << "\n";
b = std::move(c); // move assign: b's 8 bytes freed
std::cout << "after assign: b=" << b.size() << " c=" << c.size() << "\n";
a = Buffer(16); // a moved-from object is reusable
std::cout << "reused a: a=" << a.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o buffer buffer.cpp
$ ./buffer
after construct: a=0 c=64
after assign: b=64 c=0
reused a: a=16
After the move construction, a owns nothing (size 0) and c holds the 64 bytes. Move assignment first frees what b had, then takes c’s buffer. The moved-from a is still a valid object, so assigning a new Buffer(16) to it works — that’s the contract: destructible and assignable, contents unspecified. Both move operations are noexcept so std::vector<Buffer> will move rather than copy on reallocation (the classes lesson shows the difference when it’s missing).
Copy Elision, RVO and Pessimizing Moves
Returning an object often involves neither a copy nor a move: the compiler constructs it directly in the caller’s variable.
#include <iostream>
#include <utility>
struct Tracer {
Tracer() { std::cout << " construct\n"; }
Tracer(const Tracer&) { std::cout << " copy\n"; }
Tracer(Tracer&&) noexcept { std::cout << " move\n"; }
};
Tracer make_prvalue() { return Tracer{}; } // guaranteed elision (C++17)
Tracer make_named() { Tracer t; return t; } // NRVO: allowed, usually done
Tracer make_moved() { Tracer t; return std::move(t); } // blocks NRVO
Tracer pass_through(Tracer t) { return t; } // parameter: implicitly moved
int main() {
std::cout << "prvalue:\n"; [[maybe_unused]] Tracer a = make_prvalue();
std::cout << "named:\n"; [[maybe_unused]] Tracer b = make_named();
std::cout << "std::move:\n"; [[maybe_unused]] Tracer c = make_moved();
std::cout << "parameter:\n"; [[maybe_unused]] Tracer d = pass_through(Tracer{});
}
$ g++ -std=c++20 -Wall -Wextra -o rvo rvo.cpp 2>&1 | grep warning
rvo.cpp:12:51: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
$ ./rvo
prvalue:
construct
named:
construct
std::move:
construct
move
parameter:
construct
move
Returning a temporary (return Tracer{};) is guaranteed elision since C++17: one construction, nothing else. Returning a named local is NRVO — not guaranteed, but GCC did it here, again with no move. Writing return std::move(t) changes the returned expression from “the local t” to “an rvalue reference”, which disqualifies elision, so an extra move appears — and -Wall flags it as -Wpessimizing-move. Parameters can’t be elided (the caller constructed them), but return t; on a parameter moves implicitly, so no std::move is needed there either.
Forwarding References and std::forward
In a template, T&& with T deduced is a forwarding reference: it binds to lvalues and rvalues alike. Inside the function, though, the parameter has a name, so it’s an lvalue:
#include <iostream>
#include <string>
#include <utility>
void sink(const std::string&) { std::cout << " sink(const&): copies\n"; }
void sink(std::string&&) { std::cout << " sink(&&): can move\n"; }
template <typename T>
void wrapper_bad(T&& arg) { sink(arg); } // arg has a name: lvalue
template <typename T>
void wrapper(T&& arg) { sink(std::forward<T>(arg)); } // restores the category
int main() {
std::string s = "hello";
std::cout << "wrapper_bad(std::move(s)):\n"; wrapper_bad(std::move(s));
std::cout << "wrapper(s):\n"; wrapper(s);
std::cout << "wrapper(std::move(s)):\n"; wrapper(std::move(s));
std::cout << "wrapper(std::string(\"x\")):\n"; wrapper(std::string("x"));
}
$ g++ -std=c++20 -Wall -Wextra -o forward forward.cpp
$ ./forward
wrapper_bad(std::move(s)):
sink(const&): copies
wrapper(s):
sink(const&): copies
wrapper(std::move(s)):
sink(&&): can move
wrapper(std::string("x")):
sink(&&): can move
wrapper_bad received an rvalue but passed arg on as an lvalue, so the copying overload ran — the rvalue-ness was lost at the first hop. std::forward<T>(arg) restores it: when the caller passed an lvalue, T is std::string& and forward returns an lvalue; when the caller passed an rvalue, T is std::string and forward returns an rvalue. This is how emplace_back and std::make_unique pass their arguments to constructors untouched.
Only T&& with T itself deduced is a forwarding reference. A concrete type or a template wrapped around T is a plain rvalue reference:
#include <string>
#include <vector>
template <typename T> void f(T&&) {} // forwarding reference
void g(std::string&&) {} // rvalue reference
template <typename T> void h(std::vector<T>&&) {} // rvalue reference, not forwarding
int main() {
std::string s;
std::vector<int> v;
f(s); // fine: T = std::string&
g(s); // rejected: s is an lvalue
h(v); // rejected: v is an lvalue
}
$ g++ -std=c++20 -Wall -Wextra -o not_forwarding not_forwarding.cpp 2>&1 | grep error
not_forwarding.cpp:12:7: error: cannot bind rvalue reference of type 'std::string&&' {aka 'std::__cxx11::basic_string<char>&&'} to lvalue of type 'std::string' {aka 'std::__cxx11::basic_string<char>'}
not_forwarding.cpp:13:7: error: cannot bind rvalue reference of type 'std::vector<int>&&' to lvalue of type 'std::vector<int>'
f(s) compiled; g and h rejected lvalues, because std::vector<T>&& can only ever be an rvalue reference to some vector.
Move-Only Types
std::unique_ptr, std::thread and file streams can be moved but not copied, so ownership can’t be accidentally duplicated.
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
std::vector<std::unique_ptr<int>> pool;
void add(std::unique_ptr<int> p) { // by value: the caller must hand it over
pool.push_back(std::move(p)); // and so must we
}
int main() {
auto p = std::make_unique<int>(42);
add(std::move(p));
add(std::make_unique<int>(7)); // a temporary moves implicitly
std::cout << std::boolalpha << "p is null: " << (p == nullptr)
<< ", pool size " << pool.size() << ", first " << *pool[0] << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o move_only move_only.cpp
$ ./move_only
p is null: true, pool size 2, first 42
Every hand-off of the named p needs an explicit std::move, and afterwards p is null — the ownership visibly left. A temporary like std::make_unique<int>(7) moves on its own. Forget the std::move and the compiler refuses:
#include <memory>
void add(std::unique_ptr<int>) {}
int main() {
auto p = std::make_unique<int>(42);
add(p); // would need a copy
}
$ g++ -std=c++20 -Wall -Wextra -o copy_unique_fail copy_unique_fail.cpp 2>&1 | grep error
copy_unique_fail.cpp:7:8: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'
Practice
-
Add
void take(const std::string&&)tocategories.cppand calltake(std::move(cs)). Which overload runs, and what does it tell you about moving from const objects?Solution
The new
const&&overload runs. Without it, the call would picktake(const std::string&)— the same thing that happened tod = std::move(c)instd_move.cpp. Aconst&&can’t be moved from in any useful way, so in practicestd::moveon a const object means “copy”. -
Remove
noexceptfromBuffer’s move constructor, make it copyable, and push threeBuffers into astd::vectorwithoutreserve. How do you tell whether moves or copies happened?Solution
Add a logging copy constructor. Without
noexcept, the vector’s reallocations log copies of the existing elements; with it, they’re moved.std::vectorusesstd::move_if_noexceptso that a throwing move can’t leave it half-relocated. -
Write
template <typename T, typename... Args> std::unique_ptr<T> make(Args&&... args)that forwards toT’s constructor, and use it to build astd::string(3, 'z').Solution
template <typename T, typename... Args> std::unique_ptr<T> make(Args&&... args) { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); } auto p = make<std::string>(3, 'z'); // "zzz"This is essentially
std::make_unique: each argument keeps its value category on the way to the constructor. -
Make
pass_throughinrvo.cpptakeconst Tracer&and return it. What changes in the output, and why?Solution
The
moveline becomescopy.return t;only moves implicitly for local objects and by-value parameters; aconst&refers to someone else’s object, so returning it must copy. -
In
move_cost.cpp, timestd::vector<int> moved2 = std::move(copied);after it has already been moved to. What doescopied.size()report afterwards?Solution
The move is again sub-microsecond, and
copied.size()is 0. Moving from any vector — even an empty one — just swaps its three internal pointers.