返回文章列表

Rust生命週期與參照安全

Rust 的生命週期與借用檢查機制確保記憶體安全,避免懸掛指標和資料競爭。文章探討生命週期引數的運用、分享與變異規則、以及與 C 語言 const 指標的比較,並解析 Rust 如何透過所有權和借用系統管理物件生命週期,避免物件海洋問題,最後介紹 Rust 的表示式導向特性。

程式語言 系統程式設計

Rust 的生命週期特性是其確保記憶體安全的重要機制,它避免了懸掛指標的產生,並允許在編譯時期就檢查出潛在的記憶體錯誤。這與 C/C++ 等語言的指標管理方式有著顯著區別,Rust 的編譯器可以強制執行分享與變異的規則,從而防止資料競爭。生命週期引數的使用,讓編譯器能追蹤參照的有效範圍,確保參照不會指向已釋放的記憶體。而分享與變異的限制,雖然看似嚴格,卻能有效避免多執行緒環境下的資料競爭問題,提升程式碼的安全性與穩定性。Rust 的所有權系統鼓勵單向資料流,避免物件之間過於複雜的相互參照,降低系統的耦合度。最後,Rust 作為一種表示式導向的語言,簡化了程式碼結構,並提供了更強大的表達能力。

生命週期與參照安全

在 Rust 程式設計中,生命週期(lifetime)是一個重要的概念,它關係到參照(reference)的安全性和有效性。生命週期描述了某個參照的有效範圍,確保參照不會在所指向的資料被釋放後仍然存在,從而避免了懸掛指標(dangling pointer)的問題。

明確的生命週期引數

Rust 要求包含參照的型別必須有明確的生命週期引數。這是因為 Rust 需要確保參照的有效性,避免懸掛指標。例如,假設有一個解析函式 parse_record,它接受一個位元組片段並傳回一個包含解析結果的結構 Record

fn parse_record<'i>(input: &'i [u8]) -> Record<'i> { ... }

從這個函式的簽名中,我們可以看出 Record 的生命週期與輸入 input 的生命週期相同,這意味著 Record 中的任何參照都必須指向 input 緩衝區中的資料。

內容解密:

  1. 生命週期引數 'i:表示 input 的生命週期,同時也是 Record 的生命週期。
  2. 函式簽名:明確指出 Record 的生命週期與 input 相關聯。

獨立的生命週期引數

當一個結構包含多個參照時,為每個參照指定獨立的生命週期引數是非常重要的。例如:

struct S<'a> {
    x: &'a i32,
    y: &'a i32
}

在這種定義下,xy 的生命週期相同,這可能會導致一些問題。考慮以下程式碼:

let x = 10;
let r;
{
    let y = 20;
    {
        let s = S { x: &x, y: &y };
        r = s.x;
    }
}

這段程式碼實際上是安全的,但 Rust 編譯器會報錯,因為它無法找到一個同時滿足 s.xs.y 生命週期的合適範圍。

內容解密:

  1. xy 使用相同的生命週期 'a:這導致 Rust 需要找到一個同時滿足 s.xs.y 的生命週期。
  2. 修改結構定義:為每個參照指定獨立的生命週期引數可以解決這個問題。
struct S<'a, 'b> {
    x: &'a i32,
    y: &'b i32
}

這樣,s.xs.y 的生命週期就變得獨立,Rust 可以更容易地確保程式碼的安全性。

省略生命週期引數

在某些情況下,Rust 允許省略生命週期引數。例如,當函式不傳回任何參照或不包含需要生命週期引數的型別時,可以省略生命週期引數。即使傳回參照,如果函式引數中只有一個生命週期,那麼 Rust 也會假設傳回值的生命週期與該引數相同。

內容解密:

  1. 省略生命週期引數的條件:當函式不傳回參照或只有一個輸入引數具有生命週期。
  2. 單一生命週期的情況:如果只有一個輸入引數具有生命週期,那麼 Rust 會假設傳回值的生命週期與該引數相同。

分享與變異的衝突

在Rust中,參照(reference)的使用有一套嚴格的規則,以確保記憶體安全。這些規則主要圍繞著分享(sharing)和變異(mutation)之間的衝突。

變數被移動的情況

考慮以下程式碼:

let v = vec![4, 8, 19, 27, 34, 10];
let r = &v;
let aside = v; // 將向量移動到 aside
r[0]; // 錯誤:使用了現在未初始化的 `v`

內容解密:

  1. let v = vec![4, 8, 19, 27, 34, 10];:建立一個名為 v 的向量,包含指定的元素。
  2. let r = &v;:建立一個對 v 的分享參照 r
  3. let aside = v;:將 v 的所有權移動到 aside,此時 v 變成未初始化狀態。
  4. r[0];:嘗試透過 r 存取 v 的第一個元素,但由於 v 已被移動,其內容不再有效。

這個例子展示了當一個值被移動後,其原有的參照將變成懸掛指標(dangling pointer)。Rust 編譯器會捕捉到這個錯誤,並報告 v 在被借用期間不能被移動。

修改集合時的問題

另一個問題出現在修改集合(如向量)時,如果有參照指向該集合的元素:

fn extend(vec: &mut Vec<f64>, slice: &[f64]) {
    for elt in slice {
        vec.push(*elt);
    }
}

let mut wave = Vec::new();
let head = vec![0.0, 1.0];
let tail = [0.0, -1.0];
extend(&mut wave, &head); 
extend(&mut wave, &tail); 
assert_eq!(wave, vec![0.0, 1.0, 0.0, -1.0]);

extend(&mut wave, &wave); // 這裡會出錯

內容解密:

  1. extend 函式接受一個可變的向量參照和一個切片參照,將切片中的元素新增到向量中。
  2. 在第一次呼叫 extend(&mut wave, &wave); 時,問題出現了,因為 wave 同時被可變地借用和分享借用。
  3. 當向量需要重新分配記憶體時,原有的切片參照將指向無效的記憶體區域,導致懸掛指標。

Rust 編譯器同樣會捕捉到這個錯誤,並報告不能同時以可變和不可變的方式借用 wave

Rust 的分享與變異規則

Rust 的借用檢查器強制執行以下規則:

  • 分享存取是唯讀存取:當一個值被分享參照借用時,它是唯讀的。在分享參照的生命週期內,不能透過其他路徑修改它或其可達的值。
  • 可變存取是獨佔存取:當一個值被可變參照借用時,只能透過該可變參照存取它。在可變參照的生命週期內,不能有其他路徑存取該值或其可達的值。

這些規則確保了記憶體安全,避免了懸掛指標和資料競爭等問題。

分享與變異的限制

在 Rust 中,當我們借用一個值的參考時,會對該值及其相關聯的值造成一定的限制。這些限制確保了參考的有效性,避免了諸如資料競爭等問題。

借用規則

Rust 的借用規則規定,當一個值被借用時,會對該值的存取造成限制。具體來說:

  • 當一個值被分享借用(&T)時,該值的路徑是唯讀的,其他程式碼無法修改該值。
  • 當一個值被可變借用(&mut T)時,該值的路徑是完全無法存取的,其他程式碼無法讀取或修改該值。

以下是一個簡單的例子,展示了這些規則:

let mut x = 10;
let r1 = &x;
let r2 = &x; // ok: 多個分享借用是允許的
x += 10; // error: 無法對 `x` 進行指定,因為它被借用了
let m = &mut x; // error: 無法將 `x` 借用為可變,因為它已經被借用為不可變

同樣地,對於可變借用,也有類別似的限制:

let mut y = 20;
let m1 = &mut y;
let m2 = &mut y; // error: 無法多次將 `y` 借用為可變
let z = y; // error: 無法使用 `y`,因為它被可變借用了

內容解密:

  1. let mut x = 10;:宣告一個可變變數 x 並初始化為 10。
  2. let r1 = &x;let r2 = &x;:對 x 進行分享借用,產生兩個不可變參考 r1r2
  3. x += 10;:嘗試修改 x,但由於 x 被分享借用,編譯器會報錯。
  4. let m = &mut x;:嘗試對 x 進行可變借用,但由於 x 已經被分享借用,編譯器會報錯。

再借用

Rust 允許從一個參考再借用出新的參考,但有一些限制:

let mut w = (107, 109);
let r = &w;
let r0 = &r.0; // ok: 從分享參考再借用分享參考
let m1 = &mut r.1; // error: 無法從分享參考再借用可變參考

let mut v = (136, 139);
let m = &mut v;
let m0 = &mut m.0; // ok: 從可變參考再借用可變參考
*m0 = 137;
let r1 = &m.1; // ok: 從可變參考再借用分享參考,且不與 m0 重疊
v.1; // error: 透過其他路徑存取仍然被禁止

內容解密:

  1. let r0 = &r.0;:從分享參考 r 再借用出新的分享參考 r0,這是允許的。
  2. let m1 = &mut r.1;:嘗試從分享參考 r 再借用出可變參考,這是不允許的,因為分享參考不能被修改。
  3. let m0 = &mut m.0;:從可變參考 m 再借用出新的可變參考 m0,這是允許的,並且可以對 *m0 進行指定。
  4. let r1 = &m.1;:從可變參考 m 再借用出分享參考 r1,這是允許的,且與 m0 不重疊。

為什麼這些限制是有用的

這些限制看似嚴格,但它們有效地防止了一類別常見的錯誤,例如資料競爭、迭代器失效等。在 C++ 或其他語言中,這些問題可能導致未定義行為或執行時錯誤。

例如,在 C++ 中,以下程式碼可能導致檔案描述符被關閉後再次使用:

struct File {
    int descriptor;
    File(int d) : descriptor(d) { }
    File& operator=(const File &rhs) {
        close(descriptor);
        descriptor = dup(rhs.descriptor);
    }
};

File f(open("foo.txt", ...));
f = f; // 糟糕!檔案描述符被關閉後再次使用

而在 Rust 中,相應的程式碼會被編譯器拒絕:

struct File {
    descriptor: i32,
}

fn clone_from(this: &mut File, rhs: &File) {
    close(this.descriptor);
    this.descriptor = dup(rhs.descriptor);
}

let mut f = new_file(open("foo.txt", ...));
clone_from(&mut f, &f); // 編譯錯誤:無法將 `f` 借用為不可變,因為它已經被借用為可變

內容解密:

  1. C++ 版本的程式碼在自我指定時會關閉檔案描述符,然後嘗試複製它,這會導致未定義行為。
  2. Rust 版本的程式碼同樣會在自我指定時出現問題,但編譯器會捕捉這個錯誤,防止執行時錯誤的發生。

這些規則確保了 Rust 程式的記憶體安全和執行緒安全,使得開發者可以寫出更可靠、更高效的程式碼。特別是在並發程式設計中,這些規則有效地防止了資料競爭,使得 Rust 成為編寫並發程式的理想語言。

Rust 的分享參照與 C 的指向 const 的指標

乍看之下,Rust 的分享參照似乎與 C 和 C++ 中指向 const 值的指標十分相似。然而,Rust 對分享參照的規則要嚴格得多。舉例來說,請考慮以下 C 程式碼:

int x = 42; // 非 const 的 int 變數
const int *p = &x; // 指向 const int 的指標
assert(*p == 42);
x++; // 直接修改變數
assert(*p == 43); // 「常數」參照值已改變

p 是 const int * 型別,這意味著你不能透過 p 本身修改其參照物件:(*p)++ 是被禁止的。但是,你也可以直接將參照物件視為 x(非 const),並透過這種方式改變其值。C 語言家族中的 const 關鍵字有其用途,但它並非真正的常數。

在 Rust 中,分享參照在其生命週期結束前禁止對其參照物件的所有修改:

let mut x = 42; // 非 const 的 i32 變數
let p = &x; // 指向 i32 的分享參照
assert_eq!(*p, 42);
x += 1; // 錯誤:無法對 x 指定,因為它已被借用
assert_eq!(*p, 42); // 如果你移除指定陳述式,這是正確的

內容解密:

  • let mut x = 42; 定義了一個名為 x 的可變 i32 變數,並初始化為 42。
  • let p = &x; 建立了一個對 x 的分享參照 p。
  • assert_eq!(*p, 42); 檢查 p 所參照的值是否為 42,如果不是則觸發斷言錯誤。
  • x += 1; 試圖將 x 的值加 1,但由於 x 已被借用(被 p 參照),因此編譯器會報錯。
  • 為了確保某個值是常數,我們需要追蹤所有通往該值的路徑,並確保它們要麼不允許修改,要麼根本無法使用。C 和 C++ 的指標太過自由,以至於編譯器無法檢查這一點。Rust 的參照始終與特定的生命週期繫結,使得在編譯時檢查變得可行。

對抗物件海洋的挑戰

自1990年代自動記憶體管理興起以來,所有程式的預設架構都變成了如圖 5-10 所示的「物件海洋」。

此圖示展示了當你擁有垃圾回收機制並開始寫程式卻沒有進行任何設計時會發生的情況。我們都曾經建立過看起來像這樣的系統。

這種架構有許多在圖表中看不到的優點:初始進展迅速,很容易加入新的東西,並且幾年後,你會很容易地為完全重寫找到理由。(參照AC/DC的「Highway to Hell」。)

當然,也有缺點。當所有東西都像這樣相互依賴時,很難測試、進化或甚至單獨思考任何元件。

Rust 的所有權模型在通往地獄的高速公路上設定了一個減速帶。要在 Rust 中建立一個迴圈(兩個值彼此包含指向對方的參照)需要花點功夫。你必須使用智慧指標型別,如 Rc,以及內部可變性——一個我們還沒介紹過的話題。

Rust 更傾向於讓指標、所有權和資料流向單一方向流經系統,如圖 5-11 所示。

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Rust生命週期與參照安全

package "安全架構" {
    package "網路安全" {
        component [防火牆] as firewall
        component [WAF] as waf
        component [DDoS 防護] as ddos
    }

    package "身份認證" {
        component [OAuth 2.0] as oauth
        component [JWT Token] as jwt
        component [MFA] as mfa
    }

    package "資料安全" {
        component [加密傳輸 TLS] as tls
        component [資料加密] as encrypt
        component [金鑰管理] as kms
    }

    package "監控審計" {
        component [日誌收集] as log
        component [威脅偵測] as threat
        component [合規審計] as audit
    }
}

firewall --> waf : 過濾流量
waf --> oauth : 驗證身份
oauth --> jwt : 簽發憑證
jwt --> tls : 加密傳輸
tls --> encrypt : 資料保護
log --> threat : 異常分析
threat --> audit : 報告生成

@enduml

我們現在提到這一點的原因是,在讀完本章後,你很自然地會想要立即建立一個「結構體海洋」,所有結構體都用 Rc 智慧指標綁在一起,並重新創造你熟悉的所有導向物件的反模式。

這不會馬上對你奏效。Rust 的所有權模型會給你帶來一些麻煩。解決方案是進行一些前期設計,並建立一個更好的程式。

Rust 的特點是將理解程式的痛苦從未來轉移到現在。它的工作效果出乎意料地好:不僅 Rust 能夠迫使你理解為什麼你的程式是執行緒安全的,它甚至可以要求一定程度的高層架構設計。

表示式語言

Rust 在視覺上與 C 語言家族相似,但這有點誤導。在 C 中,表示式和陳述式之間有著明顯的區別。表示式是像這樣的一段程式碼:

5 * (fahr-32) / 9

而陳述式則更像是這樣:

for (; begin != end; ++begin) {
    if (*begin == target)
        break;
}

表示式有值,而陳述式則沒有。

Rust 被稱為表示式語言。這意味著它遵循一種更古老的傳統,可以追溯到 Lisp,在這種傳統中,表示式完成了所有的工作。

在 C 中,if 和 switch 是陳述式。它們不產生值,也不能在表示式的中間使用。在 Rust 中,if 和 match 可以產生值。我們在第 2 章中已經看到了 match 表示式產生數值的一個例子:

pixels[r * bounds.0 + c] =
    match escapes(Complex { re: point.0, im: point.1 }, 255) {
        None => 0,
        Some(count) => 255 - count as u8
    };

內容解密:

  • match 表示式根據 escapes 函式的傳回值進行模式匹配。
  • 如果傳回值是 None,則表示式的值為 0。
  • 如果傳回值是 Some(count),則表示式的值為 255 - count as u8

if 表示式可以用來初始化變數:

let status =
    if cpu.temperature <= MAX_TEMP {
        HttpStatus::Ok
    } else {
        HttpStatus::ServerError //伺服器熔斷
    };

match 表示式可以作為引數傳遞給函式或巨集:

println!("Inside the vat, you see {}.",
    match vat.contents {
        Some(brain) => brain.desc(),
        None => "nothing of interest"
    });

內容解密:

  • if 表示式根據條件傳回不同的列舉值。
  • match 表示式用於處理 vat.contents 的不同可能值,並傳回相應的字串描述。

這解釋了為什麼 Rust 不需要 C 中的三元運算元(expr1 ? expr2 : expr3)。在 C 中,它是 if 陳述式在表示式層級的類別似物。在 Rust 中,if 表示式同時處理了這兩種情況。

C 中的大多數控制流程工具都是陳述式。在 Rust 中,它們都是表示式。