Rust 入門 (Rust Rocks)

  • 緣起
  • 實踐出真知
    • 快速獲取
    • 澄清概念
      • Ownership
      • Move
      • Reference
      • Mutable reference
    • 解釋錯誤
    • 數據競態條件
    • 構建樹狀結構
    • 渲染樹狀結構
  • 總結
  • 源碼 Github

TL;DR

下面我對內部分享話題的引言部分

Rust 是一門系統編程語言,也是許多區塊鏈底層編程語言,不論是舊歡 Parity,還是新貴 Libra;不論是微軟還是 Linux 的核心開發者都對它青眼有加。

Rust 有一些很迷人的地方,比如:AOT,內存安全性,空指針安全性,還是豐富的類型系統,異或是龐大的社區生態。這些別的語言也有,但是都沒有這么徹底。

Rust 的特點也十分突出,Ownership 和 borrow、references 還有 shadow,令人眼花繚亂,最最有趣的就是你寫一段程序,編譯器會 blame 你很多,你就感覺是在和編譯器搏斗。

學習 Rust 不只是一時興起(早在2015年我就聽聞過),也是一種擁抱變化的態度,重要的是可以讓你在看眾多區塊鏈代碼時不會那么心慌。

語言的趨勢反映了未來的主流開發群體的預測,這點 Rust 確實算是后起之秀。

本次話題,我會講解我學習 Rust 的過程,希望指導大家在學習新編程語言時應該怎么做才更高效,同時會用一段小程序 tree 來給大家演示它的與眾不同之處。

緣起

做區塊鏈的基本幾乎沒有人不知道 Rust 這門編程語言,它非常受區塊鏈底層開發人員的青睞。說來也奇怪,Rust 起源于 Mozilla,唯一大規模應用就是 Firefox,作為小眾語言卻在區塊鏈圈子里火了。這其中應該和以太坊的發起人 Govin Wood 創建的 Parity 項目有關,Parity 是一款用 Rust 編寫的以太坊客戶端。

最初接觸 Rust 的時間大概是 2015 年,當年有同事發了一封“是否對 Rust 編程語言感興趣的”的郵件。當時年少不懂事熱血,覺得這門語言因為它小眾很酷,所以特別適合拿來練功,所以就激情地回應了郵件,結果之后就沒有了下文,想必那位同事也因為響應的人數太少而興致缺缺。

第二次關注 Rust 是因為陳天在自己的公眾號中提到了這門語言。我比較欣賞陳天,當初學習 Elixir 也是受他影響,所以也跟著他的步伐去聽了張漢東的知乎Live,然后加入了他的讀者群(魅力Rust),在這個群中潛水了大半年,一直很驚嘆這個群的活躍度。

2019年,區塊鏈圈中的一次大事件是 Facebook 要發非主權貨幣 Libra,隨之而來是基于 Rust 之上的 Move 編程語言。這個 Move 說白了就是 Rust 的一種 DSL,用比較學術的話說是指稱(denotational)語義,用簡單的編譯器把 Move 的語法翻譯成 Rust 的語法然后借助 Rust 的編譯器生成二進制碼。這個過程沒有什么驚喜,不過 Move 語言顯然是借鑒了 Rust 中移交(Move)主權(Ownership)的概念,它表征了這樣一種事實——數字資產只能有一個主人,一旦移動,就會發生主權轉移,以前的主人就喪失了該主權。這種想法和 Rust 中主權管理非常契合,所以不難理解為什么 Libra 的開發團隊把名字也照搬過來了。當然,Libra 的底層區塊鏈也用的是 Rust。這個大事件加上以太坊 Parity 的珠玉在前,對于程序員這群天生喜歡新鮮事物的人類而言,學習 Rust 的熱情必然水漲船高。

大概就是在這種契機下,我開始學習 Rust 的。依照老規矩,我還是會從 tree 這個命令行程序入手,在試錯中逐步學習 Rust 這門語言。包含它的基本數據類型,組合數據類型,控制流,模塊(函數)以及文件和集合操作,還有最關鍵的 Ownership 的應用。

實踐出真知

學習 Rust 最深刻的體驗莫過于和編譯器較勁,這也是我聽到過最多的抱怨。我想許多新手看到這么多警告或者錯誤,嘴上不說,心里應該很不是滋味。但是這也是 Rust 引以為豪的設計哲學。

每一門新進的語言都有自己的本質原因(Rationale)或者設計哲學,比如 Lisp 家族的 Clojure 就有 Elegance and familiarity are orthogonal 的玄言妙語;往遠古追溯,Java 的 Write Once, Run Anywhere 豪言壯語;而 Rust 的基本設計哲學是 If it compiles, then it works,這個條件有多苛刻我們稍微想一想就能知道——動態弱類型語言向靜態強類型語言的逐步趨同態勢,基本已經宣告了類型系統的勝利。

但即便如此,現代軟件工程也還是處處強調程序員要手寫各種測試確保代碼運行時的正確性——從單元測試到集成測試,從冒煙測試到回歸測試,從 Profiling 到性能測試。這些測試方法和工具已經深入到軟件工程的方方面面,然而各類軟件還是漏洞百出。Rust 發出這種高調宣言,不免有夜郎自大之嫌疑。不過程序屆是個能造概念也能落地概念的神奇圈子,高調的牛吹著吹著也就實現了。況且,Rust 充分詮釋了現代編程語言的核心思想——約束程序員,不是勸勸你的意思,是憋死你的意思。

我在《我是如何學習新的編程語言》中說過學習的最好方式是有目的地試錯,我時常拿來練手的程序叫tree - list contents of directories in a tree-like format. 這段程序需要用到的 Rust 基本構件有:

基礎概念
1. 變量 - let
2. ownership borrow - &
3. 可變性 - mut
4. 可變引用 - &mut


復合數據類型
1. String - String::from("") // 非基本類型
2. Slice - "" or vec[..]
2. struct - struct {}

集合及其操作
1. Vec<_> - Vec::new() // 考慮到集合需要自動擴展
2. iter()
3. .map()
4. .enumerate()
5. .flatten()
6. .collect()
7. .extend() //集合拼接

控制語句
1. if Expressions - if {} else {}
2. recursions

模塊
1. fn - fn x(s: String) -> Vec<String>

功能組件
1. Path
2. fs
3. env

當嘗試尋找這些元素時,我發現 Rust 或者諸如此類的編譯型語言都有一個讓人不舒服的地方——驗證的前置步驟耗時太長。因為沒有repl,所以想去了解一些概念的使用方法,就不得不另外創建一個項目(我可不想污染當前項目的代碼),在它的 main 函數里編寫試驗程序,這比起具有快速反饋能力的repl,著實太慢了。不過這里的慢也是相對的,Rust 也有一個顯著的優勢,在出現編譯錯誤時,編譯器不僅能向你解釋原因,還能推薦潛在的修改方式,這就比 Javascript 一類的動態語言要清晰和高明得多。再利用內置的 assert_eq! 等斷言函數預判結果,又比單獨寫測試省事。所以,總體而言,學習的過程還是很愉悅的。

快速獲取

這里舉個例子,為了解如何拼接兩個集合時,需要事先搞明白幾個問題:

  1. 集合的構造?
  2. 集合的拼接?
  3. 結果的斷言?

在沒有repl的條件下,唯一快速上手的工具就是文檔,在 https://doc.rust-lang.org/std/ 的官方標準庫中,可以搜到Struct std::vec::Vec詳細解釋

通過例子程序,可以很快知道集合的構造方式如下:

let mut v = vec![1, 2, 3];
v.reverse();
assert_eq!(v, [3, 2, 1]);

vec! 宏可以快速構造出一個集合來,順便試驗下它的reverse方法。那么集合如何拼接呢?為了解答這個問題,我一般會用搜索引擎,或者深入文檔,查找如 concatappend等關鍵字,每每總有收獲。

在不考慮非功能需求的前提下,我們先用最直接的方式實現,例如:文檔中給出的樣例extend方法

let v = vec![1, 2, 3];
v.extend([1, 2, 3].iter().cloned()); // 編譯錯誤

注意,這里編譯失敗。Rust 編譯器會直截了當地給出錯誤信息。

error[E0596]: cannot borrow `v` as mutable, as it is not declared as mutable
  --> src/main.rs:13:5
   |
12 |     let v = vec![1, 2, 3];
   |         - help: consider changing this to be mutable: `mut v`
13 |     v.extend([1, 2, 3].iter().cloned());
   |     ^ cannot borrow as mutable

錯誤信息中透露出我們的程序在嘗試借用(borrow)一個不可變的變量。borrowmutable都是新的概念。對于新的概念,我們會習慣地用熟知的知識去類比。如果套用函數式編程中不可變的特性,大體可以猜到 Rust 中的變量默認是不可變的。但是 cannot borrow as mutableborrow 確實是有點超出認知范圍。那么此時弄清定義是非常有必要的。

澄清概念

學習語言的過程中最需要注意的事項就是澄清概念。當遇到嶄新的概念時,我們得停下先去補充這部分的知識,然后再回過頭來理解和解決實際遇到的問題。因為每一門編程語言都有本門派的哲學原理,它本身就萃取了多種理論和實踐的成果,所以必須學習這些概念。學習的過程其實就是逐步澄清概念的過程。

在學習(嘗試定義)borrow 的過程中,我又先后接觸到了 ownership, move, reference, mutable reference 等概念。所以我定義了這些概念:

Ownership

變量擁有它指稱的值的所有權。
在 Rust 當中,變量擁有它指稱的值,即變量(variable)是它指稱值(value)的主人(owner),值一次只能有一個主人,一旦主人離開作用域它的值就會被銷毀。

Move

把一個變量的值重新賦值給另一個變量的行為。
根據 Ownership 的定義,值一次只能有一個主人,所以此時該值的所有權會被轉移給另一個變量,原來的變量就喪失了對這個值的所有權,導致的直接影響就是這個變量此后不再可用。

Reference

一個變量指向(refer to)值而非擁有該值的所有權的狀態。
在很多賦值的場景,包括變量賦值或者函數參數賦值,我們并不希望之后原來的變量不再可用,此時可以通過&(ampersands創建一個指向值的引用,將引用進行賦值時不會發生 Move,所以原來的變量依舊可用。這種賦值行為被稱為borrow(借用)。結合實際,我們擁有的物品可以出借給別人,別人享有該物品的使用權(Possession),而非所有權(Ownership)。

Mutable reference

標識該引用的值是可變的。

很多場景下,我們希望引用傳遞的值是可以改變的。此時我們就必須通過&mut標識該引用,否則不允許修改操作發生。值得注意的是,&mut標識要求原來的變量也必須是mut的,這很好理解,可變的變量的引用也得可變。而且為了防止數據競態條件的發生,在同一個作用域下,&mut的引用只能有一個,因為一旦出現多個可變引用,就可能遭遇不可重復讀風險(注意,Rust 保證這里沒有并行修改的風險)。而且同一個值的&mut&的引用不能共存,因為我們不希望一個只讀&的值同時還能被寫&mut,這樣會導致歧義。

解釋錯誤

澄清了必要概念以后,我們再來回顧上面的代碼。先去看一下這個extend函數的定義:

fn extend<I>(&mut self, iter: I)
where
    I: IntoIterator<Item = T>, 
Extends a collection with the contents of an iterator...

原來v.extend只是一個語法糖,真正的方法調用會把self作為第一個參數傳遞到extend(&mut self, iter: I)當中。可變引用作為函數參數賦值,那么自然原來的變量也必須聲明成可變的。

所以我們照著它的指示修正如下:

let mut v = vec![1, 2, 3]; // 加上一個mut修飾符
v.extend([1, 2, 3].iter().cloned());

這回編譯器消停了,利用assert_eq!,我們來驗證extend操作的正確性。

assert_eq!(v, [1, 2, 3, 1, 2, 3]);

另外,值得注意的是,Rust 和我們熟悉的函數式編程有些不同,集合的拼接不會產生一個新的集合,而是對原有的集合進行修改。一般情況下,我們都會警惕可能會出現數據的競態條件——多個線程對該集合進行寫入操作怎么辦?帶著這個問題,我們反思一下什么是數據的競態條件。

數據競態條件

數據競態條件發生的必要條件有:

  1. 多個引用同時指向相同的數據;
  2. 至少有一個引用在寫數據;
  3. 對于數據的訪問沒有同步機制。

考察1和2:
假如此處有兩個引用指向同一個集合,如下:

let mut v = vec![1, 2, 3];
let r1 = &mut v;
let r2 = &mut v;
assert_eq!(r1, r2);

編譯器會立即給出編譯錯誤

error[E0499]: cannot borrow `v` as mutable more than once at a time
--> src/main.rs:13:10
|
12 | let r1 = &mut v;
|          ------ first mutable borrow occurs here
13 | let r2 = &mut v;
|          ^^^^^^ second mutable borrow occurs here
14 | assert_eq!(r1, r2);
| ------------------- first borrow later used here

也就是說,在指定的作用域下只能有一個可變引用。為什么要如此設計呢?在單線程下,這好像并不會出現數據競爭的問題[1]。不過考慮到下面這種場景的語義,我們思考一下。

let mut v = vec![1, 2, 3];
let r1 = &mut v;
let r2 = &mut v;
assert_eq!(r2[1], 2);
*r1 = vec![0]
assert_eq!(r2[1], 2); // 失效

一旦允許r1改變數據,那對于r2而言,它先前持有的數據就已經發生改變甚至失效,再拿來使用就有問題了,在上面這個例子當中,*r1解除引用后被重新賦值,導致v的值隨之改變,但是r2并不知情,依舊使用r2[1]導致此處越界。這個問題和數據庫中事務的不可重復讀(提交讀)的隔離級別類似,但是在單線程下這并不能算作充分的理由,只是說在語義層面有細微的不自然,留待后續研究。

蹊蹺的是,如果我將兩個可變引用放到不同的函數中,同樣的邏輯卻可以繞過編譯器錯誤。

fn main() {
    let mut v = vec![1, 2, 3];
    mut1(&mut v);
    mut2(&mut v);
}

fn mut1(v: &mut Vec<i32>) {
    *v = vec![0];
}

fn mut2(v: &mut Vec<i32>) {
    println!("{}", v[1]); // panicked at 'index out of bounds' 運行時錯誤
}

可見,上述的論述并沒有解釋清楚在單線程下同一個作用域下限制多個可變引用的根本原因。

對于&mut&其實也可以做同樣的解釋。所以&mut&在 Rust 同一個作用域中無法共存。

考察3:
至于在多線程的環境下,是否會出現數據競態條件,我們得看 Rust 在線程使用方面的限制。在 Rust 的上下文里,使用Thread::spawn的線程時必須 Move 所有權[2],因為在 Rust 看來,Thread 的 LifeTime(生命周期)會比調用它的函數的生命周期的長,如果不 Move 所有權,那么線程中數據就會在調用函數結束后釋放掉變量的內存,導致線程中的數據無效。所以,這樣的限制是很有必要的,但反過來想,一旦數據的所有權發生轉移,那么多個線程并行修改同樣數據的可能性也就不復存在。

構建樹狀結構

struct Entry {
    name: String,
    children: Vec<Entry> 
}

fn tree(path: &Path) -> Entry {
    Entry{
        name: path.file_name()
            .and_then(|name| name.to_str())
            .map_or(String::from("."), |str| String::from(str)),
       
        children: if path.is_dir() {
            children(path)
        } else {
            Vec::new()
        }
    }
}

既然是樹狀結構,定義的結構體就是遞歸的。這里的struct Entry {}就是一種遞歸的結構。我想實現的樹狀結構大致如下:

entry :: {name, [child]}
child :: entry

Rust 中沒有顯式的return,最后一個表達式的結果會被當成返回值,所以此處整個Entry結構體會被返回。

path.file_name()
 .and_then(|name| name.to_str())
 .map_or(String::from("."), |str| String::from(str)),

這段代碼看上去很復雜,但實現的功能其實很簡單,目的是為了獲取當前文件的文件名。那么邏輯為何如此繞呢?這是由于 Rust 中的多種字符串表示導致的問題,暫按不表。先去看看各個函數的定義。

Path.file_name 的定義

pub fn file_name(&self) -> Option<&OsStr>

and_then是我們常見的flat_map操作在 Rust 中的命名,其目的是為了在兩個Option之間實現轉換。

OsStr.to_str 的定義

pub fn to_str(&self) -> Option<&str>

上面的path.file_name().and_then(|name| name.to_str())最終轉變成了Option<&str>,在其上調用Option.map_or方法并提供默認值:字符串"."。為什么要提供默認值呢?這和OsStrStr的轉換密切相關,當我們傳入參數"."時,Path.file_name返回的其實是一個None

構建了父級的樹狀結構,我們需要把子級的樹狀結構也一并完成,最終通過遞歸,構建出一棵內存中的目錄樹。

fn children(dir: &Path) -> Vec<Entry> {
    fs::read_dir(dir)
        .expect("unable to read dir")
        .into_iter()
        .map(|e| e.expect("unable to get entry"))
        .filter(|e| is_not_hidden(&e))
        .map(|e| e.path())
        .map(|e| tree(&e))
        .collect()
}

fn is_not_hidden(entry: &DirEntry) -> bool {
    entry
         .file_name()
         .to_str()
         .map(|s| !s.starts_with("."))
         .unwrap_or(false)
}

這里也存在挺多的轉換操作,我們一一解釋。

fs::read_dir(dir).expect("unable to read dir")

使用expect是因為fs::read_dir返回的是一個Result<ReadDir>,在其上調用expect會嘗試解開其中的值,如果有錯則會拋出錯誤。解開的結果類型是ReadDir,它是io::Result<DirEntry>的迭代器,也就是一個目錄下的所有類目,可以在上面調用into_iter()創建出可以被消費的迭代器。

.map(|e| e.expect("unable to get entry"))
.filter(|e| is_not_hidden(e))
.map(|e| e.path())
.map(|e| tree(&e))

接著,解開Result<DirEntry>之后,我們把隱藏文件過濾掉,因為filter接收的一個閉包,這個閉包的類型聲明是P: FnMut(&Self::Item) -> bool,所以filter接收的所有元素都是引用類型,故調用時無需需聲明成is_not_hidden(&e)

然后利用e.path()獲取每個文件的全路徑,并依次交給tree去遞歸構建。經過treechildren兩個函數的交替遞歸,內存中的一棵目錄樹就被構建出來了。

有了內存中的樹狀結構,我們接下來就可以渲染這個結構了。具體的做法如下:

  1. 對于第一層目錄名,如果它是最后一個目錄,則前綴修飾為L_branch = "└── ";反之,裝飾成 T_branch = "├── "
  2. 對于有子目錄,如果是其父目錄是父級最后一個目錄,則前綴裝飾為SPACER = " ";反之,前綴裝飾成 I_branch = "│ "

邏輯如下:

fn decorate(is_last: bool, children: Vec<String>) -> Vec<String> {
    const I_BRANCH: &str = "│   ";
    const T_BRANCH: &str = "├── "; 
    const L_BRANCH: &str = "└── ";
    const   SPACER: &str = "    ";

    let prefix_first = if is_last { L_BRANCH } else { T_BRANCH };

    let prefix_rest = if is_last { SPACER } else { I_BRANCH };

    let mut first = vec![format!("{}{}", prefix_first, children[0])];

    first.extend(children[1..].iter().map(|child| format!("{}{}", prefix_rest, child)).collect::<Vec<_>>());

    first
}

這里比較好用的字符串拼接操作是format!("{}{}", &str, &str)

渲染樹狀結構

fn render_tree(tree: &Entry) -> Vec<String> {
    let mut names = vec![tree.name]; // error
    let children = &tree.children;
    let children: Vec<_> = children
        .iter()
        .enumerate()
        .map(|(i, child)| decorate(children.len() - 1 == i, render_tree(child)))
        .flatten()
        .collect();
    
    names.extend(children);

    names
}

這里會有編譯錯誤,錯誤信息如下:

error[e0507]: cannot move out of `tree.name` which is behind a shared reference
  --> src/main.rs:48:26
   |
48 |     let mut names = vec![tree.name];
   |                          ^^^^^^^^^ move occurs because `tree.name` has type `std::string::string`, which does not implement the `copy` trait

由于tree.name不是標量類型(Scalar Type),它沒有實現copy trait(見提示),又因為tree本身是復合類型(Compound Type),tree.name如果發生 Move 的話,包含它的tree就有問題了。為了避免發生這種情況,我們不得不去引用&tree.name。但是一旦加上引用,又會出現類型不匹配的編譯錯誤。

59 |     names
   |     ^^^^^ expected struct `std::string::String`, found reference
      |
         = note: expected type `std::vec::Vec<std::string::String>`
                       found type `std::vec::Vec<&std::string::String>`

我們期待的是Vec<String>而不是Vec<&String>,所以需要重新構建出一個String出來。可以使用String::from(&String)方法

let mut names = vec![String::from(&tree.name)];

這樣修改下來,才能保證編譯完全通過。但事實上,Rust 給我們提供了一個更加便捷的寫法

let mut names = vec![tree.name.to_owned()]

使用to_owned()表示重新拷貝了一份數據,和重新構建一個String出來別無二致。

組合調用

use std::env;
use std::path::Path;
use std::fs::{self, DirEntry};

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{}", render_tree(&tree(Path::new(&args[1]))).join("\n"));
}

render_tree 返回的是Vec<String>,所以為了打印出來,我們將所有元素用"\n" join到一起。

.
├── Cargo.toml
├── Cargo.lock
...
└── src
    └── main.rs

總結

學習下來的一些主觀感覺是 Rust 中的概念繁雜,有些地方的設計確實讓人有些迷惑。再加上類型眾多(如:OsStr, String),代碼很難通過直覺判斷寫出,需要大量查閱文檔才能讓編譯器消停。所以學習曲線相對陡峭。

不過,語言約束的越多,某種程度上講,對于程序員而言卻是福音。If it compiles, then it works. 的哲學理念在前,學習道阻且長,努力加餐飯。


提示
一般標量類型都實現了copy trait.

  • 所有的整型,如:u32
  • 布爾類型,如:true 或 false
  • 字符類型,如:char
  • 浮點數類型,如:f64
  • 當且僅當所有元素都是Copy的元組,如:(i32, i32)是Copy,但是(i32, String)就不是Copy的。

于2019年9月22日


  1. https://www.reddit.com/r/rust/comments/95ky6u/why_arent_multiple_mutable_references_allowed_in/ ?

  2. http://squidarth.com/rc/rust/2018/06/04/rust-concurrency.html ?

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容