Skip to content

The Rust Engineering Handbook / Chapter 30

Dynamically Sized Types, Fat Pointers, Unsizing, and Trait Objects

Reason about values whose size is known only through pointer metadata, and design slice and trait-object APIs without turning observations into ABI promises.

Why can &[u8] be passed by value when [u8] has no fixed size?

The apparent contradiction disappears when the pointer-like value and its pointee are kept separate. A slice value [u8] is dynamically sized: its size depends on the element count. A shared slice reference &[u8] is itself a sized value. It carries enough runtime metadata to locate and interpret the borrowed slice. Functions pass that reference by value while the bytes remain elsewhere.

This distinction is the working model for Rust’s dynamically sized types:

A DST’s size is not determined from its static type alone. It therefore appears behind a pointer-like type whose metadata supplies the missing information required by that pointee kind.

For a slice, the metadata is a length. For str, it is a byte length. For a trait object, metadata identifies the dynamic implementation information used for operations such as dispatch and destruction. The language and standard library document these semantic forms; exact widths, field arrangements, and foreign ABI remain separate questions.

A coercion map distinguishes array-to-slice and concrete-to-trait-object unsizing from String-reference-to-str deref coercion, then separates each sized pointer-like value into its data pointer and semantic metadata above the dynamically sized pointee layout.

The three lanes deliberately separate mechanisms: [T; N] to [T] and concrete T to dyn Trait are unsizing cases at coercion sites, while &String commonly becomes &str through Deref coercion. All three produce convenient borrowed pointer-like values, but their mechanisms and API implications differ.

Sized is the ordinary default

Most generic type parameters implicitly require Sized:

fn store<T>(value: T) -> Box<T> {
    Box::new(value)
}

The compiler must know how much stack space and calling-convention storage a by-value T requires. T: Sized means that every value of a particular T has a size known at compile time. It does not mean “small,” “stack allocated,” or “contains no heap allocation.” String, Vec<T>, and Box<T> are sized handles even though they manage variable-sized data elsewhere.

Sized also does not mean a generic function duplicates machine code for every call in a way the source contract can observe. Monomorphization and optimization were separated in Chapter 18. Here the important fact is that by-value layout is known for each instantiation.

Some types are not Sized:

  • [T], because its element count is absent from the type;
  • str, because its UTF-8 byte count is absent from the type;
  • dyn Trait, because the concrete implementor is erased from the static type;
  • a struct whose final field is dynamically sized, an advanced representation pattern.

The compiler may use DSTs in places that support them, principally behind references and owning or raw pointer-like forms. A local variable cannot simply have bare type str with an unknown storage requirement.

Slice and string metadata describe extent

An array [T; N] is sized because N is part of its type. A slice [T] is not. A reference &[T] combines access to the first element with the number of elements in the view. Bounds checks and len() use that extent. For &str, the metadata is the number of bytes; the pointee also carries the semantic invariant that those bytes are valid UTF-8.

fn inspect(values: &[u32]) -> (usize, usize) {
    (values.len(), std::mem::size_of_val(values))
}

let array = [10_u32, 20, 30, 40];
assert_eq!(inspect(&array), (4, 16));

The call is a coercion site: the array reference becomes a slice reference. No element allocation or copy is required. The runtime size reported for the pointee follows length times element size for this ordinary slice. The reference remains sized independently of that result.

The metadata is part of the pointer-like value’s interpretation, not a header promised immediately before the first element. This prevents a common FFI error: handing C only the data address and assuming Rust’s slice length can be rediscovered from adjacent memory. A foreign API needs an explicit pointer-and-length contract, including nullability, element layout, ownership, mutation, retention, and bounds.

For str, length metadata does not create UTF-8 validity. Safe construction establishes the invariant before a string slice exists. Nor does byte length count Unicode scalar values, grapheme clusters, or display columns. Chapter 28 separated those units.

Trait objects pair erased data with dynamic behavior

A trait object such as dyn Describe represents some concrete value whose type is not named at the use site but implements the trait under the object’s rules. It is a DST, so code normally uses &dyn Describe, Box<dyn Describe>, or another supported pointer-like owner.

trait Describe {
    fn describe(&self) -> String;
}

struct Worker(u16);

impl Describe for Worker {
    fn describe(&self) -> String { format!("worker:{}", self.0) }
}

let worker = Worker(7);
let item: &dyn Describe = &worker;
assert_eq!(item.describe(), "worker:7");

The data pointer reaches the concrete value. The metadata supports operations for the particular trait-object type and concrete implementation, conventionally discussed as vtable metadata. Method calls can dispatch indirectly through it. Destruction and dynamic size/alignment information are also relevant to owning trait objects.

Do not turn that model into a public ABI claim that a trait-object reference is exactly two machine words in every relevant environment or that the vtable has a stable field layout. Rust does not specify a stable C ABI for trait objects. The concrete pointee retains the layout of its concrete type; it is not transformed into an inline “object record” with metadata prefixed to it.

Trait objects also preserve auto-trait and lifetime bounds in their type. dyn Service + Send + Sync + 'static is a different contract from an unconstrained dyn Service. Erasing the concrete type does not erase thread-safety or validity requirements.

Unsizing is a controlled coercion, not conversion by convention

Rust performs certain coercions where the expected type is known. Important cases include:

  • &[T; N] to &[T] and analogous owning-pointer forms;
  • a pointer to concrete T to a pointer to dyn Trait when T implements the eligible trait-object contract;
  • structural unsizing through supported pointer/container forms and final fields under language rules.
fn sum(values: &[u32]) -> u32 { values.iter().sum() }

let fixed = [2, 3, 5];
assert_eq!(sum(&fixed), 10);

The array remains the allocation and owner. The borrowed pointer gains slice metadata describing a view. Unsizing does not mean serializing, reallocating, or copying the elements.

Not every wrapper participates. A user-defined struct MyBox<T>(Box<T>) does not automatically gain all coercion behavior merely because its field could. The stable language exposes coercions for specified forms; implementing low-level coercion traits for arbitrary smart pointers has stability and language-support boundaries. Design public APIs around stable coercion sites rather than promising nightly-only customization.

String owns a growable UTF-8 buffer and implements Deref<Target = str>. When a function expects &str, a borrowed &String can be adjusted through dereference coercion:

fn route_name(name: &str) -> usize { name.len() }
let owned = String::from("ready");
assert_eq!(route_name(&owned), 5);

String itself is not unsized into str; the compiler follows the dereference relationship for the borrow. Similarly, Vec<T> can lend a slice through deref coercion. This is why APIs generally accept &str or &[T] when they need a read-only view, leaving callers free to own data in several containers.

Autoderef can make calls convenient but should not obscure ownership. The callee receives a borrow; it cannot retain it beyond the lifetime expressed in the signature. Conversion to Box<str> or Box<[T]> is an ownership transition with potentially different allocation/capacity behavior, not merely the same borrowed coercion.

?Sized relaxes a generic bound where indirection supplies layout

Because generic T means T: Sized by default, a helper intended to accept sized and dynamically sized pointees writes T: ?Sized:

fn dynamic_len<T: ?Sized>(value: &T) -> usize {
    std::mem::size_of_val(value)
}

The ?Sized spelling means that Sized is not required; it does not mean that T must be unsized. The function accepts &u64, &[u8], &str, and suitable trait-object references. The reference gives the function a sized argument and makes runtime layout available where necessary.

This relaxation belongs on parameters whose operations genuinely work for DSTs. Adding ?Sized everywhere can complicate implementations and downstream bounds without improving the API. Conversely, a trait often uses ?Sized indirectly so it can be implemented for or operate on DSTs. Review which methods require by-value Self, construction, or concrete layout and whether those methods need a where Self: Sized restriction.

Object safety is now called dyn compatibility

Not every trait can form a trait object. The current reference uses the term dyn compatible. Restrictions exist because dynamic dispatch must have a coherent callable representation without knowing the concrete Self. Methods with incompatible uses of Self, unsupported generic method shapes, or other disallowed features cannot be dispatched through dyn Trait as written.

This is an API-design signal, not merely a compiler obstacle. Credible repairs include:

  1. Keep static dispatch when callers benefit from concrete types and monomorphization.
  2. Split a dyn-compatible operational trait from sized construction, cloning, or generic extension methods.
  3. Add where Self: Sized to methods that should remain unavailable on trait objects.
  4. Introduce an explicit erased request/response representation at a true runtime extension boundary.

Do not mechanically box everything. Box<dyn Trait> buys heterogeneous ownership and runtime dispatch while adding allocation in common constructions, indirection, erased concrete APIs, and lifetime/auto-trait choices. An enum can represent a closed set of variants with exhaustive matching and no trait-object ABI boundary. A generic parameter keeps a concrete type and supports static dispatch but makes heterogeneous storage less direct and can increase code size.

Custom DSTs are an advanced representation boundary

Rust permits a struct’s final field to be dynamically sized, so the complete struct becomes dynamically sized. This enables layouts conceptually similar to a header followed by a variable tail. Merely declaring such a type is not the hard part. Constructing, allocating, aligning, initializing, projecting, and dropping it correctly often crosses unstable facilities or unsafe code.

Production alternatives should be evaluated first:

  • a sized header plus Box<[T]> tail;
  • one allocation owned by a stable crate with a documented safety case;
  • offsets into a byte buffer validated by a safe front end;
  • separate allocations where simplicity matters more than locality.

Custom DST code must distinguish the logical layout from allocator layout, calculate size and alignment without overflow, initialize only valid values, and create metadata consistent with the allocation. It belongs in a narrow unsafe abstraction and needs Miri/fuzz/target evidence appropriate to its operations. This chapter does not provide a construction recipe because stable, safe slice and trait-object APIs solve most application needs.

API design: accept views, return ownership deliberately

DST-aware design often improves APIs without exposing the term DST:

  • accept &[T] rather than &Vec<T> when capacity and growth are irrelevant;
  • accept &str rather than &String for a borrowed UTF-8 view;
  • return Box<[T]> when the caller owns a fixed-length sequence;
  • use impl Trait when returning one hidden concrete type;
  • use Box<dyn Trait> when runtime heterogeneity and ownership are contractual;
  • add Send, Sync, and lifetime bounds that match retention and execution context.

Avoid overly generic AsRef signatures when one exact view communicates the contract and type inference matters. Use them where caller flexibility genuinely outweighs diagnostic and coherence complexity. Do not accept &dyn Trait solely to reduce generic code size without measuring or defining the plugin boundary.

Returning a borrowed DST requires naming its owner relationship through lifetimes. Returning &[T] from a parser is cheap only while the backing buffer lives and stays immutably available. Queuing the view may require transferring the backing owner, copying the field, or storing offsets and reborrowing later.

FFI and persistence require explicit representations

Rust slices, strings, and trait objects are not portable wire formats. At a C boundary:

  • represent slices with an agreed pointer and length, checking null, alignment, extent, element ABI, ownership, and mutation;
  • represent UTF-8 with bytes plus length and validate on entry, or use the foreign API’s named encoding;
  • never pass a Rust trait object as a stable foreign interface;
  • expose an opaque handle plus extern "C" functions or a versioned function table with a fully specified C layout;
  • keep allocator and deallocator ownership paired across the same ABI contract.

Raw pointer metadata APIs and compiler observations are not substitutes for a foreign contract. A vtable layout may change across compiler versions or compilation contexts. Persistence should encode semantic data and version it, never dump an object or fat-pointer representation.

Performance and operational consequences

Dynamic dispatch can inhibit inlining and add an indirect call, but the effect depends on the hot path, optimizer visibility, branch predictability, and work performed per call. It can also reduce duplicated generic code and compile time. Measure both runtime and binary effects on representative targets.

Slice metadata enables bounds checks and safe iteration. Optimizers often eliminate redundant checks in recognizable loops, but that is an implementation outcome, not a source-level guarantee. Write clear iteration first and inspect profiles or generated code only where cost matters.

Trait-object ownership affects shutdown and error behavior. Box<dyn Plugin + Send> says a plugin may cross a thread boundary but not necessarily be shared; adding Sync changes the contract. A borrowed object ties retention to another owner. A boxed object drops dynamically, so destructor latency and panic policy still matter. Logs should identify a stable domain/plugin name rather than rely on concrete type_name as a compatibility ID.

Metadata corruption is relevant only once unsafe code or foreign input can fabricate pointer-like values. Safe Rust maintains slice bounds and trait-object validity. Unsafe code creating a slice must prove the data pointer is valid for the stated element count, alignment and total byte size fit the allocation and language limits, elements are initialized, and aliasing permits the reference. Creating a trait object through unsupported representation tricks is not a valid optimization.

Separate object layout, pointee layout, and allocation layout

Three uses of “layout” often collapse into one claim:

  1. The pointer-like object’s representation: enough information to access its pointee, including metadata when required.
  2. The pointee’s layout: the sequence of slice elements, UTF-8 bytes, or the concrete value implementing a trait.
  3. The allocation’s layout: the size and alignment requested from an allocator, potentially including headers or neighboring data owned by an implementation.

size_of_val reports the runtime size of a pointee value under its Rust layout semantics. It does not reveal allocator bookkeeping, prove spare accessible capacity, or produce a serialization extent. A slice view may cover only part of a larger allocation. A trait-object view reports the concrete value’s dynamic size, not the total bytes held indirectly by its fields. For example, a String implementor has a sized handle whose heap buffer is separate.

This separation also prevents an optimization error: replacing Box<Concrete> with Box<dyn Trait> does not necessarily resize or rearrange the concrete allocation merely because the pointer now carries metadata. Allocation behavior depends on construction and conversion path; the public contract is ownership plus dynamic access, not a promised allocator trace.

At review time, ask which layer a claim describes and which source guarantees it. If the answer comes from printing size_of on one target, label it an observation and keep it out of ABI, persistence, and unsafe proofs unless the relevant representation attribute and platform contract make it guaranteed.

Failure modes to reject

  • Saying every “fat pointer” is exactly two words as a stable cross-target ABI promise.
  • Drawing slice length as a hidden header stored before the elements.
  • Calling String to str a direct unsizing conversion instead of a borrowed deref coercion.
  • Assuming T: ?Sized means T is definitely unsized.
  • Taking bare DST parameters or returning bare DST values where by-value layout is required.
  • Using Box<dyn Trait> by default when an enum, borrow, or generic boundary is clearer.
  • Removing dyn-incompatible methods instead of separating sized construction from erased operation.
  • Omitting Send, Sync, or lifetime bounds from an object that crosses tasks or is retained.
  • Passing trait objects or Rust slice representations directly through C ABI.
  • Persisting pointer or vtable bytes.
  • Building a custom DST before measuring whether separate header and tail allocations matter.
  • Inferring stable pointee layout from size_of_val observations.

Exercise: explain and redesign three boundaries

For each signature, explain which value is sized, which pointee may be dynamically sized, what metadata is required, who owns the allocation, and which coercion occurs:

fn parse(input: &[u8]) -> Result<&str, ParseError>;
fn render(item: &dyn Render) -> String;
fn install(item: Box<dyn Plugin + Send + Sync + 'static>);

Then compare these alternatives:

  1. Replace &dyn Render with a generic &R where R: Render and measure code size and hot-call latency.
  2. Replace a closed plugin trait object with an enum and document the compatibility consequence.
  3. Design a C-facing plugin boundary using an opaque handle and versioned functions without exposing Rust trait objects.
  4. Change a parser returning a borrowed &str into one that can queue work safely, comparing copy, shared backing owner plus range, and immediate processing.

The result must state failure and drop behavior, auto-trait/lifetime bounds, allocation count, compatibility surface, and evidence required before choosing. Include a diagram that places metadata in the pointer representation rather than the pointee.

DST review card

  • Is the type sized, or is it a DST behind a sized pointer-like value?
  • What metadata interprets the pointee: element count, byte length, or trait-object metadata?
  • Is the operation unsizing, deref coercion, conversion, or allocation?
  • Does a generic parameter need its default Sized bound, or genuinely support ?Sized?
  • Is the trait dyn compatible, and which methods remain Self: Sized?
  • Would static dispatch, an enum, or impl Trait preserve a stronger contract?
  • Are object lifetime and Send/Sync bounds explicit?
  • Is any observed pointer width or vtable detail being promoted into an ABI claim?
  • Does FFI use explicit C-compatible pointer/length/handle contracts?
  • Does unsafe construction prove extent, alignment, initialization, aliasing, and lifetime?
  • Are dispatch, allocation, code size, and retention costs measured where consequential?

What engineers may rely on

Ordinary generic parameters require Sized unless that bound is relaxed. Slices, str, and trait objects are dynamically sized and therefore appear behind supported pointer-like forms. Their metadata supplies information absent from the static pointee type: a slice element count, a string byte length, or dynamic trait-object information. The metadata belongs to the pointer representation model, not a promised header in the pointee.

Array-to-slice and concrete-to-trait-object coercions can add the appropriate metadata without copying the pointee. Borrowing a String as str instead uses deref coercion. ?Sized permits both sized and potentially unsized types where an indirect API can operate on them. These semantics support flexible APIs; they do not make Rust trait objects or fat-pointer layouts stable FFI or persistence formats.

The next problem is address sensitivity. A pointer may locate a value today, yet ordinary moves can relocate it. Chapter 31 asks when an API must guarantee that a value will not move and what Pin does—and does not—prove.

Sources and version notes