潛力:如何讓Rust變得更高階?

banq發表於2024-06-24


這篇文章討論了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);

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

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

如果這看起來很難看、很乏味,那是因為它確實很難看、很乏味。這很快就會讓人厭煩。在 Cloudflare 工作時,我不得不處理一個包含近 30 個 Arced 資料欄位的結構。生成 tokio 任務的過程如下

<font>// listen for dns connections<i>
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<i>
});

在這個程式碼庫上工作讓人意志消沉。我們想不出更好的架構方法--我們需要根據應用狀態過濾更新的監聽器。

  • 你可以說 "笑死我了",但這個團隊的工程師是我共事過的最聰明的人。
  • 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>) {
    <font>//1 呼叫函式並從 &T 到 T<i>
 
// 透過廉價克隆,狀態自動從 &T 到 T<i>
    some_inner_fn(state);
    
    
// 2.使用閉包時<i>
    
// 狀態是透過隱式呼叫 ToOwned 來獲取的。<i>
    let cb = move || {}
    
    
// 3. 使用非同步<i>
   
//當移動到非同步移動作用域時, // 狀態被強制為自有型別<i>
    task::spawn(async move { some_inner_async_fn(state) });
}

// 該內部函式使用狀態的自有版本<i>
fn some_inner_fn(state: Shared<State> {}

令人驚奇的是,回撥(Rust UI 開發的禍根)立刻變得毫不費力:


<font>// Due to rust supporting disjoint borrows in closures, <i>
// capture would propagate through structs.<i>
// <i>
// Not a clone in sight - could you imagine?<i>
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 專案在自身的重壓下掙扎,很快就變得幾乎無法開展工作。

我們為缺乏部分借用而苦惱。缺乏部分借用會導致程式碼無法編譯:

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

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

// bummer....<i>
// This code doesn't compile because `o2` is borrowed while .modify is called<i>
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 = <font>"modified".to_string();
    let read_something =  || &s.children.last().unwrap().name;

   
// This works!!<i>
    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!()
    }
}

你知道什麼會比數百行的構建模式更棒嗎?

  • 命名的、可選的函式引數。

不是明年或後年實現,而是今天:

<font>// 就像其他語言一樣,使用函式<i>
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(<font>"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(<font>"https://dog.ceo/api/breeds/list/all")
    .header(
"content/text".parse()!)
    .send()!
    .await!
    .json::<DogApi>()
    .await!;

我不是語言設計者,但如果語言能像處理錯誤傳播一樣一致地處理解包,我們就能更快地建立原型,這一點應該是顯而易見的。

5、我們在編譯器層面可以做的事情:全域性依賴快取
...點選標題

其他建議:

  • 形式化的預構建crates
  • Rust JIT和熱過載
  • ThreadSafeSend - 修復工作竊取非Send任務的問題


最後
總的來說,這篇文章是對Rust程式語言的深入分析,提出了一些有見地的觀點和建議。如果您對這個話題感興趣,可以點選標題連結閱讀全文。

相關文章