Rust入坑指南:核心概念

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

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

什麼是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的值,第二段卻報錯了。這是什麼緣由呢?ui

咱們來分析一下代碼。對於第一段代碼,首先有個整數值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。

怎麼樣?是否是感受今天的坑很是給力?若是以前在地下一層的話,那如今已經到地下三層了。因此請各位注意安全,有序降落。

相關文章
相關標籤/搜索