程序员眼中的Rust:像初代iPhone,惊艳却有瑕疵!

图片

许多人认为 Rust 是一门保障内存安全的理想编程语言,尤其适合新开发者使用。然而,Rust 真的完美无缺吗?并非所有人都这么认为。本文作者 Seph 在经过四年的 Rust 开发体验后,将其比作“初代 iPhone”,尽管令人惊艳,却仍有诸多不足。针对这些问题,Seph 提出了几项自己的改进设想。

作者 | Joseph Gentle     
翻译工具 | ChatGPT  责编 | 苏宓

Rust 编程语言感觉像是第一代产品。

我的意思是,它就像第一代 iPhone——非常令人惊艳。要是到当时苹果公司围绕多点触控设计了一个完整的操作系统,打造一部没有实体键盘的智能手机,并且还有一个可用的网页浏览器,让整个科技圈为之兴奋。几个月后,我们都意识到了 iPhone 真正想成为的样子。然而,第一代 iPhone 并不完全成熟。它没有 3G 网络,没有 GPS 芯片,也没有应用商店。不过,在接下来的几年里,iPhone 变得越来越好。

如今 Rust 给人的感觉就有点像第一代 iPhone。

一开始,当我看到代数数据类型(Algebraic Data Types)、无需牺牲性能的内存安全(Memory Safety)、一个现代的包管理器(Package Manager)......我就喜欢上了 Rust。

只是现在,我编写 Rust 程序已经有四年左右了,它给我的感觉就是,始终差点意思。

而且我不知道它是否会有真正“成熟”的一天。毕竟当下这门语言的发展速度已经放缓了很多。还记得我当初刚开始使用它时,每个版本似乎都会为稳定版 Rust 增加一些新的强大功能。而现在呢?

寂静无声。

甚至不少人觉得,Rust 的 RFC(提案)流程简直是一个“好点子的墓地”。

比如协程(Coroutines)这种功能。这个提案已经有 7 年历史了。别误会,协程其实已经在编译器中实现了。它们只不过对我们这些使用“稳定版 Rust 的凡人”不可用。如果协程是个孩子的话,现在它都已经上小学了。到目前为止,这个协程的 RFC 存在的时间比第一次或第二次世界大战还长。

我怀疑 Rust 正在变得僵化,因为其维护团队内部迟迟无法达成共识。早期,Rust 有一个小型的贡献者群体,他们可以快速做出决策。但现在,在 GitHub 的 Rust 语言项目下,不乏有很多这样的讨论帖——25 个聪明且意图良好的人花了两年时间、超过 200 条评论,试图找出如何改进 Mutex(互斥锁)。但据我所知,最终他们差不多放弃了。

也许这是有意为之。也许我们也可以自我洗脑,即优秀的语言是一门稳定的语言。也许是时候将 Rust 视为一种成熟的语言了——不管它有什么瑕疵。就像 Python 2.7 一样,永远可用。

但对我来说,我无法说服自己。我仍然想要一个更好的 Rust 语言,但我却感觉无能为力。我的协程在哪里?就连 JavaScript 都有协程了。

图片

幻想自己改造 Rust 语言

有时候,我会在夜晚辗转反侧,幻想着分叉(fork)编译器。我知道该怎么做。在我的分叉版本中,我会保留所有的 Rust 特性,但会创建我自己的 “seph” 版本。然后我可以为这个版本添加各种破坏性的新特性。只要我的编译器还能编译主线 Rust 代码,我就可以继续使用 Cargo 上所有优秀的 crates(Rust 的包管理器中的库)。

我经常会这么想。如果我真的这么做了,以下是我会修改的内容:

图片

函数特性(效果)

Rust 的结构体(structs)上有特征(traits)。这些特征被用在各种场景中。有些是标记特性,有些是编译器内置的(比如 Copy 特性),还有一些是用户自定义的。

我觉得 Rust 也应该为函数定义一堆 trait。在其他编程语言中,函数特性通常被称为“效果”(effects)。

乍一听这可能有点奇怪,但请听我解释。你看,函数实际上有很多不同的“特性”,比如:

  • 这个函数会不会触发 panic?

  • 这个函数有固定的栈大小吗?

  • 这个函数会执行到结束,还是会中途 yield / await(挂起)?

  • 如果这是一个协程(coroutine),它的继续执行类型是什么?

  • 这个函数是纯函数(pure)吗?也就是说,相同的输入总是产生相同的输出,并且没有副作用。

  • 这个函数会不会在半可信的库中(直接或间接地)运行不安全的代码?

  • 这个函数是否保证会终止?

等等。

一个函数的参数和返回类型其实只是这个函数的关联类型(associated types):

fn some_iter() -> impl Iterator<Item = usize> {      vec![1,2,3].into_iter()}
fn main() { // Why doesn't this work already via FnOnce? let x: some_iter::Output = some_iter();}

公开这些属性非常有用。例如,Linux 内核希望在编译时确保某些代码块永远不会触发 panic(程序崩溃)。目前在 Rust 中这是不可能做到的。但通过使用函数特性(function traits),我们可以明确标记一个函数是否有可能触发 panic,或者明确它不会触发 panic:

#[disallow(Panic)] // Syntax TBD.fn some_fn() { ... }

而且如果函数做了任何可能触发 panic 的操作(即使是递归地触发),编译器会抛出错误。

实际上,编译器已经在某种程度上为函数实现了类似 Fn、FnOnce 和 FnMut 的特性(traits)。但不知为何,这些特性非常简陋。(为什么??)

我想要的是这样的东西:

/// Automatically implemented on all functions.trait Function {    type Args,  type Output,  type Continuation, // Unit type () for normal functions  // ... and so on.
fn call_once(self, args: Self::Args) -> Self::Output;}
trait NoPanic {} // Marker trait, implemented automatically by the compiler.
/// Automatically implemented on all functions which don't recurse.trait KnownStackSize { const STACK_SIZE: usize,}

然后你可以编写如下代码:

fn some_iter() -> impl Iterator<Item = usize> {    vec![1,2,3].into_iter();}
struct SomeWrapperStruct { iter: some_iter::Output, // In 2024 this is still impossible in stable rust.}

或者使用协程:

coroutine fn numbers() -> impl Iterator<Item = usize> {    yield 1;  yield 2;  yield 3;}
coroutine fn double<I: Iterator<Item=usize>>(inner: I) -> impl Iterator<Item = usize> { for x in inner { yield x * 2; }}
struct SomeStruct { // Suppose we want to store the iterator. We can name it directly: iterator: double<numbers>::Continuation,}

或者,举个例子,接受一个函数参数,但要求该参数本身不会触发 panic:

fn foo<F>(f: F)      where F: NoPanic + FnOnce() -> String{ ... }

图片

编译时能力(Compile-time Capabilities)

大多数 Rust 项目都会引入大量第三方库。其中大部分是小型的实用程序库——比如 human-size crate,它用于将文件大小格式化为人类易于理解的形式。非常棒!但不幸的是,这些小型库都会增加供应链风险。任何一个库的作者都有可能发布包含恶意代码的更新,可能加密锁定我们的计算机或服务器,甚至偷偷将恶意代码嵌入到我们的二进制文件中。

我认为这个问题类似于内存安全的问题。当然,有时程序员会编写不安全的内存代码,Rust 标准库中也充满了这样的代码。但 Rust 的 unsafe 关键字让开发者可以选择性地启用潜在不安全的功能。我们只在必要时添加 unsafe 块。

我们可以对特权函数调用(privileged function calls)做类似的事情——比如从文件系统或网络中读取和写入数据。这些功能很有用,但也有潜在的危险。开发者应该主动列入白名单中允许调用这些函数的代码。

要实现这一点,首先我们需要给标准库中所有与安全相关的函数添加标记特性(marker traits)(例如,从字符串中打开文件、执行命令、FFI、打开网络连接、大多数与原始指针交互的不安全函数等)。例如,std::fs::write(path, contents) 用用户的凭据将内容写入磁盘上的任意路径。我们可以给这个函数添加 #[cap(fs_write)] 标记,表示只能从某种受信任的代码中调用它。编译器会自动“污染”整个调用树中调用 write 的其他函数。

假设我调用了第三方 crate 中的一个需要 fs_write 能力的函数。为了调用该函数,我需要显式将该调用列入白名单。(可以通过在我的 Cargo.toml 文件中明确添加权限,或者可能在调用点处添加注释来实现)。

例如,假设 foo 库中包含这样的一个函数。该函数将被标记(污染)为“写入文件系统”的标签:

// In crate `foo`.

// (this function is implicitly tagged with #[cap(fs_write)])pub fn do_stuff() { std::fs::write("blah.txt", "some text").unwrap();}

当我尝试从我的代码运行该函数时:

fn main() {    foo::do_stuff();}

编译器可以给我一个很好的错误,如下所示:

Error: foo::do_stuff() writes to the local filesystem, but the `foo` crate has not been trusted with this capability in Cargo.toml.
Tainted by this line in do_stuff:
std::fs::write("blah.txt", "some text").unwrap();
Add this to your Cargo.toml to fix:
foo = { version = "1.0.0", allow_capabilities: ["fs_write"] }

显然,大多数 unsafe 的使用情况也需要列入白名单。

我使用的大多数库——比如 human-size 或 serde,并不需要任何特殊权限才能正常工作。因此,我们不必过于担心它们的作者会“变坏”并在我们的软件中添加恶意代码。将我目前依赖的大约 100 个库(通过传递依赖的方式)减少到仅依赖少数几个库,能大幅降低供应链风险。

这是引入能力到 Rust 中非常简单、静态的一种方式。但可能还有更好的方法,那就是将特权代码(privileged code)改为需要额外的能力参数(Capability parameter)(某种单元结构类型)。并严格限制 Capability 对象的实例化方式。例如:

struct FsWriteCapability;
impl FsWriteCapability { fn new() { Self } // Only callable from the root crate}
// Then change std::fs::write's signature to this:pub fn write(path: Path, contents: &[u8], cap: FsWriteCapability) { ... }

这需要更多的样例代码,但它的灵活性更高。(显然,我们还需要以某种方式对 `build.rs` 脚本和不安全代码块进行类似的处理。)

这一切的结果是,工具包变得“不可被破坏”。想象一下,如果 crates.io 被黑客入侵,`serde` 被恶意更新,包含了加密锁定代码。如今,这段恶意代码会在数百万开发者的机器上自动运行,并被编译进各处的程序中。而通过这个改变,你只会遇到编译错误。

这非常重要,这个功能单独就可能值回分叉 Rust 的成本。至少,对某些人来说是这样。(有没有人想赞助这项工作?)

图片

Rust 中的 Pin、Move 和 Struct Borrow

如果 Pin 和借用检查器让你感到头痛,可以跳过这一部分。

Rust 中的 Pin 是一种奇怪且复杂的补丁,用来解决借用检查器中的一个漏洞。这是一个在极端情况下为了维护向后兼容性而做出的奇怪选择。

  • 它和你真正想要的特性正好相反。一个标记 trait(类似 MoveCopy)来指示哪些对象可以移动,原本应该更有意义。但实际上并没有这样的 trait。

  • Pin 只适用于引用类型。如果你查看大量使用 Pin 的代码,会发现到处都有不必要的对值进行 Box 操作。例如,在 tokio 或类似 ouroboros、asynctrait 和 selfcell 这样的辅助库中。

  • 痛苦会蔓延。任何接受被 pin 的值的函数都需要用某种丑陋的东西包裹值,比如 Future::poll(self: Pin<&mut Self>, ..)。然后你需要想办法通过投影读取实际的值,而投影如此复杂,以至于有多个库专门处理它们。这种痛苦无法被遏制,它将永远蔓延,腐蚀一切。

我发誓,学习 Rust 中的 pinning 比我学习整个 Go 编程语言花的时间还要多。而且我仍然不敢说我完全掌握了它。我不是唯一一个有这种感受的人。我听说 Fuchsia 操作系统项目的某些部分因为 Pin 过于复杂,放弃了 Rust 转而使用 C++。

为什么我们需要 Pin 呢?

我们可以写出这样的 Rust 函数:

fn main() {      let x = vec![1,2,3];    let y = &x;

//drop(x); // error[E0505]: cannot move out of `x` because it is borrowed dbg!(y);}

Rust 函数中的所有变量实际上处于三种不同的状态之一:

  • 正常状态(所有权状态)

  • 借用状态

  • 可变借用状态

当一个变量被借用时(如 `y = &x`),你无法移动、修改或释放这个变量。在这个例子中,`x` 在 `y` 的生命周期内被放入了一个特殊的“借用”状态。在“借用”状态下的变量是被 pin 的、不可变的,并且还有许多其他的约束条件。这个“借用状态”对于编译器是可见的,但对程序员是完全不可见的。你无法知道某个东西被借用了,直到你尝试编译程序。(顺便说一句,我希望 Rust 的 IDE 能在编程时显示这种状态!)

但至少这个程序是可以正常工作的。

遗憾的是,对于结构体,没有类似的机制。让我们尝试将这个函数改为 `async`:

async fn foo() {      let x = vec![1,2,3];    let y = &x;
    some_future().await;
dbg!(y);}

当你编译这个程序时,编译器为你创建了一个隐藏的结构体,存储该函数暂停时的状态。它大概是这样的:

struct FooFuture {    x: Vec<usize>,  y: &'_ Vec<usize>,}
impl Future for FooFuture { ... }

`x` 被 `y` 借用了。因此,`x` 必须遵守所有借用变量的约束:

  • 不能在内存中移动(必须被 Pin 固定)

  • 必须是不可变的

  • 我们不能获取 `x` 的可变引用(因为存在 `&` 和 `&mut` 互斥规则)

  • `x` 的生命周期必须长于 `y`

但 Rust 没有任何语法来表达这一点。Rust 没有语法来标记结构体的字段为“借用状态”,而且我们也无法表达 `y` 的生命周期。

请记住:Rust 编译器在你使用 `async` 函数时已经自动生成并使用了这样的结构体,只不过它没有提供任何途径让我们自己编写这样的代码。那我们就扩展借用检查器来修复这个问题吧!

我不知道理想的语法会是什么样,但我相信我们可以设计出一些东西。比如,也许可以声明 `y` 为“局部借用”,写作 `y: &'Self::x Vec<usize>`。编译器可以使用这个注解来推断 `x` 被 `y` 借用了,并将其置于与函数内部借用变量相同的约束条件下。

这也能让你处理自引用结构体,比如编译器中的抽象语法树 (AST):

struct Ast {    source: String,  ast_nodes: Vec<&'Self::source str>,}

这种语法还可以适配支持部分借用:

impl Foo {    fn get_some_field<'a>(&'a self) -> &'a::some_field usize {    &self.some_field  }}

这还不是一个完整的解决方案。

我们还需要一个标记 trait 来替代 `MovePin`。任何带有借用字段的结构体都不能被移动(`Move`)——所以它不应该有 `MovePin`。我还会考虑一个名为 `Mover` 的 trait,它允许结构体智能地在内存中移动自己。比如:

trait Mover {    // Something like that.  unsafe fn move(from: *Self, to: MaybeUninit<&mut Self>);}

我们还需要一种合理且安全的方式来构造这样的结构体。我相信我们可以做得比 `MaybeUninit` 更好。

图片

编译时执行(Comptime)

这是一个热门的观点。我没有花太多时间研究 Zig 语言,但至少从远处看,我非常喜欢 comptime(编译时执行)。

在 Rust 编译器中,我们实际上实现了两种语言:Rust 和 Rust 宏语言。(好吧,严格来说应该有三种,因为还有 proc macros)。Rust 编程语言本身非常棒,但 Rust 的宏语言却非常糟糕。

但是,如果你已经懂了 Rust,为什么不直接使用 Rust 本身,而是插入另一种语言呢?这就是 Zig 语言中 comptime 设计的天才之处。编译器附带了一个小型解释器,可以在编译时执行你的部分代码。函数、参数、`if` 语句和循环都可以标记为编译时执行的代码。在代码块中,任何非编译时执行的代码都会被直接生成到程序中。

我不会在这里详细解释这个功能,而是展示它让 Zig 的 `std print` 函数有多么美妙。

它完全通过 comptime 实现。因此,当你在 Zig 中编写这样的代码时:

pub fn main() void {      print("here is a string: '{s}' here is a number: {}\n", .{ a_string, a_number });}

`print` 函数将格式化字符串作为编译时参数,并在编译时循环中解析它。除了几个关键字外,整个函数只是普通的 Zig 代码——任何熟悉这个语言的人都会觉得很自然。只是这些代码在编译器中执行。结果是,它生成了这个漂亮的代码:

pub fn print(self: *Writer, arg0: []const u8, arg1: i32) !void {      try self.write("here is a string: '");    try self.printValue(arg0);    try self.write("' here is a number: ");    try self.printValue(arg1);    try self.write("\n");    try self.flush();}

相比之下,我试着查找 Rust 中 `println!()` 宏的实现方式,但 `println!` 调用了某个神秘的 `format_args_nl` 函数。我猜那个函数是直接硬编码在 Rust 编译器中的。

当连 Rust 编译器的作者都不想使用 Rust 的宏语言时,这可不是什么好迹象。

图片

奇怪的小修复

加分回合时间。以下是我希望能顺便修复的一些“小问题”:

  • 对 `Range<T>` 实现 `impl<T: Copy>`。如果你懂的话,你就懂。

  • 让 `if-let` 表达式支持逻辑与(logical AND)。这非常简单、明显且实用。它应该像这样工作:

// Compile error! We can't have nice things.if let Some(x) = some_var && some_expr { }

你可以像下面这样变通解决这个问题,但写起来很别扭,读起来也不直观。而且,它的语义与正常的 `if` 语句不同,因为它缺少短路求值(short-circuit evaluation)。

// check_foo() will run even if some_var is None.if let (Some(x), true) = (some_var, check_foo()) { ... } 

Rust 的原始指针使用体验简直糟糕透顶。当我处理不安全代码(unsafe code)时,我的代码应该尽可能易读易写。但是 Rust 编译器似乎有意在“惩罚”我。例如,如果我有一个指向结构体的引用(reference),我可以写 `myref.x`。但如果我有一个指针(pointer),Rust 则要求我写 `(*myptr).x`,更糟的是:`(*(*myptr).p).y`。太可怕了!不仅可怕,而且完全适得其反。不安全代码应该是清晰的。

我还会把所有内置的集合类型修改为在构造函数中接收一个分配器(Allocator)作为参数。我个人不喜欢 Rust 的全局分配器(global allocator)设计。显式优于隐式。

图片

总结

以上就是我的所有想法。其实,async(异步)也需要一些改进,但这个话题太大了,值得单独写一篇文章。

不幸的是,这些改动大多会与现有的 Rust 版本不兼容。即便是增加安全能力也需要一个新的 Rust 版本,因为它引入了一种新的方式,可能会打破 crates 的 semver(语义版本控制)兼容性。

几年前,我可能还会考虑为这些提议写一些 RFCs(请求评审)。但我更喜欢编程,而不是慢慢死在 GitHub 上的 RFC 评论深坑中。我不想让几个月的努力再一次成为 Rust 那个未实现梦想的“垃圾填埋场”中的一员。

也许我应该自己分叉(fork)编译器来做这些改动。唉,项目太多了。如果我能活一百万年,我会专门投入一生来研究编译器。

图片