什么是所有權
所有權是一組規則,它決定了 Rust 程序如何管理內存。所有運行中的程序都必須管理它們對計算機內存的使用方式。某些語言使用垃圾回收(GC),在程序運行時定期查找不再使用的內存;另一些語言則要求程序員顯式地分配和釋放內存。Rust 采用第三種方式:通過一套編譯期檢查的“所有權系統”來管理內存。一旦違反這些規則,程序就無法通過編譯。所有權機制的任何特性都不會在運行時拖慢程序。
對許多開發者來說,所有權是一個全新概念,確實需要一定時間適應。好消息是:隨著你對 Rust 及所有權規則愈發熟悉,你會自然而然寫出既安全又高效的代碼。堅持下去!
理解了所有權,你就掌握了理解 Rust 獨特特性的基石。本章將通過一種非常常見的數據結構——字符串(String)——的示例來學習所有權。
棧(Stack)與堆(Heap)
很多高級語言很少要求你關注棧和堆。但在像 Rust 這樣的系統編程語言里,值位于棧還是堆,會直接影響語言的行為以及你為何必須做出某些決策。后面講解所有權時,會結合棧和堆的概念,因此先簡要說明。
棧和堆都是可供代碼在運行時使用的內存區域,但組織方式不同。
- 棧按“后進先出”順序存取值:就像一摞盤子,后放的在上,先取最上面的。往棧里加數據叫“壓棧”(push),移除叫“彈棧”(pop)。棧上所有數據的大小必須在編譯期已知且固定。
- 堆則沒那么有序:把數據放入堆時,先向內存分配器申請一塊足夠大的空間,分配器標記該空間為“已用”,并返回指向此位置的指針。這個過程叫“堆分配”(簡稱分配),壓棧不算分配。由于指針大小固定,可以把指針存在棧上;真正訪問數據時,必須順著指針去堆里拿。就像進餐廳時,告訴服務員你們幾個人,他找一張空桌領你們過去,后來者問服務員即可找到你們。
壓棧比堆分配更快,因為無需尋找空閑位置;棧頂永遠是下一個位置。堆分配需要找到足夠大的空間,并記錄元數據以備后續分配,工作量更大。
訪問堆數據也比棧慢,需要一次指針跳轉。現代處理器在內存連續時更快。類似地,服務員若逐桌收齊一桌的訂單再換下一桌,效率最高;若來回穿插,則慢得多。同理,處理器處理棧上緊密排布的數據更高效。
當函數被調用,傳入的值(可能包括指向堆數據的指針)以及局部變量都會壓棧;函數結束時,這些值被彈棧。
追蹤哪些代碼正在使用堆上的哪些數據、減少堆上重復數據、及時清理不再使用的數據以免耗盡內存——這些正是所有權要解決的問題。理解所有權后,你無須時刻惦記棧和堆,但明白“所有權主要用來管理堆數據”有助于理解其設計初衷。
所有權規則
先記住三條核心規則,后面示例會逐一闡釋:
- Rust 中每個值都有且僅有一個所有者(owner)。
- 同一時間只能有一個所有者。
- 所有者離開作用域(scope)時,該值被丟棄(drop)。
變量的作用域
我們不再在每個示例中寫 fn main() { ... }
,請自行把代碼放進 main
函數。先看變量作用域:作用域指一個項在程序中有效的范圍。例如:
{ // s 尚未聲明,不可用let s = "hello"; // 從這里開始 s 有效// 使用 s
} // 作用域結束,s 失效
重點:
- s 進入作用域時生效。
- 離開作用域時失效。
這與多數語言類似。接下來引入 String
類型,以進一步說明所有權。
String 類型
為了展示所有權規則,我們需要比第 3 章更復雜的數據類型。之前提到的類型大小已知,可放棧上,作用域結束時彈出,且易于按位復制出獨立副本。現在我們想研究存放在堆上的數據,以及 Rust 如何決定何時清理它們——String
是很好的例子。
我們已見過字符串字面量("hello"
),其值在編譯期已知并直接寫入可執行文件,速度快、效率高,但不可變,且無法在編譯期確定所有文本(如用戶輸入)。于是 Rust 提供第二種字符串類型 String
,它在堆上管理數據,允許存儲編譯期大小未知的文本。可用 String::from
由字面量創建:
let s = String::from("hello");
::
語法把 from
置于 String
命名空間下,第 5 章與第 7 章會再談。
String
可被修改:
let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}"); // 輸出 `hello, world!`
為何 String
可變,而字面量不行?關鍵在于二者內存處理方式不同。
內存與分配
- 字面量內容在編譯期已知,直接嵌入可執行文件,因此不可變。
String
需支持可增長文本,于是:- 在運行時向內存分配器申請未知大小的堆內存。
- 用完后需將此內存歸還(釋放)。
第一步由 String::from
完成,與多數語言相同。第二步則不同:
- 有 GC 的語言由 GC 清理;
- 無 GC 的語言通常需程序員顯式釋放,易出錯:忘了解放會泄漏,過早釋放為懸垂指針,重復釋放是 bug。
Rust 的做法:變量離開作用域時自動歸還內存。例如:
{let s = String::from("hello"); // 申請內存// 使用 s
} // 作用域結束,Rust 自動調用 drop 釋放內存
C++ 中類似模式叫 RAII(資源獲取即初始化)。Rust 的 drop
函數即此思想的體現。
變量與數據:移動(Move)
Rust 中多個變量可與同一數據交互。先看整數示例:
let x = 5;
let y = x;
整數大小固定,直接復制值壓棧,于是 x
、y
均為 5。
再看 String
:
let s1 = String::from("hello");
let s2 = s1;
看起來相似,實則不然。如圖 4-1 所示,String
由三部分組成(存棧上):指向堆內容的指針、長度、容量;右側堆上才是真正的字符數據。
圖 4-1:變量 s1
綁定到值為 "hello"
的 String
在內存中的表示
- length(長度)表示該
String
的內容當前占用的字節數。 - capacity(容量)表示該
String
從分配器處獲得的堆內存總字節數。
二者有區別,但在本節并不重要,可先忽略容量。
當我們執行 let s2 = s1;
時,復制的是棧上的那三部分數據(指針、長度、容量),而不會復制指針所指向的堆上的實際內容。換句話說,內存中的數據表示如圖 4-2 所示。
圖 4-2:變量 s2
復制了 s1
的指針、長度和容量后的內存示意圖
(并沒有復制堆上的實際數據)
這種表示并不是圖 4-3 所展示的情況——圖 4-3 表示的是“連堆上的數據也一并深拷貝”后的內存布局。
如果 Rust 真的那樣做,當堆上的數據很大時,s2 = s1
這一操作在運行時就會變得非常昂貴。
圖4-3:如果Rust也復制堆數據,s2 = s1可能的另一種行為
我們之前提到,當一個變量超出作用域時,Rust會自動調用drop函數并清理該變量的堆內存。但圖4-2顯示兩個數據指針指向同一個位置。這是一個問題:當s2和s1超出作用域時,它們都會嘗試釋放相同的內存。這被稱為雙重釋放錯誤,是我們之前提到的內存安全漏洞之一。釋放內存兩次可能導致內存損壞,進而可能引發安全漏洞。
為了確保內存安全,在執行let s2 = s1;
這行代碼后,Rust認為s1不再有效。因此,當s1超出作用域時,Rust不需要釋放任何東西。看看在創建s2之后嘗試使用s1會發生什么;它不會工作:
這段代碼無法編譯!
let s1 = String::from("hello");
let s2 = s1;println!("{s1}, world!");
你會得到這樣的錯誤,因為Rust阻止你使用無效的引用:
$ cargo runCompiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`--> src/main.rs:5:15|
2 | let s1 = String::from("hello");| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;| -- value moved here
4 |
5 | println!("{s1}, world!");| ^^^^ value borrowed here after move|= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable|
3 | let s2 = s1.clone();| ++++++++For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
如果你在使用其他語言時聽說過淺拷貝和深拷貝的概念,那么只復制指針、長度和容量而不復制數據的想法聽起來可能像是淺拷貝。但由于Rust還會使第一個變量失效,因此它不被稱為淺拷貝,而是被稱為移動(move)。在這個例子中,我們會說s1被移動到了s2。因此,實際發生的情況如圖4-4所示。
圖4-4:s1失效后內存中的表示
這解決了我們的問題!只有s2是有效的,當它超出作用域時,它將獨自釋放內存,任務完成。
此外,這里還隱含了一個設計選擇:Rust永遠不會自動創建數據的“深拷貝”。因此,任何自動拷貝都可以假設在運行時性能方面是廉價的。
作用域與賦值
這一規則的反面也適用于作用域、所有權以及通過drop函數釋放內存之間的關系。當你將一個全新的值賦給一個已存在的變量時,Rust會立即調用drop并釋放原始值的內存。考慮以下代碼,例如:
let mut s = String::from("hello");
s = String::from("ahoy");println!("{s}, world!");
我們最初聲明了一個變量s,并將其綁定到一個值為"hello"的String。然后我們立即創建了一個值為"ahoy"的新String,并將其賦給s。此時,沒有任何東西引用堆上的原始值了。
圖 4-5:初始值被完全替換后在內存中的表示。
因此,原始字符串會立即超出作用域。Rust 會調用 drop
函數來釋放它的內存。當我們打印最終的值時,它將是“ahoy, world!”。
變量和數據的克隆操作
如果我們確實需要深度復制 String
的堆數據,而不僅僅是棧數據,我們可以使用一個常見的方法,稱為 clone
。我們將在第 5 章討論方法的語法,但由于方法是許多編程語言中的常見特性,你可能之前已經見過。
以下是一個 clone
方法的示例:
let s1 = String::from("hello");
let s2 = s1.clone();println!("s1 = {s1}, s2 = {s2}");
這可以正常工作,并明確地表現出圖 4-3 中所示的行為,即堆數據確實被復制了。
當你看到對 clone
的調用時,你應該知道正在執行一些任意代碼,而這些代碼可能代價高昂。它是一個視覺提示,表明正在發生一些不同的事情。
僅在棧上的數據:Copy
特性
我們還沒有提到的另一個細節是,使用整數的代碼——其中一部分在清單 4-2 中展示過——可以正常工作且有效:
let x = 5;
let y = x;println!("x = {x}, y = {y}");
但這段代碼似乎與我們剛剛學到的內容相矛盾:我們沒有調用 clone
,但 x
仍然有效,并且沒有被移動到 y
中。
原因是像整數這樣在編譯時已知大小的類型完全存儲在棧上,因此實際值的副本可以快速生成。這意味著我們沒有理由阻止在創建變量 y
后 x
仍然有效。換句話說,在這里淺拷貝和深拷貝沒有區別,因此調用 clone
與通常的淺拷貝沒有什么不同,我們可以省略它。
Rust 有一個特殊的注解,稱為 Copy
特性,我們可以將其應用于存儲在棧上的類型,例如整數(我們將在第 10 章中更多地討論特性)。如果一個類型實現了 Copy
特性,使用它的變量不會被移動,而是被簡單地復制,這使得它們在賦值給另一個變量后仍然有效。
如果類型本身或其任何部分實現了 Drop
特性,Rust 不會允許我們為該類型添加 Copy
注解。如果類型在值超出作用域時需要執行一些特殊操作,而我們為該類型添加了 Copy
注解,那么我們將會得到一個編譯時錯誤。要了解如何為你的類型添加 Copy
注解以實現該特性,請參閱附錄 C 中的“可派生特性”。
那么,哪些類型實現了 Copy
特性呢?你可以查看給定類型的文檔來確認,但一般來說,任何一組簡單的標量值都可以實現 Copy
,而任何需要分配內存或是一種資源的類型都不能實現 Copy
。以下是一些實現了 Copy
的類型:
- 所有整數類型,例如
u32
。 - 布爾類型
bool
,其值為true
和false
。 - 所有浮點數類型,例如
f64
。 - 字符類型
char
。 - 如果元組只包含也實現了
Copy
的類型,則元組也實現Copy
。例如,(i32, i32)
實現了Copy
,但(i32, String)
則沒有。
所有權和函數
將值傳遞給函數的機制與將值賦給變量時的機制類似。將變量傳遞給函數會移動或復制,就像賦值一樣。清單 4-3 有一個帶有注釋的示例,顯示了變量進入和超出作用域的位置。
文件名:src/main.rs
fn main() {let s = String::from("hello"); // s 進入作用域takes_ownership(s); // s 的值被移動到函數中...// ...因此在這里不再有效let x = 5; // x 進入作用域makes_copy(x); // 因為 i32 實現了 Copy 特性,// x 沒有被移動到函數中,println!("{}", x); // 因此之后仍然可以使用 x} // 這里,x 超出作用域,然后是 s。但由于 s 的值被移動了,所以沒有// 特殊的事情發生。fn takes_ownership(some_string: String) { // some_string 進入作用域println!("{some_string}");
} // 這里,some_string 超出作用域,并且調用 `drop`。后端// 內存被釋放。fn makes_copy(some_integer: i32) { // some_integer 進入作用域println!("{some_integer}");
} // 這里,some_integer 超出作用域。沒有特殊的事情發生。
清單 4-3:帶有所有權和作用域注釋的函數
如果我們試圖在調用 takes_ownership
之后使用 s
,Rust 會在編譯時拋出錯誤。這些靜態檢查可以保護我們免于犯錯。嘗試在 main
中添加使用 s
和 x
的代碼,看看你可以在哪里使用它們,以及所有權規則阻止你在哪里使用它們。
返回值和作用域
返回值也可以轉移所有權。清單 4-4 展示了一個返回某些值的函數的示例,其注釋與清單 4-3 中的類似。
文件名:src/main.rs
fn main() {let s1 = gives_ownership(); // gives_ownership 將其返回值移動到 s1 中let s2 = String::from("hello"); // s2 進入作用域let s3 = takes_and_gives_back(s2); // s2 被移動到// takes_and_gives_back 中,該函數也// 將其返回值移動到 s3 中
} // 這里,s3 超出作用域并被釋放。s2 被移動了,因此沒有// 發生任何事情。s1 超出作用域并被釋放。fn gives_ownership() -> String { // gives_ownership 將其返回值移動到調用它的函數中let some_string = String::from("yours"); // some_string 進入作用域some_string // some_string 被返回并移動到調用函數中
}// 這個函數接收一個 String 并返回一個 String。
fn takes_and_gives_back(a_string: String) -> String {// a_string 進入作用域a_string // a_string 被返回并移動到調用函數中
}
清單 4-4:返回值的所有權轉移
變量的所有權每次遵循相同的模式:將值賦給另一個變量會移動它。當包含堆數據的變量超出作用域時,除非數據的所有權被移動到另一個變量,否則值將通過 drop
被清理。
雖然這可以工作,但每次函數都獲取所有權然后再返回所有權會有些繁瑣。如果我們想讓函數使用一個值但不獲取所有權怎么辦?我們傳遞的任何東西都需要再次返回,這相當煩人,尤其是當我們還想返回函數體中可能產生的任何數據時。
幸運的是,Rust 允許我們使用元組返回多個值,如清單 4-5 所示。
文件名:src/main.rs
fn main() {let s1 = String::from("hello");let (s2, len) = calculate_length(s1);println!("'{}' 的長度是 {}。", s2, len);
}fn calculate_length(s: String) -> (String, usize) {let length = s.len(); // len() 返回一個 String 的長度(s, length)
}
清單 4-5:返回參數的所有權
但這仍然過于繁瑣,對于一個應該很常見的概念來說,工作量太大了。幸運的是,Rust 有一個特性,可以在不轉移所有權的情況下使用值,稱為引用。