#codegen #proc-macro #ffi #dynamic-library

macro dylo

Generate dyn-compatible traits with procedural macros

2 stable releases

new 1.0.1 Dec 6, 2024
1.0.0 Dec 5, 2024

#250 in Procedural macros

Download history 252/week @ 2024-12-02

252 downloads per month

Apache-2.0 OR MIT

12KB

license: MIT/Apache-2.0 crates.io docs.rs

dylo

dylo provides the #[dylo::export] attribute.

This crate has zero dependencies and does not perform any code generation of its own - it simply provides the attribute definitions that the dylo-cli tool looks for when generating consumer crates.

Usage

Slap #[dylo::export] on impl blocks whose trait you want dylo-cli to genaerte:

#[dylo::export]
impl Mod for ModImpl {
    /// Parses command line arguments
    fn parse(&self) -> Args {
        Args::parse()
    }
}

Warning Only dyn-compatible traits can be marked with #[dylo::export] — dynamic dispatch is kinda the whole point.

Traits generated by dylo are Send + Sync + 'static by default. If you need a trait to be not sync, you can pass nonsync as an arugment to dylo::export:

#[dylo::export]
impl Foo for FooImpl {}

// will generate:
// trait Foo: Send + Sync + 'static { }

#[dylo::export(nonsync)]
impl Bar for BarImpl {}

// will generate:
// trait Bar: Send + 'static { }

The Mod trait has special treatment: the concrete type ModImpl must implement Default, because it must be able to be constructed dynamically when the mod is loaded, from no arguments.

If Mod or ModImpl are missing, the code geneated by dylo will not compile.

dylo-cli will make sure that:

  • In the mod crate, there's an exported function that returns a Box<dyn ModImpl>
  • In the "consumer" crate, there is code (leveraging dylo-runtime) that knows how to build, load, and return a Box<dyn Mod>.

If you need your initialization to take arguments, you can simply export two interfaces:

#[dylo::export]
impl Mod for ModImpl {
    type Error = anyhow::Error;

    fn make_client(&self, endpoint: &str) -> Result<Box<dyn Client>, Self::Error> {
        let client = ClientImpl::new(endpoint)?;
        Ok(Box::new(client))
    }

    fn parse_args(&self) -> Args {
        // ...
    }
}

#[dylo::export]
impl Client for ClientImpl {
    fn send_request(&self, request: Request) -> Result<Response, anyhow::Error> {
        // ...
    }
}

Note that you're not supposed to write the trait definition yourself — just the impl Blah for BlahImpl block. It's dylo-cli's job to generate the trait for you.

In concrete terms, it will add an src/.dylo/spec.rs file to your original crate, and add an include!(".dylo/spec.rs") item to your src/lib.rs

Warning:

Other crate structures exist but aren't supported for now.

Dependencies

Because the consumer crate is generated from the mod-XXX crate, it shares some dependencies with it: any types that appear in the public API must be available to the con-XXX crate as well.

However, some types and functions and third-party crates are only used in the implemention. Those can be feature-gated both in the Cargo.toml manifest:

[package]
name = "mod-cliargs"
version = "0.1.0"
edition = "2021"

[lib]
# mods are always cdylibs — this one will build into
# `target/debug/libmod_cliargs.so` or `target/debug/libmod_cliargs.dylib` on macOS.
crate-type = ["cdylib"]

[dependencies]
# camino types are used in the public API of this mod
camino = "1"
con = "1"

# impl deps are marked "optional"
clap = { version = "4.5.13", features = ["derive"], optional = true }

[features]
default = ["impl"]
# ... and they are enabled by the "impl" feature, which is itself enabled by default
impl = ["dep:clap"]

And in the src/lib.rs code itself:

#[cfg(feature = "impl")]
#[derive(Default)]
struct ModImpl;

#[cfg_attr(feature = "impl", derive(Parser))]
pub struct Args {
    #[cfg_attr(feature = "impl", clap(default_value = "."))]
    /// config file
    pub path: Utf8PathBuf,
}

#[dylo::export]
impl Mod for ModImpl {
    fn parse(&self) -> Args {
        Args::parse()
    }
}

In the consumer version of the crate, only the non-impl dependencies and items will remain:

# rough outline of what `dylo-runtime` would generate for the consumer `cliargs` crate

[package]
name = "cliargs"
version = "0.1.0"
edition = "2021"

[dependencies]
# only the dependencies used in the public API
camino = "1"
// generated code for the public API
pub struct Args {
    /// config file
    pub path: Utf8PathBuf,
}

pub trait Mod: Send + Sync + 'static {
    fn parse(&self) -> Args;
}

Note that filtering out items with #[cfg(feature = "impl")] isn't done via something like -Zunpretty-expand, for myriad reasons. It's done by parsing the AST with syn, removing offending items and attributes, then formatting the AST with rustfmt.

Limitations

dylo will expect all your exported traits to be dyn-compatible (this used to be call "object safe")

Here's a list of things you cannot do.

Traits cannot be generic over types

// ❌ This won't work
#[dylo::export]
impl Parser<T> for JsonParser<T> {
    fn parse(&self, input: &str) -> Result<T>;
}

Function arguments or return types cannot be generic

Methods in exported traits cannot have generic type parameters.

// ❌ This won't work
#[dylo::export]
impl Parser for JsonParser {
    fn parse<T: DeserializeOwned>(&self, input: &str) -> Result<T>;
}

You cannot use impl Trait

Neither argument position nor return position impl Trait is supported — they're essentially generic type parameters in disguise.

// ❌ This won't work
#[dylo::export]
impl Handler for MyHandler {
    fn process(&self, transform: impl Fn(u32) -> u32) -> impl Iterator<Item = u32>;
}

You can be generic over lifetimes

Unlike with type parameters, traits can be generic over lifetimes:

#[dylo::export]
impl Parser<'a> for MyParser {
    fn parse(&self, input: &'a str) -> Result<&'a str>;
}

This works because lifetimes are erased at compile time and don't affect dynamic dispatch.

You can (and should) use boxed trait objects

A surprising amount of things can be achieved through boxed trait objects if most of your traits are dyn-compatible:

// that's okay
#[dylo::export]
impl Handler for MyHandler {
    fn process(&self, transform: Box<dyn Fn(u32) -> u32>) -> Box<dyn Iterator<Item = u32>>;
}

Note that if you don't need ownership of something, you can just take a reference to it, like the transform function here:

// that's okay too!
#[dylo::export]
impl Handler for MyHandler {
    fn process(&self, transform: &dyn Fn(u32) -> u32) -> Box<dyn Iterator<Item = u32>>;
}

Async functions are not supported yet

async fns in trait (AFIT) are supported by Rust as of 1.75, but as of 1.83, they are still not dyn-compatible, so this won't work:

// ❌ This won't work yet
#[dylo::export]
impl Client for HttpClient {
    async fn fetch(&self, url: &str) -> Result<Response> {
        reqwest::get(url).await
    }
}

However, you can return boxed futures:

// no need to pull in `futures-core` for this
pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;

#[dylo::export]
impl Client for HttpClient {
    fn fetch(&self, url: &str) -> BoxFuture<'_, Result<Response>> {
        Box::pin(async move {
            reqwest::get(url).await
        })
    }
}

Sometimes you'll need to be a bit more explicit with lifetimes:

#[dylo::export]
impl Client for HttpClient {
    fn fetch<'fut>(&'fut self, url: &'fut str) -> BoxFuture<'fut, Result<Response>> {
        Box::pin(async move {
            reqwest::get(url).await
        })
    }
}

Support for dyn-compatible async fn in traits is in the cards afaict, see the dynosaur crate for a peek into the future (however you cannot use it with dylo).

Self type restrictions

You cannot take or return self by value, but you can use Box<Self> or Arc<Self> receivers:

#[dylo::export]
impl Builder for RequestBuilder {
    // ❌ These won't work: we don't know the size of "Self"
    fn with_header(mut self, name: &str) -> Self {
        // etc.
    }
    fn build(self) -> Request {
        // etc.
    }
}

#[dylo::export]
impl Builder for RequestBuilder {
    // ✅ These will work: a Box<Self> is the size of a pointer
    fn with_header(mut self: Box<Self>, name: &str) -> Box<Self> {
        // etc.
    }
    fn build(self: Box<Self>) -> Request {
        // etc.
    }
}

Essentially, as a consumer, we don't know the size of "Self" — so we need the indirection. References (&self, &mut self) are always fine.

Should dylo exist?

Not really — much like rubicon, all that should be possible in stable Rust, with support from the compiler, etc.

Half the reason to bother with an approach like dylo's is to avoid unnecessary rebuilds. The proper approach for that is being explored by other folks, see:

However, I live in the today, and for now I'll stick to my horrible codegen hacks.

No runtime deps