iT邦幫忙

2024 iThome 鐵人賽

DAY 15
1

今天延續介紹所有權機制另一個要素的生命週期(lifetime)。

回憶初見生命週期

我們回憶一下在所有權借用有故意寫出一段會造成迷途指標的程式碼,用來舉例 Rust 編譯器保證所有的參考都是有效的。

fn main() {
    let reference_to_nothing = dangle();
    println!("{reference_to_nothing}");
}

fn dangle() -> &String {
    let s = String::from("hello"); // s 有所有權

    &s // 外部拿的指標對應的記憶體會被釋放
} // s 離開作用域, 釋放記憶體
error[E0106]: missing lifetime specifier
  --> src/main.rs:65:16
   |
65 | fn dangle() -> &String {
   |                ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
   |
65 | fn dangle() -> &'static String {
   |                 +++++++
help: instead, you are more likely to want to return an owned value
   |
65 - fn dangle() -> &String {
65 + fn dangle() -> String {
   |

error[E0515]: cannot return reference to local variable `s`
  --> src/main.rs:68:5
   |
68 |     &s
   |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

當時有提到 Rust 有一個機制是透過分析程式碼中參考的生命週期,確保參考不會在無效時被使用,現在我們就要來理解生命週期是什麼,才能理解這些錯誤訊息。

觀察生命週期

生命週期的概念就是描述參考的有效範圍,在這個有效範圍內不允許釋放掉原本的資源,從而避免記憶體重複釋放或是迷途指標。Rust 編譯器的借用檢查器(Borrow Checker)會確保所有的引用和生命週期都是安全且有效的。

我們先看一段程式碼來解釋變數還有參考的生命週期。

fn main() {
    let life = String::from("life");                   // - life 生命週期'b 開始 
    let life_ref = &life;      // ref - 生命週期 'a 開始    |
                               //     |                   |
    println!("{}", life_ref);  // ref - 生命週期 'a 結束.   |
                                                       // - life 生命週期'b 結束                                       
}

只要參考的生命週期小於變數的生命週期,那就可以保證一定參考一定是有效的。
反過來說,只要參考的生命週期比變數的生命週期還長,就會有迷途指標的風險:

fn main() {
    let life_ref;                          // ref   生命週期 'a 開始 - 
    {                                      //                      |
        let life = String::from("life"); // - life 生命週期'b 開始   |
									     // |                      |
        life_ref = &life;                // |                      |
    }                                    // - life 生命週期'b 結束   |
                                           //                      |
    println!("{}", life_ref);              // ref    生命週期'a 結束 -
}
$ cargo run
error[E0597]: `life` does not live long enough
 --> src/main.rs:5:20
  |
4 |         let life = String::from("life");
  |             ---- binding `life` declared here
5 |         life_ref = &life;
  |                    ^^^^^ borrowed value does not live long enough
6 |     }
  |     - `life` dropped here while still borrowed
7 |
8 |     println!("{}", life_ref);
  |                    -------- borrow later used here

For more information about this error, try `rustc --explain E0597`.

這個錯誤訊息就很明確指出程式碼中的變數life生命週期不夠長,不允許在life的生命週期結束後還使用它的參考life_ref

不過這個錯誤顯然和我們一開始的錯誤不一樣,差別在於原本那個錯誤是因為我們呼叫一個回傳參考的函數。
我們再寫一個函數,這個函數會從傳進兩個字串參考中回傳字串比較長的那一個,看如果用函數會遇到什麼狀況和要怎麼處理。

fn get_longer(s: &str, t: &str) -> &str {
    if s.len() >= t.len() {
        s
    } else {
        t
    }
}

fn main() {
    let js = String::from("JavaScript");
    let rust = String::from("Rust");
    let longer = get_longer(&js, &rust);
    println!("Longer: {}", longer);
}

這樣就會報和一開始同一個編號的錯誤:

$ cargo run
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:36
  |
1 | fn get_longer(s: &str, t: &str) -> &str {
  |                  ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `s` or `t`
help: consider introducing a named lifetime parameter
  |
1 | fn get_longer<'a>(s: &'a str, t: &'a str) -> &'a str {
  |              ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.

這邊遇到的問題是 Rust 無法自動辨認回傳的參考的生命週期,因為st的生命週期可能不同,實際回傳的參考的生命週期要根據哪個判斷也不知道。

生命週期詮釋

我們可以透過在函數簽名中引入一個明確的生命週期標註來解決這個問題。

fn get_longer<'a>(s: &'a str, t: &'a str) -> &'a str {
    if s.len() >= t.len() {
        s
    } else {
        t
    }
}

函數後的 <> 是用來放泛型參數,目前只需要知道生命週期也是其中一種即可。
上面是生命週期詮釋(Lifetime Annotation)的寫法,生命週期參數的名稱必須以撇號'作為開頭,通常全是小寫且很短,而參考的生命週期參數則放在 & 後面,並且空一格和參數型別區隔。

我們在上面引入了 'a 作為生命週期參數,並標註 s 和 t 的引用與這個生命週期相關聯,同時也標註回傳的引用有相同的生命週期,這樣告訴編譯器回傳的參考必須和 st 的生命週期一致。現在編譯器可以正確推斷這兩個參考之間的關係,不會出現錯誤。

需要注意上面提到編譯器雖然可以編譯通過,但是在更複雜的情況下,如果輸入的兩個參考的生命週期不一致,那麼有可能會導致潛在的錯誤,這種情況下編譯器仍然無法判斷並防止潛在的問題,因為我們上面的程式碼告訴編譯器兩者的生命週期是一致的,而編譯器會相信我們的判斷。

需要注意生命週期詮釋只是詮釋,僅描述了數個參考的生命週期之間互相的關係,不是生命週期本身,不會改變參考能存活多久。

當然,函數本身不會知道外面傳的參數的生命週期會在什麼時候結束,所以這邊的'a其實代表的是st中最短的生命週期,編譯器就會用這個最短的生命週期來判斷我們在從函數取回來的參考。

觀察最短生命週期

舉個例子來觀察生命週期詮釋是用這個最短的生命週期來判斷。

把 main 做個修改,現在變數 rust 的生命週期在裡面的大括號就結束了。
以下是可以正常編譯的程式碼:

fn main() {
    let js = String::from("JavaScript");
    let longer;
    {
        let rust = String::from("Rust");
        longer = get_longer(&js, &rust);
        println!("Longer: {}", longer);
    }
}

我們再做一個修改,把println!("Longer: {}", longer); 移到最後,這樣就會有問題了:

fn main() {
    let js = String::from("JavaScript");
    let longer;
    {
        let rust = String::from("Rust");
        longer = get_longer(&js, &rust); // rust 生命週期結束
    }
    println!("Longer: {}", longer);
}
$ cargo run
error[E0597]: `rust` does not live long enough
  --> src/main.rs:14:34
   |
13 |         let rust = String::from("Rust");
   |             ---- binding `rust` declared here
14 |         longer = get_longer(&js, &rust);
   |                                  ^^^^^ borrowed value does not live long enough
15 |     }
   |     - `rust` dropped here while still borrowed
16 |     println!("Longer: {}", longer);
   |                            ------ borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `lifetime` (bin "lifetime") due to 1 previous error

雖然我們知道這個情況取回來的參考是 js 的,所以最後 logger 的參考實際上是有效的,但是編譯器判斷不出來,因為函數在定義的時候沒辦法知道實際被執行的時候是哪一個參考回傳回去,它只能跟編譯器講說至少最短是怎樣,然後編譯器採取最保險的做法:用這個最短的生命週期判斷,這樣可以保證回傳的參考一定是有效的,也就是有名的那句:寧可錯殺不可錯放。這裡的情況就是被錯殺了,編譯器不放行我們只能換種寫法。

生命週期省略

至於為什麼我們之前寫的函數都不需要另外寫生命週期詮釋呢?
因為新版本的 Rust 有一個生命週期省略(Lifetime Elision)的機制,早期的 Rust 全部的生命週期都要顯示的寫出來,但這樣會讓程式碼撰寫變得很繁瑣,後來 Rust 團隊有歸納一些明確的模式,並把這些模式,又稱為生命週期省略規則(lifetime elision rules)加入編譯器中,讓它能自己去判斷特定情況,這些情況我們就不需要顯式把生命週期寫出來。除此之外的情況,它沒辦法百分之百確定就會報錯要求顯式寫出生命週期詮釋。

生命週期規則

在函式或方法中,輸入生命週期(input lifetimes)指的是參數的生命週期,輸出生命週期(output lifetimes)則是返回值的生命週期。

目前編譯器有三個規則可以遵循:

  1. 每個參數都有自己的獨立生命週期
  2. 如果只有一個輸入生命週期參數,所有輸出生命週期會和它一致
  3. 如果有多個輸入生命週期參數,但其中一個是 &self 或 &mut selfself的生命週期會賦值給所有輸出生命週期參數。這是針對方法的規則,目前我們先不討論。

我們重新思考,如果一個函數會回傳參考,那它的的來源要嘛是裡面要嘛是外面,但如果是裡面的話,代表它對應的記憶體會在函數作用域結束的時候清掉,那就一定是無效的參考,編譯器會把這種情況擋下來,所以實際上回傳的參考的生命週期一定和外部傳進來的參數有關。

所以以前寫的函數剛好都是屬於只有一個輸入生命週期參數的情況,那編譯器就可以自動判斷,就不需要我們顯式的寫。

而這篇舉到的例子有就是有兩個輸入生命週期參數所以編譯器無法判斷。

我們另外舉一個例子不用標註所有參數的生命週期的情況。
在這邊我們只可能回傳第一個參考,那第二個參數的生命週期就不重要了,這種情況我們只要標註可能會關聯的參考即可,當然回傳的參考還是要寫清楚。

fn get_first<'a>(s: &'a str, t: &str) -> &'a str {
    println!("unseleted one: {}", t);
    s
}

生命週期型別檢查

另外再做個小實驗,我們刻意讓回傳的參考的生命週期和函數簽名的部分不一致會發生什麼事?

fn get_first<'a, 'b>(s: &'a str, t: &'b str) -> &'b str {
    println!("unseleted one: {}", t);
    s
}
$ cargo run
error: lifetime may not live long enough
 --> src/main.rs:3:5
  |
1 | fn get_first<'a, 'b>(s: &'a str, t: &'b str) -> &'b str {
  |              --  -- lifetime `'b` defined here
  |              |
  |              lifetime `'a` defined here
2 |     println!("unseleted one: {}", t);
3 |     s
  |     ^ function was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a`
  |
  = help: consider adding the following bound: `'a: 'b`

error: could not compile `lifetime` (bin "lifetime") due to 1 previous error

這句話很有趣:

function was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a

可以想像成 Rust 的檢查型別其實包含了生命週期的部分,即使回傳的型別都是 &str ,依然會被編譯器視為不同類型而擋下來,這樣的防呆機制的確避免編譯器後續誤判引發的錯誤,例如上面如果編譯器沒擋下來,我不小心寫錯的部分就會讓編譯器相信錯誤的參考資訊導致未預期的錯誤。

'static

最後回到最初錯誤訊息裡面看到的 'static ,這是一種特別的生命週期,它是指該參考可以存活在整個程式運行期間,所以所有的字串字面值的生命週期都是 'static ,因為它是直接存在程式的執行檔中而且不可變的,當然也適用於一些全局變量或使用到其他靜態資料的情境。也因此一般的生命週期不能拿 static 來命名。

有時可能會看到錯誤訊息建議使用 'static 生命週期,像最初的錯誤訊息。但把參考定為 'static 生命週期前,必須思考一下該參考生命週期是否真的會存在於整個程式期間,以及是否真的該活得這麼久,否則誤導編譯器的情況那程式出錯就是我們的責任了。

結語

從今天的例子可以看到,生命週期是「保證參考總是有效的」的核心,大部分情況編譯器能自動推斷,但在某些更複雜的情況下則需要手動標註。
到這邊,我們對 Rust 保證參考一定有效的機制有一定程度的認識,也了解它在背後做了多大的努力,更了解它的檢查採取的是保守的策略,因此會有誤判的時候,所以之後再遇到類似的編譯錯誤的時候也不要氣餒,這是一種 Rust 保護我們的方式,避免在實際程式運行的時候造成更大的損失。


上一篇
Day14 - 所有權(三):切片
下一篇
Day16 - 結構體
系列文
螃蟹幼幼班:Rust 入門指南25
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言