Chapter 21 of 23

Move Semantics and Rvalue References

Understand lvalues vs rvalues, rvalue references, move constructors, std::move, perfect forwarding, and the Rule of Five to write high-performance C++ that avoids unnecessary deep copies.

Meritshot10 min read
C++Move SemanticsRvalue Referencesstd::movePerfect ForwardingRule of Five
All C++ Chapters

Move Semantics and Rvalue References

When a senior engineer at a Bangalore-based startup reviews your C++ code, one of the first things they check is whether you are needlessly copying large objects. In systems that handle millions of network packets, image buffers, or JSON documents per second, unnecessary copying kills performance. C++11 introduced move semantics — a way to transfer resources from one object to another instead of copying them — and it is one of the most impactful improvements the language has ever received.

This chapter explains the underlying concepts (lvalues, rvalues, and references to each), the mechanics of move constructors and move assignment, and how to apply them through a Buffer class that avoids expensive deep copies.


Lvalues and Rvalues — A Mental Model

Every expression in C++ has a value category. The two fundamental ones are:

CategoryDescriptionExample
lvalueHas a persistent, addressable location in memory. Can appear on the left of =.int x = 5; x is an lvalue
rvalueTemporary; no stable address. Cannot appear on the left of =.5, x + 1, std::string("hello")

Think of an lvalue as something with a name and a home address, and an rvalue as a temporary value that exists only long enough to be used.

int a = 10;       // a is an lvalue; 10 is an rvalue
int b = a + 3;    // a + 3 is an rvalue (temporary result)
int* p = &a;      // OK: a is an lvalue, has an address
// int* q = &(a + 3); // Error: rvalue has no address

Lvalue References and Rvalue References

A regular (lvalue) reference binds to lvalues:

int x = 42;
int& ref = x;     // OK
// int& ref2 = 42; // Error: cannot bind lvalue ref to rvalue

C++11 introduced the rvalue reference (&&), which binds only to rvalues (temporaries):

int&& rref = 42;       // OK: rvalue reference to a temporary
int&& rref2 = x + 1;   // OK: x + 1 is a temporary
// int&& rref3 = x;    // Error: x is an lvalue

The purpose of rvalue references is to let you detect temporaries and steal their resources rather than copying them.


The Problem with Copying

Consider a class that manages a heap-allocated buffer:

#include <iostream>
#include <cstring>

class Buffer {
public:
    int* data;
    size_t size;

    Buffer(size_t n) : size(n), data(new int[n]) {
        std::cout << "Constructed buffer of size " << n << "\n";
    }

    // Copy constructor — expensive deep copy
    Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
        std::memcpy(data, other.data, size * sizeof(int));
        std::cout << "Copied buffer of size " << size << "\n";
    }

    ~Buffer() {
        delete[] data;
        std::cout << "Destroyed buffer\n";
    }
};

Buffer makeBuffer() {
    return Buffer(1024); // temporary rvalue
}

int main() {
    Buffer b = makeBuffer(); // triggers copy in C++03
}

In C++03, makeBuffer() returns a temporary Buffer, which is then copied into b. That means new int[1024] runs twice and memcpy copies 4 KB of data — all for a temporary that is immediately discarded. Move semantics solve this.


The Move Constructor

A move constructor takes an rvalue reference to its own type and steals (transfers) the resources, leaving the source in a valid but unspecified state (usually a null/empty state):

// Move constructor
Buffer(Buffer&& other) noexcept
    : size(other.size), data(other.data)  // steal pointers/values
{
    other.data = nullptr;  // leave source in a safe state
    other.size = 0;
    std::cout << "Moved buffer of size " << size << "\n";
}

No heap allocation, no memcpy — just pointer assignment. The destructor of the moved-from object will then call delete[] nullptr, which is a no-op.


The Move Assignment Operator

Similarly, move assignment transfers resources from a temporary to an existing object:

Buffer& operator=(Buffer&& other) noexcept {
    if (this == &other) return *this;  // self-assignment guard

    delete[] data;          // release our current resource

    data = other.data;      // steal
    size = other.size;
    other.data = nullptr;
    other.size = 0;
    std::cout << "Move-assigned buffer of size " << size << "\n";
    return *this;
}

The Rule of Five

If your class manages a resource (raw pointer, file handle, socket, etc.), you almost certainly need all five special member functions:

Special MemberPurpose
DestructorReleases the resource
Copy constructorDeep copy for lvalue sources
Copy assignment operatorDeep copy for lvalue assignment
Move constructorTransfer for rvalue sources
Move assignment operatorTransfer for rvalue assignment

If you define any one of these, the compiler may not generate the others correctly. Define all five explicitly — this is the Rule of Five.

Here is the complete, production-quality Buffer class:

#include <iostream>
#include <cstring>
#include <utility>

class Buffer {
public:
    int*   data;
    size_t size;

    // 1. Constructor
    explicit Buffer(size_t n = 0)
        : size(n), data(n ? new int[n]() : nullptr) {
        std::cout << "[Construct] size=" << size << "\n";
    }

    // 2. Destructor
    ~Buffer() {
        delete[] data;
        std::cout << "[Destroy] size=" << size << "\n";
    }

    // 3. Copy constructor
    Buffer(const Buffer& other)
        : size(other.size), data(other.size ? new int[other.size] : nullptr) {
        if (data) std::memcpy(data, other.data, size * sizeof(int));
        std::cout << "[Copy] size=" << size << "\n";
    }

    // 4. Copy assignment
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;
        delete[] data;
        size = other.size;
        data = size ? new int[size] : nullptr;
        if (data) std::memcpy(data, other.data, size * sizeof(int));
        std::cout << "[CopyAssign] size=" << size << "\n";
        return *this;
    }

    // 5. Move constructor
    Buffer(Buffer&& other) noexcept
        : size(other.size), data(other.data) {
        other.data = nullptr;
        other.size = 0;
        std::cout << "[Move] size=" << size << "\n";
    }

    // 6. Move assignment
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
        std::cout << "[MoveAssign] size=" << size << "\n";
        return *this;
    }
};

std::move — Casting to an Rvalue

std::move does not move anything — it is a cast that turns an lvalue into an rvalue reference, signalling "I am done with this object; you may steal its resources."

#include <iostream>
#include <vector>

int main() {
    Buffer a(512);

    // Without std::move: would call copy constructor
    Buffer b = std::move(a); // calls MOVE constructor

    std::cout << "a.size after move: " << a.size << "\n"; // 0
    std::cout << "b.size: " << b.size << "\n";            // 512
}

After std::move(a), the object a is in a moved-from state. You can still assign to it or destroy it, but you must not use its former contents.


std::move with STL Containers

STL containers use move semantics extensively. Inserting a temporary string into a vector is free (no copy):

#include <iostream>
#include <vector>
#include <string>

int main() {
    std::vector<std::string> names;

    std::string temp = "Aditya Kumar";
    names.push_back(std::move(temp)); // steals temp's buffer

    std::cout << "temp is now: '" << temp << "'\n"; // empty
    std::cout << "names[0]: " << names[0] << "\n";   // Aditya Kumar
}

Perfect Forwarding with std::forward

When writing a template function that forwards arguments to another function, you want to preserve whether the argument was an lvalue or an rvalue. This is called perfect forwarding, and it uses std::forward:

#include <iostream>
#include <utility>

void process(Buffer& b)  { std::cout << "lvalue process\n"; }
void process(Buffer&& b) { std::cout << "rvalue process\n"; }

template<typename T>
void relay(T&& arg) {
    // Without forward: always calls lvalue overload
    // With forward: preserves value category
    process(std::forward<T>(arg));
}

int main() {
    Buffer b(10);
    relay(b);             // lvalue process
    relay(std::move(b));  // rvalue process
    relay(Buffer(20));    // rvalue process
}

T&& in a template context is a forwarding reference (also called a universal reference), not an rvalue reference. std::forward<T> restores the original value category.


Copy Elision — RVO and NRVO

In many cases the compiler can completely eliminate the copy or move, constructing the object directly in its final location. This is called copy elision:

  • RVO (Return Value Optimization): The compiler builds the returned temporary directly in the caller's return slot.
  • NRVO (Named Return Value Optimization): Same, but for named local variables returned by value.
Buffer makeBuffer(size_t n) {
    Buffer b(n);    // NRVO: b is constructed directly in caller's slot
    return b;       // No copy, no move in practice
}

int main() {
    Buffer result = makeBuffer(256); // Only one construction
}

Since C++17, RVO is guaranteed for prvalues. Do not std::move a return value — it prevents NRVO:

// WRONG: prevents NRVO, forces a move instead of elision
return std::move(b);

// CORRECT: let the compiler elide
return b;

Common Pitfalls

1. Using a moved-from object After std::move(x), the object x is in a valid but indeterminate state. Do not read its value; only destroy it or reassign it.

2. Forgetting noexcept on move operations STL containers (e.g., std::vector) will only use your move constructor during reallocation if it is marked noexcept. Without noexcept, the vector falls back to copying for strong exception safety. Always mark move constructor and move assignment as noexcept when they cannot throw.

3. std::move on a return statement prevents elision Writing return std::move(localVar) is a pessimisation. The compiler applies NRVO automatically; std::move interferes with it.

4. Copying instead of moving large containers Passing a std::vector by value to a function that does not need a copy should use std::move at the call site, or the function should take an rvalue reference or a value with the expectation that the caller moves in.

5. Shallow move leaving double-free bugs If your move constructor transfers a pointer but forgets to null the source pointer, both objects will delete[] the same memory. Always null the source after stealing.


Practice Exercises

  1. Add a fill(int val) method to the Buffer class that sets every element to val. Write a program that creates a Buffer(8), fills it with 42, moves it into a second Buffer, and prints the first element of the second buffer.

  2. Implement a Matrix class with rows * cols heap-allocated double storage. Provide all five special member functions. Verify with a makeMatrix() factory function that only one construction occurs (use print statements).

  3. Write a template function makeUnique(Args&&... args) that perfectly forwards its arguments to construct a T on the heap and return a raw pointer. (This is a simplified version of std::make_unique.)

  4. Explain why std::vector<Buffer> uses the move constructor (not copy) when it resizes, and what happens if Buffer's move constructor is not noexcept.

  5. Create a swap function for Buffer that exchanges two buffers without any heap allocation (hint: use std::move twice or three times).


Summary

  • An lvalue has a named, addressable location; an rvalue is a temporary with no stable address.
  • Rvalue references (&&) bind to temporaries and enable resource theft.
  • The move constructor and move assignment operator transfer heap resources instead of copying them, turning O(n) copies into O(1) pointer swaps.
  • The Rule of Five: if you define a destructor, copy constructor, or copy assignment, also define the move constructor and move assignment.
  • std::move casts an lvalue to an rvalue reference — it does not move anything by itself.
  • std::forward in templates preserves the original value category (perfect forwarding).
  • Copy elision / RVO / NRVO can eliminate copies and moves entirely; never write return std::move(local) as it prevents elision.
  • Always mark move operations noexcept so STL containers can use them during reallocation.