(譯)理解Rust的 borrow checker

明醬發表於2021-12-04

原文連結:Understanding the Rust borrow checker

初嘗Rust的這一天終於到來了,你滿懷期待地寫下幾行 Rust 程式碼,然後在命令列輸入cargo run指令,等待著編譯通過。之前就聽說過,Rust 是一門只要編譯能通過,就能執行地語言,你興奮地等待著程式是否會正常執行。編譯跑起來了,然後立馬輸出了錯誤:

error[E0382]: borrow of moved value

看來你是遭遇了“借用檢查器”的問題。

什麼是借用檢查器?

借用檢查器是Rust之所以為Rust的基石之一,它能夠幫助(或者說是強迫)你管理“所有權”,即官方文件第四章介紹的ownership:“Ownership 是Rust最特別的特徵,它確保Rust不需要垃圾回收機制也能夠保證記憶體安全”。

所有權,借用檢查器以及垃圾回收:這些概念展開講能講很多,本文將介紹借用檢查器能為我們做什麼(能阻止我們做什麼),以及它和其他記憶體管理機制的區別。

本文假設你對高階語言有,比如Python,JavaScript或C#之類的一定了解就行,不要求計算機記憶體工作原理相關的知識。

垃圾回收 vs. 手動記憶體分配 vs. 借用檢查

對於很多常用的程式語言,你都不用考慮變數是存在哪兒的,直接宣告變數,剩下的部分,語言的執行時環境會通過垃圾回收來處理。這種機制抽象了計算機記憶體管理,使得程式設計更加輕鬆統一。

不過這就需要我們額外深入一層才能展示它和借用檢查的區別,就從棧 stack 和堆 heap 開始吧

棧與堆

我們的程式有兩種記憶體來存值,棧 stack 和堆 heap 。他們的區別有好些,但我們只用關心其中最重要的一點:棧上儲存的必須是大小固定的資料,存取都很方便,開銷小;像字串(可變長),列表和其它擁有可變大小的集合型別資料,儲存在堆上。因此計算機需要給這些不確定的資料分配足夠大的堆記憶體空間,這一過程會消耗更多的時間,並且程式通過指標訪問它們,而不能像棧那樣直接訪問。

總結來說,棧記憶體存取資料快速,但要求資料大小固定;堆記憶體雖然存取速度慢些,但是對資料的要求寬鬆。

垃圾回收

在帶有垃圾回收機制的語言中,棧上的資料會在超出作用域範圍時被刪除,堆上的資料不再使用後會由垃圾回收器處理,不需要程式設計師去具體關心堆疊上發生的事情。

但是對於像 C 這樣的語言,要手動管理記憶體。那些在更高階的語言中隨便就可以簡單初始化的列表,在C語言中需要手動分配堆記憶體來初始化,而且資料不用了還需要手動釋放這塊兒記憶體,否則就會造成記憶體洩漏,而且記憶體只能被釋放一次。

這種手動分配手動釋放記憶體的過程容易出問題。微軟證實他們70%的漏洞都是記憶體相關的問題導致的。既然手動操作記憶體的風險這麼高,為什麼還要使用呢?因為相比垃圾回收機制,它具備更高的控制力和效能,程式不用停下來花時間檢查哪些記憶體需要被釋放。

Rust 的所有權機制就處在二者之間。通過在程式中記錄資料的使用並遵循一定的規則,借用檢查器能夠判斷資料在什麼時候能夠初始化,什麼時候能被釋放(在Rust中釋放被稱作 drop),結合了垃圾回收的便利與手動管理的效能,就像一個內嵌在語言中的記憶體管理器。

在實操中,在所有權機制下我們可以對資料進行三種操作方式:

  1. 直接將資料的所有權移交出去
  2. 拷貝一份資料,單獨將拷貝資料的所有權移交出去
  3. 將資料的引用移交出去,保留資料本身的所有權,讓接收方暫時“借用”(borrow

使用哪種方式依據場景而定。

借用檢查器的其它能力:併發

除了處理記憶體的分配與釋放,借用檢查器還能阻止資料競爭,正如Rust所謂的“無懼併發”,讓你毫無顧慮地進行併發、並行程式設計。

缺點

美好的事物總是伴隨著代價,Rust的所有權系統同樣也有缺陷,事實上如果不是這些缺陷,我也不會專門寫這篇文章。

比較難上手,是借用檢查機制的一大缺點。Rust社群中不乏被它折磨的新人,我自己也在掌握它上面花費了很多時間。

舉個例子,在借用機制下,共享資料會變得比較繁瑣,尤其是共享資料的同時還要改變資料的場景。很多其它語言中非常簡便就能建立的資料結構,在Rust中會比較麻煩。

但是當你理解了它,編寫Rust程式碼會更順手。我很喜歡社群裡的一句話:

借用機制的幾條規則,就像拼寫檢查一樣,如果你一點兒都不理解他們,那你寫出來的程式碼基本都是錯的。心平氣和地理解了它們,才會寫出正確的程式碼。

幾條基本規則:

  1. 每當向一個方法傳遞引數變數(非變數的引用)時,都是將該變數的所有權轉移給呼叫的方法,此後你就不能再使用它了。
  2. 每當傳遞變數的引用(即所謂的借用),你可以傳遞任意多個不可變引用,或者一個可變引用。也就是說可變引用只能有一個。

實踐

理解了借用檢查機制後,現在實踐一下。我們將使用Rust中可變長度的list: Vec<T> 型別(類似Python中的 list 和 JavaScript中的 Array),可變長度的特性決定了它需要使用堆記憶體來儲存。

這個例子比較刻意,但它能很好的說明上述的規則。我們將建立一個 vector,將它作為引數傳遞給一個函式進行呼叫,然後看看在裡面會發生什麼。

注意:下面這個程式碼例項不會通過編譯

fn hold_my_vec<T>(_: Vec<T>) {}

fn main() {
    let v = vec![2, 3, 5, 7, 11, 13, 17];
    hold_my_vec(v);
    let element = v.get(3);
    
    println!("I got this element from the vector: {:?}", element);
}

執行後,會得到如下錯誤:

error[E0382]: borrow of moved value: `v`
--> src/main.rs:6:19
          |
        4 |     let v = vec![2, 3, 5, 7, 11, 13, 17];
          |         - move occurs because `v` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait
        5 |     hold_my_vec(v);
          |                 - value moved here
        6 |     let element = v.get(3);
          |                   ^ value borrowed here after move

這個報錯資訊告訴我們 Vec<i32> 沒有實現 Copy 特性(trait),因此它的所有權是被轉移(借用)了,無法在這之後再訪問它的值。只有能在棧上儲存的型別實現了 Copy 特性,而 Vec 型別必須分配在堆記憶體上,它無法實現該特性。我們需要找到另一種手段來處理類似情況。

Clone

雖然 Vec 型別變數不能實現 Copy 特性,但它實現了 Clone 特性。在Rust中,克隆是另一種複製資料的方式。與copy只能對棧上的資料進行拷貝、開銷小的特點不同,克隆也可以面向堆資料,並且開銷可以很大。

回到上面的例子中,傳值給函式的場景,那我們給它一個向量的克隆也可以大道目的。如下程式碼可以正常執行:

fn hold_my_vec<T>(_: Vec<T>) {}

fn main() {
    let v = vec![2, 3, 5, 7, 11, 13, 17];
    hold_my_vec(v.clone());
    let element = v.get(3);

    println!("I got this element from the vector: {:?}", element);
}

但這個程式碼實際做了很多無用功,hold_my_vec 函式都沒使用傳入的向量,只是接收的它的所有權。並且例子中的向量非常小,克隆起來沒什麼負擔,對於剛開始接觸rust開發的階段,這樣可以方便地看到結果。實際上也有更好的方式,下面就來介紹。

引用

除了直接將變數的值所有權移交給函式,還可以把它“借”出去。我們需要修改下 hold_my_vec 的函式簽名,讓它接收的引數從 Vec<T> 更改為 &Vec<T>,即引用型別。呼叫該函式的方式也需要修改下,讓Rust編譯器知道只是將向量的引用———— 一個借用值,交給函式使用。這樣函式就是會短暫地借用這個值,在之後的程式碼中仍然可以使用它。

fn hold_my_vec<T>(_: &Vec<T>) {}

fn main() {
    let v = vec![2, 3, 5, 7, 11, 13, 17];
    hold_my_vec(&v);
    let element = v.get(3);

    println!("I got this element from the vector: {:?}", element);
}

總結

這篇文章只是對借用檢查機制簡短地概覽,介紹它會做什麼,以及為什麼這麼做。更多的細節就留給讀者自己挖掘了。

實際上,隨著你的程式程式碼量擴張,你會遭遇更多棘手的問題,需要圍繞所有權和借用機制展開更深入的思考。甚至為了貼合Rust的借用機制,你得重新設計程式碼的組織結構。

Rust的學習曲線確實比較陡峭,但只要持續學習,你總能一路向上。

相關文章