[為你自己學 Rust] 所有權(Ownership)
前面幾個章節老實說只要有寫過一陣子程式的網站工程師,大概稍微對照一下語法基本上不會太難學,但從這個章節開始可能會有些不同。「所有權(Ownership)」的設計有點特別,沒意外的話,這可能是網站工程師學習 Rust 的時候第一個遇到的門檻,我不知道各位,但至少對我這種沒有天生神力的一般工程師來說就是個門檻。
在使用 C 語言撰寫程式的時候,記憶體管理是一件很重要但也有點麻煩的事。需要資源的時候,你可能得呼叫 malloc
函數跟系統要一塊記憶體來用,用完的話要呼叫 free
函數把記憶體放掉。手動管理記憶體的方式如果寫不好可能會造成一些麻煩:
- 沒有把用完的記憶體還回去,程式執行久了可能會把記憶體吃光光,這就是所謂的 Memory Leak。
- 要放掉記憶體的時候是跟系統說「這塊記憶體我不用了,還你!」,然後其它程式就可以使用這塊空間。但如果不小心重複釋放,這種 Double Free 的操作可能會去放掉其它正在使用這塊記憶體的物件,又或是直接讓程式當掉。
- 放掉記憶體但又去取用它,可能會造成系統錯誤,或是因為放掉的記憶體空間已經被其它程式給拿去放別的值,雖然也許還是能拿到資料,但拿到的卻不是你想像的結果。
更多記憶體的內容可參考「Stack 與 Heap」章節。
手動管理記憶體有些麻煩而且容易出錯,所以有些程式語言有提供相對比較「自動」的方式來管理記憶體,例如像 Java 的 GC(Garbage Collection)會負責識別、回收不再使用的物件,確保記憶體可以有效被利用。不過 GC 也有它的問題,比較明顯的問題就是當在發動 GC 的時候,系統會有明顯的卡頓感,對某些即時通訊服務或是線上遊戲,這種時不時來一下的 lag 說不定就害你被其它玩家 KO 了。
我當年用 Objective-C 寫 iOS app 的時候,因為在行動裝置上的效能通常沒有桌機來得好(現在就不一定了),發動 GC 的卡頓感可能會更明顯,所以 Objective-C 是使用 Reference Counting 機制,只要該物件的參照計數降到 0 就準備可以收走,這樣在回收過程比較不會造成長時間的停頓。
因為系統的資源都是有限的,所以,適當的跟系統索取資源、釋放資源就是一門學問了。根據微軟在某次的資安研討會上的發表,許多應用程式的問題,大概有七成左右都跟記憶體的管理有關,所以如果能搞定記憶體安全性(Memory Safety),等於就能讓應用程式的品質更好了。
在 Rust 沒有 Garbage Collector 的設計,那麼在 Rust 怎麼做記憶體管理?我們來看一段計算得分的程式碼:
fn calc_score() -> i32 {
let scores = vec![1450, 9527, 5566];
let mut total = 0;
for score in scores.iter() {
total += score;
}
return total;
}
大概就是跑個 for
迴圈做個簡單的計算。各位可能覺得寫起來很輕鬆,不用去要記憶體也不用放掉記憶體,但事實上在使用 vec!
巨集建立 Vector 的時候就是去要記憶體了,只是你不用自己寫而已。同樣的,當 calc_score()
函數即將結束並且離開這個 Scope 之前,Rust 也會幫你做釋放掉這個 Vector 所佔用的記憶體,所以你也不用自己做。
咦?就這麼簡單?那為什麼其它程式語言不也這麼做就好了?事情沒這麼簡單...
大家先看這段程式碼:
fn main() {
let scores = get_scores();
println!("{:?}", scores);
}
fn get_scores() -> Vec<i32> {
let scores = vec![1450, 9527, 5566];
return scores; // 自動釋放佔用的記憶體
}
這段範例沒什麼邏輯,get_scores()
就只是回傳一個 Vector 而已。But...,就是這個 But,剛剛不是才學到 Rust 會在離開函數 Scope 的時候自動幫你放掉記憶體嗎?這樣一來變數 scores
拿到的 Vector 是什麼?它如果被標記成已釋放,那麼待會這個 Vector 原本佔記憶體位置會不會被其它資料給拿去用?
所有權(Ownership)
事實上 Rust 並不是那麼單純的自動釋放記憶體。Rust 設計了「所有權」的概念。再看看剛才那段程式碼:
fn main() {
let scores = get_scores();
println!("{:?}", scores);
}
fn get_scores() -> Vec<i32> {
let scores = vec![1450, 9527, 5566]; // 設定所有權
return scores;
}
當我們在 get_scores()
函數裡要了一塊記憶體位置來放剛剛建立了的 Vector,Rust 會把這個 scores
變數的所有權設定在這個 Scope 裡。當在 main
函數裡呼叫 get_scores()
的時候,scores
變數的所有權就會轉移到 main
的 Scope 裡,這個行為叫做「移動(Move)」,還記得在「常數與變數」章節的最後面看到一些奇怪的錯誤訊息嗎?就是這玩意兒。
原本 get_scores()
函數要結束的時候應該要自動放掉佔用的記憶體,但當 Rust 發現 scores
變數的所有權轉移給別人了,就先不會去釋放記憶體了。既然 scores
的所有權移到了 main()
,那就表示 scores
現在是 main()
的責任,在 main()
函數執行結束的時候就會被自動釋放掉了。
接著我們讓程式碼再複雜一點點,這回把計算總分的函數 calc_score()
也加進來了:
fn main() {
let scores = get_scores();
let total_score = calc_score(scores);
println!("{:?}", total_score);
}
fn get_scores() -> Vec<i32> {
let scores = vec![1450, 9527, 5566];
return scores;
}
fn calc_score(scores: Vec<i32>) -> i32 {
let mut total = 0;
for score in scores.iter() {
total += score;
}
return total;
}
我把上面的範例簡化一點:
let total_score = calc_score(scores);
// ... 略 ...
fn calc_score(scores: Vec<i32>) -> i32 { ... }
這裡需要注意的是,當呼叫 calc_score()
函數並且把 scores
這個 Vector 傳進去的時候,也會發生所有權轉移,也就是說這時候這個 Vector 的所有權 Move 到了 calc_score()
函數裡了。到目前看起來沒什麼問題,執行之後沒有發生錯誤,Good!
但接著如果我想試著印出 scores
變數的話:
let scores = get_scores();
let total_score = calc_score(scores);
println!("{:?}", total_score);
println!("{:?}", scores); // 印出 scores
這時候就會出錯了:
$ cargo run
error[E0382]: borrow of moved value: `scores`
|
3 | let scores = get_scores();
| ------ move occurs because `scores` has type `Vec<i32>`, which does not implement the `Copy` trait
4 | let total_score = calc_score(scores);
| ------ value moved here
...
7 | println!("{:?}", scores);
| ^^^^^^ value borrowed here after move
這種錯誤訊息我猜你在其它程式語言應該沒看過,但這時候我想你應該稍微比較看的懂錯誤訊息想要表達的內容了,不過可能還是不知道為什麼。
原因是因為當我們把 scores
這個 Vector 傳進 calc_score()
函數裡的時候,所有權轉移給它了,因為當 calc_score()
在執行結束的時候雖然把計算的總分傳回來,但也順手把那個 Vector 給放掉了。所以後續想要印出 scores
的時候就會出現錯誤訊息,因為它已經借給別人(Borrowed),而且對方沒有把所有權還回來。
當然你也可以在 calc_score()
函數裡一併把總分跟傳進去的 Vector 透過 Tuple 或其它資料結構一併打包傳回來,但每次都要這樣寫也太麻煩了。Rust 有個叫做 .clone()
的函數,看名字就大概知道用途了:
let total_score = calc_score(scores.clone());
這樣寫的話,你就不是把 scores
傳給 calc_score()
函數,而只是傳給它複製品,所以所有權的轉移也是那個複製品的事情,原本的 scores
的所有權並沒有變化,這樣程式就不會出錯了。問題是解決了沒錯,但 .clone()
也不是不用錢,光想就知道複製這件事就是會浪費額外的資源。
Rust 有提供更簡單的機制來解決這個情況,就是用「借(Borrow)」的
想要嗎?我不買給你,但我借給你
先看程式碼的變化:
fn main() {
let scores = get_scores();
let total_score = calc_score(&scores);
println!("{:?}", total_score);
println!("{:?}", scores);
}
fn get_scores() -> Vec<i32> { ... }
fn calc_score(scores: &Vec<i32>) -> i32 {
// ... 一樣的程式碼
}
其實跟原本的程式碼幾乎一模一樣,我只多加了 2 個 &
符號:
let total_score = calc_score(&scores);
// ... 略 ...
fn calc_score(scores: &Vec<i32>) -> i32 { ... }
calc_score(&scores)
的大概意思就是跟 calc_score()
說:「嘿,我先把 scores
借給你用,但你要記得,我不是給你喔,我是借你!」。而 fn calc_score(scores: &Vec<i32>)
的意思是指它要接的參數不是普通的 Vector,而是一個別人借給它的 Vector。
因為只是暫時借出去,所以所有權並沒有轉移,程式執行就不會出錯了。這概念有點像你去圖書館借書,這本書雖然不是你的,但你可以帶回家看。照理說你從圖書館借回家的書你應該要好好保管它,在閱讀的時候也不要也不應該在上面畫線或作筆記。
先撇開應不應該的公德心問題,你可不可以在借來的書上劃線做筆記?
借來的書能不能在上面劃線?
我們可以試著對借來的東西加點料:
fn main() {
let mut scores = vec![1450, 9527, 5566];
let total_score = calc_score(&scores);
}
fn calc_score(scores: &Vec<i32>) -> i32 {
scores.push(123); // 加料!
let mut total = 0;
for score in scores.iter() {
total += score;
}
return total;
}
這裡我先把 scores
變數宣告成 mut
,讓它可以被修改,接著透過 &
借給 calc_score()
函數之後,故意在裡面加一點料,執行之後會發生錯訊息:
$ cargo run
error[E0596]: cannot borrow `*scores` as mutable, as it is behind a `&` reference
|
16 | scores.push(123);
| ^^^^^^^^^^^^^^^^ `scores` is a `&` reference, so the data it refers to cannot be borrowed as mutable
雖然 scores
本身說可以 mut
,但借進來的時候並沒有說這個可以改,所以還是不能動它。如果希望借進來的東西是可以修改的,需要做以下調整:
fn main() {
let mut scores = vec![1450, 9527, 5566];
let total_score = calc_score(&mut scores);
}
fn calc_score(scores: &mut Vec<i32>) -> i32 {
scores.push(123); // 加料
// ... 略 ...
return total;
}
calc_score(&mut scores)
在出借的時候需要明確的講「我這本書可以借你,如果你要的話可以在上面劃線、做筆記,但這本書還是我的喔」;fn calc_score(scores: &mut Vec<i32>)
則是明確的講借進來的這個 Vector 是可以修改的。在 Rust,很多東西都得講清楚、說明白才行。
一次可以借給多少人?
來看看這段程式碼:
let mut book = String::from("為你自己學 Rust");
let b1 = &book;
let b2 = &book;
let b3 = &book;
在上面的範例中,雖然這本書我是宣告成 mut
,但因為我在借出去的時候都只用 &
也就是說「這個書我可以借你們看,但你們不能在上面劃線!」。Rust 知道借給這些人都保證不會修改,所以想要借幾個就借幾個,並沒有什麼限制。但如果你讓其中某個人說「沒關係,如果是你的話可以喔」,像這樣:
let mut book = String::from("為你自己學 Rust");
let b1 = &book;
let b2 = &mut book; // 用 mut 的方式出借
let b3 = &book;
程式執行就會出錯了:
$ cargo run
error[E0502]: cannot borrow `book` as immutable because it is also borrowed as mutable
|
6 | let b2 = &mut book;
| --------- mutable borrow occurs here
7 | let b3 = &book;
| ^^^^^ immutable borrow occurs here
想想看,雖然你只給一個人可以用 mutable 的方式出借,誰知道會不會對借出去的東西做什麼事,這樣其它人拿到的書會變成什麼樣子?
或是你想說不管了,乾脆讓你們每個人都可劃線好了,這樣總行了吧:
let mut book = String::from("為你自己學 Rust");
let b1 = &mut book;
let b2 = &mut book;
let b3 = &mut book;
Rust 會告訴你這樣也不行:
$ cargo run
error[E0499]: cannot borrow `book` as mutable more than once at a time
|
5 | let b1 = &mut book;
| --------- first mutable borrow occurs here
6 | let b2 = &mut book;
| ^^^^^^^^^ second mutable borrow occurs here
簡單的說,如果是 immutable borrow 的話,想要同時借給多少人都無所謂,但如果是 mutable borrow 的話,一次只能借給一個人,而且同時不能有其它人借,不管他們會不會在書上劃線都一樣。
Rust 的編譯器很聰明但也很龜毛,雖然囉嗦,但它給出來的錯誤訊息看起來是真的挺清楚的。
Rust 的「所有權」設計,讓你不用自己手動配置、釋放記憶體的同時,又可確保不會誤觸像 C 語言的 malloc
跟 free
的操作地雷,所以 Rust 說它是自己個「安全」的程式語言,指的就是「Memory Safety」的安全。
但說到底,為什麼 Rust 要管這麼多?原因就是 Rust 的 Concurrency 的設計可以同時執行多個執行緒(Thread),現代的電腦設備基本上都是多核心的設計,越多核心等於可以同時執行越多的執行緒,所以這是 Rust 效能可以很好的原因之一。如果資料借出去的時候不能保證一次只有一個,這個 Concurrency 可能就會有狀況。因此,在 Rust 的官網手冊上還有特別提到特色之一是無懼並行(fearless concurrency),再回頭想想上面的設計,難怪不會怕了。
這是我在學習 Rust 的時候遇到的第一個關卡,希望各位現在對於 Rust 的記憶體管理有多一些認識了 :)