Type-erasing dyn traits in Rust
Introduction
In Rust, Box<dyn Any>
lets you store any type and recover it later, but what if you don’t want the concrete type? What if you want to go back to a Box<dyn SomeTrait>
instead?
In this post, I’ll show how to type-erase dyn
trait objects - including dyn Fn
- so that you can store them and call them later.
A concrete illustration of the problem comes from a previous post about typed-eval
, where I stored a Box<dyn Fn(&A) -> R>
inside a Box<dyn Any>
.
The goal is to have a type-erased function that can be downcast back to something callable. Ideally, the interface would look like this:
struct TypeErasedFn { .. }impl TypeErasedFn { fn new<A, R>(f: impl Fn(A) -> R) -> Self { .. } fn downcast_ref<A, R>(&self) -> &dyn Fn(A) -> R { .. }}
Why Box<dyn Any>
Alone Isn’t Enough
The first thing that comes to mind when you need type erasure in Rust is Box<dyn Any>
.
We can indeed put a function or closure into a Box<dyn Any>
:
let boxed_fn: Box<dyn Any> = Box::new(|a: i32| a * 2);
However, we cannot downcast it back to a function or closure type, because each closure has a unique, unnamed type in Rust. downcast_ref
requires knowing the exact type at compile time, which we don’t have here:
impl dyn Any { pub fn downcast_ref<T: Any>(&self) -> Option<&T> { .. }}
As a side note: for pure functions, you could theoretically use function pointers, since they have named types and can be cast to a common type. But this does not work for closures.
Using Box<dyn Fn>
Inside Box<dyn Any>
We can, however, store a function or closure in a Box<dyn Fn>
and then put that inside a Box<dyn Any>
. A Box<dyn Fn>
is a callable type that can hold any function or closure matching its argument and return types—for example, Box<dyn Fn(i32) -> i32>
can store any closure or function that takes an i32
and returns an i32
.
By storing the Box<dyn Fn>
inside a Box<dyn Any>
, we achieve type erasure while still being able to recover a callable reference later. Here’s how it works:
struct TypeErasedFn(Box<dyn Any>);impl TypeErasedFn { fn new<A: 'static, R: 'static>(f: impl Fn(A) -> R + 'static) -> Self { let boxed_fn: Box<dyn Fn(A) -> R> = Box::new(f); Self(Box::new(boxed_fn)) } fn downcast_ref<A: 'static, R: 'static>(&self) -> Option<&dyn Fn(A) -> R> { let boxed_fn = self.0.downcast_ref::<Box<dyn Fn(A) -> R>>()?; Some(boxed_fn.as_ref()) }}
This approach succeeds, but I was still wondering if we could avoid the double boxing and the extra indirection it introduces. Why can’t we store a function inside a Box<dyn Any>
and then downcast it directly to Box<dyn Fn>
?
Eventually, I decided to explore why this isn’t possible and whether we can avoid the double allocation and double indirection caused by double boxing.
Pointers and Fat Pointers in Rust
More generally: why can’t we convert from Box<dyn Any>
to Box<dyn SomeTrait>
? The Box<dyn Fn>
case is just a specific instance of this broader question.
Let’s start with the basics. What is a Box
? It’s a smart pointer that owns heap-allocated data. It allocates memory on Box::new
and frees it when dropped.
What is a pointer in Rust? Turns out it is more than just an address in memory. Some pointers, like pointers to str
, slices, or, what we are interested in, trait objects, are so-called fat pointers. They store both an address in memory and some additional metadata: for str
and slices, the length of the data; for dyn Trait
, a pointer to the vtable. A vtable (virtual table, familiar to those who have used C++) is a table of functions implementing the trait, stored in a uniform manner, which allows us to call trait methods without knowing the concrete type.
Thus, a pointer to dyn Trait
contains two pieces of information: a data pointer and a vtable pointer.
Why Box<dyn Any> --> Box<dyn Trait>
Doesn’t Work
How does Box<dyn Any>
work? It stores the type ID of the contained data. When downcasting Box<dyn Any>
to a concrete type, it checks if the type IDs match and discards the fat pointer metadata — the dyn Any
vtable pointer.
If we want to downcast to a pointer to dyn SomeTrait
, we don’t just need to discard the Any
vtable pointer; we need to replace it with the vtable pointer for SomeTrait
. But Any
doesn’t store this, so converting from Box<dyn Any>
to Box<dyn SomeTrait>
directly is impossible.
Manipulating Fat Pointers Manually
However, we can do this manually, on nightly Rust. Rust has an API for manipulating fat pointers, hidden behind the #![feature(ptr_metadata)]
feature. It provides functions to split a fat pointer into the data pointer and metadata, and to recombine them, namely to_raw_parts
and from_raw_parts
.
For dyn SomeTrait
, the metadata type is DynMetadata<T>
where T
is dyn SomeTrait
. Documentation here explains this.
For each trait, there is a different DynMetadata<T>
type. This matters because we want to store the metadata without referring to the specific type T
. The documentation states that DynMetadata<T>
is essentially a pointer to a vtable, and we can confirm this by looking at the source code:
pub struct DynMetadata<Dyn: PointeeSized> { _vtable_ptr: NonNull<VTable>, _phantom: crate::marker::PhantomData<Dyn>,}
Thus, it’s safe to convert it to a *const ()
and back to DynMetadata
if we ensure the type matches.
Type-Erased Trait Objects Implementation
Here’s the TypeErasedBox
implementation I ended up with:
// !! Some details stripped for readility here !!
/// A type-erased `Box<dyn Trait>`./// Similar to `Box<dyn Any>`, but stores trait objects instead of concrete types.struct TypeErasedBox { data_pointer: *mut (), // pointer to the actual data stored in the box metadata: *const (), // pointer to the trait object's metadata (vtable) type_id: TypeId, // the TypeId of the concrete type stored (TypeId::of::<dyn Trait>()) drop_fn: Option<fn(&mut Self)>, // function to free the heap-allocated memory}
impl TypeErasedBox { /// Constructs a TypeErasedBox by erasing the concrete type of `Box<dyn Trait>`. /// The type parameter `T` represents the `dyn Trait` being erased. pub fn new<T>(box_dyn: Box<T>) -> Self { let (data_pointer, metadata) = Box::into_raw(box_dyn).to_raw_parts(); let metadata: *const () = unsafe { std::mem::transmute(metadata) }; .. TypeErasedBox { .. } }
/// Attempts to downcast the stored type back to `&dyn Trait`. /// Returns `None` if the stored type does not match. pub fn downcast_ref<T>(&self) -> Option<&T> { if self.type_id != TypeId::of::<T>() { return None } let metadata: DynMetadata<T> = unsafe { std::mem::transmute(self.metadata) }; let ptr = std::ptr::from_raw_parts_mut(self.data_pointer, metadata); Some(unsafe { &*ptr }) }}
Full source code for reference
#![feature(ptr_metadata)]
use std::{ any::TypeId, ptr::{DynMetadata, Pointee},};
/// A type-erased `Box<dyn Trait>`./// Similar to `Box<dyn Any>`, but stores trait objects instead of concrete types.struct TypeErasedBox { data_pointer: *mut (), // pointer to the actual data stored in the box metadata: *const (), // pointer to the trait object's metadata (vtable) type_id: TypeId, // the TypeId of the concrete type stored (TypeId::of::<dyn Trait>()) drop_fn: Option<fn(&mut Self)>, // function to free the heap-allocated memory}
impl Drop for TypeErasedBox { fn drop(&mut self) { // call the drop function exactly once when the TypeErasedBox is dropped if let Some(drop_fn) = self.drop_fn.take() { drop_fn(self) } }}
impl TypeErasedBox { /// Constructs a TypeErasedBox by erasing the concrete type of `Box<dyn Trait>`. /// The type parameter `T` represents the `dyn Trait` being erased. pub fn new<T>(box_dyn: Box<T>) -> Self where T: Pointee<Metadata = DynMetadata<T>> + ?Sized + 'static, { let (data_pointer, metadata) = Box::into_raw(box_dyn).to_raw_parts();
// SAFETY: Erasing `DynMetadata<T>` into `*const ()`. // Invariant: `DynMetadata<T>` is represented as a pointer-sized vtable reference // on current compilers. We rely on that representation here (nightly-only `ptr_metadata`). // This is an implementation detail and not a stable language guarantee. let metadata: *const () = unsafe { std::mem::transmute(metadata) }; let type_id = TypeId::of::<T>();
// Reconstructs the original `Box<T>` and drops it safely. // Ownership is transferred back to Rust exactly once, ensuring proper memory management. fn drop_fn<T>(me: &mut TypeErasedBox) where T: Pointee<Metadata = DynMetadata<T>> + ?Sized + 'static, { let ptr = me.as_ptr_impl::<T>();
// SAFETY: `ptr` was produced by `Box::into_raw` in `new` and is consumed here. // We reconstruct the original `Box<T>` exactly once, transferring ownership back to Rust so it is dropped normally. let box_dyn = unsafe { Box::from_raw(ptr) }; drop(box_dyn); }
TypeErasedBox { data_pointer, metadata, type_id, drop_fn: Some(drop_fn::<T>), } }
/// Attempts to downcast the stored type back to `&dyn Trait`. /// Returns `None` if the stored type does not match. pub fn downcast_ref<T>(&self) -> Option<&T> where T: Pointee<Metadata = DynMetadata<T>> + ?Sized + 'static, { if self.type_id != TypeId::of::<T>() { return None; }
let ptr = self.as_ptr_impl::<T>();
// SAFETY: The reconstructed pointer refers to the unique `Box` // owned by this container, so creating a shared reference is valid. Some(unsafe { &*ptr }) } // Internal helper that reconstructs a pointer to the stored type. // Should only be called when the type matches. fn as_ptr_impl<T>(&self) -> *mut T where T: Pointee<Metadata = DynMetadata<T>> + ?Sized + 'static, { assert_eq!(self.type_id, TypeId::of::<T>());
// SAFETY: We are reinterpreting the stored erased metadata as `DynMetadata<T>`. // Invariant: `metadata` came from `DynMetadata<T>` in `new`, and we only // ever call this for the matching `T`. let metadata: DynMetadata<T> = unsafe { std::mem::transmute(self.metadata) };
std::ptr::from_raw_parts_mut(self.data_pointer, metadata) }}
This struct allows storing any trait object in a type-erased way and recovering it later via downcast_ref
. Internally, it works by breaking the Box<dyn Trait>
into its two components: the data pointer and the metadata pointer (which points to the vtable). These are stored in a type-erased form (*mut ()
for data and *const ()
for metadata), and the original Box<dyn Trait>
is reconstructed on demand when downcast_ref
is called.
Type-Erased dyn Fn
Now, we can use TypeErasedBox
to hold dyn Fn
:
struct DynFn(TypeErasedBox);
impl DynFn { fn new<A: 'static, R: 'static>( f: impl Fn(A) -> R + Sized + 'static, ) -> Self { Self(TypeErasedBox::new::<dyn Fn(A) -> R>(Box::new(f))) }
fn downcast_ref<A: 'static, R: 'static>(&self) -> Option<&dyn Fn(A) -> R> { self.0.downcast_ref() }}
fn main() { let dyn_fn = DynFn::new(|a: i32| a * 2); dbg!((dyn_fn.downcast_ref::<i32, i32>().unwrap())(10));}
This demonstrates that we can call the original closure through a type-erased trait object without knowing its concrete type.
Conclusion
I don’t plan to use this in the typed-eval
crate for now, but the perfectionist inside me is satisfied: it is actually possible, even though it may not be worth the complexity.
I’m not an expert in unsafe Rust, so if you see anything wrong here, I would greatly appreciate feedback.
Thank you for reading.
Discussion
Join the discussion on Reddit.