對 Rust 語言的分析
Rust 是一門最近比較熱的語言,有很多人問過我對 Rust 的看法。由於我本人是一個語言專家,實現過幾乎所有的語言特性,所以我不認為任何一種語言是新的。任何“新語言”對我來說,不過是把早已存在的語言特性(或者毛病),挑一些出來放在一起。所以一般情況下我都不會去評論別人設計的語言,甚至懶得看一眼,除非它歷史悠久(比如像 C 或者 C++),或者它在工作中惹惱了我(像 Go 和 JavaScript 那樣)。這就是為什么這些人問我 Rust 的問題,我一般都沒有回復,或者一筆帶過。
不過最近有點閑,我想既然有人這么熱衷於這種新語言,那我還是稍微湊下熱鬧,順便分享一下我對某些常見的設計思路的看法。所以這篇文章雖然是在評論 Rust 的設計,它卻不只是針對 Rust。它是針對某些語言特性,而不只是針對某一種語言。
由於我這人性格很難閉門造車,所以現在我只是把這篇文章的開頭發布出來,邊寫邊更新。所以你要明白,這只是一個開端,我會按自己理解的進度對這篇文章進行更新。你看了之后,可以隔一段時間再回來看新的內容。如果有特別疑惑的問題,也可以發信來問,我會匯總之后把看法發布在這里。
變量聲明語法
Rust 的變量聲明跟 Scala 和 Swift 的很像。你用
let x = 8;
這樣的構造來聲明一個新的變量。大部分時候 Rust 可以推導出變量的類型,所以你不一定需要寫明它的類型。如果你真的要指明變量類型,需要這樣寫:
let x: i32 = 8;
在我看來這是丑陋的語法。本來語義是把變量 x 綁定到值 8,可是 x 和 8 之間卻隔着一個“i32”,看起來像是把 8 賦值給了 i32……
變量缺省都是不可變的,也就是不可賦值。你必須用一種特殊的構造
let mut x = 8;
來聲明可變變量。這跟 Swift/Scala 的 let 和 var 的區別是一樣的,只是形式不大一樣。
變量可以重復綁定
Rust 的變量定義有一個比其它語言更奇怪的地方,它可以讓你在同一個作用域里面“重復綁定”同一個名字,甚至可以把它綁定到另外一個類型:
let mut x: i32 = 1; x = 7; let x = x; // 這兩個 x 是兩個不同的變量 let y = 4; // 30 lines of code ... let y = "I can also be bound to text!"; // 30 lines of code ... println!("y is {}", y); // 定義在第二個 let y 的地方
在 Yin 語言最初的設計里面,我也是允許這樣的重復綁定的。第一個 y 和 第二個 y 是兩個不同的變量,只不過它們碰巧叫同一個名字而已。你甚至可以在同一行出現兩個 x,而它們其實是不同的變量!這難道不是一個很酷,很靈活,其他語言都沒有的設計嗎?后來我發現,雖然這實現起來沒什么難度,可是這樣做不但沒有帶來更大的方便性,反而可能引起程序的混淆不清。在同一個作用域里面,給兩個不同的變量起同一個名字,這有什么用處呢?自找麻煩而已。
比如上面的例子,在下面我們看到一個對變量 y 的引用,它是在哪里定義的呢?你需要在頭腦中對程序進行“數據流分析”,才能找到它定義的位置。從上面讀起,我們看到 let y = 4,然而這不一定是正確的定義,因為 y 可以被重新綁定,所以我們必須繼續往下看。30 行代碼之后,我們看到了第二個對 y 的綁定,可是我們仍然不能確定。繼續往下掃,30行代碼之后我們到了引用 y 的地方,沒有再看到其它對 y 的綁定,所以我們才能確信第二個 let 是 y 的定義位置,它是一個字符串。
這難道不是很費事嗎?更糟的是,這種人工掃描不是一次性的工作,每次看到這個變量,你都要疑惑一下它是什么東西,因為它可以被重新綁定,你必須重新確定一下它的定義。如果語言不允許在同一個作用域里面重復綁定同一個名字,你就根本不需要擔心這個事情了。你只需要在作用域里面找到唯一的那個 let y = ...,那就是它的定義。
也許你會說,只有當有人濫用這個特性的時候,才會導致問題。然而語言設計的問題往往就在於,一旦你允許某種奇葩的用法,就一定會有人自作聰明去用。因為你無法確信別人是否會那樣做,所以你隨時都得提高警惕,而不能放松下心情來。
類型推導
另外一個很多人誤解的地方是類型推導。在 Rust 和 C# 之類的語言里面,你不需要像 Java 那樣寫
int x = 8;
這樣顯式的指出變量的類型,而是可以讓編譯器把類型推導出來。比如你寫:
let x = 8; // x 的類型推導為 i32
編譯器的類型推導就可以知道 x 的類型是 i32,而不需要你把“i32”寫在那里。這似乎是一個很方便的東西。然而看過很多 C# 代碼之后你發現,這看似方便,卻讓程序變得不好讀。在看 C# 代碼的時候,我經常看到一堆的變量定義,每一個的前面都是 var。我沒法一眼就看出它們表示什么,是整數,bool,還是字符串,還是某個用戶定義的類?
var correct = ...; var id = ...; var slot = ...; var user = ...; var passwd = ...;
我需要把鼠標移到變量上面,讓 Visual Studio 顯示出它推導出來的類型,可是鼠標移開之后,我可能又忘了它是什么。有時候發現看同一片代碼,都需要反復的做這件事,鼠標移來移去的。而且要是沒有 Visual Studio,用其它編輯器,或者在 github 上看代碼或者 code review 的時候,你就得不到這種信息了。很多 C# 程序員為了避免這個問題,開始用很長的變量名,把類型的名字加在變量名字里面去,這樣一來反而更復雜了,卻沒有想到直接把類型寫出來。所以這種形式的類型推導,看似先進或者方便,其實還不如直接在聲明處寫下變量的類型,就像 Java 那樣。
所以,雖然 Rust 在變量聲明上似乎有更靈活的設計,然而我覺得 C 和 Java 之類的語言那樣看似死板的方式其實更好。我建議不要使用 Rust 變量的重復綁定,避免使用類型推導,盡量明確的寫出類型,以方便讀者。如果你真的在乎代碼的質量,就會發現大部分時候你的代碼的讀者是你自己,而不是別人,因為你需要反復的閱讀和提煉你的代碼。
動作的“返回值”
Rust 的文檔說它是一種“大部分基於表達式”的語言,並且給出這樣一個例子:
let mut y = 5; let x = (y = 6); // x has the value `()`, not `6`
奇怪的是,這里變量 x 會得到一個值,空的 tuple,()。這種思路不大對,它是從像 OCaml 那樣的語言照搬過來的,而 OCaml 本身就有問題。在 OCaml 里面,如果你使用 print_string,那你會得到如下的結果:
print_string "hello world!\n";; hello world! - : unit = ()
這里,print_string 是一個“動作”,它對應過程式語言里面的“statement”。就像 C 語言的 printf。動作通常只產生“副作用”,而不返回值。在 OCaml 里面,為了“理論的優雅”,動作也會返回一個值,這個值叫做 ()。其實 () 相當於 C 語言的 void。C 語言里面有 void 類型,然而它卻不允許你聲明一個 void 類型的變量。比如你寫
int main() { void x; }
程序是沒法編譯通過的(試一試?)。讓人驚訝的是,古老的 C 的做法其實是正確的,這里有比較深入的原因。如果你把一個類型看成是一個集合(比如 int 是機器整數的集合),那么 void 所表示的集合是個空集,它里面是不含有任何元素的。聲明一個 void 類型的變量是沒有任何意義的,因為它不可能有一個值。如果一個函數返回 void,你是沒法把它賦值給一個變量的。
可是在 Rust 里面,不但動作(比如 y = 6 )會返回一個值 (),你居然可以把這個值賦給一個變量。其實這是錯誤的作法。原因在於 y = 6 只是一個“動作”,它只是把 6 放進變量 y 里面,這個動作發生了就發生了,它根本不應該返回一個值,它不應該可以出現在 let x = (y = 6); 的右邊。就算你牽強附會說 y = 6 的返回值是 (),這個值是沒有任何用處的。更不要說使用空的 tuple 來表示這個值,會引起更大的類型混淆,因為 () 本身有另外的,更有用的含義。
你根本就不應該可以寫 let x = (y = 6); 這樣的代碼。只有當你犯錯誤或者邏輯不清晰的時候,才有可能把 y = 6 當成一個值來用。Rust 允許你把這種毫無意義的返回值賦給一個變量,這種錯誤就沒有被及時發現,反而能夠通過變量傳播到另外一個地方去。有時候這種錯誤會傳播挺遠,然后導致問題(運行時錯誤或者類型檢查錯誤),可是當它出問題的時候,你就不大容易找到錯誤的起源了。
這是很多語言的通病,特別是像 JavaScript 或者 PHP 之類的語言。它們把毫無意義或者牽強附會的結果(比如 undefined)到處傳播,結果使錯誤很難被發現和追蹤。
return 語句
Rust 的設計者似乎很推崇“面向表達式”的語言,所以在 Rust 里面你不需要直接寫“return”這個語句。比如,這個例子里面,你可以直接這樣寫:
fn add_one(x: i32) -> i32 { x + 1 }
返回函數里的最后一個表達式,而不需要寫 return 語句,這是函數式語言共有的特征。然而其實我覺得直接寫 return 其實是更好的作法,像這個樣子:
fn foo(x: i32) -> i32 { return x + 1; }
編程有一個容易引起問題的作法,叫做“不夠明確”,總想讓編譯器自動去處理一些問題,在這里也是一樣的問題。如果你隱性的返回函數里最后一個表達式,那么每一次看見這個函數,你都必須去搞清楚最后一個表達式是什么,這並不是每次都那么明顯的。比如下面這段代碼:
fn main() { println!("{}", add_one(7)); } fn add_one(x: i32) -> i32 { if (x < 5) { if (x < 10) { // 做很多事... x * 2 } else { // 做很多事... x + 1 } } else { // 做很多事... x / 2 } }
由於 if 語句里面有嵌套,每個分支又有好些代碼,而且 if 語句又是最后一個語句,所以這個嵌套 if 的三個出口的最后一個表達式都是返回值。如果你寫了“return”,那么你可以直接看有幾個“return”,或者拿編輯器加亮一下,就知道這個函數有幾個出口。然而現在沒有了“return”這個關鍵字,你就必須把最后那個 if 語句自己看清楚了,找到每一個分支的“最后表達式”。很多時候這不是那么明顯,你總需要找一下,而且這件事在讀代碼的時候總是反復做。
所以對於返回值,我的建議是總是明確的寫上“return”,就像第二個例子那樣。Rust 的文檔說這是“poor style”,那不是真的。有一個例外,那就是當函數體里面只有一條語句的時候,那個時候沒有任何歧義哪一個是返回表達式。
這個問題類似於重復綁定變量和類型推導的問題,屬於一種“用戶體驗設計”問題。無論如何,編譯器都很容易實現,然而不同樣式的代碼,對於人類閱讀的工作量,是很不一樣的。很多時候最省人力的做法並不是那種看來最聰明,最酷,打字量最少的辦法,而是寫得最明確,讓讀者省事的辦法。人們常說,代碼讀的時候比寫的時候多得多,所以要想語言好用省事,我們應該更加重視讀的時候,而不是寫的時候。
數組的可變性
Rust 的數組可變性標記,跟 Swift 犯了一樣的錯誤。Swift 的問題,我已經在之前的文章有詳細敘述,所以這里就不多說了。簡言之,同一個標記能表示的可變性,要么針對數組指針,要么針對數組元素,應該只能選擇其一。而在 Rust 里面,你只有一個地方可以放“mut”進去,所以要么數組指針和元素全部都可變,要么數組指針和元素都不可變。你沒有辦法制定一個不可變的數組指針,而它指向的數組的元素卻是可變的。
請對比下面兩個例子:
fn main() { let m = [1, 2, 3]; // 指針和元素都不可變 m[0] = 10; // 出錯 m = [4, 5, 6]; // 也出錯 }
fn main() { let mut m = [1, 2, 3]; // 指針和元素都可變 m[0] = 10; // 不出錯 m = [4, 5, 6]; // 也不出錯 }
內存管理
Rust 號稱實現了非常先進的內存管理機制,不需要垃圾回收(GC)或者引用計數(RC)就可以“靜態”的管理內存的分配和釋放。然而仔細思考之后你就會發現,這很可能是不切實際的夢想(或者廣告)。內存的分配和釋放(如果要及時釋放的話),本身是一個動態的過程,無法用靜態分析來實現。現在你說可以通過一些特殊的構造,特殊的指針和傳值方式,靜態的決定內存的回收時間,真的有可能嗎?
實際上我有一個類似的夢。我曾經向我的教授們提出過 N 多種不需 GC 和 RC 就能靜態管理內存的辦法,結果每一次都被他們給我的小例子給打敗了,以至於我很難相信有任何人可以想到比 GC 和 RC 更好的方法。
Rust 那些炫酷的 move semantics, borrowing, lifetime 之類的概念加在一起,不但讓語言變得復雜不堪,我感覺並不能從根本上解決內存管理問題。很多人在 blog 里面為這些概念熱情洋溢地做宣傳,顯得自己很懂一樣,拿一些玩具代碼來演示,可是從沒看到任何人說清楚這些東西為什么可以從根本上解決問題,能用到復雜一點的代碼里面去。所以我覺得這些東西有“皇帝的新裝”之嫌。
連 Rust 自己的文檔都說,你可能需要“fight with the borrow checker”。為了通過這些檢查,你必須用很怪異的方式來寫程序,隨着問題復雜度的增加,就要求有更怪異的寫法。如果用了 lifetime,很簡單一個代碼看起來就會是這種樣子。真夠煩的,我感覺我的眼睛都沒法 parse 這段代碼了。
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { }
上一次我看 Rust 文檔的時候,沒發現有 lifetime 這概念。文檔對此的介紹非常粗略,仔細看了也不知道他們在說些什么,更不要說相信這辦法真的管用了。對不起,我根本不想去理解這些尖括號里的 'a 和 'b 是什么,除非你先向我證明這些東西真的能解決內存管理的問題。實際上這個 lifetime 我感覺像是跨過程靜態分析時產生的一些標記,要知道靜態分析是無法解決內存管理的問題的,我猜想這種 lifetime 在有遞歸函數的情況下就會遇到麻煩。
實際上我最開頭看 Rust 的時候,它號稱只用 move semantics 和好幾種不同的指針,就可以解決內存管理的問題。可是一旦有了那幾種不同的指針,就已經復雜不堪了,比 C 語言還要麻煩,而且顯然不能解決問題。Lifetime 恐怕是后來發現有新的問題解決不了才加進去的,可是我不知道他們這次是不是又少考慮了某些情況。
Rust 的設計者顯然受了 Linear Logic 一類看似很酷的邏輯的啟發和熏陶,想用類似的方式奇跡般的解決內存和資源的回收問題。然而研究過一陣子 Linear Logic 之后我發現,這個邏輯自己都沒有解決任何問題,只不過給對象的引用方式施加了一些無端的限制,這樣使得對象的引用計數是一個固定的值(1)。內存管理當然容易了,可是這樣導致有很多程序你沒法表達。
開頭讓你感覺很有意思,似乎能解決一些小問題。到后來遇到大一點的實際問題的時候,你就發現需要引入越來越復雜的概念,使用越來越奇葩的寫法,才能達到目的,而且你總是會在將來某個時候發現它沒法解決的問題。因為這個問題很可能從根本上是無法解決的,所以每當遇到有超越現有能力的事情,你就得增加新的“繞過方法”(workaround)。縫縫補補,破敗不堪。最后你發現,除了垃圾回收(GC)和引用計數(RC),內存管理還是沒有其它更好更簡單的辦法。
當然我的意見也許不是完全准確,可我真是沒有時間去琢磨這么多亂七八糟,不知道管不管用的概念(特別是 lifetime),更不要說真的用它來構建大型的系統程序了。有用來理解這些概念,把程序改成奇葩樣子的時間,我可能已經用 C 語言寫出很好的手動內存管理代碼了。如果你真的看進去理解了,發現這些東西可以用的話,告訴我一聲!不過你必須說明原因,不要只告訴我“皇帝是穿了衣服的” :P
完
本來想寫一個更詳細的評價的,可是到了這個地方,我感覺已經失去興趣了,困就一個字啊…… Rust 比 C 語言復雜太多,我很難想象用這樣的語言來構造大型的操作系統。而構造系統程序,是 Rust 設計的初衷。說真的,寫操作系統那樣的程序,C 語言真的不算討厭。用戶空間的程序,Java,C# 和 Swift 完全可以勝任。所以我覺得 Rust 的市場空間恐怕非常狹小……
