Fat Pointers and Slice Metadata
A reference to a slice (&[T], &mut [T], &str, or Box<[T]>) is represented at runtime as a fat pointer (also called a wide pointer): a two-word pair consisting of the memory address of the first element (ptr) and the element count (len).
Thin Pointers vs. Fat Pointers
For any type T that implements Sized (such as i32, [u8; 32], or Vec<T>), the compiler knows the exact byte size of T at compile time, so a reference &T only needs to store a single usize memory address (thin pointer, 8 bytes on 64-bit targets).
For a dynamically sized slice ([T] or str), the length varies at runtime, so the compiler attaches a second usize word containing the metadata (number of elements of T for &[T], or number of UTF-8 bytes for &str):
64-Bit Stack Representation:
Thin Pointer (`&[u32; 4]` or `&Vec<u32>`): 8 bytes total
+--------------------------+
| data_ptr: 0x7ffee40100 | (8 bytes)
+--------------------------+
Fat Pointer (`&[u32]` or `&str`): 16 bytes total
+--------------------------+--------------------------+
| data_ptr: 0x7ffee40100 | len: 4 | (8 bytes + 8 bytes)
+--------------------------+--------------------------+
| Pointer Type | Target Type Sized? |
Stack Size (64-bit) | Metadata Stored in Pointer |
|---|---|---|---|
&i32, &[u8; 64], &Vec<u8> |
Yes (Sized) |
8 bytes | None (size known statically) |
&[T], &mut [T], Box<[T]> |
No (!Sized slice DST) |
16 bytes | len: usize (number of T elements, not byte size unless size_of::<T>() == 1) |
&str, &mut str, Box<str> |
No (!Sized string slice DST) |
16 bytes | len: usize (number of UTF-8 bytes) |
&dyn Trait, Box<dyn Trait> |
No (!Sized trait object DST) |
16 bytes | vtable_ptr: *const () (pointer to virtual method table) |
Inspecting Slice Pointer and Metadata Components
Every slice provides .as_ptr() (or .as_mut_ptr()) to read its raw starting address *const T and .len() to read its element count metadata:
use std::mem::size_of;
fn main() {
let array: [u32; 4] = [100, 200, 300, 400];
let full_slice: &[u32] = &array;
let tail_slice: &[u32] = &array[2..];
println!("size_of::<&[u32; 4]>() = {} bytes (thin pointer)", size_of::<&[u32; 4]>());
println!("size_of::<&[u32]>() = {} bytes (fat pointer)", size_of::<&[u32]>());
println!(
"full_slice -> ptr: {:p}, len: {} elements ({} bytes)",
full_slice.as_ptr(),
full_slice.len(),
std::mem::size_of_val(full_slice)
);
println!(
"tail_slice -> ptr: {:p}, len: {} elements ({} bytes)",
tail_slice.as_ptr(),
tail_slice.len(),
std::mem::size_of_val(tail_slice)
);
}
size_of::<&[u32; 4]>() = 8 bytes (thin pointer)
size_of::<&[u32]>() = 16 bytes (fat pointer)
full_slice -> ptr: 0x16d9a6530, len: 4 elements (16 bytes)
tail_slice -> ptr: 0x16d9a6538, len: 2 elements (8 bytes)
Notice how tail_slice (&array[2..]) has a pointer address exactly 8 bytes (2 * size_of::<u32>()) higher than full_slice and a len metadata value of 2.
Empty Slices and Non-Null Dangling Pointers
Even when a slice is empty (&[] or ""), Rust requires the data_ptr inside &[T] to be non-null and properly aligned for T (NonNull::dangling()). Because data_ptr is guaranteed never to be null, the compiler applies the null-pointer optimization to Option<&[T]> and Option<&str>, keeping them 16 bytes on 64-bit platforms without needing an extra tag word:
use std::mem::size_of;
fn main() {
let empty: &[u64] = &[];
println!("Empty slice ptr: {:p}, len: {}", empty.as_ptr(), empty.len());
println!("size_of::<&[u64]>() = {} bytes", size_of::<&[u64]>());
println!("size_of::<Option<&[u64]>>() = {} bytes", size_of::<Option<&[u64]>>());
}
Empty slice ptr: 0x8, len: 0
size_of::<&[u64]>() = 16 bytes
size_of::<Option<&[u64]>>() = 16 bytes