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.

C++
// 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).
  • 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:

C++
#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:
    1. Destructor
    2. Copy Constructor
    3. Copy Assignment Operator
    4. Move Constructor
    5. 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 them noexcept.
  • 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/

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top