宣布关键字泛型计划

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

我们(OliNikoYosh)很高兴地宣布 关键字泛型计划的启动,这是语言团队管辖下的一个新计划 1。我们正式成立仅几周,在这篇文章中,我们想简要分享我们为什么启动这个计划,并分享一些关于我们工作的见解。

一种缺失的泛型

Rust 的一个显著特点是能够编写对其输入类型具有泛型的函数。这使我们能够编写一次函数,并让编译器为我们生成正确的实现。

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

文章 “你的函数是什么颜色” 2 描述了当一种语言引入异步函数,但无法对其进行泛型化时会发生什么。

我宁愿选择 async-await 而不是裸回调或 futures。但是,如果我们认为所有麻烦都消失了,那我们就是在自欺欺人。一旦你开始尝试编写高阶函数或重用代码,你会很快意识到颜色仍然存在,并渗透到你的整个代码库中。

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

为了让你快速了解我们正在做的事情,这大致是我们设想的将来如何编写一个对“异步性”具有泛型性的函数

请注意,此语法完全是虚构的,只是为了我们可以在示例中使用。在我们开始研究语法之前,我们需要最终确定语义,而我们还没有到那一步。这意味着语法可能会随着时间推移而发生变化。

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 的函数中。你可以将其视为一个标志,指示该函数是在异步上下文中编译的还是不是。参数 A 被转发到 impl Read,这也使其取决于“异步性”。

在函数体中,你可以看到一个 .await 调用。因为 .await 关键字标记了取消点,所以我们很遗憾不能只是推断出它们 4。相反,我们要求当代码在异步模式下编译时写入它们,但在非异步模式下它们基本上被简化为无操作。

我们还有许多细节需要弄清楚,但我们希望这至少展示了我们目标的大致感觉

回顾过去: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-std,它复制并将 stdlib 的大部分 API 表面翻译为异步 6。并且由于 Async WG 已明确提出要 使异步 Rust 与非异步 Rust 并驾齐驱,因此代码重复的问题对于 Async WG 也尤为重要。Async WG 中的任何人似乎都不特别热衷于建议我们在 stdlib 中添加几乎所有 API 的第二个实例。

我们今天在 async 中面临着与 2018 年之前的 const 类似的情况。复制整个接口并将它们包装在 block_on 调用中是例如 mongodb [异步非异步],postgres [异步非异步] 和 reqwest [异步非异步] crate 所采取的方法

// 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())
}

这种情况并不理想。我们现在不是使用主机的同步系统调用,而是通过异步运行时来获得相同的结果——这通常不是零成本的。但更重要的是,很难保持同一 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 不是异步上下文,因此不能在其中使用 .await。而且我们也不能将闭包转换为 async,因为 fn map 不知道如何调用异步函数。为了解决这个问题,我们可以提供一个新的 async_map 方法,该方法确实提供异步闭包。但是我们可能希望对更多效果重复这些操作,这将导致效果的组合爆炸。例如,“可能失败”和“可能是异步的”

非异步 异步
无错误 fn map fn async_map
可能失败 fn try_map fn async_try_map

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

影响所有效果

constasync 都存在非常类似的问题,我们预计其他“效果”也会面临同样的问题。“可失败性”是我们特别关注的问题,但它不是唯一的效果。为了使语言感觉一致,我们需要一致的解决方案。

常见问题解答

问:是否有可供阅读的 RFC?

Rust 计划旨在进行探索。关键字泛型计划的宣布标志着探索过程的开始。探索的一部分是不了解结果会是什么。现在我们处于设计的“pre-RFC”阶段。我们希望实现的是枚举完整的问题空间、设计空间,找到一个平衡的解决方案,并最终以 RFC 的形式总结出来。然后在 RFC 被接受后:在 nightly 上实现它,解决问题,并最终进行稳定化。但是我们可能会在这个过程中的任何时候得出结论,这个计划实际上是不可行的,并开始逐步减少。

虽然我们无法对该计划的结果做出任何保证,但我们能分享的是,我们对该计划的整体前景非常乐观。如果我们认为我们实际上能够看到它的完成,我们就不会投入我们现在投入的时间。

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

关键字泛型的目标不是最小化 Rust 编程语言的复杂性,而是最小化在 Rust 中编程的复杂性。这两个听起来可能相似,但它们并非如此。我们在此的理由是,通过添加一项功能,我们将实际上能够显著减少 stdlib、crates.io 库和用户代码的表面积——从而带来更精简的用户体验。

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

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

问:你们在构建效果系统吗?

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

我们与效果系统(effect systems)的共同之处在于,我们将修饰符关键字更直接地集成到类型系统中。像 async 这样的修饰符关键字通常被称为“效果(effects)”,因此能够在可组合的方式中对它们进行条件处理,实际上为我们提供了一个“效果代数”。但这与其它语言中的“广义效果系统”非常不同。

问:除了 asyncconst 之外,你们是否考虑其他关键字?

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

我们最先想到的是一个用于表示失败性的未来关键字。有人讨论引入 try fn() {}fn () -> throws 语法。这可以使得诸如 Iterator::filter 之类的方法能够使用 ? 来跳出闭包并短路迭代。

我们这个功能的主要动机是,没有它,Rust 很容易开始感觉不连贯。我们有时会开玩笑说,Rust 实际上是披着一件外套的 3-5 种语言。在 const rust、fallible rust、async rust、unsafe rust 之间,常见的 API 很容易只在语言的某个变体中可用,而在其他变体中不可用。我们希望通过这个功能,我们可以开始系统地弥合这些差距,从而为所有 Rust 用户带来更一致的 Rust 体验。

问:向后兼容性会如何?

Rust 有非常严格的向后兼容性保证,我们实现的任何功能都需要遵守这一点。幸运的是,由于版本机制,我们有一些回旋余地,但我们的目标是争取最大的向后兼容性。我们有一些关于如何实现这一目标的想法,并且我们谨慎乐观地认为我们可能真的能够实现它。

但坦率地说:这是这个功能迄今为止最难的一个方面,我们很幸运,我们不是独自设计这些东西,而是得到了语言团队的支持。

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

Const Rust 不能对其运行的主机做任何假设,因此它不能做任何特定于平台的事情。这包括使用在某个平台上可用但在另一个平台上不可用的更高效的指令或系统调用。为了解决这个问题,标准库中的 const_eval_select 内在函数使 const 代码能够检测它是在 CTFE 期间还是在运行时执行,并基于此执行不同的代码。

对于 async,我们希望能够添加一个类似的内在函数,允许库作者检测代码是被编译为同步还是异步,并基于此执行不同的操作。这包括:使用内部并发,或切换到另一组系统调用。我们不确定内在函数是否是正确的选择;我们可能希望为此提供一个更符合人体工程学的 API。但是,由于关键字泛型被设计为一致的功能,我们期望我们最终使用的任何东西都可以被所有修饰符关键字一致地使用。

结论

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

该倡议在 Rust Zulip 上 t-lang/keyword-generics 下活跃 - 如果您对此感兴趣,请来逛逛!

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

  1. Rust 的治理术语有时会让人困惑。在 Rust 的说法中,“倡议”不同于“工作组”或“团队”。倡议是有意限制的:它们的存在是为了探索、设计和实施特定的工作 - 一旦这项工作结束,倡议就会逐渐结束。这与语言团队不同,例如,它基本上带有 'static 生命周期,并且其工作没有明确的结束。

  2. R. Nystrom,“你的函数是什么颜色的?”,2015 年 2 月 1 日。https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/(访问时间:2022 年 4 月 6 日)。

  3. 更长、更具体的名字将是:“关键字修饰符泛型”。我们尝试过这样称呼它,但这有点拗口。所以我们现在只坚持使用“关键字泛型”,即使此功能的名称最终在参考和文档中可能会被称为什么更具体的东西。

  4. 不,我们真的不能仅仅推断它们 - 而且它可能不像省略所有 .await 调用那么简单。Async WG 正在研究取消站点、异步 drop 等等的所有方面。但目前,我们假设 .await 在未来仍然是相关的。即使在不太可能的情况下,失败性在调用站点也有与 async 类似的要求。

  5. CTFE 代表“编译时函数执行”:const 函数可以在编译期间求值,这是使用 Rust 解释器 (miri) 实现的。

  6. async-std 中存在一些限制:异步 Rust 缺少异步 Drop、异步 trait 和异步闭包。因此,并非所有 API 都可以重复。此外,async-std 明确没有重新实现任何集合 API 以使其感知异步,这意味着用户会受到“三明治问题”的影响。async-std 的目的是作为一个试验场,测试创建 stdlib 的异步镜像是否可行:并且它已经证明了这一点,在缺少语言功能的情况下尽可能地实现了这一点。

  7. 不要与高阶三明治困境混淆,当你看到三明治问题时,试图确定三明治是两片面包夹着馅料,还是两片馅料夹着一片面包。在我看来,问题的操作部分感觉更像面包,但这会做成一个看起来很奇怪的三明治。因此:三明治困境。(是的,你可以忽略所有这些。)