一区二区三区日韩精品-日韩经典一区二区三区-五月激情综合丁香婷婷-欧美精品中文字幕专区

分享

圖解 Rust 所有權(quán)與生命周期

 新用戶82165308 2022-08-11 發(fā)布于廣東

后臺回復(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 的局部變量。這樣如果找不到 BA 是無法編譯通過的。

關(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)存地址。

這就引出三個問題:

  1. 內(nèi)存的不正確訪問引發(fā)的內(nèi)存安全問題
  2. 由于多個變量指向同一塊內(nèi)存區(qū)域?qū)е碌臄?shù)據(jù)一致性問題
  3. 由于變量在多個線程中傳遞,導(dǎo)致的數(shù)據(jù)競爭的問題

由第一個問題引發(fā)的內(nèi)存安全問題一般有 5 個典型情況:

  • 使用未初始化的內(nèi)存
  • 對空指針解引用
  • 懸垂指針(使用已經(jīng)被釋放的內(nèi)存)
  • 緩沖區(qū)溢出
  • 非法釋放內(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)利:

  • 控制資源的釋放
  • 出借所有權(quán)
  • 轉(zhuǎn)移所有權(quán)

4.所有權(quán)的轉(zhuǎn)移

所有者的重要權(quán)利之一就是“轉(zhuǎn)移所有權(quán)”。這引申出三個問題:

  1. 為什么要轉(zhuǎn)移?
  2. 什么時候轉(zhuǎn)移?
  3. 什么方式轉(zhuǎ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é)下來有以下兩種情況:

  1. 位置表達式出現(xiàn)在值上下文時轉(zhuǎn)移所有權(quán)
  2. 變量跨作用域傳遞時轉(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ù)情況,也有簡單的示例代碼:

  • 變量被花括號內(nèi)使用
  • match 匹配
  • if let 和 While let
  • 移動語義函數(shù)參數(shù)傳遞
  • 閉包捕獲移動語義變量
  • 變量從函數(shù)內(nèi)部返回

為什么變量跨作用域要轉(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)移的方式有兩種:

  1. 移動語義-執(zhí)行所有權(quán)轉(zhuǎn)移
  2. 復(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)有“引用”和“智能指針”兩種方式:

  1. 引用(包含可變借用和不可變借用)
  2. 智能指針
    • 獨占式智能指針 Box<T>

    • 非線程安全的引用計數(shù)智能指針 Rc<T>

    • 線程安全的引用計數(shù)智能指針 Arc<T>

    • 弱指針 Weak<T>

引用實際上也是指針,指向的是實際的內(nèi)存位置。

借用有兩個重要的安全規(guī)則:

  1. 代表借用的變量,其生命周期不能比被借用的變量(所有者)的生命周期長

  2. 同一個變量的可變借用只能有一個

第一條規(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)合影響:

  1. 變量被捕獲的方式(值,不可變借用,可變借用)

  2. 閉包是否有 move 限定

  3. 被捕獲變量的類型是否實現(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 ,&ValueArc<Value> 類型一定都實現(xiàn)了“SendTrait. 我們看看如何實現(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)了 'CopyTrait, 不會有所有權(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 圖。

    本站是提供個人知識管理的網(wǎng)絡(luò)存儲空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點。請注意甄別內(nèi)容中的聯(lián)系方式、誘導(dǎo)購買等信息,謹防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請點擊一鍵舉報。
    轉(zhuǎn)藏 分享 獻花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多

    久久精品亚洲精品一区| 日韩欧美国产精品中文字幕| 欧美一级特黄大片做受大屁股| 亚洲另类欧美综合日韩精品 | 亚洲成人精品免费在线观看 | 亚洲一区二区三区福利视频| 儿媳妇的诱惑中文字幕| 绝望的校花花间淫事2| 黄片在线免费看日韩欧美| 91超精品碰国产在线观看| 国产精品视频久久一区| 国产av一区二区三区久久不卡| 在线观看视频国产你懂的| 欧美日韩国产的另类视频| 色婷婷视频在线精品免费观看| 草草视频精品在线观看| 久久99亚洲小姐精品综合| 国产成人精品国产亚洲欧洲| 精品国产成人av一区二区三区| 极品少妇嫩草视频在线观看| 在线免费看国产精品黄片| 欧美不卡一区二区在线视频| 欧美久久一区二区精品| 高清亚洲精品中文字幕乱码| 国产精品福利精品福利| 日韩特级黄色大片在线观看| 99久久国产综合精品二区 | 久热香蕉精品视频在线播放| 免费在线观看欧美喷水黄片| 日韩中文字幕视频在线高清版| 日本最新不卡免费一区二区| 日韩高清一区二区三区四区| 亚洲熟女国产熟女二区三区| 91欧美亚洲精品在线观看| 亚洲熟妇中文字幕五十路| 老司机精品视频免费入口| 日本高清一道一二三区四五区| 护士又紧又深又湿又爽的视频| 懂色一区二区三区四区| 午夜精品福利视频观看| 亚洲专区一区中文字幕|