DEV Community

Bar Dror
Bar Dror

Posted on Originally published at codebeneath.com

C++ Move Semantics and Rvalue References: Avoiding Unnecessary Copies

Originally published on Code Beneath.

C++ copy operations are expensive. When you pass an object to a function or return it from a scope, the compiler traditionally creates a full copy: allocate memory, copy every byte, free the original. For large containers or complex objects, this wastes CPU and memory. C++ move semantics and rvalue reference example std move patterns let you transfer ownership instead of duplicating. Understanding when the compiler moves versus copies, and how to force moves with std::move, is the difference between code that scales and code that stalls. This article shows exactly how move constructors, move assignment operators, and perfect forwarding work, with runnable code you can compile and measure.

What Are Rvalue References and Why They Matter

An lvalue is an object with a persistent memory address: a variable, an array element, a dereferenced pointer. An rvalue is a temporary: the result of an expression that will not be accessed again after the statement ends. In C++, & binds to lvalues. The new && operator (rvalue reference) binds to rvalues and temporaries.

The key insight: if an object is about to be destroyed anyway (it is an rvalue), why copy its internals? Instead, steal its resources. An rvalue reference lets you detect temporaries at compile time and write a move constructor that says "I will take ownership of this temporary's heap buffers, file handles, or other resources."

Here is a concrete example. A Vector class that owns a heap array:

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

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

    // Default constructor
    Vector() : data(nullptr), size(0) {
        std::cout << "Default constructor\n";
    }

    // Constructor from size
    Vector(size_t n) : data(new int[n]), size(n) {
        std::cout << "Constructor(size_t)\n";
    }

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

    // Move constructor: steal the data pointer
    Vector(Vector&& other) noexcept : data(other.data), size(other.size) {
        std::cout << "Move constructor\n";
        other.data = nullptr;
        other.size = 0;
    }

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

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

    ~Vector() {
        std::cout << "Destructor (size: " << size << ")\n";
        delete[] data;
    }
};

Vector create_vector() {
    Vector v(1000);
    return v;  // Rvalue: eligible for move
}

int main() {
    std::cout << "--- Creating temp and assigning ---\n";
    Vector v1 = create_vector();

    std::cout << "\n--- Copy assignment ---\n";
    Vector v2(500);
    v2 = v1;  // Lvalue: copy assignment

    std::cout << "\n--- Move assignment ---\n";
    Vector v3(500);
    v3 = create_vector();  // Rvalue: move assignment

    std::cout << "\n--- Done ---\n";
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compile and run with:

g++ -std=c++17 -fno-elide-constructors -o vector_move vector_move.cpp
./vector_move
Enter fullscreen mode Exit fullscreen mode

The flag -fno-elide-constructors disables copy elision (discussed below) so you see every move and copy. Output shows that create_vector() triggers the move constructor, not a copy. The temporary is detected, and the heap array is transferred without allocation or memcpy.

std::move: Casting Lvalues to Rvalues

Sometimes you own an object (it is an lvalue) but know you will not use it again. You want to move from it, not copy. The standard library function std::move casts an lvalue to an rvalue reference, signaling to the compiler "treat this as a temporary."

#include <iostream>
#include <utility>
#include <vector>

class Buffer {
public:
    int* ptr;
    size_t len;

    Buffer(size_t n) : ptr(new int[n]), len(n) {
        std::cout << "Buffer constructor\n";
    }

    Buffer(const Buffer& other) : ptr(new int[other.len]), len(other.len) {
        std::cout << "Buffer copy constructor\n";
        std::copy(other.ptr, other.ptr + other.len, ptr);
    }

    Buffer(Buffer&& other) noexcept : ptr(other.ptr), len(other.len) {
        std::cout << "Buffer move constructor\n";
        other.ptr = nullptr;
        other.len = 0;
    }

    ~Buffer() {
        delete[] ptr;
    }
};

int main() {
    std::cout << "--- Without std::move ---\n";
    Buffer b1(100);
    Buffer b2 = b1;  // Calls copy constructor (b1 is an lvalue)

    std::cout << "\n--- With std::move ---\n";
    Buffer b3(200);
    Buffer b4 = std::move(b3);  // Calls move constructor (std::move makes b3 an rvalue)

    std::cout << "\n--- Push to vector ---\n";
    std::vector<Buffer> buffers;
    Buffer b5(300);
    buffers.push_back(std::move(b5));  // Move into vector, not copy

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compile and run:

g++ -std=c++17 -fno-elide-constructors -o move_demo move_demo.cpp
./move_demo
Enter fullscreen mode Exit fullscreen mode

Without std::move, b2 copies b1. With std::move, b4 steals b3's buffer. When pushing into a vector, std::move transfers ownership rather than duplicating a potentially large allocation.

Perfect Forwarding and Parameter Passing

A function that receives an object should preserve whether that object was an lvalue or rvalue. If the caller passed a temporary, the function should pass it as a temporary to downstream code. This is perfect forwarding.

The pattern uses a template parameter with && and std::forward:

#include <iostream>
#include <utility>

class Logger {
public:
    Logger(const std::string& msg) {
        std::cout << "Logger copy constructor: " << msg << "\n";
    }

    Logger(std::string&& msg) {
        std::cout << "Logger move constructor: " << msg << "\n";
    }
};

// Without perfect forwarding: binds to const Logger& so always calls copy constructor
void process_without_forwarding(const Logger& log) {
    // log is const, so Logger copy constructor is called
}

// With perfect forwarding: preserves lvalue/rvalue-ness
template <typename T>
void process_with_forwarding(T&& log) {
    // If T is Logger, T&& becomes Logger&& (rvalue)
    // If T is const Logger, T&& becomes const Logger&& (const rvalue)
    Logger l = std::forward<T>(log);  // Forwards the type exactly
}

int main() {
    std::cout << "--- Calling with lvalue (variable) ---\n";
    std::string s = "hello";
    process_with_forwarding(s);

    std::cout << "\n--- Calling with rvalue (temporary) ---\n";
    process_with_forwarding(std::string("world"));

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compile and run:

g++ -std=c++17 -fno-elide-constructors -o forwarding forwarding.cpp
./forwarding
Enter fullscreen mode Exit fullscreen mode

When passed an lvalue (s), the template parameter T deduces as std::string&, so T&& collapses to std::string&. When passed an rvalue (std::string("world")), T deduces as std::string, so T&& is std::string&&. std::forward preserves this distinction and passes it downstream. The Logger move constructor is called for the rvalue.

Copy Elision and Return Value Optimization

Modern C++ compilers optimize away many copies automatically, even before move semantics. Return Value Optimization (RVO) and Named Return Value Optimization (NRVO) eliminate the temporary object and construct the result directly in the caller's memory.

Compile with optimizations enabled (-O2 or -O3) and copy/move operations may be elided entirely. Disable elision with -fno-elide-constructors to see what the code would do without the optimization.

#include <iostream>

class Object {
public:
    Object() { std::cout << "Default constructor\n"; }
    Object(const Object&) { std::cout << "Copy constructor\n"; }
    Object(Object&&) noexcept { std::cout << "Move constructor\n"; }
    ~Object() { std::cout << "Destructor\n"; }
};

// Named Return Value Optimization (NRVO): compiler may eliminate the copy
Object create_object() {
    Object obj;
    return obj;  // Even without std::move, compiler may optimize
}

// Guaranteed to use move (no NRVO)
Object create_object_alt() {
    Object obj;
    return std::move(obj);  // Explicit move overrides NRVO
}

int main() {
    std::cout << "--- With NRVO (may elide entirely) ---\n";
    Object a = create_object();

    std::cout << "\n--- With explicit std::move ---\n";
    Object b = create_object_alt();

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Run with -O2:

g++ -std=c++17 -O2 -o elision elision.cpp
./elision
Enter fullscreen mode Exit fullscreen mode

With -O2, neither constructor nor destructor is called. The object is constructed in place. Remove -O2 and add -fno-elide-constructors, and you see the move constructor fire.

Comparing Copy vs Move Performance

Operation Time Complexity Heap Allocation Data Duplication
Copy constructor O(n) for n bytes Yes, allocate new Yes, memcpy or loop
Move constructor O(1), pointer swap No, steal existing No, just pointers
Copy assignment O(n) Maybe, reuse if fit Yes
Move assignment O(1) No, swap pointers No

For a Vector storing 1 million integers (4 MB), a copy constructor allocates 4 MB, then memcpys all data. A move constructor swaps two pointers (16 bytes on 64-bit systems). The speedup is not merely faster: it is a different order of magnitude, and more importantly, it reduces memory fragmentation and garbage collection pressure.

Common Mistakes

Mistake 1: Using std::move on every variable. You can std::move an lvalue, but the receiving code must have a move constructor or move assignment operator. If it does not, std::move is a no-op cast and you get a copy anyway. Also, after you call std::move on a variable, that variable is in a "moved-from" state: its resources are gone. Do not use it.

Mistake 2: Forgetting noexcept on move operations. Move constructors and move assignment operators should be marked noexcept. If they can throw, containers like std::vector will fall back to copying during reallocation, defeating the purpose.

// WRONG: move can throw, so vector uses copy on resize
Vector(Vector&& other) {
    data = new int[other.size];  // Can throw bad_alloc
}

// RIGHT: move never throws
Vector(Vector&& other) noexcept : data(other.data), size(other.size) {
    other.data = nullptr;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Assuming a temporary is always moved. With copy elision enabled (default in optimized builds), the compiler may skip the move entirely and construct the object in place. This is correct behavior, but do not rely on move constructors running for profiling or side effects.

Mistake 4: Not providing move semantics in a template utility. Template code should use std::forward to preserve lvalue/rvalue-ness. Failing to do so forces copies or makes generic code less efficient.

Move Semantics in the Standard Library

std::unique_ptr, std::string, std::vector, and other standard containers have move constructors and move assignment operators. Code that uses the standard library correctly automatically benefits from moves. When you push_back a temporary into a vector, it moves. When you return a local std::string, it moves or is elided. The standard library enforces that move operations are fast and marked noexcept.

For a deeper look at how lower-level memory and pointer operations work, see the article on native language programming fundamentals.

FAQ

Do I need to implement both copy and move constructors?

No, but you should. If you define a move constructor but no copy constructor, the copy constructor is deleted (C++11 and later). If you want to forbid copying, that is fine: delete the copy constructor explicitly. If you want both copy and move, implement both. If you do not implement either, the compiler generates default implementations (shallow copy for move, which is dangerous for pointers).

What is the difference between std::move and std::forward?

std::move unconditionally casts to rvalue reference. It says "I am done with this object." std::forward is a conditional cast: it casts to rvalue reference only if the original argument was an rvalue. It says "preserve what the caller passed." Use std::move when you know you will not use the variable again. Use std::forward in template code to preserve the caller's intent.

Why mark move operations noexcept?

Containers like std::vector grow by reallocating. If a move operation can throw, the container cannot safely move elements during reallocation (it might leave the container in an inconsistent state). So it falls back to copying. Marking move operations noexcept tells containers that moves are safe, so they can use them during growth.

Does copy elision mean I should never std::move?

No. Copy elision is a compiler optimization that happens in specific cases (returning a local variable from a function). For other cases (passing an object to a function, storing in a container), you need explicit std::move or an rvalue argument. Also, some code may be compiled without optimizations, where elision does not happen.

Can I move from a const object?

No. You cannot bind a const object to an rvalue reference (Vector&&). You can std::move it, but that just casts the const away, and the move constructor receives a const rvalue reference, which must be treated as non-modifying. The move is pointless. Do not do this.

How do I test if my code is using moves?

Compile with -fno-elide-constructors to disable copy elision, so you see every operation. Add cout statements in constructors and assignment operators. Run the code and observe the log. In production, remove -fno-elide-constructors so the compiler can elide copies.

Does move semantics work with exceptions?

Move operations should be noexcept if possible. If a move throws (rare, but possible if custom code in the move constructor throws), the exception propagates. Containers rely on move operations being noexcept, so an exception during move is a serious bug. Design move operations to never throw.

Top comments (0)