宣布关键字泛型倡议

2022年7月27日 · Yoshua Wuyts 代表 关键字泛型倡议

我们(OliNikoYosh)很高兴地宣布关键字泛型倡议的启动,这是一项由语言团队负责的新倡议1。该倡议正式启动只有几周时间,在这篇文章中,我们想简要分享一下我们为什么启动这项倡议,并分享一些关于它的见解。

一种缺失的泛型类型

Rust 的一个核心特性是能够编写对其输入类型是泛型的函数。这使得我们可以编写一次函数,然后由编译器为我们生成正确的实现。

Rust 允许对类型进行泛型,但不允许对通常由关键字指定的其他事物进行泛型。例如,函数是否是 async、函数是否可能失败、函数是否是 const 等等。

帖子“你的函数是什么颜色?”2描述了当一门语言引入 async 函数却无法对其进行泛型时会发生什么

我随时都会选择 async-await 而不是裸露的回调或 Future。但如果我们认为所有麻烦都消失了,那是在自欺欺人。一旦你开始尝试编写高阶函数或重用代码,你就会立刻意识到“颜色”依然存在,并且蔓延到整个代码库。

然而,这不仅仅局限于 async,它适用于所有修饰符关键字,包括我们将来可能定义的关键字。因此,我们希望通过探索我们称之为“关键字泛型”的东西来填补这一空白3:能够对诸如 constasync 等关键字进行泛型的能力。

为了让您快速了解我们正在研究的内容,这大致是我们设想您未来可能如何编写对“异步性(asyncness)”进行泛型的函数

请注意,此语法完全是编造的,仅用于示例。在研究语法之前,我们需要确定语义,而我们目前尚未达到该阶段。这意味着语法可能会随时间变化。

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

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

此函数向 A 的函数中引入了一个“关键字泛型”参数。您可以将其视为一个标志,指示函数是否在 async 上下文中编译。参数 A 被转发到 impl Read,也使其取决于“异步性(asyncness)”。

在函数体中,您可以看到一个 .await 调用。因为.await 关键字标记了取消点,我们不幸地无法直接推断它们4。相反,我们要求在代码以 async 模式编译时必须编写它们,但在非 async 模式下,它们基本上被简化为无操作(no-op)。

我们还有很多细节需要解决,但我们希望这至少能展示出我们所追求的总体感觉

回顾过去:const 之前的可怕经历

Rust 语言中并非一直都有 const fn。很久很久以前(2018年),我们不得不为运行时计算编写常规函数,并为编译时计算使用泛型类型的关联常量逻辑。例如,要将数字 1 添加到提供给你的常量中,你必须这样写 (playground)

trait Const<T> {
    const VAL: T;
}

/// `42` as a "const" (type) generic:
struct FourtyTwo;
impl Const<i32> for FourtyTwo {
    const VAL: i32 = 42;
}

/// `C` -> `C + 1` operation:
struct AddOne<C: Const<i32>>(C);
impl<C: Const<i32>> Const<i32> for AddOne<C> {
    const VAL: i32 = C::VAL + 1;
}

AddOne::<FourtyTwo>::VAL

今天,这就像写一个 const fn 一样简单

const fn add_one(i: i32) -> i32 {
    i + 1
}

add_one(42)

有趣的是,你也可以在运行时代码中调用这个函数,这意味着实现可以在 const (CTFE5) 和非 const (运行时) 上下文之间共享。

回忆当下:今天的 async

人们为 async/non-async 编写重复代码,唯一的区别是 async 关键字。今天的一个很好的例子是 async-std,它复制并转换了 stdlib 的大部分 API 表面以支持 async6。而且由于 Async WG 已明确将使 async Rust 与非 async Rust 并驾齐驱作为目标,代码重复问题对 Async WG 来说也特别重要。Async WG 的成员似乎没有人特别热衷于提议我们在 stdlib 中添加几乎所有现有 API 的第二个实例。

我们今天在 async 方面的情况与 2018 年之前的 const 相似。例如,mongodb [async, non-async]、postgres [async, non-async] 和 reqwest [async, non-async] 等 crate 采取的方法是复制整个接口并将其包装在 block_on 调用中。

// Async functionality like this would typically be exposed from a crate "foo":
async fn bar() -> Bar { 
    // async implementation goes here
}
// And a sync counterpart would typically be exposed from a crate
// named "blocking_foo" or a submodule on the original crate as
// "foo::blocking". This wraps the async code in a `block_on` call:
fn bar() -> Bar {
    futures::executor::block_on(foo::bar())
}

这种情况并不理想。我们现在不是使用主机的同步系统调用,而是通过 async 运行时来获得相同的结果——这通常不是零成本的。但更重要的是,要保持同一个 crate 的同步和异步 API 版本之间的一致性相当困难。如果没有自动化,两个 API 很容易不同步,导致功能不匹配。

生态系统已经为此提出了一些解决方案,其中最值得注意的是基于 proc-macro 的 maybe-async crate。它不是为你编写两个独立的 foo 副本,而是为你生成同步和异步变体

#[maybe_async]
async fn foo() -> Bar { ... }

虽然很有用,但该宏在诊断和人体工程学方面存在明显的局限性。这绝不是 crate 的问题,而是它试图解决的问题的固有属性。实现一种对 async 关键字进行泛型的方式将以多种方式影响语言,类型系统 + 编译器将比 proc 宏更能合理地处理它。

麻烦初尝:三明治问题

现有 Rust 中普遍存在一个三明治问题。当一个传递给操作的类型想要执行该操作接收的类型不支持的控制流时,就会发生这种情况。从而创建了一个三明治7。经典的例子是 map 操作

enum Option<T> {
    Some(T),
    None,
}

impl<T> Option<T> {
    fn map<J>(self, f: impl FnOnce(T) -> J) -> Option<J> { ... }
}

my_option.map(|x| x.await)

这将产生一个编译器错误:闭包 f 不是 async 上下文,因此不能在其中使用 .await。而且我们也不能仅仅将闭包转换为 async,因为 fn map 不知道如何调用 async 函数。为了解决这个问题,我们可以提供一个新的 async_map 方法,它确实提供一个 async 闭包。但我们可能希望对更多效应重复这样做,这将导致效应的组合爆炸。例如,“可能失败”和“可能是 async”

非 asyncasync
不会失败fn mapfn async_map
可能失败fn try_mapfn async_try_map

仅一个方法就有大量的 API 表面积,而这个问题在 stdlib 的整个 API 表面积中呈倍数增长。我们期望一旦开始将“关键字泛型”应用于 trait,我们将能够解决三明治问题。类型 f 将被标记为对一组效应进行泛型,并且编译器将在编译期间选择正确的变体。

影响所有效应

constasync 都面临一个非常相似的问题,我们预计其他“效应”也将面临同样的问题。这里我们特别关注“可失败性(fallibility)”,但它不是唯一的效应。为了让语言感觉一致,我们需要一致的解决方案。

常见问题解答

问:是否有 RFC 可以阅读?

Rust 的倡议旨在进行探索。关键字泛型倡议的宣布标志着探索过程的开始。探索的一部分就是不知道结果会是什么。目前我们处于设计的“pre-RFC”阶段。我们希望实现的目标是列举完整的问题空间、设计空间,找到平衡的解决方案,并最终以 RFC 的形式进行总结。然后在 RFC 被接受后:在 nightly 版本上实现,解决问题,并最终走向稳定。但在整个过程中的任何时候,我们都可能得出结论,认为这项倡议实际上不可行,并开始逐渐停止。

虽然我们无法对这项倡议的结果做出任何保证,但我们可以分享的是,我们对整个倡议相当乐观。如果我们不认为我们能够真正将其完成,我们就不会为此投入时间和精力。

问:这会使语言更复杂吗?

关键字泛型的目标不是最小化 Rust 编程语言本身的复杂性,而是最小化使用 Rust 进行编程的复杂性。这两者听起来相似,但实际上不同。我们的逻辑是,通过增加一个特性,我们将能够显著减少 stdlib、crates.io 库和用户代码的表面积,从而带来更流畅的用户体验。

选择同步还是异步代码是一个必须做出的基本选择。这种复杂性是无法避免的,并且必须存在于某个地方。目前在 Rust 中,这种复杂性完全由用户承担,他们负责选择他们的代码是否应该支持 async Rust。但其他语言做出了不同的选择。例如,Go 不区分“同步”和“异步”代码,并且有一个能够消除这种区别的运行时。

在今天的 Rust 中,应用开发者选择他们的应用是同步还是异步,即使引入关键字泛型后,我们也不指望这一点会改变。所有泛型最终都需要知道它们的类型,关键字泛型也不例外。我们针对的是作者的选择,即他们的库是否支持同步或异步。有了关键字泛型,库作者将能够在编译器的帮助下同时支持两者,并由应用作者决定他们希望如何编译代码。

问:你们正在构建一个效应系统(effect system)吗?

简短的回答是:有点像,但不是真的。“效应系统”或“代数效应系统”通常有很大的表面积。效应允许你做的一个常见例子是实现你自己的 try/catch 机制。我们正在研究的东西有意地仅限于内置关键字,根本不允许你实现类似的东西。

我们与效应系统的共同点是,我们将修饰符关键字更直接地集成到类型系统中。像 async 这样的修饰符关键字通常被称为“效应”,因此能够以可组合的方式对其进行条件化,实际上为我们提供了一个“效应代数”。但这与其它语言中的“通用效应系统”非常不同。

问:除了 asyncconst 之外,你们还在关注其他关键字吗?

有一段时间我们曾将这项倡议称为“修饰符泛型”或“修饰符关键字泛型”,但这从未真正固定下来。我们真正感兴趣的只是那些修改类型工作方式的关键字。目前主要是 constasync,因为它们与 const-generics WG 和 async WG 最相关。但我们在设计这项特性时也考虑到了其他关键字。

我们最关注的是未来用于表示可失败性(fallibility)的关键字。目前有关于引入 try fn() {}fn () -> throws 语法的讨论。这可能使得诸如 Iterator::filter 这样的方法能够使用 ? 从闭包中跳出并短路迭代。

我们开发这项特性的主要动机是,没有它,Rust 很容易开始让人感觉脱节。我们有时开玩笑说,Rust 实际上是穿着一件风衣的 3-5 种语言。在 const Rust、可失败性的 Rust (fallible rust)、async Rust、unsafe Rust 之间,常见的 API 很容易只在语言的一种变体中可用,而在其他变体中不可用。我们希望通过这项特性,能够系统性地弥合这些差距,为所有 Rust 用户带来更一致的体验。

问:向后兼容性会是怎样的?

Rust 有相当严格的向后兼容性保证,我们实现的任何特性都需要遵守这一点。幸运的是,由于 edition 机制,我们有一些回旋空间,但我们的目标是实现最大限度的向后兼容。我们已经有一些关于如何实现这一目标的想法,并且我们谨慎乐观地认为我们可能真的能做到。

但坦白地说:这是这项特性中最困难的一个方面,我们很幸运不是独自设计这一切,而是得到了语言团队的支持。

问:实现有时难道不是根本不同的吗?

Const Rust 不能对运行的主机做任何假设,因此不能做任何平台相关的事情。这包括使用更高效的指令或只在一个平台上可用而在另一个平台不可用的系统调用。为了解决这个问题,标准库中的 const_eval_select intrinsic 允许 const 代码检测它是在 CTFE 期间还是运行时执行,并根据此执行不同的代码。

对于 async,我们期望能够添加类似的 intrinsic,允许库作者检测代码是作为同步还是异步编译的,并根据此执行不同的操作。这包括:使用内部并发,或切换到不同的系统调用集。不过,我们不确定 intrinsic 是否是正确的选择;我们可能希望为此提供一个更符合人体工程学的 API。但由于关键字泛型被设计为一个一致的特性,我们期望无论我们最终采用什么方式,所有修饰符关键字都可以一致地使用。

结论

在这篇文章中,我们介绍了新的关键字泛型倡议,解释了它存在的原因,并展示了它未来可能是什么样子的一个简要示例。

该倡议在 Rust Zulip 的 t-lang/keyword-generics 流中活跃 - 如果您对此感兴趣,请随时加入!

感谢所有帮助审阅这篇文章的人,特别是:fee1-deadDaniel Henry-MantillaRyan Levick