?作者 | Roman Kashitsyn
編譯 | 言征
Rust是語言設(shè)計(jì)領(lǐng)域的一個(gè)熱點(diǎn)。它允許我們用簡潔、可移植、有時(shí)甚至是漂亮的代碼構(gòu)建高效、內(nèi)存安全的程序。
然而,凡事都有兩面,不會到處都是玫瑰和陽光。內(nèi)存管理的細(xì)節(jié)通常會讓開發(fā)工作陷入抓狂,并使代碼比“高級”編程語言(如Haskell或OCaml)中的,更丑陋、更重復(fù)。最讓人惱怒的是,在幾乎所有情況下,這些問題都不是編譯器的缺陷,而是Rust團(tuán)隊(duì)設(shè)計(jì)選擇的直接后果。
《編程元素》一書中,作者Alexander Stepanov寫到:“函數(shù)式編程處理值;命令式編程處理對象?!北疚耐ㄟ^豐富的案例詳細(xì)介紹了如果你以函數(shù)式編程思維來處理Rust,它會有多令開發(fā)者沮喪,以及Rust也別無選擇的原因。建議收藏。
一、對象和引用:萬惡之源
值和對象起著互補(bǔ)的作用。值是不變的,并且與計(jì)算機(jī)中的任何特定實(shí)現(xiàn)無關(guān)。對象是可變的,并且具有特定于計(jì)算機(jī)的實(shí)現(xiàn)。
——Alexander Stepanov,"Elements of Programming"
在深入研究Rust之前,了解對象、值和引用之間的差異很有幫助。
在本文的上下文中,值是具有不同身份的實(shí)體,例如數(shù)字和字符串。對象是計(jì)算機(jī)內(nèi)存中值的表示。引用是我們可以用來訪問對象或其部分的對象的地址。
系統(tǒng)編程語言,如C++和Rust,迫使程序員處理對象和引用之間的區(qū)別。這種區(qū)別使我們能夠編寫出驚人的快速代碼,但代價(jià)很高:這是一個(gè)永無止境的bug來源。如果程序的其他部分引用對象,那么修改對象的內(nèi)容幾乎總是一個(gè)錯(cuò)誤。有多種方法可以解決此問題:
- 忽略掉問題,相信程序員的操作。大多數(shù)傳統(tǒng)的系統(tǒng)編程語言,如C++,都走了這條路。
- 使所有對象不可變。該選項(xiàng)是Haskell和Clojure中純函數(shù)編程技術(shù)的基礎(chǔ)。
- 完全禁止引用。Val語言探索了這種編程風(fēng)格。
- 采用防止修改引用對象的類型系統(tǒng)。ATS和Rust等語言選擇了這條路。
對象和引用之間的區(qū)別也是意外復(fù)雜性和選擇爆炸的根源。一種具有不可變對象和自動(dòng)內(nèi)存管理的語言包容開發(fā)者對這種區(qū)別的盲區(qū),并將一切視為一個(gè)值(至少在純代碼中)。統(tǒng)一的存儲模型解放了程序員的思考精力,使其能夠編寫更具表達(dá)力和優(yōu)雅的代碼。
然而,我們在便利性上獲得的東西,卻在效率上失去了:純功能程序通常需要更多的內(nèi)存,可能會變得無響應(yīng),并且更難優(yōu)化,這意味著項(xiàng)目的進(jìn)度會很趕。
二、內(nèi)傷1:漏洞百出的抽象
手動(dòng)內(nèi)存管理和所有權(quán)感知類型系統(tǒng)會干擾我們將代碼抽象為更小的部分的能力。
1.公共表達(dá)式消除
將公共表達(dá)式提取到變量中可能會帶來意想不到的挑戰(zhàn)。讓我們從以下代碼片段開始。
如上, "a very long string".to_string() ,我們的第一直覺是為表達(dá)式指定一個(gè)名稱并使用兩次:
然而,我們的第一個(gè)雛形版本不會通過編譯,因?yàn)镾tring類型沒有實(shí)現(xiàn)Copy特性。我們必須改用以下表達(dá)式:
如果我們關(guān)心額外的內(nèi)存分配,因?yàn)閺?fù)制內(nèi)存變得顯式,我們可以從積極的角度看到額外的冗長。但在實(shí)踐中,這可能會很煩人,特別是當(dāng)你在兩個(gè)月后添加
2.同態(tài)限制
Rust中, let x = y; 并不意味著t x和y是同一個(gè)。一個(gè)自然中斷的例子是,當(dāng)y是一個(gè)重載函數(shù)時(shí),這個(gè)自然屬性就會中斷。例如,讓我們?yōu)橹剌d函數(shù)定義一個(gè)短名稱。
該代碼段未編譯,因?yàn)榫幾g器將f綁定到MyType::from的特定實(shí)例,而不是多態(tài)函數(shù)。我們必須顯式地使f多態(tài)。
Haskell程序員可能會發(fā)現(xiàn)這個(gè)問題很熟悉:它看起來可疑地類似于可怕的單態(tài)限制!不幸的是,rustc沒有NoMonomorphismRestriction字段。
3.函數(shù)abstraction
將代碼分解為函數(shù)可能比預(yù)期的要困難,因?yàn)榫幾g器無法解釋跨函數(shù)邊界的混疊。假設(shè)我們有以下代碼。
self.x+=1語句出現(xiàn)多次。為什么不把它抽取成一個(gè)方法…
Rust會對我們咆哮,因?yàn)樵摲椒ㄔ噲D以獨(dú)占方式重新借用self.state,而周圍的上下文仍然保持對self.state的可變引用。
Rust 2021版實(shí)現(xiàn)了不相交捕獲,以解決閉包的類似問題。在Rust 2021之前,類似于x.f.m(||x.y)的代碼可能無法編譯,但可以手動(dòng)內(nèi)聯(lián)m,閉包可以解決該錯(cuò)誤。例如,假設(shè)我們有一個(gè)結(jié)構(gòu),它擁有一個(gè)映射和映射條目的默認(rèn)值。
然而,如果我們內(nèi)聯(lián)or_insert_with的定義和lambda函數(shù),編譯器最終可以看到借用規(guī)則成立
當(dāng)有人問你,“Rust閉包可以做哪些命名函數(shù)不能做的事情?”你會知道答案:它們只能捕獲它們使用的字段。
4.Newtype抽象
Rust中的新類型習(xí)慣用法允許程序員為現(xiàn)有類型賦予新的標(biāo)識。該習(xí)語的名稱來自Haskell的newtype關(guān)鍵字。
這個(gè)習(xí)慣用法的一個(gè)常見用法是處理孤立規(guī)則,并為別名類型定義特征實(shí)現(xiàn)。例如,下面的代碼定義了一種以十六進(jìn)制顯示字節(jié)向量的新類型。
新的類型習(xí)慣用法是有效的:機(jī)器內(nèi)存中十六進(jìn)制類型的表示與Vec<u8>的表示相同。然而,盡管表示形式相同,編譯器并沒有將我們的新類型視為Vec<u8>的強(qiáng)別名。例如,如果不重新分配外向量,我們就不能安全地將Vec<Hex>轉(zhuǎn)換為Vec<Vec<u8>>并返回。此外,如果不復(fù)制字節(jié),我們無法安全地將&Vec<u8>強(qiáng)制為&Hex。
總之,newtype習(xí)語是一種漏洞百出的抽象,因?yàn)樗且环N慣例,而不是一種一流的語言特性。
5.視圖和捆綁包
每當(dāng)程序員描述結(jié)構(gòu)字段或向函數(shù)傳遞參數(shù)時(shí),她必須決定字段/參數(shù)是對象還是引用?;蛘咦詈玫倪x擇是在運(yùn)行時(shí)決定?這是很多決策!不幸的是,有時(shí)沒有最佳選擇。在這種情況下,我們會咬緊牙關(guān),用稍微不同的字段類型定義同一類型的幾個(gè)版本。
Rust中的大多數(shù)函數(shù)通過引用獲取參數(shù),并將結(jié)果作為自包含的對象返回。這種模式非常常見,因此定義新術(shù)語可能會有所幫助。我用生存期參數(shù)視圖調(diào)用輸入類型,因?yàn)樗鼈冏钸m合檢查數(shù)據(jù)。我稱常規(guī)輸出類型為bundle,因?yàn)樗鼈兪仟?dú)立的。
以下代碼段來自Lucet WebAssembly運(yùn)行時(shí)。
作者復(fù)制了GlobalSpec數(shù)據(jù)結(jié)構(gòu),以支持兩種用例:
GlobalSpec<a>是代碼作者從字節(jié)緩沖區(qū)解析的視圖對象。此視圖的各個(gè)字段指向緩沖區(qū)的相關(guān)區(qū)域。此表示對于需要檢查GlobalSpec類型的值而不修改它們的函數(shù)很有用。
OwnedGlobalSpec是一個(gè)包:它不包含對其他數(shù)據(jù)結(jié)構(gòu)的引用。此表示對于構(gòu)造GlobalSpec類型的值并將其傳遞或放入容器的函數(shù)很有用。
在具有自動(dòng)內(nèi)存管理的語言中,我們可以在單個(gè)類型聲明中將GlobalSpec<a>的效率與OwnedGlobalSpec的多功能性結(jié)合起來。
三、內(nèi)傷2:組合便成了“苦修”
在Rust中,從較小的部分組合程序,簡直會令人沮喪。
1.對象組合
當(dāng)開發(fā)者有兩個(gè)不同的對象時(shí),他們通常希望將它們組合成一個(gè)結(jié)構(gòu)。聽起來很簡單?Rust中可不容易。
假設(shè)我們有一個(gè)對象Db,它有一個(gè)方法為您提供另一個(gè)對象Snapshot<a>??煺盏纳嫫谌Q于數(shù)據(jù)庫的生存期。
我們可能希望將數(shù)據(jù)庫與其快照捆綁在一起,但Rust不允許。
Rust擁躉者稱這種安排為“兄弟指針”。Rust禁止安全代碼中的兄弟指針,因?yàn)樗鼈兤茐牧薘ust的安全模型。
正如在對象、值和引用部分中所討論的,修改被引用的對象通常是一個(gè)bug。在我們的例子中,快照對象可能取決于db對象的物理位置。如果我們將DbSnapshot作為一個(gè)整體移動(dòng),則db字段的物理位置將發(fā)生變化,從而損壞快照對象中的引用。我們知道移動(dòng)Arc<Db>不會改變Db對象的位置,但無法將此信息傳遞給rustc。
DbSnapshot的另一個(gè)問題是它的字段銷毀順序很重要。如果Rust允許同級指針,更改字段順序可能會引入未定義的行為:快照的析構(gòu)函數(shù)可能會嘗試訪問已破壞的db對象的字段。
2.無法對boxes進(jìn)行模式匹配
在Rust中,我們無法對Box、Arc、String和Vec等裝箱類型進(jìn)行模式匹配。這種限制通常會破壞交易,因?yàn)槲覀冊诙x遞歸數(shù)據(jù)類型時(shí)無法避免裝箱。
For example, let us try to match a vector of strings.例如,我們試圖對字符串Vector做一個(gè)匹配。
首先,我們不能匹配一個(gè)向量,只能匹配一個(gè)切片。幸運(yùn)的是,編譯器建議了一個(gè)簡單的解決方案:我們必須用匹配表達(dá)式中的x[..]替換x。讓我們試一試。
正如大家所看到的,刪除一層框不足以讓編譯器滿意。我們還需要在向量內(nèi)取消字符串的框,這在不分配新向量的情況下是不可能的:
Forget about balancing Red-Black trees in five lines of code in Rust.
老實(shí)話,放棄在Rust用五行代碼搞定平衡紅黑樹吧!
3.孤立規(guī)則
Rust使用孤立(Orphan)規(guī)則來決定類型是否可以實(shí)現(xiàn)特征。對于非泛型類型,這些規(guī)則禁止在定義特征或類型的板條箱之外為類型實(shí)現(xiàn)特征。換句話說,定義特征的包必須依賴于定義類型的包,反之亦然。
這些規(guī)則使編譯器很容易保證一致性,這是一種聰明的方式,可以說程序的所有部分都看到特定類型的相同特性實(shí)現(xiàn)。作為交換,這一規(guī)則使整合無關(guān)庫中的特征和類型變得非常復(fù)雜。
一個(gè)例子是我們只想在測試中使用的特性,例如proptest包中的任意特性。如果編譯器從我們的包中派生類型的實(shí)現(xiàn),我們可以節(jié)省很多類型,但我們希望我們的生產(chǎn)代碼獨(dú)立于proptest包。在完美的設(shè)置中,所有的任意實(shí)現(xiàn)都將進(jìn)入一個(gè)單獨(dú)的僅測試包。不幸的是,孤兒規(guī)則反對這種安排,迫使我們咬緊牙關(guān),手動(dòng)編寫proptest策略。
在孤立規(guī)則下,類型轉(zhuǎn)換特性(如From和Into)也存在問題。我經(jīng)??吹絰xx類型的包開始很小,但最終成為編譯鏈中的瓶頸。將這樣的包拆分成更小的部分通常是令人畏懼的,因?yàn)閺?fù)雜的類型轉(zhuǎn)換網(wǎng)絡(luò)將遙遠(yuǎn)的類型連接在一起。孤立規(guī)則不允許我們在模塊邊界上切割這些包,并將所有轉(zhuǎn)換移動(dòng)到一個(gè)單獨(dú)的包中,而不需要做大量乏味的工作。
不要誤會:孤立規(guī)則是一個(gè)默認(rèn)原則。Haskell允許您定義孤立實(shí)例,但程序員不贊成這種做法。讓我難過的是無法逃脫孤兒規(guī)則。在大型代碼庫中,將大型包分解為較小的部分并維護(hù)淺依賴關(guān)系圖是獲得可接受編譯速度的唯一途徑。孤立規(guī)則通常會妨礙修剪依賴關(guān)系圖。
四、內(nèi)傷3Fearless Concurrency是一個(gè)謊言
Rust團(tuán)隊(duì)創(chuàng)造了術(shù)語Fearless Concurrency,以表明Rust可以幫助您避免與并行和并發(fā)編程相關(guān)的常見陷阱。盡管有這些說法,每次筆者在Rust程序中引入并發(fā)時(shí),皮質(zhì)醇水平都會升高。
1.Deadlocks
因此,對于Safe Rust程序來說,如果同步不正確而導(dǎo)致死鎖或做一些無意義的事情,這是完全“好的”。很明顯,這樣的程序不是很好,但Rust只能握著你的手
——The Rustonomicon,Data Races and Race Conditions
Safe Rust可防止稱為數(shù)據(jù)競爭的特定類型的并發(fā)錯(cuò)誤。并發(fā)Rust程序還有很多其他方式可以不正確地運(yùn)行。
筆者親身經(jīng)歷的一類并發(fā)錯(cuò)誤是死鎖。這類錯(cuò)誤的典型解釋包括兩個(gè)鎖和兩個(gè)進(jìn)程試圖以相反的順序獲取鎖。但是,如果您使用的鎖不是可重入的(Rust的鎖不是),那么只有一個(gè)鎖就足以導(dǎo)致死鎖。
例如,下面的代碼是錯(cuò)誤的,因?yàn)樗鼉纱螄L試獲取相同的鎖。如果do_something和helper_function很大,并且在源文件中相隔很遠(yuǎn),或者如果我們在一個(gè)罕見的執(zhí)行路徑上調(diào)用helper_function,那么可能很難發(fā)現(xiàn)這個(gè)bug。
RwLock::read的文檔提到,如果當(dāng)前線程已經(jīng)持有鎖,則函數(shù)可能會死機(jī)。我得到的只是一個(gè)掛起的程序。
一些語言試圖在其并發(fā)工具包中提供解決此問題的方法。Clang編譯器具有線程安全注釋,支持一種可以檢測競爭條件和死鎖的靜態(tài)分析形式。然而,避免死鎖的最佳方法是不使用鎖。從根本上解決這個(gè)問題的兩種技術(shù)是軟件事務(wù)內(nèi)存(在Haskell、Clojure和Scala中實(shí)現(xiàn))和actor模型(Erlang是第一種完全采用它的語言)。
2.文件系統(tǒng)是共享資源
Rust為我們提供了處理共享內(nèi)存的強(qiáng)大工具。然而,一旦我們的程序需要與外部世界進(jìn)行交互(例如,使用網(wǎng)絡(luò)接口或文件系統(tǒng)),我們就只能靠自己了。
Rust在這方面與大多數(shù)現(xiàn)代語言相似。然而,它會給你一種虛假的安全感。
千萬要注意,即使在Rust中,路徑也是原始指針。大多數(shù)文件操作本質(zhì)上是不安全的,如果不正確同步文件訪問,可能會導(dǎo)致數(shù)據(jù)競爭(廣義上)。例如,截至2023年2月,我仍然在rustup(https://rustup.rs/)中遇到了一個(gè)長達(dá)六年的并發(fā)錯(cuò)誤(https://github.com/rust-lang/rustup/issues/988)。
3.隱式異步運(yùn)行時(shí)
我不能認(rèn)真地相信量子理論,因?yàn)?物理學(xué)應(yīng)該描寫存在于時(shí)空之中,而沒有“不可思議的超距作用”的實(shí)在。
——愛因斯坦
筆者最喜歡Rust的一點(diǎn)是,它專注于本地推理。查看函數(shù)的類型簽名通常會讓自己對函數(shù)的功能有一個(gè)透徹的理解。
- 由于可變性和生存期注釋,狀態(tài)突變是顯式的。
- 由于普遍存在的Result類型,錯(cuò)誤處理是明確和直觀的。
- 如果正確使用,這些功能通常會導(dǎo)致神秘的編譯效果。
然而,Rust中的異步編程是不同的。
Rust支持async/.await語法來定義和組合異步函數(shù),但運(yùn)行時(shí)支持有限。幾個(gè)庫(稱為異步運(yùn)行時(shí))定義了與操作系統(tǒng)交互的異步函數(shù)。tokio包是最流行的庫。
運(yùn)行時(shí)的一個(gè)常見問題是它們依賴于隱式傳遞參數(shù)。例如,tokio運(yùn)行時(shí)允許在程序中的任意點(diǎn)生成并發(fā)任務(wù)。為了使該函數(shù)工作,程序員必須預(yù)先構(gòu)造一個(gè)運(yùn)行時(shí)對象。
這些隱式參數(shù)將編譯時(shí)錯(cuò)誤轉(zhuǎn)化為運(yùn)行時(shí)錯(cuò)誤。本來應(yīng)該是編譯錯(cuò)誤的事情變成了“調(diào)試冒險(xiǎn)”:
如果運(yùn)行時(shí)是一個(gè)顯式參數(shù),則除非程序員構(gòu)造了一個(gè)運(yùn)行時(shí)并將其作為參數(shù)傳遞,否則代碼不會編譯。當(dāng)運(yùn)行時(shí)是隱式的時(shí),您的代碼可能編譯得很好,但如果您忘記用神奇的宏注釋主函數(shù),則會在運(yùn)行時(shí)崩潰。
混合選擇不同運(yùn)行時(shí)的庫非常復(fù)雜。如果這個(gè)問題涉及同一運(yùn)行時(shí)的多個(gè)主要版本,那么這個(gè)問題就更加令人困惑了。筆者編寫異步Rust代碼的經(jīng)驗(yàn)與異步工作組收集的真實(shí)情況,可以說是一個(gè)悲慘的“事故”!
有些人可能會認(rèn)為,在整個(gè)調(diào)用堆棧中使用無處不在的參數(shù)是不符合邏輯的。顯式傳遞所有參數(shù)是唯一可以很好擴(kuò)展的方法。
4.函數(shù)是有顏色的
2015年,Bob Nystrom在博客《你的函數(shù)是什么顏色》中說道:理性的人可能會認(rèn)為語言討厭我們。
Rust的 async/.await語法簡化了異步算法的封裝,但同時(shí)也帶來了相當(dāng)多的復(fù)雜性問題:將每個(gè)函數(shù)涂成藍(lán)色(同步)或紅色(異步)。有新的規(guī)則需要遵循:
同步函數(shù)可以調(diào)用其他同步函數(shù)并獲得結(jié)果。異步函數(shù)可以調(diào)用和.await其他異步函數(shù)以獲得結(jié)果。
我們不能直接從sync函數(shù)調(diào)用和等待異步函數(shù)。我們需要一個(gè)異步運(yùn)行時(shí),它將為我們執(zhí)行一個(gè)異步函數(shù)。
我們可以從異步函數(shù)調(diào)用同步函數(shù)。但要小心!并非所有同步功能都是相同的藍(lán)色。
沒錯(cuò),有些sync函數(shù)非常神奇地變成了紫色:它們可以讀取文件、連接線程或在couch上睡眠thread::sleep。我們不想從紅色(異步)函數(shù)調(diào)用這些紫色(阻塞)函數(shù),因?yàn)樗鼈儠枞\(yùn)行時(shí),并扼殺促使我們陷入異步混亂的性能優(yōu)勢。
不幸的是,紫色函數(shù)非常吊軌:如果不檢查函數(shù)的主體和調(diào)用圖中所有其他函數(shù)的主體,就無法判斷函數(shù)是否為紫色。這些主體還在進(jìn)化,所以我們最好關(guān)注它們。
真正的樂趣來自于擁有共享所有權(quán)的代碼庫,其中多個(gè)團(tuán)隊(duì)將同步和異步代碼夾在一起。這樣的軟件包往往是bug筒倉,等待足夠的系統(tǒng)負(fù)載來顯示三明治中的另一個(gè)紫色缺陷,使系統(tǒng)無響應(yīng)。
具有圍繞綠色線程構(gòu)建的運(yùn)行時(shí)的語言,如Haskell和Go,消除了函數(shù)顏色的泛濫。在這種語言中,從獨(dú)立組件構(gòu)建并發(fā)程序更容易、更安全。
五、寫在最后
C++之父Bjarne Stroustrup曾說,世界上只有兩種語言:一種是人們總是抱怨的,另一種是沒人用的。
Rust是一種有“紀(jì)律型”的語言,它讓許多重要的決策都得到了正確的處理,例如對安全的毫不妥協(xié)的關(guān)注、特質(zhì)系統(tǒng)設(shè)計(jì)、缺乏隱式轉(zhuǎn)換以及錯(cuò)誤處理的整體方法。它允許我們相對快速地開發(fā)健壯且內(nèi)存安全的程序,而不會影響執(zhí)行速度。
然而,筆者經(jīng)常發(fā)現(xiàn)自己被意外的復(fù)雜性所淹沒,特別是當(dāng)我不太關(guān)心性能,并且想要快速完成一些工作時(shí)(例如,在測試代碼中)。Rust會將程序解構(gòu)成更小的部分,并將其由更小的部分來組合程序。此外,Rust僅部分消除了并發(fā)問題。
最后,筆者只想說,沒有哪種語言是萬金油。
原文鏈接:https://mmapped.blog/posts/15-when-rust-hurts.html#filesystem-shared-resource