Crates.io | tinydyn |
lib.rs | tinydyn |
version | 0.1.1 |
source | src |
created_at | 2023-04-17 17:46:38.472672 |
updated_at | 2023-04-17 18:33:47.079386 |
description | A lightweight dynamic dispatch library |
homepage | |
repository | https://github.com/kupiakos/tinydyn/ |
max_upload_size | |
id | 841729 |
size | 139,195 |
tinydyn
Lightweight dynamic dispatch, intended for embedded use.
Ref<dyn Trait>
and RefMut<dyn Trait>
wrap a pointer and metadata necessary to call
trait methods, and Deref
into a tinydyn trait object that implements the Trait
.
Traits must currently opt-in by annotating with #[tinydyn]
.
This defines an alternate, lighter weight vtable, and if the trait has one method, eliminates
it entirely by putting the function pointer inline.
This does not affect normal behavior of the trait, and can still be made into a dyn Trait
.
This, however, would be wasteful.
use tinydyn::{tinydyn, Ref};
#[tinydyn]
trait Foo {
fn blah(&self) -> i32;
fn blue(&self) -> i32 { 10 }
}
impl Foo for i32 {
fn blah(&self) -> i32 { *self + 1 }
}
// Like upcasting to `&dyn Foo`, but with a lighter weight vtable.
let x: Ref<dyn Foo> = Ref::new(&15);
assert_eq!(x.blah(), 16);
assert_eq!(x.blue(), 10);
TODO: numbers on a real large embedded project
For every trait and concrete type which upcasts into that trait, Rust creates a new vtable. Each vtable includes 3 extra pointer-sized values of layout and drop info. These aren't needed, so tinydyn's custom vtables do not include them.
In addition, tinydyn places the vtable inline in Ref[Mut]
if it has only one method.
This saves a dereference when making the virtual call as well as removing the need for a static
vtable to allocate - truly as zero-cost as dynamic dispatch can get!
Trait objects in Rust are not fully zero-cost. In order for one set of code to handle multiple types with varying sizes, alignments, and behaviors, Rust must include extra metadata with the erased type to be able to work with it.
Say we have a trait Doggo
with two methods:
trait Doggo {
fn wag(&self);
fn bark(&self);
}
A concrete type can implement that trait by defining the necessary methods. Each of these methods knows the concrete type at compile time.
struct Pupper {
age: u32,
name: &'static str,
}
impl Doggo for Pupper {
fn wag(&self) { /* wag when self.name heard */ }
fn bark(&self) { /* yip based on self.age */ }
}
struct Woofer {
woof_freq: u16,
}
impl Doggo for Woofer {
fn wag(&self) { /* big woofer wagging */ }
fn bark(&self) { /* release a woof at self.woof_freq */ }
}
To work with multiple types that implement Doggo
with the same
code, generics can be used. A fn take_doggo(x: &impl Doggo)
will
create a copy of take_doggo
for every concrete type passed in,
and at compile time, this copy knows how the type is laid out in memory,
how to call the needed Doggo
, and how to best inline.
When you only have one copy or the code is trivial, this monomorphization is the best, as the compiler has the most information available to it.
If you need one copy of code to deal with multiple types,
we can erase some of this compile-time information.
A fn take_doggo(x: &dyn Doggo)
works with a reference to a
trait object, a dynamically-sized type that holds the needed info for
Rust to work with it. This function trades some indirection and more
challenging inlining with only needing one copy of take_doggo
.
Say we have a bluey: Pupper
(bluey
is a value with type Pupper
).
When we upcast &bluey
to a &dyn Doggo
, we erase its type through
an unsizing coercion. This unsizing coercion tacks on an extra
pointer to a vtable, a table that defines runtime-accessible type
information specific to the trait, most notably the method addresses.
This creates a wide pointer, like how &[T]
carries a pointer and a length.
Multiple &dyn Doggo
of the same concrete type can share the same vtable.
There's three pointer-sized values that are always included, but aren't used for dynamic dispatch, but instead for other type-erased operations:
size
, which is used by mem::size_of_val
, for deallocation, and for
type layout inside custom DSTs.align
, which is used by mem::align_of_val
, for deallocation, and for
type layout inside custom DSTs.drop_in_place
for the
concrete type. Only needed to dynamically drop a type, like Box<dyn Trait>
.However, what if your code doesn't need any of this? If all you need is dynamic dispatch through an borrow and have no need for accurate layout information, these values are an unnecessary bloat that pile up on an embedded system.
tinydyn
vtablestinydyn
defines lighter-weight dynamic dispatch objects through a #[tinydyn]
macro on a trait. This defines an alternative vtable format and reference
wrappers to call these methods. This wrapper can't query the runtime layout
information about the concrete type, nor can it drop it. However, it can
call trait methods.
tinydyn
optimizes one step further for traits with one method:
it includes the function pointer for that method alongside the erased
type instead of using a static vtable. This is as cheap as this scheme
of dynamic dispatch can be, and is how one might implement it in C.
For safety reasons, the unsized trait object that Ref
/ RefMut
deref into is a
pointer to the trait object, creating a double pointer to the object. So, while you can turn
them into a &(impl Trait + ?Sized)
, that will be marginally larger code size if not optimized.
dyn Trait
be made smaller as an optimization?In theory, rustc
could identify that a trait object's size, align, and drop glue are never
accessed throughout the whole program and remove them from the vtable, possibly even inlining
the vtable as tinydyn does. However, rustc is averse to global analysis, preferring to leave
this to LLVM; and LLVM doesn't know how trait object vtables are formatted.
These are requirements tinydyn doesn't have to uphold. It doesn't have a Box
.
Since tinydyn trait objects don't know the size or alignment of what they point to, no reference to the concrete type can be made while the type is erased.
So, in order for the tinydyn trait object to implement a trait, the implementer itself has to
have an erased pointer type. If that pointer type is sized, however, that comes with its own set
of issues. You can swap two Sized
references, and trait object-unsafe
functions marked with where Self: Sized
are now available to call, even though there's no
possible implementation.
tinydyn trait objects do this with a specific design:
Ref
] and [RefMut
] types, which hold the data
pointer and metadata needed to call trait methods with no overhead.Deref
into a !Sized
wrapper object that does,
called the dyn wrapper.Ref[Mut]
,
so the Deref
creates a double reference to avoid creating a direct reference to the target.size_of_val
and align_of_val
, it is
a double pointer and is more expensive to use directly.#[inline(always)]
so the double pointer created
when calling trait methods is detected as unnecessary and optimized away by LLVM.This fake layout and double dereference is, in the end, a necessary design decision for soundness.
transmute
In order to avoid the generation of duplicate functions or multiple addresses for the same method,
this library performs an unsafe
transmute of a function item cast to *const ()
into a
"&self
-erased" fn
pointer. This is the most problematic operation it performs. It makes these
assertions about unsafe Rust:
&'a T
where T: Sized
and *const ()
. These pointers can
be soundly transmuted between each other for the lifetime 'a
. Similarly for &mut
and
*mut ()
.*const ()
can soundly be transmuted to a function pointer if:
All parameters have identical layout.
All pointer parameters have the same mutability.
Function pointers are the size of *const ()
on this platform
(checked by transmute
).
See CONTRIBUTING.md
for details.
Apache 2.0; see LICENSE
for details.
This project is not an official Google project. It is not supported by Google and Google specifically disclaims all warranties as to its quality, merchantability, or fitness for a particular purpose.