前言
本文為「The Rust Programming Language」語言指南的學習筆記。
介紹
所有權在 Rust 中是用來管理程式記憶體的一系列規則。
有些語言會用垃圾回收機制,在程式執行時不斷尋找不再使用的記憶體;而有些程式,開發者必須親自分配和釋放記憶體。Rust 選擇了第三種方式:記憶體由所有權系統管理,且編譯器會在編譯時加上一些規則檢查。如果有地方違規的話,程式就無法編譯。這些所有權的規則完全不會降低執行程式的速度。
堆疊與堆積
堆疊(Stack)與堆積(Heap)都是提供程式碼在執行時能夠使用的記憶體部分,但他們組成的方式卻不一樣。
堆疊會按照它取得數值的順序依序存放它們,並以相反的順序移除數值。這通常稱為後進先出(last in, first out)。當我們要新增資料時,我們會稱呼為推入堆疊(pushing onto the stack),而移除資料則是叫做彈出堆疊(popping off the stack)。所有在堆疊上的資料都必須是已知固定大小。在編譯時屬於未知或可能變更大小的資料必須儲存在堆積。
堆積就比較沒有組織,當要將資料放入堆積,得要求一定大小的空間。記憶體分配器(memory allocator)會找到一塊夠大的空位,標記為已佔用,然後回傳一個指標(pointer),指著該位置的位址。這樣的過程稱為在堆積上分配(allocating on the heap),或者有時直接簡稱為分配(allocating)就好。將數值放入堆疊不會被視為是在分配。因為指標是固定已知的大小,所以可以存在堆疊上。但當要存取實際資料時,就得去透過指標取得資料。
將資料推入堆疊會比在堆積上分配還來的快,因為分配器不需要去搜尋哪邊才能存入新資料,其位置永遠在堆疊最上方。相對的,堆積就需要比較多步驟,分配器必須先找到一個夠大的空位來儲存資料,然後作下紀錄為下次分配做準備。
在堆積上取得資料也比在堆疊上取得來得慢,因為需要用追蹤指標才找的到。現代的處理器如果在記憶體間跳轉越少的話速度就越快。
追蹤哪部分的程式碼用到了堆積上的哪些資料、最小化堆積上的重複資料、以及清除堆積上沒再使用的資料確保不會耗盡空間,這些問題都是所有權系統要處理的。
規則
所有權規則:
- Rust 中每個數值都會有一個變數作為它的擁有者(owner)。
- 同時間只能有一個擁有者。
- 當擁有者離開作用域時,數值就會被丟棄。
變數作用域
作為所有權的第一個範例,我們先來看變數的作用域(scope)。作用域是一些項目在程式內的有效範圍。假設有以下變數:
1 | let s = "hello"; |
此變數的有效範圍是從它宣告開始一直到當前作用域結束為止。
1 | { // s 在此處無效,因為它還沒宣告 |
我們已經看過字串字面值(string literals),字串的數值是寫死在程式內的。字串字面值的確很方便,但它不可能完全適用於使用文字時的所有狀況。其中一個原因是因為它是不可變的,另一個原因是並非所有字串值在編寫程式時就會知道。舉例來說,要是想要收集使用者的輸入並儲存它呢?對於這些情形,Rust 提供第二種字串型別 String
。此型別管理分配在堆積上的資料,所以可以儲存我們在編譯期間未知的一些文字。可以從字串字面值使用 from
函式來建立一個 String
,如以下所示:
1 | let s = String::from("hello"); |
這種類型的字串是可以被改變的:
1 | let mut s = String::from("hello"); |
這邊有何差別呢?為何 String
是可變的,但字面值卻不行?兩者最主要的差別在於它們對待記憶體的方式。
記憶體與分配
以字串字面值來說,我們在編譯時就知道它的內容,所以可以寫死在最終執行檔內。這就是為何字串字面值非常迅速且高效。但這些特性均來自於字串字面值的不可變性。不幸的是我們無法將編譯時未知大小的文字,或是執行程式時大小可能會改變的文字等對應記憶體塞進二進制檔案中。
而對於 String
型別來說,為了要能夠支援可變性、改變文字長度大小,我們需要在堆積上分配一塊編譯時未知大小的記憶體來儲存這樣的內容,這代表:
- 記憶體分配器必須在執行時請求記憶體。
- 我們不再需要這個
String
時,我們需要以某種方法將此記憶體還給分配器。
當我們呼叫 String::from
時就等於完成第一個部分,它的實作會請求分配一塊它需要的記憶體。這邊大概和其他程式語言都一樣。
不過第二部分就不同了。在擁有垃圾回收機制(garbage collector, GC)的語言中,GC 會追蹤並清理不再使用的記憶體,所以我們不用去擔心這件事。沒有 GC 的話,識別哪些記憶體不再使用並顯式呼叫程式碼釋放它們就是我們的責任了,就像我們請求取得它一樣。在以往的歷史我們可以看到要完成這件事是一項艱鉅的任務,如果我們忘了,那麼就等於在浪費記憶體。如果我們釋放的太早的話,我們則有可能會拿到無效的變數。要是我們釋放了兩次,那也會造成程式錯誤。
Rust 選擇了一條不同的道路:當記憶體在擁有它的變數離開作用域時就會自動釋放。
1 | { |
當 s
離開作用域時,我們就可以很自然地將 String
所需要的記憶體釋放回分配器。當變數離開作用域時,Rust 會幫我們呼叫一個特殊函式。此函式叫做 drop
,在這裡當時 String
的作者就可以寫入釋放記憶體的程式碼。Rust 會在大括號結束時自動呼叫 drop
。
變數與資料互動的方式:移動(Move)
數個變數在 Rust 中可以有許多不同方式來與相同資料進行互動。
1 | let x = 5; |
「x 取得數值 5,然後拷貝(copy)了一份 x 的值給 y。」所以我們有兩個變數 x
與 y
,而且都等於 5。因為整數是已知且固定大小的簡單數值,所以這兩個數值 5 都會推入堆疊中。
若為 String
的版本:
1 | let s1 = String::from("hello"); |
一個 String
由三個部分組成:一個指向儲存字串內容記憶體的指標、它的長度和它的容量,這些資料是儲存在堆疊上的。
長度指的是目前所使用的 String
內容在記憶體以位元組為單位所佔用的大小。而容量則是 String
從分配器以位元組為單位取得的總記憶體量。
當我們將 s1
賦值給 s2
,String
的資料會被拷貝,不過我們拷貝的是堆疊上的指標、長度和容量。我們不會拷貝指標指向的堆積資料。
兩個資料指標都指向相同位置,這會造成一個問題。當 s2
與 s1
都離開作用域時,它們都會嘗試釋放相同的記憶體。這被稱呼為雙重釋放(double free)錯誤。釋放記憶體兩次可能會導致記憶體損壞,進而造成安全漏洞。
為了保障記憶體安全,在此情況中 Rust 還會在做一件重要的事。在 let s2 = s1
之後,Rust 就不再將 s1
視爲有效。因此當 s1
離開作用域時,Rust 不需要釋放任何東西。請看看如果在 s2
建立之後繼續使用 s1
會發生什麼事,以下程式就執行不了:
1 | let s1 = String::from("hello"); |
如果有在其他語言聽過淺拷貝(shallow copy)和深拷貝(deep copy)這樣的詞,拷貝指標、長度和容量而沒有拷貝實際內容這樣的概念應該就相近於淺拷貝。但因為 Rust 同時又無效化第一個變數,我們不會叫此為淺拷貝,而是稱此動作為移動(move)。
除此之外,這邊還表達了另一個設計決策:Rust 永遠不會自動將資料建立「深拷貝」。因此任何自動的拷貝動作都可以被視為是對執行效能影響很小的。
變數與資料互動的方式:克隆(Clone)
要是我們真的想深拷貝 String
在堆積上的資料而非僅是堆疊資料的話,我們可以使用一個常見的方法(method)叫做 clone
。
1 | let s1 = String::from("hello"); |
此程式碼能執行無誤,也就是堆積資料的確被複製了一份。
當你看到 clone
的呼叫,你就會知道有一些特定的程式碼被執行且消費可能是相對昂貴的。
只在堆疊上的資料:拷貝(Copy)
還有一個小細節沒提到,也就是我們在使用整數時的程式碼。它能執行而且是有效的:
1 | let x = 5; |
原因是因為像整數這樣的型別在編譯時是已知大小,所以只會存在在堆疊上。所以要拷貝一份實際數值的話是很快的。這也讓我們沒有任何理由要讓 x
在 y
建立後被無效化。換句話說,這邊沒有所謂淺拷貝與深拷貝的差別。
Rust 有個特別的標記叫做 Copy
特徵(trait),可以用在標記像整數這樣存在堆疊上的型別。如果一個型別有 Copy
特徵的話,一個變數在賦值給其他變數後仍然會是有效的。如果一個型別有實作(implement) Drop
特徵的話,Rust 不會允許我們讓此型別擁有 Copy
特徵。
以下是一些有實作 Copy
的型別:
- 所有整數型別,像是
u32
。 - 布林型別(bool),它只有數值
true
與false
。 - 所有浮點數型別,像是
f64
。 - 字元型別(char)。
- 元組,不過包含的型別也都要有實作
Copy
才行。比如(i32, i32)
就有實作Copy
,但(i32, String)
則無。
所有權與函式
傳遞數值給函式這樣的語義和賦值給變數是類似的。傳遞變數給函式會是移動或拷貝,就像賦值一樣。
1 | fn main() { |
如果我們嘗試在呼叫 takes_ownership
後使用 s
,Rust 會拋出編譯時期錯誤。這樣的靜態檢查可以免於我們犯錯。
回傳值與作用域
回傳值一樣能轉移所有權。
1 | fn main() { |
變數的所有權每次都會遵從相同的模式:賦值給其他變數就會移動。當擁有堆積資料的變數離開作用域時,該數值就會被 drop 清除,除非該資料的所有權被移動到其他變數所擁有。
雖然這樣是正確的,但在每個函式取得所有權在回傳所有權的確有點囉唆。要是我們可以讓函式使用一個數值卻不取得所有權呢?要是我們想重複使用同個值,但每次都要傳入再傳出實在是很麻煩。而且我們有時也會想要讓函式回傳一些它們自己產生的值。
Rust 能讓我們使用元組回傳多個數值。
1 | fn main() { |
但這實在太繁瑣,而且這樣的情況是很常見的。幸運的是 Rust 有提供一個概念能在不轉移所有權的狀況下使用數值,這叫做引用(references)。
引用與借用
在以上範例使用元組的問題在於,我們必須回傳 String
給呼叫的函式,我們才能繼續在呼叫 calculate_length
之後繼續使用 String
,因為 String
會被傳入 calculate_length
。不過我們其實可以提供個 String
數值的引用。引用(references) 就像是指向某個地址的指標,我們可以追蹤存取到該處儲存的資訊,而該地址仍被其他變數所擁有。和指標不一樣的是,引用保證所指向的特定型別的數值一定是有效的。以下是我們定義並使用 calculate_length
時,在參數改用引用物件而非取得所有權的程式碼:
1 | fn main() { |
變數 s
有效的作用域和任何函式參數的作用域一樣,但當引用不再使用時,引用所指向的數值不會被丟棄,因為我們沒有所有權。當函式使用引用作為參數而非實際數值時,我們不需要回傳數值來還所有權,因為我們不曾擁有過。
我們會稱呼建立引用這樣的動作叫做借用(borrowing)。就像現實世界一樣,如果有人擁有每項東西,他可以借用給你。當你使用完後,你就還給他。你並不擁有它。
所以要是我們嘗試修改我們借用的東西會如何呢?它執行不了的!
1 | fn main() { |
如同變數預設是不可變,引用也是一樣的。我們不被允許修改我們引用的值。
可變引用
我們可以修正程式碼,讓我們可以變更借用的數值。我們可以加一點小修改,改用可變引用就好。
1 | fn main() { |
首先我們將 s
加上了 mut
,然後我們在呼叫 change
函式的地方建立了一個可變引用 &mut s
,然後更新函式的簽章成 some_string: &mut String
來接收這個可變引用。這樣能清楚表達 change
函式會改變它借用的引用。
可變引用有個很大的限制:同一時間中對一個特定資料只能有一個可變引用。所以嘗試建立兩個 s
的可變引用的話就會失敗。
1 | let mut s = String::from("hello"); |
錯誤表示此程式碼是無效的,因爲我們無法同時可變借用 s
超過一次。第一次可變借用在 r1
且必須持續到它在 println!
用完爲止,但在其產生到使用之間,我們嘗試建立了另一個借用了與 r1
相同資料的可變借用 r2
。
這項防止同時間對相同資料進行多重可變引用的限制允許了可變行為,但是同時也受到一定程度的約束。這通常是新 Rustaceans 遭受挫折的地方,因為多數語言都會任你去改變其值。這項限制的好處是 Rust 可以在編譯時期就防止資料競爭(data races)。資料競爭和競爭條件(race condition)類似,它會由以下三種行為引發:
- 同時有兩個以上的指標存取同個資料。
- 至少有一個指標在寫入資料。
- 沒有針對資料的同步存取機制。
資料競爭會造成未定義行為(undefined behavior),而且在執行時你通常是很難診斷並修正的。Rust 能夠阻止這樣的問題發生,不讓有資料競爭的程式碼編譯通過。
如往常一樣,我們可以用大括號來建立一個新的作用域來允許多個可變引用,只要不是同時擁有就好:
1 | let mut s = String::from("hello"); |
Rust 對於可變引用和不可變引用的組合中也實施著類似的規則,以下程式碼就會產生錯誤:
1 | let mut s = String::from("hello"); |
不可以擁有不可變引用的同時也擁有可變引用。擁有不可變引用的使用者可不希望有人暗地裡突然改變了值!不過數個不可變引用是沒問題的,因為所有在讀取資料的人都無法影響其他人閱讀資料。
注意引用的作用域始於它被宣告的地方,一直到它最後一次引用被使用為止。舉例來說以下程式就可以編譯,因為不可變引用最後一次的使用(println!
)在可變引用宣告之前:
1 | let mut s = String::from("hello"); |
不可變引用 r1
和 r2
的作用域在 println!
之後結束。這是它們最後一次使用到的地方,也就是在宣告可變引用 r3
之前。它們的作用域沒有重疊,所以程式碼是允許的。編譯器這樣能辨別出引用何時在作用域之前不再被使用的能力叫做 Non-Lexical Lifetimes(NLL)。
迷途引用
在有指標的語言中,通常都很容易不小心產生迷途指標(dangling pointer)。當資源已經被釋放但指標卻還留著,這樣的指標指向的地方很可能就已經被別人所有了。相反地,在 Rust 中編譯器會保證引用絕不會是迷途引用:如果你有某些資料的引用,編譯器會確保資料不會在引用結束前離開作用域。
讓我們來嘗試產生迷途指標,看看 Rust 怎麼產生編譯期錯誤。
1 | fn main() { |
因為 s
是在 dangle
內產生的,當 dangle
程式碼結束時,s
會被釋放。但我們卻嘗試回傳引用。此引用會指向一個已經無效的 String
。Rust 不允許我們這麼做。
解決的辦法是直接回傳 String
就好:
1 | fn no_dangle() -> String { |
這樣就沒問題了。所有權轉移了出去,沒有任何值被釋放。
引用規則
回顧我們討論到的引用規則:
- 在任何時候,我們要嘛只能有一個可變引用,要嘛可以有任意數量的不可變引用。
- 引用必須永遠有效。
切片
切片(slice) 讓可以引用一串集合中的元素序列,而並非引用整個集合。切片也算是某種類型的引用,所以它沒有所有權。
以下是個小小的程式問題:寫一支接收字串的函式並回傳第一個找到的單字,如果函式沒有在字串找到空格的話,就代表整個字串就是一個單字,所以就回傳整個字串。
我們可以回傳該單字的最後一個索引,也就是和空格作比較。因為我們需要遍歷 String
的每個元素並檢查該值是否為空格,我們要用 as_bytes
方法將 String
轉換成一個位元組陣列。接下來我們使用 iter
方法對位元組陣列建立一個疊代器(iterator)。
1 | fn first_word(s: &String) -> usize { |
現在我們只需要知道 iter
是個能夠回傳集合中每個元素的方法,然後 enumerate
會將 iter
的結果包裝起來回傳成元組。enumerate
回傳的元組中的第一個元素是索引,第二個才是元素的引用。
既然 enumerate
回傳的是元組,我們可以用模式配對來解構元組。所以在 for
迴圈中,我們指定了一個模式讓 i
取得索引然後 &item
取得元組中的位元組。因為我們從用 .iter().enumerate()
取得引用的,所以在模式中我們用的是 &
來獲取。
在 for
迴圈裡面我們使用字串字面值的語法搜尋位元組是不是空格。如果我們找到空格的話,我們就回傳該位置。不然我們就用 s.len()
回傳整個字串的長度。
我們現在有了一個能夠找到字串第一個單字結尾索引的辦法,但還有一個問題。我們回傳的是一個獨立的 usize
,它套用在 &String
身上才有意義。換句話說,因為它是個與 String
沒有直接關係的數值,我們無法保證它在未來還是有效的。
1 | fn main() { |
幸運的是 Rust 為此提供了一個解決辦法:字串切片(String slice)。
字串切片
字串切片是 String
其中一部分的引用,它長得像這樣:
1 | let s = String::from("hello world"); |
與其引用整個 String
,hello
只引用了一部分的 String
,透過 [0..5]
來指示。我們可以像這樣 [起始索引..結束索引]
用中括號加上一個範圍來建立切片。
要是想用 Rust 指定範圍的語法 ..
從索引零開始的話,可以省略兩個句點之前的值。換句話說,以下兩個是相等的:
1 | let s = String::from("hello"); |
同樣地,如果你的切片包含 String
的最後一個位元組的話,你同樣能省略最後一個數值。這代表以下都是相等的:
1 | let s = String::from("hello"); |
如果你要獲取整個字串的切片,你甚至能省略兩者的數值,以下都是相等的:
1 | let s = String::from("hello"); |
有了這些資訊,讓我們用切片來重寫 first_word
吧。對於「字串字面值」的回傳型別我們會寫 &str
。
1 | fn first_word(s: &String) -> &str { |
我們現在有個不可能出錯且更直觀的 API,因為編譯器會確保 String
的引用會是有效的。使用切片版本 first_word
的程式碼的話就會出現編譯期錯誤:
1 | fn main() { |
字串字面值就是切片
回想一下我們講說字串字面值是怎麼存在二進制檔案的。現在既然我們已經知道切片,我們就能知道更清楚理解字串字面值:
1 | let s = "Hello, world!"; |
此處 s
的型別是 &str
,它是指向二進制檔案某部份的切片。這也是為何字串字面值是不可變的,&str
是個不可變引用。
字串切片作為參數
知道可以取得字面值的切片與 String
數值後,我們可以再改善一次 first_word
。較富有經驗的 Rustacean 會用以下方式編寫函式簽名,因為這讓該函式可以同時接受 &String
和 &str
的數值。
1 | fn first_word(s: &str) -> &str {} |
如果我們有字串字面值的話,我們可以直接傳遞。如果我們有 String
的話,我可以們傳遞整個 String
的切片或引用。這樣的彈性用到了強制解引用(deref coercion)。定義函式的參數為字串字面值而非 String
可以讓我們的 API 更通用且不會失去去任何功能:
1 | fn main() { |
其他切片
字串切片如你所想的一樣是特別針對字串的。但是我們還有更通用的切片型別。請考慮以下陣列:
1 | let a = [1, 2, 3, 4, 5]; |
就像我們引用一部分的字串一樣,我們可以這樣引用一部分的字串:
1 | let a = [1, 2, 3, 4, 5]; |
此切片的型別為 &[i32]
,它和字串運作的方式一樣,儲存了切片的第一個元素以及總長度。