Stack, Heap, and Move
Where a value actually lives, what a move costs at runtime, and why Copy is not an optimization.
By the end of this session you will be able to:
- Predict which bytes of a value sit in the stack frame and which sit in a heap allocation, then check your prediction with
size_of. - State exactly what machine work a move performs, and prove that it never touches the heap.
- Read an E0382 error and fix it by changing who owns the value, instead of pasting in the
.clone()the compiler suggested.
Everything here runs on Rust 1.97, 2024 edition, which is what cargo new gives you.
Where the bytes actually live
Every Rust value has a size known at compile time, and that size is what goes into the stack frame. You can ask for it directly; size_of has been in the prelude since Rust 1.80, so there is nothing to import:
struct Point { x: f64, y: f64 }
fn main() {
println!("i32 {}", size_of::<i32>());
println!("Point {}", size_of::<Point>());
println!("String {}", size_of::<String>());
println!("Vec<u8> {}", size_of::<Vec<u8>>());
println!("Box<[u8; 1024]> {}", size_of::<Box<[u8; 1024]>>());
}On a 64-bit target that prints 4, 16, 24, 24, 8.
A String is 24 bytes of stack: a pointer, a length, and a capacity, three machine words. The characters are not in there. They are in a heap allocation the String owns. Box<[u8; 1024]> is 8 bytes, one pointer, and the kilobyte is somewhere else entirely. Point is 16 bytes with nothing else anywhere.
That is the entire distinction. Some values are wholly contained in their frame. Others are a small fixed-size handle in the frame plus an allocation they are responsible for releasing.
The reason the compiler cares is the second half. A stack frame is destroyed by moving the stack pointer, which costs nothing and cannot be wrong. A heap allocation has to be handed back with an explicit call, exactly once, not zero times and not twice. Ownership is the compiler's answer to the question of who makes that call.
A move copies the handle, not the data
Run this:
fn main() {
let s = String::from("hello");
println!("before ptr {:p} len {} cap {}", s.as_ptr(), s.len(), s.capacity());
let t = s;
println!("after ptr {:p} len {} cap {}", t.as_ptr(), t.len(), t.capacity());
}before ptr 0x103425d70 len 5 cap 5
after ptr 0x103425d70 len 5 cap 5The address differs between runs. What matters is that it is the same address twice. let t = s; copied 24 bytes of stack and did nothing else: no allocation, no free, no copy of the five characters. In a release build the compiler will usually keep the value in the same slot and emit no instructions at all.
So moving a Vec of a million elements costs the same 24 bytes as moving an empty one. The cost does not scale with the data, because the data does not move.
What did change is bookkeeping, and only at compile time. After let t = s;, the name s is statically dead. The compiler will refuse to read it, and it will not run its destructor at the end of the scope. Only t is dropped, so 0x103425d70 is freed once. If both names stayed live, both would be dropped, and that address would be freed twice. That double free is the bug ownership exists to make unrepresentable, and it is why moving out of a variable has to invalidate it.
Copy is not an optimization
Write let n = m; where m is an i32 and the machine does the same kind of work: it copies bytes, 4 of them instead of 24. The difference between that and the String case is not speed. It is whether the source stays usable afterwards.
Copy is a promise about meaning, not about cost. It says that duplicating the bytes of this value produces a second, independent, equally valid value, so the compiler has no reason to invalidate the original. A type can make that promise only when nothing about it is unique: no owned allocation, no file handle, no destructor. Copy and Drop are mutually exclusive in Rust for exactly that reason.
Which is why this does not compile:
#[derive(Clone, Copy)]
struct Config { name: String }error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:1:17
|
1 | #[derive(Clone, Copy)]
| ^^^^
2 | struct Config { name: String }
| ------------ this field does not implement `Copy`Do not look for a way around that: a Copy Config means two structs pointing at one buffer, each convinced it should free it.
Clone is the other half of the pair: the explicit, possibly expensive duplicate. Calling .clone() on a String allocates fresh memory and copies the bytes into it. A move is free and a clone is an allocation, so turning one into the other to quiet the compiler is a real cost you chose without meaning to.
Reading the move error
Here is the shape you will hit constantly:
fn consume(v: Vec<i32>) -> usize { v.len() }
fn main() {
let v = vec![1, 2, 3];
let n = consume(v);
println!("{n} {}", v.len());
}error[E0382]: borrow of moved value: `v`
--> src/main.rs:6:24
|
4 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5 | let n = consume(v);
| - value moved here
6 | println!("{n} {}", v.len());
| ^ value borrowed here after move
note: consider changing this parameter type in function `consume` to borrow instead if owning the value isn't necessary
--> src/main.rs:1:15
|
1 | fn consume(v: Vec<i32>) -> usize { v.len() }
| ------- ^^^^^^^^ this parameter takes ownership of the value
help: consider cloning the value if the performance cost is acceptableThree facts, in the order they are printed: where the value was created and why it is not Copy, where it moved, where you used it afterwards. Then a note pointing at the real cause, which is a function signature demanding ownership of something it only reads, and a help line offering .clone().
Take the note, not the help. Change the signature to fn consume(v: &[i32]) -> usize and the call to consume(&v). v never moves, nothing is allocated, and the error is gone. That & is a borrow, and the rules governing it are the next session.
Try it
cargo new moves
cd moves
cargo run- Put the pointer program from the second section into
src/main.rsand run it. Confirm the two printed addresses are identical. - Add
fn count(v: Vec<i32>) -> usize { v.len() }, call it frommain, then printv.len()on the next line. Read the whole E0382 output before touching anything. - Fix it twice. Once with
.clone()at the call site. Once by changing the parameter type so nothing moves.
You are done when the second version compiles, contains zero calls to .clone(), and you can say in one sentence which line in the error output told you to change the signature.
Common mistakes
- Adding
.clone()because rustc suggested it. The suggestion says "if the performance cost is acceptable", which is the compiler admitting it cannot judge that. The note above it, "this parameter takes ownership of the value", is the one that names the actual problem. - Assuming a move is expensive for large collections. It is a fixed handful of bytes whatever the length, and frequently zero after optimization. The expensive operation is the clone you added to avoid it.
- Deriving
Copyto silence a move error. If the type owns anything, E0204 stops you, and that is the compiler catching a shallow copy of a resource before it becomes a double free. - Believing the moved-from variable is zeroed or filled with garbage. Nothing is written to it at runtime; the old bytes still sit in the frame. The invalidation is purely a compile-time judgment, which is why ownership costs nothing to run, and why no test you write can observe it.
Where this goes next
Ownership answers who frees the value. The next session, The Borrow Rules, answers who may look at it in the meantime: shared versus exclusive references, aliasing XOR mutation, and which borrow errors are telling you to restructure rather than annotate.