When generics hit a wall
You write a generic function in Rust. It works for i32. It works for String. It even works for your custom User struct. You feel the rhythm. Then you try to pass a string slice &str or a trait object &dyn Display. The compiler rejects you with E0277 (the trait Sized is not implemented for str).
You didn't break the logic. The function body is fine. The compiler is complaining about memory layout. Specifically, it wants to know how big T is, and for slices and trait objects, that information simply does not exist at compile time.
This is the Sized trait. It is the gatekeeper between values that fit in a box and values that are too big or too vague to hold directly. Understanding Sized unlocks the difference between str and String, [T] and Vec<T>, and why trait objects always live behind pointers.
The stack needs a number
Rust stores local variables on the stack. The stack is a contiguous region of memory with a fixed size. When the compiler generates code for a function, it calculates exactly how much stack space every local variable needs. It reserves that space before the function runs.
To reserve space, the compiler must know the size of every type. If a type has a known size at compile time, it implements the Sized trait. Most types are Sized. An i32 is always 4 bytes. A String is always 24 bytes (a pointer, a length, and a capacity). A struct containing Sized fields is Sized.
Some types do not have a fixed size. A string slice str could be one character or a gigabyte of text. A slice [u8] could be empty or fill the entire RAM. A trait object dyn Display could be any type that implements Display, and those types can have different sizes. These are unsized types. They do not implement Sized.
The stack is a strict landlord. If you cannot declare the square footage, you do not get a room. Unsized types cannot live on the stack. They cannot be passed by value. They cannot be stored directly in a struct. They must be accessed through a pointer that carries the missing information.
Sized vs Unsized in practice
Generic functions in Rust have a default assumption. When you write fn foo<T>(t: T), the compiler implicitly treats this as fn foo<T: Sized>(t: T). The bound T: Sized is added automatically. This is why your generic function works for i32 but fails for str. The compiler assumes T fits on the stack, and str does not.
/// This function accepts any type with a known compile-time size.
/// The `T: Sized` bound is implicit.
fn process_sized<T>(t: T) {
// We can put `t` on the stack because the compiler knows its size.
// We can copy or move it freely.
println!("Processing a sized value.");
}
/// This function relaxes the requirement.
/// `?Sized` means "T may or may not be Sized".
fn process_flexible<T: ?Sized>(t: &T) {
// We cannot take `T` by value here.
// If T is unsized, we cannot move it.
// We take a reference instead.
// The reference itself is always Sized.
println!("Processing a reference to T.");
}
fn main() {
// i32 is Sized. Both functions work.
process_sized(42);
process_flexible(&42);
// str is unsized.
// process_sized("hello"); // Error: E0277
// We must use the flexible version with a reference.
process_flexible("hello");
}
The syntax ?Sized is an opt-out. It tells the compiler, "Do not assume T is Sized." You will see this mostly in library code where a function needs to accept both concrete types and unsized types like slices or trait objects. In application code, the default Sized bound is usually what you want.
Convention aside: You rarely write T: Sized explicitly. It is redundant. You write T: ?Sized only when you need to support unsized types. The ? prefix is the standard way to relax trait bounds in Rust.
The magic of fat pointers
If unsized types cannot live on the stack, how do you use them? You use pointers. But not just any pointer. Rust uses "fat pointers" for references to unsized types.
A standard reference &T where T is Sized is a "thin pointer". It is just a memory address. On a 64-bit system, it takes 8 bytes. The compiler knows the size of T, so the address is enough.
A reference to an unsized type is a "fat pointer". It carries extra data to describe the size. A &str contains a pointer to the start of the string and a length. A &dyn Trait contains a pointer to the data and a pointer to the vtable (the table of function pointers for dynamic dispatch). These fat pointers are always Sized. They fit on the stack. They give you access to the unsized data without requiring the data itself to be sized.
fn main() {
// A reference to a sized type is a thin pointer.
let int_ref: &i32 = &42;
// Size is 8 bytes on 64-bit systems.
println!("Size of &i32: {}", std::mem::size_of_val(&int_ref));
// A reference to an unsized type is a fat pointer.
let str_ref: &str = "hello";
// Size is 16 bytes: 8 for pointer, 8 for length.
println!("Size of &str: {}", std::mem::size_of_val(&str_ref));
// Trait objects also use fat pointers.
trait Greet {
fn greet(&self);
}
struct Person;
impl Greet for Person {
fn greet(&self) { println!("Hi"); }
}
let dyn_ref: &dyn Greet = &Person;
// Size is 16 bytes: 8 for data pointer, 8 for vtable pointer.
println!("Size of &dyn Greet: {}", std::mem::size_of_val(&dyn_ref));
}
This explains why &T: ?Sized works. The function takes a reference. The reference is Sized, even if T is not. The fat pointer packs the size information into the reference itself. The stack gets a fixed-size value. The unsized data lives elsewhere, on the heap or in static memory, and the fat pointer describes how to find it and how big it is.
Fat pointers are invisible to the programmer in most cases. You write &str and it just works. But they are the mechanism that makes unsized types usable. They bridge the gap between the rigid stack and flexible data.
Pitfalls and compiler errors
The Sized bound causes friction in specific patterns. Knowing these patterns saves time debugging.
Generic structs require sized fields by default.
If you define a struct with a generic parameter, the parameter defaults to Sized. You cannot put an unsized type in a struct field directly.
// This struct requires T to be Sized.
struct Wrapper<T> {
data: T,
}
fn main() {
// This works.
let w = Wrapper { data: 42 };
// This fails. E0277.
// str is not Sized.
// let w_str = Wrapper { data: "hello" };
}
If you need a struct that can hold unsized types, you must opt out with ?Sized and ensure the unsized field is the last field. This creates an unsized struct, which is rare and usually unnecessary. The standard solution is to store a pointer.
/// A wrapper that can hold any type, sized or unsized.
/// We store a reference, which is always Sized.
struct Wrapper<'a, T: ?Sized> {
data: &'a T,
}
Vec requires sized elements.
Vec<T> stores elements contiguously in memory. To calculate the offset of the nth element, it multiplies n by the size of T. If T is unsized, the size is unknown, and the math fails.
fn main() {
// This works.
let ints: Vec<i32> = vec![1, 2, 3];
// This fails. E0277.
// dyn Display is unsized.
// let traits: Vec<dyn Display> = vec![];
}
To store trait objects in a collection, you must use pointers. Vec<Box<dyn Display>> works because Box is a sized pointer. The heap handles the varying sizes of the concrete types.
Trait bounds propagate.
If a function requires T: Sized, and you call it with a type that is Sized, it works. If you have a generic type MyType<T> and T is unsized, MyType<T> might not be Sized either, depending on how T is used. The compiler tracks these bounds carefully. If you see E0277 deep in a call stack, check the generic bounds of the intermediate types. The unsized type is leaking through a layer that assumed Sized.
Trust the borrow checker and the trait solver here. If the compiler says a type is not Sized, it is not. You cannot coerce an unsized type into a sized context without a pointer.
Decision: handling size in generics
Choosing how to handle size depends on what your code needs to do with the value.
Use T with the implicit Sized bound when you need to store the value on the stack, pass it by value, or put it in a Vec. This is the default and covers 95% of use cases. It gives the compiler the most information for optimization.
Use T: ?Sized with &T when writing generic helper functions that should accept slices, trait objects, or concrete types. This pattern appears in logging, formatting, and serialization utilities. The function operates on the reference, so the size of the underlying data does not matter.
Use Box<T> or &T to hold unsized data in structs or collections. Pointers have a fixed size and carry the necessary metadata. Box gives ownership. & gives borrowing. Choose based on lifetime requirements, not size.
Use dyn Trait behind a pointer when you need dynamic dispatch. Trait objects are inherently unsized because they represent a family of types with potentially different sizes. You cannot have a value of type dyn Trait. You must have &dyn Trait, Box<dyn Trait>, or Arc<dyn Trait>.
Default to Sized. Opt out only when the data truly refuses to fit in a box. The compiler's default is there for a reason: stack allocation is fast, and known sizes enable powerful optimizations. Reach for ?Sized when flexibility is required, not by habit.