Enable Dynamic Typing for Non-Owned Types
std::any::Any
is an object-safe trait that can be used for implementing dynamic typing. It provides a series of downcast functions that allow you to cast from &dyn Any
to &T
with runtime type enforcement. It can be really neat in certain scenarios, for example, when you want to implement heterogeneous containers (i.e. container that can hold different types of objects).
However, std::any::Any
has a limitation: it is only implemented for 'static
types (or owned types). See its definition:
pub trait Any: 'static {
// Required method
fn type_id(&self) -> TypeId;
}
The : 'static
bound effectively means that any type which holds a non-'static
reference to other objects cannot implement Any
. These types thus cannot benefit from dynamic typing. The reason is simple and intuitive: when you coerce a T
into a dyn Any
, not only the type T
is elided, but also all lifetimes associated with T
are elided. The type system can no longer track those lifetimes across the coercion; so this coercion should not be allowed.
Is there a way that enables dynamic typing for such types? As discussed above, in general, such approaches will result in lifetime violations because lifetimes are also elided along the type information during coercion. So the key is to keep the lifetime while erasing the type. Let’s demonstrate this by implementing a simple type-erased smart pointer TypeErasedBox
, which is conceptually similar to Box<dyn Any>
, but it can additionally hold objects bounded by a given lifetime:
pub struct TypeErasedBox<'a> {
ptr: *mut u8,
ty: TypeId,
layout: Layout,
inplace_dropper: Box<dyn Fn(*mut u8)>,
_ref: PhantomData<&'a ()>,
}
impl<'a> TypeErasedBox<'a> {
pub fn new<T: 'a>(value: T) -> Self {
todo!()
}
pub fn get<T: 'a>(&self) -> Option<&T> {
todo!()
}
}
impl<'a> Drop for TypeErasedBox<'a> {
fn drop(&mut self) {
todo!()
}
}
The 'a
lifetime parameter is the lifetime bound of the object owned by the smart pointer. When creating a TypeErasedBox
, objects of any types bounded by 'a
can be put into the smart pointer. Later, users can extract the object through the get
function which does runtime type checking. The data fields should be self-explanatory:
ptr
is a pointer to a heap-allocated memory chunk that contains the owned object.ty
is a dynamic type ID that identifies the type of the owned object at runtime. This field is crucial for runtime type checking.layout
gives the memory layout of the contained object. It is useful during dropping when the heap-allocated memory chunk needs to be released.inplace_dropper
points to a function that drops the contained object during dropping.
We may implement new
as follows:
pub fn new<T: 'a>(value: T) -> Self {
let layout = Layout::for_value(&value);
let ptr = unsafe { std::alloc::alloc(layout) };
// We don't really handle allocation failure for simplicity here.
assert!(!ptr.is_null());
unsafe {
std::ptr::write(ptr as *mut T, value);
}
Self {
ptr,
ty: TypeId::of::<T>(),
layout,
inplace_dropper: Box::new(|ptr| unsafe {
std::ptr::drop_in_place(ptr as *mut T);
}),
_ref: PhantomData::default(),
}
}
Unfortunately, the above code won’t compile. Similar to Any, only owned types can have corresponding TypeIds
at runtime. You cannot get a TypeId
for a non-owned type. See the definition of TypeId::of
:
pub fn of<T>() -> TypeId
where
T: 'static + ?Sized;
The solution to overcome this issue is to introduce a “tag type” for the purpose of runtime type checking. Let’s say we want to put objects of the following type into a TypeErasedBox
:
pub struct Ref<'a>(&'a i32);
Ref
can be regarded as a family of types parameterized by a lifetime 'a
. We introduce a tag type RefTag
to identify this family of types:
pub struct RefTag;
Within a TypeErasedBox
that contains a Ref<'a>
object, the stored type identity ty should be the type identity of RefTag
. This correspondence between an owned tag type RefTag
and its represented family of types Ref<'a>
can be expressed via a trait:
pub trait ErasableTypeFamily: 'static {
type Member<'a>;
}
impl ErasableTypeFamily for RefTag {
type Member<'a> = Ref<'a>;
}
Based on the solution above, we can implement TypeErasedBox::new
and TypeErasedBox::get
now:
pub fn new<T: ErasableTypeFamily>(value: T::Member<'a>) -> Self {
let layout = Layout::for_value(&value);
let ptr = unsafe { std::alloc::alloc(layout) };
// We don't really handle allocation failure for simplicity here.
assert!(!ptr.is_null());
unsafe {
std::ptr::write(ptr as *mut T::Member<'a>, value);
}
Self {
ptr,
ty: TypeId::of::<T>(),
layout,
inplace_dropper: Box::new(|ptr| unsafe {
std::ptr::drop_in_place(ptr as *mut T::Member<'a>);
}),
_ref: PhantomData::default(),
}
}
pub fn get<T: ErasableTypeFamily>(&self) -> Option<&T::Member<'a>> {
if self.ty != TypeId::of::<T>() {
return None;
}
let value_ref = unsafe {
(self.ptr as *mut T::Member<'a>).as_ref().unwrap()
};
Some(value_ref)
}
Finally, to make the implementation complete, here is the implementation for the Drop
trait:
impl<'a> Drop for TypeErasedBox<'a> {
fn drop(&mut self) {
(self.inplace_dropper)(self.ptr);
unsafe {
std::alloc::dealloc(self.ptr, self.layout);
}
}
}
Here are some key takeaways from this short write-up:
std::any::Any
is only implemented for owned types (i.e. types bounded by'static
). To safely enable dynamic typing for non-owned types, lifetime bounds on these types cannot be elided.TypeId
can be used to identify the dynamic type of an object at runtime. However, it is only available for owned types. Thus, we need to introduce a “tag type” to identify a family of lifetime-parameterized types, and uses theTypeId
for tag types to do the type check at runtime.