作者:京東零售 周凱
一.前言
Rust 語言由 Mozilla 開發,最早發布于 2014 年 9 月,是一種高效、可靠的通用高級語言,其高效不僅限于開發效率,它的執行效率也是令人稱贊的,是一種少有的兼顧開發效率和執行效率的語言,Rust語言具備如下特性:
?高性能 - Rust 速度驚人且記憶體利用率極高,由于沒有運行時和垃圾回收,它能夠勝任對性能要求特別高的服務,可以在嵌入式設備上運行,還能輕松和其他語言集成,
?可靠性 - Rust 豐富的型別系統和所有權模型保證了記憶體安全和執行緒安全,讓您在編譯期就能夠消除各種各樣的錯誤,
?生產力 - Rust 擁有出色的檔案、友好的編譯器和清晰的錯誤提示資訊, 還集成了一流的工具 —— 包管理器和構建工具, 智能地自動補全和型別檢驗的多編輯器支持, 以及自動格式化代碼等等,
Rust最近幾年發展非常迅速,廣受一執行緒式員的歡迎,Rust有一個官方維護的模塊庫(crates.io: Rust Package Registry),可以通過編譯器自帶的cargo管理工具方便的引入模塊,目前crates.io上面的模塊數量已經突破10萬個,仍在快速增長,此情此景仿佛過去10年node.js的發展情景再現,
12月11日,Linus Torvalds發布了Linux6.1內核穩定版,并帶來一個重磅的新聞,即Linux6.1將包含對Rust語言的原生支持,盡管這一功能仍在構建中,不過這也意味著,在可見的將來,Linux的歷史將翻開嶄新的一頁——除了C之外,開發人員將第一次能夠使用另一種語言Rust進行內核開發,
在近幾年的討論中,是否在Linux內核中引入Rust多次成為議題,不過包括 Torvalds在內的一眾關鍵人物均對此表示了期待,早在2019年,Alex Gaynor和Geoffrey Thomas就曾于Linux Security Summit安全峰會上進行了演講,他們指出,在Android和Ubuntu中,約有三分之二的內核漏洞被分配到CVE中,這些漏洞都是來自于記憶體安全問題,原則上,Rust可以通過其type system和borrow checker所提供的更安全的API來完全避免這類錯誤,簡言之,Rust比C更安全,谷歌Android團隊的Wedson Almeida Filho也曾公開表示:“我們覺得Rust現在已經準備好加入C語言,作為實作內核的實用語言,它可以幫助我們減少特權代碼中潛在錯誤和安全漏洞的數量,同時很好地與核心內核配合并保留其性能特征,”
當前,谷歌在Android中廣泛使用Rust,在那里,“目標不是將現有的C/C++轉換為Rust,而是隨著時間的推移,將新代碼的開發轉移到記憶體安全語言”,這一言論也逐漸在實踐中得到論證,“隨著進入Android的新記憶體不安全代碼的數量減少,記憶體安全漏洞的數量也在減少,從2019年到2022年,相關漏洞占比已從Android總漏洞的76%下降到35%,2022年,在Android漏洞排行中,記憶體安全漏洞第一次不再是主因,”
本文將探尋相比于其他語言,Rust是怎樣實作記憶體安全的,Rust針對創建于記憶體堆上的復雜資料型別,設計了一套獨有的記憶體管理機制,該套機制包含變數的所有權機制、變數的作用域、變數的參考與借用,并專門針對字串、陣列、元組等復雜型別設計了slice型別,下面將具體講述這些機制與規則,
二.變數的所有權
Rust 的核心功能(之一)是 所有權(ownership),雖然該功能很容易解釋,但它對語言的其他部分有著深刻的影響,
所有程式都必須管理其運行時使用計算機記憶體的方式,一些語言中具有垃圾回識訓制,在程式運行時有規律地尋找不再使用的記憶體;在另一些語言中,程式員必須親自分配和釋放記憶體,Rust 則選擇了第三種方式:通過所有權系統管理記憶體,編譯器在編譯時會根據一系列的規則進行檢查,如果違反了任何這些規則,程式都不能編譯,在運行時,所有權系統的任何功能都不會減慢程式,
因為所有權對很多程式員來說都是一個新概念,需要一些時間來適應,好訊息是隨著你對 Rust 和所有權系統的規則越來越有經驗,你就越能自然地撰寫出安全和高效的代碼,持之以恒!
當你理解了所有權,你將有一個堅實的基礎來理解那些使 Rust 獨特的功能,在本章中,我們將通過完成一些示例來介紹所有權,這些示例基于一個常用的資料結構:字串,
堆疊(Stack)與堆(Heap)在很多語言中,你并不需要經常考慮到堆疊與堆,不過在像 Rust 這樣的系統編程語言中,值是位于堆疊上還是堆上在更大程度上影響了語言的行為以及為何必須做出這樣的抉擇,我們會在本文的稍后部分描述所有權與堆疊和堆相關的內容,所以這里只是一個用來預熱的簡要解釋,堆疊和堆都是代碼在運行時可供使用的記憶體,但是它們的結構不同,堆疊以放入值的順序存盤值并以相反順序取出值,這也被稱作 后進先出(last in, first out),想象一下一疊盤子:當增加更多盤子時,把它們放在盤子堆的頂部,當需要盤子時,也從頂部拿走,不能從中間也不能從底部增加或拿走盤子!增加資料叫做 進堆疊(pushing onto the stack),而移出資料叫做 出堆疊(popping off the stack),堆疊中的所有資料都必須占用已知且固定的大小,在編譯時大小未知或大小可能變化的資料,要改為存盤在堆上, 堆是缺乏組織的:當向堆放入資料時,你要請求一定大小的空間,記憶體分配器(memory allocator)在堆的某處找到一塊足夠大的空位,把它標記為已使用,并回傳一個表示該位置地址的 指標(pointer),這個程序稱作 在堆上分配記憶體(allocating on the heap),有時簡稱為 “分配”(allocating),(將資料推入堆疊中并不被認為是分配),因為指向放入堆中資料的指標是已知的并且大小是固定的,你可以將該指標存盤在堆疊上,不過當需要實際資料時,必須訪問指標,想象一下去餐館就座吃飯,當進入時,你說明有幾個人,餐館員工會找到一個夠大的空桌子并領你們過去,如果有人來遲了,他們也可以通過詢問來找到你們坐在哪,入堆疊比在堆上分配記憶體要快,因為(入堆疊時)分配器無需為存盤新資料去搜索記憶體空間;其位置總是在堆疊頂,相比之下,在堆上分配記憶體則需要更多的作業,這是因為分配器必須首先找到一塊足夠存放資料的記憶體空間,并接著做一些記錄為下一次分配做準備,訪問堆上的資料比訪問堆疊上的資料慢,因為必須通過指標來訪問,現代處理器在記憶體中跳轉越少就越快(快取),繼續類比,假設有一個服務員在餐廳里處理多個桌子的點菜,在一個桌子報完所有菜后再移動到下一個桌子是最有效率的,從桌子 A 聽一個菜,接著桌子 B 聽一個菜,然后再桌子 A,然后再桌子 B 這樣的流程會更加緩慢,出于同樣原因,處理器在處理的資料彼此較近的時候(比如在堆疊上)比較遠的時候(比如可能在堆上)能更好的作業,當你的代碼呼叫一個函式時,傳遞給函式的值(包括可能指向堆上資料的指標)和函式的區域變數被壓入堆疊中,當函式結束時,這些值被移出堆疊,跟蹤哪部分代碼正在使用堆上的哪些資料,最大限度的減少堆上的重復資料的數量,以及清理堆上不再使用的資料確保不會耗盡空間,這些問題正是所有權系統要處理的,一旦理解了所有權,你就不需要經常考慮堆疊和堆了,不過明白了所有權的主要目的就是為了管理堆資料,能夠幫助解釋為什么所有權要以這種方式作業,
2.1.所有權規則
首先,讓我們看一下所有權的規則,當我們通過舉例說明時,請謹記這些規則:
Rust 中的每一個值都有一個 所有者(owner),值在任一時刻有且只有一個所有者,當所有者(變數)離開作用域,這個值將被丟棄,
2.2.變數作用域
既然我們已經掌握了基本語法,將不會在之后的例子中包含 fn main() { 代碼,所以如果你是一路跟過來的,必須手動將之后例子的代碼放入一個 main 函式中,這樣,例子將顯得更加簡明,使我們可以關注實際細節而不是樣板代碼,
在所有權的第一個例子中,我們看看一些變數的 作用域(scope),作用域是一個項(item)在程式中有效的范圍,假設有這樣一個變數:
let s = "hello";
變數 s 系結到了一個字串字面值,這個字串值是硬編碼行程式代碼中的,這個變數從宣告的點開始直到當前 作用域 結束時都是有效的,示例 1 中的注釋標明了變數 s 在何處是有效的,
{ // s 在這里無效, 它尚未宣告
let s = "hello"; // 從此處起,s 是有效的
// 使用 s
} // 此作用域已結束,s 不再有效
示例 1:一個變數和其有效的作用域
換句話說,這里有兩個重要的時間點:
?當 s 進入作用域 時,它就是有效的,
?這一直持續到它 離開作用域 為止 ,
目前為止,變數是否有效與作用域的關系跟其他編程語言是類似的,現在我們在此基礎上介紹 String 型別,
2.3.String 型別
為了演示所有權的規則,我們需要一個比基本資料型別都要復雜的資料型別,前面介紹的型別都是已知大小的,可以存盤在堆疊中,并且當離開作用域時被移出堆疊,如果代碼的另一部分需要在不同的作用域中使用相同的值,可以快速簡單地復制它們來創建一個新的獨立實體,不過我們需要尋找一個存盤在堆上的資料來探索 Rust 是如何知道該在何時清理資料的,
我們會專注于 String 與所有權相關的部分,這些方面也同樣適用于標準庫提供的或你自己創建的其他復雜資料型別,
我們已經見過字串字面值,即被硬編碼行程式里的字串值,字串字面值是很方便的,不過它們并不適合使用文本的每一種場景,原因之一就是它們是不可變的,另一個原因是并非所有字串的值都能在撰寫代碼時就知道:例如,要是想獲取用戶輸入并存盤該怎么辦呢?為此,Rust 有第二個字串型別,String,這個型別管理被分配到堆上的資料,所以能夠存盤在編譯時未知大小的文本,可以使用 from 函式基于字串字面值來創建 String,如下:
let s = String::from("hello");
這兩個冒號 :: 是運算子,允許將特定的 from 函式置于 String 型別的命名空間(namespace)下,而不需要使用類似 string_from 這樣的名字,
可以 修改此類字串 :
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字串后追加字面值
println!("{}", s); // 將列印 `hello, world!`
那么這里有什么區別呢?為什么 String 可變而字面值卻不行呢?區別在于兩個型別對記憶體的處理上,
2.4.記憶體與分配
就字串字面值來說,我們在編譯時就知道其內容,所以文本被直接硬編碼進最終的可執行檔案中,這使得字串字面值快速且高效,不過這些特性都只得益于字串字面值的不可變性,不幸的是,我們不能為了每一個在編譯時大小未知的文本而將一塊記憶體放入二進制檔案中,并且它的大小還可能隨著程式運行而改變,
對于 String 型別,為了支持一個可變,可增長的文本片段,需要在堆上分配一塊在編譯時未知大小的記憶體來存放內容,這意味著:
?必須在運行時向記憶體分配器(memory allocator)請求記憶體,
?需要一個當我們處理完 String 時將記憶體回傳給分配器的方法,
第一部分由我們完成:當呼叫 String::from 時,它的實作 (implementation) 請求其所需的記憶體,這在編程語言中是非常通用的,
然而,第二部分實作起來就各有區別了,在有 垃圾回收(garbage collector,GC)的語言中, GC 記錄并清除不再使用的記憶體,而我們并不需要關心它,在大部分沒有 GC 的語言中,識別出不再使用的記憶體并呼叫代碼顯式釋放就是我們的責任了,跟請求記憶體的時候一樣,從歷史的角度上說正確處理記憶體回收曾經是一個困難的編程問題,如果忘記回收了會浪費記憶體,如果過早回收了,將會出現無效變數,如果重復回收,這也是個 bug,我們需要精確的為一個 allocate 配對一個 free,
Rust 采取了一個不同的策略:記憶體在擁有它的變數離開作用域后就被自動釋放,下面是示例 1 中作用域例子的一個使用 String 而不是字串字面值的版本:
{
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 代碼的方式有著深遠的影響,現在它看起來很簡單,不過在更復雜的場景下代碼的行為可能是不可預測的,比如當有多個變數使用在堆上分配的記憶體時,現在讓我們探索一些這樣的場景,
2.4.1.變數與資料互動的方式(一):移動
在Rust 中,多個變數可以采取不同的方式與同一資料進行互動,讓我們看看示例 2 中一個使用整型的例子,
let x = 5;
let y = x;
示例 2:將變數 x 的整數值賦給 y
我們大致可以猜到這在干什么:“將 5 系結到 x;接著生成一個值 x 的拷貝并系結到 y”,現在有了兩個變數,x 和 y,都等于 5,這也正是事實上發生了的,因為整數是有已知固定大小的簡單值,所以這兩個 5 被放入了堆疊中,
現在看看這個 String 版本:
let s1 = String::from("hello");
let s2 = s1;
這看起來與上面的代碼非常類似,所以我們可能會假設他們的運行方式也是類似的:也就是說,第二行可能會生成一個 s1 的拷貝并系結到 s2 上,不過,事實上并不完全是這樣,
看看圖1 以了解 String 的底層會發生什么,String 由三部分組成,如圖左側所示:一個指向存放字串內容記憶體的指標,一個長度,和一個容量,這一組資料存盤在堆疊上,右側則是堆上存放內容的記憶體部分,
圖 1:將值 "hello" 系結給 s1 的 String 在記憶體中的表現形式
長度表示 String 的內容當前使用了多少位元組的記憶體,容量是 String 從分配器總共獲取了多少位元組的記憶體,長度與容量的區別是很重要的,不過在當前背景關系中并不重要,所以現在可以忽略容量,
當我們將 s1 賦值給 s2,String 的資料被復制了,這意味著我們從堆疊上拷貝了它的指標、長度和容量,我們并沒有復制指標指向的堆上資料,換句話說,記憶體中資料的表現如圖2 所示,
圖 2:變數 s2 的記憶體表現,它有一份 s1 指標、長度和容量的拷貝
這個表現形式看起來 并不像 圖3 中的那樣,如果 Rust 也拷貝了堆上的資料,那么記憶體看起來就是這樣的,如果 Rust 這么做了,那么操作 s2 = s1 在堆上資料比較大的時候會對運行時性能造成非常大的影響,
圖 3:另一個 s2 = s1 時可能的記憶體表現,如果 Rust 同時也拷貝了堆上的資料的話
之前我們提到過當變數離開作用域后,Rust 自動呼叫 drop 函式并清理變數的堆記憶體,不過圖 2 展示了兩個資料指標指向了同一位置,這就有了一個問題:當 s2 和 s1 離開作用域,他們都會嘗試釋放相同的記憶體,這是一個叫做 二次釋放(double free)的錯誤,也是之前提到過的記憶體安全性 bug 之一,兩次釋放(相同)記憶體會導致記憶體污染,它可能會導致潛在的安全漏洞,
為了確保記憶體安全,在 let s2 = s1 之后,Rust 認為 s1 不再有效,因此 Rust 不需要在 s1 離開作用域后清理任何東西,看看在 s2 被創建之后嘗試使用 s1 會發生什么;這段代碼不能運行:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
你會得到一個類似如下的錯誤,因為 Rust 禁止你使用無效的參考,
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
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!("{}, world!", s1);
| ^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error
如果你在其他語言中聽說過術語 淺拷貝(shallow copy)和 深拷貝(deep copy),那么拷貝指標、長度和容量而不拷貝資料可能聽起來像淺拷貝,不過因為 Rust 同時使第一個變數無效了,這個操作被稱為 移動(move),而不是淺拷貝,上面的例子可以解讀為 s1被 移動 到了 s2 中,那么具體發生了什么,如圖 4 所示,
圖 4:s1 無效之后的記憶體表現
這樣就解決了我們的問題!因為只有 s2 是有效的,當其離開作用域,它就釋放自己的記憶體,完畢,
另外,這里還隱含了一個設計選擇:Rust 永遠也不會自動創建資料的 “深拷貝”,因此,任何 自動 的復制可以被認為對運行時性能影響較小,
2.4.2.變數與資料互動的方式(二):克隆
如果我們 確實 需要深度復制 String 中堆上的資料,而不僅僅是堆疊上的資料,可以使用一個叫做 clone 的通用函式,第五章會討論方法語法,不過因為方法在很多語言中是一個常見功能,所以之前你可能已經見過了,
這是一個實際使用 clone 方法的例子:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
這段代碼能正常運行,并且明確產生圖 3 中行為,這里堆上的資料 確實 被復制了,
當出現 clone 呼叫時,你知道一些特定的代碼被執行而且這些代碼可能相當消耗資源,你很容易察覺到一些不尋常的事情正在發生,
2.4.3.只在堆疊上的資料:拷貝
這里還有一個沒有提到的小竅門,這些代碼使用了整型并且是有效的,他們是示例 2 中的一部分:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
但這段代碼似乎與我們剛剛學到的內容相矛盾:沒有呼叫 clone,不過 x 依然有效且沒有被移動到 y 中,
原因是像整型這樣的在編譯時已知大小的型別被整個存盤在堆疊上,所以拷貝其實際的值是快速的,這意味著沒有理由在創建變數 y 后使 x 無效,換句話說,這里沒有深淺拷貝的區別,所以這里呼叫 clone 并不會與通常的淺拷貝有什么不同,我們可以不用管它,
Rust 有一個叫做 Copy trait 的特殊注解,可以用在類似整型這樣的存盤在堆疊上的型別上,如果一個型別實作了 Copy trait,那么一個舊的變數在將其賦值給其他變數后仍然可用,
Rust 不允許自身或其任何部分實作了 Drop trait 的型別使用 Copy trait,如果我們對其值離開作用域時需要特殊處理的型別使用 Copy 注解,將會出現一個編譯時錯誤,
那么哪些型別實作了 Copy trait 呢?你可以查看給定型別的檔案來確認,不過作為一個通用的規則,任何一組簡單標量值的組合都可以實作 Copy,任何不需要分配記憶體或某種形式資源的型別都可以實作 Copy ,如下是一些 Copy 的型別:
?所有整數型別,比如 u32,
?布爾型別,bool,它的值是 true 和 false,
?所有浮點數型別,比如 f64,
?字符型別,char,
?元組,當且僅當其包含的型別也都實作 Copy 的時候,比如,(i32, i32) 實作了 Copy,但 (i32, String) 就沒有,
2.5.所有權與函式
將值傳遞給函式與給變數賦值的原理相似,向函式傳遞值可能會移動或者復制,就像賦值陳述句一樣,示例 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 移出作用域,沒有特殊之處
示例 3:帶有所有權和作用域注釋的函式
當嘗試在呼叫 takes_ownership 后使用 s 時,Rust 會拋出一個編譯時錯誤,這些靜態檢查使我們免于犯錯,試試在 main 函式中添加使用 s 和 x 的代碼來看看哪里能使用他們,以及所有權規則會在哪里阻止我們這么做,
2.6.回傳值與作用域
回傳值也可以轉移所有權,示例 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
// 并移出給呼叫的函式
//
}
// takes_and_gives_back 將傳入字串并回傳該值
fn takes_and_gives_back(a_string: String) -> String { // a_string 進入作用域
//
a_string // 回傳 a_string 并移出給呼叫的函式
}
示例 4: 轉移回傳值的所有權
變數的所有權總是遵循相同的模式:將值賦給另一個變數時移動它,當持有堆中資料值的變數離開作用域時,其值將通過 drop 被清理掉,除非資料被移動為另一個變數所有,
雖然這樣是可以的,但是在每一個函式中都獲取所有權并接著回傳所有權有些啰嗦,如果我們想要函式使用一個值但不獲取所有權該怎么辦呢?如果我們還要接著使用它的話,每次都傳進去再回傳來就有點煩人了,除此之外,我們也可能想回傳函式體中產生的一些資料,
我們可以使用元組來回傳多個值,如示例 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)
}
示例 5: 回傳引數的所有權
但是這未免有些形式主義,而且這種場景應該很常見,幸運的是,Rust 對此提供了一個不用獲取所有權就可以使用值的功能,叫做 參考(references),
三.參考與借用
示例 5 中的元組代碼有這樣一個問題:我們必須將 String 回傳給呼叫函式,以便在呼叫 calculate_length 后仍能使用 String,因為 String 被移動到了 calculate_length 內,相反我們可以提供一個 String 值的參考(reference),參考(reference)像一個指標,因為它是一個地址,我們可以由此訪問儲存于該地址的屬于其他變數的資料, 與指標不同,參考確保指向某個特定型別的有效值,
下面是如何定義并使用一個(新的)calculate_length 函式,它以一個物件的參考作為引數而不是獲取值的所有權:
檔案名: src/main.rs
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
首先,注意變數宣告和函式回傳值中的所有元組代碼都消失了,其次,注意我們傳遞 &s1 給 calculate_length,同時在函式定義中,我們獲取 &String 而不是 String,這些 & 符號就是 參考,它們允許你使用值但不獲取其所有權,圖 5 展示了一張示意圖,
圖 5:&String s 指向 String s1 示意圖
注意:與使用 & 參考相反的操作是 解參考(dereferencing),它使用解參考運算子,*,我們將會在第八章遇到一些解參考運算子,并在第十五章詳細討論解參考,
仔細看看這個函式呼叫:
let s1 = String::from("hello");
let len = calculate_length(&s1);
&s1 語法讓我們創建一個 指向 值 s1 的參考,但是并不擁有它,因為并不擁有這個值,所以當參考停止使用時,它所指向的值也不會被丟棄,
同理,函式簽名使用 & 來表明引數 s 的型別是一個參考,讓我們增加一些解釋性的注釋:
fn calculate_length(s: &String) -> usize { // s是String的參考
s.len()
} // 這里,s 離開了作用域,但因為它并不擁有參考值的所有權,
// 所以什么也不會發生
變數 s 有效的作用域與函式引數的作用域一樣,不過當 s 停止使用時并不丟棄參考指向的資料,因為 s 并沒有所有權,當函式使用參考而不是實際值作為引數,無需回傳值來交還所有權,因為就不曾擁有所有權,
我們將創建一個參考的行為稱為 借用(borrowing),正如現實生活中,如果一個人擁有某樣東西,你可以從他那里借來,當你使用完畢,必須還回去,我們并不擁有它,
如果我們嘗試修改借用的變數呢?嘗試示例 6 中的代碼,劇透:這行不通!
檔案名: src/main.rs
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
some_string.push_str(", world");
}
示例 6:嘗試修改借用的值
這里是錯誤:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` due to previous error
正如變數默認是不可變的,參考也一樣,(默認)不允許修改參考的值,
3.1.可變參考
我們通過一個小調整就能修復示例 6 代碼中的錯誤,允許我們修改一個借用的值,這就是 可變參考(mutable reference):
檔案名: src/main.rs
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
首先,我們必須將 s 改為 mut,然后在呼叫 change 函式的地方創建一個可變參考 &mut s,并更新函式簽名以接受一個可變參考 some_string: &mut String,這就非常清楚地表明,change 函式將改變它所借用的值,
可變參考有一個很大的限制:如果你有一個對該變數的可變參考,你就不能再創建對該變數的參考,這些嘗試創建兩個 s 的可變參考的代碼會失敗:
檔案名: src/main.rs
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
錯誤如下:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error
這個報錯說這段代碼是無效的,因為我們不能在同一時間多次將 s 作為可變變數借用,第一個可變的借入在 r1 中,并且必須持續到在 println! 中使用它,但是在那個可變參考的創建和它的使用之間,我們又嘗試在 r2 中創建另一個可變參考,該參考借用與 r1 相同的資料,
這一限制以一種非常小心謹慎的方式允許可變性,防止同一時間對同一資料存在多個可變參考,新 Rustacean 們經常難以適應這一點,因為大部分語言中變數任何時候都是可變的,這個限制的好處是 Rust 可以在編譯時就避免資料競爭,資料競爭(data race)類似于競態條件,它可由這三個行為造成:
?兩個或更多指標同時訪問同一資料,
?至少有一個指標被用來寫入資料,
?沒有同步資料訪問的機制,
資料競爭會導致未定義行為,難以在運行時追蹤,并且難以診斷和修復;Rust 避免了這種情況的發生,因為它甚至不會編譯存在資料競爭的代碼!
一如既往,可以使用大括號來創建一個新的作用域,以允許擁有多個可變參考,只是不能 同時 擁有:
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在這里離開了作用域,所以我們完全可以創建一個新的參考
let r2 = &mut s;
Rust 在同時使用可變與不可變參考時也采用的類似的規則,這些代碼會導致一個錯誤:
let mut s = String::from("hello");
let r1 = &s; // 沒問題
let r2 = &s; // 沒問題
let r3 = &mut s; // 大問題
println!("{}, {}, and {}", r1, r2, r3);
錯誤如下:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // no problem
| -- immutable borrow occurs here
5 | let r2 = &s; // no problem
6 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error
錯誤提示我們也不能在擁有不可變參考的同時擁有可變參考,
不可變參考的用戶可不希望在他們的眼皮底下值就被意外的改變了!然而,多個不可變參考是可以的,因為沒有哪個只能讀取資料的人有能力影響其他人讀取到的資料,
注意一個參考的作用域從宣告的地方開始一直持續到最后一次使用為止,例如,因為最后一次使用不可變參考(println!),發生在宣告可變參考之前,所以如下代碼是可以編譯的:
let mut s = String::from("hello");
let r1 = &s; // 沒問題
let r2 = &s; // 沒問題
println!("{} and {}", r1, r2);
// 此位置之后 r1 和 r2 不再使用
let r3 = &mut s; // 沒問題
println!("{}", r3);
不可變參考 r1 和 r2 的作用域在 println! 最后一次使用之后結束,這也是創建可變參考 r3 的地方,它們的作用域沒有重疊,所以代碼是可以編譯的,編譯器在作用域結束之前判斷不再使用的參考的能力被稱為 非詞法作用域生命周期(Non-Lexical Lifetimes,簡稱 NLL),
盡管這些錯誤有時使人沮喪,但請牢記這是 Rust 編譯器在提前指出一個潛在的 bug(在編譯時而不是在運行時)并精準顯示問題所在,這樣你就不必去跟蹤為何資料并不是你想象中的那樣,
3.2.懸垂參考(Dangling References)
在具有指標的語言中,很容易通過釋放記憶體時保留指向它的指標而錯誤地生成一個 懸垂指標(dangling pointer),所謂懸垂指標是其指向的記憶體可能已經被分配給其它持有者,相比之下,在 Rust 中編譯器確保參考永遠也不會變成懸垂狀態:當你擁有一些資料的參考,編譯器確保資料不會在其參考之前離開作用域,
讓我們嘗試創建一個懸垂參考,Rust 會通過一個編譯時錯誤來避免:
檔案名: src/main.rs
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
這里是錯誤:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| ~~~~~~~~
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error
錯誤資訊參考了一個我們還未介紹的功能:生命周期(lifetimes),第十章會詳細介紹生命周期,不過,如果你不理會生命周期部分,錯誤資訊中確實包含了為什么這段代碼有問題的關鍵資訊:
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from
讓我們仔細看看我們的 dangle 代碼的每一步到底發生了什么:
檔案名: src/main.rs
fn dangle() -> &String { // dangle 回傳一個字串的參考
let s = String::from("hello"); // s 是一個新字串
&s // 回傳字串 s 的參考
} // 這里 s 離開作用域并被丟棄,其記憶體被釋放,
// 危險!
因為 s 是在 dangle 函式內創建的,當 dangle 的代碼執行完畢后,s 將被釋放,不過我們嘗試回傳它的參考,這意味著這個參考會指向一個無效的 String,這可不對!Rust 不會允許我們這么做,
這里的解決方法是直接回傳 String:
fn no_dangle() -> String {
let s = String::from("hello");
s
}
這樣就沒有任何錯誤了,所有權被移動出去,所以沒有值被釋放,
3.3.參考的規則
讓我們概括一下之前對參考的討論:
?在任意給定時間,要么 只能有一個可變參考,要么 只能有多個不可變參考,
?參考必須總是有效的,
接下來,我們來看看另一種不同型別的參考:slice,
四.Slice 型別
slice 允許你參考集合中一段連續的元素序列,而不用參考整個集合,slice 是一類參考,所以它沒有所有權,
這里有一個編程小習題:撰寫一個函式,該函式接收一個用空格分隔單詞的字串,并回傳在該字串中找到的第一個單詞,如果函式在該字串中并未找到空格,則整個字串就是一個單詞,所以應該回傳整個字串,
讓我們推敲下如何不用 slice 撰寫這個函式的簽名,來理解 slice 能解決的問題:
fn first_word(s: &String) -> ?
first_word 函式有一個引數 &String,因為我們不需要所有權,所以這沒有問題,不過應該回傳什么呢?我們并沒有一個真正獲取 部分 字串的辦法,不過,我們可以回傳單詞結尾的索引,結尾由一個空格表示,試試如示例 7 中的代碼,
檔案名: src/main.rs
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
示例 7:first_word 函式回傳 String 引數的一個位元組索引值
因為需要逐個元素的檢查 String 中的值是否為空格,需要用 as_bytes 方法將 String 轉化為位元組陣列:
let bytes = s.as_bytes();
接下來,使用 iter 方法在位元組陣列上創建一個迭代器:
for (i, &item) in bytes.iter().enumerate() {
上述代碼中, iter 方法回傳集合中的每一個元素,而 enumerate 包裝了 iter 的結果,將這些元素作為元組的一部分來回傳,enumerate 回傳的元組中,第一個元素是索引,第二個元素是集合中元素的參考,這比我們自己計算索引要方便一些,
因為 enumerate 方法回傳一個元組,我們可以使用模式來解構,我們將在第六章中進一步討論有關模式的問題,所以在 for 回圈中,我們指定了一個模式,其中元組中的 i 是索引而元組中的 &item 是單個位元組,因為我們從 .iter().enumerate() 中獲取了集合元素的參考,所以模式中使用了 &,
在 for 回圈中,我們通過位元組的字面值語法來尋找代表空格的位元組,如果找到了一個空格,回傳它的位置,否則,使用 s.len() 回傳字串的長度:
if item == b' ' {
return i;
}
}
s.len()
現在有了一個找到字串中第一個單詞結尾索引的方法,不過這有一個問題,我們回傳了一個獨立的 usize,不過它只在 &String 的背景關系中才是一個有意義的數字,換句話說,因為它是一個與 String 相分離的值,無法保證將來它仍然有效,考慮一下示例 8 中使用了示例 7 中 first_word 函式的程式,
檔案名: src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word 的值為 5
s.clear(); // 這清空了字串,使其等于 ""
// word 在此處的值仍然是 5,
// 但是沒有更多的字串讓我們可以有效地應用數值 5,word 的值現在完全無效!
}
示例 8:存盤 first_word 函式呼叫的回傳值并接著改變 String 的內容
這個程式編譯時沒有任何錯誤,而且在呼叫 s.clear() 之后使用 word 也不會出錯,因為 word 與 s 狀態完全沒有聯系,所以 word 仍然包含值 5,可以嘗試用值 5 來提取變數 s 的第一個單詞,不過這是有 bug 的,因為在我們將 5 保存到 word 之后 s 的內容已經改變,
我們不得不時刻擔心 word 的索引與 s 中的資料不再同步,這很啰嗦且易出錯!如果撰寫這么一個 second_word 函式的話,管理索引這件事將更加容易出問題,它的簽名看起來像這樣:
fn second_word(s: &String) -> (usize, usize) {
現在我們要跟蹤一個開始索引 和 一個結尾索引,同時有了更多從資料的某個特定狀態計算而來的值,但都完全沒有與這個狀態相關聯,現在有三個飄忽不定的不相關變數需要保持同步,
幸運的是,Rust 為這個問題提供了一個解決方法:字串 slice,
4.1.字串 slice
字串 slice(string slice)是 String 中一部分值的參考,它看起來像這樣:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
不同于整個 String 的參考,hello 是一個部分 String 的參考,由一個額外的 [0..5] 部分指定,可以使用一個由中括號中的 [starting_index..ending_index] 指定的 range 創建一個 slice,其中 starting_index 是 slice 的第一個位置,ending_index 則是 slice 最后一個位置的后一個值,在其內部,slice 的資料結構存盤了 slice 的開始位置和長度,長度對應于 ending_index 減去 starting_index 的值,所以對于 let world = &s[6..11]; 的情況,world 將是一個包含指向 s 索引 6 的指標和長度值 5 的 slice,
圖 6 展示了一個圖例,
圖 6:參考了部分 String 的字串 slice
對于 Rust 的 .. range 語法,如果想要從索引 0 開始,可以不寫兩個點號之前的值,換句話說,如下兩個陳述句是相同的:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
依此類推,如果 slice 包含 String 的最后一個位元組,也可以舍棄尾部的數字,這意味著如下也是相同的:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
也可以同時舍棄這兩個值來獲取整個字串的 slice,所以如下亦是相同的:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
注意:字串 slice range 的索引必須位于有效的 UTF-8 字符邊界內,如果嘗試從一個多位元組字符的中間位置創建字串 slice,則程式將會因錯誤而退出,出于介紹字串 slice 的目的,本部分假設只使用 ASCII 字符集;第八章的 “使用字串存盤 UTF-8 編碼的文本” 部分會更加全面的討論 UTF-8 處理問題,
在記住所有這些知識后,讓我們重寫 first_word 來回傳一個 slice,“字串 slice” 的型別宣告寫作 &str:
檔案名: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
我們使用跟示例 7 相同的方式獲取單詞結尾的索引,通過尋找第一個出現的空格,當找到一個空格,我們回傳一個字串 slice,它使用字串的開始和空格的索引作為開始和結束的索引,
現在當呼叫 first_word 時,會回傳與底層資料關聯的單個值,這個值由一個 slice 開始位置的參考和 slice 中元素的數量組成,
second_word 函式也可以改為回傳一個 slice:
fn second_word(s: &String) -> &str {
現在我們有了一個不易混淆且直觀的 API 了,因為編譯器會確保指向 String 的參考持續有效,還記得示例 8 程式中,那個當我們獲取第一個單詞結尾的索引后,接著就清除了字串導致索引就無效的 bug 嗎?那些代碼在邏輯上是不正確的,但卻沒有顯示任何直接的錯誤,問題會在之后嘗試對空字串使用第一個單詞的索引時出現,slice 就不可能出現這種 bug 并讓我們更早的知道出問題了,使用 slice 版本的 first_word 會拋出一個編譯時錯誤:
檔案名: src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // 錯誤!
println!("the first word is: {}", word);
}
這里是編譯錯誤:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {}", word);
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error
回憶一下借用規則,當擁有某值的不可變參考時,就不能再獲取一個可變參考,因為 clear 需要清空 String,它嘗試獲取一個可變參考,在呼叫 clear 之后的 println! 使用了 word 中的參考,所以這個不可變的參考在此時必須仍然有效,Rust 不允許 clear 中的可變參考和 word 中的不可變參考同時存在,因此編譯失敗,Rust 不僅使得我們的 API 簡單易用,也在編譯時就消除了一整類的錯誤!
4.1.1.字串字面值就是 slice
還記得我們講到過字串字面值被儲存在二進制檔案中嗎?現在知道 slice 了,我們就可以正確地理解字串字面值了:
let s = "Hello, world!";
這里 s 的型別是 &str:它是一個指向二進制程式特定位置的 slice,這也就是為什么字串字面值是不可變的;&str 是一個不可變參考,
4.1.2.字串 slice 作為引數
在知道了能夠獲取字面值和 String 的 slice 后,我們對 first_word 做了改進,這是它的簽名:
fn first_word(s: &String) -> &str {
而更有經驗的 Rustacean 會撰寫出示例 9 中的簽名,因為它使得可以對 &String 值和 &str 值使用相同的函式:
fn first_word(s: &str) -> &str {
示例 9: 通過將 s 引數的型別改為字串 slice 來改進 first_word 函式
如果有一個字串 slice,可以直接傳遞它,如果有一個 String,則可以傳遞整個 String 的 slice 或對 String 的參考,定義一個獲取字串 slice 而不是 String 參考的函式使得我們的 API 更加通用并且不會丟失任何功能:
檔案名: src/main.rs
fn main() {
let my_string = String::from("hello world");
// `first_word` 適用于 `String`(的 slice),整體或全部
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` 也適用于 `String` 的參考,
// 這等價于整個 `String` 的 slice
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` 適用于字串字面值,整體或全部
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// 因為字串字面值已經 **是** 字串 slice 了,
// 這也是適用的,無需 slice 語法!
let word = first_word(my_string_literal);
}
4.2.其他型別的 slice
字串 slice,正如你想象的那樣,是針對字串的,不過也有更通用的 slice 型別,考慮一下這個陣列:
let a = [1, 2, 3, 4, 5];
就跟我們想要獲取字串的一部分那樣,我們也會想要參考陣列的一部分,我們可以這樣做:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
這個 slice 的型別是 &[i32],它跟字串 slice 的作業方式一樣,通過存盤第一個集合元素的參考和一個集合總長度,你可以對其他所有集合使用這類 slice,第八章講到 vector 時會詳細討論這些集合,
五.總結
所有權、借用和 slice 這些概念讓 Rust 程式在編譯時確保記憶體安全,Rust 語言提供了跟其他系統編程語言相同的方式來控制你使用的記憶體,但擁有資料所有者在離開作用域后自動清除其資料的功能意味著你無須額外撰寫和除錯相關的控制代碼,Rust自帶的這些機制雖然犧牲了一些靈活性,但也從根本上保證了記憶體的安全,只要遵循這些規則,就能輕松寫出安全的代碼,
六.參考
[1] Rust 教程 | 菜鳥教程 (runoob.com)
[2] 除了RUST,還有國產架構:Linux6.1內核穩定版首發布!_中文科技資訊 提供快捷產業新資訊 創新驅動商業 (citnews.com.cn)
[3] crates.io: Rust Package Registry
[4] 位元組跳動在 Rust 微服務方向的探索和實踐 | QCon_代碼_問題_時候 (sohu.com)
[5] Rust 程式設計語言 - Rust 程式設計語言 簡體中文版 (kaisery.github.io)
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/543398.html
標籤:其他
上一篇:Python裝飾器實體講解(二)
