A Stable ABI for Rust with compact sum-types
stabby is your one-stop-shop to create stable binary interfaces for your shared libraries easily, without having your sum-types (enums) explode in size.
Your main vector of interraction with stabby will be the #[stabby::stabby] proc-macro, with which you can annotate a lot of things:
Structures
When you annotate structs with #[stabby::stabby], two things happen:
- The struct becomes
#[repr(C)]. Unless you specify otherwise or your struct has generic fields,stabbywill assert that you haven't ordered your fields in a suboptimal manner at compile time. stabby::abi::IStablewill be implemented for your type. It is similar toabi_stable::Stable, but represents the layout (including niches) through associated types. This is key to being able to provide niche-optimization in enums (at least, until#[feature(generic_const_exprs)]becomes stable).
Enums
When you annotate an enum with #[stabby::stabby], you may select an existing stable representation (like you must with abi_stable), but you may also select #[repr(stabby)] (the default representation) to let stabby turn your enum into a tagged-union with a twist: the tag may be a ZST that inspects the union to emulate Rust's niche optimizations.
Note that #[repr(stabby)] does lose you the ability to pattern-match.
Due to limitations of the trait solver, #[repr(stabby)] enums have a few papercuts:
- Compilation times suffer from
#[repr(stabby)]enums: on my machine, adding one typically adds about one second to compilation time. - Additional trait bounds are required when writing
impl-blocks generic enums. They will always be of the form of one or multipleA: stabby::abi::IDiscriminantProvider<B>bounds (althoughrustc's error may suggest more complex tuples, the 2 element tuple will always be the one you should use).
#[repr(stabby)] enums are implemented as a balanced binary tree of stabby::result::Result<Ok, Err>, so discriminants are always computed between two types through the following process:
- If some of
Err's forbidden values (think0for non-zero types) fit inside the bits thatOkdoesn't care for, that value is used to signify that we are in theOkvariant. - The same thing is attempted with
ErrandOk's roles inverted. - If no single value discriminant is found,
OkandErr's unused bits are intersected. If the intersection exists, the least significant bit is used, while the others are kept as potential niches for sum-types that would contain aResult<Ok, Err>variant. - Should no niche be found, the smallest of the two types is shifted right by its alignment, and the process is attempted again. This shifting process stops if the union would become bigger, or at the 8th time it has been attempted. If the process stops before a niche is found, a single bit will be used as the determinant (shifting the union right by its own alignment, with
1representingOk).
Unions
If you want to make your own internally tagged unions, you can tag them with #[stabby::stabby] to let stabby check that you only used stable variants, and let it know the size and alignment of your unions. Note that stabby will consider that unions have no niches.
Traits
When you annotate a trait with #[stabby::stabby], an ABI-stable vtable is generated for it. You can then use any of the following type equivalence:
&'a dyn Traits→DynRef<'a, vtable!(Traits)>&'a mut dyn Traits→Dyn<&'a mut (), vtable!(Traits)>Box<dyn Traits + 'a>→Dyn<'a, Box<()>, vtable!(Traits)>Arc<dyn Traits + 'a>→Dyn<'a, Arc<()>, vtable!(Traits)>
Note that vtable!(Traits) supports any number of traits: vtable!(TraitA + TraitB<Output = u8>) is perfectly valid, but ordering must remain consistent.
Alternatively, you can use stabby::dynptr!(Box<dyn Traits + 'a>).
However, the vtables generated by stabby will not take supertraits into account.
In order for stabby::dynptr!(Box<dyn Traits + 'a>) to have Trait's methods, you will need to use trait::{TraitDyn, TraitDynMut};, so make sure you don't accidentally seal these traits which are automatically declared with the same visibility as your Trait.
stabby::closure exports the CallN, CallMutN and CallOnceN traits, where N (in 0..=9) is the number of arguments, as ABI-stable equivalents of Fn, FnMut and FnOnce respectively.
Functions
For now, annotating a function with #[stabby::stabby] merely makes it extern "C" (but not #[no_mangle]) and checks its signature to ensure all exchanged types are marked with stabby::abi::IStable. You may also specify the calling convention of your choice.
Future plans include:
#[stabby::export]will export a stably-mangled symbol which may be used to extract the function, but also obtain a report of its signature's layout.stabbywould include a function similar tolibloading::Library::get, which would also check that the signature you specified for a symbol matches the one encoded by the exporter.#[stabby::import]will act similarly to#[link]. Its exact behaviour is still to be defined, but the goal is to obtain the same reliability with shared-dependencies as whatstabbywill grant you with dynamically-loaded libraries.
Async
Any implementation of core::future::Future on a stable type will work regardless of which side of the FFI-boundary that stable type was constructed. However, futures created by async blocks and async functions aren't ABI-stable, so they must be used through trait objects.
stabby supports futures through the stabby::future::Future trait. Async functions are turned by #[stabby::stabby] into functions that return a Dyn<Box<()>, vtable!(stabby::future::Future + Send + Sync)> (the Send and Sync bounds may be removed by using #[stabby::stabby(unsync, unsend)]), which itself implements core::future::Future.
stabby doesn't support async traits yet, but you can use the following pattern to implement them:
use ;
Incremental stability
stabby also lets you tell it that something is ABI-stable even if you couldn't chain #[stabby::stabby] all along using stabby::abi::StableLike.
Combined with the ZSTs in stabby::compiler_version that implement stabby::IStable however you tell them to, but only when compiled with their respective versions of the compiler, this lets you state that some types are only stable if compiled with the appropriate compiler version. But the ZSTs will still exist even if not, so the types will still be usable anywhere that doesn't have a stabby::IStable bound.
The stabby "manifesto"
stabby was built in response to the lack of ABI-stability in the Rust ecosystem, which makes writing plugins and other dynamic linkage based programs painful. Currently, Rust's only stable ABI is the C ABI, which has no concept of sum-types, let alone niche exploitation.
However, our experience in software engineering has shown that type-size matters a lot to performance, and that sum-types should therefore be encoded in the least space-occupying manner.
My hope with stabby comes in two flavors:
- Adoption in the Rust ecosystem: this is my least favorite option, but this would at least let people have a better time with Rust in situations where they need dynamic linkage.
- Triggering a discussion about providing not a stable, but versionned ABI for Rust:
stabbyessentially provides a versionned ABI already through the selected version of thestabby-abicrate. However, having a library implement type-layout, which is normally the compiler's job, forces abi-stability to be per-type explicit, instead of applicable to a whole compilation unit. In my opinion, aabi = "1.xx"(wherexxwould be a subset ofrustc's version that the compiler team is willing to support for a given amount of time) key in the cargo manifest would be a much better way to do this.