Trait Objects
Trait objects (dyn Trait) provide runtime polymorphism when you genuinely need it. But they come with costs—heap allocation, vtable lookups, and no inlining. Use generics or enums by default. Only reach for dyn when you have a specific reason.
When to Use dyn (and When Not To)
Don't Use dyn When:
- All types are known at compile time → Use generics
- You have a closed set of variants → Use enums
- Performance is critical → Use generics (zero-cost)
- You want exhaustive matching → Use enums
Use dyn When:
- Plugin systems → Types loaded at runtime
- User-defined extensions → Unknown implementors
- FFI boundaries → Interacting with external code
- Reducing binary size → One function instead of monomorphized copies
If you're reaching for dyn in application code, pause and ask: "Could I use an enum or generic here instead?" Usually the answer is yes.
The Costs
When you use dyn, you pay for:
- Heap allocation — Trait objects are stored on the heap
- Vtable lookup — Each method call goes through a pointer (2-3ns overhead)
- No inlining — The compiler can't optimize across the trait boundary
- Object safety constraints — Some trait methods can't be used
These costs are small, but they're not zero. Generics and enums have none of these costs.
The Core Idea
A trait object says "I have something that implements this trait, but I don't know what it is specifically."
// Generic - compile-time dispatch, type must be known (PREFER THIS)
fn process<T: Speaker>($thing T) {
$thing.speak();
}
// Trait object - runtime dispatch, any Speaker works (OPT-IN)
fn process_any($thing dyn Speaker) {
$thing.speak();
}
That dyn keyword is an explicit choice. It says: "I'm willing to pay runtime costs for runtime flexibility." This is called type erasure—the specific type gets erased, leaving only the trait's interface.
Implicit Storage
Coco manages trait object storage automatically—just like it does for parameter passing and strings. You write dyn Trait, and the compiler figures out the rest.
The Rules
- Parameters: Passed by reference automatically (like large structs)
- Return values: Heap-allocated automatically
- Struct fields: Heap-allocated automatically
- Shared ownership: Reference-counted automatically (like strings)
This matches Coco's philosophy: smart defaults, no ceremony. You focus on what you're doing with the trait object, not how it's stored.
Examples
// Parameter - compiler borrows it
fn describe($item dyn Describable) {
echo $item.describe();
}
// Return value - compiler heap-allocates it
fn create_speaker($is_dog bool): dyn Speaker {
if $is_dog {
return Dog { name: "Rex" };
} else {
return Cat { name: "Whiskers" };
}
}
// Struct field - compiler heap-allocates it
type Canvas {
shapes [dyn Drawable]
}
// Shared across threads - compiler uses ARC
fn spawn_handler($handler dyn Handler) {
spawn(|| { $handler.handle($request); });
spawn(|| { $handler.handle($request); });
}
No Box, no Arc, no &—just dyn Trait. The compiler handles the plumbing.
Why Would You Want This?
Let's make it concrete. Say you're building a game with different entities:
trait Updatable {
fn update($delta f64);
}
type Player {
name string
health i32
@Updatable
fn update($delta f64) {
// Player update logic
}
}
type Enemy {
kind string
damage i32
@Updatable
fn update($delta f64) {
// Enemy AI logic
}
}
type NPC {
dialogue string
@Updatable
fn update($delta f64) {
// NPC behavior
}
}
Now you want to update all entities in your game loop. With generics, you're stuck:
// Can't do this - all items must be the same type
$entities: Vec<???> = vec![$player, $enemy, $npc];
Trait objects solve it:
$entities: [dyn Updatable] = [$player, $enemy, $npc];
// One loop to update them all
for $entity in $entities {
$entity.update($delta);
}
The concrete types are different, but they all implement Updatable, and that's all we need.
How Trait Objects Work
When you create a trait object, the compiler builds what's called a vtable (virtual method table). It's a lookup table of function pointers—one for each method in the trait.
A trait object is actually two pointers:
- A pointer to the data (the actual instance)
- A pointer to the vtable (how to call methods on it)
// Conceptually, a trait object looks like:
type TraitObject {
data *void // Pointer to the actual data
vtable *VTable // Pointer to method lookup table
}
When you call a method on a trait object, the runtime:
- Looks up the method in the vtable
- Calls it with the data pointer
This is called dynamic dispatch—the actual function to call is determined at runtime.
Object Safety
Not every trait can be turned into a trait object. The trait must be object-safe. This matters because the compiler needs to build that vtable, and some things can't be represented in one.
What Makes a Trait Object-Safe?
A trait is object-safe if all its methods:
- Don't return
Selfby value - Don't have generic parameters
- Take
selfby reference (not by value)
Let's see why these rules exist.
Rule 1: No Returning Self by Value
// NOT object-safe
trait Cloneable {
fn clone(&$this): Self;
}
Why? When you call clone() on a dyn Cloneable, what size should the return value be? It could be a 4-byte Point or a 1MB BigStruct. The compiler can't know, so it can't generate the code.
Workaround: Return a trait object instead:
// Object-safe version
trait Cloneable {
fn clone_dyn(&$this): dyn Cloneable;
}
Rule 2: No Generic Parameters
// NOT object-safe
trait Serializer {
fn serialize<T: Serialize>(&$this, $value T): string;
}
Why? Each instantiation of a generic method would need its own vtable entry. With infinite possible types, you'd need infinite entries. Not gonna work.
Workaround: Use trait objects for the parameter too:
// Object-safe version
trait Serializer {
fn serialize(&$this, $value dyn Serialize): string;
}
Rule 3: Methods Must Take Self by Reference
// NOT object-safe
trait Consumable {
fn consume($this); // Takes self by value
}
Why? Moving out of a trait object would require knowing the size of the underlying data, which we've erased.
Checking Object Safety
The compiler will tell you if you try to use a non-object-safe trait as a trait object:
trait NotObjectSafe {
fn clone(&$this): Self;
}
// Error: the trait NotObjectSafe cannot be made into an object
fn use_it($x dyn NotObjectSafe) {
// ...
}
Making Traits Object-Safe
Sometimes you can have your cake and eat it too. Keep the trait object-safe while still providing convenient methods:
trait Cloneable {
// This method makes the trait not object-safe
fn clone(&$this): Self where Self: Sized;
// This one is object-safe
fn clone_dyn(&$this): dyn Cloneable;
}
The where Self: Sized bound excludes the method from the trait object's vtable. You can still call clone() on concrete types, but not through a trait object.
Multiple Trait Bounds
Sometimes you need an object that implements multiple traits:
fn process($item dyn Debug + Display) {
echo "{:?}", $item; // Uses Debug
echo "{}", $item; // Uses Display
}
The syntax combines traits with +. The resulting trait object has vtable entries for all methods from all traits.
For complex bounds, use type aliases:
type Debuggable = dyn Debug + Display + Clone;
fn process($item Debuggable) {
// ...
}
Trait Objects vs Generics
This is the big decision. Let's compare them thoroughly.
Generics: Static Dispatch
fn process<T: Drawable>($item T) {
$item.draw();
}
Pros:
- Zero runtime overhead—method calls are direct
- Compiler can inline and optimize
- Full type information available at compile time
Cons:
- Code bloat—compiler generates a version for each type
- Type must be known at compile time
- Can't have heterogeneous collections
Trait Objects: Dynamic Dispatch
fn process($item dyn Drawable) {
$item.draw();
}
Pros:
- One version of the function for all types
- Heterogeneous collections work
- Types can be determined at runtime
Cons:
- Vtable lookup on every call (usually a few nanoseconds)
- Compiler can't inline across the trait boundary
- Limited by object safety rules
Performance Comparison
The overhead of dynamic dispatch is often overstated. Let's be precise:
// Direct call (generics): ~0.5ns
// Indirect call (trait object): ~2-3ns
That's 2-3 nanoseconds. For most code, this is noise. The overhead matters when:
- You're in a hot loop calling millions of times
- The method body is trivial (e.g., just returns a field)
If your method does any real work—allocations, I/O, computations—the vtable lookup is lost in the noise.
Decision Matrix
| Use Case | Recommendation |
|---|---|
| All types known at compile time | Generics |
| Hot loop, performance-critical | Generics |
| Heterogeneous collections | Trait objects |
| Plugin/extension systems | Trait objects |
| Reducing binary size | Trait objects |
| Returning different types from functions | Trait objects |
| FFI boundaries | Trait objects |
Trait Objects vs Enums
There's a third option: enums. If you have a closed set of variants, enums are often better than trait objects.
// Trait object approach
trait Shape {
fn area(&$this): f64;
}
$shapes: [dyn Shape] = [...];
// Enum approach
enum Shape {
Circle { radius f64 },
Square { side f64 },
Rectangle { width f64, height f64 }
}
type Shape {
fn area(&$this): f64 {
match $this {
Circle { radius } => 3.14159 radius radius,
Square { side } => side * side,
Rectangle { width, height } => width * height
}
}
}
When to Use Enums
- Closed set: You know all variants upfront
- No external extensions: Only you add new variants
- Pattern matching: You want exhaustive matching
- Stack allocation: No heap allocation needed
When to Use Trait Objects
- Open set: Others can add new types
- External extensions: Plugins, user-defined types
- Unknown at compile time: Types determined at runtime
- API boundaries: Hide implementation details
Common Patterns
The Handler Pattern
Perfect for callbacks and event systems:
trait Handler {
fn handle($request Request): Response;
}
type Router {
routes HashMap<string, dyn Handler>
fn register($path string, $handler dyn Handler) {
$this.routes.insert($path, $handler);
}
fn dispatch($path string, $request Request): ?Response {
match $this.routes.get($path) {
Some($handler) => Some($handler.handle($request)),
None => None
}
}
}
The Strategy Pattern
Swap behavior at runtime:
trait Compressor {
fn compress($data &[u8]): [u8];
}
type FileWriter {
compressor dyn Compressor
fn set_compressor($compressor dyn Compressor) {
$this.compressor = $compressor;
}
fn write($data &[u8]) {
$compressed = $this.compressor.compress($data);
// Write compressed data...
}
}
The Builder Pattern
Return trait objects to hide implementation:
trait Database {
fn query($sql string): !Rows;
}
fn connect($url string): !dyn Database {
if $url.starts_with("postgres://") {
return Ok(PostgresDb::connect($url)?);
} else if $url.starts_with("mysql://") {
return Ok(MysqlDb::connect($url)?);
} else {
return Err("Unknown database type");
}
}
// Caller doesn't know or care which database type
$db = connect($config.database_url)?;
$rows = $db.query("SELECT * FROM users")?;
The Registry Pattern
Collect implementations at runtime:
trait Plugin {
fn name(&$this): string;
fn execute(&$this);
}
type PluginRegistry {
plugins [dyn Plugin]
fn register($plugin dyn Plugin) {
$this.plugins.push($plugin);
}
fn run_all {
for $plugin in $this.plugins {
echo "Running plugin: {$plugin.name()}";
$plugin.execute();
}
}
}
Downcasting
Sometimes you need to get back to the concrete type. Coco supports downcasting:
trait Any {
fn type_id(&$this): TypeId;
}
fn downcast<T: 'static>($obj dyn Any): ?T {
// Returns Some(T) if the type matches, None otherwise
}
$speaker: dyn Speaker = Dog { name: "Rex" };
// Try to downcast to Dog
if let Some($dog) = downcast::<Dog>($speaker) {
echo "It's a dog named {$dog.name}";
} else {
echo "Not a dog";
}
Use downcasting sparingly. If you're downcasting a lot, you probably want enums or a different design. Downcasting works against the abstraction that trait objects provide.
Coercion and Supertraits
Trait objects can be coerced to "wider" trait objects:
trait Animal {
fn breathe(&$this);
}
trait Dog requires Animal {
fn bark(&$this);
}
// Can coerce dyn Dog to dyn Animal
fn takes_animal($a dyn Animal) {
$a.breathe();
}
$dog: dyn Dog = $some_dog;
takes_animal($dog); // Coerced to dyn Animal
The trait object carries the vtable for both Dog and Animal methods, so it can be "narrowed" to just the Animal interface.
Advanced: Custom Vtables
In rare cases, you might want to work with vtables directly. This is advanced territory:
// Manually constructing a trait object (usually for FFI)
type DynSpeaker {
data *void
vtable *SpeakerVTable
}
type SpeakerVTable {
speak fn(*void)
drop fn(*void)
}
This is implementation detail—don't rely on the exact layout. But understanding it helps demystify trait objects.
Best Practices
Prefer generics by default. Use trait objects when you need runtime flexibility. Generics give you better performance and compile-time checking.
Design for object safety. If your trait might need trait objects, make it object-safe from the start. It's easier than refactoring later.
Use trait objects at API boundaries. They're great for hiding implementation details and allowing users to provide their own implementations.
Don't over-abstract. Trait objects add complexity. If you only have two or three types, an enum is simpler.
Document behavior, not implementation. When you accept a trait object, document what the trait requires, not what concrete types you expect.
/// Processes any drawable item.
///
/// The drawable must be ready to render—this function
/// does not handle initialization.
fn render($item dyn Drawable) {
$item.draw();
}
Trust the optimizer. The performance difference between generics and trait objects is often smaller than you'd think. Profile before optimizing.
Summary
Trait objects give you runtime polymorphism—the ability to work with different types through a common interface when you don't know (or don't care about) the concrete type.
Coco handles the storage details automatically: parameters are borrowed, fields and returns are heap-allocated, and shared ownership is reference-counted. You just write dyn Trait and focus on your logic.
They come with trade-offs: a small runtime cost and object safety constraints. But they enable patterns that are impossible with generics alone: heterogeneous collections, plugin systems, and dynamic configuration.
Use generics when you can, trait objects when you must. And remember—the decision isn't permanent. You can often refactor from one to the other as your needs change.