理解 Rust 中的生命週期
Ownership, Borrowing 與 Lifetime 共同成就了 rust 中的記憶體安全,也是 rust 語言中最精髓的創造,我們就來學習學習它們究竟是什麼,為什麼要引入這些概念。
#權力與風險共生
權力與風險往往是一同出現。如果你被授予了製作核彈的權力,那麼在你製造它時其實是面臨著諸多的風險。
早期的程式語言如 C/C++ 賦予了程式設計師極高的權力,它們能自由地操作計算機的記憶體(虛擬記憶體),程式設計師們因此可以盡情地揮灑著自己的創造力來達到更強大的效能。
然而這份權力也帶來了許多風險,例如一個常見的問題是記憶體洩漏,即忘記 free
自己
malloc
出來的記憶體,程式不斷執行最終導致記憶體耗盡,C++ 通過引入解構函式防止程式設計師忘記釋放記憶體。但另一個常見問題依舊無法避免,即訪問已經釋放的記憶體,或者嘗試釋放已經釋放的記憶體。
人們認識到,記憶體管理存在的風險已經遠遠大於它所賦予的權力帶來的好處,Java 語言的便通過引入 GC (垃圾回收器)替程式設計師管理記憶體。程式設計師不再需要關心什麼時候釋放記憶體,因為 JVM 會自動處理;也不必害怕會訪問已經釋放的記憶體,因為只要記憶體還有變數使用,JVM 就不會去釋放它。而對應的,GC 剝奪了程式設計師自由操作記憶體的權力,付出的代價便是額外的效能開銷。
#什麼是記憶體安全
讓我們舉個例子:
void example() { vector<string> vector; ... auto& elem = vector[0]; vector |
我們知道,vector
內部儲存著一個數組,當 push_back
被呼叫時,它會檢視該陣列還有多少剩餘空間,若空間不足,則會開闢新的空間,並將原陣列的內容拷貝,如:
// this code might not compile, but you got the idea.void push_back(string elem) { if (this.size == this.capacity) { string* new_data = new string[this.capacity * 2 |
即在執行 vector.push_back
時,elem
指向的記憶體已經被釋放了,造成了“訪問已釋放記憶體”的問題。也許程式不會直接崩潰,但極可能得到的錯誤的結果。
上面的例子中,產生“記憶體安全”的原因是同時達成了兩個因素:
- 存在別名。即不同的變數(
elem
和vector
)指向了同一塊記憶體區域。 - 存在修改。即
push_back
過程中delete
了該記憶體。
#Ownership 及 Borrowing
Rust 提出了 Ownership(所有權)及 Borrowing(租借)的概念,做了如下限制:
- 所有的資源只能有一個主人(owner)。
- 其它人可以租借這個資源。
- 但當這個資源被借走時,主人不允許釋放或修改該資源。
可以看到,這 3 條規則的目的是防止“存在別名”和“存在修改”同時發生。一個資源如果被共享了,則不允許修改;如果想修改資源,則不允許共享。
想象有一本書(資源),則依照上述 3 個準則,有:
1. 它只有一個主人。當然你可以把書“給”其它人,所有權就歸其它人。
fn main() { let a = String::from("book"); // "book" 歸 a 所有 let b = a; // a 將 "book" 轉讓給 b println!("a = {}", a); // 出錯,a 已經無權使用資源} |
2. 允許租借。你可以先把書“給”別人,別人用完後再“給”你。但 rust 中的“借”,則保證了對方不會不把書還你。例如:
pub fn main() { let a = String::from("book"); { let b = a; // a 將 "book" 轉讓給 b } // b 死了,卻沒有將 "book" 還給 a println!("a = '{}'", a); // 出錯,"book" 不在 a 手上。} |
你可以將書借給多個人(想象幾個人一起看書),前提是它們只想“讀”這本書,即 rust 允許有多個不可變的引用 (&T):
pub fn main() { let mut a = String::from("book"); let b = &a; // "book" 借給 b 只讀 let c = &a; // "book" 同時 借給 c 只讀 println!("a = '{}'", a); println!("b = '{}'", b); println!("b = '{}'", c);} |
如果有一個人將書借去“寫”,則不允許其它人同時“讀”,即 rust 只允許有一個可變的引用(&mut T):
pub fn main() { let mut a = String::from("book"); let b = &mut a; // "book" 借給 b 寫 let c = &a; // 錯誤,有人借書“寫”時,不允許借來“讀”} |
3. 如果有人還藉著書(無論讀寫),不允許主人修改或銷燬書。
pub fn main() { let b; { let a = String::from("book"); b = &a; // "book" 借給 b } // 錯誤,a 死亡,需要銷燬書,但 b 還藉著書} |
最後,當擁有者死亡時,rust 會銷燬它擁有的資源,由於一份資源只有一個擁有者,因此並不會造成銷燬多次的情況。
這三條規則一起,保證了“存在別名”和“存在修改”不會同時發生,最終保證了記憶體安全,同時防止了多執行緒的資料競爭。
#Lifetime
我們再回顧上節關於 Ownership 的三條規則,以便分析:
- 所有的資源只能有一個主人(owner)。
- 其它人可以租借這個資源。
- 同時可以有多個不可變引用(&T)。
- 同時只可以有一個可變引用(&mut T)。
- 但當這個資源被借走時,主人不允許釋放或修改該資源。
rust 需要在編譯期間就要保證我們的程式碼不會違反上面三條限制,這樣做最大的優點就是不需要 runtime ,也就是不會增加額外的執行時開銷。那麼編譯器又是如何通過靜態分析來保證上述限制呢?
一個很直接的想法(不代表實際實現)是:為每個變數維護一個集合,集合裡記錄該變數的引用(Reference,也就是租借),那麼編譯器在分析時就能確保規則 #1, #2.1, #2.2。而為了確保規則 #3,rust 編譯器需要確保一個資源的 reference 的存在時間小於比資源的 Owner 的存在時間。
Lifetime (生命週期)是 rust 編譯器用於對比資源 owner 的存在時間與資源 reference 的存在時間的工具。Lifetime 可以理解為變數的作用域,例如:
pub fn main() { let mut a = String::from("book"); let x = &a; a.push('A'); // 違反 #3 存在 a 的引用,不允許修改} |
上例中,
{ a x * }所有者 a: |______________|借用者 x: |_________| x = &a 修改 a: | 失敗:存在 a 的引用 x 違反 #3 |
而下例中
pub fn main() { let mut a = String::from("book"); { let x = &a; } // x 作用域結束 a.push('A'); // 成功:所有對 a 的引用已經結束} |
對應是作用域為:
{ a { x } * }所有者 a: |________________________|借用者 x: |____| x = &a 修改 a: | 成功:對 a 的引用已經結束 |
可以看到,通過對作用域的分析,rust 編譯器就能夠保證資源的 owner 存活時間比資源的引用更長。
#人為標註生命週期
上面的例子較為簡單,編譯器可以做一些自動的分析來判斷程式碼是否僉,但還有一些情況下,編譯器並沒有辦法知道生命週期的是否合法,如:
fn foo(x: &str, y: &str) -> &str { if random() % 2 == 0 { x } else { y }}fn main() { let x = String::from("X"); let z; { let y = String::from("Y"); z = foo(&x, &y); } // ① println!("z = {}", z);} |
上述例子中,如果 foo
返回了 x
的值,由於變數 z
生命週期小於 x
因此不會產生記憶體安全問題;但當 foo
返回 y
時,①處 y
作用域結束,但 z
依舊持有
y
的引用,因此存在記憶體安全問題。
這裡的問題是單憑靜態分析本身並沒有辦法確定所有的生命週期,因此需要一定的人工介入,人為地給編譯器一些提示:
fn foo<'a>(x: &'a str, y: &'a str) -> &'a str { if random() % 2 == 0 { x } else { y }} |
上述標識的含義是,函式 foo
的返回值的生命週期,要小於任意引數的生命週期。有了這個提示,編譯器就很容易知道下例中的程式碼違反了這個約定。
pub fn main() { let x = String::from("X"); let z; let y = String::from("Y"); z = foo(&x, &y); println!("z = {}", z);} |
給出作用域如下:
{ x z y * }x: |____________________|z: |_______________|y: |__________|foo 的要求: Lifetime(z) <= Lifetime(x) & Lifetime(y) // 不成立 |
#作用域作為生命週期的不足
上小節的例子說明了為什麼 rust 需要引入 Lifetime 的概念,以及為什麼在一些情況下需要人為指定 Lifetime。只是使用變數的作用域作為生命週期會有“誤判”,即某些並沒有違反規則 #3 的情形也會被 rust 認為是非法的。例如:
pub fn main() { let mut a = String::from("book1"); let mut b = String::from("book2"); let mut c = &mut a; c = &mut b; a.push('C'); // ① rust 報錯:已存在對 a 的可變引用} |
上述程式碼中,rust 認為①處存在對變數 a
的引用,原因是變數 c
是對 a
的引用,並且在
①處 c
的作用域還未結束,因此認為依舊存在對 a
的引用。但實際上 c
對 a
的引用已經結束。這也是直接用作用域作為生命週期的不足,在 rust 中可以通過如下方案繞過:
pub fn main() { let mut a = String::from("book1"); let mut b = String::from("book2"); { let mut c = &mut a; c = &mut b; } a.push('C');} |
#如何指定 Lifetime
雖然理論上,我們可以指定各種複雜的 Lifetime 規則,但由於我們指定的規則是作用在編譯期的靜態分析,所以我們指定的規則有一定的要求,具體如下:
fn foo<'X, 'Y, 'Z>(x: &'X str, x: &'Y str, x: &'Z str) -> &'R str { ...} |
Lifetime 推導公式 : 當輸出值 R
依賴輸入值 X
Y
Z
…,當且僅當輸出值的 Lifetime 為所有輸入值的 Lifetime 交集的子集時,生命週期合法。
Lifetime(R) ⊆ ( Lifetime(X) ∩ Lifetime(Y) ∩ Lifetime(Z) ∩ Lifetime(...) ) |
因此,下例中指定的 Lifetime 是非法的。
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { if true { x } else { y }} |
因為
Lifetime(返回值) ⊆ ( Lifetime(x) ∩ Lifetime(y) )即:'a ⊆ ('a ∩ 'b) // 'b 可能小於 'a ,因此不總是成立 |
上面的規則本質上就是要求函式返回值的 Lifetime 要小於任意一個引數的 Lifetime。為什麼需要這樣的規則呢?我們重用上節用到的一個例子,如下:
fn foo<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { if random() % 2 == 0 { x } else { y }}fn main() { let x = String::from("X"); let z; { let y = String::from("Y"); z = foo(&x, &y); // ① } println!("z = {}", z);} |
由於 rust 做的是靜態分析,因此在 ① 處分析時,z
的 Lifetime 為函式 foo
返回值的 Lifetime 'a
,它小於變數 x
的生命週期,因此如果 rust 不強制執行
Lifetime 的推導規則,則上述程式碼能通過靜態分析,但若執行時函式 foo
返回了
y
,則又產生了記憶體安全的問題。上例可以用這種方式解決:
fn foo<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str |
'b: 'a
表示 Lifetime 'b
比 'a
活得長 (outlive)。因此可以通過 Lifetime
規則。
如上,即使 rust 需要我們人工指定一些生命週期,它對指定的內容也是有要求的,要求就是函式返回值的生命週期要小於任意一個引數的生命週期,這樣靜態分析的結果才能保證執行時的正確性。
#小結
記憶體安全、資料競爭等問題的根源是“共享可變資料”,C/C++ 語言將這些問題完全交結程式設計師,由程式設計師保證不出錯;Java 採用 GC 解決記憶體回收問題,但依舊面臨著資料競爭等問題,需要程式設計師處理;一些函式式語言,諸如 Haskell, Clojure 針對“共享可變資料”中的“可變”,強制要求資料是“不可變”的,以解決上述問題;而 rust 另闢蹊徑處理了“共享”的問題,來達到同樣的效果。
當然,在程式語言降低我們出錯風險的同時,也剝奪了我們的“自由”與“權力”。有些語言讓我們付出的代價是效能,而 rust 需要的則是程式設計師付出更多的學習時間。