原文標題: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: Trait
,T
到dyn 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 T
和Vec<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
的協變,因為在一個期望得到短的生命周期的地方傳遞一個更長的生命周期總是可以的。除非它們被包裹在可變引用或相似事物中。 -
&'a mut T
,UnsafeCell<T>
,Cell<T>
,*mut T
允許可變訪問,所以為了避免上面的evil_feeder
示例以及類似問題,我們想要它是T
的不變,這意味着生命周期的准確匹配。
每一個版本的發布,Rust 的可用性和友好性都在改善,然而,生命周期是一個核心概念,仍然需要深挖。這篇文章集合了各種資源的信息,讓你做一次深入研究而不必多次:)。

參考資料
Rustonomicon: https://doc.rust-lang.org/stable/nomicon/coercions.html
[2]更高級別 trait 約束: https://doc.rust-lang.org/stable/nomicon/hrtb.html