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 {}.", 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 語言提供了和其他編程語言相同的方式來控制內存,而不需要我們編寫額外代碼來手動管理內存,當數據超出所有者的作用域就會被自動清理。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM