1. 程式人生 > >【譯】深入理解Rust中的生命週期

【譯】深入理解Rust中的生命週期

原文標題:Understanding Rust Lifetimes
原文連結:https://medium.com/nearprotocol/understanding-rust-lifetimes-e813bcd405fa
公眾號: Rust 碎碎念
翻譯 by: Praying


從 C++來到 Rust 並需要學習生命週期,非常類似於從 Java 來到 C++並需要學習指標。起初,它看起來是一個不必要的概念,是編譯器應該處理好的東西。後來,當你意識到它賦予你更多的力量——在 Rust 中,它帶來了更多的安全性和更好的優化--你感到興奮,想掌握它,但卻失敗了,因為它並不直觀,很難找到形式化的規則。可以說,C++指標比 Rust 生命週期更容易沉浸其中,因為 C++指標在程式碼中隨處可見,而 Rust 生命週期通常隱藏在大量的語法糖背後。所以你最終會在語法糖不適用的時候接觸生命週期,這通常是一些複雜的情況。當你面臨的只有這些複雜情況時,你很難內化這個概念。

引言

對於生命週期,需要記住的第一件事就是,它們全都是關於引用(references)的,與其他東西無關。例如,當我們看到一個帶有生命週期(lifetime)型別引數的結構體時,它指的是這個結構體所擁有的引用的生命週期,再無其他。不存在結構體的生命週期或者閉包的生命週期,只有結構體或閉包內部引用的生命週期。因此,我們對生命週期的討論會不可避免地涉及到 Rust 引用。

生命週期背後的動機

要理解生命週期,我們首先需要理解其背後的動機,這就要求我們先理解借用規則背後的動機。借用規則中指出:

在程式碼中,存在對重疊記憶體的引用,也稱為別名(aliasing),它們中至少有一個會變更(mutate)記憶體中的內容。

同時變更是不允許的,因為這樣是不安全的,並且它阻礙編譯器進行各種優化。

示例

假定我們現在想要寫一個函式,該函式將一個座標沿著 x 軸在給定方向上移動兩倍的距離。

struct Coords {
  pub x: i64,
  pub y: i64,
}
fn shift_x_twice(coords: &mut Coords, delta: &i64) {
  coords.x += *delta;
  coords.x += *delta;
}

fn main() {
  let mut a = Coords{x: 10, y: 10};
  let delta_a = 10;
  shift_x_twice(&mut a, &delta_a);  // All good.

  let mut b = Coords{x: 10, y: 10};
  let delta_b = &b.x;
  // shift_x_twice(&mut b, delta_b);  // Compilation failure.
}

最後一條語句會把座標移動三倍距離而不是兩倍,這可能會在生產系統中引發各種 bug。關鍵問題在於,delta_b&mut b指向一塊重疊的記憶體,而這在 Rust 中是被生命週期和借用規則所阻止的。尤其是,Rust 編譯器會提醒,delta_b要求持有一個b的不可變引用直到main()結束,但是在那個作用域內,我們還試圖建立一個b的可變引用,這是被禁止的。

為了能夠進行借用規則檢查,編譯器需要知道所有引用的生命週期。在很多情況下,編譯器能夠自己推匯出生命週期,但是有些情況它無法完成,這就需要開發者手動的對生命週期進行標註。此外,編譯器還給開發者提供了工具,例如,我們可以要求所有實現了某個特定 trait 的結構體,其所有引用至少在給定的時間段內都是有效的。

對比 Rust 的引用和 C++中的引用,在 C++中,我們也可以有常量(const)和非常量(non-const)引用,類似於 Rust 中的&x&mut x。但是,C++中沒有生命週期。常量引用(const reference)能夠幫助 C++編譯器進行優化,但是它們不能給出完整的安全性保證。所以,上面的示例如果用 C++來寫是可以編譯通過的。

脫糖(Desugaring)

在我們深入理解生命週期之前,我們需要弄清生命週期是什麼,因為各種 Rust 文件用生命週期這個詞既指代作用域(scope)也指代型別引數(type-parameter)。在這裡,我們用生命週期(lifetime ) 表示一個作用域,用生命週期引數(lifetime-parameter ) 來表示一個引數,編譯器會用一個真正的生命週期來替換這個引數,就像它在推導泛型時那樣。

示例

為了讓解釋更加清晰,我們將會對一些 Rust 程式碼進行脫糖(譯註:指脫去語法糖)。考慮下面的程式碼:

fn announce(value: &impl Display) {
  println!("Behold! {}!", value);
}

fn main() {
 let num = 42;
 let num_ref = #
 announce(num_ref);
}

下面是脫糖的版本:

fn announce<'a, T>(value: &'a T) where T: Display {
    println!("Behold! {}!", value);
}
fn main() {
'x: {
    let num = 42;
    'y: {
        let num_ref = &'y num;
        'z: {
            announce(num_ref);
        }
    }
}
}

後面脫糖的程式碼使用生命週期引數'a和生命週期/作用域'x'y進行了顯式的標註。

我們還使用impl Display來比較生命週期引數和一般的型別引數。注意這裡語法糖是如何把生命週期引數'a和型別引數T都隱藏起來的。注意,作用域並不是 Rust 語法的一部分,我們只是用它來標註,所以脫糖後的程式碼是無法編譯的。而且,在這個以及後面的示例中,我們忽略了在 Rust 2018 中加入的非詞法生命週期(non-lexical lifetimes)以簡化我們的解釋。

子型別

從技術角度看,生命週期不是一個型別,因為我們無法像u64或者Vec<T>這樣的普通的型別一樣構建一個生命週期的例項。然而,當我們對函式或結構進行引數化時,生命週期引數就像型別引數一樣被使用,請看上面的announce示例。另外,我們後面會看到的變型規則(Variance Rule)也會像使用型別一樣使用生命週期,所以我們在本文中也會稱之為型別。

比較生命週期和普通型別、生命週期引數和普通型別引數是有用的:

  • 當編譯器為一個普通型別引數推導型別時,如果有多個型別可以滿足型別引數,編譯器就會報錯。而在生命週期的情況下,如果有多個生命週期可以滿足給定的生命週期引數,編譯器將會使用最小的那個生命週期。

  • 簡單的 Rust 型別沒有子型別,更具體來講,一個結構體不能是另一個結構體的子型別,除非它們有生命週期引數。但是,生命週期允許有子型別,並且,如果生命週期'longer覆蓋了整個'shorter,那麼'longer就是'shorter的子型別。生命週期子型別還可以對將生命週期引數化的型別進行有限的子型別化。正如我們在後面所見,它是指&'longer int&'shorter int的子型別。'static生命週期是所有生命週期的一個子型別,因為它是最長的。'static和 Java 中的Object恰好相反,Object在 Java 中是所有型別的超型別。

規則

強制轉換和子型別

Rust 有一系列規則,允許一個型別被強制轉換為另一個型別。儘管強制轉換和子型別很相似,但是能夠區分它們也很重要。關鍵的不同在於,子型別沒有改變底層的值,但是強制轉換改變了。具體來講,編譯器在強制轉換的位置插入額外的程式碼以執行某些底層轉換,而子型別只是一個編譯器檢查。因為這些額外的程式碼對開發者是不可見的,並且強制轉換和子型別看起來很相似,因為二者看起來都像這樣:

let b: B;
...
let a: A = b;

強制轉換和子型別放一起:

// 這是強制轉換(This is coercion):
let values: [u32; 5] = [1, 2, 3, 4, 5];
let slice: &[u32] = &values;

// 這是子型別(This is subtyping):
let val1 = 42;
let val2 = 24;
'x: {
    let ref1 = &'x val1;
    'y: {
        let mut ref2 = &'y val2;
        ref2 = ref1;
    }
}

這段程式碼能夠工作,因為'x'y的子型別,而且也因此,&'x也是&'y的子型別。

通過學習一些最常見的強制轉換,很容易就能區分二者,剩下的一些不常見的,見 Rustonomicon[1]

  • 指標弱化:&mut T&T

  • 解引用:型別&T&x&U的型別&*x,如果T: Deref<Target=U>。這使得我們可以像使用普通型別一樣使用智慧指標

  • [T; n][T]

  • 如果T: TraitTdyn Trait

你可能想知道為什麼'x'y的子型別這件事能夠推匯出&'x也是&'y的子型別?要回答這個問題,我們需要討論 Variance。

變型(Variance)

基於前面的內容,我已經可以很容易區分生命週期'longer是否是生命週期'shorter的子型別。你甚至可以直觀地理解為什麼&'longer T&'shorter T的子型別。但是,你能夠區分&'a mut &'longer T是否是&'a mut &'shorter T的子型別嘛?實際上做不到,要知道為什麼,我們需要 Variance 規則。

正如我們之前所說,生命週期能夠對那些生命週期引數化的型別上進行有限的子型別化。變型 是型別構造器(type-constructor)的一個屬性, 型別構造器是一個帶有引數的型別,比如Vec<T>或者&mut T。更具體的,變型決定了引數的子型別化如何影響結果型別的子型別化。如果型別構造器有多個引數,比如F<'a, T, U>或者&'b mut V,那麼變型就針對每個引數單獨計算。

有三種類型的變型:

  • 如果F<Subtype>F<Supertype>的子型別(subtype), F<T>T的協變(convarinat) 。

  • 如果F<Subtype>F<Supertype>的超型別(supertype),那麼F<T>T的逆變(contravariant)。

  • 如果F<Subtype>既不是F<Supertype>的子型別,也不算F<Supertype>的超型別,它們不相容,F<T>T的不變(invariant) 。

當型別構造器有多個引數時,我們這樣來討論單個的變型,例如,F<'a, T>'a的協變並且是T的不變。而且,還有第四種類型的變型-二變體,但它是一個特定的編譯器實現細節,這裡我們不需要了解。

下面是一張針對最常見的型別構造器的變型表格:

協變基本上是一個傳遞規則。逆變很少見,並且只發生在當我們傳遞指標到一個使用了更高級別 trait 約束[2]的函式時才會發生,不變是最重要的,當我們開始組合變型時,我們會看到它的動機。

變型運算(Variance arithmetic)

現在我們知道&'a mut TVec<T>的子型別和超型別是什麼了,但是我們知道&'a mut Vec<T>Vec<&'a mut T>的子型別和超型別是什麼嘛?要回答這個問題,我們需要知道如何組合型別構造器的 variance。

組合變型有兩種數學運算:Transform 和最大下確界(greatest lower bound, GLB )。Transform 用於型別組合,而 GLB 用於所有的聚合體:結構體、元組、列舉以及聯合體。讓我們分別用 0、+、和 - 來表示不變,協變和逆變。然後 Transform(X)和 GLB(^)可以用下面兩張表來表示:

示例

假定,我們想要知道Box<&'longer bool>是否是Box<&'shorter bool>的一個子型別。換句話說,也就是我們想要知道Box<&'a bool>關於'a的協變。&'a bool是關於'a的協變,Box<T>是關於T的協變。因為它是一個組合,所以我們需要應用 Transform(X): 協變(+) x 協變(+) = 協變(+),這意味著我們可以把Box<&'longer bool> 賦予 Box<&'shorter bool>

類似的,Cell<&'longer bool>不能被賦給Cell<&'shorter bool>,因為 協變 (+) x 不變 (0) = 不變 (0)

示例

下面來自 Rustonomicon 的示例解釋了為什麼在一些型別構造器上我們需要不變性(invariant)。它試圖編寫一段程式碼,這段程式碼使用了一個被釋放後的物件。

fn evil_feeder<T>(input: &mut T, val: T) {
    *input = val;
}

fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";  // mr. snuggles forever!!
    {
        let spike = String::from("bark! >:V");
        let spike_str: &str = &spike;                // Only lives for the block
        evil_feeder(&mut mr_snuggles, spike_str);    // EVIL!
    }
    println!("{}", mr_snuggles);                     // Use after free?
}

Rust 編譯器不會編譯這段程式碼。要理解其原因,我們首先要對程式碼進行脫糖:

fn evil_feeder<'a, T>(input: &'a mut T, val: T) {
    *input = val;
}

fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";
    {
        let spike = String::from("bark! >:V");
        'x: {
            let spike_str: &'x str = &'x spike;
            'y: {
                evil_feeder(&’y mut mr_snuggles, spike_str);
                }
            }
    }
    println!("{}", mr_snuggles);
}

在編譯期間,編譯器試圖找到滿足約束的引數T。回想一下,編譯器會採用最小的生命週期,所以它會嘗試為T使用&'x str。現在,evil_feeder的第一個引數是&'y mut &'x str,而我們試圖傳遞&'y &'static str。這樣會有效麼?

為了使其有效,&'y mut &'z str應該是'z的協變,因為'static'y的子型別。回顧一下,&'y mut T是關於T的不變,&'z T是關於'z的協變。&'y mut &'z str是關於'z,因為 協變(+) x 不變 (0) = 不變 (0)。因此,它將無法編譯。
有趣的是,這段程式碼如果用 C++來寫就可以編譯通過。

結合結構體的示例

關於結構體,我們需要使用 GLB 而不是 Transform,這隻在我們使用函式指標涉及到協變時才有意義。下面是一個無法編譯的示例,因為結構體Owner是關於生命週期引數'a的不變,編譯器給出的錯誤資訊也有表明:

type annotation requires that `spike` is borrowed for `'static`

不變性從本質上禁用了子型別化,也因此,spike的生命週期準確匹配mr_sunggles

struct Owner<'a:'c, 'b:'c, 'c> {
        pub dog: &'a &'c str,
        pub cat: &'b mut &'c str,
}

fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";
    let spike = String::from("bark! >:V");
    let spike_str: &str = &spike;
    let alice = Owner { dog: &spike_str, cat: &mut mr_snuggles };
}

結尾

要記住所有的規則是非常困難的 ,並且我們也不想每次在 Rust 中遇到困難的情況都去搜索這些規則。培養直覺的最好方式是理解和記住這些規則所阻止的不安全的情況。

  • 第一個移動座標的示例讓我們記住,借用規則不允許同時變更和別名。

  • &'a T&'a mut T'a的協變,因為在一個期望得到短的生命週期的地方傳遞一個更長的生命週