Part 1 Β· The runtime machine
Stack, heap, and fat pointers
Six ways to hold some bytes, and why three of them are two words wide. Once you see fat pointers you cannot unsee them.
What this lesson is trying to break (3)
- A pointer is one word.
- `&str` and `String` are two names for the same thing.
- Slices are a view type invented for convenience, like a JS subarray.
Start with the table, not the prose. Look at it for a minute before you read on, and try to work out the rule yourself.
Varies: only how the bytes are reached β every row can represent the same four bytes
| Type | Size on the stack | Owns the bytes? | Knows its length? | Can grow? |
|---|---|---|---|---|
[u8; 4] | 4 | yes, inline | in the type | no |
Box<[u8; 4]> | 8 | yes, on heap | in the type | no |
Box<[u8]> | 16 | yes, on heap | in the pointer | no |
&[u8] | 16 | no β borrowed | in the pointer | no |
Vec<u8> | 24 | yes, on heap | in the header | yes |
&u8 | 8 | no β borrowed | n/a (one byte) | no |
Sizes are for a 64-bit target. Three of these are one word; three are more. Work out which property forces the extra words before reading on.
The rule is this: a pointer must carry enough information to use what it points at. When the type alone is not enough, the pointer grows.
&u8 is one word because u8 tells you everything: one byte, and you know how
to read it. &[u8] is two words because [u8] β a slice, with no length in the
type β does not tell you how many bytes there are. The count has to live
somewhere, so it lives in the pointer.
A pointer carrying that extra word is called a fat pointer. Rust has exactly two kinds, and this is worth memorising because it is a permanent reading aid:
&[T] Β· &str Β· Box<[T]> Β· &mut [T]
Fat pointer, second word is a length. The data is a run of elements
somewhere; this value says where it starts and how many there are. It does not
own them unless it is a Box.
&dyn Trait Β· Box<dyn Trait> Β· Arc<dyn Error + Send + Sync>
Fat pointer, second word is a vtable pointer. The data is some concrete type
you are not allowed to know; the vtable says which functions to call on it. Every
dyn in Rust is exactly this β see
3.3.
Dynamically sized types
[u8], str and dyn Trait share a property: the compiler does not know how
big they are. They are dynamically sized types, usually written DST.
You can never have a bare DST in a place. This is illegal:
let s: str = *"hello"; // error: the size for values of type `str`
// cannot be known at compilation time
A place has to have a size β the compiler needs to know how many bytes to reserve. So DSTs can only exist behind a pointer, and the pointer supplies the missing information. That is the whole reason fat pointers exist.
This also explains a piece of syntax you will meet constantly when reading library code:
T: ?Sized
βThis generic parameter is allowed to be a DST.β Every generic parameter is
implicitly T: Sized; ?Sized removes that default. You will see it on
functions that want to accept &str and &[T] and &dyn Trait as well as
ordinary types β AsRef<T: ?Sized>, Box<T: ?Sized>, Arc<T: ?Sized>.
String versus &str
Now the pair that confuses everyone, which you can now read off the table.
String (24 bytes on stack) &str (16 bytes on stack)
ββββββββ¬βββββββββ¬βββββββββββ ββββββββ¬βββββββββ
β ptr β len: 5 β cap: 8 β β ptr β len: 5 β
ββββ¬ββββ΄βββββββββ΄βββββββββββ ββββ¬ββββ΄βββββββββ
β β
βΌ βΌ
heap: [h][e][l][l][o][ ][ ][ ] ...points anywhere: heap, static
ββββ owned, freed on drop memory, or into a String's buffer
Stringowns a heap buffer, tracks spare capacity, and can grow. It frees the buffer when dropped.&strborrows a run of UTF-8 bytes. It has no capacity because it cannot grow, and it frees nothing because it owns nothing.
A string literal "hello" has type &'static str β it points into the
programβs own read-only data, which is why it is valid for the entire run.
The practical consequence, and the reason this matters for reading real code:
a function that takes &str can be called with a String, a literal, or a
slice of either. A function that takes String demands ownership and forces
the caller to allocate. So fn greet(name: &str) is the idiomatic signature,
and seeing fn greet(name: String) is a small signal β either the function
genuinely stores the value, or the author has not thought about it.
use std::fmt::Display;
fn main() {
println!("{} {} {} {}",
size_of::<&u8>(),
size_of::<&[u8]>(),
size_of::<Option<&u8>>(),
size_of::<Box<dyn Display>>(),
);
}The third one is the interesting one.
8 16 8 16The first, second and fourth follow directly from the table: thin pointer, fat pointer with a length, fat pointer with a vtable.
The third is the one worth stopping on. Option<&u8> is the same size as
&u8 β the None case costs nothing.
A reference is never null, so the bit pattern 0 is unused. The compiler notices
this spare pattern β called a niche β and uses it to represent None. There
is no separate tag byte.
This is the technical content behind the phrase βzero-cost abstractionβ, and it
is a much better example than the ones usually given. In Java, Optional<T> is
a heap object wrapping a reference. In Rust, Option<&T> is a pointer that might
be null, with the compiler forcing you to check. Same machine code as the C
version, none of the danger.
It also generalises: Option<Box<T>>, Option<&mut T> and
Option<NonZeroU32> are all free in exactly the same way.
Why Go programmers get caught here
A Go slice is (ptr, len, cap) and it aliases its backing array. Two slices
can view overlapping memory, and writing through one is visible through the
other. Every Go programmer eventually gets bitten by append either mutating a
shared array or silently reallocating away from it.
Rustβs &[T] is (ptr, len) and looks similar. The difference is not in the
representation β it is that Rust forbids the aliasing that makes the Go bug
possible. You cannot hold a &[T] into a Vec and push to that Vec, which
is exactly the pattern that produces the surprise in Go.
That restriction is the subject of Part 2, and this is the first place you can see concretely what it buys.
Someone on your team writes:
βWhy is
&dyn Errortwice the size of&io::Error? A pointer is a pointer.β
Answer them in three sentences. Use the phrase βfat pointerβ and say what the second word contains.
What you should now be able to say
- A pointer carries whatever the type does not: a length for
[T]andstr, a vtable fordyn Trait. Those are Rustβs only two fat pointers. [T],stranddyn Traitare dynamically sized and can only live behind a pointer.?Sizedis how generic code opts in to accepting them.Stringowns and can grow (3 words);&strborrows and cannot (2 words).Option<&T>is free because a reference has a niche β the null pattern is spare.- A Rust slice looks like a Go slice, but the aliasing rules are what actually differ.
Next: 1.3 β The four verbs, where we find out which of these can be duplicated for free and which cannot.