async fn en traits en Rust: cómo usarlos correctamente ahora que son estables

Durante años, escribir código asíncrono en Rust con traits fue una de esas cosas que te hacía fruncir el ceño la primera vez que lo intentabas. No porque Rust no soportara async/await, que sí lo hace desde la versión 1.39, sino porque mezclar async fn con trait no funcionaba. Tenías que dar un rodeo.

Desde Rust 1.75 (diciembre de 2023), eso ya no es así. Puedes escribir async fn dentro de un trait de forma nativa, sin macros ni dependencias externas. Pero hay matices importantes, sobre todo cuando entran en juego el dispatch dinámico y los trait objects.

El problema que resolvía async-trait

Para entender qué ha cambiado, conviene recordar por qué async fn en traits no funcionaba antes.

Cuando escribes async fn, el compilador lo transforma internamente en una función que devuelve un Future. El tipo concreto de ese Future es anónimo y lo genera el compilador por ti. Hasta aquí, todo bien.

El problema aparece en los traits. Si defines un async fn en un trait, cada implementación del trait puede devolver un Future con un tipo diferente. Y Rust, para construir la vtable de un trait object (dyn Trait), necesita que todos los métodos tengan firmas conocidas y uniformes en tiempo de compilación. Con tipos de retorno distintos por implementación, eso no es posible de forma directa.

La solución que encontró el ecosistema fue la crate async-trait de David Tolnay. Lo que hace es simple: mediante una macro de procedimiento, transforma cada async fn del trait en una función que devuelve Pin<Box<dyn Future + Send + '_>>. Así todos los métodos tienen el mismo tipo de retorno y la vtable puede construirse sin problemas.

El coste es una asignación en el heap por cada llamada a un método async. En muchos contextos eso es perfectamente asumible, pero no es zero-cost. Si llamas al mismo método miles de veces en un bucle ajustado, la presión sobre el allocator se nota.

async fn en traits desde Rust 1.75

Con Rust 1.75 puedes escribir esto directamente:

trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, reqwest::Error>;
}

Y ya funciona. El compilador genera un tipo opaco de Future por cada implementación, sin Box, sin heap allocation. Si tienes dos structs que implementan Fetcher, cada una tiene su propio tipo de Future interno y el compilador lo resuelve en tiempo de compilación mediante monomorphization.

struct HttpClient;
struct MockClient;

impl Fetcher for HttpClient {
    async fn fetch(&self, url: &str) -> Result<String, reqwest::Error> {
        reqwest::get(url).await?.text().await
    }
}

impl Fetcher for MockClient {
    async fn fetch(&self, url: &str) -> Result<String, reqwest::Error> {
        Ok(format!("respuesta simulada para {}", url))
    }
}

El resultado es código más limpio y sin dependencias externas para el caso más común.

La restricción que te vas a encontrar

Ojo con esto: no puedes usar dyn Fetcher directamente cuando el trait tiene async fn. Si lo intentas, el compilador te dice que el trait no es "object safe". El motivo sigue siendo el mismo de siempre: distintas implementaciones tienen distintos tipos de Future y no hay vtable posible sin un tipo uniforme.

Si tu código no necesita dispatch dinámico (es decir, si sabes en tiempo de compilación qué tipo usas), esto no te afecta. Usa generics y listo:

async fn procesar<F: Fetcher>(fetcher: F, url: &str) {
    match fetcher.fetch(url).await {
        Ok(body) => println!("Recibido: {} bytes", body.len()),
        Err(e) => eprintln!("Error: {}", e),
    }
}

En la mayoría de los casos, esto es suficiente y más eficiente que el dispatch dinámico.

RPITIT: return position impl trait in traits

Relacionado con los async fn en traits, Rust 1.75 también estabilizó lo que se conoce como RPITIT: Return Position impl Trait in Traits.

Te permite escribir esto:

trait Fetcher {
    fn fetch(&self, url: &str) -> impl Future<Output = Result<String, reqwest::Error>>;
}

Es equivalente a async fn fetch(): un async fn no es más que azúcar sintáctico que desugara exactamente a eso. La diferencia es que RPITIT te da control explícito sobre la firma, lo que puede ser útil cuando quieres documentar el tipo de retorno con más precisión o añadir bounds adicionales.

En la práctica, para la mayoría del código nuevo simplemente usarás async fn porque es más legible. RPITIT está ahí cuando necesitas el control extra.

El dispatch dinámico: cómo resolverlo

Si realmente necesitas Box<dyn Fetcher> porque el tipo concreto no se conoce hasta el runtime (por ejemplo, para inyección de dependencias o en un servidor que elige el cliente HTTP según configuración), tienes varias opciones.

Opción 1: seguir usando async-trait

Para dispatch dinámico, async-trait sigue siendo la solución más directa. La macro se encarga de boxear los futures automáticamente y el trait object funciona sin problemas:

#[async_trait::async_trait]
trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, reqwest::Error>;
}

// Ahora puedes usar Box<dyn Fetcher> sin problemas
async fn procesar(fetcher: Box<dyn Fetcher>, url: &str) {
    let _ = fetcher.fetch(url).await;
}

Opción 2: trait_variant

La crate trait-variant es la solución mantenida por el propio equipo de Rust. No es externa en el sentido habitual: está en el repositorio oficial de Rust y forma parte del proyecto. Permite generar automáticamente una variante del trait compatible con dispatch dinámico:

#[trait_variant::make(FetcherDyn: Send)]
trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, reqwest::Error>;
}

// FetcherDyn es object-safe y puede usarse como Box<dyn FetcherDyn>

La macro genera un segundo trait (FetcherDyn en este caso) que boxea los futures internamente. Las implementaciones de Fetcher implementan FetcherDyn automáticamente. Es más limpio que async-trait para código nuevo porque separa explícitamente los dos casos de uso.

Opción 3: boxear manualmente

Si no quieres ninguna dependencia extra y el trait es tuyo, puedes escribir manualmente la versión con Box:

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

type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

trait Fetcher: Send + Sync {
    fn fetch<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result<String, reqwest::Error>>;
}

Más verboso, pero sin dependencias y con control total sobre los bounds.

Ejemplo completo: cliente HTTP con trait async

Para cerrar la teoría con algo práctico, aquí tienes un ejemplo que define el trait con async fn nativo, una implementación real con reqwest y un mock para tests:

use reqwest::Error;

// Trait con async fn nativo (Rust 1.75+)
trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, Error>;
}

// Implementación real
struct HttpClient {
    client: reqwest::Client,
}

impl Fetcher for HttpClient {
    async fn fetch(&self, url: &str) -> Result<String, Error> {
        self.client.get(url).send().await?.text().await
    }
}

// Mock para tests
struct MockFetcher {
    respuesta: String,
}

impl Fetcher for MockFetcher {
    async fn fetch(&self, _url: &str) -> Result<String, Error> {
        Ok(self.respuesta.clone())
    }
}

// Función genérica: acepta cualquier impl Fetcher
async fn obtener_titulo<F: Fetcher>(fetcher: &F, url: &str) -> String {
    match fetcher.fetch(url).await {
        Ok(body) => {
            // Extracción muy simplificada del título
            if let Some(start) = body.find("<title>") {
                if let Some(end) = body.find("</title>") {
                    return body[start + 7..end].to_string();
                }
            }
            String::from("sin título")
        }
        Err(e) => format!("error: {}", e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_obtener_titulo() {
        let mock = MockFetcher {
            respuesta: "<html><title>Hola mundo</title></html>".to_string(),
        };
        let titulo = obtener_titulo(&mock, "https://ejemplo.com").await;
        assert_eq!(titulo, "Hola mundo");
    }
}

El mock y la implementación real comparten el mismo trait. Los tests no necesitan un servidor HTTP real, y el código de producción no paga el coste de boxear futures.

¿Cuándo seguir usando async-trait en 2026?

Con todo esto, puede parecer que async-trait ha quedado obsoleta. No es así. Hay casos en los que sigue siendo la opción más práctica.

  • Cuando necesitas Box<dyn Trait> con métodos async y no quieres añadir trait-variant como dependencia. async-trait lo resuelve con una línea.
  • Cuando tu base de código ya la usa de forma extensa y migrar no aporta beneficio real. El coste del boxeo existe, pero en la mayoría de aplicaciones no es el cuello de botella.
  • Cuando tienes traits que mezclan métodos sync y async, y quieres un tratamiento uniforme. async-trait lo gestiona bien sin configuración adicional.
  • Para compatibilidad con versiones de Rust anteriores a 1.75, si por algún motivo no puedes actualizar el compilador.

Para código nuevo que no requiere dispatch dinámico, usa async fn nativo. Es más limpio, no tiene overhead de heap y no necesita dependencias externas. Si luego necesitas dyn Trait, valora si trait-variant encaja mejor que async-trait según el proyecto.

Puedes ver más sobre las últimas novedades del lenguaje en Rust 1.95 y las últimas mejoras del lenguaje, y si te preguntas por qué Rust sigue ganando terreno, hay una mirada amplia a dónde se usa Rust hoy en producción.

Imagen: Pexels / RealToughCandy.com

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP