后臺回復(fù) aes13 ,獲取高清版原圖
1.引言 所有權(quán) 與生命周期 是 Rust
語言非常核心的內(nèi)容。其實不僅僅是 Rust
有這兩個概念,在C/C
中也一樣是存在的。而幾乎所有的內(nèi)存安全問題也源于對所有權(quán)和生命周期的錯誤使用。只要是不采用垃圾回收來管理內(nèi)存的程序語言,都會有這個問題。只是 Rust
在語言級明確了這兩個概念,并提供了相關(guān)的語言特性讓用戶可以顯式控制所有權(quán)的轉(zhuǎn)移與生命周期的聲明。同時編譯器會對各種錯誤使用進行檢查,提高了程序的內(nèi)存安全性。
所有權(quán)和生命周期其涉及的語言概念很多,本文主要是對梳理出與“所有權(quán)與生命周期”相關(guān)的概念,并使用 UML
的類圖表達概念間的關(guān)系,幫助更好的理解和掌握。
圖例說明
本文附圖都是 UML
類圖,UML
類圖可以用來表示對概念的分析。表達概念之間的依賴、繼承、聚合、組成等關(guān)系。圖中的每一個矩形框都是一個語義概念,有的是抽象的語言概念,有的是 Rust
庫中的結(jié)構(gòu)和 Trait
。
所有圖中使用的符號也只有最基礎(chǔ)的幾個。圖 1 對符號體系做簡單說明,主要解釋一下表達概念之間的關(guān)系的符號語言。
圖 1 UML 符號 依賴關(guān)系:
依賴是 UML
中最基礎(chǔ)的關(guān)系語義。以帶箭頭的虛線表示,A
依賴與 B
表達如下圖。直觀理解可以是 A
“看的見” B
,而 B
可以對 A
一無所知。比如在代碼中 結(jié)構(gòu)體 A
中有 結(jié)構(gòu)體 B
的成員變量,或者 A
的實現(xiàn)代碼中有 B
的局部變量。這樣如果找不到 B
,A
是無法編譯通過的。
關(guān)聯(lián)關(guān)系:
一條實線連接表示兩個類型直接有關(guān)聯(lián),有箭頭表示單向'可見',無箭頭表示相互之間可見。關(guān)聯(lián)關(guān)系也是一種依賴,但是更具體。有時候兩個類型之間的關(guān)聯(lián)關(guān)系太復(fù)雜,需要用一個類型來表達,叫做關(guān)聯(lián)類型,如例圖中的 H
.
聚合與組成:
聚合與組成都是表示的是整體和部分的關(guān)系。差別在于“聚合”的整體與部分可以分開,部分可以在多個整體之間共享。而“組成”關(guān)系中整體對部分有更強的獨占性,部分不能被拆開,部分與整體有相同的生命周期。
繼承與接口實現(xiàn):
繼承與接口實現(xiàn)都是一種泛化關(guān)系,C
繼承自 A
,表示 A
是更泛化的概念。UML
中各種關(guān)系語義也可以用 UML
自身來表達,如圖 2:“關(guān)聯(lián)”和“繼承”都是“依賴”的具體體現(xiàn)方式。
圖 2用 UML表達UML自身 總圖
圖 3 是本文的總圖,后續(xù)各節(jié)分局部介紹。圖片在布局上很小,點擊放大保存后看。
圖3 所有權(quán)與生命周期總圖 2.所有權(quán)與生命周期期望解決的問題 我們從圖中間部分開始看起,所謂“所有權(quán)”是指對一個變量擁有了一塊“內(nèi)存區(qū)域”。這個內(nèi)存區(qū)域,可以在堆上,可以在棧上,也可以在代碼段,還有些內(nèi)存地址是直接用于 I/O
地址映射的。這些都是內(nèi)存區(qū)域可能存在的位置。
在高級語言中,這個內(nèi)存位置要在程序中要能被訪問,必然就會與一個或多個變量建立關(guān)聯(lián)關(guān)系(低級語言如匯編語言,可以直接訪問內(nèi)存地址)。也就是說,通過這一個或多個變量,就能訪問這個內(nèi)存地址。
這就引出三個問題:
內(nèi)存的不正確訪問引發(fā)的內(nèi)存安全問題 由于多個變量指向同一塊內(nèi)存區(qū)域?qū)е碌臄?shù)據(jù)一致性問題 由于變量在多個線程中傳遞,導(dǎo)致的數(shù)據(jù)競爭的問題 由第一個問題引發(fā)的內(nèi)存安全問題一般有 5 個典型情況:
懸垂指針(使用已經(jīng)被釋放的內(nèi)存) 非法釋放內(nèi)存(釋放未分配的指針或重復(fù)釋放指針) 圖4 變量綁定與內(nèi)存安全的基本概念 這些問題在 C/C
中是需要開發(fā)者非常小心的自己處理。比如我們可以寫一段 C
代碼,把這五個內(nèi)存安全錯誤全部犯一遍。
#include <iostream> struct Point { int x; int y; }; Point* newPoint(int x,int y) { Point p { .x=x,.y=y }; return &p; //懸垂指針 } int main() { int values[3]= { 1,2,3 }; std::cout<<values[0]<<','<<values[3]<<std::endl; //緩沖區(qū)溢出 Point *p1 = (Point*)malloc(sizeof(Point)); std::cout<<p1->x<<','<<p1->y<<std::endl; //使用未初始化內(nèi)存 Point *p2 = newPoint(10,10); //懸垂指針 delete p2; //非法釋放內(nèi)存 p1 = NULL; std::cout<<p1->x<<std::endl; //對空指針解引用 return 0; }
這段代碼是可以編譯通過的,當(dāng)然,編譯器還是會給出警告信息。這段代碼也是可以運行的,也會輸出信息,直到執(zhí)行到最后一個錯誤處“對空指針解引用時”才會發(fā)生段錯誤退出。
Rust
的語言特性為上述問題提供了解決方案,如下表所示
問題 解決方案 使用未初始化的內(nèi)存 編譯器禁止變量讀取未賦值變量 對空指針解引用 使用 Option 枚舉替代空指針 懸垂指針 生命周期標(biāo)識與編譯器檢查 緩沖區(qū)溢出 編譯器檢查,拒絕超越緩沖區(qū)邊界的數(shù)據(jù)訪問 非法釋放內(nèi)存 語言級的RAII機制,只有唯一的所有者才有權(quán)釋放內(nèi)存 多個變量修改同一塊內(nèi)存區(qū)域 允許多個變量借用所有權(quán),但是同一時間只允許一個可變借用 變量在多個線程中傳遞時的安全問題 對基本數(shù)據(jù)類型用 Sync和Send兩個Trait 標(biāo)識其線程安全特性,即能否轉(zhuǎn)移所有權(quán)或傳遞可變借用,把這作為基本事實。再利用泛型限定語法和 Trait impl 語法描述出類型線程安全的規(guī)則。編譯期間使用類似規(guī)則引擎的機制,基于基本事實和預(yù)定義規(guī)則為用戶代碼中的跨線程數(shù)據(jù)傳遞做推理檢查。
3.變量綁定與所有權(quán)的賦予 Rust
中為什么叫“變量綁定”而不叫“變量賦值'。我們先來看一段 C
代碼,以及對應(yīng)的 Rust
代碼。
C :
#include <iostream> int main() { int a = 1; std::cout << &a << std::endl; /* 輸出 0x62fe1c */ a = 2; std::cout << &a << std::endl; /* 輸出 0x62fe1c */ }
Rust:
fn main() { let a = 1; println!('a:{}',a); // 輸出1 println!('&a:{:p}',&a); // 輸出0x9cf974 //a=2; // 編譯錯誤,不可變綁定不能修改綁定的值 let a = 2; // 重新綁定 println!('&a:{:p}',&a); // 輸出0x9cfa14地址發(fā)生了變化 let mut b = 1; // 創(chuàng)建可變綁定 println!('b:{}',b); // 輸出1 println!('&b:{:p}',&b); // 輸出0x9cfa6c b = 2; println!('b:{}',b); // 輸出2 println!('&b:{:p}',&b); // 輸出0x9cfa6c地址沒有變化 let b = 2; // 重新綁定新值 println!('&b:{:p}',&b); // 輸出0x9cfba4地址發(fā)生了變化 }
我們可以看到,在 C
代碼中,變量 a
先賦值為 1,后賦值為 2,但其地址沒有發(fā)生變化。Rust
代碼中,a
是一個不可變綁定,執(zhí)行a=2
動作被編譯器拒絕。但是可以使用 let
重新綁定,但這時 a
的地址跟之前發(fā)生了變化,說明 a 被綁定到了另一個內(nèi)存地址。b
是一個可變綁定,可以使用b = 2
重新給它指向的內(nèi)存賦值,b
的地址不變。但使用 let
重新綁定后,b
指向了新的內(nèi)存區(qū)域。
可以看出,'賦值' 是將值寫入變量關(guān)聯(lián)的內(nèi)存區(qū)域,'綁定' 是建立變量與內(nèi)存區(qū)域的關(guān)聯(lián)關(guān)系,Rust
里,還會把這個內(nèi)存區(qū)域的所有權(quán)賦予這個變量。
不可變綁定的含義是:將變量綁定到一個內(nèi)存地址,并賦予所有權(quán),通過該變量只能讀取該地址的數(shù)據(jù),不能修改該地址的數(shù)據(jù)。對應(yīng)的,可變綁定就可以通過變量修改關(guān)聯(lián)內(nèi)存區(qū)域的數(shù)據(jù)。從語法上看,有 let
關(guān)鍵字是綁定, 沒有就是賦值。
這里我們能看出 Rust
與 C
的一個不同之處。C
里是沒有“綁定”概念的。Rust
的變量綁定概念是一個很關(guān)鍵的概念,它是所有權(quán)的起點。有了明確的綁定才有了所有權(quán)的歸屬,同時解綁定的時機也確定了資源釋放的時機。
所有權(quán)規(guī)則:
所有者離開作用域,值被丟棄(釋放/析構(gòu)) 作為所有者,它有如下權(quán)利:
4.所有權(quán)的轉(zhuǎn)移 所有者的重要權(quán)利之一就是“轉(zhuǎn)移所有權(quán)”。這引申出三個問題:
相關(guān)的語言概念如下圖。
圖 5 所有權(quán)轉(zhuǎn)移 為什么要轉(zhuǎn)移所有權(quán)? 我們知道,C/C /Rust 的變量關(guān)聯(lián)了某個內(nèi)存區(qū)域,但變量總會在表達式中進行操作再賦值給另一個變量,或者在函數(shù)間傳遞。實際上期望被傳遞的是變量綁定的內(nèi)存區(qū)域的內(nèi)容,如果這塊內(nèi)存區(qū)域比較大,復(fù)制內(nèi)存數(shù)據(jù)到給新的變量就是開銷很大的操作。所以需要把所有權(quán)轉(zhuǎn)移給新的變量,同時當(dāng)前變量放棄所有權(quán)。所以歸根結(jié)底,轉(zhuǎn)移所有權(quán)還是為了性能。
所有權(quán)轉(zhuǎn)移的時機總結(jié)下來有以下兩種情況:
位置表達式出現(xiàn)在值上下文時轉(zhuǎn)移所有權(quán) 變量跨作用域傳遞時轉(zhuǎn)移所有權(quán) 第一條規(guī)則是一個精確的學(xué)術(shù)表達,涉及到位置表達式,值表達式,位置上下文,值上下文等語言概念。它的簡單理解就是各種各樣的賦值行為。能明確指向某一個內(nèi)存區(qū)域位置的表達式是位置表達式,其它的都是值表達式。各種帶有賦值語義的操作的左側(cè)是位置上下文,右側(cè)是值上下文。
當(dāng)位置表達式出現(xiàn)在值上下文時,其程序語義就是要把這邊位置表達式所指向的數(shù)據(jù)賦給新的變量,所有權(quán)發(fā)生轉(zhuǎn)移。
第二條規(guī)則是“變量跨作用域時轉(zhuǎn)移所有權(quán)”。
圖上列舉出了幾種常見的跨作用域行為,能涵蓋大多數(shù)情況,也有簡單的示例代碼:
為什么變量跨作用域要轉(zhuǎn)移所有權(quán)?在 C/C
代碼中,是否轉(zhuǎn)移所有權(quán)是程序員自己隱式或顯式指定的。
試想,在 C/C
代碼中,函數(shù) Fun1
在棧上創(chuàng)建一個 類型 A
的實例 a
, 把它的指針 &a
傳遞給函數(shù) void fun2(A* param)
我們不會希望 fun2
釋放這個內(nèi)存,因為 fun1
返回時,棧上的空間會自動被釋放。
如果 fun1
在堆上創(chuàng)建 A
的實例 a
, 把它的指針 &a
傳遞給函數(shù) fun2(A* param)
,那么關(guān)于 a
的內(nèi)存空間的釋放,fun1
和 fun2
之間需要有個商量,由誰來釋放。fun1
可能期望由 fun2
來釋放,如果由 fun2
釋放,則 fun2
并不能判斷這個指針是在堆上還是棧上。歸根結(jié)底,還是誰擁有 a
指向內(nèi)存區(qū)的所有權(quán)問題。 C/C
在語言層面上并沒有強制約束。fun2
函數(shù)設(shè)計的時候,需要對其被調(diào)用的上下文做假定,在文檔中對對誰釋放這個變量的內(nèi)存做約定。這樣編譯器實際上很難對錯誤的使用方式給出警告。
Rust
要求變量在跨越作用域時明確轉(zhuǎn)移所有權(quán),編譯器可以很清楚作用域邊界內(nèi)外哪個變量擁有所有權(quán),能對變量的非法使用作出明確無誤的檢查,增加的代碼的安全性。
所有權(quán)轉(zhuǎn)移的方式有兩種:
移動語義-執(zhí)行所有權(quán)轉(zhuǎn)移 復(fù)制語義-不執(zhí)行轉(zhuǎn)移,只按位復(fù)制變量 這里我把 ”復(fù)制語義“定義為所有權(quán)轉(zhuǎn)移的方式之一,也就是說“不轉(zhuǎn)移”也是一種轉(zhuǎn)移方式。看起來很奇怪。實際上邏輯是一致的,因為觸發(fā)復(fù)制執(zhí)行的時機跟觸發(fā)轉(zhuǎn)移的時機是一致的。只是這個數(shù)據(jù)類型被打上了 Copy
標(biāo)簽 trait
, 在應(yīng)該執(zhí)行轉(zhuǎn)移動作的時候,編譯器改為執(zhí)行按位復(fù)制。
Rust
的標(biāo)準(zhǔn)庫中為所有基礎(chǔ)類型實現(xiàn)的 Copy Trait
。
這里要注意,標(biāo)準(zhǔn)庫中的
impl<T: ?Sized> Copy for &T {}
為所有引用類型實現(xiàn)了 Copy
, 這意味著我們使用引用參數(shù)調(diào)用某個函數(shù)時,引用變量本身是按位復(fù)制的。標(biāo)準(zhǔn)庫沒有為可變借用 &mut T
實現(xiàn)“Copy” Trait
, 因為可變借用只能有一個。后文講閉包捕獲變量的所有權(quán)時我們可以看到例子。
5.所有權(quán)的借用 變量擁有一個內(nèi)存區(qū)域所有權(quán),其所有者權(quán)利之一就是“出借所有權(quán)”。
與出借所有權(quán)相關(guān)的概念關(guān)系如圖 6
擁有所有權(quán)的變量借出其所有權(quán)有“引用”和“智能指針”兩種方式:
引用實際上也是指針,指向的是實際的內(nèi)存位置。
借用有兩個重要的安全規(guī)則:
代表借用的變量,其生命周期不能比被借用的變量(所有者)的生命周期長
同一個變量的可變借用只能有一個
第一條規(guī)則就是確保不出現(xiàn)“懸垂指針”的內(nèi)存安全問題。如果這條規(guī)則被違反,例如:變量 a
擁有存儲區(qū)域的所有權(quán),變量 b
是 a
的某種借用形式,如果 b
的生命周期比 a
長,那么 a
被析構(gòu)后存儲空間被釋放,而 b
仍然可以使用,則 b
就成為了懸垂指針。
第二條是不允許有兩個可變借用,避免出現(xiàn)數(shù)據(jù)一致性問題。
1 Struct Foo{v:i32} 2 fn main(){ 3 let mut f = Foo{v:10}; 4 let im_ref = &f; // 獲取不可變引用 5 let mut_ref = & mut f; // 獲取可變引用 6 //println!('{}',f.v); 7 //println!('{}',im_ref.v); 8 //println!('{}',mut_ref.v); 9 }
變量 f
擁有值的所有權(quán),im_ref
是其不可變借用,mut_ref
是其可變借用。以上代碼是可以編譯過去的,但是這幾個變量都沒有被使用,這種情況下編譯器并不禁止你同時擁有可變借用和不可變借用。最后的三行被注釋掉的代碼(6,7,8)使用了這些變量。打開一行或多行這些注釋的代碼,編譯器會報告不同形式的錯誤:
開放注釋行 編譯器報告 6 正確 7 第5行錯誤:不能獲得 f 的可變借用,因為已經(jīng)存在不可變借用 8 正確 6,7 第5行錯誤:不能獲得 f 的可變借用,因為已經(jīng)存在不可變借用 6,8 第6行錯誤:不能獲得 f 的不可變借用,因為已經(jīng)存在可變借用
對'借用' 的抽象表達
Rust
的核心包中有兩個泛型 trait
,core::borrow::Borrow 與 core::borrow::BorrowMut,可以用來表達'借用'的抽象含義,分別代表可變借用和不可變借用。前面提到,“借用”有多種表達形式 (&T,Box<T>,Rc<T> 等等)
,在不同的使用場景中會選擇合適的借用表達方式。它們的抽象形式就可以用 core::borrow::Borrow 來代表. 從類型關(guān)系上, Borrow
是'借用' 概念的抽象形式。從實際應(yīng)用上,某些場合我們希望獲得某個類型的“借用”,同時希望能支持所有可能的“借用”形式,Borrow Trait
就有用武之地。
Borrow 的定義如下:
pub trait Borrow<Borrowed: ?Sized> { fn borrow(&self) -> &Borrowed; }
它只有一個方法,要求返回指定類型的引用。
Borrow
的文檔中有提供例子
use std::borrow::Borrow; fn check<T: Borrow<str>>(s: T) { assert_eq!('Hello', s.borrow()); } fn main(){ let s: String = 'Hello'.to_string(); check(s); lets: &str = 'Hello'; check(s); }
check
函數(shù)的參數(shù)表示它希望接收一個 “str”類型的任何形式的“借用”,然后取出其中的值與 “Hello”進行比較。
標(biāo)準(zhǔn)庫中為 String
類型實現(xiàn)了 Borrow<str>
,代碼如下
impl Borrow<str> for String{ #[inline] fn borrow(&self) -> &str{ &self[..] } }
所以 String
類型可以作為 check
函數(shù)的參數(shù)。
從圖上可以看出,標(biāo)準(zhǔn)庫為所有類型 T
實現(xiàn)了 Borrow Trait
, 也為 &T
實現(xiàn)了 Borrow Trait
。
代碼如下 ,這如何理解。
impl<T: ?Sized> Borrow<T> for T { fn borrow(&self) -> &T { // 是 fn borrow(self: &Self)的縮寫,所以 self 的類型就是 &T self } } impl<T: ?Sized> Borrow<T> for &T { fn borrow(&self) -> &T { &**self } }
這正是 Rust
語言很有意思的地方,非常巧妙的體現(xiàn)了語言的一致性。既然 Borrow<T>
的方法是為了能獲取 T
的引用,那么類型 T
和 &T
當(dāng)然也可以做到這一點。在 Borrow for T
的實現(xiàn)中,
fn borrow(&self)->&T
是 fn borrow(self: &Self)->&T
的縮寫,所以 self
的類型就是 &T
,可以直接被返回。在 Borrow for &T
的實現(xiàn)中,fn borrow(&self)->&T
是 fn borrow(self: &Self)->&T
的縮寫,所以 self
的類型就是 &&T
, 需要被兩次解引用得到 T
, 再返回其引用。
智能指針 Box<T>
,Rc<T>
,Arc<T>
,都實現(xiàn)了 Borrow<T>
,其獲取 &T
實例的方式都是兩次解引用在取引用。Weak<T>
沒有實現(xiàn) Borrow<T>
, 它需要升級成 Rc<T>
才能獲取數(shù)據(jù)。
6.生命周期參數(shù) 變量的生命周期主要跟變量的作用域有關(guān),在大部分程序語言中都是隱式定義的。Rust
中能顯式聲明變量的生命周期參數(shù),這是非常獨特的設(shè)計,其語法特性在其他語言也是不太可能見到的。以下是生命周期概念相關(guān)的圖示。
生命周期參數(shù)的作用
生命周期參數(shù)的核心作用就是解決懸垂指針問題。就是讓編譯器幫助檢查變量的生命周期,防止出現(xiàn)變量指向的內(nèi)存區(qū)域被釋放后,變量仍然可以使用的問題。那么什么情況下會讓編譯器無法判斷生命周期,而必須引入一個特定語法來對生命周期進行標(biāo)識?
我們來看看最常見的懸垂指針問題,函數(shù)以引用方式返回函數(shù)內(nèi)部的局部變量:
struct V{v:i32} fn bad_fn() -> &V{ //編譯錯誤:期望一個命名的生命周期參數(shù) let a = V{v:10}; &a } let res = bad_fn();
這個代碼是一個典型的懸垂指針錯誤,a
是函數(shù)內(nèi)的局部變量,函數(shù)返回后 a
就被銷毀,把 a
的引用賦值給 res
,如果能執(zhí)行成功,res
綁定的就是未定義的值。
但編譯器并不是報告懸垂指針錯誤,而是說返回類型 &V
沒有指定生命周期參數(shù)。C
的類似代碼編譯器會給出懸垂指針的警告(警告內(nèi)容:局部變量的地址被返回了)。
那我們指定一個生命周期參數(shù)看看:
fn bad_fn<'a>() -> &'a V{ let a = V{v:10}; let ref_a = &a; ref_a //編譯錯誤:不能返回局部變量的引用 }
這次編譯器報告的是懸垂指針錯誤了。那么編譯器的分析邏輯是什么?
首先我們明確一下 'a 在這里的精確語義到底是什么?
函數(shù)將要返回的引用會代表一個內(nèi)存數(shù)據(jù),這個數(shù)據(jù)有其生命周期范圍,'a
參數(shù)是對這個生命周期范圍提出的要求。就像 &V
是對返回值類型提的要求類似,'a 是對返回值生命周期提的要求 。編譯器需要檢查的就是實際返回的數(shù)據(jù),其生命是否符合要求。
那么 'a 參數(shù)對返回值的生命周期到底提出了什么要求?
我們先區(qū)分一下'函數(shù)上下文'和“調(diào)用者上下文”,函數(shù)上下文是指函數(shù)體內(nèi)部的作用域范圍,調(diào)用者上下文是指該函數(shù)被調(diào)用的位置。上述的懸垂指針錯誤其實并不會影響函數(shù)上下文范圍的程序執(zhí)行,出問題的地方是調(diào)用者上下文拿到一個無效引用并使用時,會出現(xiàn)不可預(yù)測的錯誤。
函數(shù)返回的引用會在“調(diào)用者上下文”中賦予某個變量,如:
let res = bod_fn();
res
獲得了返回的引用, 函數(shù)內(nèi)的 ref_a
引用會按位復(fù)制給變量 res
(標(biāo)準(zhǔn)庫中 impl<T: ?Sized> Copy for &T {}
指定了此規(guī)則)res
會指向 函數(shù)內(nèi) res_a
同樣的數(shù)據(jù)。為了保證將來在調(diào)用者上下文不出懸垂指針,編譯器真正要確保的是 res
所指向的數(shù)據(jù)的生命周期,不短于 res
變量自己的生命周期。否則如果數(shù)據(jù)的生命周期短,先被釋放,res
就成為懸垂指針。
可以把這里的 'a
參數(shù)理解為調(diào)用者上下文中接收函數(shù)返回值的變量 res
的生命周期,那么 'a
對函數(shù)體內(nèi)部返回引用的要求是:返回引用所指代數(shù)據(jù)的生命周期不短于 'a ,也就是不短于調(diào)用者上下文接收返回值的變量的生命周期。
上述例子中函數(shù)內(nèi) ref_a
指代的數(shù)據(jù)生命周期就是函數(shù)作用域,函數(shù)返回前,數(shù)據(jù)被銷毀,生命周期小于調(diào)用者上下文的 res
, 編譯器根據(jù) 返回值的生命周期要求與實際返回值做比較,發(fā)現(xiàn)了錯誤。
實際上,返回的引用或者是靜態(tài)生命周期,或者是根據(jù)函數(shù)輸入的引用參數(shù)通過運算變換得來的,否則都是這個結(jié)果,因為都是對局部數(shù)據(jù)的引用。
靜態(tài)生命周期
看函數(shù)
fn get_str<'a>() -> &'a str { let s = 'hello'; s }
這個函數(shù)可以編譯通過,返回的引用雖然不是從輸入?yún)?shù)推導(dǎo),不過是靜態(tài)生命周期,可以通過檢查。
因為靜態(tài)生命周期可以理解為“無窮大”的語義,實際是跟進程的生命周期一致,也就是在程序運行期間始終有效。
Rust
的字符串字面量是存儲在程序代碼中,程序加載后在代碼空間,始終有效。可以通過一個簡單試驗驗證這一點:
let s1='Hello'; println!('&s1:{:p}', &s1);//&s1:0x9cf918 let s2='Hello'; println!('&s2:{:p}',&s2);//&s2:0x9cf978 //s1,s2是一樣的值但是地址不一樣,是兩個不同的引用變量 let ptr1: *const u8 = s1.as_ptr(); println!('ptr1:{:p}', ptr1);//ptr1:0x4ca0a0 let ptr2: *const u8 = s2.as_ptr(); println!('ptr2:{:p}', ptr2);//ptr2:0x4ca0a0
s1
,s2
的原始指針都指向同一個地址,說明編譯器為 'Hello' 字面量只保存了一份拷貝,所有引用都指向它。
get_str
函數(shù)中靜態(tài)生命周期長于返回值要求的'a
,所以是合法的。
如果把 get_str
改成
fn get_str<'a>() -> &'static str
即把對返回值生命周期的要求改為無窮大,那就只能返回靜態(tài)字符串引用了。
函數(shù)參數(shù)的生命周期
前面的例子為了簡單起見,沒有輸入?yún)?shù),這并不是一個典型的情況。大多數(shù)情況下,函數(shù)返回的引用是根據(jù)輸入的引用參數(shù)通過運算變換而來。比如下面的例子:
fn remove_prefix<'a>(content:&'a str,prefix:&str) -> &'a str{ if content.starts_with(prefix){ let start:usize = prefix.len(); let end:usize = content.len(); let sub = content.get(start..end).unwrap(); sub }else{ content } } let s = 'reload'; let sub = remove_prefix(&s0,'re'); println!('{}',sub); // 輸出: load
remove_prefix
函數(shù)從輸入的 content
字符串中判斷是否有 prefix
代表的前綴。如果有就返回 content
不包含前綴的切片,沒有就返回 content
本身。
無論如何這個函數(shù)都不會返回前綴 prefix
,所以 prefix
變量不需要指定生命周期。
函數(shù)兩個分支返回的都是通過 content
變量變換出來的,并作為函數(shù)的返回值。所以 content
必須標(biāo)注生命周期參數(shù),編譯器要根據(jù) content
的生命周期參數(shù)與返回值的要求進行比較,判斷是否符合要求。即:實際返回數(shù)據(jù)的生命周期,大于或等于返回參數(shù)要求的生命周期。
前面說到,我們把返回參數(shù)中指定的生命周期參數(shù) 'a
看做調(diào)用者上下文中接收返回值的變量的生命周期,在這個例子中就是字符串引用 sub
,那么輸入?yún)?shù)中的 'a 代表什么意思 ?
這在 Rust
語法設(shè)計上是一個很讓人困惑的地方,輸入?yún)?shù)和輸出參數(shù)的生命周期都標(biāo)志為 'a
,似乎是要求兩者的生命周期要求一致,但實際上并不是這樣。
我們先看看如果輸入?yún)?shù)的生命周期跟輸出參數(shù)期待的不一樣是什么情況,例如下面兩個例子:
fn echo<'a, 'b>(content: &'b str) -> &'a str { content //編譯錯誤:引用變量本身的生命周期超過了它的借用目標(biāo) } fn longer<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 }//編譯錯誤:生命周期不匹配 }
echo
函數(shù)輸入?yún)?shù)生命周期標(biāo)注為 'b
, 返回值期待的是 'a
.編譯器報錯信息是典型的“懸垂指針”錯誤。不過內(nèi)容似乎并不明確。編譯器指出查閱詳細信息 --explain E0312 ,這里的解釋是'借用內(nèi)容的生命周期與期待的不一致'。這個錯誤描述就與實際的錯誤情況是相符合的了。
longer
函數(shù)兩個參數(shù)分別具有生命周期 'a
和 'b
, 返回值期待 'a
,當(dāng)返回 s2
時,編譯器報告生命周期不匹配。把 longer
函數(shù)中的生命周期 'b
標(biāo)識為比 'a
長,就可以正確編譯了。
fn longer<'a, 'b: 'a>(s1: &'a str, s2: &'b str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 }//編譯通過 }
回到我們前面的問題,那么輸入?yún)?shù)中的 'a 代表什么意思 ?
我們知道編譯器在函數(shù)定義上下文中所做的生命周期檢查就是要確?!?span>實際返回數(shù)據(jù)的生命周期,大于或等于返參數(shù)要求的生命周期“。當(dāng)輸入?yún)?shù)給出與返回值一樣的生命周期參數(shù) 'a
時,實際上是人為地向編譯器保證:在調(diào)用者上下文中,實際給出的函數(shù)輸入?yún)?shù)的生命周期,不小于將來用于接收返回值的變量的生命周期。
當(dāng)有兩個生命周期參數(shù) 'a
'b
, 而 'b
大于 'a
,當(dāng)然 也保證了在調(diào)用者上下文 'b
代表的輸入?yún)?shù)生命周期也足夠長。
在函數(shù)定義中,編譯器并不知道將來實際調(diào)用這個函數(shù)的上下文是怎么樣的。生命周期參數(shù)相當(dāng)是函數(shù)上下文與調(diào)用者上下文之間關(guān)于參數(shù)生命周期的協(xié)議。
就像函數(shù)簽名中的類型聲明一樣,類型聲明約定了與調(diào)用者之間輸入輸出參數(shù)的類型,編譯器編譯函數(shù)時,會檢查函數(shù)體返回的數(shù)據(jù)類型與聲明的返回值是否一致。同樣對與參數(shù)與返回值的生命周期,函數(shù)也會檢查函數(shù)體中返回的變量生命周期與聲明的是否一致。
前面說的是編譯器在“函數(shù)定義上下文的生命周期檢查 ”機制,這只是生命周期檢查的一部分,還有另一部分就是“調(diào)用者上下文對生命周期的檢查 ”機制。兩者檢查的規(guī)則如下:
函數(shù)定義上下文的生命周期檢查:
函數(shù)簽名中返回值的生命周期標(biāo)注可以是輸入標(biāo)注的任何一個,只要保證由輸入?yún)?shù)推導(dǎo)出來的返回的臨時變量的生命周期,比函數(shù)簽名中返回值標(biāo)注的生命周期相等或更長。這樣保證了調(diào)用者上下文中,接收返回值的變量,不會因為輸入?yún)?shù)失效而成為懸垂指針。
調(diào)用者上下文對生命周期的檢查:
調(diào)用者上下文中,接收函數(shù)返回借用的變量 res
,其生命周期不能長于返回的借用的生命周期(實際是根據(jù)輸入借用參數(shù)推導(dǎo)出來的)。否則 res
會在輸入?yún)?shù)失效后成為懸垂指針。
前面 remove_prefix
函數(shù)編譯器已經(jīng)校驗合格,那么我們在調(diào)用者上下文中構(gòu)建如下例子
let res: &str; { let s = String::from('reload'); res = remove_prefix(&s, 're') //編譯錯誤:s 的生命周期不夠長 } println!('{}', res);
這個例子中 remove_prefix
被調(diào)用這一行,編譯器會報錯 “s 的生命周期不夠長”。代碼中的 大括號創(chuàng)建了一個新的詞法作用域,導(dǎo)致 res
的生命周期比大括號內(nèi)部的 s
更長。這不符合函數(shù)簽名中對生命周期的要求。函數(shù)簽名要求輸入?yún)?shù)的生命周期不短于返回值要求的生命周期。
結(jié)構(gòu)體定義中的生命周期
結(jié)構(gòu)體中有引用成員時,就會有潛在的懸垂指針問題,需要標(biāo)識生命周期參數(shù)來讓編譯器幫助檢查。
struct G<'a>{ m:&'a str} fn get_g() -> () { let g: G; { let s0 = 'Hi'.to_string(); let s1 = s0.as_str(); //編譯錯誤:借用值存活時間不夠長 g = G{ m: s1 }; } println!('{}', g.m); }
上面的例子中,結(jié)構(gòu)體 G
包含了引用成員,不指定生命周期參數(shù)是無法編譯的。函數(shù) get_g
演示了在使用者上下文中如何出現(xiàn)生命周期不匹配的情況。
結(jié)構(gòu)體的生命周期定義就是要保證在一個結(jié)構(gòu)體實例中,其引用成員的生命周期不短于結(jié)構(gòu)體實例自身的生命周期。否則如果結(jié)構(gòu)體實例存活期間,其引用成員的數(shù)據(jù)先被銷毀,那么訪問這個引用成員時就構(gòu)成了對懸垂指針的訪問。
實際上結(jié)構(gòu)體的生命周期參數(shù)可以和函數(shù)生命周期參數(shù)做類比,成員的生命周期相當(dāng)函數(shù)的輸入?yún)?shù)的生命周期,結(jié)構(gòu)體整體的生命周期相當(dāng)函數(shù)返回值的生命周期。這樣所有之前對函數(shù)生命周期參數(shù)的分析一樣可以適用。
如果結(jié)構(gòu)體有方法成員會返回引用參數(shù),方法同樣需要填寫生命周期參數(shù)。返回的引用來源可以是方法的輸入引用參數(shù),也可以是結(jié)構(gòu)體的引用成員。在做生命周期分析的時候,可以把“方法的輸入引用參數(shù)”和“結(jié)構(gòu)體的引用成員”都看做普通函數(shù)的輸入?yún)?shù),這樣前面對普通函數(shù)參數(shù)和返回值的生命周期分析方法可以繼續(xù)套用。
泛型的生命周期限定
前文說過生命周期參數(shù)跟類型限定很像,比如在代碼
fn longer<'a>(s1:&'a str, s2:&'a str) -> &'a str struct G<'a>{ m:&'a str }
中,'a
出現(xiàn)的位置參數(shù)類型旁邊,一個對參數(shù)的靜態(tài)類型做限定,一個對參數(shù)的動態(tài)時間做限定。'a
使用前需要先聲明,聲明的位置與模板參數(shù)的位置一樣,在 <>
括號內(nèi),也是用來放泛型的類型參數(shù)的地方。
那么,把類型換成泛型可以嗎,語義是什么?使用場景是什么?
我們看看代碼例子:
use std::cmp::Ordering; #[derive(Eq, PartialEq, PartialOrd, Ord)] struct G<'a, T:Ord>{ m: &'a T } #[derive(Eq, PartialEq, PartialOrd, Ord)] struct Value{ v: i32 } fn longer<'a, T:Ord>(s1: &'a T, s2: &'a T) -> &'a T { if s1 > s2 { s1 } else { s2 } } fn main(){ let v0 = Value{ v:12 }; let v1 = Value{ v:15 }; let res_v = longer(&v0, &v1); println!('{}', res_v.v);//15 let g0 = G{ m: &v0 }; let g1 = G{ m: &v1 }; let res_g = longer(&g0, &g1);//15 println!('{}', res_g.m.v); }
這個例子擴展了 longer
函數(shù),可以對任何實現(xiàn)了 Ord trait
的類型進行操作。 Ord
是核心包中的一個用于實現(xiàn)比較操作的內(nèi)置 trait
. 這里不細說明。longer
函數(shù)跟前一個版本比較,只是把 str
類型換成了泛型參數(shù) T
, 并給 T
增加了類型限定 T:Ord
.
結(jié)構(gòu)體 G
也擴展成可以容納泛型 T
,但要求 T
實現(xiàn)了 Ord trait
.
從代碼及執(zhí)行結(jié)果看,跟 把 T
當(dāng)成普通類型一樣,沒有什么特別,生命周期參數(shù)依然是他原來的語義。
但實際上 '&'a T
' 還隱含另一層語義:如果 T
內(nèi)部含有引用成員,那么其中的引用成員的生命周期要求不短于 T
實例的生命周期。
老規(guī)矩,我們來構(gòu)造一個反例。結(jié)構(gòu)體 G
內(nèi)部包含一個泛型的引用成員,我們將 G
用于 longer
函數(shù),但是讓 G
內(nèi)部的引用成員生命周期短于 G
。代碼如下:
fn main(){ let v0 = Value{ v:12 }; let v1_ref: &Value; // 將 v1 的引用定義在下面大括號之外,有意延長變量的生命周期范圍 let res_g: &G<Value>; { let v1 = Value{ v:15 }; v1_ref = &v1; //編譯錯誤:v1的生命周期不夠長。 let res_v = longer(&v0,v1_ref); println!('{}',res_v.v); } let g0 = G{ m:&v0 }; let g1 = G{ m:v1_ref }; // 這時候 v1_ref 已經(jīng)是懸垂指針 res_g = longer(&g0, &g1); println!('{}', res_g.m.v); }
變量 g1
自身的生命周期是滿足 longer
函數(shù)要求的,但是其內(nèi)部的引用成員,生命周期過短。
這個范例是在“調(diào)用者上下文”檢查時觸發(fā)的,對泛型參數(shù)的生命周期限定比較難設(shè)計出在“函數(shù)定義或結(jié)構(gòu)體定義上下文”觸發(fā)的范例。畢竟 T
只是類型指代,定義時還沒有具體類型。
實際上要把在 “struct G<'a,T>{m:&'a T}
中,T
的所有引用成員的生命周期不短于'a
”這個語義準(zhǔn)確表達,應(yīng)該寫成:
struct G<'a,T:'a>{m:&'a T}
因為 T:'a
才是這個語義的明確表述。但是第一種表達方式也是足夠的(我用反證法證明了這一點)。所以編譯器也接受第一種比較簡化的表達形式。
總而言之,泛型參數(shù)的生命周期限定是兩層含義,一層是泛型類型當(dāng)做一個普通類型時一樣的含義,一層是對泛型內(nèi)部引用成員的生命周期約束。
Trait 對象的生命周期
看如下代碼
trait Foo{} struct Bar{v:i32} struct Qux<'a>{m:&'a i32} struct Baz<'a,T>{v:&'a T} impl Foo for Bar{} impl<'a> Foo for Qux<'a>{} impl<'a,T> Foo for Baz<'a,T>{}
結(jié)構(gòu)體 Bar
,Qux
,Baz
都實現(xiàn)了 trait Foo
, 那么 &Foo
類型可以接受這三個結(jié)構(gòu)體的任何一個的引用類型。
我們把 &Foo
稱為 Trait
對象。
Trait
對象可以理解為類似其它面向?qū)ο笳Z言中,指向接口或基類的指針或引用。其它OO
語言指向基類的指針在運行時確定其實際類型。Rust
沒有類繼承,指向 trait
的指針或引用起到類似的效果,運行時被確定具體類型。所以編譯期間不知道大小。
Rust
的 Trait
不能有非靜態(tài)數(shù)據(jù)成員,所以 Trait
本身就不會出現(xiàn)引用成員的生命周期小于對象自身,所以 Trait
對象默認的生命周期是靜態(tài)生命周期。我們看下面三個函數(shù):
fn check0() -> &'static Foo { // 如果不指定 'static , 編譯器會報錯,要求指定生命周期命參數(shù), 并建議 'static const b:Bar = Bar{v:0}; &b } fn check1<'a>() -> &'a Foo { //如果不指定 'a , 編譯器會報錯 const b:Bar = Bar{v:0}; &b } fn check2(foo:&Foo) -> &Foo {//生命周期參數(shù)被省略,不要求靜態(tài)生命周期 foo } fn check3(foo:&'static Foo) -> &'static Foo { foo } fn main(){ let bar= Bar{v:0}; check2(&bar); //能編譯通過,說明 chenk2 的輸入輸出參數(shù)都不是靜態(tài)生命周期 //check3(&bar); //編譯錯誤:bar的生命周期不夠長 const bar_c:Bar =Bar{v:0}; check3(&bar_c); // check3 只能接收靜態(tài)參數(shù) }
check0
和 check1
說明將 Trait
對象的引用作為 函數(shù)參數(shù)返回時,跟返回其他引用類型一樣,都需要指定生命周期參數(shù)。函數(shù) check2
的生命周期參數(shù)只是被省略了(編譯器可以推斷),但這個函數(shù)里的 Trait
對象并不是靜態(tài)生命周期,這可以從 main
函數(shù)內(nèi)能成功執(zhí)行 check2(bar)
分析出來,因為 bar
不是靜態(tài)生命周期.
實際上在運行時,Trait
對象總會動態(tài)綁定到一個實現(xiàn)了該 Trait
的具體結(jié)構(gòu)體類型(如 Bar
,Qux
,Baz
等),這個具體類型的在其上下文中有它的生命周期,可以是靜態(tài)的,更多情況下是非靜態(tài)生命周期 'a
,那么 Trait
對象的生命周期也是 'a
.
結(jié)構(gòu)體或成員生命周期 Trait 對象生命周期 Foo 無 'static Bar 'a 'a Qux<'a>{m:&'a str} 'a 'a Baz<'a,T>{v:&'a T} 'a 'a
fn qux_update<'a>(qux: &'a mut Qux<'a>, new_value: &'a i32)->&'a Foo { qux.v = new_value; qux } let value = 100; let mut qux = Qux{v: &value}; let new_value = 101; let muted: &dyn Foo = qux_update(& mut qux, &new_value); qux_update 函數(shù)的智能指針版本如下: fn qux_box<'a>(new_value: &'a i32) -> Box<Foo 'a> { Box::new(Qux{v:new_value}) } let new_value = 101; let boxed_qux:Box<dyn Foo> = qux_box(&new_value);
返回的智能指針中,Box
裝箱的類型包含了引用成員,也需要給被裝箱的數(shù)據(jù)指定生命周期,語法形式是在被裝箱的類型位置增加生命周期參數(shù),用 ' ' 號連接。
這兩個版本的代碼其實都說明一個問題,就是 Trait
雖然默認是靜態(tài)生命周期,但實際上,其生命周期是由具體實現(xiàn)這個 Trait
的結(jié)構(gòu)體的生命周期決定,推斷方式跟之前敘述的函數(shù)參數(shù)生命周期并無太大區(qū)別。
7.智能指針的所有權(quán)與生命周期 如圖 6,在 Rust
中引用和智能指針都算是“指針”的一種形態(tài),所以他們都可以實現(xiàn) std::borrow::Borrow Trait
。一般情況下,我們對棧中的變量獲取引用,棧中的變量存續(xù)時間一般比較短,當(dāng)前的作用域退出時,作用域范圍內(nèi)的棧變量就會被回收。如果我們希望變量的生命周期能跨越當(dāng)前的作用域,甚至在線程之間傳遞,最好是把變量綁定的數(shù)據(jù)區(qū)域創(chuàng)建在堆上。
棧上的變量其作用域在編譯期間就是明確的,所以編譯器能夠確定棧上的變量何時會被釋放,結(jié)合生命周期參數(shù)生命,編譯器能找到絕大部分對棧上變量的錯誤引用。
堆上變量其的內(nèi)存管理比棧變量要復(fù)雜很多。在堆上分配一塊內(nèi)存之后,編譯器無法根據(jù)作用域來判斷這塊內(nèi)存的存活時間,必須由使用者顯式指定。C
語言中就是對于每一塊通過 malloc
分配到的內(nèi)存,需要顯式的使用 free
進行釋放。C
中是 new / delete
。但是什么時候調(diào)用 free
或 delete
就是一個難題。尤其當(dāng)代碼復(fù)雜,分配內(nèi)存的代碼和釋放內(nèi)存的代碼不在同一個代碼文件,甚至不在同一個線程的時候,僅僅靠人工跟蹤代碼的邏輯關(guān)系來維護分配與釋放就難免出錯。
智能指針的核心思想是讓系統(tǒng)自動幫我們決定回收內(nèi)存的時機。其主要手段就是“將內(nèi)存分配在堆上,但指向該內(nèi)存的指針變量本身是在棧上,這樣編譯器就可以捕捉指針變量離開作用域的時機。在這時決定內(nèi)存回收動作,如果該指針變量擁有內(nèi)存區(qū)的所有權(quán)就釋放內(nèi)存,如果是一個引用計數(shù)指針就減少計數(shù)值,計數(shù)為 0 就回收內(nèi)存 ”。
Rust
的 Box<T>
為獨占所有權(quán)指針,Rc<T>
為引用計數(shù)指針,但其計數(shù)過程不是線程安全的,Arc<T>
提供了線程安全的引用計數(shù)動作,可以跨線程使用。
我們看 Box<T>
的定義
pub struct Box<T: ?Sized>(Unique<T>); pub struct Unique<T: ?Sized>{ pointer: *const T, _marker: PhantomData<T>, }
Box
本身是一個元組結(jié)構(gòu)體,包裝了一個 Unique<T>
, Unique<T>
內(nèi)部有一個原生指針。
(注:Rust 最新版本的 Box 實現(xiàn)還可以通過泛型參數(shù)指定內(nèi)存分配器,讓用戶可以自己控制實際內(nèi)存的分配。還有為什么通過 Unique多層封裝,這涉及智能指針實現(xiàn)的具體問題,這里不詳述。)
Box
沒有實現(xiàn) Copy Trait
,它在所有權(quán)轉(zhuǎn)移時會執(zhí)行移動語意。
示例代碼:
Struct Foo {v:i32} fn inc(v:& mut Foo) -> &Foo {//省略了生命周期參數(shù) v.v = v.v 1; v } //返回Box指針不需要生命周期參數(shù),因為Box指針擁有了所有權(quán),不會成為懸垂指針 fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {//輸入?yún)?shù)和返回參數(shù)各經(jīng)歷一次所有權(quán)轉(zhuǎn)移 foo_ptr.v = foo_ptr.v 1; println!('ininc_ptr:{:p}-{:p}', &foo_ptr, &*foo_ptr); foo_ptr } fn main() { let foo_ptr1 = Box::new(Foo{v:10}); println!('foo_ptr1:{:p}-{:p}', &foo_ptr1, &*foo_ptr1); let mut foo_ptr2 = inc_ptr(foo_ptr1); //println!('{}',foo_ptr1.v);//編譯錯誤,f0_ptr所有權(quán)已經(jīng)丟失 println!('foo_ptr2:{:p}-{:p}', &foo_ptr2, &*foo_ptr2); inc(foo_ptr2.borrow_mut());//獲得指針內(nèi)數(shù)據(jù)的引用,調(diào)用引用版本的inc函數(shù) println!('{}',foo_ptr2.v); }
inc
為引用版本,inc_ptr
是指針版本。改代碼的輸出為:
foo_ptr1:0x8dfad0-0x93a5e0 in inc_ptr:0x8df960-0x93a5e0 foo_ptr2:0x8dfb60-0x93a5e0 12
可以看到 foo_ptr1
進入函數(shù) inc_ptr
時,執(zhí)行了一次所有權(quán)轉(zhuǎn)移,函數(shù)返回時又執(zhí)行了一次。所以三個 Box<Foo>
的變量地址都不一樣,但是它們內(nèi)部的數(shù)據(jù)地址都是一樣的,指向同一個內(nèi)存區(qū)。
Box
類型自身是沒有引用成員的,但是如果 T
包含引用成員,那么其相關(guān)的生命周期問題會是怎樣的?
我們把 Foo
的成員改成引用成員試試,代碼如下:
use std::borrow::BorrowMut; struct Foo<'a>{v:&'a mut i32} fn inc<'a>(foo:&'a mut Foo<'a>) ->&'a Foo<'a> {//生命周期不能省略 *foo.v=*foo.v 1; // 解引用后執(zhí)行加法操作 foo } fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {//輸入?yún)?shù)和返回參數(shù)各經(jīng)歷一次所有權(quán)轉(zhuǎn)移 *foo_ptr.v = *foo_ptr.v 1; / 解引用后執(zhí)行加法操作 println!('ininc_ptr:{:p}-{:p}', &foo_ptr, &*foo_ptr); foo_ptr } fn main(){ let mut value = 10; let foo_ptr1 = Box::new(Foo{v:& mut value}); println!('foo_ptr1:{:p}-{:p}', &foo_ptr1, &*foo_ptr1); let mut foo_ptr2 = inc_ptr(foo_ptr1); //println!('{}',foo_ptr1.v);//編譯錯誤,f0_ptr所有權(quán)已經(jīng)丟失 println!('foo_ptr2:{:p}-{:p}', &foo_ptr2, &*foo_ptr2); let foo_ref = inc(foo_ptr2.borrow_mut());//獲得指針內(nèi)數(shù)據(jù)的引用,調(diào)用引用版本的inc函數(shù) //println!('{}',foo_ptr2.v);//編譯錯誤,無法獲取foo_ptr2.v的不可變借用,因為已經(jīng)存在可變借用 println!('{}', foo_ref.v); }
引用版本的 inc
函數(shù)生命周期不能再省略了。因為返回 Foo
的引用時,有兩個生命周期值,一個是Foo
實例的生命周期,一個是 Foo
中引用成員的生命周期,編譯器無法做推斷,需要指定。但是智能指針版本 inc_ptr
函數(shù)的生命周期依然不用指定。Foo
的實例被智能指針包裝,生命周期由 Box
負責(zé)管理。
如果 Foo
是一個 Trait
,而實現(xiàn)它的結(jié)構(gòu)體有引用成員,那么 Box<Foo>
的生命周期會有什么情況。示例代碼如下:
trait Foo{ fn inc(&mut self); fn value(&self)->i32; } struct Bar<'a>{v:&'amuti32} impl<'a> Foo for Bar<'a> { fn inc(&mutself){ *(self.v)=*(self.v) 1 } fn value(&self)->i32{ *self.v } } fn inc(foo:& mut dyn Foo)->& dyn Foo {//生命周期參數(shù)被省略 foo.inc(); foo } fn inc_ptr(mut foo_ptr:Box<dyn Foo>) -> Box< dyn Foo> {//輸入?yún)?shù)和返回參數(shù)各經(jīng)歷一次所有權(quán)轉(zhuǎn)移 foo_ptr.inc(); foo_ptr } fn main() { }
引用版本和智能指針版本都沒生命周期參數(shù),可以編譯通過。不過 main
函數(shù)里是空的,也就是沒有使用這些函數(shù),只是定義編譯通過了。我先試試使用引用版本:
fn main(){ let mut value = 10; let mut foo1= Bar{v:& mut value}; let foo2 =inc(&mut foo1); println!('{}', foo2.value()); // 輸出 11 }
可以編譯通過并正常輸出。再試智能指針版本:
fn main(){ let mut value = 10; let foo_ptr1 = Box::new(Bar{v:&mut value}); //編譯錯誤:value生命周期太短 let mut foo_ptr2 = inc_ptr(foo_ptr1); //編譯器提示:類型轉(zhuǎn)換需要value為靜態(tài)生命周期 }
編譯失敗。提示的錯誤信息是 value
的生命周期太短,需要為 'static
。因為 Trait
對象( Box< dyn Foo>
)默認是靜態(tài)生命周期,編譯器推斷出返回數(shù)據(jù)的生命周期太短。去掉最后一行 inc_ptr
是可以正常編譯的。
如果將 inc_ptr
的定義加上生命周期參數(shù)上述代碼就可以編譯通過。修改后的 inc_ptr
如下:
fn inc_ptr<'a>(mut foo_ptr:Box<dyn Foo 'a>) -> Box<dyn Foo 'a> { foo_ptr.inc(); foo_ptr }
為什么指針版本不加生命周期參數(shù)會出錯,而引用版沒有生命周期參數(shù)卻沒有問題?
因為引用版是省略了生命周期參數(shù),完整寫法是:
fn inc<'a>(foo:&'a mut dyn Foo)->&'a dyn Foo { foo.inc(); foo }
8. 閉包與所有權(quán) 這里不介紹閉包的使用,只說與所有權(quán)相關(guān)的內(nèi)容。閉包與普通函數(shù)相比,除了輸入?yún)?shù),還可以捕獲上線文中的變量。閉包還支持一個 move
關(guān)鍵字,來強制轉(zhuǎn)移捕獲變量的所有權(quán)。
我們先來看 move
對輸入?yún)?shù)有沒有影響:
//結(jié)構(gòu) Value 沒有實現(xiàn)Copy Trait struct Value{x:i32} //沒有作為引用傳遞參數(shù),所有權(quán)被轉(zhuǎn)移 let mut v = Value{x:0}; let fun = |p:Value| println!('in closure:{}', p.x); fun(v); //println!('callafterclosure:{}',point.x);//編譯錯誤:所有權(quán)已經(jīng)丟失 //作為閉包的可變借用入?yún)ⅲ]包定義沒有move,所有權(quán)沒有轉(zhuǎn)移 let mut v = Value{x:0}; let fun = |p:&mut Value| println!('in closure:{}', p.x); fun(& mut v); println!('call after closure:{}', v.x); //可變借用作為閉包的輸入?yún)?shù),閉包定義增加move,所有權(quán)沒有轉(zhuǎn)移 let mut v = Value{x:0}; let fun = move |p:& mut Value| println!('in closure:{}', p.x); fun(& mut v); println!('call after closure:{}', v.x);
可以看出,變量作為輸入?yún)?shù)傳遞給閉包時,所有權(quán)轉(zhuǎn)移規(guī)則跟普通函數(shù)是一樣的,move 關(guān)鍵字對閉包輸入?yún)?shù)的引用形式不起作用,輸入?yún)?shù)的所有權(quán)沒有轉(zhuǎn)移。
對于閉包捕獲的上下文變量,所有權(quán)是否轉(zhuǎn)移就稍微復(fù)雜一些。
下表列出了 10 多個例子,每個例子跟它前后的例子都略有不同,分析這些差別,我們能得到更清晰的結(jié)論。
首先要明確被捕獲的變量是哪個,這很重要。比如例 8 中,ref_v
是 v
的不可變借用,閉包捕獲的是 ref_v
,那么所有權(quán)轉(zhuǎn)移的事情跟 v
沒有關(guān)系,v
不會發(fā)生與閉包相關(guān)的所有權(quán)轉(zhuǎn)移事件。
明確了被捕獲的變量后,是否轉(zhuǎn)移所有權(quán)受三個因素聯(lián)合影響:
變量被捕獲的方式(值,不可變借用,可變借用)
閉包是否有 move 限定
被捕獲變量的類型是否實現(xiàn)了 'Copy' Trait
是用偽代碼描述是否轉(zhuǎn)移所有權(quán)的規(guī)則如下:
if 捕獲方式 == 值傳遞 { if 被捕獲變量的類型實現(xiàn)了 'Copy' 不轉(zhuǎn)移所有權(quán) // 例 :9 else 轉(zhuǎn)移所有權(quán) // 例 :1 } } else { // 捕獲方式是借用 if 閉包沒有 move 限定 不轉(zhuǎn)移所有權(quán) // 例:2,3,6,10,12 else { // 有 move if 被捕獲變量的類型實現(xiàn)了 'Copy' 不轉(zhuǎn)移所有權(quán) // 例: 8 else 轉(zhuǎn)移所有權(quán) // 例: 4,5,7,11,13,14 } }
先判斷捕獲方式,如果是值傳遞,相當(dāng)于變量跨域了作用域,觸發(fā)轉(zhuǎn)移所有權(quán)的時機。move
是對借用捕獲起作用,要求對借用捕獲也觸發(fā)所有權(quán)轉(zhuǎn)移。是否實現(xiàn) 'Copy' 是最后一步判斷。前文提到,我們可以把 Copy Trait
限定的位拷貝語義當(dāng)成一種轉(zhuǎn)移執(zhí)行的方式。Copy Trait
不參與轉(zhuǎn)移時機的判定,只在最后轉(zhuǎn)移執(zhí)行的時候起作用。
例 1 和(例 2、例 3) 的區(qū)別在于捕獲方式不同。
(例 2、例 3) 和例 4 的區(qū)別在于 move 關(guān)鍵字。
例 6 和例 7 的區(qū)別 演示了 move 關(guān)鍵字對借用方式捕獲的影響。
例 8 說明了捕獲不可變借用變量,無論如何都不會轉(zhuǎn)移,因為不可變借用實現(xiàn)了 Copy.
例 8 和例 11 的區(qū)別就在于例 11 捕獲的 '不可變借用'沒有實現(xiàn) 'Copy' Trait 。
例 10 和例 11 是以“不可變借用的方式”捕獲了一個“可變借用變量”
例 12,13,14 演示了對智能指針的效果,判斷邏輯也是一致的。
C 11
的閉包需要在閉包聲明中顯式指定是按值還是按引用捕獲,Rust
不一樣。Rust
閉包如何捕獲上下文變量,不取決與閉包的聲明,取決于閉包內(nèi)部如何使用被捕獲的變量。實際上編譯器會盡可能以借用的方式去捕獲變量(例,除非實在不行,如例 1.)
這里刻意沒有提及閉包背后的實現(xiàn)機制,即 Fn
,FnMut
,FnOnce
三個 Trait
。因為我們只用閉包語法時是看不到編譯器對閉包的具體實現(xiàn)的。所以我們僅從閉包語法本身去判斷所有權(quán)轉(zhuǎn)移的規(guī)則。
9.多線程環(huán)境下的所有權(quán)問題 我們把前面的例 1 再改一下,上下文與閉包的實現(xiàn)都沒有變化,但是閉包在另一個線程中執(zhí)行。
let v = Value{x:1}; let child = thread::spawn(||{ // 編譯器報錯,要求添加 move 關(guān)鍵字 let p = v; println!('inclosure:{}',p.x) }); child.join();
這時,編譯器報錯,要求給閉包增加 move
關(guān)鍵字。也就是說,閉包作為線程的入口函數(shù)時,強制要求對被捕獲的上下文變量執(zhí)行移動語義。下面我們看看多線程環(huán)境下的所有權(quán)系統(tǒng)。
前面的討論都不涉及變量在跨線程間的共享,一旦多個線程可以訪問同一個變量時,情況又復(fù)雜了一些。這里有兩個問題,一個仍然是內(nèi)存安全問題,即“懸垂指針”等 5 個典型的內(nèi)存安全問題,另一個是線程的執(zhí)行順序?qū)е聢?zhí)行結(jié)果不可預(yù)測的問題。這里我們只關(guān)注內(nèi)存安全問題。
首先,多個線程如何共享變量?前面的例子演示了啟動新線程時,通過閉包捕獲上下文中的變量來實現(xiàn)多個線程共享變量。這是一個典型的形式,我們以這個形式為基礎(chǔ)來闡述多線程環(huán)境下的所有權(quán)問題。
我們來看例子代碼:
//結(jié)構(gòu) Value 沒有實現(xiàn)Copy Trait struct Value{x:i32} let v = Value{x:1}; let child = thread::spawn(move||{ let p = v; println!('in closure:{}',p.x) }); child.join(); //println!('{}',v.x);//編譯錯誤:所有權(quán)已經(jīng)丟失
這是前面例子的正確實現(xiàn),變量 v
被傳遞到另一個線程(閉包內(nèi)),執(zhí)行了所有權(quán)轉(zhuǎn)移
//閉包捕獲的是一個引用變量,無論如何也拿不到所有權(quán)。那么多線程環(huán)境下所有引用都可以這么傳遞嗎? let v = Value{x:0}; let ref_v = &v; let fun = move ||{ let p = ref_v; println!('inclosure:{}',p.x) }; fun(); println!('call after closure:{}',v.x);//編譯執(zhí)行成功
這個例子中,閉包捕獲的是一個變量的引用,Rust
的引用都是實現(xiàn)了 Copy Trait
,會被按位拷貝到閉包內(nèi)的變量 p.p
只是不可變借用,沒有獲得所有權(quán),但是變量 v
的不可變借用在閉包內(nèi)外進行了傳遞。那么把它改成多線程方式會如何呢?這是多線程下的實現(xiàn)和編譯器給出的錯誤提示:
let v:Value = Value{x:1}; let ref_v = &v; // 編譯錯誤:被借用的值 v0 生命周期不夠長 let child = thread::spawn(move||{ let p = ref_v; println!('in closure:{}',p.x) }); // 編譯器提示:參數(shù)要求 v0 被借用時為 'static 生命周期 child.join();
編譯器的核心意思就是 v
的生命周期不夠長。當(dāng) v
的不可變借用被傳遞到閉包中,并在另一個線程中使用時,主線程繼續(xù)執(zhí)行, v
隨時可能超出作用域范圍被回收,那么子線程中的引用變量就變成了懸垂指針。如果 v
為靜態(tài)生命周期,這段代碼就可以正常編譯執(zhí)行。即把第一行改為:
const v:Value = Value{x:1};
當(dāng)然只能傳遞靜態(tài)生命周期的引用實際用途有限,多數(shù)情況下我們還是希望能把非靜態(tài)的數(shù)據(jù)傳遞給另一個線程??梢圆捎?nbsp;Arc<T>
來包裝數(shù)據(jù)。 Arc<T>
是引用計數(shù)的智能指針,指針計數(shù)的增減操作是線程安全的原子操作,保證計數(shù)的變化是線程安全的。
//線程安全的引用計數(shù)智能指針Arc可以在線程間傳遞 let v1 = Arc::new(Value{x:1}); let arc_v = v1.clone(); let child = thread::spawn(move||{ let p = arc_v; println!('Arc<Value>in closure:{}',p.x) }); child.join(); //println!('Arc<Value> out of closure:{}',arc_v.x);//編譯錯誤,克隆出來的指針變量的所有權(quán)丟失
如果把上面的 Arc<T>
換成 Rc<T>
,編譯器會報告錯誤,說'Rc<T>
不能在線程間安全的傳遞'。
通過上面的例子我們可以總結(jié)出來一點,因為閉包定義中的 move
關(guān)鍵字,以閉包啟動新線程時,被閉包捕獲的變量本身的所有權(quán)必然會發(fā)生轉(zhuǎn)移。無論捕獲的變量是 '值變量'還是引用變量或智能指針(上述例子中 v
,ref_v
,arc_v
本身的所有權(quán)被轉(zhuǎn)移)。但是對于引用或指針,它們所指代的數(shù)據(jù)的所有權(quán)并不一定被轉(zhuǎn)移。
那么對于上面的類型 struct Value{x:i32}
, 它的值可以在多個線程間傳遞 (轉(zhuǎn)移所有權(quán)),它的多個不可變借用可以在多個線程間同時存在 。同時 &Value
和 Arc<Value>
可以在多個線程間傳遞(轉(zhuǎn)移引用變量或指針變量自身的所有權(quán)),但是 Rc<T>
不行。
要知道,Rc<T>
和 Arc<T>
只是 Rust
標(biāo)準(zhǔn)庫(std
)實現(xiàn)的,甚至不在核心庫(core
)里。也就是說,它們并不是 Rust
語言機制的一部分。那么,編譯器是如何來判斷 Arc 可以安全的跨線程傳遞,而 Rc 不行呢?
Rust
核心庫 的 marker.rs
文件中定義了兩個標(biāo)簽 Trait
:
pub unsafe auto trait Sync{} pub unsafe auto trait Send{}
標(biāo)簽 Trait
的實現(xiàn)是空的,但編譯器會分析某個類型是否實現(xiàn)了這個標(biāo)簽 Trait
.
如果一個類型 T
實現(xiàn)了“Sync ”,其含義是 T
可以安全的通過引用可以在多個線程間被共享。 如果一個類型 T
實現(xiàn)了“Send ”,其含義是 T
可以安全的跨線程邊界被傳遞。 那么上面的例子中的類型,Value
,&Value
,Arc<Value>
類型一定都實現(xiàn)了“Send
”Trait
. 我們看看如何實現(xiàn)的。
marker.rs
文件還定義了兩條規(guī)則:
unsafe impl<T:Sync ?Sized> Send for &T{} unsafe impl<T:Send ?Sized> Send for & mut T{}
其含義分別是:
如果類型 T 實現(xiàn)了“Sync ”,則自動為類型 &T
實現(xiàn)“Send ”. 如果類型 T 實現(xiàn)了“Send ”,則自動為類型 &mut T
實現(xiàn)“Send ”. 這兩條規(guī)則都可以直觀的理解。比如:對第一條規(guī)則 T
實現(xiàn)了 “Sync ”, 意味則可以在很多個線程中出現(xiàn)同一個 T
實例的 &T
類型實例。如果線程 A
中先有 &T
實例,線程 B
中怎么得到 &T
的實例呢?必須要有在線程 A
中通過某種方式 send
過來,比如閉包的捕獲上下文變量。而且 &T
實現(xiàn)了 'Copy
' Trait
, 不會有所有權(quán)風(fēng)險,數(shù)據(jù)是只讀的不會有數(shù)據(jù)競爭風(fēng)險,非常安全。邏輯上也是正確的。那為什么還會別標(biāo)記為 unsafe ? 我們先把這個問題暫時擱置,來看看為智能指針設(shè)計的另外幾條規(guī)則。
impl <T:?Sized>!marker::Send for Rc<T>{} impl <T:?Sized>!marker::Sync for Rc<T>{} impl<T:?Sized>!marker::Send for Weak<T>{} impl<T:?Sized>!marker::Sync for Weak<T>{} unsafe impl<T:?Sized Sync Send>Send for Arc<T>{} unsafe impl<T:?Sized Sync Send>Sync for Arc<T>{}
這幾條規(guī)則明確指定 Rc<T>
和 Weak<T>
不能實現(xiàn) “Sync ”和 “Send ”。
同時規(guī)定如果類型 T
實現(xiàn)了 “Sync ”和 “Send ”,則自動為 Arc<T>
實現(xiàn) “Sync ”和 “Send ”。Arc<T>
對引用計數(shù)增減是原子操作,所以它的克隆體可以在多個線程中使用(即可以為 Arc<T>
實現(xiàn)”Sync ”和“Send ”),但為什么其前提條件是要求 T
也要實現(xiàn)'Sync ”和 “Send ”呢。
我們知道,Arc<T>
實現(xiàn)了 std::borrow
,可以通過 Arc<T>
獲取 &T
的實例,多個線程中的 Arc<T>
實例當(dāng)然也可以獲取到多個線程中的 &T
實例,這就要求 T
必須實現(xiàn)“Sync ”。Arc<T>
是引用計數(shù)的智能指針,任何一個線程中的 Arc<T>
的克隆體都有可能成為最后一個克隆體,要負責(zé)內(nèi)存的釋放,必須獲得被 Arc<T>
指針包裝的 T
實例的所有權(quán),這就要求 T
必須能跨線程傳遞,必須實現(xiàn) “Send ”。
Rust
編譯器并沒有為 Rc<T>
或 Arc<T>
做特殊處理,甚至在語言級并不知道它們的存在,編譯器本身只是根據(jù)類型是否實現(xiàn)了 “Sync ”和 “Send ”標(biāo)簽來進行推理。實際上可以認為編譯器實現(xiàn)了一個檢查變量跨線程傳遞安全性的規(guī)則引擎,編譯器為基本類型直接實現(xiàn) “Sync ”和 “Send ”,這作為“公理”存在,然后在標(biāo)準(zhǔn)庫代碼中增加一些“定理”,也就是上面列舉的那些規(guī)則。用戶自己實現(xiàn)的類型可以自己指定是否實現(xiàn) “Sync ”和 “Send ”,多數(shù)情況下編譯器會根據(jù)情況默認選擇是否實現(xiàn)。代碼編譯時編譯器就可以根據(jù)這些公理和規(guī)則進行推理。這就是 Rust
編譯器支持跨線程所有權(quán)安全的秘密。
對于規(guī)則引擎而言,'公理'和'定理'是不言而喻無需證明的,由設(shè)計者自己聲明,設(shè)計者自己保證其安全性,編譯器只保證只要定理和公理沒錯誤,它的推理也沒錯誤。所以的'公理'和'定理'都標(biāo)注為 unsafe
,提醒聲明著檢查其安全性,用戶也可以定義自己的'定理',有自己保證安全。反而否定類規(guī)則 (實現(xiàn) !Send
或 !Sync
)不用標(biāo)注為 unsafe
, 因為它們直接拒絕了變量跨線程傳遞,沒有安全問題。
當(dāng)編譯器確定 “Sync ”和 “Send ”適合某個類型時,會自動為其實現(xiàn)此。
比如編譯器默認為以下類型實現(xiàn)了 Sync
:
[u8] 和 [f64] 這樣的基本類型都是 [Sync],
包含它們的簡單聚合類型(如元組、結(jié)構(gòu)和名號)也是[Sync] 。
'不可變' 類型(如 &T)
具有簡單繼承可變性的類型,如 Box 、Vec
大多數(shù)其他集合類型(如果泛型參數(shù)是 [Sync],其容器就是 [Sync]。
用戶也可以手動使用 unsafe
的方式直接指定。
下圖是與跨線程所有權(quán)相關(guān)的概念和類型的 UML
圖。