我們要運用目前為止學到的Rust程式語言知識來製作出屬於我們的「grep」(globally search a regular expression and print)程式。簡單來說,grep程式可以用來尋找特定檔案中的特定字串。grep程式可以透過命令列參數來傳入檔案路徑和要尋找的字串,執行的時候就可以讀取這個檔案中所有包含要尋找的字串的該行資料,並將其印出。



就像其它的命令列程式一樣,我們要製作的grep程式也會有自己的命令列參數,並且能將錯誤訊息和我們要的檔案資料分別輸出到標準錯誤(stderr)和標準輸出(stdout)中。除了基本的程式觀念外,我們的grep程式還會用到以下我們已經學習過的Rust觀念:

1. 模組。
2. Vec和String集合。
3. 錯誤處理。
4. 特性和生命周期。
5. 測試。

首先用Cargo建立出一個名叫minigrep的應用程式專案。

讀取命令列的參數

如果要使用cargo run指令來編譯和執行專案,且順便在執行專案主程式的時候順便從命令列代入參數,可以直接將參數接在cargo run指令之後。舉例來說,假設我們的grep程式第一個參數是要搜尋的字串searchstring,第二個的參數是要被搜尋指定字串的檔案路徑example-filename.txt。指令如下:

cargo run searchstring example-filename.txt

如果要在Rust程式中讀到透過命令列傳進來的參數,可以使用標準函式庫std::env模組提供的args函數。這個args函數會回傳一個Args結構體,有實作一些迭代器的相關特性,因此可以使用collect方法來產生Vec結構體。程式如下:

use std::env;

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

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

執行cargo run searchstring example-filename.txt指令看看,結果如下:

rust-grep

args函數所回傳的Vec結構體,索引0的位置會是呼叫這個程式所使用的字串,之後的元素才是額外傳入的參數。我們要做的grep程式,會使用到索引1和索引2的元素,可以另外使用其它變數來借用它們。程式如下:

use std::env;

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

    let query = &args[1];
    let filepath = &args[2];

    println!("Searching for {query}");
    println!("In file {filepath}");
}

執行cargo run searchstring example-filename.txt指令看看,結果如下:

rust-grep

讀取檔案

Rust程式語言的標準函式庫中的std::fs模組,可以用來處理檔案相關的操作。std::fs模組的File結構體提供了open函數,能夠傳入檔案路徑來開啟指定的檔案,其結構實體實作了std::io::prelude模組提供的Read特性,可以使用read_to_string方法,來讀取整個檔案檔內容並直接存到String結構實體中。

程式如下:

use std::env;
use std::fs::File;
use std::io::prelude::*;

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

    let query = &args[1];
    let filepath = &args[2];

    println!("Searching for {query}");
    println!("In file {filepath}");

    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{contents}");
}

我們直接在main函數來處理命令列傳進來的參數,以及檔案的開啟與讀取,這樣的作法目前並沒有什麼太大的問題,但是一旦程式功能變多,如果還是通通都在main函數中處理的話,會變得很難閱讀、測試和維護。並且我們在使用open函數開啟檔案的時候,是直接用Result列舉實體所提供的expect方法來處理當實體為Err變體時的情形,然而,open函數並不會只有檔案不存在的情況才會回傳Err變體,我們必須要提供使用者更詳細的錯誤資訊!再來,如果使用者在使用命令列執行我們的程式時,並沒有額外再多傳入二個參數,來指定要搜尋的字串和要被搜尋的檔案,程式第8行和第9行就會有超出陣列索引範圍的問題。

在設計命令列程式時,可以參考以下準則:

1. 將程式邏輯寫在lib.rs檔案,在main.rs檔案中應用。
2. 如果命令列參數的邏輯很簡單,可以直接寫在main.rs檔案中。
3. 當命令列參數的邏輯變得更加複雜時,要將其移動到lib.rs檔案。
4. main函數的責任為:呼叫處理命令列參數邏輯的函數或方法、建構組態設定、以建構出來的組態設定來呼叫lib.rs檔案的run函數、處理函數或方法回傳的錯誤。

main.rs檔案用來處理進入程式和離開程式的流程,lib.rs檔案用來處理程式的主要邏輯。如此一來,main.rs檔案中的程式就會足夠精簡,讓我們可以快速地就由閱讀程式碼來判斷main函數的正確性,因為我們很難撰寫一個測試來去做main函數的功能驗證。

修改一下目前minigrep專案的程式,在src目錄下建立出lib.rs檔案,並將處理命令列參數的邏輯部份在lib.rs檔案中另外實作出Config結構體來處理。如下:

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

我們新增了Config結構體來儲存queryfilepath的資訊,並替它實作出一個new函數,可以傳入命令列參數來檢查參數數量,並建立出Config結構實體或是錯誤訊息來經由Result列舉包裹回傳。

main.rs檔案也修改如下:

use std::env;
use std::fs::File;
use std::io::prelude::*;

use minigrep::*;

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

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

    println!("Searching for {}", config.query);
    println!("In file {}", config.filepath);

    let mut f = File::open(config.filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{contents}");
}

以上程式,第10行我們直接使用unwrap方法來處理new函數所回傳的Result列舉實體,遇到命令列參數不足的情形,程式就會發生panic。

執行cargo run指令看看,結果如下:

rust-grep

然而,這樣的錯誤訊息對於一般不懂開發程式的使用者來說是很不友善的,我們需要使用其它的方式來處理new函數回傳的錯誤。Result列舉的實體除了有提供unwrapexpect方法外,還有一個常用的unwrap_or_else方法。unwrap_or_else方法可以直接從參數傳入一個「閉包」(closure),「閉包」類似沒有名稱的函數,可以透過變數、參數來進行儲存與傳遞。呼叫unwrap_or_else方法的實體如果是Ok變體的話,行為和unwrap方法一樣;但如果是Err變體的話,則會去執行傳入的閉包。閉包的實作方式跟函數很像,差別只在於閉包的參數是使用|語法來定義,而不是函數所用的小括號(),且閉包的參數會被自動進行型別推論,不需要特別去明確指定型別。

使用unwrap_or_else方法將程式修改成如下:

use std::env;
use std::fs::File;
use std::io::prelude::*;
use std::process;

use minigrep::*;

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filepath);

    let mut f = File::open(config.filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{contents}");
}

我們在閉包中,將Err實體直接印出到標準錯誤,並且使用Rust程式語言的標準函式庫中的std::process模組所提供的exit方法,讓程式回傳1。執行程式時,作業系統會開啟新的行程(process)來處理,這個行程在結束的時候也會有個整數回傳值,稱作退出狀態碼(Exit Code)。一般程式在完全正常的運作下結束,執行該程式的行程會回傳0,否則就會是其它非0的值。在Linux作業系統下,shell中前一個行程的退出狀態碼,會儲存到$?環境變數中,因此可以使用echo $?來查看退出狀態碼。

執行cargo run指令和echo $?指令看看,結果如下:

rust-grep

再來將目前main函數中開啟與讀取檔案的程式也改寫至lib.rs檔案中,並實作出run函數,如下:

use std::fs::File;
use std::io::prelude::*;

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

pub fn run(config: Config) {
    let contents = open_read_file(&config.filepath);

    println!("With text:\n{contents}");
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}
use std::env;
use std::process;

use minigrep::*;

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filepath);

    run(config);
}

接著我們要來實作搜尋字串的程式了,由於這個是比較複雜的邏輯,我們先從定義函數的簽名和回傳執型別開始,接著撰寫測試,最後才是實作搜尋字串的邏輯部份。

我們先在lib.rs檔案中實作出一個最簡單的search函數,這個函數需要接受run函數先前於Config結構實體的query欄位和contents變數所儲存的字串作為參數,然後回傳所有尋找到子字串的該行的字串切片。由於我們不知道一個檔案內究竟會有多少行被搜尋到,因此search函數回傳的字串切片應該要使用Vec結構實體來儲存。

程式如下:

use std::fs::File;
use std::io::prelude::*;

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

pub fn run(config: Config) {
    let contents = open_read_file(&config.filepath);

    println!("With text:\n{contents}");
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}

這邊在定義search函數的時候使用了泛型來明確定義contents參數和回傳的Vec結構實體所儲存的元素的生命周期必須要是一樣的,因為Vec結構實體的元素就是從contents參數來的,它們的生命周期必須要一樣。在search函數主體,我們只先回傳一個空的Vec結構實體,確保目前的程式可以通過編譯。

接下來要撰寫測試,我們需要先想好一個或是數個使用search函數的不同案例。舉例來說,假設輸入的檔案內容是以下這段文字:

Rust:
safe, fast, productive.
Pick three.

則當我們搜尋duct字串的時候,search函數應該要回傳只存著一個元素safe, fast, productive.Vec結構實體。程式如下:

use std::fs::File;
use std::io::prelude::*;

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

pub fn run(config: Config) {
    let contents = open_read_file(&config.filepath);

    println!("With text:\n{contents}");
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "Rust:\nsafe, fast, productive.\nPick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

此時使用cargo test指令來測試專案,結果如下:

rust-grep

one_result測試函數測試失敗是很正常的,因為我們還沒有真的把search函數實作出來嘛!

search函數的主體修改如下:

use std::fs::File;
use std::io::prelude::*;

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn run(config: Config) {
    let contents = open_read_file(&config.filepath);

    println!("With text:\n{contents}");
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "Rust:\nsafe, fast, productive.\nPick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

程式第23行,使用Vec結構體的new方法建立出一個Vec結構實體,並且指派給results變數儲存。程式第25行,使用for迴圈搭配字串的lines方法來走訪字串的各行,line變數的型別為字串切片。程式第26行,使用字串提供的contains方法來尋找其是否含有指定的子字串。程式第27行,如果目前走訪的該行含有我們要找的子字串,就將該行儲存到Vec結構實體中。程式第31行,回傳Vec結構實體。

此時使用cargo test指令來測試專案,結果如下:

rust-grep

one_result測試函數成功通過測試了!

接下來我們要把search函數應用在run函數內,將程式修改如下:

use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

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

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        Ok(Config { query, filepath })
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = open_read_file(&config.filepath);

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "Rust:\nsafe, fast, productive.\nPick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

程式第1行,將Rust程式語言標準函式庫中std::error模組的Error特性給引入到目前程式的scope。程式第35行,讓run函數可以回傳一個Result列舉的實體,泛型的部份第一個參數使用空元組型別,也就是說Ok變體會包裹著空元組;第二個參數使用Box結構體,其泛型的第一個參數為Error特性,也就是說Err變體會包裹著Box<dyn Error>的實體,有關於Box結構體和dyn關鍵字的用法之後的章節才會介紹。程式第38行,我們直接使用for迴圈去走訪search函數回傳的Vec結構實體,而不是使用其提供的iter方法來建立迭代器再進行走訪,這是因為Vec結構體本身有實作IntoIterator特性,使用在for迴圈時會自動進入其所實作好要被使用的迭代器。有關於迭代器的概念會在之後的章節介紹。

接著將main函數修改如下:

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

use minigrep::*;

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");

        process::exit(1);
    }
}

如此一來我們初步的grep程式就算是完成啦!

在Linux作業系統環境下,執行cargo run bash /etc/passwd指令,就可以查看哪些Linux系統的使用者使用bash shell。如下圖:

rust-grep

再來我們要強化這個grep程式,替它加上忽略大小寫的搜尋功能,可以藉由環境變數CASE_INSENSITIVE來設定搜尋時要不要忽略英文字母大小寫。如下:

use std::env;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

pub struct Config {
    pub query: String,
    pub filepath: String,
    pub case_sensitive: bool,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config { query, filepath, case_sensitive })
    }
}

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = open_read_file(&config.filepath);

    for line in if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    } {
        println!("{line}");
    }

    Ok(())
}

fn open_read_file(filepath: &str) -> String {
    let mut f = File::open(filepath).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    contents
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "Rust:\nsafe, fast, productive.\nPick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "Rust:\nsafe, fast, productive.\nPick three.\nTrust me.";

        assert_eq!(vec!["Rust:", "Trust me."], search_case_insensitive(query, contents));
    }
}

為了要能夠在Rust程式中讀取CASE_INSENSITIVE環境變數,我們需要使用Rust程式語言的標準函式庫中提供的std::env模組所提供的var函數,這個函數可以傳入要取得值的變數名稱,會回傳一個Result列舉實體,其Ok變體所包裹的值為String結構實體。程式第21行,直接使用了Result列舉實體的is_err方法來判斷Result列舉實體是否為Err變體,並將這個布林值指派給宣告出來的case_sensitive變數儲存,換句話說,當CASE_INSENSITIVE環境變數有被設定,且它是正確的「Unicode」資料時,case_sensitive變數就會是false,反之就是true。程式第9行,我們替Config結構體增加了一個case_sensitive欄位,用來儲存程式第21行case_sensitive變數的值。

我們新增了一個新的測試函數case_insensitive,用來測試新實作出來的search_case_insensitive函數的功能是否正確。search_case_insensitive函數和search函數的實作邏輯差異不大,只差在search_case_insensitive函數會在搜尋字串前,將字串都使用to_lowercase方法轉成小寫。

run函數中,我們使用if關鍵字判斷Config結構實體的case_sensitive欄位,如果它是true,就呼叫search函數;如果它是false,就呼叫search_case_insensitive函數。

在Linux作業系統環境下,執行cargo run bAsh /etc/passwdCASE_INSENSITIVE=1 cargo run bAsh /etc/passwd指令。如下圖:

rust-grep

執行cargo run bAsh /etc/passwd指令會搜尋不到任何結果,因為bAsh/etc/passwd檔案中並不存在。而CASE_INSENSITIVE=1 cargo run bAsh /etc/passwd則會搜尋到結果,因為CASE_INSENSITIVE環境變數被有設定了,因此可以不用管bAsh的大小寫,而bash/etc/passwd檔案中是存在的,所以能搜尋到一些結果。

總結

在這個章節中,我們練習實作出了一個非常簡單、能讀取檔案、命令列參數和環境變數的grep程式,也初步使用到了「閉包」。下一章中,我們將會更深入地學習「閉包」和「迭代器」的用法。

下一章:閉包和迭代器