返回文章列表

Rust所有權機制深入剖析

Rust 的所有權系統是其核心特性,賦予其記憶體安全和高效能的優勢。本文探討 Rust 的所有權、借用和生命週期機制,闡述移動語義如何避免常見的記憶體錯誤,並以程式碼範例輔助說明所有權規則如何與控制流程和資料結構互動作用,最終理解 Rust 如何在不使用垃圾回收的情況下確保記憶體安全。

系統程式設計 程式語言

Rust 語言的核心特性之一就是其所有權系統,這個系統讓 Rust 能夠在編譯時期就避免許多其他語言中常見的記憶體相關錯誤,像是空指標或資料競爭。所有權系統的核心概念包括所有權、借用和生命週期,它們共同作用,在不使用垃圾回收機制的前提下,確保記憶體安全並提供高效能。Rust 的所有權規則規定每個值都只有一個所有者,當所有者超出作用域時,值會被自動釋放。這與 Python 的參照計數或 C++ 的深複製機制不同,Rust 的移動語義將值的所有權從一個變數轉移到另一個變數,避免了不必要的複製,也防止了懸空指標等問題。理解這些規則對於編寫正確且高效的 Rust 程式碼至關重要。

所有權(Ownership)

許多開發者在學習Rust的過程中會發現,Rust強制要求他們遵循許多在C/C++中被視為「良好實踐」的原則。Rust並不是一種可以在幾天內學會並延後處理技術細節的語言。開發者需要立即學習嚴格的安全規則,這在初期可能會讓人感到不適應。然而,實際經驗告訴我們,這種強制性的安全規則最終會讓我們對程式碼的編譯有更深的信心。

— Mitchell Nordine

Rust對系統程式設計語言做出了以下兩項承諾: • 開發者可以控制程式中每個值的生命週期。Rust能夠在開發者控制的特定時間點釋放記憶體和其他資源。 • 即使如此,程式也不會使用指向已被釋放物件的指標。在C和C++中,使用懸掛指標是一種常見的錯誤,可能導致程式當機或產生安全漏洞。Rust在編譯時捕捉這些錯誤。

C和C++實作了第一項承諾:開發者可以隨時呼叫freedelete來釋放動態分配的堆積記憶體中的任何物件。然而,這是以犧牲第二項承諾為代價:完全由開發者負責確保不會使用指向已釋放值的指標。有大量實證表明,這是一個難以履行的責任:指標誤用一直是公開的安全問題資料函式庫中的常見罪魁禍首。

許多語言透過垃圾回收機制實作了第二項承諾,只有當物件的所有可達指標都消失時才會自動釋放物件。然而,這是以犧牲對物件釋放時間的控制為代價的。一般來說,垃圾回收器是難以預測的,要理解為什麼記憶體沒有在預期時釋放是一個挑戰。當開發者處理代表檔案、網路連線或其他作業系統資源的物件時,無法信任這些資源會在預期時間被釋放,並且相關的底層資源會被清理,這是一種令人失望的體驗。

這些折衷方案對於Rust來說都是不可接受的:開發者應該能夠控制值的生命週期,並且語言應該是安全的。然而,這是語言設計中一個相當成熟的領域。如果沒有一些根本性的改變,就無法做出重大改進。

Rust以一種令人驚訝的方式打破了僵局:透過限制程式使用指標的方式。本章和下一章將詳細解釋這些限制是什麼以及為什麼它們有效。目前只需知道,一些常見的資料結構可能不符合這些規則,您需要尋找替代方案。然而,這些限制的最終效果是為混亂帶來足夠的秩序,使Rust的編譯時檢查能夠驗證程式沒有記憶體安全錯誤:懸掛指標、雙重釋放、使用未初始化的記憶體等。在執行時,您的指標只是記憶體中的簡單位址,就像在C和C++中一樣。不同的是,您的程式碼已被證明可以安全地使用它們。

這些規則也構成了Rust支援安全平行程式設計的基礎。使用Rust精心設計的執行緒原語,確保程式碼正確使用記憶體的規則也可以用來證明程式碼沒有資料競爭。Rust程式中的錯誤無法導致一個執行緒損壞另一個執行緒的資料,從而在系統的不相關部分引入難以重現的錯誤。多執行緒程式碼中固有的非確定性行為被隔離到那些設計用來處理它的功能中——互斥鎖、訊息通道、原子值等——而不是出現在普通的記憶體參照中。C和C++中的多執行緒程式碼已經獲得了糟糕的名聲,但Rust很好地修復了它。

Rust的基本主張是,即使有這些限制,您仍會發現語言對於幾乎所有任務都足夠靈活,並且消除廣泛類別的記憶體管理和平行錯誤的好處將證明您需要對程式風格進行的調整是合理的。本文的作者非常看好Rust,正是因為我們在C和C++方面有廣泛的經驗。對我們來說,Rust的交易是一個不二之選。

Rust的規則可能與您在其他程式語言中看到的不同。學習如何使用這些規則並將它們轉化為優勢,在我們看來,是學習Rust的核心挑戰。在本章中,我們將首先透過展示其他語言中相同的基本問題來激發Rust規則的動機。然後,我們將詳細解釋Rust的規則。最後,我們將討論一些例外和幾乎是例外的情況。

為什麼需要所有權?

當處理像是檔案名稱這類別可能不是有效Unicode的字串時,Rust提供了多種字串型別的選擇:

  • 使用String&str處理Unicode文字。
  • 處理檔案名稱時,使用std::path::PathBuf&Path
  • 處理非文字的二進位資料時,使用Vec<u8>&[u8]
  • 處理環境變數名稱和命令列引數時,使用OsString&OsStr
  • 與使用空終止字串的C函式庫互操作時,使用std::ffi::CString&CStr

這些型別幫助開發者在不同的場景下正確地處理資料,避免潛在的安全問題或資料損壞。

Rust的所有權規則

Rust的所有權系統是一套用於管理記憶體和資源的規則,旨在防止常見的錯誤,如空指標例外、資料競爭等。這些規則主要圍繞著三個基本概念:所有權(Ownership)、借用(Borrowing)和生命週期(Lifetimes)。

所有權(Ownership)

每個值都有一個所謂的所有者(owner)。

  • 值在任一時刻都有且只有一個所有者。
  • 當所有者離開作用域(scope),值將被丟棄。

借用(Borrowing)

為了能夠使用值而不取得其所有權,Rust提供了借用的機制。借用允許你以參考的形式使用值,而不擁有它。

  • 你可以借用一個值作為不可變參考(&T),這允許你讀取值但不能修改它。
  • 你也可以借用一個值作為可變參考(&mut T),這允許你讀取和修改值。

生命週期(Lifetimes)

生命週期是用來描述參考有效性的機制。它確保參考始終指向有效的資料。

  • Rust編譯器會檢查生命週期,以確保所有的借用都是有效的。

實際應用與優勢

透過嚴格的所有權和借用規則,Rust能夠在編譯時期就捕捉許多常見的錯誤,如空指標解參照、資料競爭等。這使得開發者能夠寫出更安全、更可靠的程式碼。同時,這些規則也讓多執行緒程式設計變得更加安全,因為它們防止了多個執行緒同時修改相同的資料。

總之,Rust的所有權系統是其安全性和效能的核心。它提供了一種新的方式來思考和管理記憶體與資源,讓開發者能夠寫出既高效又安全的系統程式碼。

Rust 中的所有權概念

在 C 或 C++ 程式碼中,我們經常會看到註解中提到某個類別的例項擁有它所指向的其他物件。這通常意味著擁有者物件決定何時釋放被擁有的物件:當擁有者被銷毀時,它也會一併銷毀其所擁有的物件。Rust 將這個原則從註解中抽取出來,並在語言中明確表達。

所有權的基本原則

在 Rust 中,每個值都有一個單一的擁有者,決定了其生命週期。當擁有者被釋放(在 Rust 中稱為「丟棄」)時,被擁有的值也會被丟棄。這些規則旨在讓你能夠透過檢查程式碼輕易地找到任何給定值的生命週期,從而提供系統語言應有的對生命週期的控制。

變數與所有權

變數擁有其值。當控制離開變數宣告所在的區塊時,變數會被丟棄,因此其值也會被丟棄。例如:

fn print_padovan() {
    let mut padovan = vec![1, 1, 1]; 
    for i in 3..10 {
        let next = padovan[i-3] + padovan[i-2];
        padovan.push(next);
    }
    println!("P(1..10) = {:?}", padovan);
} 

此範例中,padovan 的型別是 std::vec::Vec<i32>,一個 32 位元整數的向量。在記憶體中,padovan 的最終值會類別似下圖所示。

內容解密:

  • padovan 是一個向量,擁有其緩衝區中的元素。
  • padovan 變數在函式結束時超出範圍,程式會丟棄向量。
  • 由於向量擁有其緩衝區,因此緩衝區也會隨之被丟棄。

Box 型別與所有權

Rust 的 Box 型別是另一個所有權的例子。Box<T> 是一個指向堆積上儲存的 T 型別值的指標。呼叫 Box::new(v) 會在堆積上分配一些空間,將值 v 移入其中,並傳回指向堆積空間的 Box。由於 Box 擁有它所指向的空間,因此當 Box 被丟棄時,它也會釋放該空間。

例如,你可以像這樣在堆積上分配一個元組:

{
    let point = Box::new((0.625, 0.5)); 
    let label = format!("{:?}", point); 
    assert_eq!(label, "(0.625, 0.5)");
} 

內容解密:

  • 當程式呼叫 Box::new 時,它會在堆積上為一個包含兩個 f64 值的元組分配空間,將其引數 (0.625, 0.5) 移入該空間,並傳回一個指向它的指標。
  • 當控制到達 assert_eq! 呼叫時,堆積疊框架包含變數 pointlabel,每個都參照到它所擁有的堆積分配。
  • 當它們被丟棄時,它們所擁有的分配會隨之被釋放。

結構體、元組、陣列和向量與所有權

正如變數擁有其值,結構體擁有其欄位;元組、陣列和向量擁有其元素。例如:

struct Person { name: String, birth: i32 }

let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
composers.push(Person { name: "Dowland".to_string(), birth: 1563 });
composers.push(Person { name: "Lully".to_string(), birth: 1632 });

for composer in &composers {
    println!("{}, born {}", composer.name, composer.birth);
}

內容解密:

  • 在此範例中,composers 是一個 Vec<Person>,一個結構體的向量,每個結構體都包含一個字串和一個數字。
  • 在記憶體中,composers 的最終值呈現出一棵複雜的所有權樹。
  • composers 擁有向量;向量擁有其元素,每個元素都是 Person 結構體;每個結構體擁有其欄位;字串欄位擁有其文字。
  • 當控制離開 composers 宣告的作用域時,程式會丟棄其值,並帶走整個結構。

所有權樹

每個值都有一個單一的擁有者,這使得決定何時丟棄它變得容易。但單一值可能擁有許多其他值:例如,向量 composers 擁有一切元素。這些值可能反過來擁有其他值:composers 的每個元素都擁有一個字串,該字串擁有其文字。

因此,擁有者和它們所擁有的值形成了樹:在這棵樹中,你的擁有者是你的父節點,你所擁有的值是你的子節點。而每棵樹的根源是一個變數;當該變數超出範圍時,整棵樹都會隨之消失。我們可以在 composers 的圖表中看到這樣一棵所有權樹。這不是資料結構意義上的「樹」,也不是由 DOM 元素構成的 HTML 檔案。相反,我們有一棵由混合型別構成的樹,Rust 的單一擁有者規則禁止任何可能使結構更加複雜的重新連線。

探討Rust中的所有權與移動語義

Rust程式語言以其獨特的所有權系統而聞名,這種系統使得Rust能夠在編譯時期就確保記憶體安全,避免了許多其他語言中常見的記憶體相關錯誤。所有權系統的核心概念包括所有權、借用和生命週期。

所有權樹狀結構

在Rust中,每個值都有一個所謂的「所有者」(owner),這個所有者負責在適當的時候釋放該值所佔用的記憶體。Rust程式中的每個值都是某個樹狀結構的一部分,這棵樹的根節點是某個變數。可以說,Rust中的每個值都是被某個變數直接或間接擁有的。

與C和C++不同,Rust程式通常不會顯式地釋放記憶體。相反,Rust透過變數離開作用域或其他方式(如從向量中刪除元素)來自動釋放值及其所擁有的資源。這種機制保證了記憶體的安全和高效管理。

移動語義

在Rust中,大多數型別的指定、函式引數傳遞和函式傳回值操作都會移動(move)值,而不是複製它。移動操作將值的擁有權從源頭轉移到目的地,源頭變得未初始化,而目的地現在控制著該值的生命週期。這種機制允許Rust程式以一種高效且安全的方式構建和解構複雜的資料結構。

與其他語言的比較

為了更好地理解Rust的移動語義,讓我們來看看Python和C++是如何處理指定操作的。

Python中的指定

在Python中,指定操作只是簡單地讓目標變數指向與源變數相同的物件,並增加該物件的參照計數。這種方式使得指定操作非常高效,但同時也需要維護參照計數,以便在適當的時候釋放記憶體。

s = ['udon', 'ramen', 'soba']
t = s
u = s

在上述Python程式碼中,stu都指向同一個列表,該列表的參照計數為3。

C++中的指定

在C++中,指定操作通常會進行深複製(deep copy),這意味著會建立源物件的完整副本。這種方式保證了每個物件都有明確的所有權,但可能會導致大量的記憶體分配和複製操作,尤其是在處理大型資料結構時。

vector<string> s = {"udon", "ramen", "soba"};
vector<string> t = s;
vector<string> u = s;

在上述C++程式碼中,stu都是獨立的向量,每個向量都有自己的元素複製。

Rust中的指定

那麼,Rust是如何處理指定的呢?考慮以下Rust程式碼:

let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;
let u = s;

在Rust中,指定操作let t = s;會將向量s的元素所有權移動到t,使得s變得未初始化。因此,後續的let u = s;會導致編譯錯誤,因為s已經不再有效。

詳細分析移動語義的程式碼範例

fn main() {
    let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
    let t = s;  // 將s的所有權移動到t
    // println!("{:?}", s);  // 編譯錯誤,因為s已經不再有效
    println!("{:?}", t);  // 正確,因為t現在擁有向量的所有權
}

內容解密:

  1. let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string();:建立了一個包含三個字串的向量s
  2. let t = s;:將s的所有權移動到t。此時,s變得未初始化,不能再被使用。
  3. println!("{:?}", t);:列印t的值。由於t現在擁有向量的所有權,這行程式碼正確執行。
  4. // println!("{:?}", s);:如果取消註解這行,會導致編譯錯誤,因為s已經不再有效。

這個範例展示了Rust中移動語義的基本工作原理,以及如何透過所有權系統來確保記憶體安全。

深入理解Rust的所有權機制

Rust是一種強調記憶體安全和效能的系統程式語言,其所有權機制是實作這些目標的核心概念之一。在本章中,我們將探討Rust的所有權機制,包括移動語義、所有權規則以及它們如何影響程式的行為。

移動語義

在Rust中,當你將一個值賦給另一個變數時,原始值的所有權會被移動到新的變數。這意味著原始變數不再擁有該值,也不能再被使用。這種行為與Python不同,在Python中,指定操作只會增加對原始值的參照計數。

例如,考慮以下程式碼:

let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;

在這段程式碼中,向量s的所有權被移動到t,因此s不再有效。如果嘗試使用s,編譯器將會報錯:

let u = s;

錯誤訊息如下:

error[E0382]: use of moved value: `s`
--> ownership_double_move.rs:9:9
|
8 | let t = s;
| - value moved here
9 | let u = s;
| ^ value used here after move

這種行為可以避免不必要的記憶體複製,並且確保了記憶體的安全性。

所有權規則

Rust的所有權規則如下:

  1. 每個值都有一個所有者。
  2. 同一時間內,一個值只能有一個所有者。
  3. 當所有者超出作用域時,該值將被丟棄。

這些規則確保了Rust程式的記憶體安全性,避免了常見的記憶體相關錯誤,如空指標參照和資料競爭。

移動與控制流程

在更複雜的控制流程中,移動語義可能會與條件陳述式和迴圈互動。例如,考慮以下程式碼:

let x = vec![10, 20, 30];
if c {
    f(x);
} else {
    g(x);
}
h(x);

在這段程式碼中,如果c為真,則x的所有權被移動到f(x);否則,它被移動到g(x)。無論哪種情況,x在呼叫h(x)時都將是未初始化的,因此編譯器將會報錯。

同樣,在迴圈中移動值也是被禁止的,除非在每次迭代中都重新初始化該值。

索引內容與移動

當從向量或其他集合中移動元素時,Rust需要跟蹤哪些元素已經被移動,哪些尚未被移動。這可能會導致一些複雜性,因此Rust通常不允許直接從向量中移動元素。

效能考量

雖然移動語義可能會看起來效率低下,但實際上,Rust編譯器能夠最佳化程式碼,使得移動操作的成本非常低。對於向量和字串等型別,移動操作只涉及複製三個單字頭部,而實際的資料則保留在原處。