发布 trait 中的 `async fn` 和 返回位置 `impl Trait`

2023年12月21日 · Tyler Mandry 代表 Async 工作组

Rust Async 工作组很高兴宣布,我们在使 async fn 能够在 trait 中使用的目标上取得了重大进展。Rust 1.75 版本将于下周进入稳定版,它将包含对 trait 中 -> impl Trait 表示法和 async fn 的支持。

这是一个重要的里程碑,我们知道许多用户将迫不及待地想在自己的代码中尝试这些功能。但是,我们仍然缺少许多用户需要的一些重要功能。请继续阅读,了解关于何时以及如何使用这些已稳定功能的建议。

正在稳定化什么

自从 Rust 1.26 中 RFC #1522 稳定化以来,Rust 允许用户将 impl Trait 写为函数的返回类型(通常称为“RPIT”,即返回位置 impl Trait)。这意味着函数返回的是“某个实现了 Trait 的类型”。这通常用于返回闭包、迭代器以及其他复杂或无法显式编写的类型。

/// Given a list of players, return an iterator
/// over their names.
fn player_names(
    players: &[Player]
) -> impl Iterator<Item = &String> {
    players
        .iter()
        .map(|p| &p.name)
}

从 Rust 1.75 版本开始,你可以在 trait 定义和 trait impls 中使用 trait 中的返回位置 impl Trait (RPITIT)。例如,你可以用它来编写一个返回迭代器的 trait 方法

trait Container {
    fn items(&self) -> impl Iterator<Item = Widget>;
}

impl Container for MyContainer {
    fn items(&self) -> impl Iterator<Item = Widget> {
        self.items.iter().cloned()
    }
}

那么这一切与异步函数有什么关系呢?嗯,异步函数只是返回 -> impl Future 的函数的“语法糖”。既然这些现在可以在 trait 中使用,我们也允许你编写使用 async fn 的 trait

trait HttpService {
    async fn fetch(&self, url: Url) -> HtmlBody;
//  ^^^^^^^^ desugars to:
//  fn fetch(&self, url: Url) -> impl Future<Output = HtmlBody>;
}

存在哪些不足

公开 trait 中的 -> impl Trait

在**公开** trait 和 API 中,通常不鼓励使用 -> impl Trait,原因是用户无法为返回类型添加额外的约束。例如,没有办法以泛型方式对 Container trait 编写这个函数

fn print_in_reverse(container: impl Container) {
    for item in container.items().rev() {
        // ERROR:                 ^^^
        // the trait `DoubleEndedIterator`
        // is not implemented for
        // `impl Iterator<Item = Widget>`
        eprintln!("{item}");
    }
}

即使某些实现可能返回实现了 DoubleEndedIterator 的迭代器,泛型代码也无法利用这一点,除非定义另一个 trait。将来我们计划为此添加一个解决方案。目前,最好在内部 trait 或当你确定用户不需要额外约束时使用 -> impl Trait。否则,你应该考虑使用关联类型。1

公开 trait 中的 async fn

由于 async fn 会被解糖(desugar)为 -> impl Future,同样的限制也适用。事实上,如果你现在在公开 trait 中使用裸 async fn,你会看到一个警告。

warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
 --> src/lib.rs:7:5
  |
7 |     async fn fetch(&self, url: Url) -> HtmlBody;
  |     ^^^^^
  |
help: you can desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
  |
7 -     async fn fetch(&self, url: Url) -> HtmlBody;
7 +     fn fetch(&self, url: Url) -> impl std::future::Future<Output = HtmlBody> + Send;
  |

对于 async 用户来说,特别值得关注的是返回的 future 上的 Send 约束。由于用户无法在之后添加约束,错误消息是在说你作为 trait 作者需要做出选择:你是否希望你的 trait 能够与多线程、工作窃取(work-stealing)的执行器一起工作?

幸好,我们现在有一个解决方案,允许在公开 trait 中使用 async fn!我们建议使用 trait_variant::make 过程宏(proc macro),让用户自己选择。这个过程宏是 trait-variant crate 的一部分,由 rust-lang 组织发布。使用 cargo add trait-variant 将它添加到你的项目中,然后像这样使用它

#[trait_variant::make(HttpService: Send)]
pub trait LocalHttpService {
    async fn fetch(&self, url: Url) -> HtmlBody;
}

这会创建 两个 版本的 trait:LocalHttpService 用于单线程执行器,HttpService 用于多线程工作窃取执行器。由于我们预计后者会更常用,因此在此示例中使用了较短的名称。它有额外的 Send 约束

pub trait HttpService: Send {
    fn fetch(
        &self,
        url: Url,
    ) -> impl Future<Output = HtmlBody> + Send;
}

这个宏适用于 async,因为 impl Future 除了 Send 外很少需要其他额外约束,这样我们就能帮助用户成功使用。请参阅下面的常见问题解答,了解需要它的示例。

动态分派

使用 -> impl Traitasync fn 的 trait 不是对象安全的(object-safe),这意味着它们不支持动态分派。我们计划在 trait-variant crate 的后续版本中提供支持动态分派的工具。

我们希望未来如何改进

将来,我们希望允许用户为 impl Trait 返回类型添加自己的约束,这将使它们更普遍有用。这也将支持 async fn 的更高级用法。语法可能看起来像这样

trait HttpService = LocalHttpService<fetch(): Send> + Send;

由于这些别名不需要 trait 作者提供任何支持,从技术上讲,这将使 async trait 的 Send 变体不再必需。但是,这些变体仍然会为用户提供方便,因此我们预计大多数 crate 将继续提供它们。

当然,Async 工作组的目标不止于 trait 中的 async fn。我们希望在此基础上继续构建功能,以实现更可靠和更复杂的 async Rust 用法,并打算在新的一年发布一个更全面的路线图。

常见问题

在 trait 中使用 -> impl Trait 可以吗?

对于私有 trait,你可以自由使用 -> impl Trait。对于公开 trait,目前最好避免使用它们,除非你能预见到用户可能需要的所有约束(在这种情况下,你可以使用 #[trait_variant::make],就像我们在 async 中所做的那样)。我们预计将来会解除此限制。

我还需要使用 #[async_trait] 宏吗?

有几个原因可能需要你继续使用 async-trait

  • 你想支持早于 1.75 版本的 Rust。
  • 你需要动态分派。

如上所述,我们希望在 trait-variant crate 的未来版本中支持动态分派。

在 trait 中使用 async fn 可以吗?有什么限制?

假设你不需要出于上述原因之一而使用 #[async_trait],在 trait 中使用普通的 async fn 是完全可以的。只需要记住,如果你想支持多线程运行时(runtime),请使用 #[trait_variant::make]

最大的限制是,一个类型必须始终决定它实现的是 trait 的 Send 版本还是非 Send 版本。它不能 有条件地 根据其泛型之一来实现 Send 版本。例如,在使用 中间件(middleware) 模式时可能会遇到这种情况,例如如果 T: HttpServiceRequestLimitingService<T> 实现 HttpService。

为什么我需要 #[trait_variant::make]Send 约束?

在简单的情况下,你可能会发现你的 trait 在多线程执行器下工作正常。但是,有些模式是无法工作的。考虑以下情况

fn spawn_task(service: impl HttpService + 'static) {
    tokio::spawn(async move {
        let url = Url::from("https://rust-lang.net.cn");
        let _body = service.fetch(url).await;
    });
}

如果我们的 trait 没有 Send 约束,这将无法编译,并出现错误:“future cannot be sent between threads safely”(future 不能在线程间安全地发送)。通过创建带有 Send 约束的 trait 变体,你可以避免让你的用户陷入这个陷阱。

请注意,如果你的 trait 不是公开的,你不会看到警告,因为如果你遇到这个问题,你可以随时自己添加 Send 约束。

有关此问题的更详细解释,请参阅这篇博文2

我可以混合使用 async fn 和 impl trait 吗?

是的,你可以在 trait 和 impls 中自由地在 async fn-> impl Future 表示法之间切换。即使其中一种形式有 Send 约束,这一点也适用。3 这使得 trait_variant 创建的 trait 更易于使用。

trait HttpService: Send {
    fn fetch(&self, url: Url)
    -> impl Future<Output = HtmlBody> + Send;
}

impl HttpService for MyService {
    async fn fetch(&self, url: Url) -> HtmlBody {
        // This works, as long as `do_fetch(): Send`!
        self.client.do_fetch(url).await.into_body()
    }
}

为什么这些签名不使用 impl Future + '_

对于 trait 中的 -> impl Trait,我们提前采用了2024 Capture Rules。这意味着你在 trait 中今天经常看到的 + '_ 是不必要的,因为返回类型已经假定会捕获输入生命周期。在 2024 edition 中,这条规则将适用于所有函数签名。详情请参阅链接的 RFC。

当我实现一个使用 -> impl Trait 的 trait 时,为什么会收到“refine”警告?

如果你的 impl 签名包含比 trait 本身更详细的信息,你会收到一个警告

pub trait Foo {
    fn foo(self) -> impl Debug;
}

impl Foo for u32 {
    fn foo(self) -> String {
//                  ^^^^^^
//  warning: impl trait in impl method signature does not match trait method signature
        self.to_string()
    }
}

原因是你的实现可能泄露了比你预期更多的细节。例如,下面的代码应该编译通过吗?

fn main() {
    // Did the implementer mean to allow
    // use of `Display`, or only `Debug` as
    // the trait says?
    println!("{}", 32.foo());
}

多亏了refined trait 实现,它的确可以编译,但编译器要求你在 impl 上使用 #[allow(refining_impl_trait)] 来确认你细化(refine)trait 接口的意图。

结论

Async 工作组很高兴宣布完成了今年的主要目标,以此结束 2023 年!感谢所有积极参与设计、实现和稳定化讨论的人。也要感谢多年来提供了宝贵反馈的 async Rust 用户。我们期待着看到你们构建出什么,并将在未来几年继续提供改进。