透過重構來改善模組性與錯誤處理

為了改善我們的程式,我們需要修正四個問題,這與程式架構與如何處理潛在錯誤有關。首先,我們的 main 函式會處理兩件任務:它得解析引數並讀取檔案。隨著我們的程式增長,main 函式中要處理的任務就會增加。要是一個函式有這麼多責任,它就會越來越難理解、越難測試並且難在不破壞其他部分的情況下做改變。我們最好能將不同功能拆開,讓每個函式只負責一項任務。

而這也和第二個問題有關:雖然 queryfile_path 是我們程式的設置變數,而變數 contents 則用於程式邏輯。隨著 main 增長,我們會需要引入越多變數至作用域中。而作用域中有越多變數,我們就越難追蹤每個變數的用途。我們最好是將設置變數集結成一個結構體,讓它們的用途清楚明白。

第三個問題是當讀取檔案失敗時,我們使用 expect 來印出錯誤訊息,但是錯誤訊息只印出 應該要能夠讀取檔案。讀取檔案可以有好幾種失敗的方式:舉例來說,檔案可能不存在,或是我們可能沒有權限能開啟它。目前不管原因為何,我們都只印出相同的錯誤訊息,這並沒有給使用者足夠的資訊!

第四,我們重複使用 expect 來處理不同錯誤,而如果有使用者沒有指定足夠的引數來執行程式的話,他們會從 Rust 獲得 index out of bounds 的錯誤,這並沒有清楚解釋問題。最好是所有的錯誤處理程式碼都可以位於同個地方,讓未來的維護者只需要在此處來修改錯誤處理的程式碼。將所有錯誤處理的程式碼置於同處也能確保我們能提供對終端使用者有意義的訊息。

讓我們來重構專案以解決這四個問題吧。

分開執行檔專案的任務

main 函式負責多數任務的組織分配問題在許多執行檔專案中都很常見。所以 Rust 社群開發出了一種流程,這在當 main 開始變大時,能作為分開執行檔程式中任務的指導原則。此流程有以下步驟:

  • 將你的程式分成 main.rslib.rs 並將程式邏輯放到 lib.rs
  • 只要你的命令列解析邏輯很小,它可以留在 main.rs
  • 當命令行解析邏輯變得複雜時,就將其從 main.rs 移至 lib.rs

在此流程之後的 main 函式應該要只負責以下任務:

  • 透過引數數值呼叫命令列解析邏輯
  • 設置任何其他的配置
  • 呼叫 lib.rs 中的 run 函式
  • 如果 run 回傳錯誤的話,處理該錯誤

此模式用於分開不同任務:main.rs 處理程式的執行,然後 lib.rs 處理眼前的所有任務邏輯。因為你無法直接測試 main,此架構讓你能測試所有移至 lib.rs 的程式函式邏輯。留在 main.rs 的程式碼會非常小,所以容易直接用閱讀來驗證。讓我們用此流程來重構程式吧。

提取引數解析器

我們會提取解析引數的功能到一個 main 會呼叫的函式中,以將命令列解析邏輯妥善地移至 src/lib.rs。範例 12-5 展示新的 main 會呼叫新的函式 parse_config,而此函式我們先暫時留在 src/main.rs

檔案名稱:src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --省略--

    println!("搜尋 {}", query);
    println!("目標檔案為 {}", file_path);

    let contents = fs::read_to_string(file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

範例 12-5:從 main 提取 parse_config 函式

我們仍然收集命令列引數至向量中,但不同於在 main 函式中將索引 1 的引數數值賦值給變數 query 且將索引 2 的引數數值賦值給變數 file_path,我們將整個向量傳至 parse_config 函式。parse_config 函式會擁有決定哪些引數要賦值給哪些變數的邏輯,並將數值回傳給 main。我們仍然在 main 中建立變數 query and file_path,但 main 不再負責決定命令列引數與變數之間的關係。

此重構可能對我們的小程式來說有點像是殺雞焉用牛刀,但是我們正一小步一小步地累積重構。做了這項改變後,請再次執行程式來驗證引數解析有沒有正常運作。經常檢查你的進展是很好的,這能幫助你找出問題發生的原因。

集結配置數值

我們可以再進一步改善 parse_config 函式。目前我們回傳的是元組,但是我們馬上又將元組拆成獨立部分。這是個我們還沒有建立正確抽象的信號。

另外一個告訴我們還有改善空間的地方是 parse_config 名稱中的 config,這指示我們回傳的兩個數值是相關的,且都是配置數值的一部分。我們現在沒有確實表達出這樣的資料結構,而只有將兩個數值組合成一個元組而已,我們可以將這兩個數值存入一個結構體,並對每個結構體欄位給予有意義的名稱。這樣做能讓未來的維護者可以清楚知道這些數值的不同與關聯,以及它們的用途。

範例 12-6 改善了 parse_config 函式。

檔案名稱:src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    // --省略--

    println!("文字內容:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

範例 12-6:重構 parse_config 來回傳 Config 結構體實例

我們定義了一個結構體 Config 其欄位有 queryfile_pathparse_config 的簽名現在指明它會回傳一個 Config 數值。在 parse_config 的本體中,我們原先回傳 argsString 數值參考的字串切片,現在我們定義 Config 來包含具所有權的 String 數值。main 中的 args 變數是引數數值的擁有者,而且只是借用它們給 parse_config 函式,這意味著如果 Config 嘗試取得 args 中數值的所有權的話,我們會違反 Rust 的借用規則。

我們可以用許多不同的方式來管理 String 的資料,最簡單(卻較無效率)的方式是對數值呼叫 clone 方法。這會複製整個資料讓 Config 能夠擁有,這會比參考字串資料還要花時間與記憶體。然而克隆資料讓我們的程式碼比較直白,因為在此情況下我們就不需要管理參考的生命週期,犧牲一點效能以換取簡潔性是值得的。

使用 clone 的權衡取捨

由於 clone 會有運行時消耗,所以許多 Rustaceans 傾向於避免使用它來修正所有權問題。在第十三章中,你會學到如何更有效率的處理這種情況。但現在我們可以先複製字串來繼續進行下去,因為你只複製了一次,而且檔案路徑與搜尋字串都算很小。先寫出較沒有效率但可執行的程式會比第一次就要過分優化還來的好。隨著你對 Rust 越熟練,你的確就可以從有效率的解決方案開始,但現在呼叫 clone 是完全可以接受的。

我們更新 main 來將 parse_config 回傳的 Config 實例儲存至 config 變數中,並更新之前分別使用變數 queryfile_path 的程式碼段落來改使用 Config 結構體中的欄位。

現在我們的程式碼更能表達出 queryfile_path 是相關的,而且它們的目的是配置程式的行為。任何使用這些數值的程式碼都會從 config 實例中的欄位名稱知道它們的用途。

建立 Config 的建構子

目前我們將負責解析命令列引數的邏輯從 main 移至 parse_config 函式。這樣做能幫助我們理解 queryfile_path 數值是相關的,且此關係應該要能在我們的程式碼中表達出來。然後我們增加了結構體 Config 來描述 queryfile_path 的相關性,並在 parse_config 函式中將數值名稱作為結構體欄位名稱來回傳。

所以現在 parse_config 函式的目的是要建立 Config 實例,我們可以將 parse_config 從普通的函式變成與 Config 結構體相關連的 new 函式。這樣做能讓程式碼更符合慣例。我們可以對像是 String 等標準函式庫中的型別呼叫 String::new 來建立實例。同樣地,透過將 parse_config 改為 Config 的關聯函式 new,我們可以透過呼叫 Config::new 來建立 Config 的實例。範例 12-7 正是我們要作出的改變。

檔案名稱:src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");

    // --省略--
}

// --省略--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

範例 12-7:變更 parse_configConfig::new

我們更新了 main 原先呼叫 parse_config 的地方來改呼叫 Config::new。我們變更了 parse_config 的名稱成 new 並移入 impl 區塊中,讓 new 成為 Config 的關聯函式。請嘗試再次編譯此程式碼來確保它能執行。

修正錯誤處理

現在我們要來修正錯誤處理。回想一下要是 args向量中的項目太少的話,嘗試取得向量中索引 1 或索引 2 的數值的話可能就會導致程式恐慌。試著不用任何引數執行程式的話,它會產生以下結果:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

index out of bounds: the len is 1 but the index is 1 這行是給程式設計師看的錯誤訊息。這無法協助我們的終端使用者理解他們該怎麼處理。讓我們來修正吧。

改善錯誤訊息

在範例 12-8 中,我們在 new 函式加上了一項檢查來驗證 slice 是否夠長,接著才會取得索引 1 和 2。如果 slice 不夠長的話,程式就會恐慌並顯示更清楚的錯誤訊息。

檔案名稱:src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --省略--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("引數不足");
        }
        // --省略--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

範例 12-8:新增對引數數量的檢查

此程式碼類似於我們在範例 9-13 寫的 Guess::new 函式,在那裡當 value 超出有效數值的範圍時,我們就呼叫 panic!。然而在此我們不是檢查數值的範圍,而是檢查 args 的長度是否至少為 3,然後函式剩餘的段落都能在假設此條件成立情況下正常執行。如果 args 的項目數量少於三的話,此條件會為真,然後我們就會立即呼叫 panic! 巨集來結束程式。

new 多了這些額外的程式碼之後,讓我們不用任何引數再次執行程式,來看看錯誤訊息為何:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at '引數不足', src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

這樣的輸出就好多了,我們現在有個合理的錯誤訊息。然而我們還是顯示了一些額外資訊給使用者。也許在此使用範例 9-13 的技巧並不是最好的選擇,如同第九章所提及的panic! 的呼叫比較屬於程式設計問題,而不是使用問題。我們可以改使用第九章的其他技巧,像是回傳 Result來表達是成功還是失敗。

回傳 Result 而非呼叫 panic!

我們可以回傳 Result 數值,在成功時包含 Config 的實例並在錯誤時描述問題原因。我們也將函式名稱從 new 改為 build,因為許多開發者通常預期 new 函式不會失敗。當 Config::buildmain 溝通時,我們可以使用 Result 型別來表達這裡有問題發生。然後我們改變 main 來將 Err 變體轉換成適當的錯誤訊息給使用者,而不是像呼叫 panic! 時出現圍繞著 thread 'main'RUST_BACKTRACE 的文字。

範例 12-9 顯示我們得改變 Config::build 的回傳值並讓函式本體回傳 Result。注意到這還不能編譯,直到我們也更新 main 為止,這會在下個範例解釋。

檔案名稱:src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

範例 12-9:從 Config::build 回傳 Result

我們的 build 函式現在會回傳 Result,在成功時會有 Config 實例,而在錯誤時會有個 &'static str。我們的錯誤值永遠會是有 'static 生命週期的字串字面值。

我們在 build 函式本體作出了兩項改變:不同於呼叫 panic!,當使用者沒有傳遞足夠引數時,我們現在會回傳 Err 數值。此外我們也將 Config 封裝進 Ok 作為回傳值。這些改變讓函式能符合其新的型別簽名。

Config::build 回傳 Err 數值讓 main 函式能處理 build 函式回傳的 Result 數值,並明確地在錯誤情況下離開程序。

呼叫 Config::build 並處理錯誤

為了能處理錯誤情形並印出對使用者友善的訊息,我們需要更新 main 來處理 Config::build 回傳的 Result,如範例 12-10 所示。我們還要負責用一個非零的錯誤碼來離開命令列工具,這原先是 panic! 會處理的,現在我們得自己實作。非零退出狀態是個常見信號,用來告訴呼叫程式的程序,該程式離開時有個錯誤狀態。

檔案名稱:src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{err}");
        process::exit(1);
    });

    // --省略--

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

範例 12-10:如果建立新的 Config 失敗時會用錯誤碼離開

在此範例中,我們使用一個還沒詳細介紹的方法 unwrap_or_else,這定義在標準函式庫的 Result<T, E> 中。使用 unwrap_or_else 讓我們能定義一些自訂的非 panic! 錯誤處理。如果 Result 數值為 Ok,此方法行為就類似於 unwrap,它會回傳Ok 所封裝的內部數值。然而,如果數值為 Err 的話,此方法會呼叫閉包(closure)內的程式碼,這會是由我們所定義的匿名函式並作為引數傳給 unwrap_or_else。我們會在第十三章詳細介紹閉包。現在你只需要知道 unwrap_or_else 會傳遞 Err 的內部數值,在此例中就是我們在範例 12-9 新增的靜態字串「引數不足」,將此數值傳遞給閉包中兩條直線之間的 err 引數。閉包內的程式碼就可以在執行時使用 err 數值。

我們新增了一行 use 來將標準函式庫中的 process 引入作用域。在錯誤情形下要執行的閉包程式碼只有兩行:我們印出 err 數值並呼叫 process::exitprocess::exit 函式會立即停止程式並回傳給予的數字來作為退出狀態碼。這與範例 12-8 我們使用 panic! 來處理的方式類似,但我們不再顯示多餘的輸出結果。讓我們試試看:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
解析引數時出現問題:引數不足

很好!這樣的輸出結果對使用者友善多了。

main 提取邏輯

現在我們完成配置解析的重構了,接下來輪到程式邏輯了。如同我們在「分開執行檔專案的任務」中所提及的,我們會提取一個函式叫做 run,這會存有目前 main 函式中除了設置配置或處理錯誤以外的所有邏輯。當我們完成後,main 會變得非常簡潔,且能輕鬆用肉眼來驗證,然後我就能對所有其他邏輯進行測試了。

範例 12-11 提取了 run 函式。目前我們在提取函式時,會逐步作出小小的改善。我們仍然在 src/main.rs 底下定義函式。

檔案名稱:src/main.rs

use std::env;
use std::fs;
use std::process;

fn main() {
    // --省略--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{err}");
        process::exit(1);
    });

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("應該要能夠讀取檔案");

    println!("文字內容:\n{contents}");
}

// --省略--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

範例 12-11:提取 run 函式來包含剩餘的程式邏輯

run 現在會包含 main 中從讀取文件開始的所有剩餘邏輯。run 函式會接收 Config 實例來作為引數。

run 函式回傳錯誤

隨著剩餘程式邏輯都移至 run 函式,我們可以像範例 12-9 的 Config::build 一樣來改善錯誤處理。不同於讓程式呼叫 expect 來恐慌,當有問題發生時,run 函式會回傳 Result<T, E>。這能讓我們進一步穩固 main 中對使用者友善的處理錯誤邏輯。範例 12-12 展示我們對 run 的簽名與本體所需要做的改變。

檔案名稱:src/main.rs

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --省略--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{err}");
        process::exit(1);
    });

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("文字內容:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

範例 12-12:變更 run 函式來回傳 Result

我們在此做了三項明顯的修改。首先,我們改變了 run 函式的回傳型別為 Result<(), Box<dyn Error>>,此函式之前回傳的是單元型別 (),而它現在仍作為 Ok 條件內的數值。

對於錯誤型別,我們使用特徵物件(trait object) Box<dyn Error>(然後我們在最上方透過 use 陳述式來將 std::error::Error 引入作用域)。我們會在第十七章討論特徵物件。現在你只需要知道 Box<dyn Error> 代表函式會回傳有實作 Error 特徵的型別,但我們不必指定回傳值的明確型別。這增加了回傳錯誤數值的彈性,其在不同錯誤情形中可能有不同的型別。dyn 關鍵字是「動態(dynamic)」的縮寫。

再來,我們移除了 expect 的呼叫並改為第九章所介紹的 ? 運算子。所以與其對錯誤 panic!? 會回傳當前函式的錯誤數值,並交由呼叫者處理。

第三,run 函式現在成功時會回傳 Ok 數值。我們在 run 函式簽名中的成功型別為 (),這意味著我們需要將單元型別封裝進 Ok 數值。Ok(()) 這樣的語法一開始看可能會覺得有點奇怪,但這樣子使用 () 的確符合慣例,說明我們呼叫 run 只是為了它的副作用,它不會回傳我們需要的數值。

當你執行此程式時,它雖然能編譯但會顯示一個警告:

$ cargo run the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
搜尋 the
目標檔案為 poem.txt
文字內容:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust 告訴我們程式碼忽略了 Result 數值且 Result 數值可能代表會有錯誤發生。但我們沒有檢查是不是會發生錯誤,所以編譯器提醒我們可能要在此寫些錯誤處理的程式碼!我們現在就來修正此問題。

main 中處理 run 回傳的錯誤

我們會用類似範例 12-10 中處理 Config::build 的技巧來處理錯誤,不過會有一些差別:

檔案名稱:src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --省略--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{err}");
        process::exit(1);
    });

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    if let Err(e) = run(config) {
        println!("應用程式錯誤:{e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("文字內容:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

我們使用 if let 而非 unwrap_or_else 來檢查 run 是否有回傳 Err 數值,並以此呼叫 process::exit(1)run 函式沒有回傳數值,所以我們不必像處理 Config::build 得用 unwrap 取得 Config 實例。因為 run 在成功時會回傳 (),而我們只在乎偵測錯誤,所以我們不需要 unwrap_or_else 來回傳解封裝後的數值,因為它只會是 ()

if let 的本體與 unwrap_or_else 函式則都做一樣的事情:印出錯誤並離開。

將程式碼拆到函式庫 Crate

我們的 minigrep 專案目前看起來不錯!接下來我們要將 src/main.rs 檔案分開來,將一些程式碼放入 src/lib.rs 檔案中。這樣讓我們可以測試程式碼,並讓 src/main.rs 檔案的負擔變得少一點。

讓我們將 main 以外的所有程式碼從 src/main.rs 移到 src/lib.rs

  • run 函式定義
  • 相關的 use 陳述式
  • Config 的定義
  • Config::build 的函式定義

src/lib.rs 的內容應該要如範例 12-13 所示(為了簡潔,我們省略了函式本體)。注意到這還無法編譯,直到我們也修改 src/main.rs 成範例 12-14 為止。

檔案名稱:src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --省略--
        if args.len() < 3 {
            return Err("引數不足");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --省略--
    let contents = fs::read_to_string(config.file_path)?;

    println!("文字內容:\n{contents}");

    Ok(())
}

範例 12-13:將 Configrun 移至 src/lib.rs

我們對許多項目都使用了 pub 關鍵字,這包含 Config 與其欄位,以及其 build 方法,還有 run 函式。我們現在有個函式庫會提供公開 API 能讓我們來測試!

現在我們需要將移至 src/lib.rs 的程式碼引入執行檔 crate 的 src/main.rs 作用域中,如範例 12-14 所示。

檔案名稱:src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --省略--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{err}");
        process::exit(1);
    });

    println!("搜尋 {}", config.query);
    println!("目標檔案為 {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --省略--
        println!("應用程式錯誤:{e}");
        process::exit(1);
    }
}

範例 12-14:在 src/main.rs 使用 minigrep 函式庫 crate

我們加上 use minigrep::Config 這行來將 Config 型別從函式庫 crate 引入執行檔 crate 的作用域中,然後我們使用 run 函式的方式是在其前面再加上 crate 的名稱。現在所有的功能都應該正常並能執行了。透過 cargo run 來執行程式並確保一切正常。

哇!辛苦了,不過我們為未來的成功打下了基礎。現在處理錯誤就輕鬆多了,而且我們讓程式更模組化。現在幾乎所有的工作都會在 src/lib.rs 中進行。

讓我們利用這個新的模組化優勢來進行些原本在舊程式碼會很難處理的工作,但在新的程式碼會變得非常容易,那就是寫些測試!