Rust,这门编程语言,给人的感觉就像是一代划时代的产品。就像第一代iPhone那样,它围绕全新的理念设计了一整套系统——没有垃圾回收却保证了内存安全,还引入了一套现代的包管理器。
没过多久,整个行业都意识到了Rust真正想要成为的是什么。但问题是,第一代iPhone并未完全做到。它没有3G网络、没有GPS芯片,也没有App Store。后来的几年,iPhone才通过迭代变得更好。而现在的Rust,就有点像那个阶段的iPhone。
第一次接触Rust时,就被它吸引住了。代数数据类型?在不牺牲性能的前提下实现内存安全?还有Cargo?这些简直太棒了!但用了四五年之后,开始感觉它似乎一直“差那么一口气”。甚至开始怀疑,它最终是否真的会“到位”。
语言本身的发展节奏明显慢了下来。早期每次发布新版本都会带来不少实用的新特性,但现在呢?几乎听不到什么动静。官方列出了超过700个不稳定功能——估计大部分都已经实现,只是还没放入稳定版。其中很多是标准库的改动,但说实话,这个数量也太夸张了。
这些东西最终有多少能进入正式版?Rust的RFC流程似乎已成了各种好点子的“坟场”。比如协程(coroutines)这个RFC,已经提出了七年。别误会,协程其实在编译器里已经实现了,只是我们这些使用稳定版的“平民”还用不上。如果协程是个孩子,现在都该上小学了。
问题可能出在Rust的共识机制上。早期Rust还是一个小圈子,核心成员可以快速拍板决定很多事情。而现在呢?你可能会看到这样的场景:25位聪明又热心的开发者花两年时间,在某一个issue下写了数百条评论,只为改进一个Mutex。但据我所知,他们最后几乎放弃了。
也许这就是设计的宿命。一门好的语言也应该是一门稳定的语言。或许是时候把Rust当作一门已经成型的语言来看待了——哪怕带着它的种种缺陷,就像Python 2.7一样,从此不再改变。
但这无法令人满意。我们仍渴望一门更好的Rust,却感觉无能为力。那些协程到底什么时候能来?连JavaScript都有了async/await。
函数特质(Effects)
Rust为结构体定义了trait,这些trait用法广泛:有些是标记型trait,有些是编译器理解的trait(比如Copy),还有一些是用户自定义的。
那为什么不对函数也这么做呢?Rust应该给函数也定义一些trait,这些在别的语言中常被称为“效果”(effects)。
乍一听可能有点奇怪,但请听我说完。函数有很多不同的“属性”,比如:
- 这个函数是否会panic?
- 这个函数是否有固定的栈大小?
- 这个函数是执行到底,还是会yield或await?
- 如果是协程,它的continuation类型是什么?
- 这个函数是否是“纯”的?(即相同输入总是产生相同输出,并且没有副作用)
- 这个函数是否(直接或间接)运行了半信任库中的
unsafe代码?
- 这个函数是否保证一定会终止?
这些都是可以静态分析出来的信息。把这些暴露出来非常有用。比如说,Linux内核希望在编译期就确保某段代码永远不会panic。但在目前的Rust中这是做不到的。如果我们有函数trait,就可以显式地标记一个函数是否允许panic:
#[disallow(Panic)] // 语法待定
fn some_fn() { ... }
如果这个函数做了任何可能panic的操作(哪怕是递归调用),编译器就会报错。
其实编译器已经在某种程度上对函数做了trait支持,比如Fn、FnOnce和FnMut。但它们的功能太弱了。
我想要的更像下面这样的东西:
/// 所有函数都会自动实现这个trait。
trait Function {
type Args;
type Output;
type Continuation; // 普通函数的 continuation 是 ()
fn call_once(self, args: Self::Args) -> Self::Output;
}
trait NoPanic {} // 标记trait,由编译器自动实现。
/// 所有不会递归的函数都会自动实现这个trait。
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, // 在2024年,这在稳定版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 {
// 假设我们想存储这个迭代器。我们可以直接命名它:
iterator: double<numbers>::Continuation,
}
再举个例子,你可以要求一个函数参数不能panic:
fn foo<F>(f: F)
where
F: NoPanic + FnOnce() -> String
{
...
}
Yoshua Wuyts曾写过一篇精彩的文章,深入探讨了effects的意义以及如何实现这一机制。
编译时能力控制
现在的Rust项目往往依赖大量的第三方crate。这些crate大多是小型工具库,比如用来格式化文件大小的库。虽然很有用,但这些依赖也带来了不小的安全隐患。任何一个作者都有可能推送恶意更新——加密你的电脑、服务器,甚至悄悄往你的二进制中注入坏代码。
这个问题和内存安全问题很像。确实,有时候我们需要写不安全的代码。Rust的标准库里也到处是unsafe代码。但Rust提供了一个unsafe关键字,让开发者可以主动选择进入不安全区域,只有在必要时才使用。
那我们能不能也用类似的方式处理权限敏感的操作?比如读写文件系统、网络连接、FFI调用等等。这些功能虽然有用,但潜在风险很高。开发者应该明确授权哪些代码可以调用这些功能。
要实现这一点,首先我们需要在标准库中所有与安全相关的函数上加上标记trait。例如,通过字符串路径写入磁盘的函数,我们可以加上#[cap(fs_write)]标记,表示只有受信任的代码才能调用。编译器会自动将调用这条函数的所有代码“污染”成带有fs_write权限。
假设你在使用一个第三方crate,里面有个函数需要fs_write权限。为了调用它,你必须显式地在Cargo.toml中将其加入白名单,或者在调用处加注解。
比如,这个crate里的函数:
// 在 `foo` crate中
// (这个函数隐式地被打上了 #[cap(fs_write)] 标签)
pub fn do_stuff() {
std::fs::write("blah.txt", "some text").unwrap();
}
当你尝试从自己的代码中调用它:
fn main() {
foo::do_stuff();
}
编译器会给出类似这样的提示:
Error: foo::do_stuff() 会写入本地文件系统,但 `foo` crate并未在Cargo.toml中获得fs_write权限。
污染来源是 do_stuff 中的这一行:
std::fs::write("blah.txt", "some text").unwrap();
请在你的Cargo.toml中添加如下内容以修复:
foo = { version = "1.0.0", allow_capabilities: ["fs_write"] }
这种机制可以极大地提升项目的供应链安全性。就像我们用unsafe来限制内存不安全行为一样,用能力控制来限制权限滥用行为。
Rust很棒,但它似乎正在变得僵化。社区流程缓慢、RFC搁浅、功能延迟上线……这些问题让人沮丧。而与此同时,外部需求却在不断增长。
有没有一种方式,可以在保持兼容性的同时,引入更多表达力和安全性?也许不是所有人都愿意接受这些变化,但至少值得探索。毕竟,语言的进化从来都不是一蹴而就的,而是一次又一次大胆尝试的结果。
你有没有想过,如果你也能自由地修改编译器,你会怎么设计你心中的理想语言?
很明显,大多数使用unsafe的场景都需要显式白名单机制。我用的大部分库,比如human-size或者serde,它们本身并不需要任何特殊权限就能正常工作。所以也不必太担心这些库的作者“变坏”后往代码里塞恶意内容。把依赖链条上的风险从上百个crate缩减到寥寥几个核心crate,会是一个巨大的进步。
这只是一个非常简单、静态的方式来给Rust引入能力控制机制。但或许还有更好的办法——我们可以让特权代码要求一个额外的Capability参数(某种单元结构体类型),并严格限制Capability对象的创建方式。例如:
struct FsWriteCapability;
impl FsWriteCapability {
fn new() -> Self { Self } // 只能在根crate中调用
}
然后修改std::fs::write的签名如下:
pub fn write(path: Path, contents: &[u8], cap: FsWriteCapability) { ... }
这种方式虽然多了些样板代码,但灵活性高得多。(当然,我们还需要以类似方式处理build.rs脚本和unsafe块。)
最终效果是:工具类crate将变得“不可篡改”。想象一下,如果crates.io被黑,serde被恶意更新,加入了加密勒索代码。现在,这段恶意代码会在数百万台开发者机器上自动运行,并被编译进各种程序中。而有了这个机制之后,你只会收到一条编译错误。
这是革命性的变化,单凭这一项功能,就值得考虑fork一份Rust。 至少对某些人来说是如此。(有人愿意赞助这项工作吗?)
Pin、Move和结构体内引用
如果你看到Pin和借用检查器就头疼,那这部分可以跳过。
Pin是一个奇怪又复杂的权宜之计,用来弥补借用检查器中的漏洞。它就像一块创可贴,背后隐藏着一系列令人费解的选择,只是为了维持向后兼容性。
它其实并不是你真正想要的那个trait。更合理的做法是引入一个像Copy那样的Move标记trait,表示对象是否可以移动。
但Pin并不是一个真正的trait。它只有Unpin(双重否定)和!Unpin(三重否定)这样的标记,本质上是在说“非非可移动”。例如,!Unpin等同于Pin吗?呃……不完全是?因为……原因复杂。
Pin只适用于引用类型。在大量使用Pin的代码中,你会看到一堆不必要的Box化操作。痛苦蔓延开来。任何接受Pinned值的函数都需要将值包装成某个可怕的结构体。然后你还得想办法通过“投影”读取实际值,而投影机制复杂到有专门的库来处理。这种痛苦无法被隔离,它会不断扩散,污染整个项目。
我发誓,学习Rust中的Pin所花的精力,比我学完整个Go语言还要多。而且我现在也不敢说自己完全搞懂了。我也不是一个人,听说有些人在某些部分甚至放弃了Rust改用C++,就是因为Pin把事情搞得过于复杂。
那为什么我们需要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);
}
在这个例子中,变量x在y = &x的时候进入了“借用状态”,不能被移动、修改或释放。这个“借用状态”是编译器能识别的,但对程序员来说却是隐形的。你只能在尝试编译时才知道某样东西是否被借用了。(旁注:我希望IDE能在编程过程中显示这个状态!)
但至少这个程序能跑起来。
问题在于,对于结构体却没有类似的机制。我们把这个函数改成异步版本:
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借用了,因此必须满足与函数内变量借用相同的约束条件:
- 它不能在内存中移动(需要被固定)
- 它必须是不可变的
- 我们不能获取它的可变引用(因为
&和&mut排斥规则)
x必须比y活得久
但我们没有语法表达这一点。Rust没有提供标记结构体字段为“借用状态”的语法。我们也无法表达y的生命周期。
记住:每当使用异步函数时,Rust编译器都会生成这样的结构体。只是它没有提供让我们自己编写这类代码的方式。那我们为什么不扩展借用检查器来修复这个问题呢?
我不知道理想的语法应该是什么样,但我相信我们一定能找到一个方案。比如,也许y应该声明为“局部借用”,写成y: &'Self::x Vec<usize>。编译器通过这个注解知道x被借用了,并施加与函数内变量借用相同的约束。
这种方法也可以用于处理自引用结构体,比如编译器中的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
}
}
这还不是完整的解决方案。
我们还需要一个Move标记trait来替代Pin。任何包含借用字段的结构体都不能实现Move。我甚至还想引入一个Mover trait,允许结构体智能地在内存中移动自身。例如:
trait Mover {
unsafe fn move(from: *Self, to: MaybeUninit<&mut Self>);
}
此外,我们还需要一种合理且安全的方式来构造这类结构体。我相信我们可以做得比MaybeUninit更好。
Miguel Young de la Sota曾经谈过关于Move的想法。但我认为更符合Rust风格的做法应该是借助借用检查器本身。
在我看来,Pin是一条死胡同。Rust已经有了一个借用检查器。那就让它也去处理结构体吧!
Comptime —— 编译期计算的新思路
这是一个有点争议的观点。我没怎么深入研究过Zig,但从远处看,我对它的 comptime 功能简直是爱不释手。
在Rust编译器中,我们实际上实现了两种语言:Rust本身和Rust的宏系统(严格来说是三种,因为还有过程宏)。Rust语言本身是很优美的。但宏系统却让人头大。
既然你已经掌握了Rust,为什么不直接用Rust本身来做宏展开的工作,而非要嵌入另一种语言进去呢?这就是Zig的comptime的精妙之处。编译器内置了一个小解释器,可以在编译期执行一部分你的代码。你可以将函数、参数、if语句和循环都标记为编译期代码。而在这些代码块中未被标记的部分会被正常编译进程序中。
我不打算在这里详述这个特性。你只需看看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!()宏是如何实现的,结果却很失望。我猜这个函数其实是硬编码在编译器里的。
当连Rust编译器的维护者都不愿使用Rust的宏系统时,这显然不是一件光彩的事。对于追求极致表达力和系统设计优雅性的开发者而言,这类瓶颈尤为值得关注和探讨。欢迎在云栈社区分享你对Rust或其他技术演进的看法。
参考链接:https://josephg.com/blog/rewriting-rust/