極不建議以Rust來進行程式設計入門,如你所見——它的一些概念是獨有的,適合老人們來進階提升自己,而不是新手來入門。它非常難,恐怕並不會讓新進人士感受到程式設計的美好。
參考書目:《Rust語言聖經》(簡體)、《Rust程式設計語言》(繁體)
變數
變數綁定
在Rust中,我們對於變數的宣告不常用“指定”(台灣)或“賦值”(中國大陸)這兩個詞語,而是使用“綁定”。比如我們使用:
1 | let a = 1; |
為什麼要創造出這樣一個新的詞語呢?這就涉及到Rust中特有的概念和最核心的原則——所有權。簡單來講,任何一個物件都有“主人”,一般情況下,該物件完全屬於它的主人。那麼綁定的過程,就是給這個物件找主人的過程。比如上面的表達式,就是給1
這個物件找到一個主人,是一個叫a
的變數。
關於所有權的概念,我們後續再討論,這暫時不重要,讀者可以暫時忘記它。
但是接下來的事情,你就不該忘了:Rust的變數預設是不可變的!也就是說,上面的let a = 1;
宣告的變數a
,其實像一個常數⋯⋯
那有沒有什麼辦法宣告一個可變的變數呢?有,只需要加上mut
關鍵字⋯⋯
1 | // variable |
所以,使用Rust的煩惱之一,就是你應該如何選擇變數的可變性。不可變變數帶來更高的安全性,但是代價是靈活性。而可變變數最大的優點就是靈活性和性能,但是其安全性大打折扣。後面你會明白的,安全性在Rust中極其重要。
我們可以使用底線來忽略未使用的變數。在Rust中,宣告的變數如果不被使用,會爆出一個warning,但是如果使用底線作為變數名字的開頭,就會自動忽略這個不被使用的變數。
1 | let _a = 1; |
說完變數,我們再來說一說常數。這時候你就瘋了,不是說變數預設不可變嗎?不是說不可變的變數就像常數嗎?怎麼又冒出一個常數?別急,我們來解釋它們的不同。
首先,常數沒有mut關鍵字。也就是說,常數不是預設不可變,而是永遠、在任何時候都不可變。
其次,常數在宣告的時候必須指名其型別。Rust不會幫助你推斷常數的型別。
再來,常數只能夠透過常數表達式來設置,而不能透過任何運行中產生的可變數來設置。
常數用const
關鍵字來宣告,且變數名稱建議使用全大寫字母,單詞中間用底線來連結。下面是一個常見的常數宣告:
1 | const CONST_NUM: u32 = 1; |
變數遮蔽
我們可以重新宣告一個已經宣告過的變數,原來的變數對應的值就被“遮蔽”,該變數的值變成新值,直到新變數的scope結束。
比如:
1 | let x = 1; |
好,你又瘋了,這和改變變數的值有什麼分別?
別急!我們來解釋解釋。使用mut
宣告的變數,改變變數的值不能改變變數的類型。而使用這種方法改變的值事實上不是改變值⋯⋯
別繞!我們看這張圖,或許你就明白了:
使用變數遮蔽,事實上是在記憶體中,又開闢了一塊空間,放上我們的新物件,然後將這個變數指向這塊記憶體空間。所以,這種方式不僅可以在形式上改變變數的值,還可以改變變數的型別。
資料型別
Rust是強型別語言,儘管我宣告變數的時候好像並沒有特意去指定型別(當然指定也沒有錯誤),那是因為Rust的編譯器會聰明地幫助我們推測型別。Rust中使用:
來跟上型別,和TypeScript、Kotlin都類似。
數字型別
整數
數字是最基本的型別,我們有整數型別:
長度 | 有符號 | 無符號 | 有符號範圍 |
---|---|---|---|
8 | i8 | u8 | |
16 | i16 | u16 | |
32 | i32 | u32 | |
64 | i64 | u64 | |
128 | i128 | u128 | |
視CPU而定 | isize | usize | 32位元CPU: |
Rust預設的整數型別是i32
。
如果整數有溢出,Rust的處理也非常有意思。在debug模式下,Rust編譯器監測到整數溢出,會導致程式panic,然而在release模式下,不會導致panic,但是會按照“二進位補碼換行”的方式來處理。比如一個u8
類型,只能夠儲存從0至255之間的數字,如果儲存256,就會把256變成0,257變成1,依此類推,程式不會panic,但是此類程式碼仍應該被視作錯誤的程式碼。
浮點數
f32
和f64
,分別為單精度和雙精度,預設是f64
。
關於浮點數陷阱,相信大家學C語言的時候就已經深刻了解到了,這裏不再贅述了。
關於數字的運算,包括加減乘除模,還有位運算,我就不多介紹了。
序列化。Rust提供了一個非常簡單的方法來實現變數序列化。比如:
1 | 1..5 // 1~4(不包括5)的數字序列 |
字元、布林
字元的型別標識為char
。和許多現代程式設計語言一樣,Rust使用Unicode編碼系統,這很方便。
布林的型別標識為bool
,取值true
或false
。
單元
Rust特有的一個型別,只有一個取值,就是()
。這東西能作為一個型別我是沒有想到的。所以它有什麼用?
佔位。
對,它的作用基本等同於佔位,佔一個顯式的位置,但是並不耗用任何記憶體位置。比如在Rust中,返回()
的函式和沒有返回值的函式(發散函式)是完全不同的概念。這些我們下面再介紹。又比如,你可以使用()
作為map的值,代表我們不關注具體的值,只關注key。
元組
1 | let tup: (i32, f64, u8) = (500, 6.4, 1); |
元組一旦被宣告,長度無法被改變。
元組是一個復合型別,可以透過“元組解構”來取到裡面的值。
1 | let (x, y, z) = tup; |
或者透過index來拿到:
1 | println!("{}", tup.0); |
陣列
陣列(array)在Rust中也是不可以改變長度的,且其中資料的型別必須相同。
1 | let arr = [1, 2, 3, 4, 5]; |
如果你想顯式表明array的型別,需要這樣做:
1 | // 分號前面的i32表示陣列元素的型別,5表示陣列元素的個數 |
創造一個所有元素均相同的陣列:
1 | // 會宣告一個含5個整數3的陣列 |
函式
我們來看一個最簡單的函式,傳說中的add
函式:
1 | fn add(i: i32, j: i32) -> i32 { |
我們來逐步看:
fn
關鍵字:表明這是一個函式。add
:函式名稱,命名規則基本同其他。- 引數:必須顯式宣告引數的型別!去掉任意引數的型別都會出錯。
-> i32
:回傳值是i32
型別。i + j
:計算並回傳。
如果你習慣了其餘程式語言,你可能會驚奇地發現,這個函式沒有return
陳述式。
這個時候我們就要引出Rust中另外兩個需要區分的概念——陳述式和表達式。
於是你迄今為止第三次瘋了:OMG!為什麼這兩個概念還需要區分😷⋯⋯
我們來看一下:
- 陳述式:結尾有分號
;
,只進行計算,而不會回傳任何值。 - 表達式:結尾沒有分號
;
,進行計算,並且回傳計算後的結果。
所以,我們上面的函式以一個表達式做結尾,它本身就是有回傳值的⋯⋯
對於表達式,還可以進行這樣的操作:
1 | fn main() { |
真的,我知道這很逆天,但Rust已經逆天太多次了,不是嗎?
好,我們回到函式的回傳值。
得益於元組的出現,函數可以有“多個回傳值”。
1 | fn com_fun(i: i32, j: i32) -> (i32, i32) { |
在其他程式語言中,我們有回傳void
型的權利,在Rust中,你當然也有。不過Rust在這方面居然也能玩出一點花樣。
還記得我們上面說的單元()
嗎?你可以透過回傳單元,來達到類似的效果。
1 | fn void_fun() { |
對於無回傳值的函式,Rust還有一個永不回傳的函式——發散函式,回傳類型為!
。
1 | fn err -> ! { |
那麼發散函式和普通無回傳值的函式有什麼區別?
在Rust中,發散函式通常被用作程式panic的信號。
發散函式一般以panic!()
結尾,表示程式出現panic,呼叫該函式之後,程式就panic了,之後的任何程式碼都不會被執行。
1 | fn main() { |
後面的println!("unreachable println!");
永遠不會被執行,因為這時候程式已經panic了。
流程控制
條件
說到條件控制,if-else
系統當然是繞不開的話題。
1 | let number = 3; |
還有else if
的應用:
1 | let number = 6; |
if-else
和Kotlin一樣,也可以被視作有回傳值的,用於代替C語言中的三元運算子?:
。
1 | let condition = true; |
迴圈
在Rust中,除了常規的while、for迴圈之外,還有一個loop迴圈。我們一一來看。
loop迴圈勇於創造一個類似無限迴圈的迴圈,但是你可以在迴圈中要求它停下來:
1 | let mut i = 0; |
啊是的,break
和continue
關鍵字仍然在Rust中得到延續。
但是!我又要說但是了。這個break
雖然基本的用法沒有什麼差,但是Rust還是给它了一點新的用法。
因為,迴圈在Rust中是可以有回傳值的。
喔上帝,我知道這很逆天,但是⋯⋯算了,想來你也已經習慣了。
我們直接來看好了:
1 | fn main() { |
你可以在break
後面接上想回傳的數值,你就會在loop被break掉之後,把這個值綁定給外面等待已久的變數。
關於while
迴圈,基本用法已經很熟悉了,沒有什麼變化,我就不多說了。
而重頭戲for
迴圈,實際上也是for-in
迴圈:
1 | let a = [10, 20, 30, 40, 50]; |
所有權
所有權的概念可能是Rust最具特色的一點,也是一個新的概念,需要花時間去掌握。
從一開始接觸程式設計,我們就被告知,程式的執行離不開和記憶體打交道。比如最基本的變數的概念,我們就可以看作是一塊記憶體空間。
程式從記憶體中申請空間來存放資料,那我們如何在不使用的時候釋放這些記憶體空間,成為了每個程式設計語言的重點和難點。目前流行的程式設計語言在這個問題上主要分為三個流派:
- 垃圾回收機制(GC):程式運行時不斷尋找不需要的記憶體。典型代表是Java、Go。這種管理模式優點是對程式設計人員十分友好,缺點在於可能犧牲一些靈活性和性能。
- 手動管理:在程式中透過函式來手動管理記憶體的使用和釋放。典型代表是C++。這種管理模式十分靈活,能夠讓開發人員隨心所欲地控制記憶體的使用,但是相對比較複雜。
- 所有權:Rust獨特的模式,在編譯階段使用一系列規則進行檢查。由於這種檢查只發生在編譯階段,因此在運行時不會有任何的性能損失。
我們正式開始預熱。
堆疊(Stack)和堆積(Heap)
堆疊和堆積這兩個資料結構概念在中國大陸被稱為“棧”和“堆”,是兩種最基本的資料結構。
堆疊(Stack)類似於一疊盤子,我們最後放到頂上的盤子在取走的時候將會最先被取走。這種模式被稱為 “後進先出”。當我們要新增資料時,我們會稱呼為推入堆疊(pushing onto the stack),而移除資料則是叫做彈出堆疊(popping off the stack)。所有在堆疊上的資料都必須是已知固定大小。在編譯時屬於未知或可能變更大小的資料必須儲存在堆積。
堆積(Heap)就像一個雜亂無章的雜物堆,裡面的雜物有大有小,位置也不固定。當我們把資料放入堆積的時候,我們需要向記憶體申請一塊一定大小的空間,記憶體配置器(memory allocator)會找到一塊夠大的空位,標記為已佔用,然後回傳一個指標(pointer),指著該位置的位址。這樣的過程稱為在堆積上配置(allocating on the heap),或者有時直接簡稱為配置(allocating)就好。
所有權三原則
- Rust中每個物件都有主人。
- 同一時間一個物件只能有一個主人。
- 當主人離開作用域時,物件就會被丟棄。
作用域
一個變數的作用域就是該變數有效的範圍。當一個變數進入其作用域,它就是有效的,這種有效性應該一直持續到它離開作用域為止。
下面我們將以String型別來做範例,講解所有權的概念。
String型別
在Rust中,透過兩種方法創建字串是十分容易的。
第一種是透過字串字面值的方式來建立,這種方式較為簡單,但是建立的字串變數不可改變。
1 | let s = "Hello, world."; |
第二種方法是透過String型別來建立。這種方式建立的字串變數是可以被改變的,其在記憶體中的大小不固定,被放在堆積裡。
1 | let s = String::from("Hello,"); |
當我們使用這個方式創造出一個字串的時候,我們需要考慮兩件事情:
- 記憶體配置器需要向記憶體申請一塊空間。
- 不再需要這個字串時,我們需要以某種方法將此記憶體還給配置器。
當我們呼叫String::from
時就等於完成第一個部分,它的實作會請求配置一塊它需要的記憶體。這邊大概和其他程式語言都一樣。
但是第二點有所不同。在具有GC機制的程式設計語言中,這件事情是由GC來幫我們做的,我們無須太過關注。而在手動操作的程式設計語言中,我們需要手動來進行這件事。
Rust選擇的是第三條道路——所有權。具體來說,每一塊記憶體空間都有它的“主人”,即變數。根據原則三,如果變數離開了它的作用域,那麼該記憶體空間就會被釋放。
1 | fn main() { |
事實上,在作用域結束後,Rust會自動為我們呼叫一個叫做drop
的內建函式,釋放記憶體。
移動
我們來看一下下面的例子:
1 | let x = 5; |
發生了什麼呢?首先,我們在記憶體中開闢了一塊固定大小的空間(因為預設的i32型別是基本型別,大小固定)推入堆疊中,並且將它交給主人x。然後我們把這塊記憶體中的數字5拷貝一份,把這份拷貝交給新的主人y。
這似乎是一件順理成章的事情。然而,如果涉及到堆積中儲存的資料問題,可就不僅僅這麼簡單了。
在詳細講到之前,我們先來理解兩個重要的概念——拷貝(淺拷貝)和克隆(深拷貝)。
我們剛剛講到,存放在堆積中的資料會有一個儲存在堆疊中的指標來指向它。
拷貝(淺拷貝):僅把堆疊中的指標拷貝一份推入堆疊,不拷貝堆積中的資料。堆疊中的新指標依然指向堆積中的原位置。
克隆(深拷貝):不僅拷貝堆疊中的指標,還拷貝堆積中的資料。堆疊中的新指標指向的是堆積中的新資料。
好,完成了知識的前置之後,我們仍然以String為例來進行說明。
1 | let s_1 = String::from("Hello, world."); |
發生了什麼呢?事實上,我們是將堆疊中指向堆積的指標拷貝了一份推入堆疊,指向堆積中之前的資料。
那麼讀者可能就會說了,這不就是淺拷貝嗎?
是,也不是。拜託,你現在是在學Rust誒,按照這門語言在前面給你的震撼,還有什麼是不可能發生的事情嗎?
我們看一下如果常規的淺拷貝,在Rust中會產生什麼問題。
當變數s_1
退出它的作用域之後,儲存在堆疊中的指標性資料會被drop掉,同時drop掉的還有儲存在堆積中的實際資料。
而後,變數s_2
退出作用域,在drop掉堆疊中的指標性資料之後,忽然發現,本應該緊接著被drop的堆積中之實際資料,忽然沒有了!
這會造成一個error:雙重釋放(double free)。
所以,Rust實際上有幫我們又做了一件事情。當我們使用let s_2 = s_1;
將s_1
拷貝到s_2
之後,Rust就會講原來的s_1
不再視為有效了,s_1
退出作用域後,不會釋放任何東西。我們來求證一下:
1 | fn main() { |
你會發現編譯出錯了。錯誤資訊是:
1 | Compiling ownership v0.1.0 (file:///projects/ownership) |
它告訴我們,s_1
已經被“moved”。
是的,在Rust中,對於儲存在堆積中的資料的預設拷貝方式,既不是淺拷貝,也不是深拷貝,而是一種獨特的被稱作“移動”的方式。
那如果你確定了,你就是想要做一個深拷貝,不在意性能什麼的,你也可以使用一個方法,名叫clone()
。
1 | let s1 = String::from("hello"); |
這樣就完成了一次完整的深拷貝。
誒,這時候有的讀者可能就要問了。那為什麼儲存在堆疊中的基本資料,預設可以直接拷貝值再交給新的變數呢?
原因是因為像整數這樣的型別在編譯時是已知大小,所以只會存在在堆疊上。所以要拷貝一份實際數值的話是很快的。這也讓我們沒有任何理由要讓 x 在 y 建立後被無效化。換句話說,這邊沒有所謂淺拷貝與深拷貝的差別。
Rust中有一個特有的叫做“Copy”的徽記,用來標識那些型別的資料可以進行這樣的拷貝。如果一個型別有 Copy 特徵的話,一個變數在賦值給其他變數後仍然會是有效的。比如:
- 所有整數型別像是 u32。
- 布林型別 bool,它只有數值 true 與 false。
- 所有浮點數型別像是 f64。
- 字元型別 char。
- 元組,不過包含的型別也都要有實作 Copy 才行。比如 (i32, i32) 就有實作 Copy,但 (i32, String) 則無。
這些都是可以擁有Copy徽記的。
所有權和函式
傳遞參數給函式,會是移動或拷貝,就像賦值一樣。
1 | fn main() { |
回傳值會被函式移動到外面:
1 | fn main() { |
參考、借用
我們來看一下這個例子:
1 | fn main() { |
似乎只有這樣,才能夠讓原始的String在往函式裡面走一圈之後,還能夠換個皮(變數遮蔽)繼續使用。
然而,這樣轉過來轉過去,實在是有夠麻煩的,那麼又沒有什麼更加簡單的方法呢?
有。Rust為我們提供了“參考”。參考(references)就像是指向某個地址的指標,我們可以追蹤存取到該處儲存的資訊,而該地址仍被其他變數所擁有。和指標不一樣的是,參考保證所指向的特定型別的數值一定是有效的。
使用參考來實現上面的內容:
1 | fn main() { |
我們將引數s
的類型從String
變成了&String
,表示這個引數的型別是一個String的參考。然後傳入一個String型別的變數的參考&s1
。
變數s
有效的作用域和任何函式參數的作用域一樣,但當不再使用參考時,參考所指向的數值不會被丟棄,因為我們沒有所有權。
我們會稱呼建立參考這樣的動作叫做借用(borrowing)。
借用的參考,你不能夠修改它。如果你想要修改的話,你可以使用“可變參考”。
1 | fn main() { |
改變參考,會影響原來的變數一起改變。 比如經歷過去上面change()
函式中走一遍,字串s的值也會改變。
可變參考有個很大的限制:如果你有一個數值的可變參考,你就無法再對該數值有其他任何參考。所以嘗試建立兩個 s 的可變參考的話就會失敗。比如下面:
1 | fn main() { |
但是你一旦把第一個參考用掉,那麼第二個參考就可以被激活,同時第一個參考失效(參考的作用域結束於其最後一次被使用的地方):
1 | fn main() { |
聽著,我知道這很逆天。但還有一點:你也不能夠同時持有一個變數的可變參考和不可變參考。
好!收尾。既如此,我們來總結一下借用適用的場景:
- 只需要在函式中唯讀參數,不改變參數,使用不可變參考。
- 需要在函式中修改參數,並且希望修改之後的結果反映在原來的變數上,使用可變參考。
發現問題了沒有?似乎我們更常見的需求是:我們既需要在函式內修改原來的參數,而且不希望修改的參數影響到原來的變數。
這樣的需求,使用借用就沒有辦法了。那麼有其他的辦法嗎?
當然有。還記得我們之前講過的深拷貝嗎?深拷貝是將原字串完全拷貝一份,相當於創建了兩個完全不同的變數了。
1 | fn process_string(mut s: String) -> String { |
切片
切片是一種特殊的參考,可以參考一串集合中的元素序列,而並非參考整個集合。
比如:
1 | fn main() { |