Rust是語言設計領域的一個熱點。它允許我們用簡潔、可移植、有時甚至是漂亮的代碼構建高效、內存安全的程序。
然而,凡事都有兩面,不會到處都是玫瑰和陽光。內存管理的細節通常會讓開發工作陷入抓狂,并使代碼比“高級”編程語言(如Haskell或OCaml)中的,更丑陋、更重復。最讓人惱怒的是,在幾乎所有情況下,這些問題都不是編譯器的缺陷,而是Rust團隊設計選擇的直接后果。
《編程元素》一書中,作者Alexander Stepanov寫到:“函數式編程處理值;命令式編程處理對象。”本文通過豐富的案例詳細介紹了如果你以函數式編程思維來處理Rust,它會有多令開發者沮喪,以及Rust也別無選擇的原因。建議收藏。
一、對象和引用:萬惡之源
值和對象起著互補的作用。值是不變的,并且與計算機中的任何特定實現無關。對象是可變的,并且具有特定于計算機的實現。
——Alexander Stepanov,"Elements of Programming"
在深入研究Rust之前,了解對象、值和引用之間的差異很有幫助。
在本文的上下文中,值是具有不同身份的實體,例如數字和字符串。對象是計算機內存中值的表示。引用是我們可以用來訪問對象或其部分的對象的地址。
系統編程語言,如C++和Rust,迫使程序員處理對象和引用之間的區別。這種區別使我們能夠編寫出驚人的快速代碼,但代價很高:這是一個永無止境的bug來源。如果程序的其他部分引用對象,那么修改對象的內容幾乎總是一個錯誤。有多種方法可以解決此問題:
- 忽略掉問題,相信程序員的操作。大多數傳統的系統編程語言,如C++,都走了這條路。
- 使所有對象不可變。該選項是Haskell和Clojure中純函數編程技術的基礎。
- 完全禁止引用。Val語言探索了這種編程風格。
- 采用防止修改引用對象的類型系統。ATS和Rust等語言選擇了這條路。
對象和引用之間的區別也是意外復雜性和選擇爆炸的根源。一種具有不可變對象和自動內存管理的語言包容開發者對這種區別的盲區,并將一切視為一個值(至少在純代碼中)。統一的存儲模型解放了程序員的思考精力,使其能夠編寫更具表達力和優雅的代碼。
然而,我們在便利性上獲得的東西,卻在效率上失去了:純功能程序通常需要更多的內存,可能會變得無響應,并且更難優化,這意味著項目的進度會很趕。
二、內傷1:漏洞百出的抽象
手動內存管理和所有權感知類型系統會干擾我們將代碼抽象為更小的部分的能力。
1.公共表達式消除
將公共表達式提取到變量中可能會帶來意想不到的挑戰。讓我們從以下代碼片段開始。
復制
let x = f("a very long string".to_string());
let y = g("a very long string".to_string());
// …
如上, "a very long string".to_string() ,我們的第一直覺是為表達式指定一個名稱并使用兩次:
復制
let s = "a very long string".to_string();
let x = f(s);
let y = g(s);
然而,我們的第一個雛形版本不會通過編譯,因為String類型沒有實現Copy特性。我們必須改用以下表達式:
復制
let s = "a very long string".to_string();
f(s.clone());
g(s);
如果我們關心額外的內存分配,因為復制內存變得顯式,我們可以從積極的角度看到額外的冗長。但在實踐中,這可能會很煩人,特別是當你在兩個月后添加
復制
h(s) 。
let s = "a very long string".to_string();
f(s.clone());
g(s);
// fifty lines of code...
h(s); // ← won’t compile, you need scroll up and update g(s).
2.同態限制
Rust中, let x = y; 并不意味著t x和y是同一個。一個自然中斷的例子是,當y是一個重載函數時,這個自然屬性就會中斷。例如,讓我們為重載函數定義一個短名稱。
復制
// Do we have to type "MyType::from" every time?
// How about introducing an alias?
let x = MyType::from(b"bytes");
let y = MyType::from("string");
// Nope, Rust won't let us.
let f = MyType::from;
let x = f(b"bytes");
let y = f("string");
// - ^^^^^^^^ expected slice `[u8]`, found `str`
// |
// arguments to this function are incorrect
該代碼段未編譯,因為編譯器將f綁定到MyType::from的特定實例,而不是多態函數。我們必須顯式地使f多態。
復制
// Compiles fine, but is longer than the original.
fn f<T: Into<MyType>>(t: T) -> MyType { t.into() }
let x = f(b"bytes");
let y = f("string");
Haskell程序員可能會發現這個問題很熟悉:它看起來可疑地類似于可怕的單態限制!不幸的是,rustc沒有NoMonomorphismRestriction字段。
3.函數abstraction
將代碼分解為函數可能比預期的要困難,因為編譯器無法解釋跨函數邊界的混疊。假設我們有以下代碼。
復制
impl State {
fn tick(&mut self) {
self.state = match self.state {
Ping(s) => { self.x += 1; Pong(s) }
Pong(s) => { self.x += 1; Ping(s) }
}
}
}
self.x+=1語句出現多次。為什么不把它抽取成一個方法…
復制
impl State {
fn tick(&mut self) {
self.state = match self.state {
Ping(s) => { self.inc(); Pong(s) } // ← compile error
Pong(s) => { self.inc(); Ping(s) } // ← compile error
}
}
fn inc(&mut self) {
self.x += 1;
}
}
Rust會對我們咆哮,因為該方法試圖以獨占方式重新借用self.state,而周圍的上下文仍然保持對self.state的可變引用。
Rust 2021版實現了不相交捕獲,以解決閉包的類似問題。在Rust 2021之前,類似于x.f.m(||x.y)的代碼可能無法編譯,但可以手動內聯m,閉包可以解決該錯誤。例如,假設我們有一個結構,它擁有一個映射和映射條目的默認值。
復制
struct S { map: HashMap<i64, String>, def: String }
impl S {
fn ensure_has_entry(&mut self, key: i64) {
// Doesn't compile with Rust 2018:
self.map.entry(key).or_insert_with(|| self.def.clone());
// | ------ -------------- ^^ ---- second borrow occurs...
// | | | |
// | | | immutable borrow occurs here
// | | mutable borrow later used by call
// | mutable borrow occurs here
}
}
然而,如果我們內聯or_insert_with的定義和lambda函數,編譯器最終可以看到借用規則成立
復制
struct S { map: HashMap<i64, String>, def: String }
impl S {
fn ensure_has_entry(&mut self, key: i64) {
use std::collections::hash_map::Entry::*;
// This version is more verbose, but it works with Rust 2018.
match self.map.entry(key) {
Occupied(mut e) => e.get_mut(),
Vacant(mut e) => e.insert(self.def.clone()),
};
}
}
當有人問你,“Rust閉包可以做哪些命名函數不能做的事情?”你會知道答案:它們只能捕獲它們使用的字段。
4.Newtype抽象
Rust中的新類型習慣用法允許程序員為現有類型賦予新的標識。該習語的名稱來自Haskell的newtype關鍵字。
這個習慣用法的一個常見用法是處理孤立規則,并為別名類型定義特征實現。例如,下面的代碼定義了一種以十六進制顯示字節向量的新類型。
復制
struct Hex(Vec<u8>);
impl std::fmt::Display for Hex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.iter().try_for_each(|b| write!(f, "{:02x}", b))
}
}
println!("{}", Hex((0..32).collect()));
// => 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
新的類型習慣用法是有效的:機器內存中十六進制類型的表示與Vec<u8>的表示相同。然而,盡管表示形式相同,編譯器并沒有將我們的新類型視為Vec<u8>的強別名。例如,如果不重新分配外向量,我們就不能安全地將Vec<Hex>轉換為Vec<Vec<u8>>并返回。此外,如果不復制字節,我們無法安全地將&Vec<u8>強制為&Hex。
復制
fn complex_function(bytes: &Vec<u8>) {
// … a lot of code …
println!("{}", &Hex(bytes)); // That does not work.
println!("{}", Hex(bytes.clone())); // That works but is slow.
// … a lot of code …
}
總之,newtype習語是一種漏洞百出的抽象,因為它是一種慣例,而不是一種一流的語言特性。
5.視圖和捆綁包
每當程序員描述結構字段或向函數傳遞參數時,她必須決定字段/參數是對象還是引用。或者最好的選擇是在運行時決定?這是很多決策!不幸的是,有時沒有最佳選擇。在這種情況下,我們會咬緊牙關,用稍微不同的字段類型定義同一類型的幾個版本。
Rust中的大多數函數通過引用獲取參數,并將結果作為自包含的對象返回。這種模式非常常見,因此定義新術語可能會有所幫助。我用生存期參數視圖調用輸入類型,因為它們最適合檢查數據。我稱常規輸出類型為bundle,因為它們是獨立的。
以下代碼段來自Lucet WebAssembly運行時。
復制
/// A WebAssembly global along with its export specification.
/// The lifetime parameter exists to support zero-copy deserialization
/// for the `&str` fields at the leaves of the structure.
/// For a variant with owned types at the leaves, see `OwnedGlobalSpec`.
pub struct GlobalSpec<'a> {
global: Global<'a>,
export_names: Vec<&'a str>,
}
…
/// A variant of `GlobalSpec` with owned strings throughout.
/// This type is useful when directly building up a value to be serialized.
pub struct OwnedGlobalSpec {
global: OwnedGlobal,
export_names: Vec<String>,
}
作者復制了GlobalSpec數據結構,以支持兩種用例:
GlobalSpec<a>是代碼作者從字節緩沖區解析的視圖對象。此視圖的各個字段指向緩沖區的相關區域。此表示對于需要檢查GlobalSpec類型的值而不修改它們的函數很有用。
OwnedGlobalSpec是一個包:它不包含對其他數據結構的引用。此表示對于構造GlobalSpec類型的值并將其傳遞或放入容器的函數很有用。
在具有自動內存管理的語言中,我們可以在單個類型聲明中將GlobalSpec<a>的效率與OwnedGlobalSpec的多功能性結合起來。
三、內傷2:組合便成了“苦修”
在Rust中,從較小的部分組合程序,簡直會令人沮喪。
1.對象組合
當開發者有兩個不同的對象時,他們通常希望將它們組合成一個結構。聽起來很簡單?Rust中可不容易。
假設我們有一個對象Db,它有一個方法為您提供另一個對象Snapshot<a>。快照的生存期取決于數據庫的生存期。
復制
struct Db { /* … */ }
struct Snapshot<'a> { /* … */ }
impl Db { fn snapshot<'a>(&'a self) -> Snapshot<'a>; }
我們可能希望將數據庫與其快照捆綁在一起,但Rust不允許。
復制
// There is no way to define the following struct without
// contaminating it with lifetimes.
struct DbSnapshot {
snapshot: Snapshot<'a>, // what should 'a be?
db: Arc<Db>,
}
Rust擁躉者稱這種安排為“兄弟指針”。Rust禁止安全代碼中的兄弟指針,因為它們破壞了Rust的安全模型。
正如在對象、值和引用部分中所討論的,修改被引用的對象通常是一個bug。在我們的例子中,快照對象可能取決于db對象的物理位置。如果我們將DbSnapshot作為一個整體移動,則db字段的物理位置將發生變化,從而損壞快照對象中的引用。我們知道移動Arc<Db>不會改變Db對象的位置,但無法將此信息傳遞給rustc。
DbSnapshot的另一個問題是它的字段銷毀順序很重要。如果Rust允許同級指針,更改字段順序可能會引入未定義的行為:快照的析構函數可能會嘗試訪問已破壞的db對象的字段。
2.無法對boxes進行模式匹配
在Rust中,我們無法對Box、Arc、String和Vec等裝箱類型進行模式匹配。這種限制通常會破壞交易,因為我們在定義遞歸數據類型時無法避免裝箱。
For example, let us try to match a vector of strings.例如,我們試圖對字符串Vector做一個匹配。
復制
let x = vec!["a".to_string(), "b".to_string()];
match x {
// - help: consider slicing here: `x[..]`
["a", "b"] => println!("OK"),
// ^^^^^^^^^^ pattern cannot match with input type `Vec<String>`
_ => (),
}
首先,我們不能匹配一個向量,只能匹配一個切片。幸運的是,編譯器建議了一個簡單的解決方案:我們必須用匹配表達式中的x[..]替換x。讓我們試一試。
復制
let x = vec!["a".to_string(), "b".to_string()];
match x[..] {
// ----- this expression has type `[String]`
["a", "b"] => println!("OK"),
// ^^^ expected struct `String`, found `&str`
_ => (),
}
正如大家所看到的,刪除一層框不足以讓編譯器滿意。我們還需要在向量內取消字符串的框,這在不分配新向量的情況下是不可能的:
復制
let x = vec!["a".to_string(), "b".to_string()];
// We have to allocate new storage.
let x_for_match: Vec<_> = x.iter().map(|s| s.as_str()).collect();
match &x_for_match[..] {
["a", "b"] => println!("OK"), // this compiles
_ => (),
}
Forget about balancing Red-Black trees in five lines of code in Rust
Forget about balancing Red-Black trees in five lines of code in Rust.
老實話,放棄在Rust用五行代碼搞定平衡紅黑樹吧!
3.孤立規則
Rust使用孤立(Orphan)規則來決定類型是否可以實現特征。對于非泛型類型,這些規則禁止在定義特征或類型的板條箱之外為類型實現特征。換句話說,定義特征的包必須依賴于定義類型的包,反之亦然。
這些規則使編譯器很容易保證一致性,這是一種聰明的方式,可以說程序的所有部分都看到特定類型的相同特性實現。作為交換,這一規則使整合無關庫中的特征和類型變得非常復雜。
一個例子是我們只想在測試中使用的特性,例如proptest包中的任意特性。如果編譯器從我們的包中派生類型的實現,我們可以節省很多類型,但我們希望我們的生產代碼獨立于proptest包。在完美的設置中,所有的任意實現都將進入一個單獨的僅測試包。不幸的是,孤兒規則反對這種安排,迫使我們咬緊牙關,手動編寫proptest策略。
在孤立規則下,類型轉換特性(如From和Into)也存在問題。我經常看到xxx類型的包開始很小,但最終成為編譯鏈中的瓶頸。將這樣的包拆分成更小的部分通常是令人畏懼的,因為復雜的類型轉換網絡將遙遠的類型連接在一起。孤立規則不允許我們在模塊邊界上切割這些包,并將所有轉換移動到一個單獨的包中,而不需要做大量乏味的工作。
不要誤會:孤立規則是一個默認原則。Haskell允許您定義孤立實例,但程序員不贊成這種做法。讓我難過的是無法逃脫孤兒規則。在大型代碼庫中,將大型包分解為較小的部分并維護淺依賴關系圖是獲得可接受編譯速度的唯一途徑。孤立規則通常會妨礙修剪依賴關系圖。
四、內傷3Fearless Concurrency是一個謊言
Rust團隊創造了術語Fearless Concurrency,以表明Rust可以幫助您避免與并行和并發編程相關的常見陷阱。盡管有這些說法,每次筆者在Rust程序中引入并發時,皮質醇水平都會升高。
1.Deadlocks
因此,對于Safe Rust程序來說,如果同步不正確而導致死鎖或做一些無意義的事情,這是完全“好的”。很明顯,這樣的程序不是很好,但Rust只能握著你的手
——The Rustonomicon,Data Races and Race Conditions
Safe Rust可防止稱為數據競爭的特定類型的并發錯誤。并發Rust程序還有很多其他方式可以不正確地運行。
筆者親身經歷的一類并發錯誤是死鎖。這類錯誤的典型解釋包括兩個鎖和兩個進程試圖以相反的順序獲取鎖。但是,如果您使用的鎖不是可重入的(Rust的鎖不是),那么只有一個鎖就足以導致死鎖。
例如,下面的代碼是錯誤的,因為它兩次嘗試獲取相同的鎖。如果do_something和helper_function很大,并且在源文件中相隔很遠,或者如果我們在一個罕見的執行路徑上調用helper_function,那么可能很難發現這個bug。
復制
impl Service {
pub fn do_something(&self) {
let guard = self.lock.read();
// …
self.helper_function(); // BUG: will panic or deadlock
// …
}
fn helper_function(&self) {
let guard = self.lock.read();
// …
}
}
RwLock::read的文檔提到,如果當前線程已經持有鎖,則函數可能會死機。我得到的只是一個掛起的程序。
一些語言試圖在其并發工具包中提供解決此問題的方法。Clang編譯器具有線程安全注釋,支持一種可以檢測競爭條件和死鎖的靜態分析形式。然而,避免死鎖的最佳方法是不使用鎖。從根本上解決這個問題的兩種技術是軟件事務內存(在Haskell、Clojure和Scala中實現)和actor模型(Erlang是第一種完全采用它的語言)。
2.文件系統是共享資源
Rust為我們提供了處理共享內存的強大工具。然而,一旦我們的程序需要與外部世界進行交互(例如,使用網絡接口或文件系統),我們就只能靠自己了。
Rust在這方面與大多數現代語言相似。然而,它會給你一種虛假的安全感。
千萬要注意,即使在Rust中,路徑也是原始指針。大多數文件操作本質上是不安全的,如果不正確同步文件訪問,可能會導致數據競爭(廣義上)。例如,截至2023年2月,我仍然在rustup(https://rustup.rs/)中遇到了一個長達六年的并發錯誤(https://Github.com/rust-lang/rustup/issues/988)。
3.隱式異步運行時
我不能認真地相信量子理論,因為,物理學應該描寫存在于時空之中,而沒有“不可思議的超距作用”的實在。
——愛因斯坦
筆者最喜歡Rust的一點是,它專注于本地推理。查看函數的類型簽名通常會讓自己對函數的功能有一個透徹的理解。
- 由于可變性和生存期注釋,狀態突變是顯式的。
- 由于普遍存在的Result類型,錯誤處理是明確和直觀的。
- 如果正確使用,這些功能通常會導致神秘的編譯效果。
然而,Rust中的異步編程是不同的。
Rust支持async/.await語法來定義和組合異步函數,但運行時支持有限。幾個庫(稱為異步運行時)定義了與操作系統交互的異步函數。tokio包是最流行的庫。
運行時的一個常見問題是它們依賴于隱式傳遞參數。例如,tokio運行時允許在程序中的任意點生成并發任務。為了使該函數工作,程序員必須預先構造一個運行時對象。
復制
fn innocently_looking_function() {
tokio::spawn(some_async_func());
// ^
// |
// This code will panic if we remove this line. Spukhafte Fernwirkung!
} // |
// |
fn main() { // v
let _rt = tokio::runtime::Runtime::new().unwrap();
innocently_looking_function();
}
這些隱式參數將編譯時錯誤轉化為運行時錯誤。本來應該是編譯錯誤的事情變成了“調試冒險”:
如果運行時是一個顯式參數,則除非程序員構造了一個運行時并將其作為參數傳遞,否則代碼不會編譯。當運行時是隱式的時,您的代碼可能編譯得很好,但如果您忘記用神奇的宏注釋主函數,則會在運行時崩潰。
混合選擇不同運行時的庫非常復雜。如果這個問題涉及同一運行時的多個主要版本,那么這個問題就更加令人困惑了。筆者編寫異步Rust代碼的經驗與異步工作組收集的真實情況,可以說是一個悲慘的“事故”!
有些人可能會認為,在整個調用堆棧中使用無處不在的參數是不符合邏輯的。顯式傳遞所有參數是唯一可以很好擴展的方法。
4.函數是有顏色的
2015年,Bob Nystrom在博客《你的函數是什么顏色》中說道:理性的人可能會認為語言討厭我們。
Rust的 async/.await語法簡化了異步算法的封裝,但同時也帶來了相當多的復雜性問題:將每個函數涂成藍色(同步)或紅色(異步)。有新的規則需要遵循:
同步函數可以調用其他同步函數并獲得結果。異步函數可以調用和.await其他異步函數以獲得結果。
我們不能直接從sync函數調用和等待異步函數。我們需要一個異步運行時,它將為我們執行一個異步函數。
我們可以從異步函數調用同步函數。但要小心!并非所有同步功能都是相同的藍色。
沒錯,有些sync函數非常神奇地變成了紫色:它們可以讀取文件、連接線程或在couch上睡眠thread::sleep。我們不想從紅色(異步)函數調用這些紫色(阻塞)函數,因為它們會阻塞運行時,并扼殺促使我們陷入異步混亂的性能優勢。
不幸的是,紫色函數非常吊軌:如果不檢查函數的主體和調用圖中所有其他函數的主體,就無法判斷函數是否為紫色。這些主體還在進化,所以我們最好關注它們。
真正的樂趣來自于擁有共享所有權的代碼庫,其中多個團隊將同步和異步代碼夾在一起。這樣的軟件包往往是bug筒倉,等待足夠的系統負載來顯示三明治中的另一個紫色缺陷,使系統無響應。
具有圍繞綠色線程構建的運行時的語言,如Haskell和Go,消除了函數顏色的泛濫。在這種語言中,從獨立組件構建并發程序更容易、更安全。
五、寫在最后
C++之父Bjarne Stroustrup曾說,世界上只有兩種語言:一種是人們總是抱怨的,另一種是沒人用的。
Rust是一種有“紀律型”的語言,它讓許多重要的決策都得到了正確的處理,例如對安全的毫不妥協的關注、特質系統設計、缺乏隱式轉換以及錯誤處理的整體方法。它允許我們相對快速地開發健壯且內存安全的程序,而不會影響執行速度。
然而,筆者經常發現自己被意外的復雜性所淹沒,特別是當我不太關心性能,并且想要快速完成一些工作時(例如,在測試代碼中)。Rust會將程序解構成更小的部分,并將其由更小的部分來組合程序。此外,Rust僅部分消除了并發問題。
最后,筆者只想說,沒有哪種語言是萬金油。
原文鏈接:https://mmApped.blog/posts/15-when-rust-hurts.html#filesystem-shared-resource