Skip to content
← All Rust articles

Rust explainer

async fn in traits and RPITIT: what works and what does not

About 5 min to read. Allow extra time to work through examples.

Return hidden iterator and future types from traits, choose explicit Send bounds, and use boxed returns when you need dynamic dispatch.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A trait can describe an operation without naming its exact return type. For example, an iterator-producing method can return impl Iterator, and an asynchronous method can use async fn. Both forms became stable in Rust 1.75.

These features work with concrete types and generic callers. Calling a method through dyn Trait needs a different return shape. We will use an iterator first, then apply the same distinction to futures.

Return an iterator without naming its type

RPITIT means return-position impl Trait in traits. Each implementation chooses a concrete return type, while callers use the capabilities promised by the trait:

trait Maker {
    fn make(&self) -> impl Iterator<Item = u32>;
}

struct Doubler;

impl Maker for Doubler {
    fn make(&self) -> impl Iterator<Item = u32> {
        (0..3u32).map(|x| x * 2)
    }
}

fn collect<M: Maker>(maker: &M) -> Vec<u32> {
    maker.make().collect()
}

fn main() {
    assert_eq!(collect(&Doubler), vec![0, 2, 4]);
}

The hidden type here is a Map iterator containing a range and a closure. It is still a concrete type; impl Iterator hides its name from the caller. An implementation must return one underlying type for each instantiation of its method, not choose unrelated types on different branches.

Earlier Rust versions could express implementation-specific return types with named associated types. RPITIT removes the need to name that type in the trait implementation, which is useful for closures and futures.

Why dyn Maker fails

Add this line inside the preceding main and compilation fails with E0038:

let _maker: &dyn Maker = &Doubler;

The method's opaque return type prevents this trait from being dyn compatible. A generic caller is compiled with a particular implementing type; a trait-object caller uses runtime dispatch. Rust does not automatically erase or box the hidden return value for that call.

There is a useful exception: adding where Self: Sized to make allows an otherwise compatible trait to have a trait object. However, make then cannot be called through that object. This preserves other dispatchable methods; it does not provide dynamic dispatch for make.

See the Rust Reference's dyn-compatibility rules for the other requirements.

Box an iterator that borrows its source

A boxed trait object gives every implementation the same declared return type. This complete example returns an iterator over an existing vector:

trait BoxedMaker {
    fn make(&self) -> Box<dyn Iterator<Item = u32> + '_>;
}

struct Numbers(Vec<u32>);

impl BoxedMaker for Numbers {
    fn make(&self) -> Box<dyn Iterator<Item = u32> + '_> {
        Box::new(self.0.iter().copied())
    }
}

fn main() {
    let numbers = Numbers(vec![2, 4, 6]);
    let maker: &dyn BoxedMaker = &numbers;
    assert_eq!(maker.make().collect::<Vec<_>>(), vec![2, 4, 6]);
}

Here + '_ ties the returned trait object's lifetime to the borrow of self. The iterator borrows the vector's elements, so it cannot outlive numbers. Omitting the lifetime from both return signatures would request a 'static trait object and reject this implementation.

Check your understanding Does returning impl Iterator guarantee that a method performs no allocations? Show answer

No. Its implementation can allocate a vector and return that vector's owning iterator. The return syntax does not determine what the method does internally.

Boxing usually introduces heap storage for the returned value and dynamic dispatch for iterator operations. It is not a promise of exactly one allocation in every case: boxing a zero-sized value needs no heap allocation, and optimizations can also affect allocation behavior. Measure costs relevant to your implementation.

An async method returns a future

This trait declaration is valid:

trait Fetch {
    async fn get(&self, url: &str) -> String;
}

Each implementation returns a hidden future whose output is String. Calling get creates that future; awaiting it drives the operation. As with the original Maker, this declaration does not support calling get through dyn Fetch.

A plain async trait method also does not promise a Send future. That matters when a caller needs to transfer the future between threads. To make that capability part of the contract, write the return bound explicitly:

use std::future::Future;

trait Fetch {
    fn get(&self, url: &str) -> impl Future<Output = String> + Send;
}

struct Echo;

impl Fetch for Echo {
    async fn get(&self, url: &str) -> String {
        url.to_owned()
    }
}

fn require_send<T: Send>(_: T) {}

fn check<F: Fetch>(fetcher: &F) {
    require_send(fetcher.get("example"));
}

fn main() {
    check(&Echo);
}

This example checks the future's type; it does not execute the future. Without + Send in the trait, the generic check function cannot assume that bound, even if Echo happens to return a Send future.

Send is only one possible spawning requirement. A task API may also require 'static data, a Send output, or other bounds. A borrowed future does not become 'static just because it is Send.

Use a boxed future for dynamic dispatch

The following is a trait definition, not a runnable program:

use std::future::Future;
use std::pin::Pin;

type FetchFuture<'a> = Pin<Box<dyn Future<Output = String> + Send + 'a>>;

trait BoxedFetch {
    fn get<'a>(&'a self, url: &'a str) -> FetchFuture<'a>;
}

An implementation can return Box::pin(async move { /* produce a String */ }). The future may borrow both self and url for 'a. The Send bound requires those captured values and any state held across suspension to permit transfer between threads. If your callers do not require that, you can design a separate interface without Send.

Pin lets callers poll a boxed future without requiring the underlying future to be Unpin. This trait supports dyn BoxedFetch: its method has a uniform boxed return type, and lifetime parameters are permitted on dispatchable methods.

Choose the contract your callers need

Use a plain async trait method when its promised capabilities are enough for your callers. Add an explicit impl Future + Send return when generic callers must rely on a transferable future. Use a boxed future when callers need dynamic dispatch through the trait.

For a public API, decide these bounds deliberately: adding Send later can reject existing implementations, while leaving it out limits what generic callers may assume. The Rust 1.75 announcement explains this API-design tradeoff. No syntax guarantees that code will be inlined or allocation-free.

The examples and expected compilation failures in this article were checked with Rust 1.98.1. They describe those concrete cases without predicting when future language features will arrive.

step 9 of 10

Trait Objects and Dispatch

Up next Choose parameter types and shrink an enum