Rust入坑指南:核心概念

  • 2019 年 10 月 14 日
  • 筆記

如果說前面的坑我們一直在用小鏟子挖的話,那麼今天的坑就是用挖掘機挖的。

今天要介紹的是Rust的一個核心概念:Ownership。全文將分為什麼是Ownership以及Ownership的傳遞類型兩部分。

什麼是Ownership

每種程式語言都有自己的一套記憶體管理的方法。有些需要顯式的分配和回收記憶體(如C),有些語言則依賴於垃圾回收器來回收不使用的記憶體(如Java)。而Rust不屬於以上任何一種,它有一套自己的記憶體管理規則,叫做Ownership。

在具體介紹Ownership之前,我想要先聲明一點。Rust入坑指南:常規套路一文中介紹的數據類型,其數據都是存儲在棧中。而像String或一些自定義的複雜數據結構(我們以後會對它們進行詳細介紹),其數據則存儲在堆記憶體中。明確了這一點後,我們來看下Ownership的規則有哪些。

Ownership的規則

  • 在Rust中,每一個值都有對應的變數,這個變數稱為值的owner
  • 一個值在某一時刻只能有一個owner
  • 當owner超出作用域後,值會被銷毀

這三條規則非常重要,記住他們會幫助你更好的理解本文。

變數作用域

Ownership的規則中,有一條是owner超過範圍後,值會被銷毀。那麼owner的範圍又是如何定義的呢?在Rust中,花括弧通常是變數範圍作用域的標誌。最常見的在一個函數中,變數s的範圍從定義開始生效,直到函數結束,變數失效。

fn main() {                      // s is not valid here, it』s not yet declared      let s = "hello";   // s is valid from this point forward        // do stuff with s  }                      // this scope is now over, and s is no longer valid

這個這和其他大多數程式語言很像,對於大多數程式語言,都是從變數定義開始,為變數分配記憶體。而回收記憶體則是八仙過海各顯神通。對於有依賴GC的語言來說,並不需要關心記憶體的回收。而有些語言則需要顯式回收記憶體。顯式回收就會存在一定的問題,比如忘記回收或者重複回收。為了對開發者更加友好,Rust使用自動回收記憶體的方法,即在變數超出作用域時,回收為該變數分配的記憶體。

Ownership的移動

前面我們提到,花括弧通常是變數作用域隔離的標誌(即Ownership失效)。除了花括弧以外,還有其他的一些情況會使Ownership發生變化,先來看兩段程式碼。

let x = 5;  let y = x;  println!("x: {}", x);
let s1 = String::from("hello");  let s2 = s1;  println!("s1: {}", s1);

作者註:雙冒號是Rust中函數引用的標誌,上面的意思是引用String中的from函數,這個函數通常用來構建一個字元串對象。

這兩段程式碼看起來唯一的區別就是變數的類型,第一段使用的是整數型,第二段使用的是字元串型。而執行結果卻是第一段可以正常列印x的值,第二段卻報錯了。這是什麼原因呢?

我們來分析一下程式碼。對於第一段程式碼,首先有個整數值5,賦給了變數x,然後把x的值copy了一份,又賦值給了y。最後我們成功列印x。看起來比較符合邏輯。實際上Rust也是這麼操作的。

對於第二段程式碼我們想像中,也可以是這樣的過程,但實際上Rust並不是這樣做的。先來說原因:對於較大的對象來說,這樣的複製是非常浪費空間和時間的。那麼Rust中實際情況是怎麼樣呢?

首先,我們需要了解Rust中String類型的結構:

String結構

上圖中左側是String對象的結構,包括指向內容的指針、長度和容量。這裡長度和容量相同,我們暫時先不關注。後面詳細介紹String類型時會提到兩者的區別。這部分內容都存儲在棧記憶體中。右側部分是字元串的內容,這部分存儲在堆記憶體中。

有的朋友可能想到了,既然複製內容會造成資源浪費,那我只複製結構這部分好了,內容再多,我複製的內容長度也是可控的,而且也是在棧中複製,和整數類型類似。這個方法聽起啦不錯,我們來分析一下。按照上面這種說法,記憶體結構大概是這個樣子。

String-2

這種會有什麼問題呢?還記得Ownership的規則嗎?owner超出作用域時,回收其數據所佔用的記憶體。在這個例子中,當函數執行結束時,s1和s2同時超出作用域,那麼上圖中右側這塊記憶體就會被釋放兩次。這也會產生不可預知的bug。

Rust為了解決這一問題,在執行let s2 = s1;這句程式碼時,認為s1已經超出了作用域,即右側的內容的owner已經變成了s2,也可以說s1的ownership轉移給了s2。也就是下圖所示的情況。

String-real

另一種實現:clone

如果你確實需要深度拷貝,即複製堆記憶體中的數據。Rust也可以做到,它提供了一個公共方法叫做clone。

let s1 = String::from("hello");  let s2 = s1.clone();    println!("s1 = {}, s2 = {}", s1, s2);

clone的方法執行後,記憶體結構如下圖:

String-clone

函數間轉移

前面我們聊到的是Ownership在String之間轉移,在函數間也是一樣的。

fn main() {      let s = String::from("hello");  // s 作用域開始        takes_ownership(s);             // s's 的值進入函數                                      // ... s在這裡已經無效    } // s在這之前已經失效  fn takes_ownership(some_string: String) { // some_string 作用域開始      println!("{}", some_string);  } // some_string 超出作用域並調用了drop函數    // 記憶體被釋放

那有沒有辦法在執行takes_ownership函數後使s繼續生效呢?一般我們會想到在函數中將ownership還回來。然後很自然的就想到我們之前介紹的函數的返回值。既然傳參可以轉移ownership,那麼返回值應該也可以。於是我們可以這樣操作:

fn main() {      let s1 = String::from("hello");     // s2 comes into scope        let s2 = takes_and_gives_back(s1);  // s1 被轉移到函數中                                          // takes_and_gives_back,                                          // 將ownership還給s2  } // s2超出作用域,記憶體被回收,s1在之前已經失效      // takes_and_gives_back 接收一個字元串然後返回一個  fn takes_and_gives_back(a_string: String) -> String { // a_string 開始作用域        a_string  // a_string 被返回,ownership轉移到函數外  }

這樣做是可以實現我們的需求,但是有點太麻煩了,幸好Rust也覺得這樣很麻煩。它為我們提供了另一種方法:引用(references)。

引用和借用

引用的方法很簡單,只需要加一個&符。

fn main() {      let s1 = String::from("hello");        let len = calculate_length(&s1);        println!("The length of '{}' is {}.", s1, len);  }    fn calculate_length(s: &String) -> usize {      s.len()  }

這種形式可以在沒有ownership的情況下訪問某個值。其原理如下圖:

references

這個例子和我們在前面寫的例子很相似。仔細觀察會發現一些端倪。主要有兩點不同:

  1. 在傳入參數的時候,s1前面加了&符。這意味著我們創建了一個s1的引用,它並不是數據的owner,因此在它超出作用域時也不會銷毀數據。
  2. 函數在接收參數時,變數類型String前也加了&符。這表示參數要接收的是一個字元串的引用對象。

我們把函數中接收引用的參數稱為借用。就像實際生活中我寫完了作業,可以借給你抄一下,但它不屬於你,抄完你還要還給我。(友情提示:非緊急情況不要抄作業)

另外還需要注意,我的作業可以借給你抄,但是你不能改我寫的作業,我本來寫對了你給我改錯了,以後我還怎麼借給你?所以,在calculate_length中,s是不可以修改的。

可修改引用

如果我發現我寫錯了,讓你幫我改一下怎麼辦?我授權給你,讓你幫忙修改,你也需要表示能幫我修改就可以了。Rust也有辦法。還記得我們前面介紹的可變變數和不可變變數嗎?引用也是類似,我們可以使用mut關鍵字使引用可修改。

fn main() {      let mut s = String::from("hello");        change(&mut s);  }    fn change(some_string: &mut String) {      some_string.push_str(", world");  }

這樣,我們就能在函數中對引用的值進行修改了。不過這裡還要注意一點,在同一作用域內,對於同一個值,只能有一個可修改的引用。這也是因為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; // no problem  let r2 = &s; // no problem  let r3 = &mut s; // BIG PROBLEM    println!("{}, {}, and {}", r1, r2, r3);

這樣的程式碼在編譯時也會報錯。這是因為不可變引用不希望在被使用之前,其指向的值被修改。這裡只要稍微處理一下就可以了:

let mut s = String::from("hello");    let r1 = &s; // no problem  let r2 = &s; // no problem  println!("{} and {}", r1, r2);  // r1 和 r2 不再使用    let r3 = &mut s; // no problem  println!("{}", r3);

Rust編譯器會在第一個print語句之後判斷出r1和r2不會再被使用,此時r3還沒有創建,它們的作用域不會有交集。所以這段程式碼是合法的。

空指針

對於可操作指針的程式語言來講,最令人頭疼的問題也許就是空指針了。通常情況是,在回收記憶體以後,又使用了指向這塊記憶體的指針。而Rust的編譯器幫助我們避免了這個問題(再次感謝Rust編譯器)。

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

來看一下上面這個例子。在dangle函數中,返回值是字元串s的引用。但是在函數結束時,s的記憶體已經被回收了。所以s的引用就成了空指針。此時就會報expected lifetime parameter的編譯錯誤。

另一種引用:Slice

除了引用之外,還有另一種沒有ownership的數據類型叫做Slice。Slice是一種使用集合中一段序列的引用。

這裡通過一個簡單的例子來說明Slice的使用方法。假設我們需要得到給你字元串中的第一個單詞。你會怎麼做?其實很簡單,遍歷每個字元,如果遇到空格,就返回之前遍歷過的字元的集合。

對字元串的遍歷方法我來劇透一下,as_bytes函數可以把字元串分解成位元組數組,iter是返回集合中每個元素的方法,enumerate是提取這些元素,並且返回(元素位置,元素值)這樣的二元組的方法。這樣是不是可以寫出來了。

fn first_word(s: &String) -> usize {      let bytes = s.as_bytes();        for (i, &item) in bytes.iter().enumerate() {          if item == b' ' {              return i;          }      }        s.len()  }

來,感受下這個例子,雖然它返回的是第一個空格的位置,但是只要會字元串截取,還是可以達到目的的。不過不能劇透字元串截取了,不然暴露不出問題。

這麼寫的問題在哪呢?來看一下main函數。

fn main() {      let mut s = String::from("hello world");        let word = first_word(&s);        s.clear();  }

這裡在獲取空格位置後,對字元串s做了一個clear操作,也就是把s清空了。但word仍然是5,此時我們再去對截取s的前5個字元就會出問題。可能有人認為自己不會這麼蠢,但是你願意相信你的好(zhu)伙(dui)伴(you)也不會這麼做嗎?我是不相信的。那怎麼辦呢?這時候slice就要登場了。

使用slice可以獲取字元串的一段字元序列。例如&s[0..5]可以獲取字元串s的前5個字元。其中0為起始字元的位置下標,5是結束字元位置的下標加1。也就是說slice的區間是一個左閉右開區間。

slice還有一些規則:

  • 如果起始位置是0,則可以省略。也就是說&s[0..2]&s[..2]等價
  • 如果起始位置是集合序列末尾位置,也可以省略。即&s[3..len]&s[3..]等價
  • 根據以上兩條,我們還可以得出&s[0..len]&s[..]等價

這裡需要注意的是,我們截取字元串時,其邊界必須是UTF-8字元。

有了slice,就可以解決我們的問題了

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[..]  }

現在我們在main函數中對s執行clear操作時,編譯器就不同意了。沒錯,又是萬能的編譯器。

除了slice除了可以作用於字元串以外,還可以作用於其他集合,例如:

let a = [1, 2, 3, 4, 5];    let slice = &a[1..3];

關於集合,我們以後會有更加詳細的介紹。

總結

本文介紹的Ownership特性對於理解Rust來講非常重要。我們介紹了什麼是Ownership,Ownership的轉移,以及不佔用Ownership的數據類型Reference和Slice。

怎麼樣?是不是感覺今天的坑非常給力?如果之前在地下一層的話,那現在已經到地下三層了。所以請各位注意安全,有序降落。