The Rust programming language 讀書筆記——所有權與 Move 機制

  • 所有權概念是 Rust 語言的核心功能
  • Rust 沒有垃圾回收(GC)機制
  • Rust 通過所有權和相關工具保障內存安全

所有語言都需要管理自己在運行時使用的計算機內存空間。
使用垃圾回收機制的語言(Java、Python)會在運行時定期檢查并回收沒有被繼續(xù)使用的內存;另外一些語言(C、C++)則需要程序員手動地分配和釋放內存。

Rust 采用第三種方式:它使用包含特定規(guī)則的所有權系統(tǒng)來管理內存。這套規(guī)則允許編譯器在編譯過程中執(zhí)行檢查工作,不會產生任何的運行時開銷。

棧與堆

棧和堆都是代碼在運行時可以使用的內存空間。
所有存儲在棧中的數據必須擁有一個已知且固定的大小。在編譯期無法確定大小的數據只能存放在堆中。

堆空間的管理較為松散。當希望將數據放入堆中時,可以請求特定大小的空間,操作系統(tǒng)會根據請求在堆中找到一塊足夠大的可用空間,并把指向這塊空間地址的指針返回給我們。這個過程稱為分配。

由于指針(內存地址)的大小是固定的且可以在編譯期確定,因此可以將指針存放在棧中。通過指針指向的地址訪問指針所指向的具體數據。

由于多了指針跳轉的環(huán)節(jié),訪問堆上的數據要慢于訪問棧上的數據。許多系統(tǒng)編程語言都需要程序員去記錄代碼中分配的堆空間,最小化堆上的冗余,并及時清理無用數據以避免耗盡內存空間。所有權的概念就是為了將上述問題交給 Rust 處理,減輕程序員的這部分心智負擔。

所有權規(guī)則

  • Rust 中的每一個值都有一個對應的變量作為它的擁有者
  • 在同一時間內,值有且只有一個擁有者
  • 當所有者離開自己的作用域時,它擁有的值就會被釋放掉

變量作用域

作用域是一個對象在程序中有效的范圍。

如:

{                       // 變量 s 還未聲明,因此在這里不可用
    let s = "hello";    // 從這里開始變量 s 變得可用
    // 執(zhí)行與 s 相關的操作
}                       // 作用域到這里結束,變量 s 不再可用
  • 變量在進入作用域后變得有效
  • 變量會保持自己的有效性直到離開自己的作用域

字符串字面量(如 let s = "hello")屬于被硬編碼進程序的字符串值。很方便,但并不適用于所有場景。
一是因為字符串字面量是不可變的,二是因為并不是所有字符串的值都能在編寫代碼時確定。
比如需要獲取用戶的輸入并保存。

Rust 提供了第二種字符串類型 String。String 會在堆上分配存儲空間,因此能夠處理未知大小的文本。

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

s.push_str(", world!");    // push_str() 函數向 String 空間的尾部添加了一段字符串字面量

println!("{}", s);    // 這里會輸出完整的 hello, world!

對于字符串字面量而言,由于在編譯時就知道其內容,這部分硬編碼的文本被直接嵌入到了可執(zhí)行文件中。這也是訪問字符串字面量異常高效的原因。
對于 String 類型而言,為了支持一種可變的、可增長的類型,需要在堆上分配一塊在編譯時未知大小的內存來存放數據。
當使用完 String 時,則需要通過某種方式來將這些內存歸還給操作系統(tǒng)。

對于擁有 GC 機制的語言,GC 會替代程序員記錄并清理那些不再使用的內存。而對于沒有 GC 的語言,識別不再使用的內存并調用代碼顯式釋放的工作就需要程序員來完成。
假如忘記釋放內存,就會造成內存泄漏;假如過早地釋放內存,就會產生一個非法變量;假如重復釋放同一塊內存,就會產生無法預知的后果。

Rust 提供了另外一套解決方案:內存會在擁有它的變量離開作用域后自動地進行釋放。

{                                     // 變量 s 還未聲明,因此在這里不可用
    let s = String::from("hello");    // 從這里開始變量 s 變得可用
    // 執(zhí)行與 s 相關的操作
}                                     // 作用域到這里結束,變量 s 失效

Rust 會在作用域結束的地方(即 } 處)自動回收分配給變量 s 的內存。

內存與分配

對于整數類型的數據:

let x = 5;
let y = x;

上述代碼將整數值 5 綁定給變量 x,再創(chuàng)建一個 x 值的拷貝,綁定給變量 y。由于整數是已知固定大小的簡單值,兩個值 5 會同時被推入棧中。

對于 String 類型的數據:

let s1 = String::from("hello");
let s2 = s1;

類似的代碼,運行方式卻并不一致。

String 的內存布局如下圖:
String

對于綁定給變量 s1 的 String 來說,該字符串的文本內容(hello)保存在了堆上,同時在棧中保存著一個指向字符串內容的指針、一個長度和一個容量信息。

當將 s1 賦值給 s2 時,便復制了一次 String 的數據。這意味著我們復制了它存儲在棧上的指針、長度和容量字段,而指針指向的堆上的數據并沒有被復制。

變量 s1 和 s2 的內存布局如下圖:
s1 & s2

前面提到過,當一個變量離開當前的作用域時,Rust 會自動將變量使用的堆內存釋放和回收。但若是有兩個指針指向了同一個地址,就會導致如 s2 和 s1 離開自己的作用域時,Rust 會嘗試重復釋放相同的內存,進而有可能導致正在使用的數據發(fā)生損壞。
為了確保內存安全,同時也避免復制分配的內存,Rust 在上述場景下會簡單的將 s1 廢棄。因此也就不需要在 s1 離開作用域后清理任何東西。這一行為即為 Move。

試圖在 s2 創(chuàng)建完畢后訪問 s1(如下所示)會導致編譯錯誤。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // 變量 s1 在這里被廢棄
    print!("{}, world", s1);  // 錯誤
}

Rust 會報出 borrow of moved value: s1 錯誤。

Rust 永遠不會自動創(chuàng)建數據的深度拷貝

對于棧上數據的復制,比如:

let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);

上面的代碼是完全合法的。因為整型的數據可以在編譯時確定自己的大小,能夠將數據完整地存儲在棧中。對于這些類型而言,深度拷貝與淺度拷貝沒有任何區(qū)別。

所有權與函數

將值傳遞給函數在語義上類似于對變量進行賦值。將變量傳遞給函數將會觸發(fā)移動或復制。

fn main() {
    let s = String::from("hello"); //變量 s 進入作用域
    takes_ownership(s); // s 的值被移動進了函數
                        // 變量 s 從這里開始不再有效

    let x = 5; // 變量 x 進入作用域
    makes_copy(x); // 變量 x 被傳遞進了函數
                   // 但 i32 類型不受 Move 機制影響,因此這里 x 依舊可用
}

fn takes_ownership(some_string: String) {
    // some_string 進入作用域
    print!("{}", some_string);
} // some_string 離開作用域,占用的內存被釋放

fn makes_copy(some_integer: i32) {
    print!("{}", some_integer);
} // some_integer 離開作用域,沒有特別的事情發(fā)生

在上述代碼中,嘗試在調用 takes_ownership 后使用變量 s 會導致編譯錯誤。

函數在返回值的過程中也會發(fā)生所有權的轉移。

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 和 s1 在這里離開作用域并被銷毀,而 s2 已經移動了,因此不會發(fā)生任何事情

fn gives_ownership() -> String {
    let some_string = String::from("hello"); //  some_string 進入作用域
    some_string // some_string 作為返回值移動至調用方
}

// takes_and_gives_back 將取得一個 String 的所有權并將它作為結果返回
fn takes_and_gives_back(a_string: String) -> String {
    a_string // a_string 作為返回值移動至調用方
}

變量的所有權轉移總是遵循相同的模式:將一個值賦值給另一個變量時就會轉移所有權。當一個持有堆數據的變量離開作用域時,它的數據就會被清理回收,除非這些數據的所有權被移動到了另一個變量上。

引用與借用

參考如下示例代碼:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    print!("The length of '{}' is {}", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

由于調用 caculate_length 會導致 String 移動到函數體內部,我們又需要在調用后繼續(xù)使用該 String,因此不得不通過元組將 String 作為元素繼續(xù)返回。

這種寫法未免過于笨拙。在下面的代碼中,新的 calculate_length 函數使用了 String 的引用作為參數而不會直接轉移值的所有權。

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    print!("The length of '{}' is {}", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

在新的代碼中,調用 calculate_length 函數時使用了 &s1 作為參數,且在該函數的定義中使用 &String 替代了 String
& 代表引用,允許在不獲取所有權的情況下使用值

引用

&s1 語法允許在不轉移所有權的前提下創(chuàng)建一個指向 s1 值的引用。由于引用不持有值的所有權,當引用離開當前作用域時,它指向的值也不會被丟棄。
當一個函數使用引用而不是值本身作為參數時,我們就不需要為了歸還所有權而特意去返回值。畢竟引用根本沒有取得所有權。

這種通過引用傳遞參數給函數的方法也稱作借用。

可變引用

與變量類似,引用默認是不可變的。Rust 不允許修改引用指向的值(除非聲明為 mut)。

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    print!("{}", s)
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

對于特定作用域中的特定數據,一次只能聲明一個可變引用。
比如:

fn main() {
    let mut s = String::from("hello");
    let r1 = &mut s;
    let r2 = &mut s;
    println!("{}", r1)
}

就會出現 cannot borrow s as mutable more than once at a time 編譯錯誤。這個規(guī)則使得引用的可變性只能以一種受到嚴格限制的方式使用。但另一方面,遵循這條限制性規(guī)則可以在編譯時避免數據競爭。即不允許兩個或兩個以上的指針同時訪問(且至少有一個指針會寫入數據)同一空間。
數據競爭會導致未定義的行為,往往難以在運行時進行跟蹤,也就使得出現的 bug 更加難以被診斷和修復。

不能在擁有不可變引用的同時創(chuàng)建可變引用。編譯時會報出 cannot borrow s as immutable because it is also borrowed as mutable 錯誤。

fn main() {
    let mut s = String::from("hello");
    let r1 = &s; // 沒問題
    let r2 = &s; // 沒問題
    let r3 = &mut s; // 錯誤
    println!("{}", r2)
}

不能在擁有不可變引用的同時創(chuàng)建可變引用,但可以同時存在多個不可變引用。因為對數據的只讀操作不會影響到其他讀取數據的用戶。

Rust 編譯器可以為用戶提早(編譯時而不是運行時)暴露那些潛在的 bug,并且明確指出出現問題的地方。用戶就不再需要去追蹤調試為何數據會在運行時發(fā)生了非預期的變化。

參考資料

The Rust Programming Language

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容