10.5 Fat Pointers: How Slices Carry Length Without Allocation
Alright, let’s talk about one of the most brilliantly simple yet initially confusing concepts in Rust: the slice. Specifically, we’re going to dissect how they manage to know their own length without being some special, heap-allocated monstrosity. The answer is a concept called a “fat pointer,” and it’s one of my favorite pieces of Rust’s design. It’s so obvious in hindsight you’ll wonder why more languages don’t do it.
Think about a simple reference, like &i32. It’s a thin pointer. Under the hood, on a 64-bit system, it’s just a single 8-byte address pointing to some integer living elsewhere in memory. It has no idea what’s around it. It’s like knowing the exact street address of a single house but having no clue how long the street is. This is fine, until you need to talk about a whole contiguous range of houses.
This is the problem slices solve. A slice, written as &[T] or &mut [T], is a view into a contiguous sequence of elements in a collection, like a Vec or an array. The key question is: how does a &[i32] “know” how many i32s it’s looking at? The answer isn’t magic; it’s just a little more data.
The Anatomy of a Fat Pointer
A slice reference is a fat pointer. It’s “fat” because it carries two pieces of information, not one:
- The pointer to the start of the data in memory (the same 8 bytes as a thin pointer).
- The length (number of elements) of the slice (another 8 bytes on a 64-bit system).
So, while a &i32 is 8 bytes wide, a &[i32] is 16 bytes wide. It’s a two-for-one deal. The Rust compiler knows this and handles all the packing and unpacking of this data automatically whenever you work with slices. You never manually deal with the length; you just get to use it.
fn main() {
let numbers: Vec<i32> = vec![10, 20, 30, 40, 50]; // A Vec on the heap
// Create a slice that views elements from index 1 (inclusive) to 4 (exclusive)
let slice: &[i32] = &numbers[1..4]; // This is the fat pointer
println!("The slice is: {:?}", slice); // [20, 30, 40]
println!("Its length is: {}", slice.len()); // 3
// Let's see its size in memory
println!("Size of &[i32]: {}", std::mem::size_of::<&[i32]>()); // 16 bytes on a 64-bit machine
println!("Size of &i32: {}", std::mem::size_of::<&i32>()); // 8 bytes on a 64-bit machine
}
This is why passing a slice around is so cheap and doesn’t require allocation. You’re not copying the actual data ([20, 30, 40]); you’re just copying the 16-byte fat pointer that describes where the data is and how much of it there is. It’s incredibly efficient.
Why This Design is Genius (and Occasionally a Pain)
The beauty of this system is its simplicity and zero-cost nature. The length information is bundled right there with the pointer, so any function that takes a &[T] immediately has all the information it needs to do bounds checking and iteration without any further lookup. It’s why you can call .len() or .get(index) on any slice, anywhere.
However, the designers’ choice to make this a runtime value stored in the pointer has one main consequence: it’s impossible to have a statically-sized slice type. You cannot have a &[i32; 5] magically turn into a thin pointer; it will always be a 16-byte fat pointer pointing to those 5 elements. This is almost never a practical problem, but it’s a neat detail to be aware of.
The one place this can bite you is when you’re doing really low-level stuff or trying to interface with C. A *const [i32] (a raw pointer to a slice) is also a fat pointer, which is a nightmare for C FFI because C has no idea what to do with the extra length field. For this reason, you’ll almost always use a thin raw pointer (*const i32) and a separate usize for length when talking to C, manually reconstructing the fat pointer on the Rust side if needed. It’s a rough edge, but it’s a necessary consequence of the safety and convenience the fat pointer gives us everywhere else.
It’s Not Just Slices
While we’re pulling back the curtain, you should know that slice fat pointers aren’t alone. Trait objects (&dyn SomeTrait) are also fat pointers! Instead of carrying a length, the second field is a pointer to the vtable—a table of function pointers for that specific type implementing SomeTrait. This is how Rust does dynamic dispatch: the data pointer tells it where the object is, and the vtable pointer tells it what it can do. It’s the same elegant, efficient two-field pattern.
So the next time you use a slice, take a mental pause to appreciate the humble fat pointer. It’s a small piece of data doing a huge amount of work, enabling safe, efficient, and flexible array programming without any fuss. It’s the kind of pragmatic, clever solution that makes Rust such a joy to work with once you get to know it.