初識 rust

本文作者爲 360 技術中臺效能工程部的前端開發工程師

這是網上關於 Rust 的一個笑話,每次搜索 rust 相關資料時,總能看到說 rust 上手難,反人類,編程一小時,編譯一整天。但是它已經連續四年獲「最受喜愛的編程語言」,不僅有很多硬件開發,服務端開發都用這個語言,前端很多框架比如 swc,deno 底層也有用 rust 改寫。

本文通過 rust 與衆不同的設計來講解它是如何捲過其他語言。本文中所有代碼可直接複製到 https://play.rust-lang.org/ 來看下 rust 代碼執行的效果。

rust 與衆不同的設計

以垃圾回收機制爲例

各種編程語言內存管理的方式不同,但通常有以下兩種方式:

  1. 開發者自己分配和銷燬: 比如 C、C++ 等,這種方式相當於把所有權力開放給開發者,管理不當容易內存泄漏。

  2. 編程語言提供自動垃圾回收機制: 比如 JavaScript、Java、Python 等,這種方式會產生運行時開銷。

Rust 另闢蹊徑採用所有權、生命週期機制在編譯期自動插入內存釋放邏輯來實現內存管理,簡單說就是當某個變量走出作用範圍時,內存就會立即自動交還給操作系統,不需要開發者自己進行空間申請 / 釋放等操作 。由於沒有了垃圾回收產生的運行時開銷,Rust 整體表現的速度驚人且內存利用率極高。

在講解所有權、生命週期機制之前,我們先了解下 string。

string 介紹

string 分爲兩種情況,字符串字面值和動態可變的字符串。

字符串字面值:標量,手寫的字符串值,不可變,在編譯時就知道內容,類型是 & str,在棧中存儲。

動態可變的字符串:複雜類型,類型是 string,由 4 部分組成(棧中存儲指向存放字符串內容的內存指針,長度和容量。堆中存放字符串內容)。

PS:長度 len 是存放字符串內容所需的字節數(實際存了多少)。容量 capacity 是指 string 從操作系統中總共獲得內存的字節數(最大能存多少)。

// 創建string類型的值
// 雙冒號(::)運算符允許我們調用置於String命名空間下面的特定from函數,而不需要使用類似於string_from這樣的名字
fn main() {
    let mut s = String::from("hello");
    s.push_str(", world"); // 或者 s += ", world";
    println!("{}", s)
    
    // 錯誤 使用js的字符串拼接方法,報錯原因是cannot add `&str` to `&str`
    // let mut s = "hello";
    // s = s + ", world";
    // println!("{}", s)
}

所有權

所有權有以下三條規則:

Rust 中的每個值都有一個變量,稱爲其所有者。這句話我們可以理解成變量名稱就是所有者。

當所有者不在程序運行範圍時,該值將被刪除。也可以類比爲 js 的塊級作用域,超出作用域會報 undefined。

一次只能有一個所有者這句話有些似懂非懂,我們通過幾種變量與數據的交互方式來深入理解下這句話。

移動

下面的例子在 js 中只是一個簡單的賦值過程,能夠順利執行,但是在 rust 中報錯。

fn main() {
 // string類型
 let s1 = String::from("hello");
 let s2 = s1;
 println!("{}", s1); // 報錯,s1不存在
 println!("{}", s2);
}

誒,這是爲什麼呢?我們看下這個圖解。

rust 對於複雜類型的變量,只會複製其棧上的數據,在這裏就是 s2 複製了 s1 的棧上數據,兩個變量都指向同一個堆裏的數據。由於 rust 的垃圾回收機制是當變量離開作用域的時候,自動調用 drop 函數清除內存 ,並將變量使用的堆內存進行釋放,這樣 s1,s2 都會嘗試釋放相同的內存,導致二次釋放的 bug。

爲了防止釋放兩次內存的問題,rust 將 s1 的數據移動到 s2,s1 失效,這樣的行爲叫做移動。

img

克隆

js 中有 淺拷貝shallow copy)和 深拷貝deep copy)的概念,我們可以將上面的拷貝指針、長度和容量而不拷貝數據行爲理解爲淺拷貝。上述的例子如果想 s1 也生效的話,需要對數據進行一個深拷貝的操作,也就是除了棧上的三個數據,還有堆中的內容,這就是克隆,rust 裏有clone這個通用方法。

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

拷貝

只是存儲在棧中的數據,比如整數類型,浮點類型,布爾類型,字符類型。這些是 rust 直接進行拷貝,不需要移動操作。

fn main() {
    let x = 5;
    let y = x;

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

所有權與函數

向函數傳遞值可能會移動或者複製,就像賦值語句一樣。同樣的,函數返回值也有所有權。

fn main() {
    let s = String::from("hello");  // s 進入作用域

    takes_ownership(s);             // s 的值移動到函數里 ...
    // println!("{}", s);           // 報錯 ... 所以到這裏不再有效

    let x = 5;                      // x 進入作用域

    let y = makes_copy(x);          // x 應該移動函數里,
                                    // 但 i32 是 Copy 的,
    println!("x is {}", x);         // 所以在後面可繼續使用 x
                                    // gives_ownership 將返回值轉移給 y

} // 這裏, x 先移出了作用域,然後是 s。但 s 的值已被移走,

fn takes_ownership(some_string: String) { // some_string 進入作用域
    println!("{}", some_string);
} // 這裏,some_string 移出作用域並調用 `drop` 方法。
  // 佔用的內存被釋放

fn makes_copy(some_integer: i32) -> i32{ // some_integer 進入作用域
    println!("{}", some_integer);
    some_integer
} // 這裏,some_integer 移出作用域。沒有特殊之處

可以看到將引用類型的值傳進函數後不能在使用,rust 會報值已被移動找不到,但我們不想傳遞給函數之後就沒了,我們希望在後續能繼續使用這個參數,於是就出現了引用。

引用與租借

"引用" 是變量的間接訪問方式。運算符 &

img

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()
}

引用不會獲得值的所有權,只能租借(Borrow)值的所有權。它本身也是一個類型並具有一個值,這個值記錄的是別的值所在的位置。

fn main() {
     // 錯誤 s2 租借的 s1 已經將所有權移動到 s3,所以 s2 將無法繼續租借使用 s1 的所有權。如果需要使用 s2 使用該值,必須重新租借
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
    
    // 正確 
    let s1 = String::from("hello");
    let mut s2 = &s1;
    let s3 = s1;
    s2 = &s3; // 重新從 s3 租借所有權
    println!("{}", s2);
}

引用又被分爲可變引用和不可變引用。可變引用需要加上 mut 標識符。

// 不可變引用
fn main() {
  let x = 10;

  let r = &x;

}
// 可變引用
fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

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

同一時刻,只能擁有要麼一個可變引用,這是爲了防止併發修改數據衝突,另外還有一個讀寫衝突,就是不可變引用和可變引用之間的限制。

// 併發修改數據衝突
fn main() {
    let mut s = String::from("hello");
    let s2 = &mut s;
    let s3 = &mut s;
    println!("{}", s2); // 報錯 cannot borrow `s` as mutable more than once at a time
}
// 讀寫衝突
fn main() {
    let mut s = String::from("hello");
    let s2 = &mut s;
    s2.push_str(", world");
    
    // 報錯 cannot borrow `s` as immutable because it is also borrowed as mutable
    // println!("{}", s);
    // println!("{}", s2); 
    
    // 單獨輸出s或者s2,或者先輸出可變引用不會報錯
    println!("{}", s2); 
    println!("{}", s);
}

引用必須總是有效的 (無效就是懸垂指針了)

懸垂引用

當指針指向某個值後,這個值被釋放掉了,而指針仍然存在,引用無效,這就是懸垂引用也叫做懸垂指針。

下面是兩種最常見導致懸垂指針現象的例子。

下面這個例子中,rust 會報錯說 x 的值生命週期沒有那麼長。

  1. 函數內大括號導致的作用域範圍不一樣

    fn main() {
        let r;                
        {                    
            let x = 5;     
            r = &x;
        }                    
        println!("r: {}", r);                    
    }
        
    |
    |         r = &x;          
    |             ^^ borrowed value does not live long enough
  2. 函數返回一個內部創建的引用值

    fn main() {
        let reference_to_nothing = dangle(); // 報錯,懸垂指針
    }
        
    fn dangle() -> &String {// dangle 返回一個字符串的引用
        let s = String::from("hello");// s 是一個新字符串
        &s // 返回字符串 s 的引用
    }// 這裏 s 離開作用域並被丟棄。其內存被釋放。

這就引出了 rust 的生命週期。

生命週期

生命週期,簡而言之就是有效作用域。他與所有權機制同等重要的資源管理機制。主要作用是防止垂懸指針。

基礎規則

在 Rust 中,每個引用都有自己的生命週期。從數據定義到一對大括號的結束,就是一個生命週期範圍。一般情況下,我們無需手動的聲明生命週期,因爲 Rust 編譯器有一個 借用檢查器borrow checker),它通過比較作用域來判斷借用是否合法有效。

上面例子中,打印 r 的語句跟 r 的賦值語句之間有一個大括號,也就是在執行r = &x; 後,x 的生命週期就結束了。x 的生命週期小於 r 的生命週期,這時 r 指向了一個被回收的數據的地址,變成了一個懸垂指針,所以就出錯了

我們看一個判斷字符串長度的函數。

fn main() {
    let string1 = String::from("long string is long");
    let string2 = String::from("xyz");
    let result = longest(string1, string2);
    println! ("The longest string is {}", result);
}
fn longest(x: String, y: String) -> String {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

這裏參數和返回值都是 String 類型,可以編譯通過,輸出The longest string is long string is long,我們讓函數參數變成引用類型,返回值是整數類型再看看。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> usize {
    if x.len() > y.len() {
        x.len()
    } else {
        y.len()
    }
}

也可以編譯通過,輸出The longest string is 22

我們把上面的函數再改下,參數和返回值都是引用類型,編譯器就會報錯。

fn main() {
    let string1 = String::from("long string is long");
    let string2 = String::from("xyz");
    //  將字符串對象轉換爲字符串字面量,寫成longest(&string1, &string2)也是可以的
    let result = longest(string1.as_str(), string2.as_str());
    println! ("The longest string is {}", result);
}
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

這個報錯的原因是,不添加標註的話,對 Rust 編譯器來說,其實相當於

    fn longest<'a,'b> (x: &'a str , y: &'b str) -> &str{//

兩個引用參數各自擁有一個生命週期,Rust 無法推斷 x 和 y 的生命週期誰更長,以及 x 和 y 的生命週期與返回值的生命週期的關聯。爲了修復這個錯誤,我們要顯式聲明入參的生命週期。報錯提示裏寫明瞭怎麼進行修改,我們直接把第 7 行復制粘貼過去就得到了正確的寫法。

生命週期註解語法

當引用的生命週期可能以不同的方式相互關聯的時候(當函數的參數和返回值都是引用類型時),需要手動標註生命週期,避免懸垂指針。這就是生命週期註解。

生命週期註解是描述引用生命週期的辦法。它不會改變引用的生命週期

生命週期註解用單引號開頭,跟着一個小寫字母單詞(不限 a),位於 & 之後,並有一個空格來將引用類型與生命週期註解分隔開。

&i32        // 引用
&'a i32     // 帶有顯式生命週期的引用
&'a mut i32 // 帶有顯式生命週期的可變引用

longest 函數中,我們將函數生命週期參數,函數參數以及函數返回值都加上了生命週期'a,注意 longest 函數並不需要知道 x 和 y 以及返回值具體會存在多久,只需要知道有某個可以被 'a 替代的作用域將會滿足最小作用域(引用參數作用域相重疊的那一部分)。

簡單說就是,引用參數的最小作用域要大於等於函數引用返回值的作用域,任何不滿足這個約束條件的值都將被借用檢查器拒絕。我們將上面的例子中 string2 的生命週期改變下,依舊能返回正確的值。

fn main() {
    let string1 = String::from("long string is long");
    {
          let string2 = String::from("xyz");
          //  將字符串對象轉換爲字符串字面量,寫成longest(&string1, &string2)也是可以的
          let result = longest(string1.as_str(), string2.as_str());
          println! ("The longest string is {}", result);
    }
}

在這個例子中,string1 直到外部作用域結束都是有效的,string2 則在內部作用域中是有效的,而 result 則引用了一些直到內部作用域結束都是有效的值。借用檢查器認可這些代碼;它能夠編譯和運行,並打印出 The longest string is long string is long

我們在改下這個例子,將函數返回值的生命週期縮小。

fn main() {
    let string1 = String::from("long string is long");
    let string2 = String::from("xyz");
    {
        let result = longest(string1.as_str(), string2.as_str());
        println! ("The longest string is {}", result);
    }
}
fn longest<'a> (x: &'a str , y: &'a str) -> &'a str{
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

也是可以正確輸出的,接下來,我們再改下例子,將 result 變量的聲明移動出內部作用域,但是將 result 和 string2 變量的賦值語句一同留在內部作用域中。接着,使用了變量 result 的 println! 也被移動到內部作用域之外。

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println! ("The longest string is {}", result);
}
fn longest<'a> (x: &'a str , y: &'a str) -> &'a str{
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

編輯器會報錯,是因爲 rust 要保證 println! 中的 result 是有效的,也就是longest函數的參數和返回值都是相同的生命週期參數 'a。但string2 的生命週期只在內部的作用域,需要直到外部作用域結束都是有效的代碼才能正確運行。

深入理解生命週期

指定生命週期參數的正確方式是依賴函數實現的具體功能。

還是上面的判斷字符串函數爲例,如果這個函數的返回值總是返回第一個參數,則 y 這個引用參數就不需要指定生命週期,代碼也是可以編譯通過的。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

有些函數,參數和返回值也都是引用,但不需要生命週期註解也能編譯成功,比如數組或字符串的循環。

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

這個函數在早期的時候是必須添加生命週期的,但 Rust 團隊發現在特定情況下 Rust 程序員們總是重複地編寫一模一樣的生命週期註解。這些場景是可預測的並且遵循幾個明確的模式。接着 Rust 團隊就把這些模式編碼進了 Rust 編譯器中,如此借用檢查器在這些情況下就能推斷出生命週期而不再強制程序員顯式的增加註解。被編碼進 Rust 引用分析的模式被稱爲 生命週期省略規則lifetime elision rules)。

函數或方法的參數的生命週期被稱爲 輸入生命週期input lifetimes),而返回值的生命週期被稱爲 輸出生命週期output lifetimes)。

編譯器採用三條規則來判斷引用何時不需要明確的註解。第一條規則適用於輸入生命週期,後兩條規則適用於輸出生命週期。如果編譯器檢查完這三條規則後仍然存在沒有計算出生命週期的引用,編譯器將會停止並生成錯誤。這些規則適用於 fn 定義,以及 impl 塊。

第一條規則是每一個是引用的參數都有它自己的生命週期參數。換句話說就是,有一個引用參數的函數有一個生命週期參數:fn foo<'a>(x: &'a i32),有兩個引用參數的函數有兩個不同的生命週期參數,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此類推。

第二條規則是如果只有一個輸入生命週期參數,那麼它被賦予所有輸出生命週期參數:fn foo<'a>(x: &'a i32) -> &'a i32

第三條規則是如果方法有多個輸入生命週期參數並且其中一個參數是 &self 或 &mut self,說明是個對象的方法 (impl 塊)。那麼所有輸出生命週期參數被賦予 self 的生命週期。第三條規則使得方法更容易讀寫,因爲只需更少的符號。

我們可以根據這三條生命週期規則來判斷當前的函數是否需要加上顯式的生命週期註解。

結尾

我們通過代碼可以看到 rust 最重要的兩個特點,高性能和可靠性。

Rust 不僅可以在嵌入式設備上運行,還能輕鬆和其他語言集成。比如 web 開發。rust 支持把代碼編譯成 WebAssembly 在瀏覽器上運行,目前也有相關的框架將這兩部分集成可供前端開發者直接開箱部署,例如:yew。最後附上「rust 學習路徑圖」,想學習 rust 的小夥伴可以參考下來計劃學習之路。

參考資料

Rust 程序設計語言 - Rust 程序設計語言 簡體中文版

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GzDAUoCO-DmK9dP4I6Kr5Q