Reading Rust

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.

40 mincontrasting casesvariation theorybeacon trainingPOE
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

TypeSize on the stackOwns the bytes?Knows its length?Can grow?
[u8; 4]4yes, inlinein the typeno
Box<[u8; 4]>8yes, on heapin the typeno
Box<[u8]>16yes, on heapin the pointerno
&[u8]16no β€” borrowedin the pointerno
Vec<u8>24yes, on heapin the headeryes
&u88no β€” borrowedn/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:

Beaconsee this β†’ think this, without decoding it

&[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.

Beaconsee this β†’ think this, without decoding it

&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:

Beaconsee this β†’ think this, without decoding it

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
  • String owns a heap buffer, tracks spare capacity, and can grow. It frees the buffer when dropped.
  • &str borrows 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.

Predict firstcommit in writing, then look
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.

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.

Explain it to yourselfwrite it β€” thinking it does not count

Someone on your team writes:

β€œWhy is &dyn Error twice 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] and str, a vtable for dyn Trait. Those are Rust’s only two fat pointers.
  • [T], str and dyn Trait are dynamically sized and can only live behind a pointer. ?Sized is how generic code opts in to accepting them.
  • String owns and can grow (3 words); &str borrows 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.