关键字泛型进展报告:2023 年 2 月

2023 年 2 月 23 日 · Yoshua Wuyts 代表 关键字泛型倡议

简介

大约 9 个月前,我们宣布成立了关键字泛型倡议;一个在 lang 团队下工作的团队,旨在解决函数着色问题1,不仅针对 async,而且针对 const 以及所有当前和未来的函数修饰符关键字,都通过类型系统来解决。

我们很高兴地分享,在过去的几个月里,我们取得了很大的进展,并且我们终于准备好通过 RFC 提交我们的一些设计。由于距离我们上次更新已经有一段时间了,而且我们很兴奋地想分享我们一直在做的事情,所以在这篇文章中,我们将回顾我们计划提出的一些内容。

一个 async 示例

在我们的上一篇文章中,我们引入了占位符 async<A> 语法来描述“函数在其异步性上是泛型的”的概念。我们一直都知道我们想要一些比这更轻量级的东西,所以在我们当前的设计中,我们选择放弃最终用户语法的泛型参数的概念,而是选择了 ?async 表示法。我们从 trait 系统中借用了这个,例如,+ ?Sized 表示某些东西可能实现或可能不实现 Sized trait。类似地,?async 表示一个函数可能是异步的,也可能不是异步的。我们也称这些为“可能异步”函数。

现在举个例子。假设我们采用 Read traitread_to_string_methods。在 stdlib 中,它们的实现现在看起来有点像这样

trait Read {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

/// Read from a reader into a string.
fn read_to_string(reader: &mut impl Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string)?;
    Ok(string)
}

现在,如果我们想在未来使这些异步化呢?使用 ?async 表示法,我们可以将它们更改为如下所示

trait ?async Read {
    ?async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?async fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

/// Read from a reader into a string.
?async fn read_to_string(reader: &mut impl ?async Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}

其工作方式是 Readread_to_string 将在其“异步性”上变得通用。当为 async 上下文编译时,它们将异步运行。当在非异步上下文中编译时,它们将同步运行。 read_to_string 函数体中的 .await 是必要的,以便在函数被编译为异步时标记取消点;但是当不异步时,它基本上会变成一个空操作2

// `read_to_string` is inferred to be `!async` because
// we didn't `.await` it, nor expected a future of any kind.
#[test]
fn sync_call() {
    let _string = read_to_string("file.txt")?;
}

// `read_to_string` is inferred to be `async` because
// we `.await`ed it.
#[async_std::test]
async fn async_call() {
    let _string = read_to_string("file.txt").await?;
}

我们预计 ?async 表示法对于不执行任何特定于异步 Rust 的库代码最有用。想想:大多数 stdlib 和生态系统库,如解析器、编码器和驱动程序。我们期望大多数应用程序选择编译为异步或非异步,使它们主要成为 ?async API 的消费者。

一个 const 示例

关键字泛型倡议的主要驱动力是我们希望 Rust 中不同的修饰符关键字彼此感觉一致。const WG 和 async WG 都同时考虑引入关键字 trait,并且我们认为我们应该开始相互交谈,以确保我们要引入的内容感觉像是同一语言的一部分,并且可以扩展以支持将来更多的关键字。

考虑到这一点,对于可能 const 的 trait 边界和声明,我们将提议使用 ?const 表示法,这可能并不奇怪。const fn 的一个常见混淆来源是它实际上并不保证编译时执行;它仅表示在 const 编译时上下文中可能进行评估。因此,在某种程度上,const fn 一直是一种声明“可能 const”函数的方式,并且没有办法声明“总是 const”函数。稍后在这篇文章中会详细介绍。

采用我们之前使用的 Read 示例,我们可以想象一个“可能 const”版本的 Read trait 看起来非常相似

trait ?const Read {
    ?const fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?const fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

然后我们可以像这样在 const read_to_string 函数中用作边界

const fn read_to_string(reader: &mut impl ?const Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string)?;
    Ok(string)
}

就像 ?async trait 一样,当用作边界时,?const trait 也需要标记为 ?const。这对于在 trait 级别上表面化非常重要,因为它允许将非 const 边界传递给可能 const 的函数,只要在函数体中不调用 trait 方法即可。这意味着我们需要区分“从不 const”和“可能 const”。

您可能已经注意到 trait 声明上的 ?const 和 trait 方法上的额外 ?const。这是故意的:它为在 trait 上添加对“总是 const”或“从不 const”方法的支持敞开了大门。在 ?async 中,我们知道即使整个 trait 是 ?async,某些方法(例如 Iterator::size_hint)也永远不会是异步的。这将使 ?const?async trait 使用相同的规则以类似的方式运行。

组合 const 和 async

我们已经介绍了 ?async,并且介绍了 ?const。现在,如果我们一起使用它们会发生什么?让我们看看,当我们使用我们为 ?const?async 设计的扩展时,Read trait 会是什么样子

trait ?const ?async Read {
    ?const ?async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?const ?async fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}

/// Read from a reader into a string.
const ?async fn read_to_string(reader: &mut impl ?const ?async Read) -> io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}

这肯定开始感觉像是很多关键字,对吧?我们准确地描述了正在发生的事情,但是有很多重复。我们知道,如果我们要处理 const ?async fn,则大多数参数可能都希望是 ?const ?async。但是,根据我们迄今为止提出的语法规则,您最终会在各处重复该操作。一旦我们开始添加更多关键字,情况可能会变得更糟。不理想!

因此,我们非常渴望确保找到一个解决方案。我们一直在考虑一种我们可以摆脱这种情况的方法,我们称之为 effect/.do 表示法。这将允许您通过将其注释为 effect fn 来将函数标记为“对所有修饰符关键字都是通用的”,并且它将允许编译器通过在函数调用后附加 .do 来在函数体中插入所有正确的 .await?yield 关键字。

只是为了设置一些期望:这是我们提案中开发最少的部分,并且我们不打算在完成其他一些提案之前正式提出它。但是我们认为这是整个愿景的重要组成部分,因此我们想确保在此处分享它。有了这个,这是我们上面相同的示例,但是这次使用 effect/.do 表示法

trait ?effect Read {
    ?effect fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?effect fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}

/// Read from a reader into a string.
?effect fn read_to_string(reader: &mut impl ?effect Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).do;  // note the singular `.do` here
    string
}

作为 effect/.do 的一部分,我们想弄清楚的一件事是启用编写条件 effect 边界的方法。例如:可能存在一个始终是异步的函数,可能永远不会 panic,并且对于其余效果是通用的。或者就像我们在 Vec::reserveVec::try_reserve 等 API 中看到的那样:panic xor 返回错误的能力。这将需要更多的时间和研究来弄清楚,但我们相信这是可以解决的。

添加对类型的支持

我们热衷于做的一件事不仅是添加对 ?async 的支持,还将其应用于函数、trait 和 trait 边界。我们希望 ?async 也可以与类型一起使用。这将使生态系统不必再提供 crate 的同步和异步版本。它还将使 stdlib 逐步“异步化”,就像我们一直在对 const 所做的那样。

异步类型的挑战,尤其是在 stdlib 中,是它们在异步和非异步上下文中使用时其行为通常必须不同。在最低级别,异步系统调用与非异步系统调用的工作方式略有不同。但是我们认为我们也可能通过 is_async 编译器内置方法找到解决方案。

假设我们想用单个 ?async open 方法实现 ?async File。我们期望它的外观如下

/// A file which may or may not be async
struct ?async File {
    file_descriptor: std::os::RawFd,  // shared field in all contexts
    async waker: Waker,               // field only available in async contexts
    !async meta: Metadata,            // field only available in non-async contexts
}

impl ?async File {
    /// Attempts to open a file in read-only mode.
    ?async fn open(path: Path) -> io::Result<Self> {
        if is_async() {   // compiler built-in function
            // create an async `File` here; can use `.await`
        } else {
            // create a non-async `File` here
        }
    }
}

这将使作者能够使用不同的字段,具体取决于它们是为异步编译还是为非异步编译,同时仍然共享一个共同的核心。并且在函数体中,也可以根据上下文提供不同的行为。函数体表示法将作为当前不稳定的 const_eval_select 内在函数的推广,并且至少对于函数体,我们希望提供类似的 is_const() 编译器内置函数。

一致的语法

正如我们在帖子前面提到的那样:我们在语言设计中看到的最大挑战之一是以一种使其感觉与语言的其余部分协调一致的方式添加功能,而不是以一种明显不同的方式突出出来。并且由于我们正在涉及 Rust 的核心内容(关键字的方式),因此我们必须格外注意此处,以确保 Rust 仍然感觉像是一种单一语言。

幸运的是,Rust 具有通过版本系统对语言进行表面级别更改的能力。有很多事情不允许我们这样做,但是它确实允许我们要求进行语法更改。我们正在探索的一种可能性是利用版本系统对 constasync 进行一些小的更改,以使它们彼此之间以及与 ?const?async 感觉更加一致。

对于 const 来说,这意味着在语法上应该区分 const 声明和 const 使用。正如我们在文章前面提到的,当你写 const fn 时,你得到的是一个可以在运行时和编译时求值的函数。但是当你写 const FOO: () = ..; 时,这里的 const 的含义保证了编译时求值。一个关键字,不同的含义。因此,出于这个原因,我们正在考虑是否将 const fn 改为 ?const fn 会更有意义。这将明确表明它是一个可能进行常量求值的函数,但不一定必须这样做 - 并且也可以从非 const 上下文中调用。

//! Define a function which may be evaluated both at runtime and during
//! compilation.

// Current
const fn meow() -> String { .. }

// Proposed
?const fn meow() -> String { .. }

对于 async,我们正在考虑一些类似的表面层更改。Async WG 正在将“trait 中的 async 函数”的设计扩展到一个完全涵盖“async trait”的设计,这主要是出于希望能够向匿名 future 添加 + Send 约束的愿望。Eric Holk 在 "轻量级、可预测的 Async Send 约束" 中有更多关于此的细节。但它大致会变成以下表示法

struct File { .. }
impl async Read for File {                                                // async trait declaration
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { .. }  // async method
}

async fn read_to_string(reader: &mut impl async Read) -> io::Result<String> { // async trait bound
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}

这将使 impl ?async Readimpl async Read 彼此保持一致。并且它将为将 trait ?async trait 传递给 impl async Read 并保证始终将其解释为 trait async 打开大门。这是另一个很好的统一性提升。

我们正在研究的最后一件事是类型的 async 表示法。要在类型上实现固有的 ?async 方法,我们当前的设计要求该类型也标记为 ?async。为了使 ?asyncasync 更接近,我们正在探索是否也需要将类型标记为 async 也是有意义的

//! Proposed: define a method on a maybe-async type
struct ?async File { .. }
impl ?async File {
    ?async fn open(path: PathBuf) -> io::Result<Self> { .. }
}

//! Current: define a method on an always-async type
struct File { .. }
impl File {
    async fn open(path: PathBuf) -> io::Result<Self> { .. }
}

//! Proposed: define a method on an always-async type
struct async File { .. }
impl async File {
    async fn open(path: PathBuf) -> io::Result<Self> { .. }
}

我们已经通过 const-generics 系统对“始终为 const”的参数进行了类似的处理。它们看起来像这样

fn foo<const N: usize>() {}

函数的每个“始终为 const”的参数都必须始终用 const 标记,因此对于每个“始终为 async”的类型始终需要使用 async 标记并非完全没有先例。因此,我们正在探索这里可能实现的一些东西。

初步计划

我们计划最初将精力集中在 asyncconst 关键字上。我们感觉已经准备好开始将我们的一些设计转换为 RFC,并开始发布它们以供审查。在接下来的几个月中,我们预计将开始编写以下提案(没有特定顺序)

  • 没有 trait 约束的 ?async fn 表示法,包括 is_async 机制。
  • trait async 声明和约束。
  • trait ?async 声明和约束,trait ?const 声明和约束。
  • 没有 trait 约束的 ?const fn 表示法。
  • struct async 表示法和 struct ?const 表示法。

我们将与 Lang Team、Const WG 和 Async WG 密切合作处理这些提案,在某些情况下(例如 trait async),我们甚至可能会扮演一个顾问的角色,由 WG 直接推动 RFC。像往常一样,这些提案将经历 RFC-nightly-stabilization 周期。只有当我们对它们完全有信心时,它们才会在稳定的 Rust 上可用。

我们有意地未将 effect/.do 表示法纳入此路线图。我们预计只有在 nightly 版本上有了 ?async 之后才能开始这项工作,而我们目前还没有。因此,目前我们将在倡议内继续进行设计工作,并暂缓制定引入它的计划。

结论

这就是关键字泛型倡议的 9 个月进度报告的总结。我们希望能够在 RFC 中提供关于解语法糖、语义和替代方案等方面的更确切的细节。我们对过去几个月取得的进展感到非常兴奋!有一件事我想我们还没提到,但可能值得分享:我们实际上已经对这篇文章中的大部分工作进行了原型设计;因此我们感觉非常有信心所有这些实际上可能会奏效。这让我们感到非常兴奋!

  1. 简要回顾一下这个问题:你不能从非 async 函数中调用 async fn。这使得“async”表示法像病毒一样传播,因为每个调用它的函数也需要是 async 的。但我们认为可能更重要的是:它需要重复大多数 stdlib 类型和生态系统库。相反,我们怀疑我们可能可以通过引入一种新的泛型来克服这个问题,这将使函数和类型在是否是 async、是否是 const 等方面具有“泛型”。

  2. ?async 上下文的一个限制是它们只能调用其他 ?async 和非 async 函数。因为如果我们能够调用始终为 async 的函数,那么在非 async 模式下编译时就没有明确的正确做法。因此,像 async 并发操作这样的东西在始终为 async 的上下文中不会直接工作。但我们有一种解决方法,我们稍后会在文章中讨论:if is_async() .. else ..。这允许你根据编译的模式来分支 ?async fn 的主体,并允许你为 async 和非 async 模式编写不同的逻辑。这意味着你可以选择在 async 版本中使用 async 并发,但在非 async 版本中保持顺序执行。