O suporte a funções assíncronas em traits é um tópico que evoluiu bastante no Rust. Historicamente, era um grande desafio devido à natureza das futures e às limitações do sistema de tipos. Nesta aula, vamos entender por que era difícil, como o Rust moderno resolveu isso e quais alternativas existem para situações específicas.

Vamos explorar desde os problemas com traits object-safe até a sintaxe atual de async fn em traits, passando por bibliotecas como async-trait e técnicas como boxed futures. Ao final, você terá uma visão clara de como projetar APIs assíncronas em Rust.

Desafios históricos

Antes do Rust 1.75, não era possível escrever async fn diretamente em traits. O motivo principal é que async fn retorna um tipo opaco (impl Future), e os traits naquela época não suportavam tipos implícitos em assinaturas. Isso tornava impossível expressar o retorno de uma future sem especificar o tipo exato, o que violava a abstração necessária para traits.

Além disso, havia a questão da object-safety. Para que um trait pudesse ser usado como trait object (ex.: Box<dyn Trait>), todos os métodos precisavam ser object-safe. Como o tipo retornado por async fn não podia ser nomeado, os métodos não eram object-safe, impedindo o uso dinâmico. Isso limitava severamente o design de bibliotecas que precisavam de polimorfismo assíncrono.

Por exemplo, em versões antigas, tentar o seguinte código gerava um erro:

trait Service {
    async fn fetch(&self) -> String; // Erro: não suportado
}

Para contornar, os programadores recorriam a retornar Pin<Box<dyn Future>> manualmente, o que era verboso e menos ergonômico.

async fn em traits (estado atual)

A partir do Rust 1.75, a sintaxe async fn em traits foi estabilizada. Agora você pode escrever:

trait Service {
    async fn fetch(&self) -> String;
}

Internamente, o compilador transforma isso em um método que retorna um tipo impl Future, mas com uma diferença crucial: ele permite que o trait seja usado tanto em contexto estático quanto dinâmico, desde que você use a sintaxe async fn e não exija object-safety diretamente. Para tornar um trait com async fn object-safe, é necessário usar #[trait_variant::make] ou a macro async_trait (veremos nas alternativas).

Vamos ver um exemplo completo:

use std::future::Future;

// Definindo um trait com async fn
trait DataProvider {
    async fn get_data(&self) -> String;
}

// Implementação para um tipo concreto
struct ApiClient;

impl DataProvider for ApiClient {
    async fn get_data(&self) -> String {
        // Simula uma operação assíncrona
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        "dados da API".to_string()
    }
}

async fn use_provider(provider: &impl DataProvider) {
    let data = provider.get_data().await;
    println!("{}", data);
}

#[tokio::main]
async fn main() {
    let client = ApiClient;
    use_provider(&client).await;
}

Nesse exemplo, o trait DataProvider tem um método assíncrono e pode ser usado como argumento genérico. Isso já é um grande avanço.

Porém, ainda há limitações: o trait não é object-safe por padrão. Para usar Box<dyn DataProvider>, precisamos de uma macro. Vamos ver nas alternativas.

Alternativas

Existem duas principais alternativas para trabalhar com async em traits em cenários onde a sintaxe nativa não é suficiente:

1. A macro async-trait

A biblioteca async-trait (crate) é amplamente usada antes da estabilização e ainda é útil para tornar traits object-safe. Ela transforma métodos async fn em métodos que retornam Pin<Box<dyn Future + Send + '_>>, permitindo o uso como trait object.

use async_trait::async_trait;

#[async_trait]
trait DataProvider {
    async fn get_data(&self) -> String;
}

struct ApiClient;

#[async_trait]
impl DataProvider for ApiClient {
    async fn get_data(&self) -> String {
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        "dados".to_string()
    }
}

async fn use_provider(provider: &dyn DataProvider) {
    let data = provider.get_data().await;
    println!("{}", data);
}

Essa abordagem introduz uma overhead de alocação dinâmica (Box) e pode não ser adequada para sistemas de alto desempenho, mas é muito prática.

2. Retornar futures manualmente

Você pode definir o método para retornar um tipo impl Future ou Pin<Box<dyn Future>> manualmente. Isso dá mais controle e pode ser mais eficiente, mas é verboso.

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

trait DataProvider {
    fn get_data(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}

struct ApiClient;

impl DataProvider for ApiClient {
    fn get_data(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>> {
        Box::pin(async move {
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            "dados".to_string()
        })
    }
}

Essa abordagem é útil quando você precisa de controle fino sobre a future (ex.: enviar para outra thread).

3. Trait com associação de tipo (GATs)

Outra alternativa é usar Generic Associated Types (GATs), que permitem definir um tipo associado que é um Future. Essa é uma técnica mais avançada e pode ser mais eficiente, mas é complexa.

trait DataProvider {
    type Future: Future<Output = String> + Send;
    fn get_data(&self) -> Self::Future;
}

struct ApiClient;

impl DataProvider for ApiClient {
    type Future = Pin<Box<dyn Future<Output = String> + Send + 'static>>;
    fn get_data(&self) -> Self::Future {
        Box::pin(async { "dados".to_string() })
    }
}

Essa abordagem permite que o tipo de future seja específico da implementação, evitando alocações desnecessárias, mas exige mais código.

Boas práticas

Ao projetar traits assíncronos, prefira a sintaxe nativa async fn quando não precisar de object-safety. Se precisar de trait objects, use async-trait ou considere uma arquitetura diferente (ex.: usar enums em vez de traits). Evite retornar Box<dyn Future> manualmente a menos que seja necessário, pois isso adiciona overhead e complexidade.

Referências

Exercícios

  1. Escreva um trait chamado Fetcher com um método assíncrono fetch que retorna um Result<String, Error>. Implemente para um tipo HttpFetcher que simula uma requisição.

    ✓ Resposta:
    use std::error::Error;
    
    #[derive(Debug)]
    struct MyError;
    
    impl std::fmt::Display for MyError {
        fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            write!(f, "Erro de rede")
        }
    }
    
    impl Error for MyError {}
    
    trait Fetcher {
        async fn fetch(&self) -> Result<String, MyError>;
    }
    
    struct HttpFetcher;
    
    impl Fetcher for HttpFetcher {
        async fn fetch(&self) -> Result<String, MyError> {
            // Simula atraso de rede
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            Ok("conteúdo".to_string())
        }
    }
    
    #[tokio::main]
    async fn main() {
        let fetcher = HttpFetcher;
        match fetcher.fetch().await {
            Ok(data) => println!("Dados: {}", data),
            Err(e) => eprintln!("Erro: {}", e),
        }
    }
  2. Use a macro async-trait para tornar o trait Fetcher object-safe e crie uma função que aceite &dyn Fetcher.

    ✓ Resposta:
    use async_trait::async_trait;
    use std::error::Error;
    
    #[derive(Debug)]
    struct MyError;
    impl std::fmt::Display for MyError {
        fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            write!(f, "Erro")
        }
    }
    impl Error for MyError {}
    
    #[async_trait]
    trait Fetcher {
        async fn fetch(&self) -> Result<String, MyError>;
    }
    
    struct HttpFetcher;
    
    #[async_trait]
    impl Fetcher for HttpFetcher {
        async fn fetch(&self) -> Result<String, MyError> {
            Ok("dados".to_string())
        }
    }
    
    async fn do_fetch(fetcher: &dyn Fetcher) {
        match fetcher.fetch().await {
            Ok(data) => println!("Dados: {}", data),
            Err(e) => eprintln!("Erro: {}", e),
        }
    }
    
    #[tokio::main]
    async fn main() {
        let fetcher = HttpFetcher;
        do_fetch(&fetcher).await;
    }
  3. Implemente um trait assíncrono usando GATs (Generic Associated Types) para evitar alocações. Defina um tipo associado que implementa Future.

    ✓ Resposta:
    use std::future::Future;
    use std::pin::Pin;
    
    trait Fetcher {
        type Future: Future<Output = Result<String, String>> + Send;
        fn fetch(&self) -> Self::Future;
    }
    
    struct HttpFetcher;
    
    impl Fetcher for HttpFetcher {
        type Future = Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'static>>;
        fn fetch(&self) -> Self::Future {
            Box::pin(async {
                // Simula operação
                Ok("dados".to_string())
            })
        }
    }
    
    async fn use_fetcher<T: Fetcher>(fetcher: &T) {
        let result = fetcher.fetch().await;
        println!("Resultado: {:?}", result);
    }
    
    #[tokio::main]
    async fn main() {
        let fetcher = HttpFetcher;
        use_fetcher(&fetcher).await;
    }
  4. Explique por que async fn em traits não é object-safe por padrão e como contornar isso.

    ✓ Resposta:

    O async fn retorna um tipo opaco (impl Future), que não pode ser nomeado. Para que um método seja object-safe, o tipo de retorno deve ser concreto ou usar Self de forma que possa ser resolvido dinamicamente. Como o compilador não pode gerar um tipo concreto para cada implementação, o método não é object-safe. Para contornar, usamos async-trait que transforma o retorno em Pin<Box<dyn Future>> (concreto), ou usamos GATs com um tipo associado concreto (ex.: Pin<Box<dyn Future>>).

  5. Crie um exemplo que demonstre a diferença entre usar async fn nativo e a macro async-trait em termos de alocação.

    ✓ Resposta:

    Com async fn nativo, o compilador gera uma future de tamanho fixo que pode ser armazenada na pilha ou inline. Com async-trait, o retorno é Pin<Box<dyn Future>>, que aloca no heap. Por exemplo:

    // Nativo (sem alocação extra no heap)
    trait Native {
        async fn f(&self);
    }
    
    // async-trait (aloca Box)
    #[async_trait]
    trait Boxed {
        async fn g(&self);
    }

    O uso de Box pode impactar a performance em loops apertados, mas é necessário para trait objects.

Observações finais

O suporte a async fn em traits é um marco para a programação assíncrona em Rust. Ainda existem limitações, como a falta de object-safety nativa, mas as alternativas cobrem bem a maioria dos casos. Ao escolher uma abordagem, avalie a necessidade de polimorfismo dinâmico versus desempenho. Em bibliotecas de alto desempenho, prefira GATs ou async fn com genéricos; em aplicações que exigem flexibilidade, async-trait é uma solução pragmática.