What's Behind the "Fat" Pointer? An Analysis of Fat Pointers in Rust
\n\nImagine you go to a library and ask for a book. The librarian gives you a small slip of paper with the shelf and row number — that's a regular pointer. But what if you don't know the size of the book? Or what if it's a three-volume set? You'll need a second slip: "Volume 1 of 3" or "Length: 450 pages".
\n\nIn Rust, such a "double" pointer is called a fat pointer. It's not magic, but simply a pair of numbers: the data address and its meta-information (most often the length or a pointer to a virtual method table). Let's figure out why this is needed and how it works, using real code examples.
\n\nRegular Pointers: Thin and Fast
\n\nLet's start with the basics. Rust has simple pointers — &T (reference) or *const T (raw pointer). They take exactly 8 bytes (on a 64-bit system) and store only the memory address.
fn main() {\n let x: i32 = 42;\n let ptr: &i32 = &x; // thin pointer — 8 bytes\n println!("Size of reference: {} bytes", std::mem::size_of_val(&ptr));\n}\n\n\nOutput: Size of reference: 8 bytes. Everything is predictable: the compiler knows that i32 is 4 bytes, so no additional information is needed. But what if the data size is unknown in advance?
First Fat Pointer: Slices (&[T])
\n\nA slice is a "window" into an array or vector. But to use it, you need to know not only the beginning but also the number of elements. Rust stores this as a pair (ptr, len) — that's a fat pointer.
fn main() {\n let arr = [1, 2, 3, 4, 5];\n let slice: &[i32] = &arr[1..4]; // slice of 3 elements\n\n println!("Size of slice: {} bytes", std::mem::size_of_val(&slice));\n println!("Elements: {:?}", slice);\n}\n\n\nOutput: Size of slice: 16 bytes (8 bytes pointer + 8 bytes length). If we only passed the address, we wouldn't know where to stop. That's why a slice is a classic example of a fat pointer.
By the way, the string &str works exactly the same way: it's a fat pointer (*const u8, usize). That's why &str takes 16 bytes, while &String takes only 8 (because String itself stores the length internally).
Second Fat Pointer: Trait Objects (&dyn Trait)
\n\nRust has dynamic dispatch via trait objects. When you write &dyn MyTrait, the compiler doesn't know which concrete type will be passed. It needs a virtual method table (vtable) to call the correct functions.
trait Animal {\n fn speak(&self);\n}\n\nstruct Dog;\nstruct Cat;\n\nimpl Animal for Dog {\n fn speak(&self) { println!("Woof!"); }\n}\n\nimpl Animal for Cat {\n fn speak(&self) { println!("Meow!"); }\n}\n\nfn main() {\n let dog = Dog;\n let cat = Cat;\n\n let animals: [&dyn Animal; 2] = [&dog, &cat];\n\n println!("Size of &dyn Animal: {} bytes", std::mem::size_of::<&dyn Animal>());\n\n for animal in &animals {\n animal.speak();\n }\n}\n\n\nOutput: Size of &dyn Animal: 16 bytes. These 16 bytes are again a fat pointer: the first 8 bytes are the data address (Dog or Cat), the second 8 bytes are the vtable address (method table).
How Does a Fat Pointer Affect Performance?
\n\nFat pointers are a trade-off. On one hand, they carry more information, which allows working with dynamic types. On the other hand:
\n\n- \n
- Memory: A fat pointer takes 16 bytes instead of 8. This is not critical, but in large collections it can have an impact. \n
- Speed: Access via vtable (for trait objects) is slightly slower than a direct method call (monomorphization). The diff