Skip to main content

Crate extern_trait

Crate extern_trait 

Source
Expand description

§#[extern_trait]

Crates.io docs.rs

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

  1. Proxy generation: The macro creates a fixed-size proxy struct that stores the implementation value inline
  2. Symbol export: Each trait method is exported as a linker symbol from the implementation crate
  3. 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:

#[repr(C)]
struct Repr(*const (), *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

// In crate A
/// A Hello trait.
#[extern_trait(
    /// A proxy type for [`Hello`].
    pub(crate) HelloProxy
)]
pub trait Hello {
    fn new(num: i32) -> Self;
    fn hello(&self);
}

let v = HelloProxy::new(42);
v.hello();

// In crate B
struct HelloImpl(i32);

#[extern_trait]
impl Hello for HelloImpl {
    fn new(num: i32) -> Self {
        Self(num)
    }

    fn hello(&self) {
        println!("Hello, {}", self.0)
    }
}
View generated code
// In crate A
/// A Hello trait.
pub trait Hello {
    fn new(num: i32) -> Self;
    fn hello(&self);
}

/// A proxy type for [`Hello`].
#[repr(transparent)]
pub(crate) struct HelloProxy(::extern_trait::Repr);

impl Hello for HelloProxy {
    fn new(_0: i32) -> Self {
        unsafe extern "Rust" {
            #[link_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"new\" }"]
            unsafe fn new(_: i32) -> HelloProxy;

        }
        unsafe { new(_0) }
    }

    fn hello(&self) {
        unsafe extern "Rust" {
            #[link_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"hello\" }"]
            unsafe fn hello(_: &HelloProxy);
        }
        unsafe { hello(self) }
    }
}

impl Drop for HelloProxy {
    fn drop(&mut self) {
        unsafe extern "Rust" {
            #[link_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"drop\" }"]
            unsafe fn drop(this: *mut HelloProxy);
        }
        unsafe { drop(self) }
    }
}

// In crate B
struct HelloImpl(i32);

impl Hello for HelloImpl {
    fn new(num: i32) -> Self {
        Self(num)
    }

    fn hello(&self) {
        println!("Hello, {}", self.0)
    }
}

const _: () = {
    assert!(
        ::core::mem::size_of::<HelloImpl>() <= ::core::mem::size_of::<::extern_trait::Repr>(),
        "HelloImpl is too large to be used with #[extern_trait]"
    );
};

const _: () = {
    #[unsafe(export_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"new\" }")]
    fn new(_0: i32) -> ::extern_trait::Repr {
        ::extern_trait::Repr::from_value(<HelloImpl as Hello>::new(_0))
    }
};
const _: () = {
    #[unsafe(export_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"hello\" }")]
    fn hello(_0: &HelloImpl) {
        <HelloImpl as Hello>::hello(_0)
    }
};
const _: () = {
    #[unsafe(export_name = "Symbol { ..., trait_name: \"Hello\", ..., name: \"drop\" }")]
    unsafe fn drop(this: &mut HelloImpl) {
        unsafe { ::core::ptr::drop_in_place(this) };
    }
};

§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
  • Self in 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:

PlatformRepr sizeMax impl size
64-bit16 bytes16 bytes
32-bit8 bytes8 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 usize fields
  • 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 traitsStandard traits
SendClone
SyncDefault
SizedDebug
UnpinAsRef<T>
CopyAsMut<T>
use std::fmt::Debug;
use extern_trait::extern_trait;

#[extern_trait(ResourceProxy)]
trait Resource: Send + Sync + Clone + Debug {
    fn new() -> Self;
}

§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 my_extern_trait::extern_trait;

// Specify the path when defining a trait
#[extern_trait(crate = my_extern_trait, MyProxy)]
trait MyTrait {
    fn new() -> Self;
}

struct MyImpl;

// Also specify the path when implementing
#[extern_trait(crate = my_extern_trait)]
impl MyTrait for MyImpl {
    fn new() -> Self { MyImpl }
}

This is also necessary if you rename the dependency in Cargo.toml:

[dependencies]
my_extern_trait = { package = "extern-trait", version = "..." }

§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_value and Repr::into_value compile 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?

TypeSize (64-bit)Fits?
Box<T>, Arc<T>, Rc<T>8 bytes
&T, *const T8 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.

Attribute Macros§

extern_trait