Systems Programming · Resource Management
Move Semantics and the State Left Behind in the Source
What transferring ownership actually changes in memory, why a moved-from object must still be destructible, and how unique ownership differs from a shallow copy.
Copying a value that owns a resource is the origin of most double-free defects, and every language that takes ownership seriously has to answer the same question: what happens to the source when a value is transferred rather than duplicated. The answers differ sharply. C++ leaves a valid-but-unspecified object behind and expects its destructor to do nothing harmful. Rust leaves a statically unassignable slot that the compiler refuses to read. C leaves nothing at all, because there is no transfer, only a copy of a pointer and an agreement in a comment. The memory layout is nearly identical in all three cases, which is exactly why the differences are worth understanding at the level of bits rather than at the level of syntax.
What a resource handle contains
A handle is a small value that stands in for something the program does not own by value: an address of a heap block, an integer index into a descriptor table, an offset into an arena. On a 64-bit target the common cases are 8 bytes for a pointer, 4 bytes for a descriptor, and 16 bytes for a pointer plus a length or a pointer plus a vtable pointer.
The handle has no special representation. It is the same size and shape as any other integer of that width, so the compiler cannot tell by looking at it whether it is the sole reference to a resource. That is the entire difficulty. Two handles with the same bit pattern are indistinguishable to the machine, and if both are treated as owning, both will eventually release the same resource.
Ownership, then, is a property of the program’s reasoning about the handle, not of the handle’s bits. A type system that can express “this handle is the only one” converts that reasoning into a checked invariant. A language without such a type leaves it as a discipline, and the discipline is what fails when a function returns early or a structure is copied by a generic container.
Transfer as a rewrite of the source
The operation that makes single ownership work is a move: the destination receives the handle, and the source is rewritten so that it no longer refers to the resource. In C++ the move constructor for a smart pointer does exactly two things, in this order: it copies the pointer out of the source, then it sets the source’s pointer to null. The order matters, because an exception thrown between the two would otherwise leave two owners.
template <typename T>
class UniquePtr {
T *p_ = nullptr;
public:
UniquePtr() = default;
explicit UniquePtr(T *p) : p_(p) {}
UniquePtr(const UniquePtr &) = delete; // no copies
UniquePtr &operator=(const UniquePtr &) = delete;
UniquePtr(UniquePtr &&other) noexcept : p_(other.p_) {
other.p_ = nullptr; // source relinquishes
}
UniquePtr &operator=(UniquePtr &&other) noexcept {
if (this != &other) {
delete p_; // release current first
p_ = other.p_;
other.p_ = nullptr;
}
return *this;
}
~UniquePtr() { delete p_; } // delete nullptr is a no-op
T *get() const { return p_; }
T *release() { T *t = p_; p_ = nullptr; return t; }
};
Three details in that listing carry most of the semantics. The move constructor is noexcept, because a container that grows must be able to move its elements without risking a failed reallocation that leaves the container in an inconsistent state; the standard library’s vector will copy elements instead of moving them if the move constructor is not declared noexcept, and for a type that cannot be copied that is a hard error. The destructor deletes p_ unconditionally, which is safe only because delete nullptr is defined to do nothing. And move assignment releases the destination’s existing resource before taking the new one, otherwise the old resource leaks.
The null-out step is what makes the destructor safe. A moved-from object still has a destructor that will run, and it must run without releasing anything. The alternative design, marking the source as invalid and skipping its destructor, does not exist in C++: destruction is automatic and unconditional for every object that was constructed.
Rust’s stronger version of the same rule
Rust makes the same memory operation but forbids reading the source afterwards. A move is a byte copy followed by a compile-time invalidation of the source binding. No null is written into the source, because there is no need to: the compiler rejects any later use, so the runtime state of the source bytes is irrelevant.
let s = String::from("payload");
let t = s; // s is moved; s is unusable from here
// println!("{}", s); // error[E0382]: borrow of moved value
println!("{}", t);
The difference from C++ is visible in the error rather than in the generated code. The bytes of s still contain the pointer, capacity, and length of the allocation, and the allocation is now owned by t. Nothing wrote a null anywhere. What prevents a double free is that s is no longer a valid place to take a value from, so the drop glue for s is not emitted.
That design has a consequence for conditional moves. Because a moved-out binding is statically invalid, code that moves out of a value inside a loop, or from behind a reference, needs an explicit replacement. std::mem::take replaces the destination with Default::default() and returns the old value, and Option::take does the same with None. Both are the Rust spelling of the C++ null-out, required only where the compiler cannot prove the source is dead.
Rust also applies the rule to partially moved values. Moving one field out of a struct leaves the struct partially initialised, and the compiler tracks which fields remain. The struct cannot be used as a whole afterwards, and its destructor runs only for the fields that were not moved. This is a level of precision no runtime scheme can match, because it is resolved before the program runs.
The C version: transfer by convention
C has no move. What it has is a function that takes a pointer to a pointer and nulls it:
/* Takes ownership of *pp and clears the caller's handle. */
struct conn *conn_take(struct conn **pp)
{
struct conn *c = *pp;
*pp = NULL;
return c;
}
This is the same two-step rewrite, performed by convention. It works when every caller obeys the convention and fails when one does not, and the failure is silent: a caller that keeps a copy of the handle after the take has two owners, and the second conn_close operates on freed memory. The convention also cannot be enforced across a generic container, so a C list that stores handles by value will shallow-copy them and produce the same defect.
The mitigation available in C is structural rather than linguistic. Keeping ownership in one place per resource, passing borrowed handles as const pointers, and never storing an owning handle in two structures at once are the rules, and they are only as good as the review that enforces them. A run-time checker that detects double free, such as AddressSanitizer, converts a silent corruption into a report, but it does so after the fact.
Aliasing that survives the move
A move transfers the handle but not necessarily everything reachable through it. Interior pointers, iterators into a container, and references to elements all continue to point at the same memory after the container has moved, because moving a vector transfers its heap buffer rather than reallocating it. The moved-to vector now owns the buffer that the iterator points into, and the iterator remains valid.
That property is not guaranteed by every container. A small-string optimisation that stores characters inline in the object means a move copies the inline buffer into the destination and leaves the source’s copy stale, so any pointer into the source’s inline storage becomes dangling the moment the move completes. The same hazard exists for any structure that stores part of its payload inside itself, including fixed-size buffer wrappers and intrusive lists whose head node is embedded.
Self-referential structures are the degenerate case. A node that stores a pointer to its own field cannot be moved by copying bytes at all, because the copy’s internal pointer still refers to the original object. The standard library’s answer for such types is to require that they not be moved, and the practical answer is to allocate them and transfer a pointer to the allocation instead of the value. Both are ways of saying that the thing being moved is the handle, not the object.
Where the moved-from state leaks into the API
The rule that a moved-from object must be destructible is a floor, not a ceiling, and the difference shows up in interfaces. A moved-from std::string is required to be valid, and the common implementations leave it empty, but the language does not promise emptiness, only validity. Code that relies on the emptiness is relying on the implementation.
The stricter discipline is to give the moved-from state a defined meaning in the type’s own documentation, and to make the destructor and the assignment operator the only operations that are unconditionally safe. An object in that state is a placeholder: it can be assigned to, it can be destroyed, and it can be queried for whether it holds anything. Everything else requires a check.
This is also the point at which ownership transfer interacts with the error path. A move that happens inside a function that later fails has already changed the caller’s state, and the caller’s cleanup will run on a moved-from value. The compensation rules from cleanup on the error path assume the moved-from state is inert, and the ordering rules from scope exit assume the destructor still runs. Both assumptions hold only if the transfer rewrote the source. When single ownership cannot be arranged, the fallback is a shared count, whose cost is the subject of reference counting.
References
- ISO/IEC 14882:2020 — Programming languages — C++ — ISO/IEC
- std::mem::take — The Rust Standard Library — The Rust Project
- Ownership — The Rust Programming Language — The Rust Project
- std::unique_ptr — cppreference — cppreference.com