Rust:Programming Rust:所有權

songroom發表於2017-07-30

https://everstore.cn/a/oW-4NQbf9D

文章來自於:https://everstore.cn/t/encyclopedia,非常值得去看。

“我發現,甚至在我可以編譯程式碼之前,Rust已經強迫我學習曾經那些在 C/C++中慢慢學會的好習慣。…我想強調,Rust不是幾天內可以學習,以後再積累硬體/技術/好習慣那些東西的那種語言。你將被迫立即學習嚴格的安全性,起初可能會感到不舒服。然而,在我自己的經驗中,它會讓我覺得編譯我的程式碼實際上是一種又上高樓的旅途。“

Mitchell Nordine

Rust做出以下一對對安全的系統程式語言至關重要的承諾:

你決定程式中每個值的生命週期。在你控制下的某個時刻,Rust會迅速釋放屬於某個值的記憶體和其他資源。
即使如此,你的程式永遠不會使用指標指向釋放後的物件。使用“懸掛指標”是C和C++中的常見錯誤:如果幸運的話,你的程式會崩潰。如果你不幸,你的程式會有一個安全漏洞。 Rust在編譯時捕獲這些錯誤。
C和C++遵守第一個承諾:你可以隨時在動態分配的堆中的任何物件上呼叫free或delete。 但作為交換,第二個承諾被放在一邊:保沒有使用指標指向已釋放的物件完全是你的責任。 有充分的實證證據表明,這是難以承擔的責任:公共資料庫中指標濫用一直是安全問題最常見的罪魁禍首。

許多語言使用垃圾收集來實現第二個承諾,當所有可達的指標都消失時自動釋放物件。 但是作為交換,你將無法控制垃圾收集器何時釋放物件。 一般來說,垃圾收集器是難以駕馭的野獸,並且要理解為什麼記憶體沒有如預期一樣釋放是一個挑戰。如果你處理代表檔案,網路連線或其他作業系統資源的物件,你將無法確信在你釋放它們的時候它們真的會被釋放,再者它們的底層資源與它們一起被清理,讓人失望。

對於Rust來說,這些妥協是不可接受的:程式設計師應該控制值的生命週期,語言應該是安全的。 但這是一個已深入探究的語言設計領域。 沒有一些根本的變化,你不能做出重大改進。

Rust以驚人的方式打破了僵局:通過限制程式如何使用指標。 本章將致力於解釋這些限制以及它們的邏輯。 現在,總的來說你習慣使用的一些常見結構可能不符合規則,你需要尋找替代方案。 但是,這些限制的最終效果是能夠為檢查記憶體安全錯誤(懸掛指標,雙重釋放,使用未初始化的記憶體)的Rust的編譯時檢查,提供足夠的秩序。 在執行時,你的指標是記憶體中的簡單地址,就像在C和C++中一樣。 不同的是,你的程式碼已經過驗證可以安全地使用。

這些相同的規則也構成了Rust對安全併發程式設計的支援的基礎。 使用Rust精心設計的執行緒原語,確保程式碼正確使用記憶體規則也可以避免資料競爭。 Rust程式中的錯誤不能導致一個執行緒損壞其他資料,在系統的不相關部分引入難以重現的故障。 多執行緒程式碼中固有的非確定性行為被孤立到設計來處理它們的功能——互斥體,訊息通道,原子值等等,而不是出現在普通記憶體引用中。 C和C++中的多執行緒程式碼已聲名狼藉,但Rust出色的挽回了頹勢。

Rust的激進賭注——賭注越高獲利更多的豪言,構成了這門語言的根本——即使有了這些限制,你會發現這門語言對於幾乎每個任務來說都足夠靈活,而且這個好處 ——消除廣泛的記憶體管理和併發錯誤——將證明你需要對自己的風格進行調整。 本書的作者對Rust的正面評價正是因為我們在C和C++方面的豐富經驗。 對我們來說,Rust是一門毋庸置疑值得學習的語言。

Rust的規則可能與你在其他程式語言中看到的規則不同。 在我們看來,學習如何使用它們並將其轉化為你的優勢是學習Rust的主要挑戰。 在本章中,我們將首先通過展示與其他語言相同的底層問題來幫助理解Rust的規則。 然後,我們將詳細說明Rust的規則。 最後,我們將討論一些異常和最常見的異常。

所有權

如果你讀了很多C或C ++程式碼,你可能會看過一個註釋,說一些類的例項“擁有”它指向的其他一些物件。 這通常意味著物件的擁有者可以決定何時釋放所擁有的物件:當所有者被銷燬時,它的財產也會一起銷燬。

例如,假設你編寫以下C++程式碼:

std::string s = "frayed knot";

字串s通常在記憶體中表示為:
這裡寫圖片描述

這裡,實際的std::string物件本身總是三個字長,包含指向堆分配緩衝區的指標; 緩衝區的總體容量(即,在字串必須分配較大的緩衝區來儲存文字之前,文字可以增長多大); 以及它現在擁有的文字的長度。 這些是std::string類的私有欄位,字串使用者不可訪問。

std::string擁有它的緩衝區:當程式銷燬字串時,字串的解構函式釋放緩衝區。 在過去,一些C++庫在幾個std::string值之間共享一個緩衝區,使用引用計數來決定何時釋放緩衝區。 較新版本的C++規範有效排除了該表示; 所有現代C++庫都使用這裡所示的方法。在這些情況下通常能理解,儘管其他程式碼建立臨時指標指向所擁有的記憶體是正確的,但是該程式碼有責任確保其指標在所有者決定銷燬所有物件之前已經消失。 你可以建立一個指向std::string緩衝區中的字元的指標,但是當字串被破壞時,你的指標變為無效,並且由你自己確定不再使用它。 所有者決定被擁有者的生命週期,其他人都必須尊重其決定。

Rust將這個原則從註釋中拿出來,並將其用於語言中。 在Rust中,每個值都有一個所有者決定其生命週期。 當所有者被釋放——dropped(Rust術語)時——所擁有的值也被刪除。 這些規則旨在使你能夠輕鬆地通過檢查程式碼來查詢任何給定值的生命週期,從而提供系統語言應該提供的生命週期的控制。

一個變數擁有它的值。 當控制離開宣告變數的塊時,該變數將被丟棄,因此它的值將隨之被丟棄。 例如:

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

變數padovan的型別是std::vec::Vec,一個32位整數的向量。 在記憶體中,padovan的最終值將如下所示:
這裡寫圖片描述

這非常類似於我們前面顯示的C++std::string’,除了緩衝區中的元素是32位值不是字元。 請注意,持有指標,容量和長度的padovan直接存在於print_padovan`函式的棧幀中; 只有向量的緩衝區被分配在堆上。

與之前的字串一樣,向量擁有儲存其元素的緩衝區。 當函式結束變數`padovan’超出範圍時,程式會丟棄該向量。 而且由於向量擁有它的緩衝區,這個緩衝區也隨之而去。

Rust的Box類可以作為另一個所有權的例子。 一個Box是一個指向儲存在堆上的T型別值的指標。 呼叫Box::new(v) 會分配一些堆空間,將值v移動到其中,並返回一個指向堆空間的Box。 由於Box擁有它指向的空間,當Box被摧毀時,它也釋放了空間。

例如,你可以在堆中分配一個元組,如下所示:

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

當程式呼叫Box::new時,它會為堆中的兩個f64值分配空間,將其引數(0.625,0.5)移動到該空間中,並返回一個指標。 當控制到達assert_eq!的呼叫時,棧幀如下所示:

這裡寫圖片描述

棧幀本身儲存變數point和label,每個變數都引用它擁有的堆分配。 當它們被丟棄時,他們擁有的分配與它們一起被釋放。

就像變數擁有其值一樣,結構體擁有其成員,元組,陣列和向量擁有其元素。

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,它是一個結構體的向量,每個結構都包含一個字串和一個數字。在記憶體中,composers的最終值如下所示:
這裡寫圖片描述

這裡有很多所有權關係,但每一個都很簡單:composers擁有一個向量; 向量擁有它的元素,每個元素都是一個Person結構; 每個結構擁有其欄位; 字串欄位擁有其文字。 當控制離開composers被宣告的範圍時,程式將丟棄其值,並將其整體移除。 如果圖片中有其他型別的集合——一個HashMap,也許是一個BTreeSet——都是一樣的。

當這裡讓我們回顧一下,考慮一下到目前為止所呈現的所有權關係的後果。 每個值都有一個所有者,使得決定何時刪除它很容易。 但是,單個值可能擁有許多其他值:例如,向量composers擁有其所有元素。 這些值可以依次擁有其他值:composers的每個元素都擁有一個字串,它擁有其文字。

因此,所有者及其所擁有的值形成樹:你的所有者是你的父母,你擁有的值是你的孩子。 而每個樹的最終根是一個變數; 當該變數超出範圍時,整個樹就隨之而去。 我們可以在composers的圖表中看到這樣一個所有權樹:它不是搜尋樹資料結構或由DOM元素構成的HTML文件。 相反,我們有一個由混合型別構成的樹,Rust的單一所有者規則禁止重新結合任何結構,這使得它可以構造比樹更復雜模型。 Rust程式中的每個值都是某些樹的一個成員,這些樹根植於一些變數中。

Rust程式根本不顯式刪除值,C和C++程式會使用free和delete。 在Rust中刪除值的方法是將其從所有權樹中刪除:通過離開變數的範圍,或從向量中刪除元素,或者這類事件。 在這一點上,Rust確保該值以及它擁有的一切被正確的丟棄。

在某種意義上,Rust沒有其他語言強大:使用其他實用的程式語言,可以以任何你認為合適的方式構建彼此指向的物件的任意圖形。 但是正是因為Rust不那麼強大,在你的程式上執行的語言分析可以更強大。 Rust的安全保障是可能的,因為你的程式碼中可能出現的關係更可跟蹤。 這是Rust先前提到的“激進賭注”的一部分:在實踐中,Rust承若,解決問題時通常有足夠的靈活性,至少能確定有幾個完美的解決方案能符合語言的限制規範。

也就是說,我們迄今為止所講的故事仍然太僵硬,在現實中舉步維艱。 Rust以幾種方式擴充套件了前景:

你可以將值從一個所有者移動到另一個。 這允許你構建,重新排列和銷燬樹。
標準庫提供了引用計數的指標型別Rc和Arc,允許值在一些限制下有多個所有者。
你可以“借一個引用”到一個值; 引用是不是擁有者,生命週期有限的指標。
這些策略都為所有權模式提供了靈活性,同時都沒有違背Rust的承諾。 我們將依次說明。

移動

在Rust中,對於大多數型別,如把值分配給變數,傳遞給函式或從函式返回的操作不會複製該值:它們移動它。 源將該值的所有權轉交到目的地,並變為未初始化; 目的地現在控制該值的生命週期。 Rust 程式一次建立和拆除複雜結構一個值,一次移動一個。

你可能會驚訝,Rust改變了這種基本操作的意義; 賦值應該是早已定論的東西。 但是,如果你仔細觀察不同語言處理賦值的方式,那麼你會發現,它們之間存在很大的差異。 這種比較也使得Rust的選擇的意義和後果更顯而易見。

考慮以下Python程式碼:

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

每個Python物件都帶有引用計數,跟蹤當前引用的數量。 所以在賦值給s之後,程式的狀態看起來就像這樣(省略了一些欄位):
這裡寫圖片描述

由於只有`s’指向列表,所以列表的引用計數為1; 並且因為列表是指向字串的唯一物件,它們的每個引用計數也是1。

當程式執行t和u的分配時會發生什麼? Python通過使目標點與源指向相同的物件來實現分配,並增加物件的引用計數。 所以程式的最終狀態是這樣的:

這裡寫圖片描述

Python將指標從 s 複製到t和 u,在Python中的賦值是便宜的,但是因為建立了對物件的新引用,我們必須維護引用計數 ,以便我們能知道什麼時候可以釋放值。

現在考慮類似的C++程式碼:

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

s的原始值在記憶體中看起來像這樣:

這裡寫圖片描述

當程式將 s賦值給t和u會發生什麼? 在C++中,分配一個std::vector’會生成一個向量的副本;std::string`的行為類似。 所以當程式達到這段程式碼的末尾時,它實際上分配了三個向量和九個字串:

這裡寫圖片描述

根據所涉及的值,C++中賦值可以消耗無限量的記憶體和處理器時間。 然而,優點是程式容易決定何時釋放所有這些記憶體:當變數超出範圍時,這裡分配的所有內容都將自動清除。

在某種意義上,C++和Python選擇了相反的權衡:Python使得賦值成本低廉,付出引用計數的代價(在一般情況下是垃圾收集)。 C++保持所有記憶體明確的所有權,付出深度複製的代價。 C++程式設計師往往不太熱衷於這個選擇:深層複製可能是昂貴的,而且通常還有更多的實用替代方案。

那麼在Rust裡怎麼處理類似的程式? 以下是程式碼:

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

像C和C++一樣,Rust將簡單的字串文字(如udon)放在只讀記憶體中,因此為了更清楚地與C++和Python示例進行比較,我們在這裡呼叫to_string’來獲取堆分配的String`值。

在執行s的初始化之後,由於Rust和C++對向量和字串使用相似的表示,所以情況就像在C++中一樣:

這裡寫圖片描述

但是請記住,在Rust中,大多數型別的分配會將值從源移動到目的地,從而使源未初始化。所以在初始化t後,程式的記憶體如下所示:

這裡寫圖片描述

這裡發生了什麼? 初始化let t = s;將向量的三個頭欄位從s’移動到t; 現在t`擁有向量。 向量的元素停留在它們原本的地方,字串也沒有發生任何事情。 每個價值仍然有一個所有者,雖然換了一個人。 這裡沒有引用計數被調整。 而編譯器現在認為’s’未初始化。

那麼當我們執行到初始化let u = s;會發生什麼? 這將把未初始化的值s分配給u。 Rust謹慎地禁止使用未初始化的值,因此編譯器會拒絕此程式碼,並顯示以下錯誤:

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
  |

像Python一樣,Rust賦值很廉價:程式只需將向量的三個字頭的頭部從一個位置移動到另一個位置。 但是像C++一樣,所有權一直是清楚的:程式不需要引用計數或垃圾收集來判斷何時釋放向量元素和字串內容。

你付出的代價是當你想要副本時你需要明確地要求。 如果要達到與C++程式相同的狀態,每個變數都儲存一個獨立的結構副本,你必須呼叫向量的clone方法,該方法執行向量及其元素的深層拷貝:

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

你還可以使用Rust的引用計數指標型別重新建立Python的行為。

更多移動操作

在迄今為止的例子中,我們已經演示了初始化,當它們進入let語句的範圍為變數提供值。 賦值一個變數有些不同,因為如果你將一個值移動到一個已被初始化的變數中,那麼Rust會刪除變數的先前值。

例如:

let mut s = "Govinda".to_string();
s = "Siddhartha".to_string(); // value "Govinda" dropped here

在這段程式碼中,當程式將字串Siddhartha`分配給s時,其先前的值Govinda將首先丟棄。 但請考慮以下幾點:

let mut s = "Govinda".to_string();
let t = s;
s = "Siddhartha".to_string(); // nothing is dropped here

這一次,t 從 s獲取原字串的所有權, 當我們給 s賦值時,s已變成未初始化了。 在這種情況下,不會刪除任何字串。

我們在這裡的示例中使用了初始化和分配,因為它們很簡單,但Rust應用將語義幾乎移植到任何值中。 將引數傳遞給函式將所有權移動到函式的引數中; 從函式返回值將所有權移動到呼叫者。 構建元組將值移動到元組中。 等等。

你可以在上一節中提供的示例中更好地瞭解真實情況。 例如,當我們在構建我們的作曲家向量時,我們編寫了:


struct Person { name: String, birth: i32 }

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

此程式碼顯示了幾個發生移動的地方,超出了初始化和分配:

從函式返回值。 呼叫Vec::new()構造一個新的向量,並返回,不是一個指向向量的指標而是向量本身:它的所有權從Vec::new移動到變數composers。 類似地,to_string呼叫返回一個新的String例項。
構建新值。 新的Person結構的name欄位用to_string的返回值初始化。 該結構佔用該字串的所有權。
將值傳遞給函式。 整個Person結構,而不僅僅是一個指標,被傳遞給向量的push方法,它將它移動到結構的末尾。 該向量佔用該Person的所有權,因此也成為名稱name的間接所有者。
像這樣移動值可能聽起來效率不高,但要注意兩件事情。 首先,移動總是應用於值本身,而不是它們擁有的堆儲存。 對於向量和字串,值本身僅是三字頭, 潛在的大型元素陣列和文字緩衝區位於堆中。 第二,Rust編譯器的程式碼生成非常擅長看穿所有這些動作; 在實踐中,機器程式碼通常直接在其所在的位置儲存值。

移動和控制流程

上面的例子都有非常簡單的控制流程; 移動如何與更復雜的程式碼進行互動? 一般的原則是,如果一個變數有可能將其值移開,並且一直沒有確切的賦予一個新的值,那麼它被認為是未初始化的。 例如,如果在評估if表示式的條件之後變數仍然有一個值,那麼我們可以在兩個分支中使用它:

let x = vec![10, 20, 30];
if c {
    f(x); // ... okay to move from x here
} else {
    g(x); // ... and okay to also move from x here
}
h(x) // bad: x is uninitialized here if either path uses it

由於類似的原因,迴圈中禁止變數的移動:

let x = vec![10, 20, 30];
while f() {
    g(x); // bad: x would be moved in first iteration,
          // uninitialized in second
}

也就是說,除非我們在下一次迭代中確切給它一個新的價值:

let mut x = vec![10, 20, 30];
while f() {
    g(x);           // move from x
    x = h();        // give x a fresh value
}
e(x);

移動和編入索引的內容

我們已經提到,一個移動將其源未初始化,因為目的地擁有該值的所有權。 但不是每一種值的所有者都準備好變成未初始化。 例如,考慮以下程式碼:

// Build a vector of the strings "101", "102", ... "105"
let mut v = Vec::new();
for i in 101 .. 106 {
    v.push(i.to_string());
}

// Pull out random elements from the vector.
let third = v[2];
let fifth = v[4];

為了這樣可行,Rust需要以某種方式記住向量的第三個和第五個元素已變成未初始化,並跟蹤該資訊,直到向量被丟棄。 在最普遍的情況下,向量將需要攜帶額外的資訊,以指示哪些元素是活的,哪些元素已經初始化。 這顯然不是系統程式語言的正確行為; 一個向量應該只是一個向量。 實際上,Rust以錯誤拒絕上面的程式碼:

error[E0507]: cannot move out of indexed content
  --> ownership_move_out_of_vector.rs:14:17
   |
14 |     let third = v[2];
   |                 ^^^^
   |                 |
   |                 help: consider using a reference instead `&v[2]`
   |                 cannot move out of indexed content

對於“第五”個,它也有類似的錯誤提示。 在錯誤訊息中,Rust建議使用引用,以在不移動的前提下訪問它。 這通常是你想要的。 但是,如果你真的想要將一個元素從一個向量中移出呢? 你需要找到一種方法,以符合型別限制的方式執行此操作。 這裡有三種可能性:


// Build a vector of the strings "101", "102", ... "105"
let mut v = Vec::new();
for i in 101 .. 106 {
    v.push(i.to_string());
}

// 1. Pop a value off the end of the vector:
let fifth = v.pop().unwrap();
assert_eq!(fifth, "105");

// 2. Move a value out of the middle of the vector, and move the last
// element into its spot:
let second = v.swap_remove(1);
assert_eq!(second, "102");

// 3. Swap in another value for the one we're taking out:
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");

// Let's see what's left of our vector.
assert_eq!(v, vec!["101", "104", "substitute"]);

這些方法都將元素移出向量,但是以保留向量完全填充的狀態(如果可能更小)的方式。

像Vec這樣的集合型別通常還提供了一種在迴圈中消耗其所有元素的方法:


let v = vec!["liberté".to_string(),
             "égalité".to_string(),
             "fraternité".to_string()];

for mut s in v {
    s.push('!');
    println!("{}", s);
}

當我們將向量直接傳遞給迴圈時,如for … in v中,將向量移出v,使v’未初始化。for迴圈的內部機制擁有向量的所有權,並將其分解成其元素。 在每次迭代時,迴圈將另一個元素移動到變數s。 由於s`現在擁有這個字串,所以在列印之前我們可以在迴圈體中進行修改。 而且由於向量本身對於程式碼不再可見,所以部分為空的狀態在迴圈中沒什麼可以觀察。

如果你發現自己需要從編譯器無法跟蹤的所有者中移出值,則可以考慮將所有者的型別更改為可以動態跟蹤是否具有值。 例如,前面的例子的一個變體:


struct Person { name: Option<String>, birth: i32 }

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

你不能這樣做:

let first_name = composers[0].name;

這將只是引出前面顯示過相同的cannot move out of indexed content錯誤。 但是,因為你將name欄位的型別從String更改為Option,這意味著None是欄位的合法值,所以這樣做是可行的:


let first_name = std::mem::replace(&mut composers[0].name, None);
assert_eq!(first_name, Some("Palestrina".to_string()));
assert_eq!(composers[0].name, None);

replace呼叫移出composers [0] .name的值,把None留在它的位置,並將原始值的所有權傳遞給它的呼叫者。 事實上,以這種方式使用Option是非常普遍的,為了這個目的,該型別提供了一個take方法。 你可以把上面的操作更清晰地寫成:

let first_name = composers[0].name.take();

對take的這個呼叫與上面對replace的呼叫相同。

Copy 型別:異常移動

我們迄今為止演示的值被移動的示例涉及到可能使用大量記憶體並且複製成本高昂的向量,字串和其他型別。 移動保持這種型別清晰的所有權和廉價的賦值。 但是對於像整數或字元這樣更簡單的型別,這種仔細處理並不是必需的。

與分配一個String比較,當我們分配一個’i32`值時記憶體中會發生什麼:


let str1 = "somnambulance".to_string();
let str2 = str1;

let num1: i32 = 36;
let num2 = num1;

執行此程式碼後,記憶體如下所示:

這裡寫圖片描述

與前面的向量一樣,賦值將str1移動到str2,這樣我們最終不會有兩個負責釋放相同緩衝區的字串。 但是,num1和num2的情況是不同的。 i32只是記憶體中的一種位的模式; 它不擁有任何堆資源,或者真正依賴除位元組以外的任何東西。 當我們把它的位移動到num2時,我們完成了一個完全獨立的`num1’的拷貝。

移動一個值其源的會變得未初始化。 但是,它把str1當做無值來對待的基本目的,對num1是無意義的; 繼續使用不會造成傷害。 移動的優點在這裡不適用,它是不方便的。

以前我們很小心地說,大多數型別被移動; 現在這個是例外,Rust指定這些型別為Copy。 分配Copy型別的值會複製該值,而不是移動該值。 分配源保持初始化和可用性,具有與分配前相同的值。 將Copy型別傳遞給函式和建構函式的行為類似。

標準的Copy型別包括所有的機器整數和浮點數,char和bool型別和其他幾個。 Copy型別的元組或固定大小的陣列本身就是一個Copy型別。

只有一個簡單的位到位複製足夠的型別可以是 Copy。 如上所述,String不是一個Copy型別,因為它擁有一個堆分配的緩衝區。 由於類似的原因,Box不是Copy; 它擁有堆分配的物件。 File型別,表示作業系統檔案控制程式碼,不是Copy; 複製這樣的值將需要向作業系統詢問另一個檔案控制程式碼。 同樣,MutexGuard型別,代表一個鎖定的互斥體,不是Copy:這個型別的複製根本沒有意義,因為一次只有一個執行緒可以持有互斥體。

作為一個經驗法則,當一個值被丟棄任何需要做某些特殊操作的型別不能是Copy。 Vec需要釋放其元素; 一個File需要關閉它的檔案控制程式碼; MutexGuard需要解鎖其互斥體。 這種型別的位對位的複製將使誰負責原資源變得不清楚。

你自己定義的型別怎麼樣? 預設情況下,struct和enum型別不是Copy:

struct Label { number: u32 }

fn print(l: Label) { println!("STAMP: {}", l.number); }

let l = Label { number: 3 };
print(l);
println!("My label number is: {}", l.number);

這不會編譯; 生鏽抱怨:

error[E0382]: use of moved value: `l.number`
  --> ownership_struct.rs:12:40
   |
11 |     print(l);
   |           - value moved here
12 |     println!("My label number is: {}", l.number);
   |                                        ^^^^^^^^ value used here after move
   |
   = note: move occurs because `l` has type `main::Label`, which does not
           implement the `Copy` trait

由於Label不是Copy,所以把它傳給print把值的所有權移到print函式,然後在返回之前丟棄它。 但這是愚蠢的 一個Label只不過是一個’i32’的偽裝。 沒有理由把l傳給print應該移動值。

#[derive(Copy, Clone)]
struct Label { number: u32 }

有了這個變化,上面的程式碼可以編譯。 但是,如果我們嘗試這樣一個欄位不全為Copy的型別,則不起作用。 編譯以下程式碼:

#[derive(Copy, Clone)]
struct StringLabel { name: String }

引發錯誤:

error[E0204]: the trait `Copy` may not be implemented for this type
 --> ownership_string_label.rs:7:10
  |
7 | #[derive(Copy, Clone)]
  |          ^^^^
8 | struct StringLabel { name: String }
  |                      ------------ this field does not implement `Copy`

為什麼使用者定義的型別不自動Copy,假設它們符合條件? 一個型別是否為Copy對它的使用有很大的影響:Copy型別更靈活,因為分配和相關操作不會使源未初始化。 但是對於一個型別的實現者,情況恰恰相反:它們可以包含的型別中Copy型別非常限制,而非Copy型別可以使用堆分配和擁有其他種類的資源。 因此,讓一個型別Copy代表了對實現者施加了一道限制:如果有必要將其更改為非Copy,那麼使用它的大部分程式碼可能需要進行修改。

雖然C++允許你過載分配運算子並定義專門的複製和移動建構函式,但Rust不允許進行這種定製。 在Rust中,每一個移動都是一個位元組對位元組,淺拷貝,使源未初始化。 複製是相同的,除了源保持初始化。 這意味著C++類可以提供Rust不能提供的方便的介面,C++的這種介面的程式碼普遍隱含地調整引用計數,提供昂貴的複製以供以後使用,或使用其他複雜的實現技巧。

但是,這種靈活性對C++作為一種語言的影響是使基本操作像分配,傳遞引數和從函式返回的值不太可預測。 例如,本章前面我們展示了在C++中將一個變數分配給另一個變數如何可能需要任意數量的記憶體和處理器時間。 Rust的原則之一是程式設計師應該明白成本。 基本操作必須保持簡單。 潛在昂貴的操作應該是明確的,就像前面例子中對clone的呼叫一樣,它們可以製作向量的深層副本和它們包含的字串。

Rc和Arc:共享的所有權

雖然典型的Rust程式碼中的大多數值具有唯一所有者,但在某些情況下,很難找到單個具有所需生命週期的所有者的每個值; 你只是想讓這個值活著,直到每個人都使用完它。 對於這些情況,Rust提供了引用計數的指標型別Rc和Arc。 正如你對Rust的期望一樣,它們的使用是完全安全的:你不能忘記調整引用計數,或建立其他Rust不能跟蹤的指標,或者被任何其他C++引用計數類的問題絆倒。

Rc和Arc型別非常相似; 它們之間的唯一區別是Arc可以直接線上程之間共享——名稱Arc是Atomic Reference Count的縮寫——而簡單的Rc使用更快的非執行緒安全的程式碼來更新 引用計數。 如果你不需要線上程之間共享指標,則沒有理由支付Arc的效能損失,因此你應該使用Rc; Rust會阻止你意外地穿過執行緒邊界。 這兩種型別是相同的,所以在本節的其餘部分,我們將只談Rc。

在本章前面,我們展示了Python如何使用引用計數來管理其值的生命週期。 你可以使用Rc在Rust中獲得類似的效果。 請考慮以下程式碼:

use std::rc::Rc;

// Rust can infer all these types; written out for clarity
let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();

對於任何型別的T,Rc是一個附加了引用計數指向堆分配的T的指標。 克隆Rc值不會複製T; 相反,它只是建立另一個指向它的指標,並增加引用計數。 所以以上程式碼在記憶體中產生以下情況:
這裡寫圖片描述

三個Rc指標都指向同一個記憶體塊,它儲存String的引用計數和空間。 普通的所有權規則適用於Rc指標本身,當最後一個現存的Rc被刪除時,Rust也會刪除String。

你可以直接在Rc上使用任何String通常的方法。

assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u);

Rc指標所擁有的值是不可變的。 如果你嘗試在字串的末尾新增一些文字:

s.push_str(" noodles");

Rust會拒絕:

error: cannot borrow immutable borrowed content as mutable
  --> ownership_rc_mutability.rs:12:5
   |
12 |     s.push_str(" noodles");
   |     ^ cannot borrow as mutable

Rust的記憶體和執行緒安全保障取決於確保沒有任何值被共享和可變。 Rust假定Rc指標的引用通常可以共享,因此它不能是可變的。 我們解釋為什麼這個限制在[第5章](ch05.html#參考)中很重要。

使用引用計數來管理記憶體的一個眾所周知的問題是,如果有兩個指向彼此的引用計數值,則每個引用計數值將保持高於零的對另一個的引用計數,因此值將永遠不會被釋放:

這裡寫圖片描述

這樣可以在Rust中洩漏值,但這種情況很少見。 你不能建立一個迴圈,而不會在某種程度上使較舊的值指向較新的值。 這顯然需要較老的值可變。 由於Rc指標保持其指示不可變,所以通常不可能建立一個迴圈。 然而,Rust確實提供了建立不變值的可變部分的方法; 這被稱為內部可變性,我們將在“內部可變性”中詳細介紹它。 如果將這些技術與Rc指標相結合,你可以建立一個迴圈和記憶體洩漏。

有時可以通過使用“弱指標”,std::rc::Weak來代替某些連結來避免產生Rc指標的迴圈。 但是,我們不會在這裡覆蓋此主題; 有關詳細資訊,請參閱標準庫的文件。

移動和引用計數指標是放鬆所有權樹剛度的兩種方式。 在下一章中,我們來看一下第三種方法:借用對值的引用。 一旦你學會了所有權和借用,就爬上Rust學習曲線最陡峭的部分,你將可以充分利用Rust的獨特優勢。

相關文章