我們要運用目前為止學到的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結構體。程式如下:

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

rust-grep

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

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

rust-grep

讀取檔案

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

程式如下:

我們直接在「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」結構體來處理。如下:

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

「main.rs」檔案也修改如下:

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

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

rust-grep

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

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

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

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

rust-grep

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

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

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

程式如下:

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

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

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

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

rust-grep

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

將「search」函數的主體修改如下:

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

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

rust-grep

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

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

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

接著將「main」函數修改如下:

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

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

rust-grep

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

為了要能夠在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/passwd」和「CASE_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程式,也初步使用到了「閉包」。下一章中,我們將會更深入地學習「閉包」和「迭代器」的用法。