Blog series: Dyn async in traits (continues)

That's the conclusion I came to after sleeping on it.

Dynamic dispatch already requires specifying bounds for associated types; if I use a regular trait, the compiler complains otherwise:

fn dyn_use(iterator: &mut Iterator) {
    for item in iterator {}
}

The same would apply to the original example, from the blog post:

fn use_dyn(di: &mut dyn AsyncIterator) {
    di.next().await; // <— this call right here!
}

Needs to be rewritten as:

fn use_dyn(di: &mut dyn AsyncIterator<Item = ??>) {
    di.next().await; // <— this call right here!
}

That is, use_dyn is runtime polymorphic about the particular implementation of the trait, but still requires compile-time information about a number of its properties. A single dyn_use cannot, at runtime, handle a mix of i32 and String.

Do we really want to try and have use_dyn be runtime polymorphic over the various future types that various implementation of AsyncIterator<Item = X> could have?

This seems at odd with the fact that Item needs to be pinned to a specific type.

And if that requirement is lifted, then suddenly things fall in place (with a hypothetical FutureType associated item):

fn use_dyn(
    di: &mut dyn AsyncIterator<Item = X, FutureType = Box<dyn Future<Output = Option<X>>>>
) {
    di.next().await; // <— this call right here!
}

It can be made much more palatable with some library goodness:

trait DynAsyncIterator {
    type Item;
    type SizedFuture<T: ?Sized>: Future<Output = Option<Self::Item>>;

    fn poll_next(&mut self) -> SizedFuture<dyn Future<Output = Option<Self::Item>>>;
}

//  [std] only, not [core].
//  Make part of prelude, for seamless experience.
trait AsyncIteratorExt: AsyncIterator {
    type Boxed: DynAsyncIterator<Item = Self::Item, SizedFuture = Box>;

    //  Similar to the "fused" adaptor existing on Iterator.
    fn boxed(self) -> Self::Boxed;
}

impl<T: AsyncIterator> AsyncIteratorExt for T {
    type Boxed = /* todo */;

    fn boxed(self) -> Self::Boxed { todo!() }
}

Which enables a nifty:

fn make_dyn<AI: AsyncIterator>(ai: AI) {
    //  Explicit choice of strategy.
    use_dyn(&mut ai.boxed());
}

fn use_dyn(di: &mut dyn DynAsyncIterator<Item = X, SizedFuture = Box>) {
    di.next().await;
}

It looks pretty good, if I say so myself:

  • Designing DynAsyncIterator is a one-off cost for the designer, and a one-off cost for each implementation of which there'll probably be relatively few -- there's not that many strategies available.
  • AsyncIteratorExt is a one-off cost for the designer.
  • Even if talking about the return type of poll_next was possible, DynAsyncIterator is more user-friendly than having to specified the horrendously long type.
  • No unstable compiler features were harmed in this sample -- with GATs stabilized -- though AsyncIterator may still require impl Trait in return position in traits by itself, of course.
  • Compatible with [no_std]: the boxed adapter is purely optional, leaving in std itself. Users in [no_std] will instead pick a different strategy, possibly a Box<T, InlineStorage<N>>.

And of course, the usage is fairly need. A simple call to .boxed() is both succinct enough not to be a bore, and explicit enough to spot allocations.

1 Like