《The Rust Programming language》程式碼練習(part 2 進階部分)

chen0adapter發表於2021-01-17
我與Rust的緣分起始於當我在程式設計論壇上閒逛時,無意間發現了這麼一門現代型的系統安全的函式式系統程式語言,但是當時只是大致瞭解,並無深入學習,所以此次便將它細緻性地學習了一遍。
學習內容為書籍《The Rust Programming language》的全部內容(已完成)、《Rust程式設計之道》的全部內容(未完成)和《The Rustonomicon》的部分內容(未完成)。

一. 內容概述

我將 《Rust 程式語言》 的學習內容分為基礎學習(1至9章)與進階學習(10至19章),這兩個部分是對我學習內容的一個大概縮略。而後是一個根據書上最後一章(20章)進行的簡單的 web server 程式構建,最後是對比 Rust 社群已有的actix web 框架的一個簡單 example。
本文為《The Rust Programming language》後半部分概要,此部分學習練習程式碼已經發在了開源平臺 GiteeGitHub 平臺上.

檢視第二部分請轉至

3.進階學習

3.1泛型與trait

3.1.1泛型

​ 使用泛型為像函式簽名或結構體這樣的項建立定義,這樣它們就可以用於多種不同的具體資料型別.可以使用泛型定義函式、結構體、列舉和方法.

函式定義泛型:

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

​ 結構體定義泛型:

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

​ 列舉體定義泛型:(以自帶的Result列舉定義為例)

enum Result<T, E> {
    Ok(T),
    Err(E),
}

​ 方法中的泛型定義:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

3.1.2 trait

​ trait 定義是一種將方法簽名組合起來的方法,目的是定義一個實現某些目的所必需的行為的集合。

定義與實現trait:

pub trait Summary {
    fn summarize(&self) -> String {//預設實現
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

//Trait Bound 語法
pub fn notify<T: Summary>(item: T) {
    println!("Breaking news! {}", item.summarize());
}

// where Trait Bound
fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{
    10
}

//return 實現了trait的型別
fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("of course, as you probably already know, people"),
        reply: false,
        retweet: false,
    }
}

trait事實是一種高階介面用法,其使用使得程式碼編寫變得靈活且可讀性高.

3.2生命週期

​ Rust 中的每一個引用都有其 生命週期lifetime),也就是引用保持有效的作用域。生命週期實際上是一種泛型,生命週期的概念從某種程度上說不同於其他語言中類似的工具,毫無疑問這是 Rust 最與眾不同的功能.

​ Rust 編譯器有一個 借用檢查器borrow checker),它比較作用域來確保所有的借用都是有效的,即對生命週期進行檢查.

​ 生命週期註解並不改變任何引用的生命週期的長短。與當函式簽名中指定了泛型型別引數後就可以接受任何型別一樣,當指定了泛型生命週期後函式也能接受任何生命週期的引用。生命週期註解描述了多個引用生命週期相互的關係,而不影響其生命週期。

​ 生命週期註解有著一個不太常見的語法:生命週期引數名稱必須以撇號(')開頭,其名稱通常全是小寫,類似於泛型其名稱非常短.

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

​ 函式簽名表明對於某些生命週期 'a,函式會獲取兩個引數,他們都是與生命週期 'a 存在的一樣長的字串 slice。函式會返回一個同樣也與生命週期 'a 存在的一樣長的字串 slice

結構體定義中的生命週期註解:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.')
        .next()
        .expect("Could not find a '.'");
    let i = ImportantExcerpt { part: first_sentence };
}

​ 上述程式碼中出現的字串切片成員(借用),類似於泛型引數型別,必須在結構體名稱後面的尖括號中宣告泛型生命週期引數,以便在結構體定義中使用生命週期引數.

函式或方法的引數的生命週期被稱為 輸入生命週期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,說明是個物件的方法, 那麼所有輸出生命週期引數被賦予 self 的生命週期。第三條規則使得方法更容易讀寫,因為只需更少的符號。

方法定義中的生命週期註解:

​ 譬如以下的程式碼符合第三條宣告週期省略規則:

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

靜態生命週期:

'static,一種特殊的生命週期,其生命週期能夠存活於整個程式期間。所有的字串字面值都擁有 'static 生命週期.

標註例子:

let s: &'static str = "I have a static lifetime.";

​ 同一函式中指定泛型型別引數、trait bounds 和生命週期:

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
    where T: Display
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

3.3測試

​ Rust 中有可編寫的測試函式用來驗證非測試程式碼是否按照期望的方式執行。測試函式體通常執行如下三種操作:

  1. 設定任何所需的資料或狀態

  2. 執行需要測試的程式碼

  3. 斷言其結果是所期望的

    如下是一個簡單的測試例項:

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

​ 當執行多個測試時, Rust 預設使用執行緒來並行執行。

3.4閉包

​ Rust 的 閉包closures)是可以儲存進變數或作為引數傳遞給其他函式的匿名函式。可以在一個地方建立閉包,然後在不同的上下文中執行閉包運算。不同於函式,閉包允許捕獲呼叫者作用域中的值。

​ 閉包的定義以一對豎線(|)開始,在豎線中指定閉包的引數;

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

​ 使用move來捕獲環境值得所有權;

fn main() {
    let x = vec![1, 2, 3];

    let equal_to_x = move |z| z == x;//在這裡捕獲x

    println!("can't use x here: {:?}", x);//此處報錯,因為環境中的x已經無效了

    let y = vec![1, 2, 3];

    assert!(equal_to_x(y));
}

3.5智慧指標

實際上前面基礎部分所說的引用(&)是一種Rust中最常見的指標,引用以 & 符號為標誌並借用了他們所指向的值.

智慧指標smart pointers)是一類資料結構,他們的表現類似指標,但是也擁有額外的後設資料和功能。在 Rust 中,普通引用和智慧指標的一個額外的區別是引用是一類只借用資料的指標;相反,在大部分情況下,智慧指標 擁有 他們指向的資料。

​ 智慧指標通常使用結構體實現。智慧指標區別於常規結構體的顯著特性在於其實現了 DerefDrop trait。Deref trait 允許智慧指標結構體例項表現的像引用一樣,這樣就可以編寫既用於引用、又用於智慧指標的程式碼。Drop trait 允許我們自定義當智慧指標離開作用域時執行的程式碼。

Box :

​ 最簡單直接的智慧指標是 box,其型別是 Box<T>。 box 允許你將一個值放在堆上而不是棧上。留在棧上的則是指向堆資料的指標。

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Box多用於如下場景:

  • 當有一個在編譯時未知大小的型別,而又想要在需要確切大小的上下文中使用這個型別值的時候
  • 當有大量資料並希望在確保資料不被拷貝的情況下轉移所有權的時候
  • 當希望擁有一個值並只關心它的型別是否實現了特定 trait 而不是其具體型別的時候

BOX建立遞迴型別:

​ Rust 需要在編譯時知道型別佔用多少空間。一種無法在編譯時知道大小的型別是 遞迴型別recursive type),其值的一部分可以是相同型別的另一個值。這種值的巢狀理論上可以無限的進行下去,所以 Rust 不知道遞迴型別需要多少空間。不過 box 有一個已知的大小,所以通過在迴圈型別定義中插入 box,就可以建立遞迴型別了

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
}

實現一個簡易智慧指標:

use std::ops::Deref;

struct MyBox<T>(T);
impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

fn hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    use crate::List::{Cons, Nil};

    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));

    let x = 5;
    let y = MyBox::new(x);

    println!("{:?}", list);

    assert_eq!(5, x);
    assert_eq!(5, *y);

    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

Rc:

​ 為了啟用多所有權,Rust 有一個叫做 Rc<T> 的型別。其名稱為 引用計數reference counting)的縮寫。引用計數意味著記錄一個值引用的數量來知曉這個值是否仍在被使用。如果某個值有零個引用,就代表沒有任何有效引用並可以被清理。

Rc<T> 用於當我們希望在堆上分配一些記憶體供程式的多個部分讀取,而且無法在編譯時確定程式的哪一部分會最後結束使用它的時候。如果確實知道哪部分是最後一個結束使用的話,就可以令其成為資料的所有者,正常的所有權規則就可以在編譯時生效。

Rc<T> 只能用於單執行緒場景.

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

​ Rc::strong_count()顯示引用計數.

​ 過不可變引用, Rc<T> 允許在程式的多個部分之間只讀地共享資料。如果 Rc<T> 也允許多個可變引用,則會違反第四章討論的借用規則之一:相同位置的多個可變借用可能造成資料競爭和不一致。

RefCell:

RefCell<T> 代表其資料的唯一的所有權.

​ 如果 Rust 編譯器不能通過所有權規則編譯,它可能會拒絕一個正確的程式;從這種角度考慮它是保守的。如果 Rust 接受不正確的程式,那麼使用者也就不會相信 Rust 所做的保證了。然而,如果 Rust 拒絕正確的程式,雖然會帶來不便,但不會帶來災難。RefCell<T> 正是用於當確信程式碼遵守借用規則,而編譯器不能理解和確定的時候。

​ RefCell也只能用於單執行緒場景.

如下為選擇 Box<T>Rc<T>RefCell<T> 的理由:

  • Rc<T> 允許相同資料有多個所有者;Box<T>RefCell<T> 有單一所有者。
  • Box<T> 允許在編譯時執行不可變或可變借用檢查;Rc<T>僅允許在編譯時執行不可變借用檢查;RefCell<T> 允許在執行時執行不可變或可變借用檢查。
  • 因為 RefCell<T> 允許在執行時執行可變借用檢查,所以我們可以在即便 RefCell<T> 自身是不可變的情況下修改其內部的值。

​ 在不可變值內部改變值就是 內部可變性 模式,RefCell智慧指標正是為了內部可變性.

3.6 執行緒併發

併發程式設計Concurrent programming),代表程式的不同部分相互獨立的執行,而 並行程式設計parallel programming)代表程式不同部分於同時執行

​ 已執行程式的程式碼在一個 程式process)中執行,作業系統則負責管理多個程式。在程式內部,也可以擁有多個同時執行的獨立部分。執行這些獨立部分的功能被稱為 執行緒threads)。

​ 將程式中的計算拆分進多個執行緒可以改善效能,因為程式可以同時進行多個任務,不過這也會增加複雜性。因為執行緒是同時執行的,所以無法預先保證不同執行緒中的程式碼的執行順序。

  • 競爭狀態(Race conditions),多個執行緒以不一致的順序訪問資料或資源

  • 死鎖(Deadlocks),兩個執行緒相互等待對方停止使用其所擁有的資源,這會阻止它們繼續執行

  • 只會發生在特定情況且難以穩定重現和修復的 bug

​ 由程式語言呼叫作業系統 API 建立執行緒的模型有時被稱為 1:1,一個 OS 執行緒對應一個語言執行緒。程式語言提供的執行緒被稱為 綠色green)執行緒,使用綠色執行緒的語言會在不同數量的 OS 執行緒的上下文中執行它們。為此,綠色執行緒模式被稱為 M:N 模型:M 個綠色執行緒對應 N 個 OS 執行緒.

Rust 標準庫只提供了 1:1 執行緒模型實現,綠色執行緒可選擇手動實現或在社群尋找.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

訊息傳遞:

​ 確保執行緒安全併發的一個方式是 訊息傳遞message passing),執行緒或 actor 通過傳送包含資料的訊息來相互溝通。

​ Rust 中一個實現訊息傳遞併發的主要工具是 通道(channel

​ 通道有兩部分組成,一個傳送者(transmitter)和一個接收者(receiver)。程式碼中的一部分呼叫傳送者的方法以及希望傳送的資料,另一部分則檢查接收端收到的訊息。當傳送者或接收者任一被丟棄時可以認為通道被 關閉了。

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

共享併發:

互斥器mutex)是 mutual exclusion 的縮寫,也就是說,任意時刻,其只允許一個執行緒訪問某些資料。為了訪問互斥器中的資料,執行緒首先需要通過獲取互斥器的 lock)來表明其希望訪問資料。鎖是一個作為互斥器一部分的資料結構,它記錄誰有資料的排他訪問權。因此,我們描述互斥器為通過鎖系統 保護其資料。

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

這裡為了安全併發,使用了原子引用計數指標Arc,包裝一個 Mutex<T> ,使其能夠實現在多執行緒之間共享所有權.

3.7Rust與物件導向

如果用經典且傳統的方式來定義物件導向:

物件導向的程式是由物件組成的。一個 物件 包含資料和操作這些資料的過程。這些過程通常被稱為 方法 或 操作。

​ 在這個定義下,Rust 是物件導向的:結構體和列舉包含資料而 impl 塊提供了在結構體和列舉之上的方法。雖然帶有方法的結構體和列舉並不被稱為物件,但是他們提供了與物件相同的功能.

​ 如果封裝是一個語言被認為是面嚮物件語言所必要的方面的話,那麼 Rust 滿足這個要求。在程式碼中不同的部分使用 pub 與否可以封裝其實現細節。

​ 如果一個語言必須有繼承才能被稱為面嚮物件語言的話,那麼 Rust 就不是物件導向的。無法定義一個結構體繼承父結構體的成員和方法。

3.8 unsafe Rust

​ Rust 還隱藏有第二種語言,它不會強制執行這類記憶體安全保證:這被稱為 不安全 Rust(unsafe Rust

​ unsafe Rust存在的主要原因是:底層計算機硬體固有的不安全性。如果 Rust 不允許進行不安全操作,那麼有些任務則根本完成不了。Rust 需要能夠進行像直接與作業系統互動.

​ 可以通過 unsafe 關鍵字來切換到unsafe Rust,接著可以開啟一個新的存放unsafe 程式碼的塊。有五類可以在不安全 Rust 中進行而不能用於安全 Rust 的操作,

  • 解引用裸指標

  • 呼叫不安全的函式或方法

  • 訪問或修改可變靜態變數

  • 實現不安全 trait

  • 訪問 union 的欄位

    unsafe 並不會關閉借用檢查器或禁用任何其他 Rust 安全檢查:如果在不安全程式碼中使用引用,它仍會被檢查。unsafe 關鍵字只是提供了以上五個不會被編譯器檢查記憶體安全的功能.

解引用裸指標:

let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
}

呼叫不安全的函式或方法:

unsafe fn dangerous() {}

unsafe {
    dangerous();
}

​ 使用 extern 函式呼叫外部程式碼:

​ 此處呼叫c語言庫函式abs:

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

​ 匯出為其他語言所用(需要編譯為動態庫)

​ 以匯出為c語言所用為例:

#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}

訪問或修改可變靜態變數:

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {}", COUNTER);
    }
}

實現不安全 trait:

unsafe trait Foo {
    fn dangerous();
}

unsafe impl Foo for i32 {
    fn dangerous(){}
}

訪問聯合體中的欄位:

​ 聯合體主要用於和 C 程式碼中的聯合體互動。訪問聯合體的欄位是不安全的,因為 Rust 無法保證當前儲存在聯合體例項中資料的型別。

union MyUnion { f1: u32, f2: f32 }

fn f(u: MyUnion) {
    unsafe {
        match u {
            MyUnion { f1: 10 } => { println!("ten"); }
            MyUnion { f2 } => { println!("{}", f2); }
        }
    }
}

3.9高階函式

函式指標:

​ Rust中一切皆有型別,函式的型別是fn,實際上fn被稱為函式指標,即可以將函式引數指定為函式指標型別.

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {}", answer);
}

​ 函式數指標實現了所有三個閉包 trait(FnFnMutFnOnce),所以總是可以在呼叫期望閉包的函式時傳遞函式指標作為引數,一個只期望接受 fn 而不接受閉包的情況的例子是與不存在閉包的外部程式碼互動時:C 語言的函式可以接受函式作為引數,但 C 語言沒有閉包。

trait物件返回閉包:

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

​ 這裡對閉包進行了智慧指標包裝,後面採用trait物件的形式進行返回.

3.10巨集

巨集指的是 Rust 中一系列的功能:宣告巨集,使用 macro_rules!,和三種 過程巨集:

  • 自定義 #[derive] 巨集在結構體和列舉上指定通過 derive 屬性新增的程式碼
  • 類屬性(Attribute-like)巨集定義可用於任意項的自定義屬性
  • 類函式巨集看起來像函式不過作用於作為引數傳遞的 token。

​ 從根本上來說,巨集是一種為寫其他程式碼而寫程式碼的方式,即所謂的 超程式設計(metaprogramming),所有的巨集以展開 的方式來生成比所手寫出的更多的程式碼。

rust基礎語法與高階特性學習完成之後,我嘗試著進行了web伺服器的編寫,由於時間有限,只能進行簡單的開發與實現,並且構建了單執行緒web server和利用rust特性進行重構和改進為多執行緒web server:

使用 macro_rules! 來定義巨集:

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

過程巨集定義巨集:

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

類屬性巨集:

#[route(GET, "/")]
fn index() {
}

類函式巨集:

let sql = sql!(SELECT * FROM posts WHERE id=1);

這些巨集展開之後都會有大量的程式碼,提高了程式設計的簡潔性與效率.

進階部分學習結語

該部分雖然相比起基礎部分較為困難,例如閉包、測試和併發,但是依然可以跟隨書籍循序漸進地學習,當然,相當多的內容還是需要去看其他書參考解決的。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章