#[extern_trait]
Opaque types for traits using link-time static dispatch instead of dyn Trait.
Motivation
In modular systems like OS kernels, a common pattern emerges: crate A needs to call functionality that crate B provides, but A cannot depend on B (to avoid circular dependencies or to keep A generic). Examples include:
- A logging crate that needs platform-specific console output
- A filesystem crate that needs a block device driver
- A scheduler that needs architecture-specific context switching
The traditional solution is Box<dyn Trait>, but this has drawbacks:
- Heap allocation for every trait object
- Vtable indirection on every method call
- Runtime overhead that may be unacceptable in performance-critical code
#[extern_trait] solves this by acting as a static vtable - method calls are resolved at link time rather than runtime, with zero heap allocation and no pointer indirection.
How it Works
- Proxy generation: The macro creates a fixed-size proxy struct that stores the implementation value inline
- Symbol export: Each trait method is exported as a linker symbol from the implementation crate
- Symbol linking: The proxy calls these symbols, which the linker resolves to the actual implementation
Think of it as compile-time monomorphization deferred to link time.
The proxy uses a fixed-size representation:
, *const );
This is two pointers in size (16 bytes on 64-bit, 8 bytes on 32-bit), storing the implementation value directly - no heap allocation or pointer indirection is added by the macro.
Example
# use extern_trait;
// In crate A
/// A Hello trait.
let v = new;
v.hello;
// In crate B
;
// In crate A
/// A Hello trait.
/// A proxy type for [`Hello`].
pub ;
// In crate B
;
const _: = ;
const _: = ;
const _: = ;
const _: = ;
Trait Restrictions
- No generics on the trait itself
- Only methods allowed (no associated types or constants)
- Methods must be FFI-compatible: no
const,async, or generic parameters Selfin signatures must be one of:Self,&Self,&mut Self,*const Self,*mut Self
Size Constraint
The implementation type must fit within Repr, which is two pointers in size:
| Platform | Repr size |
Max impl size |
|---|---|---|
| 64-bit | 16 bytes | 16 bytes |
| 32-bit | 8 bytes | 8 bytes |
This constraint is checked at compile time. Types that fit include:
- Pointer-sized types:
Box<T>,Arc<T>,&T,*const T - Small structs: up to two
usizefields - Primitives: integers, floats, bools
For larger types, wrap them in Box.
Supertraits
An #[extern_trait] can have supertraits, and the macro will automatically forward their implementations to the proxy type.
Supported supertraits:
| Marker traits | Standard traits |
|---|---|
Send |
Clone |
Sync |
Default |
Sized |
Debug |
Unpin |
AsRef<T> |
Copy |
AsMut<T> |
use Debug;
use extern_trait;
Re-exporting / Renaming
By default, the macro references ::extern_trait. If you re-export or rename the crate, use the crate attribute to specify the correct path:
use extern_trait as my_extern_trait;
use extern_trait;
// Specify the path when defining a trait
;
// Also specify the path when implementing
This is also necessary if you rename the dependency in Cargo.toml:
[]
= { = "extern-trait", = "..." }
Internals
Why Two Pointers?
The Repr type is two pointers in size based on a key observation: most calling conventions pass structs up to two registers by value in registers, not on the stack.
On x86_64, ARM64, RISC-V, and other common architectures, a two-pointer struct is passed and returned in two registers (e.g., rdi+rsi/rax+rdx on x86_64, x0+x1 on ARM64). This means:
- No memory traffic: Values stay in registers across function calls
- Zero-cost conversion:
Repr::from_valueandRepr::into_valuecompile to nothing
For example, on x86_64:
; from_value<Box<T>> - the Box pointer is already in rdi, just move to rax
mov rax, rdi
ret
On architectures that don't pass two-pointer structs in registers, this still works correctly - just with a small memory copy instead of pure register operations. The design prioritizes the common case while remaining portable.
What Fits in Repr?
| Type | Size (64-bit) | Fits? |
|---|---|---|
Box<T>, Arc<T>, Rc<T> |
8 bytes | ✓ |
&T, *const T |
8 bytes | ✓ |
(usize, usize) |
16 bytes | ✓ |
&[T], &str (fat pointers) |
16 bytes | ✓ |
String, Vec<T> |
24 bytes | ✗ (use Box) |
Two pointers is the sweet spot: it covers fat pointers, smart pointers, and small structs - the types you'd typically use to implement a trait.
Credits
This crate is inspired by crate_interface.