1. 程式人生 > 程式設計 >Rust 入門 (四)

Rust 入門 (四)

所有權是 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 {}.",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 {}",r2,r3);複製程式碼

當存在不可變引用時,就不能再借用可變引用了。不可變引用不會修改引用的值,所以可以借用多個不可變引用。但是如果不可變引用都不使用了,就又可以借用可變引用了,比如:

let mut s = String::from("hello");

let r1 = &s; // 不可變引用,沒問題
let r2 = &s; // 不可變引用,沒問題
println!("{} and {}",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 語言提供了和其他程式語言相同的方式來控制記憶體,而不需要我們編寫額外程式碼來手動管理記憶體,當資料超出所有者的作用域就會被自動清理。

歡迎閱讀單鵬飛的學習筆記