寫給 Python 程式設計師看的 Rust 介紹

發表於2016-03-21

本文和原文都基於 CC-NC-SA 協議分享。

Rust 和 Python 截然不同,這不僅體現在 Rust 是編譯型語言,而另一者是解釋型,兩者在設計理念上就已經完全不同。
但即使出發點不一樣,並不妨礙他們在 API 設計上的存在相似性,Python 程式設計師可能會因此對 Rust 抱有好感。

語法

作為一個程式設計師,你首先注意到的不同點肯定在於語法。和 Python 不一樣,Rust 中存在著大量的花括號和分號,不過這些使得 Rust 在 Python 實現得不好的地方,比如匿名函式、閉包等等,使用起來簡單、清晰。這些特性使得編寫非縮排式的程式碼更加方便。

舉個例子,在 Python 中列印“Hello world”三次可以這樣做:

Rust版本:

除了關鍵字、函式名和花括號外並沒有明顯的不同。
語法上另一大不同之處在於 Rust 需要指定函式的引數型別,而 Python 不需要。
當然, Python 3 中新增的型別註釋(type annotations)使用起來和 Rust 的語法很像。

Rust 函式呼叫的結尾會有一個驚歎符,它其實是巨集。在編譯期間,巨集會展開成對應的東西。
具體看情況,究竟是用於字串格式化還是列印,這樣,編譯器就能在編譯階段強制字串型別了。
也就不會在列印的時候出現函式引數、型別不匹配的情況。

Traits vs Protocols(特性 vs 協議)

最常見的不同之處在於物件的行為。
在 Python 中,類可以通過實現特定的魔術方法來支援某行為,通常稱之為遵從 x 協議——比如 __iter__ 方法用於返回一個迭代器物件。
這些方法應該在類中實現,之後(例項化之後)就不能在修改了。(請無視 Monkey Patch)

Rust 的理念和 Python 類似,不過它選“特性”而非魔術方法。特性的不同之處在於它僅作用於本地,你可以在別的模組中實現更多特性。
如果你想要給整數特殊的功能,並不會影響到(全域性的)整數型別。

以一個自呼叫的型別為例。Python:

Rust:

Rust 的實現程式碼稍微長一些,但是它實現了 Python 程式碼中未處理的自動型別。
你可能還會注意到,Python 中的方法與型別宣告在一起,而 Rust 中兩者是分開宣告的:struct 定義資料,impl MyType 定義型別所具備的方法,impl Add for MyType 是 Add 特性的實現。
在 Add 方法的實現中,我們還定義了方法返回結果的型別,但是避免了像 Python 中那樣需要在執行時檢查型別的複雜性。

另一點區別在於,Rust 構造器是明確的,而 Python 的更具迷惑性。
Python 初始化物件時會呼叫 __init__ 來初始化物件,在 Rust 中則需要手動定義一個功能類似的靜態方法,通常是 new() 方法,來分配物件空間並構造。

錯誤處理/異常處理

Python 和 Rust 中的錯誤處理理念完全不同!
Python 中錯誤會以異常的形式丟擲,而 Rust 中則是返回值,這聽起來很奇怪,但卻是是一個不錯的設計。

從函式的返回值定義中就可以確定它可能“丟擲”的異常,是一種非常清晰的宣告方式,
和 Python “顯勝於隱”的設計哲學不謀而合。(Python 中也鼓勵手動丟擲異常而非過多的條件判斷。)

Rust 中的函式可以返回 Result,Result 是一種規範化的型別,分為成功和失敗兩種。
Result 表示這個方法成功時會返回 32位的整數型別,失敗時返回 MyError。
如果你需要返回多種錯誤怎麼辦?

Python 函式可能會丟擲任何錯誤,但並不會做出任何處理。比如,你在使用 requests 庫時可能會遇到 SSL error 或者其它錯誤,並且只有當它發生時你才知道出錯了,但如果文件中沒有明確的說明,你永遠都不知道它會返回什麼樣的錯誤。

Rust 不一樣,函式的宣告中就包含了會遇到什麼樣的錯誤。
如果需要返回兩種以上的錯誤,通常是建議定義一個內部錯誤來整合。
以一個 HTTP 庫為例,它可能會丟擲 Unicode error、IO error、SSL error 等等。
你可以把它們都定義為一個只在你的庫中使用的錯誤型別,使用者也只需要知道它即可。

Rust 的錯誤鏈機制能夠在你需要的時候回溯到產生錯誤的地方。
你可以在任意時候是使用 Box 這個所有錯誤的根來代替自定義錯誤。
相比之下,Rust 的錯誤更透明,而 Python 中會比較繞。

Rust 的錯誤處理機制是由 try! 巨集提供的,下面是一個例子:

上面程式碼中的 File::openread_to_string 都會失敗並返回 IO error,
try! 巨集會將錯誤向上傳遞,並且會立即返回。返回資訊是 success 還是 failure
由包裹函式是 Ok 還是 Err 確定。

try! 巨集引用了 From 特性以執行錯誤轉換。例如你可以通過修改返回值 io::ErrorMyError,並且通過實現 From 特性來實現 io::ErrorMyError 的轉換,它也會被自動引用。

或者,你也可以使用 Box 作為返回值型別代替 io::Error,這樣就可以傳遞任何型別的錯誤了。
這樣做的壞處是,原本編譯期就能確定錯誤的程式必須正式執行的時候才能確定。

如果你不打算處理異常而直接退出執行,可以 unwrap() 結果,這樣成果返回的結果會是一個錯誤,程式會因此退出。

可變性和所有權

可變性(mutability)和所有權(ownership)是 Python 和 Rust 最本質區別的地方!
Python 會自動 GC ,因此執行的時候會有很多意想不到的事情發生,你可以隨意地傳遞物件,雖然可能產生一些記憶體洩漏問題,不過大部分問題都會它都能在執行時自動解決。

Rust 不支援 GC,但仍然能夠自動管理記憶體,這得益於其所有權跟蹤機制,你建立的所有物件都被另一個物件所擁有。

對比 Python,你可以認為 Python 中的所有物件的所有權都是 Python 直譯器。

Rust 中的所有權存在範圍設定,一個呼叫物件列表的函式擁有這個列表的所有權,而列表擁有其中所有物件的所有權,而這一切都發生在函式的作用域中。

下面通過一個關於生命週期註釋(lifetime annotation)和函式簽名的更復雜的例子來幫助你理解什麼是“所有權”。以實現上面的“加法”為例,接收者(receiver)和 Python 中一樣命名為 self,不同的是值會被“移動到”函式中,而不像 Python 那樣是通過可變引用來實現。也就是說,你可能是用 Python 這樣實現:

當你將 MyType 的例項加入另一個物件時,就會將 self 洩漏到 global 列表中,
也就是說,執行上面的程式碼將獲得

當你將一個 MyType 型別的例項和另一個物件相加時都將會使得自身洩漏到全域性列表中,
也就是說,當你執行上面的程式碼時,第一個 MyType 將會被引用兩次:被第二個例項引用,
以及被 global 所引用。

而 Rust 中不存在這樣的問題,每個物件都只能有一個所有者。你在引用 self 時,
編譯器將會將值“移動”過去,這時函式將無法找到原來的 self 也就無法將它 return 回去。
想要 return self 則必須向將它移動回去(將它從引用中刪除)。
如果你想要把 self 洩露出去,編譯器會負責“移動”值,但是這樣函式就無法返回 self,因為它已經被移動了。想要返回它,你首先還是需要把它再移動回去(比如從列表中將它刪除)。

如果你需要多次引用同一個物件該怎麼辦呢?Rust 提供的解決方案是“借用(borrow)”,“借用”這個變數的值。
借用的數量可以沒有限制,但是不允許對借用的物件進行修改,或者可以修改但只允許存在一個借用。

操作不可變“借用”的函式被標記為 &self,使用可變借用的函式被標記為 &mut self。作為擁有者你只能“借出”引用。如果想要將值移出函式(比如返回),則不能有額外的借出,並且將所有權移動後不能再借出。

這可能會顛覆你思考程式的方式,但習慣它能幫你更好地理解程式。

執行時的“借用”和可變的所有者

目前為止,都能在執行時驗證所有的所有權並沒有問題,但編譯時無法驗證所有權怎麼辦?

你有以下可選方案:第一種方案你可以使用 mutex,mutex 保證只有一個使用者可以可變地借用物件,但物件的所有者是 mutex。這樣你就可以在視線獲取物件時,永遠只有一個執行緒能夠取到它。

這也意味著,你不得不使用 mutex 鎖,否則會導致資料競爭,無法通過編譯。

如果你想像 Python 一樣程式設計,不用找出記憶體的所有者?

在那種情況下你可以將一個物件封裝在引用計數器中,並在執行時將其借出。

這種方式非常類似 Python,同時也可能導致迴圈。Python 會在 GC 的時候解除迴圈,但 Rust 不會。

不妨用個比較複雜的例子來比較一下:

上面的程式碼會生成 35 個執行緒,都用於計算斐波那契數,最後將所有結果聚合並排序。
你可能會注意到 mutex(鎖)和結果陣列直接並無關係。

Rust 版本和 Python 版本最大的不同之處在於使用了 B 樹 map 而不是 hash 表,結果存放於 Arc mutex 中。

這是為什麼?這裡使用 B 樹是因為它能夠自動排序,而這正是我們所需要的。

將值存放在 mutex 中是因為這樣可以在執行時將其鎖住。

關係建立後,我們將其置入 Arc,因為 Arc 會管理它所封裝的事物的引用計數,本例中即 mutex。也就是說,直到最後一個執行緒執行結束時 mutex 才會被刪除。非常簡潔!

下面解釋下這段程式碼是如何工作的:首先數 20 次,和在 Python 中一樣,每次都執行一個本地函式。和 Python 中不同的是,我們在這裡可以使用閉包。之後將 Arc 複製到本地執行緒中,也就是說每個執行緒都會有自己的 Arc(這會 Arc 的增加引用計數,但會線上程結束的時候釋放)。之後我們使用本地函式 spawn 一個新執行緒,這會將閉包移動到執行緒中。

之後每個執行緒都會執行 Fibonacci 函式,When we lock our Arc we get back a result we can unwrap and the insert into.
先不要管解包過程,這只是將明確的結果轉換為 panic。重點在於,你只有在解除 mutex 鎖之後才能獲得結果對映,所以千萬不能忘記解鎖。

之後我們將所有執行緒集中到 vector 中,最後迭代所有執行緒,合併並列印結果。

這裡有兩點需要注意的地方:可見型別非常少。當然,Arc 和 Fibonacci 函式接受 64 位 unsigned 整數,除此之外,沒有任何型別是可見的。在我們也可以使用 B-tree 對映來代替雜湊表,因為 Rust 內建了這個型別。

迭代的執行方式和 Python 幾乎完全一致,不同之處在於,這上面這個 Rust 例子中我們需要引入 mutex,因為編譯器不知道執行緒會怎樣結束,而 mutex 是不必要的。當然 Rust 也有不需要引入 mutex 的 API,只不過在 Rust 1.0 中還不是穩定版本。

效能優化會如你所期望地那樣進行。(這裡的優化情況很糟糕,因為只是未來提供一個執行緒執行的例子。)

Unicode

我最喜歡的話題是 Unicode :) ,這也是 Python 和 Rust 相差最大的地方。

Python (2 和 3)都使用著相似的 Unicode 模型,將 Unicode 資料對映至字元陣列。

在 Rust 中,Unicode 都是 UTF-8 格式儲存,我之前也提到過為什麼這比 Python 或者 C# 的解決方案要好得多(參見 UCS vs UTF-8 as Internal String Encoding)。
非常有趣的是 Rust 是如何處理醜陋的編碼問題的。

首先 Rust 一開始就意識到作業系統(不論是 Windows Unicode 還是 Linux 非 Unicode)的 API 都非常糟糕,它沒有像 Python 一樣強制使用 Unicode,但是實現了一套低廉的字元轉化系統,這在現實使用中非常出色,也使得 Rust 擁有高效的字元處理能力。

對於大多數支援 UTF-8 的程式來說,編碼、解碼並不需要,只需要簡單地驗證編碼的正確性,並不需要的對 UTF-8 字元編碼後再輸出。如果需要整合 Windows Unicode API,只需要在內部使用 WTF-8 進行編碼,可以很高效地和 UTF-16 這樣的 UCS2 編碼之間進行轉換。

無論何時,你都可以將 Unicode 和位元組碼互相轉換,之後檢驗以確保所有操作都按預期進行了。這使得編寫協議既快速又高效。和 Python 那種不斷編碼、解碼的方式相比,只需要支援 O(1) 的字元索引。

得益於 Unicode 優秀的儲存模型,Rust 還自帶或者太 crates.io 上提供了很多 Unicode 處理的 API,包括 case folding、分類、Unicode 正規表示式、Unicode 正常化、標準的 URI/IRI/URL API、分割操作以及命名對映等等。

缺點呢?"föo"[1] 的結果不是 'ö',但你本來就不應該這樣做。

下面有一個用於示範如何和 OS 整合的例子,執行它會列印出當前目錄的資訊和檔名:

所有 IO 操作都使用了上面用過的 Path 物件,包含了 OS 內部的路徑屬性。根據系統使用的編碼,它可能是位元組、Unicode 或者 OS .display() 格式化(這會返回一個能將自身格式化為字串的物件)前的呼叫格式。這很方便,因為你不會像在 Python 3 中那樣無意中漏掉錯誤編碼的字串,它提供了清晰的區分。

庫以及應用釋出

Rust 提供了一個稱為“cargo”的工具,作用基本相當於 Python 中的 virtualenv+pip+setuptools ,
不過預設情況下它盡能保證一個版本的 Rust 正常工作。
“cargo” 能夠同時支援庫的不同版本,並且可以直接從 git 倉庫或者 crates.io 源中安裝程式,通常安裝 Rust 的時候就已經自帶了 cargo。

Rust 會代替 Python 嗎?

Python 和 Rust 之間並沒有直接關係,

  • 對於科學計算等領域,豐富的庫和文件的作用非常之大,Rust 不可能在短期內影響到 Python 在其中的地位。
  • 對於指令碼這樣的領域,只要 Python 能解決,我不認為你會考慮選擇 Rust。
  • 雖然肯定會有很多 Python 程式設計師去學習 Rust,但就和很多 Python 學習 Go 一樣,並不是以替代 Python 為目的。

Rust 是一門非常強大的語言,擁有穩定的基金會、友好的社群、人性化的許可證,可能會在程式語言的民主制度掀起一場革命。

Rust 幾乎不需要執行時支援,因此通過 ctypes 或 CFFI 來和 Python 互動並不難,直接使用 Python 封裝的 Rust 二進位制庫並非不可能。

相關文章