生命週期與引用有效性

Rust 中的每一個引用都有其生命週期(lifetime),也就是引用的有效作用域。

大部分生命週期是可以推斷的,但有時候我們需要使用泛型的生命週期參數來明確指定,確保實際使用時,引用是有效的。

生命週期的設計是為了避免迷途參考

迷途參考 (dangling reference) 是指引用了一個已經被釋放的變數的記憶體空間。

Rust 的生命週期設計是為了避免迷途參考的產生。

{
    // 這裡宣告變數卻不賦值,乍看之下似乎違反了 Rust 不存在空值的原則
    // 但如果嘗試在賦值之前使用這個變數,就會導致出錯,這代表 Rust 確實不允許空值
    let r;

    {
        let x = 5;
        r = &x;
        // x 在這個作用域結束後就會被釋放
        // 導致 r 變成一個懸垂引用
    }

    // error[E0597]: `x` does not live long enough
    // 這裡會有 "x 活得不夠久" 的錯誤, x 已經被釋放,但 r 仍然引用著 x 的記憶體空間
    println!("r: {}", r);
}

Rust 編譯器有一個借用檢查器 (borrow checker),它會確保所有借用都是有效的。

函式中的泛型生命週期

讓我們用一個找出最長字串的函式 longest 來說明泛型生命週期

// error[E0106]: missing lifetime specifier
fn longest(x: &str, y: &str) -> &str {
    // Rust 並不知道將要返回的引用是指向 x 或 y
    // 這會導致 Rust 編譯器無法確定返回的引用的生命週期
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

上面的程式碼會有錯誤,因為 Rust 編譯器無法確定 xy 的生命週期,也就無法確定 result 的生命週期。

生命週期的註解

為了解決剛剛提到的問題,我們可以在函式的引數中加入生命週期的註解 (Lifetime Annotation)。

// 使用 'a 註解生命週期,其中 a 是任意字母
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

這個 'a 說明了 xy 的生命週期是相同的,而且返回值的生命週期也與 xy 相同。

以下面的例子來看,這是一段可以正常執行的程式碼。

雖然 string1 的生命週期在外部作用域,但 string2result 的生命週期都在內部作用域,因此借用檢查器會認定這是有效的程式碼。

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

來看一個無法通過借用檢查器的例子。

因為 result 有可能指向 string2,但最後 string2 的生命週期卻比 result 還要短。

fn main() {
    let string1 = String::from("xyz");
    let result;

    {
        // 這裡的 string2 是比較長的字串
        // 所以可以預期 result 會指向 string2
        let string2 = String::from("longer string");
        result = longest(string1.as_str(), string2.as_str());

        // !Oops! string2 在這個作用域結束後就會被釋放
        // result 會變成一個懸垂引用
    }

    // error[E0597]: `string2` does not live long enough
    println!("The longest string is {}", result);
}

深入理解生命週期

指定生命週期參數的方式取決於函式的行為。 例如下方因為我們只會回傳 x,因此我們就不需要指定 y 的生命週期。 (雖然看起來有點怪)

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

注意回傳值的生命週期必須符合其中一個參數的生命週期。 如果回傳值的生命週期與參數的生命週期都不符合,那麼代表它參考的是函式本體中的值,而這會是一個迷途參考。

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("超長的字串");

    // error[E0515]: cannot return reference to local variable `result`
    // result 在離開這個這個函式之後就會被清除
    result.as_str()
}

總結來說,生命週期語法是用來連結函式中不同參數與回傳值的生命週期,讓 Rust 確保不會產生迷途參考。

結構體定義中的生命週期註解

結構體也能有生命週期註解,不過我們會需要在結構體中的每個定義中都加上生命週期註解。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    // first sentence 的生命週期與 novel 相同
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");

    // ImportantExcerpt 的生命週期與 first_sentence 相同
    // novel 在 ImportantExcerpt 離開作用域之前也不會離開作用域
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

生命週期省略

每個參考都有其生命週期,但有時候生命週期可以由借用檢查器推斷出來,因此我們可以省略生命週期註解。

例如之前的範例,雖然參數與回傳值均為參考,但仍可以編譯成功

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

Rust 有一個生命週期省略規則 (lifetime elision rules),用來分析參考。 如果你的程式碼符合生命週期省略規則時,你就不必顯式寫出生命週期。

在函式或方法參數上的生命週期稱為輸入生命週期 (input lifetimes),而在回傳值的生命週期則稱為輸出生命週期 (output lifetimes)。

當參考沒有顯式註解生命週期時,編譯器會用三項規則來推導。 第一個規則適用於輸入生命週期,而第二與第三個規則適用於輸出生命週期。 如果編譯器處理完這三個規則,卻仍有參考無法推斷出生命週期時,編譯器就會停止並回傳錯誤。

第一個規則:編譯器會給予每個參考參數一個生命週期參數

如果一個函式只有一個參數的話,就只會有一個輸入生命週期。兩個參數的話,就會有兩個輸入生命週期,以此類推。

// 這個函式有一個參數 `x`,它的生命週期是 `'a`。
fn foo<'a>(x: &'a i32) {}
// 這個函式有兩個參數 `x` 與 `y`,它們的生命週期分別是 `'a` 與 `'b`。
fn foo<'a, 'b>(x: &'a i32, y: &'b i32) {}

第二個規則:如果只有一個輸入生命週期,那麼它就會被賦予所有輸出生命週期

fn foo<'a>(x: &'a i32) -> &'a i32 {}

第三個規則:如果有多個輸入生命週期,但其中一個參考是 &self&mut self,那麼 self 的生命週期就會被賦予所有輸出生命週期

參數中如果有 self,代表這是一個方法,而 self 的生命週期就會被賦予所有輸出生命週期。

此規則讓方法更容易讀寫,因為不用寫更多符號出來

來看幾個例子,例如下方的程式碼是符合規定,根據第一個規則與第二個規則,編譯器可以很清楚的知道參數與回傳值的生命週期。

fn first_word(s: &str) -> &str {}

// 根據第一個規則,每個參數都會有自己的生命週期,因此 s 的生命週期是 `'a`
fn first_word<'a>(s: &'a str) -> &str {}

// 根據第二個規則,如果只有一個輸入生命週期,那麼該生命週期會被賦予所有輸出生命週期
// 因此回傳值的生命週期也是 `'a`
fn first_word<'a>(s: &'a str) -> &'a str {}

來看一個編譯器無法判斷的例子。

fn longest(x: &str, y: &str) -> &str {}

// 根據第一個規則,每個參數都會有自己的生命週期,因此 x 與 y 的生命週期是 `'a` 與 `'b`
// 根據第二個規則,如果只有一個輸入生命週期,那麼該生命週期會被賦予所有輸出生命週期
// 但因為輸入生命週期有兩個,所以第二個規則明顯不適用
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {}

在方法中定義生命週期註解

當我們在有生命週期的結構體上實作方法時,結構體的生命週期註解需要在 impl 關鍵字後方,與結構體 ImportantExcerpt 後方寫上。

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("請注意:{}", announcement);
        self.part
    }
}

雖然有兩個輸入生命週期,但其中一個參數是 &self,因此根據第三個規則,self 的生命週期會被賦予所有輸出生命週期。

靜態生命週期

有一種特殊的生命週期,稱為靜態生命週期 (static lifetime),它的生命週期會持續整個程式的執行期間。

let s: &'static str = "我有靜態生命週期。";

在使用靜態生命週期時,最好想一下該參考的生命週期是否真的會存在於整個程式期間,以及是否真的該活得這麼久。


This site uses Just the Docs, a documentation theme for Jekyll.