Move Semantics in C++
Prerequisites: Value categories (lvalue vs rvalue), Rule of Five, RAII
Standard: C++11 (std::move, rvalue references, move special members)
VALUE CATEGORIES Deep Dive
Watch out: std::move does NOT move -- it casts to an rvalue reference. The actual move happens in the move constructor / move assignment operator. -----------------------------------------------
class Buffer {
char* data_;
std::size_t size_;
std::string name_;
public:
// Constructor
Buffer(std::string name, std::size_t size)
: data_(new char[size]), size_(size), name_(std::move(name)) {
std::memset(data_, 0, size_);
std::cout << std::format(" [{}] Constructed ({} bytes)\n", name_, size_);
}
// Destructor
~Buffer() {
if (data_) {
std::cout << std::format(" [{}] Destroyed\n", name_);
}
delete[] data_;
}
// ---- Copy constructor (expensive: allocates + copies) ----
Buffer(const Buffer& other)
: data_(new char[other.size_]),
size_(other.size_),
name_(other.name_ + " (copy)") {
std::memcpy(data_, other.data_, size_);
std::cout << std::format(" [{}] COPIED ({} bytes allocated)\n",
name_, size_);
}
// ---- Move constructor (cheap: just pointer swap) ----
//STD::MOVE Deep Dive
std::move does NOT move anything. It is purely a cast:
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& x) noexcept {
return static_cast<std::remove_reference_t<T>&&>(x);
}
Conceptually: T&& move(T& x) { return static_cast<T&&>(x); }
All it does is change the value category of 'x' from lvalue to
xvalue (eXpiring value). This makes the expression eligible for
binding to T&& parameters, so the compiler selects the move
constructor instead of the copy constructor.
The *actual* resource transfer (stealing the pointer, nulling the
source) happens right here in the move constructor that YOU write.
Watch out: after std::move, the source object is in a
"valid but unspecified" state. The C++ standard only guarantees
that you can safely destroy it or assign a new value to it.
Do NOT read from it expecting any particular contents. Buffer(Buffer&& other) noexcept
: data_(other.data_), // Steal the pointer
size_(other.size_),
name_(std::move(other.name_) + " (moved)") {
other.data_ = nullptr; // Leave source in valid empty state
other.size_ = 0;
std::cout << std::format(" [{}] MOVED (zero allocation)\n", name_);
}
// ---- Copy assignment ----
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data_;
data_ = new char[other.size_];
size_ = other.size_;
std::memcpy(data_, other.data_, size_);
name_ = other.name_ + " (copy=)";
std::cout << std::format(" [{}] Copy assigned\n", name_);
}
return *this;
}
// ---- Move assignment ----
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
name_ = std::move(other.name_) + " (move=)";
other.data_ = nullptr;
other.size_ = 0;
std::cout << std::format(" [{}] Move assigned\n", name_);
}
return *this;
}
[[nodiscard]] std::size_t size() const { return size_; }
[[nodiscard]] const std::string& name() const { return name_; }
};2. Functions demonstrating when moves happen
of a local variable -- it inhibits NRVO (named return value optimization), making performance WORSE, not better.
Watch out: do NOT std::move the return value
Buffer create_buffer() {
Buffer b("factory", 1024);
return b; // NRVO (Named Return Value Optimization) may elide the move
}
void take_ownership(Buffer b) {
std::cout << std::format(" Took ownership of: {}\n", b.name());
// b is destroyed at end of function
}3. std::move with STL containers
Moving strings and vectors avoids deep copies. is in a valid-but-unspecified state -- you can assign to it or destroy it, but do NOT read from it expecting any particular value.
Watch out: after std::move, the source object
void stl_move_demo() {
std::cout << "\n--- STL Move Semantics ---\n";
std::string source = "Hello, this is a long string that uses heap memory";
std::cout << std::format("Before move: source = '{}'\n", source);
std::string dest = std::move(source); // Pointer swap, O(1)
std::cout << std::format("After move: dest = '{}'\n", dest);
std::cout << std::format("After move: source = '{}' (valid but unspecified)\n",
source);
// Moving elements into a vector
std::vector<std::string> names;
std::string name = "Alice";
names.push_back(std::move(name)); // Move instead of copy
names.emplace_back("Bob"); // Construct in-place (even better)
std::cout << std::format("names: [{}, {}]\n", names[0], names[1]);
}4. Perfect forwarding with std::forward
Preserves the value category (lvalue/rvalue) of arguments.
//
// HOW IT WORKS: PERFECT FORWARDING
//
// In the signature template<typename T> void wrapper(T&& arg),
// T&& is a "forwarding reference" (sometimes called "universal
// reference") -- NOT an rvalue reference -- because T is being
// deduced by the compiler.
//
// Reference collapsing rules determine what T&& becomes:
//
// If the caller passes an lvalue (e.g., a named variable):
// T is deduced as int&
// T&& = int& && = int& (reference collapsing)
// -> arg is an lvalue reference
//
// If the caller passes an rvalue (e.g., std::move(x) or a temp):
// T is deduced as int
// T&& = int&& (no collapsing needed)
// -> arg is an rvalue reference
//
// The problem: inside the function, 'arg' is always an lvalue
// (because it has a name), regardless of what the caller passed.
// If you just wrote take_ownership(arg); it would ALWAYS copy.
//
// std::forward<T>(arg) casts 'arg' back to its original category:
// - If T = int& : forward returns int& (stays lvalue)
// - If T = int : forward returns int&& (restored to rvalue)
//
// Without std::forward, the rvalue-ness of the original argument
// is lost the moment it binds to the named parameter 'arg'.
// Forward restores it, enabling the callee (take_ownership) to
// select the move constructor when the caller intended a move.template<typename T>
void wrapper(T&& arg) {
// std::forward<T> passes lvalues as lvalues, rvalues as rvalues
take_ownership(std::forward<T>(arg));
}int main() {
std::cout << "--- Copy vs Move ---\n";
Buffer original("orig", 1024);
// Copy: allocates new memory and copies bytes
std::cout << "\nCopying:\n";
Buffer copied = original;
// Move: just steals the pointer (no allocation!)
std::cout << "\nMoving:\n";
Buffer moved = std::move(original);
// 'original' is now in a valid but empty state
// Return value optimization
std::cout << "\n--- Return Value (NRVO) ---\n";
auto buf = create_buffer();
// Transfer ownership to a function
std::cout << "\n--- Transfer Ownership ---\n";
take_ownership(std::move(buf));
// STL containers
stl_move_demo();
// Perfect forwarding
std::cout << "\n--- Perfect Forwarding ---\n";
Buffer fwd_buf("forward-me", 512);
wrapper(std::move(fwd_buf)); // Forwarded as rvalue -> move
return 0;
}