什麼是所有權?
ch04-01-what-is-ownership.md
commit e81710c276b3839e8ec54d5f12aec4f9de88924b
Rust 的核心功能(之一)是 所有權(ownership)。雖然該功能很容易解釋,但它對語言的其他部分有著深刻的影響。
所有運行的程序都必須管理其使用計算機記憶體的方式。一些語言中具有垃圾回收機制,在程序運行時不斷地尋找不再使用的記憶體;在另一些語言中,程式設計師必須親自分配和釋放記憶體。Rust 則選擇了第三種方式:透過所有權系統管理記憶體,編譯器在編譯時會根據一系列的規則進行檢查。在運行時,所有權系統的任何功能都不會減慢程序。
因為所有權對很多程式設計師來說都是一個新概念,需要一些時間來適應。好消息是隨著你對 Rust 和所有權系統的規則越來越有經驗,你就越能自然地編寫出安全和高效的代碼。持之以恆!
當你理解了所有權,你將有一個堅實的基礎來理解那些使 Rust 獨特的功能。在本章中,你將透過完成一些範例來學習所有權,這些範例基於一個常用的數據結構:字串。
棧(Stack)與堆(Heap)
在很多語言中,你並不需要經常考慮到棧與堆。不過在像 Rust 這樣的系統程式語言中,值是位於棧上還是堆上在更大程度上影響了語言的行為以及為何必須做出這樣的抉擇。我們會在本章的稍後部分描述所有權與棧和堆相關的內容,所以這裡只是一個用來預熱的簡要解釋。
棧和堆都是代碼在運行時可供使用的記憶體,但是它們的結構不同。棧以放入值的順序存儲值並以相反順序取出值。這也被稱作 後進先出(last in, first out)。想像一下一疊盤子:當增加更多盤子時,把它們放在盤子堆的頂部,當需要盤子時,也從頂部拿走。不能從中間也不能從底部增加或拿走盤子!增加數據叫做 進棧(pushing onto the stack),而移出數據叫做 出棧(popping off the stack)。
棧中的所有數據都必須占用已知且固定的大小。在編譯時大小未知或大小可能變化的數據,要改為存儲在堆上。堆是缺乏組織的:當向堆放入數據時,你要請求一定大小的空間。操作系統在堆的某處找到一塊足夠大的空位,把它標記為已使用,並返回一個表示該位置地址的 指針(pointer)。這個過程稱作 在堆上分配記憶體(allocating on the heap),有時簡稱為 “分配”(allocating)。將數據推入棧中並不被認為是分配。因為指針的大小是已知並且固定的,你可以將指針存儲在棧上,不過當需要實際數據時,必須訪問指針。
想像一下去餐館就座吃飯。當進入時,你說明有幾個人,餐館員工會找到一個夠大的空桌子並領你們過去。如果有人來遲了,他們也可以透過詢問來找到你們坐在哪。
入棧比在堆上分配記憶體要快,因為(入棧時)操作系統無需為存儲新數據去搜索記憶體空間;其位置總是在棧頂。相比之下,在堆上分配記憶體則需要更多的工作,這是因為操作系統必須首先找到一塊足夠存放數據的記憶體空間,並接著做一些記錄為下一次分配做準備。
訪問堆上的數據比訪問棧上的數據慢,因為必須透過指針來訪問。現代處理器在記憶體中跳轉越少就越快(快取)。繼續類比,假設有一個服務員在餐廳裡處理多個桌子的點菜。在一個桌子報完所有菜後再移動到下一個桌子是最有效率的。從桌子 A 聽一個菜,接著桌子 B 聽一個菜,然後再桌子 A,然後再桌子 B 這樣的流程會更加緩慢。出於同樣原因,處理器在處理的數據彼此較近的時候(比如在棧上)比較遠的時候(比如可能在堆上)能更好的工作。在堆上分配大量的空間也可能消耗時間。
當你的代碼調用一個函數時,傳遞給函數的值(包括可能指向堆上數據的指針)和函數的局部變數被壓入棧中。當函數結束時,這些值被移出棧。
跟蹤哪部分代碼正在使用堆上的哪些數據,最大限度的減少堆上的重複數據的數量,以及清理堆上不再使用的數據確保不會耗盡空間,這些問題正是所有權系統要處理的。一旦理解了所有權,你就不需要經常考慮棧和堆了,不過明白了所有權的存在就是為了管理堆數據,能夠幫助解釋為什麼所有權要以這種方式工作。
所有權規則
首先,讓我們看一下所有權的規則。當我們通過舉例說明時,請謹記這些規則:
- Rust 中的每一個值都有一個被稱為其 所有者(owner)的變數。
- 值在任一時刻有且只有一個所有者。
- 當所有者(變數)離開作用域,這個值將被丟棄。
變數作用域
我們已經在第二章完成一個 Rust 程序範例。既然我們已經掌握了基本語法,將不會在之後的例子中包含 fn main() {
代碼,所以如果你是一路跟過來的,必須手動將之後例子的代碼放入一個 main
函數中。這樣,例子將顯得更加簡明,使我們可以關注實際細節而不是樣板代碼。
在所有權的第一個例子中,我們看看一些變數的 作用域(scope)。作用域是一個項(item)在程序中有效的範圍。假設有這樣一個變數:
#![allow(unused)] fn main() { let s = "hello"; }
變數 s
綁定到了一個字串字面值,這個字串值是寫死進程式碼中的。這個變數從聲明的點開始直到當前 作用域 結束時都是有效的。範例 4-1 的注釋標明了變數 s
在何處是有效的。
#![allow(unused)] fn main() { { // s 在這裡無效, 它尚未聲明 let s = "hello"; // 從此處起,s 是有效的 // 使用 s } // 此作用域已結束,s 不再有效 }
換句話說,這裡有兩個重要的時間點:
- 當
s
進入作用域 時,它就是有效的。 - 這一直持續到它 離開作用域 為止。
目前為止,變數是否有效與作用域的關係跟其他程式語言是類似的。現在我們在此基礎上介紹 String
類型。
String
類型
為了示範所有權的規則,我們需要一個比第三章 “數據類型” 中講到的都要複雜的數據類型。前面介紹的類型都是存儲在棧上的並且當離開作用域時被移出棧,不過我們需要尋找一個存儲在堆上的數據來探索 Rust 是如何知道該在何時清理數據的。
這裡使用 String
作為例子,並專注於 String
與所有權相關的部分。這些方面也同樣適用於標準庫提供的或你自己創建的其他複雜數據類型。在第八章會更深入地講解 String
。
我們已經見過字串字面值,即被寫死進程序裡的字串值。字串字面值是很方便的,不過它們並不適合使用文本的每一種場景。原因之一就是它們是不可變的。另一個原因是並非所有字串的值都能在編寫程式碼時就知道:例如,要是想獲取用戶輸入並存儲該怎麼辦呢?為此,Rust 有第二個字串類型,String
。這個類型被分配到堆上,所以能夠存儲在編譯時未知大小的文本。可以使用 from
函數基於字串字面值來創建 String
,如下:
#![allow(unused)] fn main() { let s = String::from("hello"); }
這兩個冒號(::
)是運算符,允許將特定的 from
函數置於 String
類型的命名空間(namespace)下,而不需要使用類似 string_from
這樣的名字。在第五章的 “方法語法”(“Method Syntax”) 部分會著重講解這個語法而且在第七章的 “路徑用於引用模組樹中的項” 中會講到模組的命名空間。
可以 修改此類字串 :
#![allow(unused)] fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() 在字串後追加字面值 println!("{}", s); // 將列印 `hello, world!` }
那麼這裡有什麼區別呢?為什麼 String
可變而字面值卻不行呢?區別在於兩個類型對記憶體的處理上。
記憶體與分配
就字串字面值來說,我們在編譯時就知道其內容,所以文本被直接寫死進最終的可執行文件中。這使得字串字面值快速且高效。不過這些特性都只得益於字串字面值的不可變性。不幸的是,我們不能為了每一個在編譯時大小未知的文本而將一塊記憶體放入二進位制文件中,並且它的大小還可能隨著程序運行而改變。
對於 String
類型,為了支持一個可變,可增長的文本片段,需要在堆上分配一塊在編譯時未知大小的記憶體來存放內容。這意味著:
- 必須在運行時向操作系統請求記憶體。
- 需要一個當我們處理完
String
時將記憶體返回給操作系統的方法。
第一部分由我們完成:當調用 String::from
時,它的實現 (implementation) 請求其所需的記憶體。這在程式語言中是非常通用的。
然而,第二部分實現起來就各有區別了。在有 垃圾回收(garbage collector,GC)的語言中, GC 記錄並清除不再使用的記憶體,而我們並不需要關心它。沒有 GC 的話,識別出不再使用的記憶體並調用代碼顯式釋放就是我們的責任了,跟請求記憶體的時候一樣。從歷史的角度上說正確處理記憶體回收曾經是一個困難的程式問題。如果忘記回收了會浪費記憶體。如果過早回收了,將會出現無效變數。如果重複回收,這也是個 bug。我們需要精確的為一個 allocate
配對一個 free
。
Rust 採取了一個不同的策略:內存在擁有它的變數離開作用域後就被自動釋放。下面是範例 4-1 中作用域例子的一個使用 String
而不是字串字面值的版本:
#![allow(unused)] fn main() { { let s = String::from("hello"); // 從此處起,s 是有效的 // 使用 s } // 此作用域已結束, // s 不再有效 }
這是一個將 String
需要的記憶體返回給操作系統的很自然的位置:當 s
離開作用域的時候。當變數離開作用域,Rust 為我們調用一個特殊的函數。這個函數叫做 drop
,在這裡 String
的作者可以放置釋放記憶體的代碼。Rust 在結尾的 }
處自動調用 drop
。
注意:在 C++ 中,這種 item 在生命週期結束時釋放資源的模式有時被稱作 資源獲取即初始化(Resource Acquisition Is Initialization (RAII))。如果你使用過 RAII 模式的話應該對 Rust 的
drop
函數並不陌生。
這個模式對編寫 Rust 代碼的方式有著深遠的影響。現在它看起來很簡單,不過在更複雜的場景下代碼的行為可能是不可預測的,比如當有多個變數使用在堆上分配的記憶體時。現在讓我們探索一些這樣的場景。
變數與數據交互的方式(一):移動
Rust 中的多個變數可以採用一種獨特的方式與同一數據交互。讓我們看看範例 4-2 中一個使用整型的例子。
#![allow(unused)] fn main() { let x = 5; let y = x; }
我們大致可以猜到這在幹什麼:“將 5
綁定到 x
;接著生成一個值 x
的拷貝並綁定到 y
”。現在有了兩個變數,x
和 y
,都等於 5
。這也正是事實上發生了的,因為整數是有已知固定大小的簡單值,所以這兩個 5
被放入了棧中。
現在看看這個 String
版本:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; }
這看起來與上面的代碼非常類似,所以我們可能會假設他們的運行方式也是類似的:也就是說,第二行可能會生成一個 s1
的拷貝並綁定到 s2
上。不過,事實上並不完全是這樣。
看看圖 4-1 以了解 String
的底層會發生什麼事。String
由三部分組成,如圖左側所示:一個指向存放字串內容記憶體的指針,一個長度,和一個容量。這一組數據存儲在棧上。右側則是堆上存放內容的記憶體部分。
長度表示 String
的內容當前使用了多少位元組的記憶體。容量是 String
從操作系統總共獲取了多少位元組的記憶體。長度與容量的區別是很重要的,不過在當前上下文中並不重要,所以現在可以忽略容量。
當我們將 s1
賦值給 s2
,String
的數據被複製了,這意味著我們從棧上拷貝了它的指針、長度和容量。我們並沒有複製指針指向的堆上數據。換句話說,記憶體中數據的表現如圖 4-2 所示。
這個表現形式看起來 並不像 圖 4-3 中的那樣,如果 Rust 也拷貝了堆上的數據,那麼記憶體看起來就是這樣的。如果 Rust 這麼做了,那麼操作 s2 = s1
在堆上數據比較大的時候會對運行時性能造成非常大的影響。
之前我們提到過當變數離開作用域後,Rust 自動調用 drop
函數並清理變數的堆記憶體。不過圖 4-2 展示了兩個數據指針指向了同一位置。這就有了一個問題:當 s2
和 s1
離開作用域,他們都會嘗試釋放相同的記憶體。這是一個叫做 二次釋放(double free)的錯誤,也是之前提到過的記憶體安全性 bug 之一。兩次釋放(相同)記憶體會導致記憶體汙染,它可能會導致潛在的安全漏洞。
為了確保記憶體安全,這種場景下 Rust 的處理有另一個細節值得注意。與其嘗試拷貝被分配的記憶體,Rust 則認為 s1
不再有效,因此 Rust 不需要在 s1
離開作用域後清理任何東西。看看在 s2
被創建之後嘗試使用 s1
會發生什麼事;這段代碼不能運行:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
你會得到一個類似如下的錯誤,因為 Rust 禁止你使用無效的引用。
error[E0382]: use of moved value: `s1`
--> src/main.rs:5:28
|
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`, which does
not implement the `Copy` trait
如果你在其他語言中聽說過術語 淺拷貝(shallow copy)和 深拷貝(deep copy),那麼拷貝指針、長度和容量而不拷貝數據可能聽起來像淺拷貝。不過因為 Rust 同時使第一個變數無效了,這個操作被稱為 移動(move),而不是淺拷貝。上面的例子可以解讀為 s1
被 移動 到了 s2
中。那麼具體發生了什麼事,如圖 4-4 所示。
這樣就解決了我們的問題!因為只有 s2
是有效的,當其離開作用域,它就釋放自己的記憶體,完畢。
另外,這裡還隱含了一個設計選擇:Rust 永遠也不會自動創建數據的 “深拷貝”。因此,任何 自動 的複製可以被認為對運行時性能影響較小。
變數與數據交互的方式(二):複製
如果我們 確實 需要深度複製 String
中堆上的數據,而不僅僅是棧上的數據,可以使用一個叫做 clone
的通用函數。第五章會討論方法語法,不過因為方法在很多語言中是一個常見功能,所以之前你可能已經見過了。
這是一個實際使用 clone
方法的例子:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
這段代碼能正常運行,並且明確產生圖 4-3 中行為,這裡堆上的數據 確實 被複製了。
當出現 clone
調用時,你知道一些特定的代碼被執行而且這些程式碼可能相當消耗資源。你很容易察覺到一些不尋常的事情正在發生。
只在棧上的數據:拷貝
這裡還有一個沒有提到的小竅門。這些程式碼使用了整型並且是有效的,他們是範例 4-2 中的一部分:
#![allow(unused)] fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
但這段代碼似乎與我們剛剛學到的內容相矛盾:沒有調用 clone
,不過 x
依然有效且沒有被移動到 y
中。
原因是像整型這樣的在編譯時已知大小的類型被整個存儲在棧上,所以拷貝其實際的值是快速的。這意味著沒有理由在創建變數 y
後使 x
無效。換句話說,這裡沒有深淺拷貝的區別,所以這裡調用 clone
並不會與通常的淺拷貝有什麼不同,我們可以不用管它。
Rust 有一個叫做 Copy
trait 的特殊註解,可以用在類似整型這樣的存儲在棧上的類型上(第十章詳細講解 trait)。如果一個類型擁有 Copy
trait,一個舊的變數在將其賦值給其他變數後仍然可用。Rust 不允許自身或其任何部分實現了 Drop
trait 的類型使用 Copy
trait。如果我們對其值離開作用域時需要特殊處理的類型使用 Copy
註解,將會出現一個編譯時錯誤。要學習如何為你的類型增加 Copy
註解,請閱讀附錄 C 中的 “可派生的 trait”。
那麼什麼類型是 Copy
的呢?可以查看給定類型的文件來確認,不過作為一個通用的規則,任何簡單標量值的組合可以是 Copy
的,不需要分配記憶體或某種形式資源的類型是 Copy
的。如下是一些 Copy
的類型:
- 所有整數類型,比如
u32
。 - 布爾類型,
bool
,它的值是true
和false
。 - 所有浮點數類型,比如
f64
。 - 字元類型,
char
。 - 元組,當且僅當其包含的類型也都是
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); // x 應該移動函數裡, // 但 i32 是 Copy 的,所以在後面可繼續使用 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 移出作用域。不會有特殊操作
當嘗試在調用 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("hello"); // some_string 進入作用域. some_string // 返回 some_string 並移出給調用的函數 } // takes_and_gives_back 將傳入字串並返回該值 fn takes_and_gives_back(a_string: String) -> String { // a_string 進入作用域 a_string // 返回 a_string 並移出給調用的函數 }
變數的所有權總是遵循相同的模式:將值賦給另一個變數時移動它。當持有堆中數據值的變數離開作用域時,其值將通過 drop
被清理掉,除非數據被移動為另一個變數所有。
在每一個函數中都獲取所有權並接著返回所有權有些囉嗦。如果我們想要函數使用一個值但不獲取所有權該怎麼辦呢?如果我們還要接著使用它的話,每次都傳進去再返回來就有點煩人了,除此之外,我們也可能想返回函數體中產生的一些數據。
我們可以使用元組來返回多個值,如範例 4-5 所示。
檔案名: src/main.rs
fn main() { let s1 = String::from("hello"); let (s2, len) = calculate_length(s1); println!("The length of '{}' is {}.", s2, len); } fn calculate_length(s: String) -> (String, usize) { let length = s.len(); // len() 返回字串的長度 (s, length) }
但是這未免有些形式主義,而且這種場景應該很常見。幸運的是,Rust 對此提供了一個功能,叫做 引用(references)。