iT邦幫忙

2024 iThome 鐵人賽

DAY 19
1
Software Development

螃蟹幼幼班:Rust 入門指南系列 第 19

Day19 - 更多的列舉

  • 分享至 

  • xImage
  •  

攜帶資料的列舉

上一篇有提到列舉變體可以攜帶不同類型的數據,這裡再針對這種用法來說明。

列舉變體要帶的資料型別其實就分兩種:元組(tuple)型結構體(struct)型,上一篇沒有帶資料型別的方式算是結構型中的類單元結構體。這會關係到建構實體還有解構它們的方式。

需要參數來建立實例之後就更容易觀察出來:列舉變體也會變成建構該列舉的構造器(constructor),根據不同類型變體建立實例的方式也略有不同。

元組型建構的方式和一般元組相同,先用列舉名稱作為命名空間再用 :: 連連到變體,後面用小括號依序放入參數,結構體型則用大括號再把鍵值名稱還有對應型別標上。

首先看一個用元組型的例子:不同版的 IP 位址 IPv4 與 IPv6:

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);
    // println!("{}", home.0); // 這樣寫會有編譯錯誤  

接著我們再舉一個形狀的例子,這邊用結構型,每個變體的屬性和需要的參數數量是不同的。

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn main() {
    let circle = Shape::Circle { radius: 3.0 };
    let rectangle = Shape::Rectangle { width: 4.0, height: 1.1 };
    let triangle = Shape::Triangle { base: 2.5, height: 3.5 };
}

要特別注意,透過列舉多包一層的實例無法和像元組用索引、構造用鍵值的方式直接訪問數值,這是因為列舉本身定義了一個新的類型,而這個新類型並不知道內部變體的結構。也就是說一般解構是行不通的,必須透過 match 才可以在個別的模式解構。

列舉也和結構體一樣我們可以用 impl 來實作它的關聯函數和方法。
我們幫形狀的例子Shape利用match實作能得出所有變體面積的area方法。

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
            Shape::Rectangle { width, height } => width * height,
            Shape::Triangle { base, height } => 0.5 * base * height,
        }
    }
}

基本上和結構體的寫法都一樣,再加上上一篇學到的 match ,裡面每個分支就可以去解構各個變體把需要的數值拿出來了,因為Shape的變體都是結構體型,所以我們都用大括號和鍵值的寫法,如果是元組型那就會用小括號和索引順序取值了。
另外如果實作方法就可以補足我們無法直接操作列舉建出來的實體上數值的限制。

到目前為止,我們看到了如何使用列姆定義不同變體並攜帶多樣化的數據。這讓我們能夠靈活地表達各種情境,如我們前一篇的 User 這篇看到的 IP 位址和 Shape。

不過在實際應用中,我們還會經常遇到這樣的情況:一個變量可能有值,也可能沒有值。
為了處理這樣的情境,Rust 提供了一個非常常見且實用的列舉——Option

常見列舉:Option

許多程式語言中有空值(null)的設計,變數或物件的引用可以為空值,代表該引用並未指向任何有效的記憶體地址,這相當於一個指向「無物」的空指標,使所有引用都可能存在兩種狀態:指向有效記憶體的非空值,或指向空地址的空值。

空指標產生的原因包括:

  • 未初始化的變數: 宣告了一個變數但沒有賦值。
  • 函數返回空值: 當函數無法找到所要搜尋的值時,可能會返回 null。
  • 物件被刪除: 當一個物件被刪除後,但指向它的參考仍然存在,參考就變成指向空值。

空指標很大的一個風險是當嘗試存取空指標所指向之物件時引發 NullPointerException,經常會導致程式崩潰或異常行為,而且這種錯誤通常在程式執行的時候非預期的情況發生,錯誤訊息會很有限,不容易定位和修復。另外在特定狀況空指標可能會被惡例利用造成安全漏洞。

老實說這在 JavaScript 很常遇到,如果要讀取屬性的 Object 是 undefined 就會導致,而且一旦發生通常只會知道是要讀取哪個 key 值出問題,但究竟在專案的哪個檔案、哪一行,除非剛好 key 值有辨識性,否則真的很難找。

let person;
console.log(person.name); // 拋出 TypeError: Cannot read properties of undefined (reading 'name')

但在實務上,無可避免還是有需要表達空的概念的時候,代表目前的數值因為一些原因無效或缺失。
因此另外一種方式就是透過Option型別來表達一個值可能存在或不存在的概念,也是近代 functional programming 的程式語言偏好的方式。

在 Rust 裡表達一個值可能存在或不存在用的就是Option列舉,因為其重要性,被定義在標準函數庫中:

enum Option<T> {
    None,
    Some(T),
}

Rust 已經把這個列舉(Option)和裡面的變體(NoneSome)預設就導入,所以可以直接使用,不需要另外導入,甚至 NoneSome 可以把透過 Option 命名空間那段省略,我們不需要寫成 Option::None 而可以寫 None,意義上是相同的。

<T> 指的是泛型參數,同時代表 Option 列舉中的 Some 變體中的值可以是任意型別,不過目前還沒有要討論泛型。

Option 的設計之所以可行在於 Option<T> 與 T 被視為不同的型別,所以一定要把 Option<T> 轉成 T 才能和 T 一起操作,例如相加數字。和 JavaScript 的空指標的關鍵差異在於,相較於 JavaScript 沒有強迫檢查,開發者沒處理好就會非預期的觸發空指標錯誤,Rust 列舉 Option 搭配上一篇的 match 寫法強迫開發者要去處理所有可能空值與非空值的地方,這樣可以保證拿到的參考一定都是有效的。

以下範例和錯誤訊息可以看出 Option<T> 與 T 的確被視為不同的型別,想要相加兩者的數值,處理 Option 的部分不可避,透過這種方式強迫開發者處理。

fn main() {
    let optional_number = Some(10);
    let result = 5 + optional_number;
}
error[E0277]: cannot add `Option<{integer}>` to `{integer}`
 --> src/main.rs:3:20
  |
3 |     let result = 5 + optional_number;
  |                    ^ no implementation for `{integer} + Option<{integer}>`
  |
  = help: the trait `Add<Option<{integer}>>` is not implemented for `{integer}`
  = help: the following other types implement trait `Add<Rhs>`:
            <&'a f128 as Add<f128>>
            <&'a f16 as Add<f16>>
            <&'a f32 as Add<f32>>
            <&'a f64 as Add<f64>>
            <&'a i128 as Add<i128>>
            <&'a i16 as Add<i16>>
            <&'a i32 as Add<i32>>
            <&'a i64 as Add<i64>>
          and 56 others

For more information about this error, try `rustc --explain E0277`.

接下來直接舉例比較完整處理數字相加的情況:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    fn show_result(x: Option<i32>) {
        match x {
            None => println!("Result is none"),
            Some(x) if x > 0 => println!("The number: {} is > 0", x),
            Some(x) if x == 0 => println!("The number: {} is zero", x),
            Some(x) => println!("The number: {} < 0", x)
        }
    }

    let five = Some(5);
    let six = plus_one(five);

    let absent_number: Option<i32> = None;
    let none = plus_one(absent_number);

    show_result(none);
    show_result(six);
}
       

這裡可以看到一些細節:

  1. Some賦值的變數不需要型別註記,因為編譯器可以從放進Some的值來判斷,但None``不行,所以 None`的情況必須要自己加型別註記。
  2. match裡面的分支我們可以搭配 if 實作不同分支條件,使用上會更彈性,並且要注意分支的順序性,一般來說除非特殊需求否則建議把 None 的情況放在第一個,在可讀性方面,這樣直觀地表達「先檢查是否為 None」,在效率方面,某些情況下可能略微提高執行效率。因為match是照順序匹對,所以前面有找到符合的模式就不會再往下找還有沒有匹配的模式。

常見列舉:Result

另外一個常見的列舉是Result,用來表達一個操作可能是成功(包含一個值),也可能是失敗(包含一個錯誤),我們在 Echo Function 的章節 看過它的其中一個變體Ok

Result 列舉的定義有兩個變體 OkErr

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

T 代表我們在成功時會在 Ok 變體回傳的型別,而 E 則代表失敗時在 Err 變體會回傳的錯誤型別。

我們來看一個簡單運算的例子:

fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        Err(String::from("除數不能為零"))
    } else {
        Ok(dividend / divisor)
    }
}

fn main() {
    let result = divide(10, 5);
    match result {
        Ok(result) => println!("結果為:{}", result),
        Err(error) => println!("發生錯誤:{}", error),
    }
}

這邊限制了除數不能是 0,實際上錯誤可能有各種情況需要做不同處理,我們可以從 Err(error)再去細分,例如我們要多加一個限制除數不能是負數:

enum CustomError {
    DivisionByZero,
    NegativeResult,
}

fn divide(dividend: i32, divisor: i32) -> Result<i32, CustomError> {
    if divisor == 0 {
        return Err(CustomError::DivisionByZero);
    }

    let result = dividend / divisor;
    if result < 0 {
        return Err(CustomError::NegativeResult);
    }

    Ok(result)
}

fn main() {
    let result = divide(10, -2);

    match result {
        Ok(result) => println!("結果為:{}", result),
        Err(error) => match error {
            CustomError::DivisionByZero => println!("除數不能為零"),
            CustomError::NegativeResult => println!("除數不能為負數"),
        },
    }
}

這樣處理的時候可以根據不同的錯誤做不同處理,不過問題也出來了,就是 match的巢狀結構讓可讀性變差了。

match 以外處理列舉的方式

match 不是唯一能處理 OptionResult等列舉的方式。

unwrap 與 expect

如果比較簡單的情境可以用unwrapexpect,提供了快速提取值的方式,但同時也引入了潛在的程式執行時錯誤的風險,因為兩者都會在值不存在或為 Err 時觸發 panic讓程式終止。
兩者最大的差異在於 expect可以定義錯誤發生時的訊息當參數,unwrap不能自定義只會有預設錯誤訊息,所以一般expect會是比較好的選擇,它可以在出錯的時候讓我們比較好找問題出在哪。

#[derive(Debug)]
enum CustomError {
    DivisionByZero,
    NegativeResult,
}

// ... 省略中間重複函數

fn main() {
    // let result = divide(10, -2).unwrap();
    let result = divide(10, -2).expect("invalid divisor");

    println!("結果為:{}", result);
}

使用時機:

  1. 確定值一定存在:這種情況可以用來簡化match的結構,因為match最少也要寫一種處理異常的分支。
  2. 可以允許程式崩潰:例如原形開發或該異常完全不能接受程式繼續執行的情況,相反,需要細緻錯誤處理或是要求程式穩定的情況就不適合用。

? 運算子

除了unwrapexpect,更推薦使用的是?運算子,它會把自動將錯誤向上傳遞,直到被最外層的 matchif let捕獲,所以不像unwrapexpect會直接觸發panic,相對來說比較安全穩定。
實際上它是一個 Rust 的語法糖,編譯器會幫我們轉換成下面的程式碼(以Result舉例):

match expr {
    Ok(val) => val,
    Err(err) => return Err(err),
}

它的用法就是在會回傳ResultOption的函數後面直接加上?

fn main() {
    let result = divide(10, -2)?;
    println!("結果為:{}", result);
}

可惜這樣改還不夠,會有以下錯誤:

error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
  --> src/main.rs:21:32
   |
20 | fn main() {
   | --------- this function should return `Result` or `Option` to accept `?`
21 |     let result = divide(10, -2)?;
   |                                ^ cannot use the `?` operator in a function that returns `()`
   |
   = help: the trait `FromResidual<Result<Infallible, CustomError>>` is not implemented for `()`

原因是因為前面提到的,編譯器轉換出來的Err(err) => return Err(err),這段,return的關係把回傳的型別定下來了,那用?的函數的回傳型別也要跟著配合才能避免型別錯誤,所以要改成這樣:

fn main() -> Result<(), CustomError> {
    let result = divide(10, -2)?;
    println!("結果為:{}", result);
    Ok(())
}

我們把main增加回傳型別定義以及最後要回傳Ok變體。這也解釋了Echo Function 的章節部分的程式碼。

結語

這篇我們介紹了常見的列舉OptionResult以及其使用方式。
尤其Option是 Rust 借鑒其他程式語言處理空值的方式,它與所有權、生命週期等機制共同構成了 Rust 保證所有引用有效性的拼圖。我越來越深刻體會到,這個保證有多沈重😂
另外一起介紹除了match以外取得這兩種列舉結果的簡短寫法:unwrapexpect以及?,它們可以用來簡化程式碼並避免了大量的match表達式,使得程式碼更易讀。


上一篇
Day18 - 列舉與 match
下一篇
Day20 - 泛型
系列文
螃蟹幼幼班:Rust 入門指南25
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言