C++ Move Constructor: rvalue Stealing & noexcept Best

Key takeaways

C++ move constructors: rvalue resource transfer, copy vs move, vector and noexcept, self-move, and when not to std::move return locals.

What is a move constructor?

A move constructor is a constructor that transfers ownership of resources from a temporary or explicitly-moved object (an rvalue) instead of duplicating them. Before C++11, every time you passed an object by value, returned one from a function, or inserted it into a container, the only tool the language gave you was the copy constructor — which meant a fresh heap allocation and a full byte-for-byte (or element-for-element) duplication, even when the source object was about to be destroyed anyway and its buffer thrown away. Move semantics closed that gap: if the compiler can prove the source is an rvalue — a value with no name that nobody can observe afterward — it can pick an overload that just steals the pointer instead of copying what it points to.

class Buffer {
    int* data;
    size_t size;
    
public:
    // Move constructor
    Buffer(Buffer&& other) noexcept 
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
};

The signature Buffer(Buffer&& other) takes an rvalue reference, which is what tells the overload resolution machinery “this constructor only matches temporaries or objects explicitly cast with std::move.” Inside the body, the constructor copies the pointer value (a handful of bytes) rather than the buffer it points to, then immediately nulls out the source’s pointer so that when other’s destructor eventually runs, it does not delete[] memory that *this now owns. This “steal, then neutralize” pattern is the entire idea behind every move constructor you will ever write — the details differ (file handles, sockets, mutexes, reference counts) but the shape is identical.

Copy vs move

// Copy: duplicate resources
Buffer(const Buffer& other) {
    data = new int[other.size];
    std::copy(other.data, other.data + size, data);
}
// Move: transfer ownership
Buffer(Buffer&& other) noexcept {
    data = other.data;
    other.data = nullptr;
}

The performance difference between these two is not a micro-optimization — it is the difference between an O(n) allocation-plus-copy and an O(1) pointer swap. For a Buffer holding a few megabytes of data, the copy constructor pays for a heap allocation (which itself may involve a system call, lock contention in a multi-threaded allocator, or page faults) and then walks every element. The move constructor pays for none of that. This is exactly why algorithms like std::sort and containers like std::vector became dramatically cheaper to use with movable value types after C++11 — sorting used to mean copying elements around during every swap; after move semantics, a “swap” is three pointer moves.

The trade-off is that move constructors introduce a new failure mode that copy constructors don’t have: a source object that still exists but is in an unspecified, “moved-from” state. A copy constructor never touches its source; a move constructor always mutates it. That single fact is the root cause of most of the pitfalls further down in this article — self-assignment, use-after-move, and exception safety are all really just different angles on “what does it mean for an object to have had its guts pulled out but still be sitting there in scope.”

Move constructors in classes, vectors, and return values

A basic move constructor

class String {
    char* data;
    size_t length;
    
public:
    String(const char* str) {
        length = strlen(str);
        data = new char[length + 1];
        strcpy(data, str);
    }
    
    ~String() {
        delete[] data;
    }
    
    // Copy constructor
    String(const String& other) : length(other.length) {
        data = new char[length + 1];
        strcpy(data, other.data);
        std::cout << "copy" << std::endl;
    }
    
    // Move constructor
    String(String&& other) noexcept 
        : data(other.data), length(other.length) {
        other.data = nullptr;
        other.length = 0;
        std::cout << "move" << std::endl;
    }
};
int main() {
    String s1("Hello");
    String s2 = s1;              // copy
    String s3 = std::move(s1);   // move
}

s2 = s1 binds to the copy constructor because s1 is an lvalue — it has a name and could be used again later, so the compiler cannot assume it is safe to steal from. s3 = std::move(s1) compiles to the same expression, except std::move performs an unconditional static_cast<String&&>, which changes the value category of the expression without changing anything about the object itself. std::move does not move — it is purely a cast that tells the overload resolution “trust me, treat this as disposable.” After that line, s1.data is nullptr; if you call any method on s1 that dereferences data, you have undefined behavior. This is worth internalizing because it is the single most common mistake teams make when they first adopt move semantics: they assume std::move does something to the object at the call site, when really it just changes which overload gets picked, and the constructor’s implementation is what actually does the stealing.

How vector chooses copy or move

#include <vector>
class Widget {
public:
    Widget() { std::cout << "ctor" << std::endl; }
    Widget(const Widget&) { std::cout << "copy" << std::endl; }
    Widget(Widget&&) noexcept { std::cout << "move" << std::endl; }
};
int main() {
    std::vector<Widget> vec;
    vec.reserve(10);
    
    Widget w;
    vec.push_back(w);              // copy
    vec.push_back(std::move(w));   // move
    vec.emplace_back();            // construct in place
}

vec.reserve(10) matters here because it pre-allocates storage, so neither push_back call in this snippet triggers a reallocation — both operate on already-reserved slots. push_back(w) copies because w is passed as an lvalue; push_back(std::move(w)) moves because the argument’s value category is now an rvalue reference; emplace_back() sidesteps both by constructing the Widget directly in the vector’s storage with no temporary at all, which is the cheapest of the three when you don’t already have an object lying around. The distinction between these three insertion styles is one of the more common code-review nits in performance-sensitive C++ codebases, and it’s a nit worth catching, because push_back(w) where push_back(std::move(w)) or emplace_back(...) would work is a silent, invisible-in-the-diff cost that never shows up until someone profiles the hot path.

unique_ptr as a move-only type

#include <memory>
class Resource {
public:
    Resource() { std::cout << "Resource ctor" << std::endl; }
    ~Resource() { std::cout << "Resource dtor" << std::endl; }
};
int main() {
    auto ptr1 = std::make_unique<Resource>();
    
    // Move only (no copy)
    auto ptr2 = std::move(ptr1);
    // ptr1 is nullptr
}

std::unique_ptr is the canonical example of a move-only type: its copy constructor is = deleted because copying a unique_ptr would violate the single-owner invariant the whole class exists to enforce. This is a deliberate design choice you should imitate whenever a type represents exclusive ownership of something that must not have two owners — a file descriptor, a lock, a GPU resource handle. Trying to write auto ptr3 = ptr1; after the move above is a compile error, not a runtime bug, which is exactly the point: the type system catches the double-ownership mistake at compile time instead of letting it surface as a double-free in production three months later.

Return values, NRVO, and move

Buffer createBuffer(size_t size) {
    Buffer b(size);
    return b;  // move or RVO
}
int main() {
    Buffer b = createBuffer(100);
}

In practice, most modern compilers apply Named Return Value Optimization (NRVO) here and construct b directly in the caller’s storage, eliding both the copy and the move entirely — there is no intermediate object at all. When the compiler cannot prove elision is safe (for example, if the function has multiple return statements returning different named locals along different paths), it falls back to treating the return as a move instead of a copy, per the language rules introduced in C++11. Either way, the outcome is cheap; the case you actually need to worry about is explained in the std::move on returned locals pitfall below, because writing return std::move(b); yourself can actually make things worse.

Why noexcept matters

// Without noexcept: vector may fall back to copy on reallocation
Buffer(Buffer&& other) {
    // ...
}
// With noexcept: vector can move elements
Buffer(Buffer&& other) noexcept {
    // ...
}

This is the single most consequential detail in this entire article, and it is also the one most C++ developers get wrong the first time, because the compiler will not warn you about it. std::vector provides a strong exception guarantee for growth: if push_back triggers a reallocation and something throws partway through moving the old elements into the new buffer, the vector must be left exactly as it was before the call — not half-migrated. The only way to guarantee that is to either (a) know the “move” operation cannot throw, or (b) copy instead of move, since a copy leaves the original elements untouched if it fails partway through. std::vector checks this at compile time using std::is_nothrow_move_constructible. If your move constructor is not marked noexcept — even if it happens to never actually throw in practice — the vector cannot take the risk and silently falls back to copying every element on every reallocation. Your code still compiles, still runs, and still produces correct output; it is simply several times slower than you think it is, and there is no compiler diagnostic that tells you this is happening. The fix is one keyword, but you have to know to look for it.

The Rule of Five and implicit generation

Move constructors don’t exist in isolation — they’re one of five “special member functions” the compiler can generate for you: the destructor, copy constructor, copy assignment operator, move constructor, and move assignment operator. The rule of thumb (formalized as the “Rule of Five”) is: if you need to hand-write any one of these because your class manages a raw resource, you almost certainly need to define all five, because the compiler’s implicit-generation rules are stricter than most people expect.

Specifically: if you declare any of the destructor, copy constructor, or copy assignment operator yourself, the compiler will not implicitly generate a move constructor or move assignment operator for that class at all — not a broken one, none. Your type quietly falls back to copy semantics everywhere a move would have been preferred, and std::is_move_constructible reports false. This is deprecated-but-still-real behavior left over from pre-C++11 code that defined a destructor for RAII cleanup and never got the memory to also suppress the implicit copy — the standard keeps this behavior for backward compatibility, but it means classes written with “just a destructor” silently lose move optimization the moment C++11-and-later code interacts with them. If your class manages a resource, the fix is to either write out all five special members explicitly (Rule of Five) or delegate resource ownership entirely to a member like std::unique_ptr or std::vector and let the compiler generate all five for you for free (Rule of Zero) — the latter is almost always the better engineering choice unless you’re the one implementing the low-level resource wrapper itself.

flowchart TD
    A["Class needs custom<br/>destructor / copy ctor /<br/>copy assign?"] -->|No| B["Compiler generates<br/>all 5 members<br/>(Rule of Zero)"]
    A -->|Yes| C["Move ctor / move assign<br/>NOT implicitly generated"]
    C --> D{"Write all 5<br/>explicitly?"}
    D -->|Yes| E["Rule of Five:<br/>full control, more code"]
    D -->|No| F["Falls back to copy<br/>semantics everywhere —<br/>silent perf regression"]

Moved-from state invariants

The standard’s requirement is deliberately loose: after a move, the source object must be left in a “valid but unspecified state.” Valid means the destructor can still run safely and, for standard-library types, the object can still be assigned to or otherwise reset — it does not mean the object still holds any of its original value. This looseness is a common source of bugs in application code that assumes a moved-from std::string or std::vector is guaranteed empty; for the standard library types it usually is in practice on every major implementation, but that is an implementation detail, not a contract, so portable code should not depend on it. For your own types, the discipline that matters is: pick an invariant (usually “empty/null, equivalent to default-constructed”) and enforce it in every move operation, consistently, so that any code downstream that accidentally touches a moved-from object gets predictable behavior instead of reading stale pointers.

Moved-from state, self-move, and throwing moves

Forgetting to reset the source

// Bad: moved-from not cleared
Buffer(Buffer&& other) noexcept {
    data = other.data;
    // other.data still non-null (risky)
}
// Good: null out source
Buffer(Buffer&& other) noexcept {
    data = other.data;
    other.data = nullptr;
}

If you skip nulling out other.data, both *this and other now hold the same pointer. The first destructor to run frees the memory; the second one frees it again. This is a textbook double-free, and it is often not caught by testing, because it only manifests when the moved-from object’s destructor actually executes — which for a local variable moved out of a function happens at the end of scope, potentially many lines away from the move itself and easy to miss in review.

Self-move assignment

// Bad: no self-check
Buffer& operator=(Buffer&& other) noexcept {
    delete[] data;
    data = other.data;
    other.data = nullptr;
    return *this;
}
// Good: guard self-assignment
Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data;
        data = other.data;
        other.data = nullptr;
    }
    return *this;
}

Self-move-assignment (a = std::move(a);) looks contrived, but it happens in real code through indirection — a swap implementation, a generic algorithm operating on a range where two iterators alias the same element, or a reassignment inside a loop where the index calculation has an off-by-one bug. Without the this != &other guard, the “bad” version deletes data, and then reads other.data — which is the same pointer you just freed — and hands it right back to yourself as if it were valid. This is a use-after-free that will not show up in a debug build with small allocations and will absolutely show up in production under ASan or a memory-constrained allocator that reuses freed pages aggressively.

A move constructor that can throw

// May throw
Buffer(Buffer&& other) {
    // ...
}
// noexcept when possible
Buffer(Buffer&& other) noexcept {
    // ...
}

Beyond the std::vector reallocation case covered above, a throwing move constructor breaks the strong exception guarantee that many generic algorithms rely on. If a move constructor throws partway through (say, because it does something more than a pointer swap — acquiring a lock, opening a handle), the object being constructed is left in an indeterminate state, and any container or algorithm that assumed “move never fails” now has to unwind through code paths that were never designed to be exercised. The practical rule: a move constructor should do the minimum work necessary to transfer ownership — pointer/handle copies, index resets — and nothing that can fail. If your “move” logically needs to do something that can throw, that is usually a sign the operation is not really a move; it should be modeled as a separate, explicitly fallible operation instead.

std::move on returned locals

// Bad: can block NRVO
Buffer func() {
    Buffer b(100);
    return std::move(b);
}
// Good: just return
Buffer func() {
    Buffer b(100);
    return b;
}

This one surprises people because it looks like it should help — “I’m explicitly telling it to move, that must be at least as fast as letting the compiler decide.” In practice, wrapping a named return value in std::move can prevent the compiler from applying NRVO, because the cast changes the expression from “a candidate for copy elision” into “an rvalue reference that must bind to the move constructor.” Depending on the compiler and optimization level, you can end up with a real move constructor call where the unmodified return b; would have compiled down to zero constructor calls at all. The rule of thumb: never call std::move on a named local you are returning from its own function; let the compiler apply NRVO or the implicit move-on-return that C++11 already gives you for free.

A debugging story: the noexcept that wasn’t there

I once spent the better part of an afternoon chasing down why a hot path that inserted thousands of small Task objects into a std::vector<Task> during a batch-processing job was noticeably slower than an equivalent version using std::deque. The Task class managed a small owned buffer and had a hand-written move constructor — but no destructor had been written, because a previous refactor had moved cleanup into a member std::unique_ptr. What nobody noticed was that the class also declared a copy constructor explicitly (for a legacy serialization path), and per the implicit-generation rules described above, that declaration didn’t suppress the move constructor since it was already user-declared — but the move constructor itself had never been annotated noexcept. Profiling with a simple std::cout counter dropped into both the copy and move constructors confirmed it: every single push_back during vector growth was going through the copy constructor, not the move constructor, purely because std::is_nothrow_move_constructible<Task> was false. Adding noexcept to the existing move constructor — no other code changed — cut the batch job’s allocation count roughly in half and closed most of the gap with the deque version. The lesson that stuck with me: noexcept is not a promise you make for documentation’s sake, it’s a compile-time flag the standard library actively reads, and a missing one degrades performance in a way that produces zero warnings and zero incorrect output — which is exactly why it survives code review undetected.

The other failure mode I’ve run into more than once is subtler: a moved-from object that gets reused later because some code path assumed “it’s just been moved, so it must still be default-ish” without that ever being part of the type’s actual contract. In one case, a Connection object was moved into a worker thread, and the original variable — still in scope on the calling thread due to an early-return path that skipped the code that would normally have let it go out of scope quickly — got passed to a retry helper that called .reconnect() on it. Because the move constructor had nulled the underlying socket handle but the class’s reconnect() method checked a separate boolean flag that hadn’t been reset during the move, the retry logic decided the connection was “already connected” and skipped reconnecting, silently dropping every retried request into a closed socket. Nothing crashed; requests just vanished. The fix was to make the moved-from state consistent across all of the object’s fields, not just the one that looked most obviously “ownership-related” — a good reminder that moved-from invariants need to be enforced holistically, not just for the pointer you happen to be thinking about at the time.

Which types actually benefit from moving

// Move-capable
std::vector<int>
std::string
std::unique_ptr<int>
// No move (trivially copyable scalars)
int
double

Trivially copyable scalar types like int and double don’t have a meaningful distinction between “copy” and “move” — a move constructor for int would just copy the four bytes anyway, so the compiler treats copy and move identically for them and there is no performance angle to chase. The types where move semantics pay off are the ones that own something indirect: heap buffers, file descriptors, container internals. If you’re deciding whether a type you’re writing needs a hand-written move constructor at all, the test is simple — does the class hold a raw pointer, handle, or other resource that a bitwise copy would duplicate incorrectly? If yes, write one (or delegate to Rule of Zero); if the class is just a bundle of value types, the compiler-generated move constructor already does the right thing and you don’t need to write anything.

FAQ

Q1: When is the move ctor used?

A: When constructing from an rvalue (including moved lvalues).

Q2: Is noexcept mandatory?

A:

  • Not required by the language
  • Important for std::vector and strong guarantees

Q3: Valid state after move?

A: Object must be valid but unspecified; nulling pointers is good practice.

Q4: What is std::move?

A: Cast to rvalue reference; does not move by itself.

Q5: Performance?

A: Pointer swaps instead of deep copies—big win for large objects.

Q6: Resources?

A:

  • Effective Modern C++
  • C++ Move Semantics (general references)
  • cppreference.com