Move Semantics and Performance
In Rust, moving a value (let b = a; or passing a by value into a function) is always a shallow bitwise copy (memcpy) of the value's stack representation, statically invalidating the source variable so its destructor (Drop) does not run twice. Moving a Vec<T>, String, or Box<T> never copies the underlying heap buffer—it copies only the 24-byte (or 8-byte) stack header!
Moving a 100 MB `Vec<u8>` (`let dst = src;`):
Stack (24 Bytes Copied): Heap (100 MB Untouched!):
`src` (Invalidated) +-----------------------------------+
`dst` [ ptr | len: 100M | cap: 100M ] ----->| 100,000,000 bytes on the heap |
+-----------------------------------+
Cost of move: ~1-2 CPU cycles (3 register moves), regardless of heap size!
Comparing Move, Clone, and Borrow Costs
| Operation | Stack Cost | Heap Cost | Source Variable State After |
|---|---|---|---|
Borrow (&T / &mut T) |
Copies 8-byte pointer (or 16-byte fat pointer) | 0 bytes | Remains valid; borrowed for 'a. |
Move (T) |
Copies size_of::<T>() bytes (24B for Vec/String) |
0 bytes (Heap pointers transferred as-is!) | Statically invalidated by compiler. |
Clone (t.clone()) |
Copies stack header | Allocates new heap buffer & deep-copies all elements | Remains valid and independent. |
Return Value Optimization (RVO / NRVO) on Large Structs
What happens when a function constructs and returns a larger struct by value (fn build_table() -> [u64; 128])? At the ABI level, the caller reserves space for the return value in its own stack frame and passes a hidden out-pointer (sret) to the callee. The callee constructs the [u64; 128] array directly inside the caller's stack slot, resulting in zero byte copies on return!
use std::mem::size_of;
pub struct PayloadBuffer {
pub tag: u64,
pub data: Vec<u8>,
}
/// Transfers ownership of `PayloadBuffer` by value.
/// Even though `buf.data` holds 1,000,000 bytes on the heap, moving `PayloadBuffer`
/// copies at most `size_of::<PayloadBuffer>()` (32 bytes) on the stack!
pub fn attach_checksum(mut buf: PayloadBuffer) -> PayloadBuffer {
let heap_addr_before = buf.data.as_ptr().addr();
buf.tag = buf.data.len() as u64;
let heap_addr_after = buf.data.as_ptr().addr();
assert_eq!(heap_addr_before, heap_addr_after);
buf
}
fn main() {
println!("Stack size of PayloadBuffer: {} bytes", size_of::<PayloadBuffer>());
let initial = PayloadBuffer {
tag: 0,
data: vec![0x42; 1_000_000],
};
let original_heap_addr = initial.data.as_ptr().addr();
// Move `initial` into `attach_checksum` and move the result into `finalized`:
let finalized = attach_checksum(initial);
// The 1 MB heap buffer never moved or reallocated!
assert_eq!(finalized.data.as_ptr().addr(), original_heap_addr);
println!(
"Moved 1 MB Vec across function return with 0 heap copies (tag={})",
finalized.tag
);
}
Stack size of PayloadBuffer: 32 bytes
Moved 1 MB Vec across function return with 0 heap copies (tag=1000000)
Taking Ownership via std::mem::take and std::mem::replace
When you only have a mutable reference &mut T (such as inside a state machine &mut self method) and want to move a heap-allocated Vec or String out of a field without cloning it, use std::mem::take(&mut self.buffer). It swaps self.buffer with Default::default() (an empty Vec::new() or String::new(), which performs zero heap allocation!) and returns the owned buffer in $O(1)$ time:
#[derive(Default)]
pub struct BatchAccumulator {
pending: Vec<u64>,
}
impl BatchAccumulator {
pub fn push(&mut self, item: u64) {
self.pending.push(item);
}
/// Moves the accumulated `Vec<u64>` out in O(1) time without cloning!
pub fn flush(&mut self) -> Vec<u64> {
std::mem::take(&mut self.pending)
}
}
fn main() {
let mut acc = BatchAccumulator::default();
acc.push(10);
acc.push(20);
let batch = acc.flush();
println!("Flushed batch: {batch:?}, remaining pending: {}", acc.pending.len());
}
Flushed batch: [10, 20], remaining pending: 0