深入瞭解Rust中引用與借用的用法

楔子

好久沒更新 Rust 瞭,上一篇文章中我們介紹瞭 Rust 的所有權,並且最後定義瞭一個 get_length 函數,但調用時會導致 String 移動到函數體內部,而我們又希望在調用完畢後能繼續使用該 String,所以不得不使用元組將 String 也作為元素一塊返回。

// 該函數計算一個字符串的長度
fn get_length(s: String) -> (String, usize) {
    // 因為這裡的 s 會獲取變量的所有權
    // 而一旦獲取,那麼調用方就不能再使用瞭
    // 所以我們除瞭要返回計算的長度之外
    // 還要返回這個字符串本身,也就是將所有權再交回去
    let length = s.len();
    (s, length)
}


fn main() {
    let s = String::from("古明地覺");

    // 接收長度的同時,還要接收字符串本身
    // 將所有權重新 "奪" 回來
    let (s, length) = get_length(s);
    println!("s = {}, length = {}", s, length); 
    /*
    s = 古明地覺, length = 12
    */
}

但這種寫法很笨拙,下面我們將 get_length 函數重新定義,並學習 Rust 的引用。

什麼是引用

新的函數簽名使用瞭 String 的引用作為參數,而沒有直接轉移所有權。

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

fn main() {
    let s1 = String::from("hello");
    let length = get_length(&s1);
    println!("s1 = {}, length = {}", s1, length); 
    // s1 = hello, length = 5
}

首先需要註意的是,變量聲明以及函數返回值中的那些元組代碼都消失瞭。其次在調用 get_length 函數時使用瞭 &s1 作為參數,並且在函數的定義中,我們使用 &String 替代瞭 String。而 & 代表的就是引用語義,它允許我們在不獲取所有權的前提下使用值。

既然有引用,那麼自然就有解引用,它使用 * 作為運算符,含義和引用相反,我們會在後續詳細地介紹。

現在,讓我們仔細觀察一下這個函數的調用過程:

let s1 = String::from("hello");
let length = get_length(&s1);

這裡的 &s1 允許我們在不轉移所有權的前提下,創建一個指向 s1 值的引用,由於引用不持有值的所有權,所以當引用離開當前作用域時,它指向的值也不會被丟棄。同理,函數簽名中的 & 用來表明參數 s 的類型是一個引用。

           // s 是一個指向 String 的引用
fn get_length(s: &String) -> usize { 
    s.len()
}  // 到這裡 s 離開作用域
   // 但由於它並不持有自己指向值的所有權
   // 所以最終不會發生任何事情

此處變量 s 的有效作用域與其它任何函數參數一樣,但唯一不同的是,它不會在離開自己的作用域時銷毀其指向的數據,因為它並不擁有該數據的所有權。當一個函數使用引用而不是值本身作為參數時,我們便不需要為瞭歸還所有權而特意去將值返回,畢竟在這種情況下,我們根本沒有取得所有權。

而將引用傳遞給函數參數的這一過程被稱為借用(borrowing),在現實生活中,假如一個人擁有某件東西,你可以從他那裡把東西借過來。但是當你使用完畢時,還必須將東西還回去。

Rust 的變量也是如此,如果一個值屬於該變量,那麼該變量離開作用域時會銷毀對應的值,就好比東西你不想要瞭,你可以將它扔掉,因為東西是你的。但如果是借用的話,變量在離開作用域時,這個值並不會被銷毀,就好比東西你不想要瞭,但這個東西並不屬於你,因此你要將它還回去,並且這個東西還在。

至於後續這個東西是否會被扔掉、何時被扔掉,就看它真正的主人是否還需要它,如果不需要瞭,東西的主人是有權利銷毀的,因為這東西是他的。當然,他也可以將東西送給別人,此時就相當於發生瞭所有權的轉移,轉移之後這東西跟他也沒關系瞭。

然後問題來瞭,如果我們嘗試修改借用的值會怎麼樣呢?相信你能猜到,肯定是不允許的,還是拿借東西舉例子,東西既然是借的,就說明你隻有使用權,而沒有修改它的權利。

fn change_string(s: &String) {
    s.push_str(" world");
}

fn main() {
    let s1 = String::from("hello");
    change_string(&s1);
}

執行這段代碼會出現編譯錯誤:

與變量類似,引用是默認不可變的,Rust 不允許我們去修改引用指向的值。

可變引用

我們可以通過一個小小的調整來修復上面的示例中出現的編譯錯誤:

fn change_string(s: &mut String) {
    s.push_str(" world");
}

fn main() {
    let mut s1 = String::from("hello");
    change_string(&mut s1);
}

首先我們需要將變量 s1 聲明為 mut,即可變的,也就是東西的主人能夠允許它的東西發生變化。其次,要使用 &mut s1 來給函數傳入一個可變引用,意思就是東西的主人在將東西借給別人時專門強調瞭,自己的東西允許修改,不然別人不知道啊。

所以這裡如果不傳遞可變引用的話,即使 s1 是可變的,函數 change_string 裡面也不能對值進行修改。因此調用函數的時候要傳遞可變引用,當然函數參數接收的也要是一個可變引用,因為類型要匹配。

另外,除瞭將引用作為參數傳遞之外,還可以賦值給一個變量,因為作為函數參數和賦值給一個變量是等價的。

fn main() {
    let mut s1 = String::from("hello");
    // 可變引用指的是,引用指向的值可以修改
    // 所以要註意這裡的寫法,不要寫成瞭 let mut s2: &String
    // 這表示 s2 是個不可變引用,但 s2 本身是可變的
    // 可變引用是一個整體,所以 &mut String 要整體作為 s2 的類型
    let s2: &mut String = &mut s1;
    // 當然啦,此時 s2 引用的值可變,但 s2 本身不可變
    // 如果希望 s2 還能接收其它字符串的可變引用,那麼應該這麼聲明
    // let mut s2: &mut String = &mut s1;
    // 此時表示 s2 是個可變引用,它引用的值可以修改
    // 並且 s2 本身也是可變的。或者還有更簡單的寫法:
    // 直接寫成 let mut s2 = &mut s1 也行,因為 Rust 會做類型推斷
   
    s2.push_str(" world");
    println!("{}", s1);  // hello world
}

此外要註意:當變量聲明為不可變時,隻能創建不可變引用。

fn main() {
    let s1 = String::from("hello");
    let s2: &mut String = &mut s1;
    println!("{}", s2); 
}

代碼中的 s1 不可變,但卻創建瞭可變引用,於是報錯。

因為 s1 是不可變的,就意味著數據(包括棧內存、堆內存)不可以修改,所以此時不能創建可變引用,否則就意味著值是可以修改的,於是就矛盾瞭。因此當變量聲明為不可變時,不可以將可變引用賦值給其它變量。

但當變量聲明為可變時,既可以創建可變引用,也可以創建不可變引用。如果是可變引用,那麼允許通過引用修改值;如果是不可變引用,那麼不允許通過引用修改值。

fn main() {
    // 變量可變
    let mut s1 = String::from("hello");
    // 可以通過 &s1 創建不可變引用
    // 也可以通過 &mut s1 創建可變引用
    // 但前者不可以修改值,後者可以
}

另外可變引用有一個很大的限制:對於特定作用域中的特定數據來說,一次隻能聲明一個可變引用,否則會導致編譯錯誤。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    s2.push_str("xx");
    s3.push_str("yy");
    println!("{}", s1);
}

我們將 s1 的可變引用給瞭 s2 之後又給瞭 s3,而這是非法的。

但 Rust 做瞭一個 "容忍" 操作,那就是聲明多個引用之後,如果都不使用的話,那麼也不會出現錯誤。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    println!("{}", s1);  // hello
}

以上這段代碼可以順利執行,雖然聲明瞭多個可變引用,但我們沒有使用,所以 Rust 編譯器就大發慈悲 "饒" 瞭我們。但隻要對任意某個引用執行瞭任意某個操作,那麼 Rust 就不會再手下留情瞭,比如:

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    println!("{}", s2); 
}

我們上面對 s2 執行瞭打印操作,於是 Rust 就會提示我們可變引用隻能被借用一次。

但說實話 Rust 編譯器做的這個 "忍讓" 對於我們而言沒有太大意義,因為它要求我們聲明多個可變引用之後不能使用其中的任何一個,但問題是聲明引用就是為瞭使用它,不然聲明它幹嘛。因此我們仍可以認為:對於特定作用域中的特定數據來說,一次隻能聲明一個可變引用,否則會導致編譯錯誤。

這個規則使得引用的可變性隻能以一種受到嚴格限制的方式來使用,許多剛剛接觸 Rust 的開發者會反復地與它進行鬥爭,因為大部分的語言都允許你隨意修改變量。但另一方面,在 Rust 中遵循這條限制性規則可以幫助我們在編譯時避免數據競爭。數據競爭(data race)與競態條件十分類似,它會在指令同時滿足以下 3 種情形時發生:

  • 兩個或兩個以上的指針同時訪問同一空間;
  • 其中至少有一個指針會向空間中寫入數據;
  • 沒有同步數據訪問的機制;

數據競爭會導致未定義的行為,由於這些未定義的行為往往難以在運行時進行跟蹤,也就使得出現的 bug 更加難以被診斷和修復。Rust 則完美地避免瞭這種情形的出現,因為存在數據競爭的代碼連編譯檢查都無法通過⚠️。

與大部分語言類似,我們可以通過花括號來創建一個新的作用域范圍,這就使我們可以創建多個可變引用,當然,同一時刻隻允許有一個可變引用。

fn main() {
    let mut s1 = String::from("hello");
    {
        let s2 = &mut s1;
        s2.push_str(" cruel");
        println!("s2 = {}", s2);
        println!("s1 = {}", s1);
    }
    // 這個 s3 不能聲明在上面的大括號之前,也就是不能先聲明 s3
    // 因為先聲明 s3 的話,那麼聲明 s2 的時候就會出現兩個可變引用
    // 違反瞭同一時刻隻能有一個可變引用的原則
    // 但是將 s3 聲明在這裡就沒有問題,因為聲明 s2 的時候 s3 還不存在
    // 聲明 s3 的時候 s2 已經失效瞭
    // 所以此時滿足同一時刻隻能有一個可變引用的原則,我生君未生、君生我已死
    let s3 = &mut s1;
    s3.push_str(" world");
    println!("s3 = {}", s3);  
    println!("s1 = {}", s1);  
    /*
    s2 = hello cruel
    s1 = hello cruel
    s3 = hello cruel world
    s1 = hello cruel world
     */
}

註意:我們一直說的"一個可變引用"、"多個可變引用",它們針對的都是同一變量;如果是多個彼此無關的變量,那麼它們的可變引用之間也沒有關系,此時是可以共存的。比如同一時刻有 N 個可變引用,但它們引用的都是不同的變量,所以此時沒有問題。

我們一直說的不允許存在多個可變引用,指的是同一變量的多個可變引用,這一點要分清楚。

如果是編程老手的話,那麼應該會想到,如果同時存在可變引用和不可變引用會發生什麼呢?我們試一下就知道瞭。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &s1;
    let s3 = &mut s1;
    println!("{}", s2);
    println!("{}", s3)
}

所以在結合使用可變引用與不可變引用時,還有一條類似的限制規則,我們不能在擁有不可變引用的同時創建可變引用,否則不可變引用就沒有意義瞭。但同時存在多個不可變引用是合理合法的,數據的讀操作之間不會彼此影響。

就有點類似於讀鎖和寫鎖的關系。

盡管這些編譯錯誤會讓人不時地感到沮喪,但是請牢記一點:Rust 編譯器可以為我們提早(在編譯時而不是運行時)暴露那些潛在的bug,並且明確指出出現問題的地方。你不再需要去追蹤調試為何數據會在運行時發生瞭非預期的變化。

懸空引用

使用擁有指針概念的語言會非常容易錯誤地創建出懸空指針,這類指針指向曾經存在的某處內存,但現在該內存已經被釋放掉、或者被重新分配另作他用瞭。而在 Rust 語言中,編譯器會確保引用永遠不會進入這種懸空狀態,假如我們當前持有某個數據的引用,那麼編譯器可以保證這個數據不會在引用被銷毀前離開自己的作用域。

讓我們試著來創建一個懸空引用,並看一看 Rust 是如何在編譯期發現這個錯誤的:

fn dangle() -> &String {
    let s = String::from("hello world");
    &s
}

fn main() {
    
}

出現的錯誤如下所示:

這段錯誤的提示信息包含瞭一個我們還沒有接觸的概念:生命周期,我們會後續詳細討論它。但即使不考慮生命周期,甚至不看錯誤提示,我們也知道原因。dangle 裡面的字符串 s 在函數結束後就會失效,內存會回收,但我們卻返回瞭它的引用。

此處和 C 就出現瞭不同,C 中的堆內存如果我們不手動釋放,那麼它是不會自己釋放的。而 Rust 中的堆內存會在變量離開作用域的時候自動回收,既然回收瞭,那麼再返回它的引用就不對瞭,因為指向的內存是無效的。所以我們也能猜到生命周期是做什麼的,後續聊。

而這個問題的解決辦法也很簡單,直接返回 String 就好。

fn dangle() -> String {
    let s = String::from("hello world");
    s
}

這種寫法沒有任何問題,因為所有權從 dangle 函數中被轉移出去瞭,自然也就不會涉及釋放操作瞭。

小結

讓我們簡要地概括一下對引用的討論:

在任何一段給定的時間裡,要麼隻能擁有一個可變引用,要麼隻能擁有任意數量的不可變引用;

引用總是有效的;

到此這篇關於深入瞭解Rust中引用與借用的用法的文章就介紹到這瞭,更多相關Rust引用 借用內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: