生命周期標記

Rust 的定位比較底層,很重視內存控制力,可以由程序員自己決定變數是在堆上分配還是在棧上分配,因此,Rust還支持指針類型。但是,它把指針類型分了類,不同作用的指針,對應不同的類型,就像 C++ 的智能指針那樣。比較常見的有:

  • Box 類型,類似 unique_ptr 類型,代表這個指針對它所指向的內容擁有所有權,有修改許可權,負責內存的分配和釋放。如果要修改所指向的內容,需要變數綁定有 mut 修飾。
  • & 類型,借用指針,也叫 reference 引用。代表這個指針可以讀它指向的內容,沒有修改許可權,也沒有釋放許可權。
  • &mut 類型,可變借用指針。代表這個指針可以讀寫它指向的內容,有修改許可權,沒有釋放許可權。
  • Rc 類型,引用計數智能指針。它允許多個 Rc 指針指向同一塊內存,而且每個 Rc 之間是平等的。當所有 Rc 都消亡後,它指向的內容就會被釋放。
  • Cow 類型,寫時複製智能指針。它允許在只讀的時候使用共享引用,需要修改的時候,再拷貝一份新的內容。

還有一些其它的智能指針類型,就不一一介紹了,本篇主要講借用指針 & 。借用指針的一個重要規則是,指針本身的生命周期不可大於被借用對象的生命周期。所謂生命周期,就是一個變數從創建到銷毀的整個過程。示例如下:

1|fn main() {2| let p : &i32;3| {4| let local = 1i32;5| p = &local;6| }7| println!("{:?}", p);8|}

對於上面這段代碼,編譯會發生錯誤:

error: `local` does not live long enough

這是為什麼呢?因為指針 p 的生命周期是,從第2行到第8行;而它指向的變數,local 的生命周期是從第4行到第6行。當執行到第7行的列印語句的時候,p就成了懸空指針,這是不允許發生的現象。在這段代碼中,p的生命周期大於local的生命周期,違反了Rust的規則,因此編譯器報錯了。

函數中的生命周期標記

對於一個函數內部的生命周期分析,Rust編譯器可以很好解決。但是,當生命周期跨函數的時候,就需要一種特殊的生命周期標記符號。示例如下:

01|struct T {02| member: i32,03|}04|05|fn test<"a>(arg: &"a T)->&"a i3206|{07| &arg.member08|}09|10|fn main() {11| let t=T{member:0}; //---- "t ---|12| let x=test(&t) //----- "x -| |13| println!("{:?}", x); // | |14|} //----- "x--"t --|

生命周期符號使用單引號開頭,後面跟一個合法的名字。生命周期標記和泛型類型參數是一樣的,都需要先聲明後使用。在上面這段代碼中,尖括弧裡面的 "a 是聲明一個生命周期參數,它在後面的參數和返回值中被使用。借用指針類型,都有一個生命周期泛型參數。

生命周期之間有重要的包含關係。如果生命周期 "a 比 "b 更長或相等,我們記為 "a : "b,意思是 "a 包含或者等於 "b。對於借用指針類型來說,如果 &"a 是合法的,那麼 "b 作為 "a 的一部分,&"b也一定是合法的。由於歷史原因,Rust的各種文檔中對引用和生命周期之間的關係的描述並不統一,有些地方是寫的 covariant(協變),有些地方寫的是 contravariant(逆變)。但是生命周期畢竟不是類型,這種描述方法就是一個類比而已,Rust team目前傾向於不再把生命周期的包含關係套用到類型的subtype關係。

另外,"static 是一個特殊的生命周期,它代表的是,這個程序從開始到結束的整個階段,所以它比其它任何生命周期都長。

在上面這個例子中,在函數被調用的時候,它的實際參數是 &t,如果我們把變數 t 的真實生命周期記為 "t,那麼可以說,在調用的時候,這個泛型參數 "a,被實例化為了 "t。這個生命周期 "t 實際上是從第11行到第14行。那麼根據函數簽名,可以推理出來,test 函數的返回類型是 &"t i32。如果我們把 x 的生命周期記為 "x,那麼 x 的類型可以記為 &"x i32。"x 生命周期是從第12行到第14行。那麼,這條 let x = text(&t); 語句實際上是,把 &"t i32 類型的變數賦值給 &"x i32 類型的變數。這個賦值是否是合理的呢?它應該是合理的。因為這兩個生命周期的關係是 "t : "x。test 返回的那個指針在 "t 這個生命周期範圍內都是合法的,在一個被 "t 包圍的更小範圍的生命周期內,它當然也是合法的。所以,上面這個例子可以編譯通過。

接下來,我們把上面這個例子稍做修改,讓 test 函數有兩個生命周期參數,其中一個給函數參數使用,另外一個給返回值使用:

fn test<"a,"b>(arg: &"a T) ->&"b i32{ &arg.member}

編譯,果然出了問題,在 &arg.member 這一行,報了生命周期錯誤。這是為什麼呢?因為這一行代碼是把 &"a i32 類型賦值給 &"b i32 類型。"a 和 "b 有什麼關係?什麼關係都沒有。所以編譯器覺得這個賦值是錯誤的。怎麼修復呢?我們指定 "a:"b 就可以了。"a比"b活得長,自然,&"a i32 類型賦值給 &"b i32類型是沒問題的。驗證如下:

fn test<"a, "b>(arg: &"a T) -> &"b i32 where "a:"b{ &arg.member}

經過這樣的改寫後,我們可以認為,在 test 函數被調用的時候,生命周期參數 "a 和 "b,被分別實例化為了 "t 和 "x。它們剛好滿足了 where 條件中的 "t : "x 約束。而 &arg.member 這條表達式,它的類型是 &"t i32,而返回值要求的是 &"x i32 類型,這也是合法的。所以 test 函數的生命周期檢查可以通過。

類型中的生命周期標記

如果自定義類型中,有成員包含生命周期參數,那麼這個自定義類型,也必須有生命周期參數。示例如下:

struct Test<"a> { member: &"a str}

在為它 impl 的時候,也需要先聲明再使用。

impl<"t> Test<"t> { fn test<"a>(&self, s: &"a str) { }}

impl 後面的那個 "t 是聲明生命周期參數,後面的 Test<"t> 是在類型中使用這個參數。如果有必要的話,方法中還能繼續引入新的泛型參數。

如果在泛型約束中有 where T: "a 之類的條件,意思是,類型 T 的所有生命周期參數必須大於等於 "a。特別的,對於 where T: "static 的約束,意思是,類型 T 裡面不包含任何指向短生命周期的借用指針,(可以有指向 "static 的借用指針)。

省略生命周期標記

在有些情況下,Rust允許我們在寫函數的時候,省略掉顯式生命周期標記。在這種時候,編譯器會通過一定的固定規則,為我們的參數和返回值指定合適的生命周期,避免一些重複性的代碼。比如我們可以寫這樣的代碼:

fn get_str(s: &String) -> &str { s.as_ref()}

實際上,它等同於下面這樣的代碼,只是把顯式的生命周期標記省略掉了而已:

fn get_str<"a>(s: &"a String) -> &"a str { s.as_ref()}

我們把以上代碼稍微修改一下,返回的指針並不指向參數傳入的數據,而是指向一個靜態常量,代碼如下:

fn get_str(s: &String) -> &str { println!("call fn {}", s); "hello world"}

這種時候,我們期望的是,返回的指針實際上是 &"static str 類型。測試代碼如下:

fn main() { let c = String::from("haha"); let x: &"static str = get_str(&c); println!("{}", x);}

我們可以看到,在 get_str 函數中,我們返回的是一個指向靜態字元串的指針。在主函數的調用方,我們希望變數x應該指向一個「靜態變數」。可是這一次,我們發現了編譯錯誤:

error: `c` does not live long enough

按照我們的分析,變數 x 理應指向一個 "static 生命周期的變數,它的存活時間足夠長,為什麼編譯器沒發現這一點呢?這是因為,編譯器對於省略掉的生命周期,不是用的「自動推理」策略,而是用的幾個非常簡單的「固定規則」策略。這是跟類型自動推導不一樣的東西,當我們省略變數的類型的時候,編譯器會試圖通過變數的使用方式,推導出變數的類型,這個過程叫 「type inference」。而對於省略掉的生命周期參數,編譯器的處理方式簡單粗暴得多,它完全不管函數內部的實現,並不嘗試找到最合適的推理方案,僅僅只是應用幾個固定的規則而已,這些規定叫 「lifetime elision rules」。以下就是省略的生命周期是怎麼被自動補全的規則:

  1. 每個帶生命周期參數的輸入參數,每個對應不同的生命周期參數;
  2. 如果只有一個輸入參數帶生命周期參數,那麼返回值的生命周期被指定為這個參數;
  3. 如果有多個輸入參數帶生命周期參數,但其中有 &self、&mut self,那麼返回值的生命周期被指定為這個參數;
  4. 以上都不滿足,就不能自動補全返回值的生命周期參數。

這時候我們再回頭去看前面的例子,我們可以知道,對於這個函數:

fn get_str(s: &String) -> &str { println!("call fn {}", s); "hello world"}

編譯器會這樣自動補全生命周期參數:

fn get_str<"a>(s: &"a String) -> &"a str{ println!("call fn {}", s); "hello world"}

所以,當我們調用

let x: &"static str = get_str(&c);

這句話的時候,就發生了編譯錯誤。了解了這些,那麼修復方案也就很簡單了,這種情況下,我們不能省略生命周期參數,讓編譯器給我們自動補全,自己手寫就對了:

fn get_str<"a>(s: &"a String) -> &"static str { println!("call fn {}", s); "hello world"}

或者只手寫返回值的生命周期參數,輸入參數靠編譯器自動補全也可:

fn get_str(s: &String) -> &"static str { ... }

最後,一句話總結,elision != inference,省略生命周期參數,和類型自動推導的原理是完全不同的。

為什麼 Rust 需要生命周期標記?

我們可以看到,在函數體內,Rust不允許顯式的生命周期標記,這些標記都可以通過自動推理來解決。那麼為什麼在跨函數的時候不使用自動推理呢?

原因之一,顯式生命周期標記是程序員意圖的體現,它能讓編譯器生成更精準的編譯錯誤。比如,下面這段代碼,其中涉及比較複雜的生命周期標記。如果編譯器允許每個地方都省略生命周期參數,通過使用方式自動推理,不同的調用方式,可能會推理出不同的結果。某個地方的微小改變,可能導致遙遠的某處的生命周期問題,編譯器無法精準的指出錯誤根源。程序員面對這樣的情況,只能在大腦中將編譯器的生命周期自動推理規則人肉推理一遍。

use std::mem::replace;pub struct IterMut<"a, T: "a> { data: &"a mut[T] };impl<"a, T> Iterator for IterMut<"a, T> { type Item = &"a mut T; fn next<"b>(&"b mut self) -> Option<Self::Item> { let d = replace(&mut self.data, &mut []); if d.is_empty() { return None; } let (l, r) = d.split_at_mut(1); self.data = r; l.get_mut(0) }}

原因之二,跟類型自動推導不同,在分析生命周期的時候,編譯器無法做到所有的生命周期全部自動推理出來。示例如下:

use std::mem::transmute;#[derive(Debug)]struct T { member: i32,}fn test<"a>(arg: &"a T) -> &"a i32{ println!("{:?}", arg); let local = T { member : 1}; unsafe { transmute(&local.member as *const i32) }}fn main() { let t = T { member : 0 }; let x = test(&t); println!("{:?}", x);}

在這個 test 函數中,我們用了 unsafe 代碼,先將 & 型引用轉為裸指針,消滅掉了生命周期參數,然後再做一次強制類型轉換,變為新的 & 引用類型。如果加上 -O 優化選項編譯執行,我們就會看到野指針出現了。這種情況,編譯器完全沒有能力自動推理出各個變數的生命周期是否合法。

本文同步發布在微信公眾號:Rust編程,歡迎關注。


推薦閱讀:

世界範圍內,有哪些用 Ruby 開發的優秀網站?
內部可變性
驀然回首萬事空

TAG:Rust编程语言 | 编程语言 | 编程学习 |