Rust 生命週期

楔子

Rust 的每個引用都有自己的生命週期,生命週期指的是引用保持有效的作用域。大多數情況下,引用是隱式的、可以被推斷出來的,但當引用可能以不同的方式互相關聯時,則需要手動標註生命週期。

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }  // 此處 r 不再有效
    println!("{}", r);
}

執行的時候會報出如下錯誤:borrowed value does not live long enough,意思就是借用的值存活的時間不夠長。因爲把 x 的引用給 r 之後,x 就被銷燬了,那麼 r 就成爲了一個懸空引用。

而 Rust 會通過借用檢查器,來檢查借用是否合法,顯然上述代碼在執行打印語句的時候,r 已經不合法了。

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

這段代碼也是不合法的,原因就是返回值要麼是 x 要麼是 y,但具體是哪一個不知道,並且它們的生命週期也都不知道。所以無法通過比較作用域,來判斷返回的引用是否是一致有效的,而借用檢查器也是做不到的,原因就是它不知道返回值的生命週期是跟 x 有關係還是跟 y 有關係。事實上,這個跟函數體的邏輯也沒有關係,函數的聲明就決定了它做不到這一點。

因此我們需要引入生命週期。

生命週期標註語法

首先生命週期標註並不會改變引用的生命長度,當指定了生命週期參數,函數可以接收帶有任何生命週期的引用。生命週期的標註:描述了多個引用的生命週期間的關係,但不影響生命週期本身。

現在光讀起來可能有點繞,別急,一會兒會解釋。

生命週期參數名以 ' 開頭,並且名字非常短,通常爲 a;標註位置在 & 後面,只有 & 才需要生命週期。因爲你引用了一個值,那麼這個值的存活時間需要知道,不然人家都被銷燬了還傻傻地用。

其實單個生命週期標註本身沒有什麼意義,它是爲了向 Rust 描述多個具有生命週期的參數之間的關係。並且生命週期和泛型一樣,也要聲明在尖括號內。

// 簽名裏面的生命週期必須要有
// 相當於告訴 Rust 有這麼一個生命週期 'a
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

此時代碼是合法的,但是注意:我們並沒有改變傳入的值和返回的值的生命週期,我們只是向借用檢查器指出了一些用於檢查非法調用的一些約束而已,而借用檢查器並不需要知道 x、y 的具體存活時長。

而事實上如果函數引用外部的變量,那麼單靠 Rust 確定函數和返回值的生命週期幾乎是不可能的事情。因爲函數傳遞什麼參數都是我們決定的,這樣的話函數在每次調用時使用的生命週期都可能發生變化,正因如此我們才需要手動對生命週期進行標註。

// 準確來說 'a 指的就是 x 和 y 生命週期重疊的那一部分
// 而返回值的生命週期不能超重疊的部分
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let x = String::from("hello");
    {
        let y = String::from("satori");
        let result = longest(&x, &y);
        println!("result = {}", result);
        // result = satori
    }
}

目前是沒有問題的,因爲 x 和 y 的生命週期重疊的部分是 y,然後返回值 result 和 y 也是一樣的。但如果我們把代碼改一下,將 println! 語句移到花括號外面:

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

fn main() {
    let x = "hello".to_string();
    let result;
    {
        let y = "satori".to_string();
        result = longest(&x, &y);
    }
    println!("result = {}", result);

此時就報錯了:borrowed value does not live long enough。相信你已經猜到了,因爲 x、y 生命週期重疊的部分是 y,返回值 result 的生命週期不能超過它。但當前明顯超過了,所以報錯。

所以說生命週期標註對變量沒有什麼影響,它只是給了借用檢查器一個可以用來判斷的約束罷了。

總結一下就是:生命週期用來關聯函數參數和返回值之間的聯繫,一旦它們取得了某種聯繫,那麼 Rust 就獲得了足夠多的信息來保證內存安全的操作,並且阻止那些出現懸空指針或者其它導致內存安全的行爲。

到目前爲止,你也許還不太瞭解生命週期,彆着急,我們繼續往下看。

結構體中的生命週期標註

struct 裏面可以放任意類型,但是不能放引用,比如下面的結構體定義就是錯誤的。

struct Girl {
    name: &str,
    age: i32
}

結構體如果是合法的,那麼它內部的所有成員值都要是合法的。但現在 name 是一個引用,所以結構體實例化的時候一定會引用某個字符串,這就使得字符串存活是結構體實例存活的前提。

但在實際編碼中,這兩者的存活時間沒有什麼關係,有可能你在使用結構體實例訪問 name 成員的時候,它引用的字符串都已經被銷燬了。所以 Rust 不允許我們這麼做,我們之前是將 name 的類型指定爲 String,也就是讓結構體持有全部數據的所有權。 

而如果非要將類型指定爲引用的話,那麼必須指定生命週期。

// 實例.name 會引用外部的一個字符串,所以要指定生命週期
// 表示字符串的存活時間一定比結構體實例要長
// 否則字符串沒了,而實例還在,那麼就會出現懸空引用
#[derive(Debug)]
struct Girl<'a> {
    name: &'a str,
    age: i32
}

fn main() {
    let g;
    {
        let name = String::from("古明地覺");
        g = Girl{name: &name, age: 16};
    }
    println!("{:?}", g);
}

因爲指定了生命週期,在編譯的時候借用檢查器就可以檢測出存活時間是否合法。首先 g 的存活時間是整個 main 函數,而 name 的存活時間是內部的花括號那一段作用域,比 g 的存活時間短,因此編譯出錯。

所以通過生命週期標註,Rust 在編譯期間就能通過借用檢查器檢測出引用是否合法,Rust 不會將這種錯誤留到運行時。

生命週期的省略

當一個函數返回了一個引用時,往往需要指定生命週期,而它的目的就是爲了保證返回的引用是合法的。如果不合法,在編譯階段就能找出來。

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

函數參數出現了引用,返回值也有引用,應該指定生命週期呀。是的,在早期版本這段代碼是編譯不過的,它需要你這麼寫:

fn f<'a>(s: &'a str) -> &'a str {
    "xxx"
}

但是久而久之,Rust 團隊發現對於這種場景實在沒有必要一遍又一遍的重複編寫生命週期,並且這種只有一個參數完全是可以預測的,有明確的模式。於是 Rust 團隊就將這些模式寫入了借用檢查器,可以自動進行推導,而無需顯式地寫上生命週期標註。

所以在 Rust 引用分析中編入的模式被稱爲生命週期省略規則:

如果生命週期在函數 / 方法的參數中,則被稱爲輸入生命週期;在函數 / 方法的返回值中,則被稱爲輸出生命週期。而 Rust 要能夠在編譯期間基於輸入生命週期,來確定輸出生命週期,如果能夠確定,那麼便是合法的。

而當我們省略生命週期時,Rust 就會基於內置的省略規則進行推斷,如果推斷完成後發現引用之間的關係還是模糊不清,就會出現編譯錯誤。而解決辦法就需要我們手動標註生命週期了,表明引用之間的相互關係。

那麼 Rust 省略規則到底是怎樣的呢?

如果編譯器在應用完上述三個規則後,能夠計算出返回值的生命週期,則可以省略,否則不能省略。這些規則同樣適用於 fn 定義和 impl 塊,我們來舉幾個例子,感受一下整個過程。

// 函數如下,然後開始應用三個規則
fn first_word(s: &str) -> &str{};

// 1. 每個引用類型的參數都有自己的生命週期,滿足
//    所以函數相當於變成如下
fn first_word<'a>(s: &'a str) -> &str{};

// 2. 只有一個輸入生命週期,該生命週期被賦給所有的輸出生命週期
//    顯然也是滿足的,所以函數變成如下
fn first_word<'a>(s: &'a str) -> &'a str{};

// 3. 不滿足,所以無事發生

應用完三個規則之後,計算出了返回值的生命週期,所以合法。

再舉個例子:

// 函數如下,然後開始應用三個規則
fn first_word(s1: &str, s2: &str) -> &str{};

// 1. 每個引用類型的參數都有自己的生命週期
//    顯然滿足,所以函數變成如下
fn first_word<'a, 'b>(s1: &'a str, s2: &'b str) -> &str{};

// 2. 只有一個輸入生命週期,該生命週期被賦予所有的輸出生命週期
// 但是這裏有兩個,所以不滿足

// 3. 不滿足

當編譯器使用了 3 個規則之後仍然無法計算出返回值的生命週期時,就會出現編譯錯誤,顯然上面代碼是會報錯的。我們需要手動標註生命週期:

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

從表面上來看 x、y 的生命週期是相同的,都是'a,但準確來說它表示的是 x、y 生命週期重疊的部分。而返回值的生命週期標註也是'a,所以此處的含義就表示輸出生命週期是兩個輸入生命週期重疊的部分。

longest 函數這麼改的話,是合法的。

方法中的生命週期標註

然後是在方法中標註生命週期,它的語法和泛型是相似的。

// 聲明週期的語法類似於泛型
// 必須要先通過 <'a> 進行聲明,然後才能使用
struct Girl <'a> {
    name: &'a str,
}

// 在學習泛型的時候我們知道
// 這種方式表示爲某個類型實現方法
// 現在則變成生命週期,並且 <'a> 不可以省略
impl <'a> Girl <'a> {
    fn say_hi(&self) -> String {
        String::from("hello world")
    }

    // 此處無需指定生命週期,因爲 Rust 可以推斷出來
    // 會自動將 self 的生命週期賦值給所有的輸出生命週期
    fn get_name(&self, useless_arg: &str) -> &str {
        self.name
    }
}
fn main() {
    let name = String::from("古明地覺");
    let g = Girl{name:&name};

    println!("{}", g.say_hi());  // hello world
    println!("{}", g.get_name(""))  // 古明地覺
}

比較簡單,另外程序中還有一個特殊的生命週期叫 'static,它表示整個程序的持續時間。所有的字符串字面量都擁有'static 生命週期:

fn main() {
    let s: &'static str = "hello";
}

爲引用指定'static 之前需要三思,是否需要引用在整個程序的生命週期內都存活。

同時指定生命週期和泛型

生命週期的指定方式和泛型是一樣的,那如果想同時指定生命週期和泛型,應該怎麼做呢?

fn largest<'a, T>(x: &'a str, y: &'a str,
                  useless_arg: T) -> &'a str {
    if x > y {
        x
    } else {
        y
    }
}

fn main() {
    let s1 = "hello";
    let s2 = "hellO";
    println!("{}", largest(s1, s2, ""));
    // hello
}

非常簡單,但要保證生命週期在前,泛型在後。

以上就是 Rust 的生命週期,它並沒有改變 Rust 變量的存活時間,只是給了借用檢查器更多的餘地去推斷引用是否合法。

就目前來說,我們介紹的內容都還很基礎,應該很好理解。等把基礎說完了,後面會介紹更多關於 Rust 的細節。最後的最後,我們再一起用 Rust 手寫一個簡易版的 Redis,並和現有的 Redis 做一下性能對比。

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