(譯)理解Rust中的生命週期

明醬發表於2021-12-11

原文連結:Understanding lifetimes in Rust
rust

今天是個好日子,你又想試試Rust程式設計的感覺了。上一次嘗試非常順滑,除了遭遇了一點點借用檢查的問題,但解決問題的過程中你理解了它的工作機制,一切都是值得的!

借用檢查可不能阻止你的Rust巨集偉計劃,攻克它之後你感覺寫Rust程式碼都寫得飛起了。然後你又一次輸入了編譯指令,開始編譯,然而:

error[E0597]: `x` does not live long enough

又來?你深呼吸一口氣,放鬆心情,看著這條報錯資訊。“存活得不夠久(does not live long enough)”,這到底是啥意思?

生命週期簡介

Rust編譯器用生命週期來記錄引用是否還有。引用檢查也是借用檢查機制的一大職責,和生命週期機制一起確保你使用的引用是有效的。

生命週期標註能夠讓借用檢查判斷引用是否有效。大多數情況下,借用檢查機制自己就可以推斷引用的生命週期,但是在程式碼中顯式地使用生命週期標註,能夠讓借用檢查機制直接得到引用有效性的相關資訊。

本文將介紹生命週期的基本概念和使用方式,以及一些常用場景,需要你對Rust的相關概念有一定了解(比如借用檢查)。

生命週期標誌

稍微要注意的是,Rust生命週期的標誌和其它語言中的不太一樣,是在變數名前加單引號來表示,通常是用從小寫字母來進行泛型表示:'a'b 等等。

為什麼需要生命週期

Rust為什麼需要這樣一個奇怪的特性呢?答案就在於Rust的所有權機制。借用檢查管理著記憶體的分配與釋放,同時確保不會存在指向被釋放記憶體的錯誤引用。相似的,生命週期也是在編譯階段進行檢查,如果存在無效的引用也就過不了編譯。

在函式返回引用以及建立引用結構體這兩種場景,生命週期尤為重要,很容易出錯。

例子

本質上生命週期就是作用域。當變數離開作用域時,它就會被釋放掉,此時任何指向它的引用都會變成無效引用。下面是一個從官方文件中拷過來的最簡單的示例:

// 這段程式碼 **無法** 通過編譯
{
    let x;
    {                           // create new scope
        let y = 42;
        x = &y;
    }                           // y is dropped

    println!("The value of 'x' is {}.", x);
}

這段程式碼含有裡外兩個不同的作用域,當裡面的作用域結束後,y 被釋放掉了,即使 x 是在外層作用域宣告的,但它仍然是無效引用,它引用的值 ”存活得不夠久”。

用生命週期的術語來講,內外作用域分別對應一個 'inner 和一個 'outer 作用域,後者明顯比前者更久,當'inner 作用域結束,其內跟隨它生命週期的所有變數都會成為不可用。

生命週期省略

當編寫接受引用型別變數作為引數的函式時,大多數場景下編譯器可以自動推匯出變數的生命週期,不用費力去手動標註。這種情況就被稱作“生命週期省略”。

編譯器使用三條規則來確認函式簽名可以省略生命週期:

  • 函式的返回值不是引用型別
  • 函式的入參中最多隻有一個引用
  • 函式是個方法(method),即第一個引數是&self 或者 &mut self

示例與常見問題

生命週期很容易就會把腦子繞暈,簡單懟一大堆文字介紹也不見得能讓你理解它是怎樣工作的。理解它的最好方式當然還是在程式設計實踐中、在解決具體問題的過程中。

函式返回引用

Rust中,如果函式沒有接收引用作為引數,是無法返回引用型別的,強行返回無法通過編譯。如果函式引數中只有一個引用型別,那就不用顯示的標註生命週期,所有出參中的引用型別都將視為和入參中的引用具有同樣的生命週期。

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

但如果你再加入一個引用型別,即便函式內部並不適用它,編譯也將無法通過了。這體現的就是上述第二條規則。

// 這段程式碼過不了編譯
fn f(s: &str, t: &str) -> &str {
    if s.len() > 5 { s } else { t }
}

當一個函式接受多個引用引數時,每個引數都有各自的生命週期,函式返回的引用對應哪一個,編譯器無從自動推導。比如下面程式碼中的 '??? 會是哪一個標註的生命週期?

// 這段程式碼過不了編譯
fn f<'a, 'b>(s: &'a str, t: &'b str) -> &'??? str {
    if s.len() > 5 { s } else { t }
}

試想一下,如果你要使用這個函式返回的引用,應該給它指定一個怎樣的生命週期?只有給它生命週期最短的那一個入參的,才能保證它有效,編譯器才知道他倆是具有同樣的、更短的生命週期的引用。如果像上面那個函式一樣,所有入參都有可能被返回,那你只能確保他們的生命週期全都一樣,如下:

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

如果函式的入參具有不同的生命週期,但你確切地知道你會返回哪一個,你可以標註對應的生命週期給返回型別,這樣入參生命週期的差異也不會產生問題:

fn f<'a, 'b>(s: &'a str, _t: &'b str) -> &'a str {
    s
}

結構體引用

結構體引用的生命週期問題會更棘手一點,最好用非引用型別替代,這樣不用擔心引用有效性、生命週期持續長度等問題。以我的經驗看這通常就是你想要的。

但是有些場景確實需要用結構體引用,尤其是當你想要編寫一個不需要資料所有權轉移、資料拷貝的程式碼包,結構體引用可以讓原始資料以引用的方式在其它地方被訪問,不用處理棘手的資料克隆問題。

舉個例子,如果你想編寫程式碼,尋找一段文字的首尾兩條句子,並將它們倆存在一個結構體 S 中。不使用資料拷貝,那麼就需要使用引用型別,並且給它們標註生命週期:

struct S<'a> {
    first: &'a str,
    last: &'a str,
}

如果段落為空,則返回 None,如果段落只有一條句子,則首尾都返回這一條:

fn try_create(paragraph: &str) -> Option<S> {
    let mut sentences = paragraph.split('.').filter(|s| !s.is_empty());
    match (sentences.next(), sentences.next_()) {
        (Some(first), Some(last)) => Some(S { first, last }),
        (Some(first), None) => Some(S { first, last: first }),
        _ => None,
    }
}

由於該函式符合生命週期自動推導的原則(如返回值非引用型別、只接收最多一個引用入參),因此不用手動給它標註生命週期。要想不改變原始資料的所有權,確實只能考慮輸入一個引用引數來解決問題。

總結

本文只是粗略地介紹下Rust中的生命週期。考慮到它的重要性,我推薦再閱讀官方文件的"引用有效性與生命週期” 一節,補充更多的概念理解。

如果你還想進階的話,推薦觀看 Jon Gjengset 的:Crust of Rust: Lifetime Annotations,該視訊包含了多生命週期的示例,當然也有一些生命週期的介紹,值得一看。

相關文章