本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版。
? 所有權機制解決的安全性問題
Rust 語言對於「所有權」的設計,使得變數的作用域和值的生命週期關係變得更加緊密。如果前面範例中的字串所有權從來都沒有傳進函數裡,那麼該變數 name 的作用域就會是整個 main 函數,也就意味著它擁有的字串 "Kyaru" 的生命週期會持續到 main 函數結束;假如字串的所有權傳進了函數 f,那麼字串的生命週期就會變成在 f 函數完成時結束,當變數作用域結束時由系統自動把變數值 drop 掉。
也就是說,在 C 語言本來必須由工程師手動執行的 free,或是檔案的 fclose,到了 Rust 語言當中換成以更明確的所有權的形式呈現了。得益於這樣的設計,heap 上的資料變得跟 stack 一樣單純,都能夠統一由編譯器來幫你安排好 free(drop)的工作。
有了所有權的系統和借用檢查器,就能夠確保程式裡所有的引用都是有效的。
比方說,有些情況下你也許會希望這塊空間被提早釋放(而不是等到整個函數結束),Rust 允許你手動釋放記憶體,且這樣的操作是安全的:
fn main() { let name = String::from("Kyaru"); std::mem::drop(name); println!("{name}"); // 這句不能被編譯 } |
Rust 內建的手動記憶體釋放函數 std::mem::drop 就採用了所有權轉移的方式。name 的所有權在這時已經轉移進 drop 函數,若你之後想再使用這個字串(包括嘗試重複 drop 它)是不可能的──編譯器透過所有權規則知道 name 的所有權已經屬於 drop 函數,也就能把這個問題揪出來。
反之,如果今天是 C 語言:
int main() { char *my_string = make_dynamic_string(10); free(my_string); printf("%s", my_string); return 0; } |
呼叫 free(my_string) 之後,字串所佔用的記憶體被釋放了,但因為 C 語言並未在編譯器實作所有權機制,變數 my_string 的作用域不會發生改變。對編譯器來說,my_string 存放的只是一個記憶體地址,它不知道這個地址有什麼意義,且沒有能力阻止你事後繼續存取 my_string 指向的記憶體位置。這樣的操作當然是危險的,因為你存取了一份沒有意義的資料,更可怕的是你無法預期這麼做會發生什麼事情,程式的安全漏洞往往出現在這類案例裡。
用 Rust 語言寫的程式,要想取得區域變數的引用,自然也是不可能的。因為函數結束的時候,區域變數的資源已經被釋放了,這種產生懸垂引用的行為會被阻止:
fn get_ref() -> &str { let my_string = String::from("Hello"); &my_string // cannot return reference to local variable `my_string` } |
關於懸垂引用還有一個類似的場景,也是我常常犯的小毛?。?/font>
let my_str_ref: &str = String::from("Hello").as_ref(); println!("{my_str_ref}"); |
有些人為了節省程式碼行數,把動態的物件取得引用之後存到變數再直接拿來用,就會遇到這樣的問題:temporary value dropped while borrowed。要注意,某些結構體的內建方法回傳的是自身的引用,但呼叫的時候因為結構體沒有所屬的擁有者,所以在取得物件的引用以後,物件也隨之被系統自動 drop 掉。
以上面的例子來說,因為 String::from("Hello").as_ref() 的意義只是「在暫存空間產生一個 String,然後取得這個 String 的引用」,實際上因為 my_str_ref 持有的是這個 String 的引用,且沒有任何人擁有這個 String,這就會導致這個 String 隨後被釋放,原先取得的引用自然也就失效了。要解決這樣的問題,就必須確保引用的對象被擁有者拿?。?/font>
let my_str = String::from("Hello"); let my_str_ref: &str = my_str.as_ref(); println!("{my_str_ref}"); |
對於沒有寫過 Rust 的程式設計師而言,戰勝所有權機制 / 借用檢查器的過程通常是痛苦的,但是熟悉了這種方式以後,你可以從程式碼清晰地知道這些資料現在活到什麼時候、會交給誰了,能夠精細控制所有權也就意味著你可以讓資源在不再被需要的時候馬上由系統自動釋放,從而讓程式實現更小的記憶體佔用峰值。
? 可變引用的排斥性
Rust 的設計除了避免懸垂指標 / 懸垂引用以外,它還會阻止你讓資料的可變引用和不可變引用同時共存:
let mut my_string = String::from("Hello"); let ref_1 = &my_string; let ref_2 = &mut my_string; // cannot borrow as mutable let ref_3 = &my_string; // cannot borrow as immutable println!("{ref_1}, {ref_2}, {ref_3}"); |
同時擁有多個可變引用也是不被允許的:
let mut my_string = String::from("Hello"); let ref_1 = &mut my_string; let ref_2 = &mut my_string; // cannot borrow as mutable more than once at a time println!("{ref_1}, {ref_2}"); |
有多執行緒開發經驗的人多半會知道什麼叫做競爭條件(race condition):對系統來說,寫入資料是需要時間的,如果我們無法保證寫入資料的時候沒有其他人正在讀取,那麼讀取的人就可能會讀到有問題的資料,就好比一個人正在房裡換衣服的時候被人闖進來,那麼他只穿著一條內褲的樣子就被人看見了。
更具體的例子:假設我們有個 Point 結構體的值是 x=5, y=5,我寫了一個函數 is_x_equals_to_y(&Point) 用來檢查 x 和 y 是否相等。如果這時候我希望把 Point 的值改為 (8, 8),但是恰好在 x 被改為 8、且 y 還沒被修改的那短短空檔呼叫了這樣的函數,那麼它就可能會認為 Point 內部的值是 (8, 5) 而得到兩者不相等的結果;如果兩個引用都是可變引用的話,情況還會更加複雜。
雖然這在單執行緒的場合不會發生,但 Rust 試圖從根本的設計阻止這樣的問題,另一方面也是避免引用關係混亂、使得開發者意外寫出難以理解的程式碼。
如果全部的引用都是不可變引用,這樣的 code 就能被允許了,因為這些引用都是只讀不寫,不會產生任何安全問題:
let my_string = String::from("Hello"); let ref_1 = &my_string; let ref_2 = &my_string; let ref_3 = &my_string; println!("{ref_1}, {ref_2}, {ref_3}"); |
或者是這樣:
fn add(num: &mut i32) { *num += 1; } fn display(num: &i32) { println!("{num}"); } fn main() { let mut my_int = 0; add(&mut my_int); display(&my_int); } |
因為 my_int 的可變引用在 add 函數結束的時候也就失效了,不算是「同時共存」,所以這樣的 code 也是允許的。
至於如何安全地在多個不同的執行緒存取、甚至修改同一份資料,就等到之後的相關章節再一起說。
? 所有權與複製開銷
在所有權系統,我們時常會利用「不可變引用」而不是「把值複製一份傳給函數」的方式,是因為我們不希望浪費運作開銷。例如以下的情況:
fn say_hello_val(name: String) { println!("Hello, {name}!"); } fn main() { let name = String::from("Kyaru"); say_hello_val(name.clone()); println!("{name}"); } |
以函數 say_hello_val 來說,它被設計成轉移所有權的方式,假如想要讓函數外的 name 持續存活、能夠再被後面的 println! 所用,那麼傳入函數的就必須是 name.clone() 而不是 name,這可以通過編譯器的檢查。
然而,在這邊對 String 執行 .clone() 所做的事情是複製整個字串底下的所有東西,包括字串資料結構底下可變長度陣列 Vec 的 capacity 值、length 值,以及 Vec 裡頭所有的 u8 成員,屬於深層複製,過程甚至需要申請 heap 空間以裝載複製出來的新 String。這種做法無論是空間或效能都產生了浪費,我們明明只是想要讀取、印出字串內容而已。
與之相比,如果我們一開始就把函數設計為傳入不可變引用:
fn say_hello_ref(name: &str) { println!("Hello, {name}!"); } fn main() { let name = String::from("Kyaru"); say_hello_ref(&name); println!("{name}"); } |
那麼系統就不需要執行字串複製,而是單純把同一個字串拿來讀取,在最小的運作開銷下完成了需求。
當然不是所有的資料型態都需要如此費工。對於單純的數值,我們通常不用考慮所有權的問題:
fn add(x: u32, y: u32) -> u32 { x+y } fn main() { let x = 1234567; let y = 7654321; let sum = add(x, y); println!("{x} + {y} = {sum}"); } |
在 Rust 內建的資料型態當中,這些簡單的數值(如 bool、u8、i16、i8、i32、f32、f64、……)都被實作了 Copy 特徵──Copy 特徵代表的是「當我遇上了所有權轉移的場景,系統會選擇自動將它 clone 一份再傳進去」,也就是說,它和以下的程式碼相當:
fn add(x: u32, y: u32) -> u32 { x+y } fn main() { let x = 1234567; let y = 7654321; let sum = add(x.clone(), y.clone()); println!("{x} + {y} = {sum}"); } |
這也是為何即使我們傳入 x 和 y,後續仍然能透過 println! 來使用 x 和 y 的值,這兩個變數的值在傳入 add 函數時,實際上並沒有真的把所有權轉移給 add 函數。會這麼設計主要也是因為複製單個數值的運作開銷本來就非常小,而且完全不涉及 heap 的空間申請。比起糾結所有權的問題,這種場景直接把它設計成能夠自動複製,對於程式設計師造成的心智負擔也會更小。
所以,這樣的程式碼也是可以編譯的:
fn main() { let x = 123; let y = x; // 因為 i32 實作了 Copy,所以這句等同於 let y = x.clone(); println!("x = {x}, y = {y}"); } |
關於 Rust 的所有權系統,大概就是這些坑。
Rust 之所以相對困難,正是因為其他高階語言多半會把這些資料的複製、釋放等行為藏在內建的系統當中,開發者僅憑一般的學習過程很難接觸到這些原理。Rust 透過這些安全限制強迫你把程式考慮得更周詳,你必須清楚自己在做什麼才能通過編譯,而一旦你寫出能通過編譯的程式碼,這樣的程式在執行階段也就通常不會遇上什麼大問題了。
大部分情況下,我們透過這些函數跟變數之間傳值的例子就能理解所有權機制怎麼搞,也通??梢酝高^參考別人的範例寫出一些堪用的 code。就我個人的經驗來看,真正比較難的部分會是「函數應該如何設計」或是「我究竟應該使用 String 還是 &str」這樣的問題,即便程式編譯得了,也有些地方顯得不夠優雅,這都需要對 Rust 有更深刻的理解。
Rust 有很多功能其實都是環環相扣的,我們不太容易只因為知道了所有權機制就感受到 Rust 何以精妙,之後就讓我透過各種不同的章節慢慢解釋吧。
HackMD 好讀版:https://hackmd.io/@upk1997/rust-ownership-2
縮圖素材原作者:Karen Rustad T?lva(CC0 1.0)