Rust 讓人難以理解的 lifetime

本篇分享案例來自 The Rust Book[1], 在很多模糊的地方增加自己的理解

上次分享了 Rust 引用, 不熟悉的可以先回顧下前文。首先什麼是 lifetimes? 生命週期定義了一個引用的有效範圍,換句話說 lifetimes 是編譯器用來比對 owner 和 borrower 存活時間的工具,目的是儘可能的避免懸垂引用 (dangling pointer)

fn main() {
    {
        let r;
        {
            let x = 5;
            r = &x;
            // ^^ borrowed value does not live long enough
        }
        // - `x` dropped here while still borrowed
        println!("r: {}", r);
        // - borrow later used here
    }
}

let r; 聲明瞭一個變量,在內層語句塊中變成對 x 變量的引用,當內層語句塊結束後,變量 x (owner) 脫離作用域釋放,println 時 r 成了懸垂引用,所以編譯器報錯

借用檢查器

借用檢查器 (borrow checker) 用來對比作用域,來決定這個引用是否有效。上圖有兩個註釋 'a 'b 來分別代表 r, x 的作用域,內存語句塊的 'b 遠遠小於外層的 'a, 編譯階段發現 x 生命週期短於 r, 所以報錯阻止編譯

如果要修復也很簡單,把 println 放到內層語句塊即可。大多數時候,我們不需要顯示指定 lifetimes, 編譯器很智能,會自動幫我們推斷,但也有例外

看個例子

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

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

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

來看一個需要指定 lifetimes 的例子,longest 返回字符串最長的引用,編譯時報錯

編譯器蒙逼了,他不知道函數返回的引用到底是哪一個,需要指定生命週期。並且很貼心的給了提示

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

這裏引出 lifetimes 的一個規則:如果函數輸入參數有引用 (凡是引用必然有 lifetimes), 返回結果如果不是引用,那麼可以省略標註生命週期,反之必須標註

道理很簡單,如果返回結果是引用,那麼根據 ownership 的三原則,他引用的對象一定不是函數內部創建的,因爲函數返回後,該引用的對象會被釋放掉,返回的引用就成了懸垂引用

所以,返回引用的生命週期必然和輸入參數的一致,這就引出 lifetimes 第二個規則:如果輸入參數只有一個是引用,帶有 lifetimes, 且返回值也是引用,那麼這兩個生命週期必然一致,可以省略標註 lifetimes, 反之必須標註

這裏稍微有些繞,大家需要仔細想想並且多跑跑測試例子,加深理解,我剛開始接觸這裏也走了很多彎路

生命週期語法

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime

語法沒什麼特別的,就是泛型語法,通常從 'a 開始,'b, 'c 都行,寫成別的也可以

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

上面的例子 <'a> 是泛型語法,函數簽名表示 x, y 生命週期是一樣的,那麼返回引用自然也是 'a

fn main() {
  let s: &'static str = "I have a static lifetime.";
}

但是上面的靜態生命週期的要用 'static 關鍵字,表示該引用存活在整個程序運行期間。該字符串會被編譯到二進制 data 數據段中

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
    where T: Display
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

上面是和正常泛型參數結合的例子,<'a, T>, 第一個 'a 是生命週期,第二個是泛型 T, 要求實現 Display trait. 注意這裏面順序不能顛倒,如果 T 放到前面會報錯

error: lifetime parameters must be declared prior to type parameters
 --> src/main.rs:6:36
  || fn longest_with_an_announcement<T, 'a>(x: &'a str, y: &'a str, ann: T) -> &'a str
  |                                ----^^- help: reorder the parameters: lifetimes, then types: `<'a, T>`

error: aborting due to previous error

函數簽名裏的標註

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

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

這個例子就會報錯,雖然我們指定了生命週期,但沒什麼用。string2 在語句塊結束後就被釋放了,println resut 時繼續使用 string2 的引用就是非法的。把 println 放在語句塊內部就可以了

多個生命週期參數

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

如果函數有多個生命週期參數,'a, 'b, 返回引用是 'a, 此時編譯會報錯

11 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
   |                                   -------     -------
   |                                   |
   |                                   this parameter and the return type are declared with different lifetimes...
...
15 |       y
   |       ^ ...but data from `y` is returned here

原理很簡單,編譯器無法確定這兩個 lifetimes 的有效長度,需要指定約束

fn longest<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
    if x.len() > y.len() {
      x
    } else {
      y
    }
}

最終函數如上所示,其中 'b: 'a 表示 'b 一定包括 'a 生命週期長度

結構體內的標註

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

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.')
        .next()
        .expect("Could not find a '.'");
    let i = ImportantExcerpt { part: first_sentence };
}

在結構體定義時,如果存在引用,也要寫上 'a 註釋,泛型語法這裏不再贅述了。part 是一個字符串引用,在實例 i 創建前就存在了,並且和 i 同時離開作用域被釋放

如果去掉泛型的 lifetime 註釋,就會報錯

error[E0106]: missing lifetime specifier
 --> src/main.rs:2:11
  ||     part: & str,
  |           ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter

結構體方法的標註

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

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

結構體方法的標註語法要在 impl 關鍵字後寫上 <'a>, 並在結構體名後使用。這個例子中並沒有在 announce_and_return_part 中標註,這裏引出另一條規則 如果方法有多個輸入生命週期參數並且其中一個參數是 &self 或 &mut self,說明是個對象的方法 (method), 那麼所有輸出生命週期參數被賦予 self 的生命週期

假如把 announce_and_return_part 返回值換成 announcement 就會報錯

9  |     fn announce_and_return_part(&self, announcement: &str) -> &str {
   |                                                      ----     ----
   |                                                      |
   |this parameter and the return type are declared with different lifetimes...
...
12 |announcement
   |         ^^^^^^^^^^^^ ...but data from `announcement` is returned here

這時需要顯示的指定來協助編譯器來完成檢查,指定 lifetime. 另外涉及子類型,協變,逆變時生命週期會更復雜一些,感興趣的可以參考 nomicon[2] 官方文檔

小結

雜七雜八寫了一大堆,建議大家還是上手多練,多琢磨。以前剛學 rust 時,有人說編譯不過的話,生命週期就加 ''a', 數據就多用 clone

其實呢,**還是要理解本質,和 Go GC 運行期遍歷不同,檢查器要在編譯期確定資源何時何處釋放,就需要收集額外的信息。比如說,結構對象有個引用字段。如果無法確認它的生命週期,那麼結構對象釋放時,是否要釋放該字段?還是說該字段可以提前自動釋放,是否導致懸垂引用?**顯然,這違反了安全規則

再次強調,Rust 爲了所謂的零運行時成本,把很多 GC 語言的工作放到了編譯期

寫文章不容易,如果對大家有所幫助和啓發,請大家幫忙點擊在看點贊分享 三連

關於 Rust lifetime 大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^

參考資料

[1]

the rust book: https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html,

[2]

subtyping-and-variance: https://doc.rust-lang.org/nomicon/subtyping.html#subtyping-and-variance,

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tX4ghbGuY3p25t1HofPCIg