follower / stabby

A Stable ABI for Rust with compact sum-types

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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, stabby will assert that you haven't ordered your fields in a suboptimal manner at compile time.
  • stabby::abi::IStable will be implemented for your type. It is similar to abi_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 multiple (A, B): stabby::abi::IDiscriminantProvider bounds (although rustc'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 (think 0 for non-zero types) fit inside the bits that Ok doesn't care for, that value is used to signify that we are in the Ok variant.
  • The same thing is attempted with Err and Ok's roles inverted.
  • If no single value discriminant is found, Ok and Err'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 a Result<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 1 representing Ok).

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 TraitsDynRef<'a, vtable!(Traits)>
  • &'a mut dyn TraitsDyn<&'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 to take 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.
  • stabby would include a function similar to libloading::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 what stabby will 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 stabby::{slice::SliceMut, future::DynFuture};
#[stabby::stabby]
pub trait AsyncRead {
	extern "C" fn read<'a>(&'a mut self, buffer: SliceMut<'a, [u8]>) -> DynFuture<'a, usize>;
}
impl MyAsyncTrait for SocketReader {
	extern "C" fn read<'a>(&'a mut self, mut buffer: SliceMut<'a, [u8]>) -> DynFuture<'a, usize> {
		Box::new(
			async move {
				let slice = buffer.deref_mut();
				let read = SocketReader::read_async(&mut self.socket, slice).await;
				buffer = slice.into();
				read
			}
		).into()
	}
}

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: stabby essentially provides a versionned ABI already through the selected version of the stabby-abi crate. 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, a abi = "1.xx" (where xx would be a subset of rustc'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.

About

A Stable ABI for Rust with compact sum-types


Languages

Language:Rust 100.0%