Rust編程基礎(chǔ)核心之所有權(quán)
什么是所有權(quán)?
Rust 的核心功能(之一)是 所有權(quán)(ownership)。雖然該功能很容易解釋,但它對語言的其他部分有著深刻的影響。
所有程序都必須管理其運(yùn)行時(shí)使用計(jì)算機(jī)內(nèi)存的方式。一些語言中具有垃圾回收機(jī)制,在程序運(yùn)行時(shí)有規(guī)律地尋找不再使用的內(nèi)存,例如:Java、Go;在另一些語言中,程序員必須親自分配和釋放內(nèi)存,例如:C、C++。Rust 則選擇了第三種方式:通過所有權(quán)系統(tǒng)管理內(nèi)存,編譯器在編譯時(shí)會(huì)根據(jù)一系列的規(guī)則進(jìn)行檢查。如果違反了任何這些規(guī)則,程序都不能編譯。在運(yùn)行時(shí),所有權(quán)系統(tǒng)的任何功能都不會(huì)減慢程序。
因?yàn)樗袡?quán)對很多程序員來說都是一個(gè)新概念,需要一些時(shí)間來適應(yīng)。隨著你對 Rust 和所有權(quán)系統(tǒng)的規(guī)則越來越有經(jīng)驗(yàn),你就越能自然地編寫出安全和高效的代碼, 需要學(xué)習(xí)者能夠持之以恒。
當(dāng)理解了所有權(quán),將會(huì)有一個(gè)堅(jiān)實(shí)的基礎(chǔ)來理解那些使 Rust 獨(dú)特的功能。
棧和堆基礎(chǔ)
在很多語言中,并不需要經(jīng)??紤]到棧與堆。不過在像 Rust 這樣的系統(tǒng)編程語言中,值是位于棧上還是堆上在更大程度上影響了語言的行為以及為何必須做出這樣的抉擇。
棧和堆都是代碼在運(yùn)行時(shí)可供使用的內(nèi)存,但是它們的結(jié)構(gòu)不同。棧以放入值的順序存儲(chǔ)值并以相反順序取出值。這也被稱作 后進(jìn)先出(last in, first out)。
增加數(shù)據(jù)叫做 進(jìn)棧(pushing onto the stack),而移出數(shù)據(jù)叫做 出棧(popping off the stack)。棧中的所有數(shù)據(jù)都必須占用已知且固定的大小。在編譯時(shí)大小未知或大小可能變化的數(shù)據(jù),要改為存儲(chǔ)在堆上。
堆是缺乏組織的:當(dāng)向堆放入數(shù)據(jù)時(shí),你要請求一定大小的空間。內(nèi)存分配器(memory allocator)在堆的某處找到一塊足夠大的空位,把它標(biāo)記為已使用,并返回一個(gè)表示該位置地址的 指針(pointer)。這個(gè)過程稱作 在堆上分配內(nèi)存(allocating on the heap),有時(shí)簡稱為 “分配”(allocating)。因?yàn)橹赶蚍湃攵阎袛?shù)據(jù)的指針是已知的并且大小是固定的,你可以將該指針存儲(chǔ)在棧上,不過當(dāng)需要實(shí)際數(shù)據(jù)時(shí),必須訪問指針。
入棧比在堆上分配內(nèi)存要快,因?yàn)椋ㄈ霔r(shí))分配器無需為存儲(chǔ)新數(shù)據(jù)去搜索內(nèi)存空間;其位置總是在棧頂。相比之下,在堆上分配內(nèi)存則需要更多的工作,這是因?yàn)榉峙淦鞅仨毷紫日业揭粔K足夠存放數(shù)據(jù)的內(nèi)存空間,并接著做一些記錄為下一次分配做準(zhǔn)備。
訪問堆上的數(shù)據(jù)比訪問棧上的數(shù)據(jù)慢,因?yàn)楸仨毻ㄟ^指針來訪問?,F(xiàn)代處理器在內(nèi)存中跳轉(zhuǎn)越少就越快(緩存)。出于同樣原因,處理器在處理的數(shù)據(jù)彼此較近的時(shí)候(比如在棧上)比較遠(yuǎn)的時(shí)候(比如可能在堆上)能更好的工作。當(dāng)你的代碼調(diào)用一個(gè)函數(shù)時(shí),傳遞給函數(shù)的值(包括可能指向堆上數(shù)據(jù)的指針)和函數(shù)的局部變量被壓入棧中。當(dāng)函數(shù)結(jié)束時(shí),這些值被移出棧。
跟蹤哪部分代碼正在使用堆上的哪些數(shù)據(jù),最大限度的減少堆上的重復(fù)數(shù)據(jù)的數(shù)量,以及清理堆上不再使用的數(shù)據(jù)確保不會(huì)耗盡空間,這些問題正是所有權(quán)系統(tǒng)要處理的。一旦理解了所有權(quán),就不需要經(jīng)??紤]棧和堆了,不過明白了所有權(quán)的主要目的就是為了管理堆數(shù)據(jù),能夠幫助解釋為什么所有權(quán)要以這種方式工作。
所有權(quán)規(guī)則
所有權(quán)規(guī)則核心主要有三條,務(wù)必牢記:
- Rust 中的每一個(gè)值都有一個(gè) 所有者(owner)。
- 值在任一時(shí)刻有且只有一個(gè)所有者。
- 當(dāng)所有者(變量)離開作用域,這個(gè)值將被丟棄。
在所有權(quán)的第一個(gè)例子中,我們看看一些變量的 作用域(scope)。作用域是一個(gè)項(xiàng)(item)在程序中有效的范圍。假設(shè)有這樣一個(gè)變量:
let s = "hello";
變量 s 綁定到了一個(gè)字符串字面值,這個(gè)字符串值是硬編碼進(jìn)程序代碼中的。這個(gè)變量從聲明的點(diǎn)開始直到當(dāng)前 作用域 結(jié)束時(shí)都是有效的??梢钥聪旅娴臉?biāo)注:
{ // s 在這里無效,它尚未聲明
let s = "hello";// 從此處起,s 是有效的
// 使用 s
} // 此作用域已結(jié)束,s 不再有效
換句話說,這里有兩個(gè)重要的時(shí)間點(diǎn):
- 當(dāng) s 進(jìn)入作用域 時(shí),它就是有效的。
- 這一直持續(xù)到它 離開作用域 為止。
目前為止,變量是否有效與作用域的關(guān)系跟其他編程語言是類似的?,F(xiàn)在在此基礎(chǔ)上介紹 String 類型。
看下面的一段代碼:
let s = String::from("hello");
這兩個(gè)冒號 :: 是運(yùn)算符,允許將特定的 from 函數(shù)置于 String 類型的命名空間(namespace)下,而不需要使用類似 string_from 這樣的名字。
可以 修改此類字符串如下:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字符串后追加字面值
println!("{}", s); // 將打印 `hello, world!`
我們已經(jīng)見過字符串字面值,即被硬編碼進(jìn)程序里的字符串值, 它們是不可變的。那么這里有什么區(qū)別呢?為什么 String 可變而字面值卻不行呢?區(qū)別在于兩個(gè)類型對內(nèi)存的處理上。
所有權(quán)內(nèi)存和分配
對于字符串字面值,我們在編譯時(shí)就知道其內(nèi)容,所以文本被直接硬編碼進(jìn)最終的可執(zhí)行文件中。這使得字符串字面值快速且高效。不過這些特性都只得益于字符串字面值的不可變性。不幸的是,我們不能為了每一個(gè)在編譯時(shí)大小未知的文本而將一塊內(nèi)存放入二進(jìn)制文件中,并且它的大小還可能隨著程序運(yùn)行而改變。
對于 String 類型,為了支持一個(gè)可變,可增長的文本片段,需要在堆上分配一塊在編譯時(shí)未知大小的內(nèi)存來存放內(nèi)容。這意味著:
- 必須在運(yùn)行時(shí)向內(nèi)存分配器(memory allocator)請求內(nèi)存。
- 需要一個(gè)當(dāng)我們處理完 String 時(shí)將內(nèi)存返回給分配器的方法。
第一部分由我們完成:當(dāng)調(diào)用 String::from 時(shí),它的實(shí)現(xiàn) (implementation) 請求其所需的內(nèi)存。這在編程語言中是非常通用的。
然而,第二部分實(shí)現(xiàn)起來就各有區(qū)別了。在有 垃圾回收(garbage collector,GC)的語言中,GC 記錄并清除不再使用的內(nèi)存,而我們并不需要關(guān)心它。在大部分沒有 GC 的語言中,識別出不再使用的內(nèi)存并調(diào)用代碼顯式釋放就是我們的責(zé)任了,跟請求內(nèi)存的時(shí)候一樣。從歷史的角度上說正確處理內(nèi)存回收曾經(jīng)是一個(gè)困難的編程問題。如果忘記回收了會(huì)浪費(fèi)內(nèi)存。如果過早回收了,將會(huì)出現(xiàn)無效變量。如果重復(fù)回收,這也是個(gè) bug。我們需要精確的為一個(gè) allocate 配對一個(gè) free。
Rust 采取了一個(gè)不同的策略:內(nèi)存在擁有它的變量離開作用域后就被自動(dòng)釋放。下面是作用域例子的一個(gè)使用 String 而不是字符串字面值的版本:
{
let s = String::from("hello"); // 從此處起,s 是有效的
// 使用 s
} // 此作用域已結(jié)束,
// s 不再有效
這是一個(gè)將 String 需要的內(nèi)存返回給分配器的很自然的位置:當(dāng) s 離開作用域的時(shí)候。當(dāng)變量離開作用域,Rust 為我們調(diào)用一個(gè)特殊的函數(shù)。這個(gè)函數(shù)叫做 drop,在這里 String 的作者可以放置釋放內(nèi)存的代碼。Rust 在結(jié)尾的 } 處自動(dòng)調(diào)用 drop。
這個(gè)模式對編寫 Rust 代碼的方式有著深遠(yuǎn)的影響。現(xiàn)在它看起來很簡單,不過在更復(fù)雜的場景下代碼的行為可能是不可預(yù)測的,比如當(dāng)有多個(gè)變量使用在堆上分配的內(nèi)存時(shí)。下面來探索一些場景。
變量與數(shù)據(jù)交互方式之移動(dòng)
在 Rust 中,多個(gè)變量可以采取不同的方式與同一數(shù)據(jù)進(jìn)行交互??聪旅娴睦?
let x = 5;
let y = x;
我們大致可以猜到這在干什么:將 5 綁定到 x;接著生成一個(gè)值 x 的拷貝并綁定到 y。現(xiàn)在有了兩個(gè)變量,x 和 y,都等于 5。因?yàn)檎麛?shù)是有已知固定大小的簡單值,所以這兩個(gè) 5 被放入了棧中。
現(xiàn)在看看這個(gè) String 版本:
let s1 = String::from("hello");
let s2 = s1;
這看起來與上面的代碼非常類似,所以我們可能會(huì)假設(shè)它們的運(yùn)行方式也是類似的:也就是說,第二行可能會(huì)生成一個(gè) s1 的拷貝并綁定到 s2 上。不過,事實(shí)上并不完全是這樣。
下面先看一張圖解:
從左邊代表的s1內(nèi)容可以看到, String是由三部分組成: 一個(gè)指向存放字符串內(nèi)容內(nèi)存的指針, 一個(gè)是長度和一個(gè)容量。這一組數(shù)據(jù)存儲(chǔ)在棧上, 而右側(cè)的數(shù)據(jù), 也就是"hello"字符串內(nèi)容則是存儲(chǔ)在堆上。
這里我們要區(qū)分一下長度和容量。長度是表示String的內(nèi)容當(dāng)前使用了多少字節(jié)的內(nèi)存; 而容量是String從分配器總共獲取了多少字節(jié)的內(nèi)存。長度和容量的區(qū)別非常重要, 但在這里的上下文中并不重要, 所以現(xiàn)在暫時(shí)忽略容量。
當(dāng)我們將s1賦值給s2, String的數(shù)據(jù)被復(fù)制了, 這意味著我們從棧上拷貝了它的指針、長度和容量。但并沒有復(fù)制指針指向的堆上的數(shù)據(jù):"hello", 為了更好的理解, 可以參考下面的圖解:
從圖中可以看出, 將s1賦給s2之后, s2有一份s1的拷貝,內(nèi)容是: ptr、len和capacity, 設(shè)想一下, 如果此時(shí)Rust也拷貝了堆上的數(shù)據(jù)將會(huì)發(fā)生什么?那么內(nèi)存看起來就像下面這樣:
如果Rust真的這樣做了, 在操作s2 = s1的過程中,假如堆里的數(shù)據(jù)不是"hello",而是一串大數(shù)據(jù), 那么在運(yùn)行時(shí)可能會(huì)對性能造成重大的影響。
之前我們提到過當(dāng)變量離開作用域后,Rust 自動(dòng)調(diào)用 drop 函數(shù)并清理變量的堆內(nèi)存。當(dāng)執(zhí)行語句:s2 = s1時(shí), 兩個(gè)數(shù)據(jù)指針指向了同一個(gè)位置, 此時(shí)就有一個(gè)問題: 當(dāng)s2和s1離開作用域, 它們都會(huì)嘗試釋放相同的內(nèi)存, 這是一個(gè)典型的二次釋放(double free)的錯(cuò)誤, 也是之前提到過的內(nèi)存安全性bug之一, 兩次釋放(相同)內(nèi)存會(huì)導(dǎo)致內(nèi)存污染, 它可能會(huì)導(dǎo)致潛在的安全漏洞。
為了確保內(nèi)存安全,在 let s2 = s1; 之后,Rust 認(rèn)為 s1 不再有效,因此 Rust 不需要在 s1 離開作用域后清理任何東西??纯丛?nbsp;s2 被創(chuàng)建之后嘗試使用 s1 會(huì)發(fā)生什么, 代碼如下:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
這段代碼執(zhí)行后, 會(huì)得到一個(gè)錯(cuò)誤, 因?yàn)镽ust禁止使用無效的引用,如圖:
如果在其他語言中聽說過術(shù)語 淺拷貝(shallow copy)和 深拷貝(deep copy),那么拷貝指針、長度和容量而不拷貝數(shù)據(jù)可能聽起來像淺拷貝。不過因?yàn)?Rust 同時(shí)使第一個(gè)變量無效了,這個(gè)操作被稱為 移動(dòng)(move),而不是叫做淺拷貝。上面的例子可以解讀為 s1 被 移動(dòng) 到了 s2 中。那么具體發(fā)生了什么,可以參考下圖:
當(dāng)執(zhí)行l(wèi)et s2 = s1后, s1被移動(dòng)到s2, 隨后被釋放, 這樣就解決了二次釋放問題, 只有s2是有效的, 當(dāng)其離開作用域, s2會(huì)釋放自己的內(nèi)存,完美解決。
另外,這里還隱含了一個(gè)設(shè)計(jì)選擇:Rust 永遠(yuǎn)也不會(huì)自動(dòng)創(chuàng)建數(shù)據(jù)的 “深拷貝”。因此,任何 自動(dòng) 的復(fù)制可以被認(rèn)為對運(yùn)行時(shí)性能影響較小。
變量與數(shù)據(jù)交互方式之二: 克隆
如果我們 確實(shí) 需要深度復(fù)制 String 中堆上的數(shù)據(jù),而不僅僅是棧上的數(shù)據(jù),可以使用一個(gè)叫做 clone 的通用函數(shù)。
看下面的代碼:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
這段代碼能正常運(yùn)行, 并且堆上的數(shù)據(jù)現(xiàn)在可以被復(fù)制了。
我們在代碼中下個(gè)斷點(diǎn), 使用調(diào)試器觀察下s1的內(nèi)容,如圖:
結(jié)合上一章節(jié)的分析, 此時(shí)s1變量中保存的指針ptr指向的內(nèi)存保存了內(nèi)容:"hello"。
現(xiàn)在執(zhí)行語句: let s2 = s1.clone(); 單不執(zhí)行一下看下s2的內(nèi)容,如圖:
可以看到, clone()函數(shù)的確將字符串內(nèi)容復(fù)制到變量s2中。
注意:當(dāng)出現(xiàn) clone 調(diào)用時(shí),我們心里要清楚一些特定的代碼被執(zhí)行而且這些代碼可能相當(dāng)消耗資源。很容易能察覺到一些不尋常的事情正在發(fā)生。
下面再看一段代碼:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
執(zhí)行這段代碼, 結(jié)果如下:
這段代碼似乎與我們剛剛學(xué)到的內(nèi)容相矛盾:沒有調(diào)用 clone,不過 x 依然有效且沒有被移動(dòng)到 y 中。
原因是像整型這樣的在編譯時(shí)已知大小的類型被整個(gè)存儲(chǔ)在棧上,所以拷貝其實(shí)際的值是快速的。這意味著沒有理由在創(chuàng)建變量 y 后使 x 無效。換句話說,這里沒有深淺拷貝的區(qū)別,所以這里調(diào)用 clone 并不會(huì)與通常的淺拷貝有什么不同,我們可以不用管它。
Rust 有一個(gè)叫做 Copy trait 的特殊注解,可以用在類似整型這樣的存儲(chǔ)在棧上的類型上, 如果一個(gè)類型實(shí)現(xiàn)了 Copy trait,那么一個(gè)舊的變量在將其賦值給其他變量后仍然可用。
Rust 不允許自身或其任何部分實(shí)現(xiàn)了 Drop trait 的類型使用 Copy trait。如果我們對其值離開作用域時(shí)需要特殊處理的類型使用 Copy 注解,將會(huì)出現(xiàn)一個(gè)編譯時(shí)錯(cuò)誤。
那么哪些類型實(shí)現(xiàn)了 Copy trait 呢?可以查看給定類型的文檔來確認(rèn),不過作為一個(gè)通用的規(guī)則,任何一組簡單標(biāo)量值的組合都可以實(shí)現(xiàn) Copy,任何不需要分配內(nèi)存或某種形式資源的類型都可以實(shí)現(xiàn) Copy 。如下是一些 Copy 的類型:
- 所有整數(shù)類型,比如 u32。
- 布爾類型,bool,它的值是 true 和 false。
- 所有浮點(diǎn)數(shù)類型,比如 f64。
- 字符類型,char。
- 元組,當(dāng)且僅當(dāng)其包含的類型也都實(shí)現(xiàn) Copy 的時(shí)候。比如,(i32, i32) 實(shí)現(xiàn)了 Copy,但 (i32, String) 就沒有。
所有權(quán)和函數(shù)
將值傳遞給函數(shù)與給變量賦值的原理相似。向函數(shù)傳遞值可能會(huì)移動(dòng)或者復(fù)制,就像賦值語句一樣。
看一下下面的代碼:
fn main() {
let s = String::from("hello"); // s 進(jìn)入作用域
takes_ownership(s); // s 的值移動(dòng)到函數(shù)里 ...
// ... 所以到這里不再有效
let x = 5; // x 進(jìn)入作用域
makes_copy(x); // x 應(yīng)該移動(dòng)函數(shù)里,
// 但 i32 是 Copy 的,
// 所以在后面可繼續(xù)使用 x
} // 這里,x 先移出了作用域,然后是 s。但因?yàn)?s 的值已被移走,
// 沒有特殊之處
fn takes_ownership(some_string: String) { // some_string 進(jìn)入作用域
println!("{}", some_string);
} // 這里,some_string 移出作用域并調(diào)用 `drop` 方法。
// 占用的內(nèi)存被釋放
fn makes_copy(some_integer: i32) { // some_integer 進(jìn)入作用域
println!("{}", some_integer);
} // 這里,some_integer 移出作用域。沒有特殊之處
當(dāng)嘗試在調(diào)用 takes_ownership 后使用 s 時(shí),Rust 會(huì)拋出一個(gè)編譯時(shí)錯(cuò)誤。這些靜態(tài)檢查使我們免于犯錯(cuò)。
返回值與作用域
返回值也可以轉(zhuǎn)移所有權(quán), 看下面的代碼:
fn main() {
let s1 = gives_ownership(); // gives_ownership 將返回值
// 轉(zhuǎn)移給 s1
let s2 = String::from("hello"); // s2 進(jìn)入作用域
let s3 = takes_and_gives_back(s2); // s2 被移動(dòng)到
// takes_and_gives_back 中,
// 它也將返回值移給 s3
} // 這里,s3 移出作用域并被丟棄。s2 也移出作用域,但已被移走,
// 所以什么也不會(huì)發(fā)生。s1 離開作用域并被丟棄
fn gives_ownership() -> String { // gives_ownership 會(huì)將
// 返回值移動(dòng)給
// 調(diào)用它的函數(shù)
let some_string = String::from("yours"); // some_string 進(jìn)入作用域。
some_string // 返回 some_string
// 并移出給調(diào)用的函數(shù)
//
}
// takes_and_gives_back 將傳入字符串并返回該值
fn takes_and_gives_back(a_string: String) -> String { // a_string 進(jìn)入作用域
//
a_string // 返回 a_string 并移出給調(diào)用的函數(shù)
}
變量的所有權(quán)總是遵循相同的模式:將值賦給另一個(gè)變量時(shí)移動(dòng)它。當(dāng)持有堆中數(shù)據(jù)值的變量離開作用域時(shí),其值將通過 drop 被清理掉,除非數(shù)據(jù)被移動(dòng)為另一個(gè)變量所有。
雖然這樣是可以的,但是在每一個(gè)函數(shù)中都獲取所有權(quán)并接著返回所有權(quán)有些啰嗦。如果我們想要函數(shù)使用一個(gè)值但不獲取所有權(quán)該怎么辦呢?如果我們還要接著使用它的話,每次都傳進(jìn)去再返回來就有點(diǎn)煩人了,除此之外,我們也可能想返回函數(shù)體中產(chǎn)生的一些數(shù)據(jù)。
我們可以使用元組來返回多個(gè)值, 看下面的代碼:
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)
}
但是這未免有些形式主義,而且這種場景應(yīng)該很常見。幸運(yùn)的是,Rust 對此提供了一個(gè)不用獲取所有權(quán)就可以使用值的功能,叫做 引用(references)。