C++ for C Developers | Lesson 12: Move Semantics & Rvalue References
Goal: Understand C++11 move semantics, rvalue references (&&
), and std::move
. Learn how they enable efficient transfer of resources (like dynamic memory) between objects, avoiding expensive copies, particularly when dealing with temporary objects. Introduce move constructors and move assignment operators.
1. The Problem: Expensive Copying
Consider a class that manages a resource, like our DynamicArray
example or even std::vector
or std::string
internally. They often hold pointers to dynamically allocated memory.
// Simplified dynamic array
class Buffer {
private:
char* m_data = nullptr;
size_t m_size = 0;
public:
Buffer(size_t size) : m_size(size) {
m_data = new char[m_size];
std::cout << "Buffer allocating " << m_size << " bytes\n";
}
~Buffer() {
std::cout << "Buffer deleting " << m_size << " bytes\n";
delete[] m_data;
}
// Copy Constructor (Potentially Expensive!)
Buffer(const Buffer& other) : m_size(other.m_size) {
std::cout << "Buffer COPYING " << m_size << " bytes\n";
m_data = new char[m_size]; // 1. Allocate new memory
std::copy(other.m_data, other.m_data + m_size, m_data); // 2. Copy data element by element
}
// Copy Assignment Operator (Potentially Expensive!)
Buffer& operator=(const Buffer& other) {
std::cout << "Buffer COPY-ASSIGNING " << m_size << " bytes\n";
if (this == &other) return *this; // Self-assignment check
delete[] m_data; // 1. Delete old data
m_size = other.m_size;
m_data = new char[m_size]; // 2. Allocate new memory
std::copy(other.m_data, other.m_data + m_size, m_data); // 3. Copy data
return *this;
}
// ... other methods ...
};
Buffer createBuffer(size_t size) {
return Buffer(size); // Returns a temporary Buffer object
}
int main() {
Buffer b1(1000);
Buffer b2 = b1; // Calls Copy Constructor (makes sense, we want two copies)
Buffer b3(500);
b3 = b1; // Calls Copy Assignment Operator (makes sense)
std::cout << "\n--- Creating from temporary ---\n";
Buffer b4 = createBuffer(2000); // Creates temporary, then potentially COPIES it into b4
std::cout << "---------------------------\n";
// In pre-C++11, the copy from the temporary returned by createBuffer could be very costly.
return 0;
}
Copying can be expensive: it involves allocating new memory and copying all the data. This is especially wasteful if the source object is a temporary (like the one returned by createBuffer
) that’s about to be destroyed anyway. Why copy its data when we could just steal its already-allocated buffer?
flowchart TD A1["Resource-Managing Class (e.g., Buffer)"] A2["Copy Constructor"] A3["Copy Assignment"] A4["Allocates new memory"] A5["Copies data from source"] A6["Inefficient with temporaries"] A1 --> A2 A1 --> A3 A2 --> A4 A2 --> A5 A3 --> A4 A3 --> A5 A5 --> A6
2. Lvalues and Rvalues (Simplified)
To understand move semantics, we need a basic idea of value categories:
flowchart LR subgraph Value Categories L[Lvalue<br>• Named<br>• Addressable<br>• Persistent] R[Rvalue<br>• Temporary<br>• No address<br>• Short-lived] end L -->|"Example"| LEx["b1, myVector"] R -->|"Example"| REx["42, x + y, createBuffer()"]
- Lvalue: Refers to an object that persists beyond a single expression. Typically has a name (like
b1
,myVector
). You can take its address (&b1
). Think “locator value” – it has a location. - Rvalue: Refers to a temporary value, often the result of an expression, that doesn’t persist. Examples:
42
,x + y
,createBuffer(100)
. Think “read value” – often just used for its value. You usually can’t take the address of an rvalue.
3. Rvalue References (T&&
)
flowchart LR T1["Function Overload Detection"] T2["Rvalue Reference (T&&)"] T3["Only binds to temporaries"] T4["Enables Move Constructor/Assignment"] T2 --> T3 T3 --> T4 T1 --> T2
C++11 introduced rvalue references, denoted by T&&
. These can only bind to rvalues (temporaries). Their main purpose is to allow function overloads (especially for constructors and assignment operators) that can detect when they are being initialized or assigned from a temporary object.
4. Move Constructor and Move Assignment
If a class manages resources, we can define special “move” versions of the constructor and assignment operator that take an rvalue reference (&&
). Their job is not to copy, but to transfer ownership of the resources from the temporary source object to the newly created/assigned object.
flowchart LR classDef highlight fill:#fffae6,stroke:#d4a017,stroke-width:2px; M0["Resource-Managing Class"] M1["Move Constructor"] M2["Steals pointers/resources"] M3["Empties source object"] M4["Marked noexcept"] M5["Move Assignment Operator"] M6["Deletes own resources"] M7["Takes ownership from source"] M8["Empties source object"] M9["Handles self-assignment"] M10["Marked noexcept"] M0 --> M1:::highlight M1 --> M2 M1 --> M3 M1 --> M4 M0 --> M5:::highlight M5 --> M6 M5 --> M7 M5 --> M8 M5 --> M9 M5 --> M10
- Move Constructor (
ClassName(ClassName&& other) noexcept
):- “Pilfers” the resources (pointers, handles) from
other
. - Leaves
other
in a valid, destructible state (usually empty or default-initialized). - Should be marked
noexcept
if possible (crucial for performance in STL containers).
- “Pilfers” the resources (pointers, handles) from
- Move Assignment Operator (
ClassName& operator=(ClassName&& other) noexcept
):- Releases its own current resources.
- Pilfers the resources from
other
. - Leaves
other
in a valid, destructible state. - Should handle self-assignment (though less likely with moves).
- Should be marked
noexcept
if possible.
Extending our Buffer
class:
#include <iostream>
#include <algorithm> // For std::copy
#include <utility> // For std::move and std::swap
class Buffer {
private:
char* m_data = nullptr;
size_t m_size = 0;
public:
// Constructor
Buffer(size_t size) : m_size(size) {
if (size > 0) m_data = new char[m_size];
std::cout << "Buffer allocating " << m_size << " bytes at " << (void*)m_data << "\n";
}
// Destructor
~Buffer() {
std::cout << "Buffer deleting " << m_size << " bytes at " << (void*)m_data << "\n";
delete[] m_data;
}
// --- Copy Semantics ---
// Copy Constructor
Buffer(const Buffer& other) : m_size(other.m_size) {
std::cout << "Buffer COPYING " << m_size << " bytes from " << (void*)other.m_data << "\n";
if (m_size > 0) {
m_data = new char[m_size];
std::copy(other.m_data, other.m_data + m_size, m_data);
} else {
m_data = nullptr;
}
}
// Copy Assignment
Buffer& operator=(const Buffer& other) {
std::cout << "Buffer COPY-ASSIGNING " << m_size << " bytes from " << (void*)other.m_data << "\n";
if (this == &other) return *this;
delete[] m_data; // Release old resource
m_size = other.m_size;
if (m_size > 0) {
m_data = new char[m_size];
std::copy(other.m_data, other.m_data + m_size, m_data);
} else {
m_data = nullptr;
}
return *this;
}
// --- Move Semantics (C++11) ---
// Move Constructor
Buffer(Buffer&& other) noexcept // Takes Rvalue Reference &&, marked noexcept
: m_data(other.m_data), m_size(other.m_size) // 1. Steal the pointers/data
{
std::cout << "Buffer MOVING (constructor) " << m_size << " bytes from " << (void*)other.m_data << "\n";
// 2. Leave the source object in a valid, destructible (empty) state
other.m_data = nullptr;
other.m_size = 0;
}
// Move Assignment Operator
Buffer& operator=(Buffer&& other) noexcept // Takes Rvalue Reference &&, marked noexcept
{
std::cout << "Buffer MOVING (assignment) " << m_size << " bytes from " << (void*)other.m_data << "\n";
if (this == &other) return *this; // Optional: Self-move check
delete[] m_data; // 1. Release OWN resource
// 2. Steal resources from other
m_data = other.m_data;
m_size = other.m_size;
// 3. Leave other in a valid state
other.m_data = nullptr;
other.m_size = 0;
return *this;
}
size_t size() const { return m_size; }
};
Buffer createBuffer(size_t size) {
std::cout << "--- createBuffer called ---\n";
Buffer local_buf(size);
std::cout << "--- returning from createBuffer ---\n";
return local_buf; // Returns Buffer (implicitly treated as Rvalue)
}
int main() {
std::cout << "*** Scenario 1: Initialize from temporary ***\n";
Buffer b1 = createBuffer(1000); // Calls Constructor, then MOVE Constructor! (Efficient)
std::cout << "b1 size: " << b1.size() << "\n";
std::cout << "*** End Scenario 1 ***\n\n";
std::cout << "*** Scenario 2: Assign from temporary ***\n";
Buffer b2(100); // Regular constructor
b2 = createBuffer(2000); // Calls Constructor, then MOVE Assignment! (Efficient)
std::cout << "b2 size: " << b2.size() << "\n";
std::cout << "*** End Scenario 2 ***\n\n";
std::cout << "*** Scenario 3: Explicit Move ***\n";
Buffer b3(500);
std::cout << "Before move, b3 size: " << b3.size() << "\n";
// Buffer b4 = b3; // This would COPY
Buffer b4 = std::move(b3); // Calls MOVE constructor explicitly
std::cout << "After move, b3 size: " << b3.size() << "\n"; // b3 is now empty!
std::cout << "After move, b4 size: " << b4.size() << "\n";
std::cout << "*** End Scenario 3 ***\n\n";
return 0; // Destructors called for b1, b2, b3 (empty), b4
}
5. std::move
Wait, didn’t we just use std::move(b3)
on b3
, which is an lvalue? Yes!
std::move
(from <utility>
) doesn’t actually move anything. It’s just a cast. It unconditionally casts its argument (lvalue or rvalue) into an rvalue reference (&&
).
- Purpose: It signals intent: “I know this object (
b3
) is technically an lvalue, but I’m done with its current state. Treat it as if it were a temporary (an rvalue) so that an overload taking&&
(like a move constructor or move assignment) can be called if available.” - Use With Caution: After using
std::move
on an object, you must assume its state has been potentially altered (pilfered). Don’t rely on its previous value. The only safe assumptions are that you can assign a new value to it or let it be destroyed.
6. The Rule of Five (or Zero)
flowchart LR RF["Rule of Five"] R0["Rule of Zero"] D1["Destructor"] D2["Copy Constructor"] D3["Copy Assignment"] D4["Move Constructor"] D5["Move Assignment"] RZ1["Use RAII (e.g., std::vector, std::unique_ptr)"] RZ2["Let compiler auto-generate everything"] RF --> D1 RF --> D2 RF --> D3 RF --> D4 RF --> D5 R0 --> RZ1 R0 --> RZ2
- Rule of Five: If you need to explicitly define any of these five special member functions for your class (because it manages resources directly), you probably need to consider all five:
- Destructor
- Copy Constructor
- Copy Assignment Operator
- Move Constructor
- Move Assignment Operator You might explicitly define some, and
= default
(use compiler version) or= delete
(disallow) others.
- Rule of Zero: The best approach is often to design classes that don’t manage raw resources directly. Instead, use RAII members (like
std::string
,std::vector
,std::unique_ptr
) to manage resources. In this case, the compiler-generated defaults for all five special functions usually do the right thing automatically, and you define none of them (hence, “Rule of Zero”).
Summary of Lesson 12:
- Copying objects that manage resources can be expensive.
- Move semantics provide a way to efficiently transfer ownership of resources (like memory) instead of copying, especially from temporary objects (rvalues).
- Rvalue references (
T&&
) bind to temporaries and enable overloading for move operations. - Move Constructor and Move Assignment Operator steal resources from the source object (passed as
T&&
) and leave the source in a valid, empty state. Mark themnoexcept
. std::move
is a cast to an rvalue reference (&&
), signaling that the object’s resources can be safely pilfered.- Move semantics make returning large objects by value and STL operations like
vector::push_back
much more efficient. - Follow the Rule of Five if your class manages raw resources, or aim for the Rule of Zero by using RAII members.
Next Lesson: We’ll look at the auto
keyword for type deduction and revisit range-based for loops, exploring conveniences that make modern C++ code cleaner.
More Sources:
cppreference – Rvalue References: https://en.cppreference.com/w/cpp/language/reference#Rvalue_references
Learn C++ – Move Semantics: https://www.learncpp.com/cpp-tutorial/move-constructors-and-move-assignment/