Skip to main content

📝 Lesson 18: Move Semantics and Rvalue References

Optimize your C++ applications by moving resources instead of copying them.

ðŸŽŊ Learning Objectives

By the end of this lesson, you will be able to:

  • Distinguish between lvalues and rvalues to understand object lifetime and move eligibility.
  • Implement move constructors and move assignment operators for efficient resource transfer.
  • Use std::move and std::forward correctly to optimize code and enable perfect forwarding.
  • Apply move semantics to standard containers and custom classes for maximum performance.

Estimated Time: 90–120 minutes

Project: Implement a move-enabled, high-performance buffer class that demonstrates significant performance gains over traditional copy-based implementations.

In This Lesson

The Problem: Expensive Copies

Imagine you're moving houses. The old way (copy semantics) is like buying duplicates of all your furniture for the new house, then throwing away the originals. Move semantics is like taking your actual furniture to the new house - much more efficient!

The Old Way: Copy Everything

class String {
private:
    char* data;
    size_t length;

public:
    // Copy constructor - expensive for large strings!
    String(const String& other)
        : length(other.length) {
        data = new char[length + 1];  // Allocate new memory
        std::strcpy(data, other.data); // Copy all characters
    }

    // Copy assignment - also expensive!
    String& operator=(const String& other) {
        if (this != &other) {
            delete[] data;  // Clean up old data
            length = other.length;
            data = new char[length + 1];  // Allocate new memory
            std::strcpy(data, other.data); // Copy all characters
        }
        return *this;
    }
};

Understanding Lvalues and Rvalues

Before diving into move semantics, we need to understand lvalues and rvalues. Think of lvalues as things with addresses (like houses) and rvalues as temporary things (like the pizza delivery guy).

graph TD A[Expression] --> B[Lvalue] A --> C[Rvalue] B --> D[Has persistent address] B --> E[Can appear on left of =] B --> F[Examples: variables, array elements] C --> G[Temporary value] C --> H[Cannot take address] C --> I[Examples: literals, temporaries]
int x = 42;        // x is lvalue, 42 is rvalue
int y = x;         // y is lvalue, x is lvalue (lvalue in rvalue context)

int& lr = x;       // OK: lvalue reference to lvalue
int& lr2 = 42;     // ERROR: cannot bind lvalue reference to rvalue

const int& cr = 42; // OK: const lvalue reference can bind to rvalue
int&& rr = 42;     // OK: rvalue reference to rvalue
int&& rr2 = x;     // ERROR: cannot bind rvalue reference to lvalue

// Function returning by value creates an rvalue
std::string getName() { return "temporary"; }
std::string s = getName();  // getName() returns an rvalue

// But you can't do:
// getName() = "something";  // ERROR: can't assign to rvalue

Rvalue References: The && Syntax

Rvalue references (&&) are like temporary ownership papers. They let you take ownership of resources that are about to be destroyed anyway.

Move Constructor and Move Assignment

Now we can implement efficient move operations that transfer ownership instead of copying data.

class String {
private:
    char* data;
    size_t length;

public:
    // Move constructor - takes ownership of temporary
    String(String&& other) noexcept
        : data(other.data), length(other.length) {
        // "Steal" the resources
        other.data = nullptr;  // Leave other in valid but empty state
        other.length = 0;
    }

    // Move assignment operator
    String& operator=(String&& other) noexcept {
        if (this != &other) {
            delete[] data;  // Clean up current resources

            // "Steal" the resources
            data = other.data;
            length = other.length;

            // Leave other in valid but empty state
            other.data = nullptr;
            other.length = 0;
        }
        return *this;
    }

    // Regular constructors and destructor
    String(const char* str) {
        length = std::strlen(str);
        data = new char[length + 1];
        std::strcpy(data, str);
    }

    ~String() {
        delete[] data;
    }
};

The Rule of Three, Five, and Zero

graph TD A[Class Managing Resources] --> B["Rule of Three<br/>C++98"] A --> C["Rule of Five<br/>C++11"] A --> Z["Rule of Zero<br/>prefer this"] B --> D[Destructor] B --> E[Copy Constructor] B --> F[Copy Assignment] C --> D C --> E C --> F C --> G[Move Constructor] C --> H[Move Assignment] Z --> Y["No manual<br/>special members"]

Modern C++ favors the Rule of Zero: let members like std::vector, std::string, and std::unique_ptr own their resources, and your class needs none of the five special members — the compiler-generated ones (including the moves) just work. Reach for the Rule of Five only when your class manages a raw resource directly.

std::move: Casting to Rvalue

std::move doesn't actually move anything! It's just a cast that tells the compiler "treat this lvalue as an rvalue". It's like putting a "free to take" sign on your furniture.

std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2 = v1;  // Copy: v1 still has data

std::vector<int> v3 = {1, 2, 3, 4, 5};
std::vector<int> v4 = std::move(v3);  // Move: v3 is now empty

// After move, v3 is in valid but unspecified state
// You can still use v3, but don't assume it has any particular value
v3.clear();  // OK
v3.push_back(10);  // OK

// Common pattern: moving in function returns
std::vector<int> createVector() {
    std::vector<int> local = {1, 2, 3, 4, 5};
    return local;  // No std::move needed! Compiler does RVO/NRVO
}

// When to use std::move
class Widget {
    std::vector<int> data;
public:
    void setData(std::vector<int> newData) {
        data = std::move(newData);  // Move parameter into member
    }
};

Perfect Forwarding with Universal References

Universal references (also called forwarding references) can bind to both lvalues and rvalues, preserving their value category. It's like a postal service that handles both regular mail and express packages appropriately.

// Universal reference: T&& where T is deduced
template<typename T>
void process(T&& param) {  // Universal reference
    // param can be lvalue or rvalue reference
}

// Perfect forwarding: preserve value category
template<typename T>
void wrapper(T&& arg) {
    // std::forward preserves whether arg was lvalue or rvalue
    actualFunction(std::forward<T>(arg));
}

// Practical example: make_unique before C++14
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

// Usage
auto p1 = make_unique<String>("Hello");  // Forward rvalue
String s = "World";
auto p2 = make_unique<String>(s);  // Forward lvalue (copies)
auto p3 = make_unique<String>(std::move(s));  // Forward rvalue (moves)
graph LR A[wrapper called with] --> B[Lvalue] A --> C[Rvalue] B --> D[forward preserves lvalue] C --> E[forward preserves rvalue] D --> F[actualFunction gets lvalue] E --> G[actualFunction gets rvalue]

Move Semantics in Standard Containers

All standard containers support move semantics, making operations like returning containers from functions extremely efficient.

// Efficient container operations
std::vector<std::string> readFile(const std::string& filename) {
    std::vector<std::string> lines;
    // ... read file into lines ...
    return lines;  // Move, not copy! (NRVO or move)
}

// Container of containers
std::vector<std::vector<int>> matrix;
std::vector<int> row = {1, 2, 3, 4, 5};

// Old way (copy)
matrix.push_back(row);  // row is still valid

// New way (move)
matrix.push_back(std::move(row));  // row is now empty

// Moving elements within containers
std::vector<std::string> v1 = {"apple", "banana", "cherry"};
std::vector<std::string> v2;

// Move all elements
v2 = std::move(v1);  // v1 is now empty

// Move individual elements
std::vector<std::unique_ptr<Widget>> widgets;
widgets.push_back(std::make_unique<Widget>());  // Move-only type

// Algorithms with move
std::vector<std::string> source = {"a", "b", "c"};
std::vector<std::string> dest;
std::move(source.begin(), source.end(), std::back_inserter(dest));

Common Patterns and Best Practices

The Copy-and-Swap Idiom

class String {
    // ... other members ...

    // Unified assignment operator handles both copy and move
    String& operator=(String other) {  // Pass by value
        swap(other);
        return *this;
    }  // other's destructor cleans up our old data

    void swap(String& other) noexcept {
        std::swap(data, other.data);
        std::swap(length, other.length);
    }
};

Move-Only Types

class FileHandle {
    // A move-aware member (unique_ptr with a custom deleter) owns the FILE*
    std::unique_ptr<std::FILE, int(*)(std::FILE*)> file;

public:
    explicit FileHandle(const char* path)
        : file(std::fopen(path, "r"), &std::fclose) {}

    // Delete copy operations
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // Defaulted move operations are correct here: moving the unique_ptr member
    // transfers ownership and leaves the source holding nullptr
    FileHandle(FileHandle&&) noexcept = default;
    FileHandle& operator=(FileHandle&&) noexcept = default;

    // Now FileHandle can only be moved, not copied —
    // perfect for unique resources like file handles
};

When to Use std::move

Practical Examples

🏋ïļ Exercise: Implement a Move-Enabled Buffer Class

template<typename T>
class Buffer {
private:
    T* data;
    size_t size;

public:
    // Constructor
    explicit Buffer(size_t n)
        : data(new T[n]), size(n) {}

    // Destructor
    ~Buffer() { delete[] data; }

    // TODO: Implement
    // 1. Copy constructor
    // 2. Copy assignment
    // 3. Move constructor
    // 4. Move assignment

    // Hints:
    // - Copy should allocate new memory and copy elements
    // - Move should transfer ownership and set source to nullptr
    // - Remember to handle self-assignment
    // - Mark move operations as noexcept
};

🏋ïļ Exercise: Perfect Forwarding Factory

// Create a factory function that perfectly forwards arguments
template<typename T, typename... Args>
T* factory(Args&&... args) {
    std::cout << "Creating object with "
              << sizeof...(args) << " arguments\n";

    // TODO: Use perfect forwarding to construct T
    // Hint: Use std::forward<Args>(args)...
}

// Test with:
struct Widget {
    Widget(int x) { std::cout << "Widget(int)\n"; }
    Widget(const std::string& s) { std::cout << "Widget(string&)\n"; }
    Widget(std::string&& s) { std::cout << "Widget(string&&)\n"; }
};

🏋ïļ Exercise: Move-Only Message Queue

// Implement a thread-safe queue for move-only types
template<typename T>
class MoveOnlyQueue {
private:
    std::queue<T> queue;
    mutable std::mutex mutex;

public:
    // Only allow moving in
    void push(T&& value) {
        // TODO: Lock mutex and move value into queue
    }

    // Only allow moving out
    std::optional<T> pop() {
        // TODO: Lock mutex, check if empty
        // If not empty, move front element out
        // Return std::nullopt if empty
    }
};

Performance Impact

Move semantics can dramatically improve performance, especially for containers and resource-heavy objects.

// Benchmark example
#include <chrono>
#include <vector>

void benchmarkCopyVsMove() {
    const size_t SIZE = 1000000;
    std::vector<int> source(SIZE, 42);

    // Benchmark copy
    auto start = std::chrono::high_resolution_clock::now();
    std::vector<int> copied = source;  // Copy
    auto copy_time = std::chrono::high_resolution_clock::now() - start;

    // Benchmark move
    start = std::chrono::high_resolution_clock::now();
    std::vector<int> moved = std::move(source);  // Move
    auto move_time = std::chrono::high_resolution_clock::now() - start;

    std::cout << "Copy time: "
              << std::chrono::duration_cast<std::chrono::microseconds>(copy_time).count()
              << " Ξs\n";
    std::cout << "Move time: "
              << std::chrono::duration_cast<std::chrono::microseconds>(move_time).count()
              << " Ξs\n";

    // Typical result:
    // Copy time: 2000 Ξs
    // Move time: 1 Ξs
}

Common Pitfalls and Solutions

Pitfall: Moving from const objects

const std::vector<int> v = {1, 2, 3};
std::vector<int> v2 = std::move(v);  // Doesn't move! Copies instead!
// std::move on const object returns const T&&, which matches copy constructor

Pitfall: Use after move

std::string s = "Hello";
std::string s2 = std::move(s);
std::cout << s;  // Undefined behavior? No! s is valid but unspecified (usually empty)
// Standard library types guarantee a valid but unspecified state

Pitfall: Returning std::move

// BAD - prevents RVO
std::vector<int> bad() {
    std::vector<int> v = {1, 2, 3};
    return std::move(v);  // Don't do this!
}

// GOOD - allows RVO/NRVO
std::vector<int> good() {
    std::vector<int> v = {1, 2, 3};
    return v;  // Compiler optimizes
}

ðŸŽŊ Quick Quiz

Question 1: Given int x = 42;, which declaration correctly compiles?

Question 2: What does std::move(obj) actually do at runtime?

Question 3: Why is return std::move(localVar); considered bad practice when returning a local by value?

Summary

🎉 Key Takeaways

  • Lvalues have persistent addresses; rvalues are temporaries that are about to be destroyed
  • Rvalue references (&&) let you bind to — and take ownership of — temporaries
  • Move constructors/assignment transfer ownership of resources instead of copying them, leaving the source in a valid but empty state
  • std::move doesn't move anything itself — it's just a cast that makes an lvalue eligible for move overloads
  • Universal references (T&& in a template) plus std::forward enable perfect forwarding of arguments
  • Never return std::move(localVar); — it defeats RVO/NRVO and can make your code slower
  • Move semantics can turn O(n) copies into O(1) pointer swaps for containers and resource-owning classes

📚 Additional Resources

🚀 What's Next?

Move semantics transformed C++ from a language that copied everything to one that can efficiently transfer resources — like handing over a document instead of photocopying it. Next, in Lesson 19: Multithreading and Concurrency, you'll run code across multiple threads at once, where cheaply moving data between threads becomes essential to writing efficient concurrent code.

🎉 Move semantics mastered!

You've crossed one of the trickiest thresholds in modern C++ — lvalues, rvalues, and the && syntax trip up even experienced developers. From here on, you'll read and write high-performance C++ with real understanding of what's happening under the hood.