Rust 的 Result 型別提供了一種強大的錯誤處理機制,強制開發者明確處理錯誤,避免錯誤被忽略。透過 ? 運算子,可以簡化錯誤傳播,使程式碼更簡潔。在 main 函式中,需要特殊處理錯誤,例如使用 if let Err(err).expect()。此外,可以定義自定義錯誤型別,並實作 Displaystd::error::Error 特性,使其更易於使用。Rust 的模組系統則提供了程式碼組織和可見性管理的機制。使用 mod 關鍵字定義模組,pub 關鍵字控制可見性。模組可以巢狀,並可以放在單獨的檔案中,使用 use 陳述式匯入需要的專案,簡化程式碼。Cargo 作為 Rust 的套件管理器,可以管理專案依賴和版本控制,並從 crates.io 下載和編譯所需的 Crate。透過 Cargo.toml 檔案可以組態編譯選項,例如在發布版本中啟用除錯符號。

Rust 中的錯誤處理:探討 Result 型別

Rust 語言透過 Result 型別提供了一種強大且靈活的錯誤處理機制。與其他語言中的例外處理不同,Rust 強制開發者在可能發生錯誤的地方做出明確的決定,並將其記錄在程式碼中。

為什麼使用 Result?

Rust 選擇 Result 而非異常的主要原因在於其設計哲學:

  • 強制開發者對錯誤處理做出明確的決定,避免因疏忽而導致錯誤處理不當。
  • 使用 ? 運算子簡化錯誤傳播,使程式碼更為簡潔。
  • 函式的傳回型別明確表明其是否可能失敗,提高程式碼的可讀性和可維護性。
  • Rust 編譯器會檢查 Result 值的使用,確保錯誤不會被默默忽略。
  • Result 是一種普通的資料型別,可以輕鬆地將成功和錯誤結果儲存在同一集合中,便於處理部分成功或失敗的情況。

自定義錯誤型別

在編寫函式庫或框架時,定義自定義錯誤型別是一種良好的實踐。例如,在編寫 JSON 解析器時,可以定義一個 JsonError 結構體來表示特定的錯誤情況:

#[derive(Debug, Clone)]
pub struct JsonError {
    pub message: String,
    pub line: usize,
    pub column: usize,
}

為了使自定義錯誤型別更易於使用,需要實作 Displaystd::error::Error 特性:

impl fmt::Display for JsonError {
    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        write!(f, "{} ({}:{})", self.message, self.line, self.column)
    }
}

impl std::error::Error for JsonError {
    fn description(&self) -> &str {
        &self.message
    }
}

內容解密:

  1. JsonError 結構體定義:此結構體用於表示 JSON 解析過程中可能發生的錯誤,包含錯誤訊息、行號和列號等資訊。
  2. Display 特性實作:透過實作 fmt 方法,定義了 JsonError 例項如何被格式化為字串。這使得錯誤訊息可以被列印預出來,並且包含了行號和列號,有助於除錯。
  3. std::error::Error 特性實作:實作 description 方法傳回錯誤訊息的字串切片,這是 std::error::Error 特性所要求的,使得 JsonError 可以作為一個標準的錯誤型別被使用。

在 main 函式中處理錯誤

在大多數情況下,錯誤會透過 ? 運算子傳播到呼叫者。然而,當錯誤到達 main 函式時,需要特殊處理,因為 main 不能直接使用 ?

一種簡單的方法是使用 .expect() 方法:

fn main() {
    calculate_tides().expect("error");
}

然而,這種方法會導致 panic,並可能列印預出令人困惑的錯誤訊息。更好的做法是手動處理錯誤並列印自定義的錯誤訊息:

fn main() {
    if let Err(err) = calculate_tides() {
        print_error(&err);
        std::process::exit(1);
    }
}

內容解密:

  1. 使用 .expect() 方法:當 calculate_tides() 傳回錯誤時,.expect() 會導致程式 panic,並列印預出指定的錯誤訊息。這種方法簡單直接,但可能導致不友好的錯誤訊息。
  2. 手動處理錯誤:透過 if let Err(err) 陳述式,可以檢查 calculate_tides() 是否傳回了錯誤。如果有錯誤,則呼叫 print_error 函式列印預出自定義的錯誤訊息,並透過 std::process::exit(1) 終止程式,傳回非零的離開碼,表示程式異常終止。

忽略錯誤

有時,我們可能希望完全忽略某些錯誤。在 Rust 中,可以使用 let _ = ... 的慣用法來忽略 Result 值:

let _ = writeln!(stderr(), "error: {}", err);

內容解密:

  1. let _ = ... 慣用法:透過將 Result 值賦給 _,可以顯式地忽略該值,避免編譯器的未使用變數警告。
  2. 忽略錯誤的場景:在某些情況下,例如在列印錯誤訊息時發生另一個錯誤,我們可能無法或不想處理這個次要的錯誤。此時,使用 let _ = ... 可以忽略這個錯誤,讓程式繼續執行。

第8章:Crates與模組

系統程式設計師也能擁有美好的事物。 — Robert O’Callahan,「Rust的隨想:Crates.io與IDE」

假設你正在撰寫一個模擬蕨類別植物生長的程式,從單一細胞到整個植物體的層次。你的程式就像蕨類別一樣,一開始非常簡單,所有的程式碼可能都在一個檔案中——只是一個想法的小小種子。隨著它成長,它將開始具有內部結構。不同的部分將具有不同的功能。它將分支成多個檔案。最終,它可能會涵蓋整個目錄樹。隨著時間的推移,它可能會成為整個軟體生態系統的重要組成部分。

本章將介紹Rust中幫助你保持程式組織化的功能:Crates和模組。我們還將討論隨著專案成長而自然出現的一系列主題,包括如何記錄和測試Rust程式碼、如何消除不想要的編譯器警告、如何使用Cargo管理專案依賴和版本控制、如何在crates.io上發布開源函式庫等等。

Crates

Rust程式是由Crates組成的。每個Crate都是一個Rust專案:包含單一函式庫或可執行檔的所有原始碼,以及任何相關的測試、範例、工具、組態和其他雜項。對於你的蕨類別模擬器,你可能會使用第三方函式庫進行3D圖形、生物資訊學、平行計算等。這些函式庫以Crate的形式分發(見圖8-1)。

圖8-1:Crate及其依賴關係

此圖示顯示了一個Crate及其依賴的其他Crate。

要了解Crate是什麼以及它們如何協同工作,最簡單的方法是使用cargo build命令並加上--verbose旗標來建置一個具有某些依賴關係的現有專案。我們以「平行Mandelbrot程式」為例進行了此操作。結果如下所示:

$ cd mandelbrot
$ cargo clean # 刪除先前編譯的程式碼
$ cargo build --verbose
Updating registry `https://github.com/rust-lang/crates.io-index`
Downloading image v0.6.1
Downloading crossbeam v0.2.9
Downloading gif v0.7.0
Downloading png v0.4.2
... (下載和編譯更多Crate)
Compiling png v0.4.2
Running `rustc .../png-0.4.2/src/lib.rs
--crate-name png
--crate-type lib
--extern num=.../libnum-a2e6e61627ca7fe5.rlib
--extern inflate=.../libinflate-331fc425bf167339.rlib
--extern flate2=.../libflate2-857dff75f2932d8a.rlib
...`
Compiling image v0.6.1
Running `rustc .../image-0.6.1/./src/lib.rs
--crate-name image
--crate-type lib
--extern png=.../libpng-16c24f58491a5853.rlib
...`
Compiling mandelbrot v0.1.0 (file://.../mandelbrot)
Running `rustc src/main.rs
--crate-name mandelbrot
--crate-type bin
--extern crossbeam=.../libcrossbeam-ba292320058da7df.rlib
--extern image=.../libimage-254ec48c8f0684f2.rlib
...`
$

我們重新格式化了rustc命令列以提高可讀性,並刪除了與討論無關的許多編譯器選項,用省略號(…)替換它們。

內容解密:

上述命令展示了Cargo如何下載並編譯專案的依賴Crate。首先,Cargo更新了crate登入檔,然後下載了所需的Crate及其依賴項。接著,它編譯了這些Crate,並最終編譯了我們的Mandelbrot程式。在編譯過程中,Cargo使用了--crate-type選項來指定是編譯函式庫還是可執行檔,並使用--extern選項來指定外部Crate的位置。

你可能記得,到我們完成時,Mandelbrot程式的main.rs包含了三個extern crate宣告:

extern crate num;
extern crate image;
extern crate crossbeam;

這些宣告告訴Rust,numimagecrossbeam是外部函式庫,而不是Mandelbrot程式本身的一部分。

我們還在Cargo.toml檔案中指定了我們想要的每個Crate的版本:

[dependencies]
num = "0.1.27"
image = "0.6.1"
crossbeam = "0.2.8"

這裡的「依賴關係」指的是這個專案使用的其他Crate:我們所依賴的程式碼。我們在crates.io(Rust社群的開源Crate網站)上找到了這些Crate。例如,我們透過存取crates.io並搜尋影像函式庫來瞭解了image函式庫。每個Crate在crates.io上的頁面都提供了檔案和原始碼的連結,以及一行組態,如image = "0.6.1",你可以複製並新增到你的Cargo.toml中。這裡顯示的版本號只是我們撰寫程式時這三個套件的最新版本。

Cargo的輸出記錄了這些資訊如何被使用。當我們執行cargo build時,Cargo首先從crates.io下載指定版本的這些Crate的原始碼。然後,它讀取這些Crate的Cargo.toml檔案,下載它們的依賴項,依此類別推。例如,版本0.6.1的imageCrate的原始碼包含一個Cargo.toml檔案,其中包括以下內容:

[dependencies]
byteorder = "0.4.0"
num = "0.1.27"
enum_primitive = "0.1.0"
glob = "0.2.10"

看到這一點,Cargo知道在它可以使用image之前,必須取得這些Crate。一旦取得了所有原始碼,Cargo就會編譯所有Crate。它為專案依賴圖中的每個Crate執行一次rustc(Rust編譯器)。在編譯函式庫時,Cargo使用--crate-type lib選項。這告訴rustc不要尋找main()函式,而是產生一個.rlib檔案,其中包含以後續rustc命令可以使用的形式編譯的程式碼。在編譯程式時,Cargo使用--crate-type bin,結果是目標平台的二進位制可執行檔:例如,在Windows上的mandelbrot.exe

內容解密:

Cargo使用不同的組態設定來控制編譯行為。在開發過程中,我們通常使用cargo build來快速編譯和測試程式。然而,當我們準備發布程式時,我們會使用cargo build --release來產生最佳化過的可執行檔。這兩種模式下,Cargo會根據我們的設定調整編譯選項。

建置組態

在你的 Cargo.toml 檔案中有幾個組態設定會影響到 cargo 產生的 rustc 命令列。

命令列使用的 Cargo.toml 區段
cargo build[profile.debug]
cargo build --release[profile.release]
cargo test[profile.test]

預設值通常就足夠了,但我們發現的一個例外是當你想使用效能分析工具(用於測量你的程式在哪些地方花費了CPU時間)。要從效能分析工具中獲得最佳資料,你需要同時啟用最佳化和除錯符號(通常只在發布版本中啟用最佳化,而只在除錯版本中啟用除錯符號)。要同時啟用兩者,請將以下內容新增到你的 Cargo.toml:

[profile.release]
debug = true # 在發布版本中啟用除錯符號

內容解密:

這段組態允許你在發布版本中同時擁有最佳化和除錯符號,從而能夠更有效地進行效能分析。預設情況下,發布版本只包含最佳化的二進位制檔案,而不包含除錯資訊。透過新增 debug = true,你可以在保持最佳化的同時,也保留除錯符號,這對於理解程式執行時的行為至關重要。

Rust 中的模組系統

Rust 的模組系統是用於組織程式碼和管理可見性的重要工具。模組可以包含函式、型別、常數等,並控制它們的可見性。

模組的基本結構

一個基本的模組定義如下:

mod spores {
    use cells::Cell;

    /// 一個由成年蕨類別植物產生的細胞,作為蕨類別生命週期的一部分在風中傳播。
    pub struct Spore {
        // ...
    }

    /// 模擬透過減數分裂產生孢子的過程。
    pub fn produce_spore(factory: &mut Sporangium) -> Spore {
        // ...
    }

    /// 混合基因以準備減數分裂(間期的部分)。
    fn recombine(parent: &mut Cell) {
        // ...
    }
    // ...
}

在這個例子中,spores 模組包含了一個公開的 Spore 結構體和兩個函式:produce_sporerecombine。其中,produce_spore 是公開的,而 recombine 是私有的。

模組的可見性

Rust 中的模組具有嚴格的可見性控制。使用 pub 關鍵字可以將專案標記為公開,使其可以從模組外部存取。未標記為 pub 的專案則是私有的,只能在模組內部存取。

let s = spores::produce_spore(&mut factory); // 可以存取公開的 produce_spore 函式
spores::recombine(&mut cell); // 錯誤:recombine 是私有的

模組的巢狀結構

模組可以巢狀,建立一個層次結構。這使得程式碼的組織更加清晰。

mod plant_structures {
    pub mod roots {
        // ...
    }
    pub mod stems {
        // ...
    }
    pub mod leaves {
        // ...
    }
}

將模組放在單獨的檔案中

Rust 允許將模組放在單獨的檔案中。例如,可以將 spores 模組放在 spores.rs 檔案中:

// spores.rs
/// 一個由成年蕨類別植物產生的細胞,作為蕨類別生命週期的一部分在風中傳播。
pub struct Spore {
    // ...
}

/// 模擬透過減數分裂產生孢子的過程。
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
    // ...
}

/// 混合基因以準備減數分裂(間期的部分)。
fn recombine(parent: &mut Cell) {
    // ...
}

然後在主檔案中宣告該模組:

mod spores;

路徑和匯入

Rust 使用 :: 運算子來存取模組中的專案。可以使用絕對路徑來參照標準函式庫或其他模組中的專案。

if s1 > s2 {
    ::std::mem::swap(&mut s1, &mut s2);
}

為了避免重複輸入長路徑,可以使用 use 陳述式匯入需要的專案:

use std::mem;

if s1 > s2 {
    mem::swap(&mut s1, &mut s2);
}

多重匯入

可以一次性匯入多個專案:

use std::collections::{HashMap, HashSet}; // 匯入兩個專案
use std::io::prelude::*; // 匯入所有公開專案

這等同於寫出多個單獨的 use 陳述式:

use std::collections::HashMap;
use std::collections::HashSet;
// 標準函式庫中 std::io::prelude 模組的所有公開專案
use std::io::prelude::Read;
use std::io::prelude::Write;
use std::io::prelude::BufRead;
use std::io::prelude::Seek;

從父模組匯入

子模組不會自動繼承父模組中的名稱,需要顯式匯入:

// proteins/synthesis.rs
use super::AminoAcid; // 從父模組匯入 AminoAcid
pub fn synthesize(seq: &[AminoAcid]) // 現在可以正確使用 AminoAcid
    // ...
}

使用Plantuml圖表說明模組關係

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Rust 錯誤處理與模組系統解析

package "Rust 記憶體管理" {
    package "所有權系統" {
        component [Owner] as owner
        component [Borrower &T] as borrow
        component [Mutable &mut T] as mutborrow
    }

    package "生命週期" {
        component [Lifetime 'a] as lifetime
        component [Static 'static] as static_lt
    }

    package "智慧指標" {
        component [Box<T>] as box
        component [Rc<T>] as rc
        component [Arc<T>] as arc
        component [RefCell<T>] as refcell
    }
}

package "記憶體區域" {
    component [Stack] as stack
    component [Heap] as heap
}

owner --> borrow : 不可變借用
owner --> mutborrow : 可變借用
owner --> lifetime : 生命週期標註
box --> heap : 堆積分配
rc --> heap : 引用計數
arc --> heap : 原子引用計數
stack --> owner : 棧上分配

note right of owner
  每個值只有一個所有者
  所有者離開作用域時值被釋放
end note

@enduml

此圖示展示了專案中不同檔案之間的關係。main.rs 是主檔案,它參照了 spores.rsplant_structures/ 目錄下的多個檔案。

程式碼解析

以下是一個範例程式碼,展示瞭如何在 Rust 中使用模組:

// 定義一個名為 my_module 的模組
mod my_module {
    pub fn say_hello() {
        println!("Hello, world!");
    }
}

fn main() {
    // 使用 my_module 中的 say_hello 函式
    my_module::say_hello();
}

內容解密:

  1. mod my_module { ... }:定義了一個名為 my_module 的模組。
  2. pub fn say_hello() { ... }:在 my_module 中定義了一個公開的函式 say_hello
  3. my_module::say_hello();:在 main 函式中呼叫了 my_module 中的 say_hello 函式。

這個範例展示瞭如何在 Rust 中定義和使用模組,以及如何控制函式的可見性。