在上一篇文章中,我們看到了在 Rust 中使用參考的方法,以及 mutable / immutable borrow 的規則。在這篇文章中,我會說明 Rust 如何避免懸置參考造成的記憶體存取錯誤。
|
|
在詳細說明這套規則之前,我想要先提出幾個案例進行思考:有些是我們想允許的功能,有些是我們想禁止的危險動作。在看過這些案例後,我們會比較容易理解為何 Rust 會設計出這套規則。
思考案例
在上面的程式碼中,若我們直接回傳參考,就會有傳回懸置參考的風險。但這是否表示所有函式都不應該回傳參考型別呢?
考慮以下的程式碼:
|
|
test_1 回傳的參考來自於參數,這件事應該是可行的。test_2 回傳指向全域常數的參考,這也是安全的。
我們可以嘗試讓函式僅能回傳來自全域常數或參數的參考,而禁止回傳其它參考。然而,若參考來自另一個內層函式的回傳值,我們沒有辦法可以簡單判斷這個值是否可以當作外層函式的回傳值。
|
|
另一方面,回傳參考只是眾多危險行為之一。考慮以下程式碼:
|
|
任何傳遞或儲存參考的行為都有風險,因此 Rust 使用型別系統 (type system) 來解決這個問題,其最核心的想法是:參考所指向的物件,其生命週期的長短也是參考型別的一部份。對生命週期很短的物件取址後,這個位址不能儲存在生命週期比它長的參考變數中。
生命週期規則
上面那句話聽起來很饒口,但說穿了也只是要符合以下的規則:
- 若變數 x 的型別為 T,則對 x 取址後得到的參考型別為
&'x T,其中'x代表 x 的生命週期 (lifetime)。 - 若 r 是指向某個 T 型別的參考,則 r 的型別為
&'r T,其中'r代表 r 的生命週期。 &'x T與&'r T雖然都是指向 T 的參考型別,但因為對象的生命週期不同,因此不能被視為相同的型別。- 唯有當
'x包含'r,亦即 x 的生命週期完全涵蓋 r 的生命週期時,&'x T才能被安全地轉型為&'r T。
我們來看幾個範例:
|
|
Rust 會把它轉變成以下的樣子:(示意用,並非合法 Rust code)
|
|
對 x 取址所得到的型別是 &'x i32,由於 x 的生命週期完全涵蓋 r 的生命週期,因此這個參考可以安全轉型為 &'r i32,可以儲存在參考 r 裡面。
我們來看看反例:
|
|
Rust 允許先宣告變數,稍後再賦予初始值。然而在這個例子中,明顯地 x 的生命週期比 r 的生命週期還短,因此這樣的賦值行為會因為型別不符合而被編譯器報錯。
函式呼叫
不同的變數,即使屬於相同型別,也因為有不同的生命週期,取址後得到的參考型別也不同。因此若某個函式接受參考型別作為參數,該如何指定這個參考的生命週期就成為難題。
|
|
&x、&y 與 &z 都是不同的型別,因此我們希望 foo 可以接收不同型別的參數,但使用一致的邏輯來進行處理。C++ 的泛型 (generics) 為這道難題帶來解答:
|
|
這邊的 'a 代表外層在呼叫 foo 的時候,傳入參考所具備的生命週期。我們不知道 'a 究竟是哪個變數的生命週期,可能是 x、可能是 y 也可能是 z,但可以保證的是,因為它指向一個更外層的變數,因此生命週期 'a 一定可以涵蓋 foo 裡面任何內層變數的生命週期。
我們來試著實作前面的幾個案例:
|
|
test_1 的參數及回傳型別看起來好像很複雜,但說穿了只是想表達這樣的規格:
test_1接受一個指向Point的參考,並回傳另一個生命週期相同,但指向i32的參考。
由於 p 指向一個 struct,很自然地,該 struct 的所有成員與 struct 本身具備了相同的生命週期。因此對 p.x 取址時,其型別會是 &'a i32。
test_2 的寫法則是這樣的意思:
test_2不接受參數,而會回傳一個生命週期任君挑選,但指向i32的參考。
由於 SOME_CONST 是個全域常數,它擁有整個程式中最長的生命週期,因此它的位址可以安全轉型為具備任意生命週期、且指向 i32 的參考 (&'a i32)。
所謂「最長的生命週期」帶有其特殊涵意,因此 Rust 使用 'static 這個符號來代表它。上面的 test_2 可以做這樣的改寫:
|
|
這樣寫的意思就很清楚:test_2 會回傳一個指向全域常數的參考。由於回傳值所指向的物件具有最長的生命週期,因此任何指向 i32 的參考都可以安全地儲存這個回傳值。
我們繼續實作前面的範例:
|
|
在 test_3 當中,呼叫 test_1(&p) 是合法的,它會回傳一個生命週期與 p 相同的參考。然而回傳型別 &'a i32 代表回傳參考的生命週期必需要涵蓋外層某個更長的生命週期,而 p 只是內層的區域變數,其生命週期顯然比 'a 還要短,因此我們不能拿 test_1(&p) 的結果當回傳值。
而在 test_4 中,因為我們知道 test_2 回傳值的生命週期為 'static,因此外層的 test_4 可以將它的結果轉為任意生命週期的參考並作為回傳值。當然,你也可以直接讓 test_4 回傳 &'static i32。
Struct 中的參考
我們可以用泛型函式來處理生命週期不同的參考,當然也可以用泛型來宣告 struct 中的參考成員:
|
|
Packet<'a> 的意思是:這個 struct 裡面帶有一個以上的參考型別,指向某生命週期為 'a 的變數。因此這個 struct 的生命週期必需被 'a 所涵蓋,以免發生懸置參考。
在 test_5 中,由於 pak 的生命週期被 x 的生命週期所涵蓋,所以第 8 行的宣告可通過編譯。然而因為 'a 代表外層另一個更長的生命週期,比 pak 的生命週期更長,因此編譯器阻止你回傳 pak。
生命週期與 borrow checker
Rust 讓生命週期成為型別的一部份,除了有助於消除懸置參考,還能夠協助上一篇文章中提到的 borrow checker:
Rust 在編譯時會保證,任何變數經過取址後,要嘛同時有許多個 immutable borrow,或是只存在唯一一個 mutable borrow,不允許兩種取址方法同時存在,也不允許有多個 mutable borrow。
考慮以下程式碼:
|
|
我們不知道 test_6 做了什麼事情,然而他的參數接受一個指向 Point 參考,卻能回傳另一個生命週期相同,但指向 i32 的參考,因此讓編譯器做出這樣的推論:
若函式的回傳值帶有與輸入參數相同的生命週期,我們可以推論回傳值就是參數本身,或是參數底下的成員。
這個推論雖然大膽,但也有其道理。因為輸入參數的生命週期 'a 可以代表任何變數的生命週期,你要怎麼找到另一個生命週期與 'a 相同、或比它更長的變數呢?當然,你可以對全域常數取出生命週期為 'static 的參考作為合法回傳值。但既然 test_6 回傳型別的生命週期不是 'static 而是 'a,編譯器就可以認定它的回傳值可能來自於 p 的成員。
既然 r1 可能指向 p 的某個成員,因此 r1 就符合 immutable borrow 的條件。在 r1 尚未消滅前,對 p 進行 mutable borrow 的動作就會被編譯器擋下來。
這個推導規則對任何包含生命週期的型別都適用,自然對包含參考的 struct 也適用:
|
|
當我們呼叫 iterate(&c) 時,由於回傳的型別是個生命週期與 c 相同的 struct,因此編譯器判定這個函式產生了 immutable borrow。這個迭代器本身雖然是 struct 而不是參考,但只要它尚未消滅,後續對 c 的 mutable borrow 就就會被編譯器阻止。
簡寫泛型涵式
若每個有接收參考或回傳參考的函式都要寫成泛型,對於撰寫或閱讀上都不太方便,因此 Rust 提供了省略生命週期的簡單寫法:
|
|
當 Rust 發現函式的輸入參數只有其中一項是參考,而回傳型別也需要標注生命週期時,會自動假設回傳值的生命週期等於參數的生命週期,因此上面的函式宣告與以下效果完全相同:
|
|
若參數有兩個以上的參考型別,或完全沒有參考型別時,編譯器無法自動推導回傳型別的生命週期。你得寫出完整的泛型宣告,或是不回傳任何需要生命週期的型別。
|
|
結語
為了追求效率,Rust 允許在 stack 上配置物件並操作參考,並且在編譯時期就盡可能阻檔一切可能造成記憶體錯誤的行為。在這兩篇文章中,我詳細介紹了這套最複雜也最獨特的參考型別系統。實務上由於參考有生命週期的限制,因此在大型資料結構中,通常會使用智慧指標 (smart pointer) 來指向其它物件。在後續文章中,我會介紹這些標準函式庫提供的工具。