潜力:如何让Rust变得更高级?


这篇文章讨论了Rust编程语言在游戏开发生态系统中的现状,并提出了一些批评意见。作者分享了自己作为Dioxus Labs的创始人和Dioxus的维护者的经历,以及他们如何尝试将Rust塑造成未来应用开发的"全能"语言。

一年前,我全职担任 Dioxus 的维护者和 Dioxus Labs 的创始人。Dioxus Labs 是一家由 YCombinator 和 Khosla Ventures 支持的初创公司,其成立的理论依据是,我们可以将 Rust 处理成未来应用程序开发的 "万能 "语言。

在开发 Rust 之前,我一直在为等离子体物理模拟(HPC)编写 Python、C、CUDA 和 JavaScript。我不仅要在昂贵的超级计算集群上求解偏微分方程,还要处理蹩脚的 Python 安装程序,与损坏的 CUDA 驱动程序争论不休,在糟糕的 C 构建链中磕磕绊绊,并试图理解JavaScript 生态系统的荒谬之处。研究领域的工具通常都很糟糕,这是有原因的:研究人员没有时间在自己的核心学科之外涉猎数以百万计的不同配置、工具链和维护不善的文档。

六年前发现 Rust 就像在黑暗中发现了蜡烛。我在不到一周的时间内就移植了我的电子等离子体模拟代码,并在短短几天内将其封装在基于 Yew 的前端中。我花了两年时间才完成的工作,只用了一周时间就用一种语言完成了。这感觉就像终于有了一个足够 "锋利 "的工具来解决下一代不可能解决的问题。

Rust 的成功不是技术成就,而是社会成就
我认为,Rust 的成功是社会性的。我的热门观点Rust 的流行并非源于其技术优势。另一种语言,如 Nim、Odin 或 Crystal,要想崭露头角,就必须大显身手。Rust 巧妙地填补了开发者社区对现代编程语言的需求:速度、类型安全和可移植性。

  1. Rust 速度快(不同于 Python)、
  2. 类型安全(不同于 JavaScript)、
  3. 可移植性好(基本上可以在 LLVM 目标语言的任何地方使用)。

而运行时庞大或编译器基础架构怪异的语言则无法做到这一点。

由于 Rust 在社会上的成功,它鼓励开发者社区将精力投入到为语言带来大规模的现代功能上。Rust-Analyzer, rustfmt, cargo, miri, rustdoc, mdbook 等项目都是 Rust 成功的社会副产品。这些项目之所以出现,是因为现代开发人员希望有一种更好的编程语言。没有什么能阻止其他新语言拥有同样的功能,但这需要大量的工作。我所见过的与 Rust 的开发工具极其相似的语言只有 Gleam。这是有可能实现的,但需要大量人才的社会支持才能完成。


很明显,开发者社区对 Rust 这样的语言很感兴趣。我在创办 Dioxus 之初就认为,Rust 是我们实现 "应用程序开发圣杯 "的最佳机会,而且我们会下定决心:

  • 1)解决语言的缺陷;
  • 2)在可能的情况下改进语言。

我们已经实施了大量的变通方法......但 LogLog 的帖子《Rust is not designed for that usecase》清楚地表明,我们需要开始推动语言向前发展。

我建议,与其把孩子和洗澡水一起倒掉,不如认真对待并优先解决阻碍 Rust 发展的重要问题。

下面是一些具体的改进建议,以使Rust更适合快速开发,包括:

  • 自动廉价克隆的Capture trait
  • 私有方法的自动部分借用
  • 命名和可选函数参数
  • 形式化的预构建crates
  • Rust JIT和热重载
  • ThreadSafeSend - 修复工作窃取非Send任务的问题


1、用Capture实现自动廉价的clone克隆--从 Swift 中窃取 ARC
不管你喜不喜欢,Rust 发现自己已经进入了一个并非最初设计的使用场景。

  • 底层内核工程师正在将 Rust 拖入内核,并留下了对 rustc 的修改痕迹。

在过去几年中,我看到高级应用开发者和有抱负的独立游戏程序员将 Rust 越拉越高。有人可能会说,Rust 并不适合这两种环境:

  • 如果你想要高级的,就用 Go 或 C#;
  • 如果你想要低级的,就用 C 或 Zig。

这种批评很中肯,但正如我之前提到的,本文的论点是,只有坦诚地指出语言的不足之处,我们才能既得到蛋糕,又吃到它。

Rust 的姊妹语言 Swift 也借鉴了 Rust 的许多功能,但没有过于复杂的垃圾回收器。基本上,Swift 中的所有东西都是 Arc<Mutex<T>>,没有明确要求对值调用 clone():

在 Rust 中,如果我们想在线程之间共享 Arc,就需要明确调用值的克隆:

let some_value = Arc::new(something);

// task 1
let _some_value = some_value.clone();
tokio::task::spawn(async move {
    do_something_with(_some_value);
});

// task 2
let _some_value = some_value.clone();
tokio::task::spawn(async move {
    do_something_else_with(_some_value);
});

如果这看起来很难看、很乏味,那是因为它确实很难看、很乏味。这很快就会让人厌烦。在 Cloudflare 工作时,我不得不处理一个包含近 30 个 Arced 数据字段的结构。生成 tokio 任务的过程如下

// listen for dns connections
let _some_a = self.some_a.clone();
let _some_b = self.some_b.clone();
let _some_c = self.some_c.clone();
let _some_d = self.some_d.clone();
let _some_e = self.some_e.clone();
let _some_f = self.some_f.clone();
let _some_g = self.some_g.clone();
let _some_h = self.some_h.clone();
let _some_i = self.some_i.clone();
let _some_j = self.some_j.clone();
tokio::task::spawn(async move {
      
// do something with all the values
});

在这个代码库上工作让人意志消沉。我们想不出更好的架构方法--我们需要根据应用状态过滤更新的监听器。

  • 你可以说 "笑死我了",但这个团队的工程师是我共事过的最聪明的人。
  • Cloudflare 全情投入 Rust。他们愿意在这样的代码库上砸钱。

如果这就是共享状态的工作方式,核聚变也无法通过 Rust 来解决。

  • Rust 需要一种新的可选类型,让我们不必再用克隆clone来污染我们的代码库。

谁会真正关心 Rc/Arc 的硬计数?100 次中有 99 次,我都会使用 Arc/Rc 来解决真正具有挑战性的共享状态问题,而硬计数对我来说根本不重要。


我建议将 Capture 特性trait作为 Clone 和 Copy 系列的一部分。

  • 当 Capture 类型在作用域之间移动时,Rust 会简单地将它们强制转换为自己的类型。
  • Capture 只适用于克隆 "便宜 "的类型,如 Arc/Rc 和其他生态系统定义的类型(如 Channel/Signal)。

这将在许多地方出现:

fn some_outer_fn(state: &Shared<State>) {
    //1 调用函数并从 &T 到 T
 
// 通过廉价克隆,状态自动从 &T 到 T
    some_inner_fn(state);
    
    
// 2.使用闭包时
    
// 状态是通过隐式调用 ToOwned 来获取的。
    let cb = move || {}
    
    
// 3. 使用异步
   
//当移动到异步移动作用域时, // 状态被强制为自有类型
    task::spawn(async move { some_inner_async_fn(state) });
}

// 该内部函数使用状态的自有版本
fn some_inner_fn(state: Shared<State> {}

令人惊奇的是,回调(Rust UI 开发的祸根)立刻变得毫不费力:


// Due to rust supporting disjoint borrows in closures, 
// capture would propagate through structs.
// 
// Not a clone in sight - could you imagine?
fn create_callback(obj: &SomethingBig) -> Callback<'static> {
    move || obj.tx.send(obj.config.reduce(
"some.raycat.data"))
}

struct SomethingBig {
        config: Shared<Config>,
    tx: Channel<State>
}

Capture 将为我们提供复制类型的人体工学设计,而无需使用像 Dioxus 最近发布的 Generational-Box crate 这样的笨拙而又创新的板条箱。世代盒(Generational Box)通过其复制类型(CopyType)提供了与 Capture 相同的语义,它将数据塞入全局运行时,并将访问隐藏在世代指针之后。我们花了 3 个月的时间将其添加到 Dioxus 中--耗费了我们 3 个月的时间--我非常愿意投入同等数量的资源将 Capture 添加到 Rust 本身中。

2、私有方法的自动部分借用
我在上文提到过我在 Cloudflare 工作时代码库的恶心之处。数千行代码专门用于克隆。但真正拖慢我们进度的问题是缺乏部分借用。

我们的代码库非常庞大--近 10 万行跨平台代码,都是 5 年前编写的。说到技术债务。我们的结构嵌套了十几层;因为,你还能用什么方法来表示 DNS、WireGuard、WebSockets、ICMP、健康检查、统计等的复杂性?

大型 Rust 项目在自身的重压下挣扎,很快就变得几乎无法开展工作。

我们为缺乏部分借用而苦恼。缺乏部分借用会导致代码无法编译:

// Imagine some struct with some items in it
struct SomethingBig {
    name: String,
    children: Vec<SomethingBig>,
}

// Also imagine this struct has some methods on it
impl SomethingBig {
   
// this method modifies the `name` field
    fn modify(&mut self) {
        self.name =
"modified".to_string();
    }
    
   
// this method reads and returns a reference to a child field
    fn read(&self) -> &str {
        &self.children.last().unwrap().name
    }
}

// bummer....
// This code doesn't compile because `o2` is borrowed while .modify is called
fn partial_borrow(s: &mut SomethingBig) {
    let o2 = s.read();
    let _ = s.modify();
    println!(
"o: {:?}", o2);
}

当你的代码库不断扩大时,这种做法很快就会令人厌烦。人们会告诉你 "好好干",重构你的应用程序。

很抱歉,Cloudflare 是 Rust 最大的生产用户之一,我可以告诉你,任何人都不可能重构代码库,并为冲刺阶段提供功能和错误修复。

公司就是死在这些东西上。我无法想象告诉 Matthew Prince,WARP 无法在截止日期前完成功能,因为我们无法在同一范围内借用子代和修改名称。

六年前,人们就已经开始讨论部分借用的语法了。当我开始编写 Rust 时,这种势头已经存在。我以为这个问题会在 2018 年得到解决。现在是 2024 年。

如果我告诉你,只需零代码改动,就能在 Rust 中实现部分借用,你会怎么想?这简直就是一个开关,我们可以(假设)在一个小版本中打开它。

抓紧你的袜子......上面的代码确实可以编译......如果你使用闭包的话:

fn partial_borrow(s: &mut SomethingBig) {
    let mut modify_something =  || s.name = "modified".to_string();
    let read_something =  || &s.children.last().unwrap().name;

   
// This works!!
    let o2 = read_something();
    let o1 = modify_something();
    println!(
"o: {:?}", o2);
}

从 Rust 2023 开始,闭包可以通过一种名为 "不连接捕获 "的技术捕获结构体的字段。部分借用的机制已经存在!我们在 Rust 编译器中已经拥有了它!

但你要问,为什么方法没有启用呢?骑自行车。可以理解的是,人们想要一种专用语法来描述这里发生的借用。对于闭包,生命周期通常是隐式的,因此没有人真正关心语法语义。闭包不可能有公共 API。

我的具体建议是:只对私有方法启用不连接capture捕获。让 Rust-Analyzer 来提示我的私有方法发生了哪些部分借用。你可以在未来的六年中继续使用 pub fn 语法,但为了 Cloudflare、LogLog 和 Dioxus 今天的成功,我们需要为私有方法打开这个开关。

为私有方法开启 "不连接capture捕获 "是一项非破坏性变更,只需几个版本即可推出。同样,如果我们对该功能的需求得到满足,并且 RFC 能够及时被接受,我将非常乐意将 Dioxus 的部分资源投入到 Rust 本身中。

3、已命名和可选的函数参数
另一个污染 Cloudflare 代码库、Dioxus 代码库和其他无数代码库的垃圾来源:构建器模式。有时候,我觉得 Rust 生态系统就像得了斯德哥尔摩综合症......怎么会有人相信构建器是大量字段的合理默认值?为什么这就是我们最好的选择?

struct PlotCfg {
   title: Option<String>,
   height: Option<u32>,
   width: Option<u32>,
   dpi: Option<u32>,
   style: Option<Style>
}

impl PlotCfg {
    pub fn title(&mut self, title: Option<u32>) -> &mut self {
        self.title = title;
        self
    }
    pub fn height(&mut self, height: Option<u32>) -> &mut self {
        self.height = height;
        self
    }
    pub fn width(&mut self, width: Option<u32>) -> &mut self {
        self.width = width;
        self
    }
    pub fn dpi(&mut self, dpi: Option<u32>) -> &mut self {
        self.dpi = dpi;
        self
    }
    pub fn style(&mut self, style: Option<u32>) -> &mut self {
        self.style = style;
        self
    }
    pub fn build() -> Plot {
        todo!()
    }
}

你知道什么会比数百行的构建模式更棒吗?

  • 命名的、可选的函数参数。

不是明年或后年实现,而是今天:

// 就像其他语言一样,使用函数
pub fn plot(
     x: Vec<usize>,
     y: Vec<usize>,
   #[default] title: Option<String>,
   #[default] height: Option<u32>,
   #[default] width: Option<u32>,
   #[default] dpi: Option<u32>,
   #[default] style: Option<Style>
) -> Plot {
  todo!()
}

4、更快的解包unwrap语法
也许你认为这是一个问题,也许你并不这么认为。在使用 Dioxus 编写应用程序时,我一直在克隆和解包的海洋中徜徉。Unwrap 并不坏;老实说,我喜欢 Rust 这样的错误处理概念。

但是,对于从服务器获取数据的演示来说,这实在是太愚蠢了:

let res = Client::new()
    .unwrap()
    .get("https://dog.ceo/api/breeds/list/all")
    .header(
"content/text".parse().unwrap())
    .send()
    .unwrap()
    .await
    .unwrap()
    .json::<DogApi>()
    .await
    .unwrap();


为什么不能有一种更简洁的解包语法?我们已经有了用于错误传播的问号语法,为什么不把它与用于解包的 ! 结合起来呢?

let res = Client::new()!
    .get("https://dog.ceo/api/breeds/list/all")
    .header(
"content/text".parse()!)
    .send()!
    .await!
    .json::<DogApi>()
    .await!;

我不是语言设计者,但如果语言能像处理错误传播一样一致地处理解包,我们就能更快地建立原型,这一点应该是显而易见的。

5、我们在编译器层面可以做的事情:全局依赖缓存
...点击标题

其他建议:

  • 形式化的预构建crates
  • Rust JIT和热重载
  • ThreadSafeSend - 修复工作窃取非Send任务的问题


最后
总的来说,这篇文章是对Rust编程语言的深入分析,提出了一些有见地的观点和建议。如果您对这个话题感兴趣,可以点击标题链接阅读全文。