Programming Languages · Memory and Ownership

Move Semantics and the Affine Type Discipline

Why a move is a byte copy plus a compile-time promise, how C++ and Rust enforce that promise differently, and what a moved-from value is still allowed to do.

Copying a value duplicates an obligation. If the value owns a heap buffer, both copies now believe they must free it, and the program will free it twice. The obvious fix is to copy the buffer as well, which turns an O(1) handle duplication into an O(n) allocation and memcpy. Move semantics is the third option: transfer the obligation, leave the source in a state that owns nothing, and make the transfer visible to the type system so that using the source afterwards is rejected.

The idea has two independent implementations. C++ encodes it in overload resolution at runtime-visible but compile-time-selected points. Rust encodes it as a property of the type: values may be used at most once unless the type explicitly permits copying.

A move is a memcpy with a contract attached

At the machine level, moving a struct of three pointers is three loads and three stores, exactly as copying it would be. Nothing about the bytes changes. What changes is the agreement about who is responsible for the pointees.

That agreement is why the C++ move constructor takes an rvalue reference:

#include <cstddef>

struct Buffer {
    int* data;
    std::size_t len;

    Buffer(Buffer&& other) noexcept
        : data(other.data), len(other.len) {
        other.data = nullptr;   // source must not free what it no longer owns
        other.len = 0;
    }
    ~Buffer() { delete[] data; }
};

Without the two assignments in the body, the destructor of other would free the buffer that the new object now owns. Every correct move constructor has this shape: steal the resources, then leave the source in a state whose destructor is harmless. The C++ standard does not say what that state must be beyond “valid but unspecified”, which in practice means the source must remain destructible and assignable but its value cannot be read.

std::move performs no move. It is a cast to an rvalue reference, static_cast<T&&>(x), whose only job is to make overload resolution pick the move constructor. Passing a const object to std::move yields const T&&, which does not bind to a T&& parameter and silently falls back to the copy constructor. That silent fallback is the most common way a move is defeated, alongside types that declare a destructor or copy constructor and therefore get no implicit move constructor at all.

Guaranteed elision removes the move entirely

C++17 changed the rules for prvalues. Buffer make() { return Buffer{...}; } and the call Buffer b = make(); construct the object directly in b’s storage. No move constructor is called, and the type does not even need to be movable. This is not an optimization the compiler may perform; it is a requirement of the language, and it exists because earlier rules permitted the copy to be elided but also required the constructor to be accessible and callable, which made returning non-movable types impossible.

Elision applies to prvalues and to the operand of a return when it names a local of the same type. It does not apply through a named variable passed to a function, which is why return std::move(local); is a pessimization: it turns an elidable construction into a real move.

Rust makes the invalidation a type error

Rust reaches the same contract from the other side. A move is defined by the compiler, not by user code, and consists of a bitwise copy followed by a static invalidation of the source binding:

let s = String::from("hello");
let t = s;              // s is moved into t
println!("{}", s);      // error[E0382]: borrow of moved value: `s`

String owns a heap buffer and does not implement Copy, so assignment moves. After the assignment the compiler treats s as uninitialized and rejects any later use, including a borrow. Because the check happens in the borrow checker rather than at runtime, there is no flag to consult and no possibility of observing a moved-from value.

Types that implement Copy are exempt: integers, bool, char, shared references, and any struct or enum all of whose fields are Copy. Copy and Drop are mutually exclusive, which follows from the semantics. A type that owns something must run code at the end of its life, and that code must run exactly once, so it cannot be duplicated by a plain bit copy.

The underlying theory names the two disciplines. A type system in which every value must be used exactly once is linear; one in which a value may be used at most once is affine. Rust’s non-Copy types are affine: dropping a String without using it is fine, but using it twice is not. Linear typing is stricter and appears in languages designed for session types and linear logic rather than systems programming, because requiring a value to be consumed makes it hard to discard a result.

What partial moves cost

Moves interact with aggregates. A struct can be moved field by field, leaving the remainder usable:

struct Pair { name: String, id: u64 }

let p = Pair { name: String::from("a"), id: 7 };
let n = p.name;          // moves one field
println!("{}", p.id);    // fine: u64 is Copy and was not moved
let q = p;               // error: p is partially moved

Reinitializing the moved field makes the whole value usable again. Rust tracks this per-field, so a partially moved struct is not simply dead: the unmoved fields remain readable and can themselves be moved out later.

Conditional moves need a record of what happened. If a value is moved inside one arm of an if, the destructor must run on the path where the move did not happen and must not run on the path where it did. The analysis that answers this is control-flow-sensitive: each point in the graph carries, for each local, whether it is initialized, moved, or partially moved, and the two branches reach the join point with different states, so the destructor is guarded by the condition that distinguishes them. Where the same variable is reassigned and moved repeatedly, so that the state space is not a small fixed set, the compiler falls back to a drop flag: a byte in the frame that records whether the value is currently initialized, tested before the destructor call. Either way the cost is paid once per conditional move, not per move.

C++ has the same problem with no static answer. A moved-from object is always destructed, so every move constructor must produce a state where destruction is correct. There is no equivalent of the drop flag because the language never knows whether a move happened.

The valid-but-unspecified state

C++ and Rust disagree about what a moved-from value is, and the disagreement is not cosmetic. Rust says the value does not exist: any use is a compile error, and the destructor is skipped. C++ says the value exists but its value is unspecified, so it may be destroyed and assigned to, and reading it is legal but yields no defined result.

The C++ position follows from the language’s inability to track initialization through control flow. Every automatic object is destroyed at scope exit, unconditionally, so a moved-from object must remain destructible. The standard library’s containers exploit this: none of them leaves a moved-from object with a pointer to storage it still owns, because the move constructor must transfer that ownership, so a moved-from std::vector has no elements in practice even though the standard only promises a valid state. A moved-from std::string is likewise valid and unspecified, which is what lets an implementation leave a small-string buffer in place rather than reinitialising it. Code that assumes a moved-from container is empty works with every implementation of the standard library but is not guaranteed by the standard.

The Rust position follows from the same analysis. Because the compiler knows whether a value was moved on each path, it can skip the destructor where the move happened and run it where it did not. The cost is that conditional moves need the flag; the benefit is that a moved-from value cannot be observed, so no library has to document what state it is in.

Move is not free

For a type that owns a heap allocation, a move is a pointer copy and a nulling store, a handful of instructions. For a type that stores its data inline, a move is a full memcpy. Rust’s [u8; 1_000_000] moves a megabyte on every assignment; the compiler will not silently turn that into a pointer. The workaround is to box the value so the move copies a pointer instead, and the same trade appears in C++ as the advice to store large payloads behind std::unique_ptr.

The cost profile is therefore the opposite of what the naming suggests. A move is cheap precisely when the type is a handle and expensive when the type is a value. Languages that define all aggregates as references, such as Java and C#, avoid the question by never moving: assignment copies a reference and the garbage collector handles the lifetime. That is a coherent design too, with a different cost: allocation and collection rather than copy and invalidation.

The borrow checker’s move analysis is what makes destructors sound. Because the compiler knows a value was moved, it knows the destructor must not run, and because it knows the value was not moved, it knows the destructor must run exactly once. Manual code has to maintain that accounting by hand, which is the subject of malloc, free, and ownership conventions. The affine discipline is also what makes interior mutability interesting: a type that permits mutation through a shared reference must still keep track of whether a value has been moved out of it.

References

  1. ISO/IEC 14882:2017 — Programming languages — C++ — ISO/IEC
  2. The Rust Programming Language, Chapter 4: Understanding Ownership — The Rust Project
  3. Substructural Type Systems — Walker, in Advanced Topics in Types and Programming Languages