以大見小 - Rust快速實踐(二)

Rust 語言部分細節

以大見小 - Rust快速實踐(一)- 主觀感受
以大見小 - Rust快速實踐(二)- 語言部分細節

快速實踐項目:ratchet

經過長時間的黏貼復制,終于還是決定刪掉大部分細節。一是為了精簡篇幅,不希望文章越來越像摘抄官方文檔的筆記,復制到這里也只是浪費讀者的時間;二是為了盡量切合實踐過程中的體驗,記錄印象最深并且實際遇到的幾個問題。如下:

  • 幾個有趣的細節
    • 可變性、宏與控制流;
    • 使用serde解析yaml;
  • Package、Crate、Mod與Path;
  • 返回結果與錯誤處理;
  • 所有權與生命周期
  • Trait、Trait bound、Trait object;

可變性、宏與控制流

可變性

“變量默認是不可改變的(immutable)。這是推動你以充分利用 Rust 提供的安全性和簡單并發性來編寫代碼的眾多方式之一。” 引自 https://kaisery.github.io/trpl-zh-cn/ch03-01-variables-and-mutability.html

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

運行會報錯

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

變量使用“mut”聲明,之后才可以進行修改

let mut x = 5;

如下代碼中的error!("{}", e);就是調用。宏調用會以"!"結尾,編譯時會被展開為實際代碼。

// 導入日志相關的宏
use log::{debug, info, warn, error};

// 調用宏,輸出錯誤日志
error!("{}", e);
  • 聲明宏(Declarative): macro_rules!
  • 過程宏(Procedural)三種:
    • #[derive] 宏:在(只能在)結構體和枚舉上添加 derive 屬性代碼
    • 類屬性宏:定義可用于任意項的自定義屬性
    • 類函數宏:類似于函數,作用于參數

宏聲明和定義實現還沒有接觸,主要關注已有宏的使用。

  • 宏有函數所沒有的能力,比如:宏調用沒有參數限制。
  • 但是宏定義更復雜,因為等于時編寫生成 Rust 代碼的 Rust 代碼。

宏的使用確實非常方便,日志記錄,打印輸出,注冊監控指標,創建vec動態數組對象等等。我想宏的設計是為了協調解決Rust的終極目標和開發者體驗之間的問題。從開發體驗來看,肯定不限制參數數量的方式更方便簡潔(例如:debug!("response: {} - {}", resp.status(), srv.url);),但這明顯與Rust需要在編碼階段嚴格定義好的邏輯相悖,而這會直接影響到Rust內存安全與極致性能的目標。宏在編譯時展開為代碼的處理方式的確是不錯的折中方案。

除了日志輸出,還有一些方便實用的宏:

// #[derive] 宏

// 添加Serialize 與Deserialize,
// 會向結構體會枚舉添加一些特定方法以實現特定功能
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Service {
    pub name: String,
    pub url: String,
}

// 類屬性宏

// 還沒有用到,簡單記錄一下,類屬性宏可在函數上使用
#[route(GET, "/")]
fn index() {

// 類函數宏

// panic! 拋出不可恢復異常錯誤

// vec!
// 根據提供的初始值創建Vec集合
let v = vec![1, 2, 3];

// json! 與 println!
// https://crates.io/crates/serde_json
// json! 創建serde_json 的Value
// println! 標準輸出
use serde_json::json;

fn main() {
    // The type of `john` is `serde_json::Value`
    let john = json!({
        "name": "John Doe",
        "age": 43,
        "phones": [
            "+44 1234567",
            "+44 2345678"
        ]
    });

    println!("first phone number: {}", john["phones"][0]);

    // Convert to a string of JSON and print it out
    println!("{}", john.to_string());
}

// sql!
// 創建過程中進行sql語法檢查
let sql = sql!(SELECT * FROM posts WHERE id=1);
控制流

Rust有多種控制流,let if 控制流,循環控制流:loop、while 和 for,match控制流運算符,if let 簡化match的控制流。match控制流挺有意思,和Java與Golang中的switch很像,要說不同就是match控制流是可以返回值的。

實踐項目中主要使用了match控制流運算符:

判斷日志配置級別

    let level = match log_level.as_str() {
        "trace" => LevelFilter::Trace,
        "debug" => LevelFilter::Debug,
        "info" => LevelFilter::Info,
        "warn" => LevelFilter::Warn,
        "error" => LevelFilter::Error,
        "crit" => LevelFilter::Error,
        _ => unreachable!(),
    };

判斷返回結果是成功還是異常

    match result {
        // result 是成功,則執行
        Ok(()) => exit(0),
        // result 執行報錯,則執行如下
        Err(e) => {
            error!("{}", e);
            drop(e);
            exit(1)
        }
    }

使用serde解析yaml

ratchet/watcher/src/config.rs

#[derive(Debug, Serialize, Deserialize)]
pub struct Service {
...

// Deserialize
let services: Vec<Service> = serde_yaml::from_str(&buf).unwrap();

上面代碼是解析yaml格式的配置文件,但是有一個細節很有趣就是:解析函數的調用并沒有傳入反序列化對象的實例,而是通過.unwrap()函數直接返回的。這一實現方式與Java和Golang都不太一樣,還需要進一步學習了解。

Packages,Mod,Crate,Path

包相關的概念

整體感覺包的使用很方便也很自由,很少會因為使用包出現編譯問題。但是使用過程中發現還是有更好的使用方法的。比如:

  • Cargo.toml文件中配置的依賴版本號(version) prometheus = { version = "0.11"} 只指定兩位的話可以下載到這一版本下的最新版本;
  • 通過重導出(re-exporting)可以使引用的路徑更簡短,也使crate本身的維護更靈活。

這兩點在剛開始開發時并不清楚,而是之后學習其他項目時才發現的。

Package: 完整的功能組,一個項目中有多個包(Package),每個包(Package)有一個Cargo.toml文件;
Crate:是模塊通過樹形結構組織起來而形成的,通過它可以構建庫或二進制可執行程序,包(Package)是由多個箱(crate)組成;
Module:允許你控制作用域和路徑的私有性,箱(crate)由樹形結構的模塊(Mod)組成;
Path:定義包,模塊和Crates的訪問路徑。

絕對路徑(absolute path)從 crate 根開始,以 crate 名或者字面值 crate 開頭。
相對路徑(relative path)從當前模塊開始,以 self、super 或當前模塊的標識符開頭。

pub,self,super:作用域
use:引用模塊
as:別名
pub use:重導出(re-exporting)
包的定義

也許是為了提高代碼的靈活性?雖然也可以通過"mod ModuleName { }"的格式定義模塊,但這并不是強制的。這么做讓我感覺唯一的作用好像只是使代碼結構更復雜了(多嵌套了一層花括號)。這一點好像與Java和Golang的包定義方式不太一樣。在開發過程中,似乎除了Cargo.toml中定義的包(Package)之外,箱(Crate)和模塊(Module)似乎都是可以根據代碼文件名自動定義的,在使用時(或者說是在引用時)聲明就可以了:"mod ModuleName;"。

包的引用
# 嵌套引用
use std::cmp::Ordering;
use std::io;
等價
use std::{cmp::Ordering, io};

use std::io;
use std::io::Write;
等價,使用self
use std::io::{self, Write};

‘*’(glob )運算符:引用所有公有項
use std::collections::*;
包的重導出

ratchet/common/exporter/src/lib.rs

#[macro_use] extern crate lazy_static;
#[macro_use] extern crate prometheus;
use prometheus::IntCounter;

mod collector;
mod grabber;

pub use grabber::Grabber;
pub use collector::{register, gather};

lazy_static! {
    pub static ref HIGH_FIVE_COUNTER: IntCounter =
        register_int_counter!(opts!(
            "ratchet_high_five",
            "Number of high five received",
            labels!{"service" => "/", "foo" => "bar",})).unwrap();
    pub static ref NOT_FOUND_COUNTER: IntCounter =
        register_int_counter!("ratchet_not_found", "Not found").unwrap();
}

mod collector 后使用分號,而不是代碼塊 ‘{ }’,表示Rust 需要加載與該模塊同名的文件作為該模塊內容(即collector.rs);
mod grabber; 含義相同。
pub use grabber::Grabber;表示將模塊grabber中的結構Grabber在當前模塊中重導出,之后即可作為當前模塊的資源開放訪問,如下面代碼段所示;
pub use collector::{register, gather};含義相同。

ratchet/watcher/src/lib.rs

...
use exporter::Grabber;
...

impl Grabber for Watcher {
...
}

pub fn get_handler() -> impl Grabber {
    Watcher { services: get_services() }
}
...
外部包(第三方包)

ratchet/common/exporter/Cargo.toml

[package]
name = "exporter"
version = "0.1.0"
authors = ["wangfeiping <wangfeiping@outlook.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# prometheus = { version = "0.11.0" }
prometheus = { version = "0.11.0", features = ["process"] }
lazy_static = "1.4.0"
log = "0.4.8"

其中:prometheus = { version = "0.11.0", features = ["process"] }
就是引用的第三方包,沒有深入學習,只發現兩個很實用的小功能:
features = ["process"]配置會在程序收集監控數據的時候抓取程序當前運行進程的一些數據并返回。這種方式沒有深入學習,不過初步使用感覺挺方便的;
version = "0.11.0"則是指定下載引用的版本,還可以配置為version = "0.11",這樣的話好像會自動下載符合0.11.*的最新版本,比如0.11.3。

實踐項目直接使用了如下這些第三方包,用于實現相關功能:
git信息獲取:git-version / target_info;
日志輸出:log / log4rs;
網絡請求(http/https)調用:reqwest ;
運行時監控:prometheus;
yaml數據解析:serde / serde_yaml。

git-version = "0.3.4"
target_info = "0.1.0"
log = "0.4.8"
log4rs = "0.13.0"
reqwest = { version = "0.10", features = ["blocking", "json"] }
prometheus = { version = "0.11.0", features = ["process"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8"

返回結果與錯誤處理

前面代碼中的result 是Result<T, E> 類型的,用于使返回結果的處理更友好,如果成功result中會包含成功的返回值,如果出錯則result中會包含錯誤的信息。在上面的代碼中,也就是如果成功會正常退出結束。如果存在錯誤信息,則會記錄錯誤日志,并以錯誤碼"1"退出程序。

Rust的結果返回與錯誤處理與Java及Golang不太一樣。而且一定會用到并且我經常會因為處理調用返回的結果不正確而導致編譯錯誤。所以也是初期學習就需要了解清楚的部分。

panic! 宏

panic! 宏會拋出一個不可恢復異常(這一點與Golang 不同),觸發執行后續處理及退出操作:

  • 展開(unwinding):默認方式,Rust 會回溯棧并清理它遇到的每一個函數的數據;這算是優雅退出的方式?
  • 終止(abort):直接退出程序,Cargo.toml 的 [profile] 部分增加 panic = 'abort'。內存等資源需要由系統回收
  • 終止(abort)的方式會使編譯生成的二進制可執行程序小一些。
# 編譯release版本時 panic 直接終止
[profile.release]
panic = 'abort'

不可恢復的操作是不太友好的,所以不是所有情況都適用。大部分情況還是需要返回錯誤信息,以便調用方判斷如何處理。比如:如果配置文件讀取失敗或文件不存在,是否可以啟用默認的配置。這就涉及到Rust返回結果(包括異常錯誤)及其處理方式。

返回結果及其處理

Result<T, E>是一個枚舉類型(Rust的枚舉就不細說了),Result 的枚舉成員是 Ok 和 Err,Ok 表示操作成功,內部包含成功時產生的值。Err 成員則意味著操作失敗,一般包含失敗信息。

Result 擁有 expect 方法。如果Result 是Err,expect 會導致程序崩潰,并顯示傳遞給expect 的信息。如果Result 是 Ok,expect 會獲取Ok 中的值并返回。這樣調用expect 方法就能夠獲得真正的調用結果值。

Result<T, E>還定義了很多輔助方法:

  • expect:
    • 如果Result為成功(Ok),則返回值;
    • 如果Result為失敗(Err),則會按照傳入expect的參數信息調用panic!;
  • unwrap:
    • 如果Result為成功(Ok),則返回值;
    • 如果Result為失敗(Err),則會調用panic!;
  • "?",錯誤傳播(propagating)運算符
    • 寫在Result 結果值之后;
    • "?" 運算符會調用錯誤值的 from 函數,該函數定義于標準庫的 From trait 中,會將該錯誤轉換為另一種類型。當 "?" 運算符調用 from 函數時,收到的錯誤類型被轉換為由當前函數返回類型所指定的錯誤類型;當前錯誤類型需要實現 from 函數來定義如何將自身轉換為返回的錯誤類型,"?" 運算符會自動轉換;
    • 如果Result為成功(Ok),會取Ok中的值作為當前表達式的返回值,后續程序可繼續執行;
    • 如果Result為失敗(Err),則會取Err中的值作為整個函數的返回值,與return 一樣
  • main 可以有兩種返回值?
    • 默認的有效返回值 ()
    • 另一種有效返回值 Result<T, E>
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

如果上面代碼File::open("hello.txt")調用結果失敗,則會立即退出main函數(退出程序);如果成功則會繼續執行表達式Ok(())作為main函數的運行結果返回。

Box<dyn Error> 被稱"Trait object" (Trait 對象),但是和Java的對象概念有些不一樣,我理解更接近于Java中實例的概念。Trait作為快速實踐最后的內容在后面介紹。

還有一種常用的枚舉類型Option<T>,用于處理非空值和空值,與Result類似。具體可以查看相關教程枚舉與Option<T>和文檔Option API文檔

所有權與生命周期

  • Rust 中的每一個值都有一個被稱為其所有者(owner)的變量;
  • 而這個所有者任一時刻有且只有一個(所有權轉移);
  • 當所有者變量離開作用域時,其內存就會被釋放。

涉及所有權的操作有:

  • 移動(所有權轉移)move
  • 克隆(堆數據復制)clone
  • 拷貝(棧數據復制)copy
  • Rust 不會自動創建數據的 “深拷貝”。因此,默認復制可以被認為對運行時性能影響較小。

引用與借用

  • 默認時,引用變量值不可修改;
  • 可變引用時,變量值可修改,但只能有一個可變引用。
  • 在任意給定時間,要么只能有一個可變引用,要么只能有多個不可變引用;
  • 引用必須總是有效的。

這些規則可以有效避免數據競爭(data race)以及懸垂指針(dangling pointer)的問題。

生命周期和所有權定義都比較簡單,但是使用起來相對會復雜一些。在函數簽名或結構體中,生命周期有比較復雜或者說繁瑣的注解語法,好在Rust已經定義了生命周期省略規則(lifetime elision rules),大部分情況下不需要額外注解。而且如果有問題在編譯時也會明確的報錯,所以這里就不一一舉例了,完全可以在實踐中通過編譯器報錯不斷解決和加深理解。

所有權與生命周期,感覺很多時候需要考慮組合的使用方式,以此控制數據的生命周期,從而實現和滿足需求。比如如果編譯器報錯說變量生命周期不夠長,就可能需要考慮通過所有權轉移的方式延長數據的生命周期。

Trait、Trait bound、Trait object

使用Trait似乎很簡單,但是真正理解Trait就需要了解很多相關概念和知識,比如泛型,單態化,智能指針等等。我也是在開發實踐中通過編譯報錯、代碼調試和查閱文檔逐步學習。

Trait

ratchet/common/exporter/src/grabber.rs

use prometheus::proto::MetricFamily;

pub trait Grabber: Sync + Send {
    fn name(&self) -> &str;
    fn help(&self) -> &str;
    fn collect(&self) -> Vec<MetricFamily>;
}

為了將實踐項目內的檢測模塊與監控模塊解耦,便于以后的更新和維護,因此對實踐項目進行了進一步重構。開始是希望直接通過閉包實現,不過嘗試了幾次沒有成功,不確定是否可行,所以改為使用Trait。上面代碼就是定義了一個名為Grabber的Trait。

Trait 類似于Java和Golang的接口,定義通用行為。
如下代碼實現了Grabber Trait的結構體,功能就是根據傳入的配置進行檢測,并返回相應的監控指標數據給監控收集模塊。
ratchet/watcher/src/lib.rs

struct Watcher {
    services: Vec<Service>,
}

impl Grabber for Watcher {
    fn name(&self) -> &str {
        "request_duration_millis"
    }
    fn help(&self) -> &str {
        "request duration millis"
    }
    fn collect(&self) -> Vec<MetricFamily> {
    ...
Trait bound

我理解的Trait bound就是聲明和限定參數Trait的一種方式(而參數類型位置的impl Summary算是Trait bound的語法糖),完整定義的話就需要按照泛型的定義規范,如下:

pub fn notify(item1: impl Summary, item2: impl Summary) {
等效
pub fn notify<T: Summary>(item1: T, item2: T) {

可以通過“+”聲明組合Trait,還可以通過“where”讓代碼結構更整潔清晰一些:

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

實現了Watcher結構體之后,需要將其注冊到監控模塊的收集器中,如下:
ratchet/ratchet/src/main.rs#L71

use exporter::{..., register};
...
    register(Box::new(watcher::get_handler()));
...

ratchet/common/exporter/src/collector.rs#L53

struct RatchetCollector {
    descs: Vec<Desc>,
    grabber: Box<dyn Grabber>,
}

而最初的代碼是這樣的:

// 編譯報錯代碼
struct RatchetCollector {
    descs: Vec<Desc>,
    grabber: Grabber,
}
// 編譯報錯代碼
use exporter::{..., register};
...
    register(watcher::get_handler());
...

并沒有使用Box<dyn Trait>,而編譯時會報錯:

  --> common/exporter/src/collector.rs:10:26
   |
10 | pub fn register(grabber: Grabber)
   |                          ^^^^^^^ help: use `dyn`: `dyn Grabber`
   |
   = note: `#[warn(bare_trait_objects)]` on by default

warning: trait objects without an explicit `dyn` are deprecated
  --> common/exporter/src/collector.rs:53:14
   |
53 |     grabber: Grabber,
   |              ^^^^^^^ help: use `dyn`: `dyn Grabber`

error[E0277]: the size for values of type `(dyn Grabber + 'static)` cannot be known at compilation time
  --> common/exporter/src/collector.rs:10:17
   |
10 | pub fn register(grabber: Grabber)
   |                 ^^^^^^^ doesn't have a size known at compile-time

warning: trait objects without an explicit `dyn` are deprecated
信息很明確,說是默認(沒有使用dyn)的trait objects 聲明方式已經作廢了,建議加上。
doesn't have a size known at compile-time的編譯錯誤雖然明確卻沒有說明如何解決,答案就是使用__Box__Rust的智能指針。

如下情況就可以使用Box智能指針:

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

而Grabber Trait的注冊應該符合了第一和第三中情況,因為實現了Grabber的Trait object 無法在編譯時確定內存占用大小,所以不能在棧中分配,也因此需要使用Box以便使用指針來指向堆中對應的數據,而只需在注冊時傳入Box的指針即可。

dyn Grabber中的dyn表示動態的,是為了避免與impl發生混淆。捋捋 Rust 中的 impl Trait 和 dyn Trait但對于我來說impl trait 和 dyn trait 區別在于靜態分發于動態分發,看了跟沒看沒什么區別,用我的理解表述就是:

  • Trait Object的含義我認為就是指為符合特定Trait定義的所有類型的實例,例如:實踐項目中的struct Watcher就是trait Grabber的Trait Object;換句話說就是Trait Object可以動態匹配多個具有特定Trait特征的不同類型;
  • impl Trait的所謂靜態分發就是編譯時會通過泛型的單態化(對每一個特定的Trait生成特定的代碼)在編譯時確定;但是由于每一個特定Trait都會生成代碼然后編譯回事編譯最終生成的程序大小較大;
  • dyn Trait動態分發則是在運行時匹配,是為了從語義上確定Trait Object;引入dyn就是為了避免Trait Object與impl Trait代碼的混淆。

總結

總的來說,Rust的確是強大的語言,而且具有很多有趣的特性。開發時要求開發者考慮的更多一些,概念也稍顯瑣碎。但這些都是值得的,因為我相信肯定有更多更為高級和深入的、有價值的內容尚待挖掘。

相關資源與參考文章

2020年報告
Tiobe Index 202101
Oschina IDE 簡介
IDE 選擇
VS Code

Rust官方網站
官方教程 通過例子學習Rust
官方教程 中文版
Rust編譯錯誤索引查詢
Rust文檔查詢 https://docs.rs/
Rust crate庫 https://crates.io/
第三方技術論壇

GitHub 上有哪些值得關注的 Rust 項目?
Rust有GC,并且速度很快
釋放堆內存,Rust是怎么做的?所有權!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容