Slices

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