aboutsummaryrefslogtreecommitdiff

Dynamize

In order to turn a trait into a trait object the trait must be object-safe and the values of all associated types must be specified. Sometimes you however want a trait object to be able to encompass trait implementations with different associated type values. This crate provides a procedural macro to achieve that.

The following code illustrates a scenario where dynamize can help you:

trait Client {
    type Error;

    fn get(&self, url: String) -> Result<Vec<u8>, Self::Error>;
}

impl Client for HttpClient { type Error = HttpError; ...}
impl Client for FtpClient { type Error = FtpError; ...}

let client: HttpClient = ...;
let object = &client as &dyn Client;

The last line of the above code fails to compile with:

error[E0191]: the value of the associated type Error (from trait Client) must be specified

To use dynamize you only have to make some small changes:

#[dynamize::dynamize]
trait Client {
    type Error: Into<SuperError>;

    fn get(&self, url: String) -> Result<Vec<u8>, Self::Error>;
}
  1. You add the #[dynamize::dynamize] attribute to your trait.
  2. You specify a trait bound for each associated type.

Dynamize defines a new trait for you, named after your trait but with the Dyn prefix, so e.g. Client becomes DynClient:

let client: HttpClient = ...;
let object = &client as &dyn DynClient;

The new "dynamized" trait can then be used without having to specify the associated type value.

How does this work?

For the above example dynamize generates the following code:

trait DynClient {
    fn get(&self, url: String) -> Result<Vec<u8>, SuperError>;
}

impl<__to_be_dynamized: Client> DynClient for __to_be_dynamized {
    fn get(&self, url: String) -> Result<Vec<u8>, SuperError> {
        Client::get(self, url).map_err(|x| x.into())
    }
}

As you can see in the dynamized trait the associated type was replaced with the destination type of the Into bound. The magic however happens afterwards: dynamize generates a blanket implementation: each type implementing Client automatically also implements DynClient!

How does this actually work?

The destination type of an associated type is determined by looking at its trait bounds:

  • if the first trait bound is Into<T> the destination type is T

  • otherwise the destination type is the boxed trait object of all trait bounds
    e.g. Error + Send becomes Box<dyn Error + Send>
    (for this the first trait bound needs to be object-safe)

Dynamize can convert associated types in:

  • return types, e.g. fn example(&self) -> Self::A
  • callback parameters, e.g. fn example<F: Fn(Self::A)>(&self, f: F)

Dynamize also understands if you wrap associated types in the following types:

  • tuples
  • Option<_>
  • Result<_, _>
  • some::module::Result<_> (type alias with fixed error type)
  • &mut dyn Iterator<Item = _>
  • Vec<_>, VecDeque<_>, LinkedList<_>, HashSet<K>, BinaryHeap<K>, BTreeSet<K>, HashMap<K, _>, BTreeMap<K, _>
    (for K only Into-bounded associated types work because they require Eq)

Note that since these are resolved recursively you can actually nest these arbitrarily so e.g. the following also just works:

fn example(&self) -> Result<Vec<Self::Item>, Self::Error>;

How does dynamize deal with method generics?

In order to be object-safe methods must not have generics, so dynamize simply moves them to the trait definition. For the following source code:

#[dynamize::dynamize]
trait Gen {
    type Result: std::fmt::Display;

    fn foo<A>(&self, a: A) -> Self::Result;
    fn bar<A, B>(&self, a: A, b: B) -> Self::Result;
    fn buz(&self) -> Self::Result;
}

dynamize generates the following trait:

trait DynGen<A, B> {
    fn foo(&self, a: A) -> Box<dyn std::fmt::Display + '_>;
    fn bar(&self, a: A, b: B) -> Box<dyn std::fmt::Display + '_>;
    fn buz(&self) -> Box<dyn std::fmt::Display + '_>;
}

If two method type parameters have the same name, dynamize enforces that they also have the same bounds and only adds the parameter once to the trait.

Note that in the dynamized trait calling the buz method now requires you to specify both generic types, even though they aren't actually required by the method. You can avoid this by splitting the original trait in two, i.e. moving the buz method to a separate trait, which can be dynamized separately.

Dynamize supports async

Dynamize supports async out of the box. Since Rust however does not yet support async functions in traits, you'll have to additionally use another library like async-trait, for example:

#[dynamize::dynamize]
#[dyn_trait_attr(async_trait)]
#[blanket_impl_attr(async_trait)]
#[async_trait]
trait Client: Sync {
    type Error: std::error::Error + Send;

    async fn get(&self, url: String) -> Result<Vec<u8>, Self::Error>;
}
  • #[dyn_trait_attr(foo)] attaches #[foo] to the dynamized trait
  • #[blanket_impl_attr(foo)] attaches #[foo] to the blanket implementation

Note that it is important that the #[dynamize] attribute comes before the #[async_trait] attribute, since dynamize must run before async_trait.

Dynamized supertraits

In Rust a macro only operates on the passed input; it does not have access to the surrounding source code. This also means that a #[dynamize] macro cannot know which other traits have been dynamized. When you want to dynamize a trait with a dynamized supertrait, you have to tell dynamize about it with the #[dynamized(...)] attribute:

#[dynamize::dynamize]
trait Client {
    type Error: std::error::Error;

    fn get(&self, url: String) -> Result<Vec<u8>, Self::Error>;
}

#[dynamize::dynamize]
#[dynamized(Client)]
trait ClientWithCache: Client {
    type Error: std::error::Error;

    fn get_with_cache<C: Cache>(
        &self,
        url: String,
        cache: C,
    ) -> Result<Vec<u8>, <Self as ClientWithCache>::Error>;
}

This results in DynClientWithCache having the dynamized DynClient supertrait.

With the above code both traits have independent associated types. So a trait could implement one trait with one Error type and and the other trait with another Error type. If you don't want that to be possible you can change the second trait to:

#[dynamize::dynamize]
#[dynamized(Client)]
#[convert = |x: <Self as Client>::Error| -> Box<dyn std::error::Error + '_> {Box::new(x) as _}]
trait ClientWithCache: Client {
    fn get_with_cache<C: Cache>(
        &self,
        url: String,
        cache: C,
    ) -> Result<Vec<u8>, <Self as Client>::Error>;
}

Note that we removed the associated type and are now using the associated type from the supertrait by qualifying Self as Client. Since the #[dynamize] attribute on the ClientWithCache trait however cannot know the associated type from another trait, we also need to add a #[convert = ...] attribute to tell dynamize how to convert <Self as Client>::Error>.

Using dynamize with other collections

Dynamize automatically recognizes collections from the standard library like Vec<_> and HashMap<_, _>. Dynamize can also work with other collection types as long as they implement IntoIterator and FromIterator, for example dynamize can be used with indexmap as follows:

#[dynamize::dynamize]
#[collection(IndexMap, 2)]
trait Trait {
    type A: Into<String>;
    type B: Into<i32>;

    fn example(&self) -> IndexMap<Self::A, Self::B>;
}

The passed number tells dynamize how many generic type parameters to expect.

  • for 1 dynamize expects: Type<A>: IntoIterator<Item=A> + FromIterator<A>
  • for 2 dynamize expects: Type<A,B>: IntoIterator<Item=(A,B)> + FromIterator<(A,B)>
  • for 3 dynamize expects: Type<A,B,C>: IntoIterator<Item=(A,B,C)> + FromIterator<(A,B,C)>
  • etc ...