所有權是 rust 語言獨有的特性,它保證了在沒有垃圾回收機制下的內存安全,所以理解 rust 的所有權是很有必要的。接下來,我們來討論所有權和它的幾個特性:借用、切片和內存結構。
什么是所有權
Rust 的核心特性是所有權。各種語言都有它們自己管理內存的方式,有些是使用垃圾回收機制,有些是手動管理內存,而 rust 使用的是所有權機制來管理內存。
所有權規則
所有權規則如下:
- rust 中的每個值都有一個自己的變量。
- rust 值在同一時間只能綁定一個變量。
- 變量超出作用域,值會自動被銷毀。
不懂沒關系,跳過往后看。
變量作用域
rust 語言的變量作用域和其他語言是類似的,看例子:
{ // 變量 s 還沒有被聲明,s 在這里是無效的
let s = "hello"; // 變量 s 是這里聲明的,從這里開始生效
// 從這里開始,可以使用 s 做一些工作
} // 變量 s 超出作用域,s 從這里開始不再生效
可以總結兩點重要特性:
- 當變量 s 聲明之后開始生效
- 當變量 s 出了作用域失效
String 類型
在章節三中學習的數據類型都是存儲在內存的棧空間中,當它們的作用域結束時清空棧空間,我們現在學習一下內存的堆空間中存儲的數據是在何時被 rust 清空的。
我們在這里使用 String 類型作為例子,當然只是簡單的使用,具體的內容后文介紹。
let s = "hello";
這個例子是把 hello 字符串硬編碼到程序中,我們把它叫做 字符串文字
(string literals 我不知道別人是怎么翻譯的,我實在想不到合適的詞,先這樣叫着吧),字符串文字很方便,但是它不能適用於任何場景,比如我們想要輸入一個文本串的時候,原理如下:
- 字符串文字是不可修改的
- 編碼(編譯)時不確定文本串的內容 (比如保存用戶的輸入)
在這種情況下,我們會使用字符串的第二種類型——String。它是存儲在堆內存中的,而且允許在編譯期間不知道字符串的大小,我們先使用 from 函數從字符串文字中創建一個 String 類型的字符串。
let s = String::from("hello");
這種雙冒號的語法細節下個章節再說,這里先聚焦於字符串,例子中創建的字符串是可以改變的,比如:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在上個字符串后追加一個字符串
println!("{}", s); // 這里會打印 `hello, world!`
內存分配
字符串文字是在編譯期間就有確定的字符內容,所以文本可以直接硬編碼到程序中,這就是字符串文字快捷方便的原因。但是字符串文字又是不可變的,我們沒辦法分配內存給編譯期間未知大小及變化的字符串。
字符串類型則支持字符串的修改和增長,即使編譯期間未知大小,也可以在堆內存分配一塊空間用於存儲數據,這意味着: * 內存必須是在運行時,從操作系統中請求 (和大多數編程語言類似,當我們調用 String::from 方法時,就完成了對內存的請求) * 當不使用這塊內存時,我們才會把它返還給操作系統 (和其它語言不同,其它語言是使用垃圾回收機制或手動釋放內存)
rust 則是另一種方式:一旦變量超出作用域,程序自動返還內存,通過下面的例子來看這個概念:
{
let s = String::from("hello"); // s 從這里開始生效
// 利用 s 做一些事情
} // s 超出作用域,不再生效
當 s 超出作用域,rust 會自動幫我們調用一個特殊的函數—— drop 函數,它是用於返還內存的,當程序執行到 大括號右半塊 } 的時候自動調用該函數。
變量和數據交互方式:移動
在 rust 中,可以使用不同的方式在多個變量間交互相同的數據,比如:
let x = 5; // 把 5 綁定到 x 上
let y = x; // 把 x 的值復制給 y,此時,x 和 y 的值都是 5
再比如:
let s1 = String::from("hello"); // 創建一個字符串綁定到 s1 上
let s2 = s1; // 把 s1 的值移動給 s2,此時,s1 就失效了,只有 s2 是有效的
s1 為什么失效,因為字符串是存儲在堆內存中的,這里只是把棧內存中的 s1 的數據移動給 s2,堆內存不變,這種方式叫做淺克隆,也叫移動。如果想讓 s1 仍然有效,可以使用深克隆。
變量和數據交互方式:克隆
關於深克隆,我們直接看例子吧:
let s1 = String::from("hello"); // 創建一個字符串綁定到 s1 上
let s2 = s1.clone(); // 把 s1 的值克隆給 s2,此時,s1 和 s2 都是有效的
println!("s1 = {}, s2 = {}", s1, s2); // 打印 s1 和 s2
下一章節再詳細介紹這種語法。
只有棧數據:復制
我們再回到前面的例子中:
let x = 5; // 把 5 綁定到 x 上
let y = x; // 把 x 的值復制給 y,此時,x 和 y 的值都是 5
println!("x = {}, y = {}", x, y); // 打印 x 和 y
這里沒有使用 clone 這個方法,但是 x 和 y 都是有效的。因為 x 和 y 都是整型,整型存儲在棧內存中,即使調用了 clone 方法,也是做相同的事。下面總結一下復制的類型:
- 所有的整型,像 u32,i32
- 布爾類型,像 bool,值是 true, false
- 所有的浮點型,像 f32,f64
- 字符類型,像 char
- 元組,但是僅僅是包含前 4 種類型的元組,像 (u32, i32),但是 (u32, String) 就不是了
所有權和函數
這里直接放一個例子應該就說清楚了,如下:
fn main() {
let s = String::from("hello"); // s 進入作用域
takes_ownership(s); // s 移動到函數里
// s 從這里開始不再生效,如果還使用 s,則會在編譯期報錯
let x = 5; // x 進入作用域
makes_copy(x); // x 復制(移動)到函數里,
// 但由於 x 是 i32 ,屬於整型,仍有效
// 使用 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 超出作用域,但是整型不需要釋放堆內存
返回值和作用域
這塊也直接放個例子,如下:
fn main() {
let s1 = gives_ownership(); // gives_ownership 把它的返回值移動給 s1
let s2 = String::from("hello"); // s2 進入作用域
let s3 = takes_and_gives_back(s2); // s2 移動進函數,函數返回值移動給 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 是返回值,移出調用函數
}
引用和借用
前面都是把值傳入傳出函數,這里我們學習一下引用,看個例子:
fn main() {
let s1 = String::from("hello"); // 創建一個字符串 s1
let len = calculate_length(&s1); // 把 字符串 s1 的引用傳給函數
println!("The length of '{}' is {}.", s1, len); // 這里可以繼續使用 s1
}
fn calculate_length(s: &String) -> usize { // 接收到 字符串 s1 的引用 s
s.len() // 這里返回函數的長度
} // s 超出作用域,但是這里沒有字符串的所有權,不釋放內存
&s1 語法是創建一個指向 s1 的值的引用,而不是 s1 本身,當引用超出作用域不會釋放引用指向值的內存。被調用函數聲明參數的時候,參數的類型也需要使用 & 來告知函數接收的參數是個引用。
修改引用
在上述例子中,如果在 calculate_length 函數中修改字符串的內容,編譯器會報錯,因為傳入的引用在默認情況下是不可變引用,如果想要修改引用的內容,需要添加關鍵字 mut,看例子:
fn main() {
let mut s = String::from("hello");
change(&mut s); // mut 同意函數修改 s 的值
}
fn change(some_string: &mut String) { // mut 聲明函數需要修改 some_string 的值
some_string.push_str(", world"); // 追加字符串
}
可變引用有一個很大的限制:在特定作用域中針對同一引用,只能有一個可變引用。比如:
let mut s = String::from("hello");
let r1 = &mut s; // 這是第一個借用的可變引用
let r2 = &mut s; // 這是第二個借用的可變引用,這里會編譯不通過
println!("{}, {}", r1, r2);
這個限制的好處是編譯器可以編譯期間阻止數據競爭,數據競爭發生在如下情況:
- 兩個或多個指針同時訪問相同的數據
- 多個指針在寫同一份數據
- 沒有同步數據的機制
數據競爭會造成不可預知的錯誤,而且在運行時修復是很困難的,rust 在編譯期間就阻止了數據競爭情況的發生。但是在不同的作用域下,是可以有多個可變引用的,比如:
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 超出作用域
// 這里可以借用新的可變引用了
let r2 = &mut s;
可變引用和不可變引用放在一起,也會出錯,比如:
let mut s = String::from("hello");
let r1 = &s; // 不可變引用,沒問題
let r2 = &s; // 不可變引用,沒問題
let r3 = &mut s; // 可變引用,會發生大問題,因為后面還在使用 r1 和 r2
println!("{}, {}, and {}", r1, r2, r3);
當存在不可變引用時,就不能再借用可變引用了。不可變引用不會修改引用的值,所以可以借用多個不可變引用。但是如果不可變引用都不使用了,就又可以借用可變引用了,比如:
let mut s = String::from("hello");
let r1 = &s; // 不可變引用,沒問題
let r2 = &s; // 不可變引用,沒問題
println!("{} and {}", r1, r2);
// r1 和 r2 從這里開始不再使用了
let r3 = &mut s; // 可變引用,也沒問題了,因為后面沒使用 r1 和 r2 的了
println!("{}", r3);
懸空引用
在一些指針語言中,很容易就會錯誤地創建懸空指針。懸空指針就是過早地釋放了指針指向的內存,也就是說,堆內存已經釋放了,而指針還指向這塊堆內存。在 rust 中,編譯器可以保證不會產生懸空引用:如果有引用指向數據,編譯器會確保引用指向的數據不會超出作用域,比如:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // 這里希望返回字條串的引用
let s = String::from("hello"); // 創建字符串
&s // 這里返回了字符串的引用
} // 這里會把字符串的內存釋放,因為 s 在這里超出作用域
這個函數做個簡單的修改就好了,看如下例子:
fn no_dangle() -> String { // 不返回字符串引用了,直接返回字符串
let s = String::from("hello");
s // 返回字符串
}
引用規則
引用的規則如下:
- 任何時候,只能有一個可變引用或多個不可變引用
- 引用必須總是有效的
切片類型
另一個沒有所有權的數據類型是切片。切片是集合中相鄰的一系列元素,而不是整個集合。
做個小小的編程題目:有一個函數,輸入一個英文字符串,返回第一個單詞。如果字符串沒有空格,則認為整個字符串是一個單詞,返回整個字符串。
我們來思考一下這個函數結構:
fn first_word(s: &String) -> ? // 這里應該返回什么
在這個函數中,字符串引用作參數,函數沒有字符串的所有權,那我們應該返回什么呢?我們不能返回字符串的一部分,那么,我們可以返回第一個單詞結束位置的索引。看實現:
fn first_word(s: &String) -> usize { // 返回一個無符號整型,因為索引不會小於 0
let bytes = s.as_bytes(); // 把字符串轉換化字節類型的數組
// iter 方法用於遍歷字節數組,enumerate 方法用於返回一個元組,元組的第 0 個元素是索引,第 1 個元素是字節數組的元素
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' { // 如果找到了空格,就返回對應的索引
return i;
}
}
s.len() // 如果沒找到空格,就返回字符串的長度
}
現在,我們找到了返回字符串第一個單詞的末尾索引,但是還有一個問題:函數返回的是一個無符號整型,返回值只是字符串中的一個有意義的數字。換句話說,返回值和字符串是分開的值,不能保證它永遠有意義,比如:
fn main() {
let mut s = String::from("hello world"); // 創建字符串
let word = first_word(&s); // word 被賦值為 5
s.clear(); // 清空字符串,現在字符串的值是 ""
// word 仍是5,但是我們不能得到單詞 hello 了,這里使用 word,編譯器也不會報錯,但是這真的是一個 bug
}
還有一個問題,如果我們想得到第二個單詞,應該怎么辦?函數聲明應該是:
fn second_word(s: &String) -> (usize, usize) {
如果想得到很多個單詞,又應該怎么辦?
字符串切片
字符串切片就是字符串一部分的引用,如下:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
就是取字符串 s 中的一部分組成一個新的變量,取值區間左閉右開,就是說,包括左邊的索引,不包括右邊的索引。
如果左邊索引是0,可省略:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2]; // 和上一行等價
如果右邊索引是字符串末尾,可省略:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..]; // 和上一行等價
如果取整個字符串,可以把兩邊都省略:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..]; // 和上一行等價
我們現在看一下一開始討論的題目應該怎么做:
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[..]
}
這樣就直接返回了第一個單詞的內容。如果要返回第二個單詞,可寫成:
fn second_word(s: &String) -> &str {
我們再來看一下字符串清空的問題是否還存在:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // 不可變借用在這里聲明
s.clear(); // 這里會報錯,因為這里在修改 s 的內容
println!("the first word is: {}", word); // 不可變借用在這里使用
}
不可變借用的聲明和使用之間是不能使用可變借用的。
字符串文字是切片
前面我們討論過字符串文字的存儲問題,現在我們學習了切片,我們可以理解字符串文字了:
let s = "Hello, world!";
s 的類型是 &str,這是一個指向了二進制程序特殊的位置的切片,這就是字符串文字不可變的原是,&str 是一個不可變引用
字符串切片作為參數
前面學習了字符串文字切片和字符串類型切片,我們來提高 first_word 函數的質量。有經驗的 rust 開發者會把函數參數類型寫成 &str,因為這樣可以使得 &String 和 &str 使用相同的函數。好像不太好理解,直接上例子:
fn main() {
let my_string = String::from("hello world"); // 創建字符串
// first_word 使用 &String切片
let word = first_word(&my_string[..]);
let my_string_literal = "hello world"; // 創建字符串
// first_word 使用 &str切片
let word = first_word(&my_string_literal[..]);
// 因為字符串文字已經是字符串切片了,可以不使用切片語法
let word = first_word(my_string_literal);
}
其它切片
我們只舉一個 i32 類型切片的例子吧:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // 值是: [2, 3]
rust 使用了所有權、借用、切片,在編譯期確保程序的內存安全。rust 語言提供了和其他編程語言相同的方式來控制內存,而不需要我們編寫額外代碼來手動管理內存,當數據超出所有者的作用域就會被自動清理。
歡迎閱讀單鵬飛的學習筆記