340: Async Trait Pattern
Tutorial Video
Text description (accessibility)
This video demonstrates the "340: Async Trait Pattern" functional Rust example. Difficulty level: Advanced. Key concepts covered: Functional Programming. Rust traits with `async fn` methods have a fundamental limitation: the returned `Future` type differs per implementation, making traits with async methods not object-safe. Key difference from OCaml: 1. **Stable Rust (1.75+)**: Return
Tutorial
The Problem
Rust traits with async fn methods have a fundamental limitation: the returned Future type differs per implementation, making traits with async methods not object-safe. The workaround is to return Pin<Box<dyn Future<Output = T> + Send>> — a heap-allocated, type-erased future. The async-trait crate automates this boxing. Understanding the manual pattern illuminates what the macro generates and when to use each approach.
🎯 Learning Outcomes
async fn in traits is not directly object-safePin<Box<dyn Future<...>>> return types manuallyAsyncResult<T, E> type alias for cleaner signaturesasync-trait crate vs manual boxingCode Example
type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
trait AsyncStore: Send + Sync {
fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
fn set(&self, key: String, val: String) -> AsyncResult<(), String>;
}Key Differences
impl Trait in traits (RPITIT) was stabilized — async fn in traits now works on stable Rust without the async-trait crate in many cases.dyn AsyncStore, boxing is still required; for monomorphic dispatch, stable async fn in traits now works.#[async_trait] macro transforms async fn methods to return Pin<Box<dyn Future>> automatically — reducing boilerplate.async fn in traits (stable Rust 1.75+) avoids this for concrete types.OCaml Approach
OCaml's module types with Lwt functions are the idiomatic equivalent — each module implementing the signature provides its own Lwt.t-returning functions:
module type STORE = sig
val get : string -> string option Lwt.t
val set : string -> string -> unit Lwt.t
end
Module types are inherently polymorphic — no boxing is required.
Full Source
#![allow(clippy::all)]
//! # Async Trait Pattern
//!
//! Async methods in traits require boxing — `async fn` in traits isn't directly
//! supported in stable Rust without the `async-trait` crate.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
/// Type alias for boxed async results.
pub type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
/// Async storage trait with boxed futures for object safety.
pub trait AsyncStore: Send + Sync {
fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
fn set(&self, key: String, value: String) -> AsyncResult<(), String>;
fn delete(&self, key: &str) -> AsyncResult<bool, String>;
}
/// In-memory implementation of AsyncStore.
pub struct MemStore {
data: Mutex<HashMap<String, String>>,
}
impl MemStore {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for MemStore {
fn default() -> Self {
Self::new()
}
}
impl AsyncStore for MemStore {
fn get(&self, key: &str) -> AsyncResult<Option<String>, String> {
let result = self.data.lock().unwrap().get(key).cloned();
Box::pin(async move { Ok(result) })
}
fn set(&self, key: String, value: String) -> AsyncResult<(), String> {
self.data.lock().unwrap().insert(key, value);
Box::pin(async { Ok(()) })
}
fn delete(&self, key: &str) -> AsyncResult<bool, String> {
let removed = self.data.lock().unwrap().remove(key).is_some();
Box::pin(async move { Ok(removed) })
}
}
/// A failing store for testing error handling.
pub struct FailStore;
impl AsyncStore for FailStore {
fn get(&self, _key: &str) -> AsyncResult<Option<String>, String> {
Box::pin(async { Err("connection refused".to_string()) })
}
fn set(&self, _key: String, _value: String) -> AsyncResult<(), String> {
Box::pin(async { Err("read-only store".to_string()) })
}
fn delete(&self, _key: &str) -> AsyncResult<bool, String> {
Box::pin(async { Err("operation not permitted".to_string()) })
}
}
/// A minimal executor for testing.
pub fn block_on<F: Future>(fut: F) -> F::Output {
unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
RawWaker::new(ptr, &VTABLE)
}
unsafe fn noop(_: *const ()) {}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_waker, noop, noop, noop);
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut);
loop {
if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
return value;
}
}
}
/// Demonstrates using the store through the trait interface.
pub fn use_store(store: &dyn AsyncStore, key: &str, value: &str) -> Result<Option<String>, String> {
block_on(store.set(key.to_string(), value.to_string()))?;
block_on(store.get(key))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mem_store_set_get() {
let store = MemStore::new();
block_on(store.set("key".to_string(), "value".to_string())).unwrap();
assert_eq!(
block_on(store.get("key")).unwrap(),
Some("value".to_string())
);
}
#[test]
fn test_mem_store_missing_key() {
let store = MemStore::new();
assert_eq!(block_on(store.get("missing")).unwrap(), None);
}
#[test]
fn test_mem_store_delete() {
let store = MemStore::new();
block_on(store.set("k".to_string(), "v".to_string())).unwrap();
assert!(block_on(store.delete("k")).unwrap());
assert!(!block_on(store.delete("k")).unwrap());
}
#[test]
fn test_fail_store_returns_errors() {
let store = FailStore;
assert!(block_on(store.get("any")).is_err());
assert!(block_on(store.set("k".into(), "v".into())).is_err());
}
#[test]
fn test_trait_object_dispatch() {
let stores: Vec<Box<dyn AsyncStore>> = vec![Box::new(MemStore::new()), Box::new(FailStore)];
// First store works
assert!(block_on(stores[0].set("k".into(), "v".into())).is_ok());
// Second store fails
assert!(block_on(stores[1].get("k")).is_err());
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mem_store_set_get() {
let store = MemStore::new();
block_on(store.set("key".to_string(), "value".to_string())).unwrap();
assert_eq!(
block_on(store.get("key")).unwrap(),
Some("value".to_string())
);
}
#[test]
fn test_mem_store_missing_key() {
let store = MemStore::new();
assert_eq!(block_on(store.get("missing")).unwrap(), None);
}
#[test]
fn test_mem_store_delete() {
let store = MemStore::new();
block_on(store.set("k".to_string(), "v".to_string())).unwrap();
assert!(block_on(store.delete("k")).unwrap());
assert!(!block_on(store.delete("k")).unwrap());
}
#[test]
fn test_fail_store_returns_errors() {
let store = FailStore;
assert!(block_on(store.get("any")).is_err());
assert!(block_on(store.set("k".into(), "v".into())).is_err());
}
#[test]
fn test_trait_object_dispatch() {
let stores: Vec<Box<dyn AsyncStore>> = vec![Box::new(MemStore::new()), Box::new(FailStore)];
// First store works
assert!(block_on(stores[0].set("k".into(), "v".into())).is_ok());
// Second store fails
assert!(block_on(stores[1].get("k")).is_err());
}
}
Deep Comparison
OCaml vs Rust: Async Trait Pattern
Trait Definition
OCaml:
module type ASYNC_STORE = sig
type t
val get : t -> string -> string option Lwt.t
val set : t -> string -> string -> unit Lwt.t
end
Rust:
type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
trait AsyncStore: Send + Sync {
fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
fn set(&self, key: String, val: String) -> AsyncResult<(), String>;
}
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Polymorphism | First-class modules | dyn Trait |
| Boxing | Implicit (Lwt.t) | Explicit Box::pin |
| Object safety | N/A (modules) | Requires boxed return |
| Crate helper | N/A | #[async_trait] |
Exercises
AsyncStore backends (in-memory and a mock filesystem), and swap them in a function that takes &dyn AsyncStore.async-trait crate and compare its generated code to the manual boxing approach.async fn call.