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. VTable generation: A #[repr(C)] VTable struct is generated containing function pointers for all trait methods (including supertraits), plus typeid and drop
  3. Symbol linking: The implementation crate exports a single VTable static per trait via a linker symbol; the proxy crate imports it and dispatches all method calls through the VTable

Think of it as compile-time monomorphization deferred to link time. Under LTO, the VTable is fully inlined and eliminated.

The proxy uses a fixed-size representation:

#[repr(C)]
#[cfg_attr(target_pointer_width = "32", repr(align(8)))]
struct Repr(*mut (), *mut ());

This is two pointers in size (16 bytes on 64-bit, 8 bytes on 32-bit) and 8-byte aligned on 32-bit targets, 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 — proxy side
/// 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);

const _: () = {
    // VTable struct: typeid + drop + one fn pointer per method
    #[repr(C)]
    struct __HelloVTable {
        typeid: ::extern_trait::__private::ConstTypeId,
        drop: unsafe fn(*mut HelloProxy),
        new: fn(i32) -> ::extern_trait::Repr,
        hello: fn(&HelloProxy),
    }

    // Import the VTable static via linker symbol
    unsafe extern "Rust" {
        #[link_name = "Symbol { ... }"]
        safe static VT: __HelloVTable;
    }

    // Dispatch trait methods through VTable
    impl Hello for HelloProxy {
        fn new(_0: i32) -> HelloProxy {
            HelloProxy((VT.new)(_0))
        }
        fn hello(&self) {
            (VT.hello)(self)
        }
    }

    impl Drop for HelloProxy {
        fn drop(&mut self) {
            unsafe { (VT.drop)(self) }
        }
    }

    // ... cast methods: from_impl, into_impl, downcast_ref, downcast_mut
};

// In crate B — impl side
struct HelloImpl(i32);

impl Hello for HelloImpl {
    fn new(num: i32) -> Self { Self(num) }
    fn hello(&self) { println!("Hello, {}", self.0) }
}

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

// Export VTable static with matching layout
const _: () = {
    #[repr(C)]
    struct __HelloVTable {
        typeid: ::extern_trait::__private::ConstTypeId,
        drop: unsafe fn(*mut HelloImpl),
        new: fn(i32) -> ::extern_trait::Repr,
        hello: fn(&HelloImpl),
    }

    #[unsafe(export_name = "Symbol { ... }")]
    static VT: __HelloVTable = __HelloVTable {
        typeid: ::extern_trait::__private::ConstTypeId::of::<HelloImpl>(),
        drop: |this: *mut HelloImpl| unsafe { ::core::ptr::drop_in_place(this) },
        new: |_0: i32| {
            let __result = <HelloImpl as Hello>::new(_0);
            unsafe { ::extern_trait::Repr::from_value(__result) }
        },
        hello: |_0: &HelloImpl| { <HelloImpl as Hello>::hello(_0) },
    };
};

§Performance

#[extern_trait] dispatches through a static VTable — a #[repr(C)] struct of function pointers linked across crates. Without LTO, these remain indirect calls. With LTO enabled (lto = "thin" is sufficient), the compiler inlines the VTable entirely, eliminating all indirection and achieving true zero overhead.

Add this to your Cargo.toml:

[profile.release]
lto = "thin"

Verified behavior under lto = "thin":

  • All VTable function pointer calls are inlined and optimized as if they were direct calls
  • Unused trait methods are eliminated by dead code elimination
  • The VTable static itself is removed from the final binary

Without LTO, every method call goes through a function pointer (call *(%rip)), similar to dyn Trait dispatch. This is the expected cost of cross-crate opaque linking.

In debug builds (opt-level = 0), there is additional overhead from Repr::from_value and Repr::into_value: these are zero-cost transmutes that normally compile to pure register moves (see Internals), but without optimization the compiler materializes them as stack round-trips. This disappears at opt-level >= 1.

§Trait Restrictions

  • No generics on the trait itself
  • Only methods allowed (no associated types or constants)
  • Methods must be FFI-compatible: no const, async, generic parameters, or non-Rust ABI
  • Self in signatures must be one of: Self, &Self, &mut Self, *const Self, *mut Self

§Size and Alignment Constraints

The implementation type must fit within Repr and must not require stricter alignment:

PlatformRepr sizeRepr alignmentMax impl sizeMax impl alignment
64-bit16 bytes8 bytes16 bytes8 bytes
32-bit8 bytes8 bytes8 bytes8 bytes

These constraints are 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 up to 64 bits: integers, floats, bools

For larger or over-aligned 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
UnpinDisplay
CopyPartialEq
EqPartialOrd
UnwindSafeOrd
RefUnwindSafeAsRef<T>
FreezeAsMut<T>
Borrow<T>
BorrowMut<T>
use std::fmt::Debug;
use extern_trait::extern_trait;

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

§Copy Supertrait

When a trait includes Copy as a supertrait, the proxy type will also implement Copy. No Drop implementation is generated for Copy proxy types, since Copy types cannot have custom drop behavior in Rust.

use extern_trait::extern_trait;

#[extern_trait(CopyProxy)]
trait CopyApi: Clone + Copy {
    fn new(v: u8) -> Self;
    fn value(&self) -> u8;
}

#[extern_trait]
impl CopyApi for u8 {
    fn new(v: u8) -> Self { v }
    fn value(&self) -> u8 { *self }
}

// CopyProxy implements Copy - can be freely copied by value
let a = CopyProxy::new(42);
let b = a;  // copied, not moved
assert_eq!(a.value(), 42);  // a is still valid

assert!(!core::mem::needs_drop::<CopyProxy>());  // no Drop

§Experimental Weak Defaults

Enable the nightly-weak feature to attach a weak default implementation to a trait definition. The defining crate must be compiled on nightly and opt into Rust’s unstable linkage feature:

#![feature(linkage)]

use extern_trait::extern_trait;

struct DefaultConsole;

#[extern_trait(default = DefaultConsole, ConsoleProxy)]
trait Console {
    fn new() -> Self;
    fn write(&self, bytes: &[u8]);
}

impl Console for DefaultConsole {
    fn new() -> Self { Self }
    fn write(&self, _bytes: &[u8]) {}
}

If no strong #[extern_trait] impl is linked, ConsoleProxy dispatches to DefaultConsole. A strong implementation from another crate overrides the weak default at link time:

struct UartConsole;

#[extern_trait]
impl Console for UartConsole {
    fn new() -> Self { Self }
    fn write(&self, bytes: &[u8]) {
        // write to hardware
    }
}

The default type follows the same restrictions as a normal implementation type: it must be concrete, fit in Repr, satisfy alignment limits, and implement the trait and supported supertraits. Do not define the weak default and a strong implementation in the same crate; Rust reports a duplicate exported symbol before the linker can choose the strong definition.

This feature inherits the portability limits of Rust’s unstable #[linkage = "weak"] support. Rust currently treats linkage as platform- and backend-specific; weak symbols may be rejected or behave differently on some target/linker combinations, especially outside ELF-style targets. extern-trait does not define a support matrix. Verify this feature on each target you ship, and gate it in your own crate if a target does not support Rust’s current weak-linkage behavior.

§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

§VTable Layout

The proxy imports a VTable symbol whose function pointer types mention the proxy type. Each implementation exports the same #[repr(C)] field layout with its concrete implementation type in those pointer signatures. Weak defaults use the same layout contract.

§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