Rust 程式設計語言

由 Steve Klabnik 與 Carol Nichols,以及 Rust 社群的貢獻撰寫而成

此版本假設你使用的是 Rust 1.41.0 或更高的版本,並在所有專案中的 Cargo.toml 都有 edition="2018" 來使用 Rust 2018 版號。請查看第一章的「安裝」段落來安裝或更新 Rust,並查看附錄 E 來瞭解版號的資訊。

Rust 語言的 2018 版號包含一系列的改進使得 Rust 更加易讀易用且更容易學習。本書這次的疊代版本中包含一些更新內容來反映這些改進:

  • 第七章的「透過套件、Crate 與模組管理成長中的專案」幾乎完全重寫。模組系統以及其在 2018 版號中的路徑處理變得更一致。
  • 第十章新增了「特徵作為參數」與「返回有實作特徵的型別」的段落來解釋新的 impl Trait 語法。
  • 第十一章有個新段落「在測試中使用 Result<T, E>」來展示如何在測試中使用 ? 運算子。
  • 第十九章的「進階生命週期」已被移除,因為編譯器的改善讓使用該段落的時機變得更加稀少了。
  • 之前的附錄 D 「巨集」被進一步擴展,將額外涵蓋程序性巨集的內容,且被移至第十九章的「巨集」段落中。
  • 附錄 A 「關鍵字」新增了原始標識符的功能介紹,來讓 2015 版號的程式碼可以用在 2018 版號也不會出現問題。
  • 附錄 D 現在的標題為「實用開發工具」,其中涵蓋有近期推出的工具,這些能在你撰寫 Rust 程式碼時幫助到你。
  • 我們修正了整本書中一些小錯誤與不精確的措辭。感謝各位讀者的回報!

值得注意的是 Rust 程式設計語言早期疊代版本中的程式碼在專案中 Cargo.toml 移除 edition="2018" 的話就能繼續編譯,儘管你升級了 Rust 編譯器也是如此。Rust 的向下相容保證一切可以正常運行!

本書的 HTML 格式可以在線上閱讀:https://doc.rust-lang.org/stable/book/正體中文版)。而離線版則包含在 rustup 安裝的 Rust 中,輸入 rustup docs --book 就能開啟。

本書也有由 No Starch Press 出版平裝與電子版格式

前言

雖然不是那麼明確,但 Rust 程式設計語言旨在賦權(empowerment):無論你現在寫的是何種程式碼,Rust 都能賦予你更多能力,在更廣泛的領域中帶有自信地向前邁進。

比方說,「系統層級」的工作會需要處理低階的記憶體管理、資料呈現與程序並行的細節。對以往來說,這塊程式領域被視為巫術般的存在,只有投入好幾年時間學習的選中之人才有辦法駕馭,也才能懂得如何避開其惡名昭彰的陷阱。而且就算是這個領域的實作者也謹慎行事,也生怕他們的程式碼會出現漏洞、當機或損壞。

Rust 破除了這些障礙,消除了以往的陷阱並提供友善全面的工具來協助你。想要「深入」底層控制的程式設計師可以使用 Rust,無需冒著常見的當機或安全漏洞風險,也無需學習得經常改變的工具鏈其中的細節。更好地是,這個語言本身就是設計成能引導你自然而然地寫出可靠且在速度與記憶體使用都十分高效的程式碼。

已經在處理底層程式碼的程式設計師可以使用 Rust 來擴展他們的野心。舉例來說,在 Rust 中進行平行化是相對低風險的動作,編譯器會幫你抓到典型的錯誤。而且你也能更有自信且更積極地進行最佳化,不必擔心會意外造成當機或漏洞。

但 Rust 並不只限於底層的系統程式設計。其表現力與易用易讀的程度能令人愉快地寫出 CLI 應用程式、網頁伺服器與許多其他種程式碼。你會在本書中看到這兩種簡單的範例。使用 Rust 能這讓你將一個領域所學到的技能延伸到另一個領域,你可以透過寫網頁應用程式來學習 Rust,然後應用同樣的技能到你的樹莓派(Raspberry Pi)上。

本書充分體現了 Rust 賦予其使用者更多能力的潛力。其內容平易近人,不止致力於協助你提升 Rust 的知識,還能提升讓你身為程式設計師的整體信心。所以讓我們準備開始學習吧,歡迎加入 Rust 的社群!

— Nicholas Matsakis 與 Aaron Turon

介紹

注意:本書的(英文)版本與出版的 The Rust Programming Language 以及電子書版本的 No Starch Press 一致。

歡迎閱讀 Rust 程式設計語言,這是本 Rust 的入門書籍。Rust 程式設計語言能幫助你寫出更快更可靠的軟體。高層的易讀易用性與底層的掌控性常常是程式設計語言之間的取捨,Rust 試圖挑戰這項矛盾。透過平衡強大的技術能力以及優秀的開發體驗,Rust 讓你能控制底層的實作細節(比如記憶體使用)的同時,免於以往這樣的控制所帶來的相關麻煩。

Rust 適用於誰

Rust 有非常多種誘因使其適用於各式各樣的人。讓我們討論一些比較重要的目標客群。

開發團隊

Rust 已被證明是個能在各種系統程式設計領域的大型開發團隊中協作的高效開發工具。底層程式碼容易產生微妙的程式錯誤,這在多數其他語言只能靠密集的測試並由經驗豐富的開發者小心翼翼地審核程式碼才能找出它們。在 Rust 中,編譯器會扮演著守門員的角色來阻擋這些難以捉摸的程式錯誤,這包含了並行錯誤。透過與編譯器一同合作,開發團隊可以將他們的時間專注在程式邏輯而不必成天追蹤錯誤。

Rust 也將一些現代化的開發工具帶入系統程式設計的世界中:

  • Cargo 是個管理依賴函式庫暨建構的工具,讓新增、編譯與管理依賴變得十分輕鬆,並在 Rust 生態系統維持一致性。
  • Rustfmt 確保開發者遵循統一的程式碼風格。
  • Rust Language Server 為整合開發環境(IDE)提供了程式碼補全與行內錯誤訊息。

透過使用這些與其他 Rust 生態系統中的工具,開發者可以在寫系統層級的程式語言時,維持一定的生產力。

學生

Rust 適用於學生以及對學習系統概念有興趣的人。許多人都透過 Rust 來學習作業系統開發的議題。社群的人都非常友善,且樂於解答學生們的問題。除了本書以外,Rust 團隊也希望系統概念可以被更多人理解,尤其是剛開始學習程式設計的人。

公司

已有大大小小數以百計的公司,在生產環境使用 Rust 來處理各種任務。這些任務包含命令列工具、網路服務、DevOps 工具、嵌入式裝置、影音分析與轉碼、加密貨幣、生物資訊、搜尋引擎、物聯網裝置、機器學習,甚至還有 Firefox 瀏覽器的主要部分。

開源開發者

Rust 適用於想要建構 Rust 程式設計語言、社群、開發工具與函式庫的開發者。我們很樂於看到你願意對 Rust 語言貢獻。

重視速度與穩定性的開發者

Rust 適用於追求語言速度與穩定性的開發者。所謂的速度,我們指的是你用 Rust 所開發出程式的執行速度以及 Rust 所提供程式的開發速度。Rust 編譯器的檢查能確保新增功能與重構時的穩定性。這與沒有這些檢查的語言形成對比,開發者通常會害怕修改這些脆弱的遺留程式碼。Rust 還力求零開銷抽象(zero-cost abstractions),高階的特性編譯成底層程式碼的執行速度能與自己手寫一樣快,Rust 致力於讓安全的程式碼同時也能是執行迅速的程式碼。

Rust 語言也希望能支援其他許多使用者,這裡提及的只是一部分的最大受益者。總體來說,Rust 最重要的目標是消除數十年來開發者不得不作出的取捨,像是同時提供安全生產力、同時具有速度易讀易用。歡迎嘗試看看 Rust,並體驗看看這門語言適不適合你。

本書寫給誰看

本書假設你已經使用其他程式語言寫過程式碼,不過並不假設你使用的是何種語言。我們試著讓這些資源能廣泛適用於不同程式背景的開發者。我們不會花費很多時間討論什麼是程式設計或如何理解它。如果你剛開始學習程式語言,你最好先閱讀專門介紹程式設計的書籍。

如何閱讀本書

一般來說,本書預設你會從頭到尾依序閱讀完。後面的章節都建立在前面章節的概念基礎上,然後前面的章節可能不會深入探討某些主題,我們會在後面的章節再重新討論該主題。

你會在本書中看到兩種章節:概念章節與專案章節。在概念章節中,你會學到 Rust 的其中某些概念。在專案章節中,我們會應用你當前所學的一同建構些小小的專案。第二、十二和二十章是專案章節,其餘都是概念章節。

第一章會解釋如何安裝 Rust、如何寫支「Hello, world!」程式以及如何使用 Cargo--Rust 的套件與建構工具。第二章是透過實作介紹 Rust 語言的章節。我們在此會以較高階的方式解釋些概念,並在之後的章節提供更詳細的介紹。如果你想馬上動手實作看看的話,第二章會很適合你。如果你是這種人的話,你很可能甚至想跳過第三章,這涵蓋 Rust 與其他程式設計語言類似的功能。這樣你可以直接前往第四章來學習 Rust 的所有權系統。然而,如果你是那種鉅細靡遺的讀者,傾向於在進行下一步前學習到所有細節的話,你可能會想跳過第二章直接前往第三章,當你想要套用你所學到的細節在專案上時,再回頭到第二章練習。

第五章討論結構體與方法,而第六章涵蓋枚舉、match 表達式與 if let 控制流結構。你會在 Rust 中用結構體與枚舉來自訂型別。

在第七章中,你會學到 Rust 的模組系統與組織程式碼的隱私規則,以及其公開應用程式介面(Application Programming Interface, API)。第八章會討論標準函式庫提供的一些常見集合資料結構,像是向量、字串與雜湊映射。第九章會探討 Rust 的錯誤處理哲學與技巧。

第十章將深入探討泛型、特徵與生命週期,讓你能定義出多種型別的程式碼。第十一章全部都關於測試,這與 Rust 的安全保障一樣重要,以確保程式邏輯正確。在第十二章中,我們會親自實作個 grep 命令列工具的子集功能,來搜尋檔案中的文字。我們會利用到前幾章討論過的許多概念。

第十三章會探索閉包與疊代器,這是 Rust 啟發自函式程式設計語言的功能。在第十四章中,我們要更深入研究 Cargo 並討論分享函式庫給其他人的最佳方式。第十五章會討論標準函式庫提供的智慧指標以及能啟用它們功能的特徵。

在第十六章中,我們會介紹各種不同的並行程式設計模型,並介紹 Rust 如何幫助你在多執行緒中無懼地開發。第十七章會比較 Rust 的慣用風格與你可能較熟悉的物件導向程式設計原則之間有何差異。

第十八章是模式與模式配對的參考章節,這是在 Rust 程式中表達不同條件的強大特色。第十九章是進階主題的大雜燴,其中包含不安全的 Rust、巨集與更多有關生命週期、特徵、型別、函式與閉包的資訊。

在第二十章中,我們會完成一項專案,那就是實作底層多執行緒的網頁伺服器!

最後的附錄以參考風格格式來包含語言中的實用資訊。附錄 A 涵蓋 Rust 的關鍵字、附錄 B 涵蓋 Rust 的運算子與符號、附錄 C 涵蓋標準函式庫提供的可推導的特徵、附錄 D 涵蓋一些實用開發工具,然後附錄 E 會解釋 Rust 的版號。

本書沒有錯誤的閱讀方式:如果你想要忽略一些章節,儘管跳過吧!如果你遇到任何疑惑的話,隨時可以再跳回之前的章節閱讀。請隨意閱讀。

學習 Rust 的過程中有個重要的部分就是要學習如何閱讀編譯器顯示的錯誤訊息,這些能引導你寫出正確的程式碼。所以我們會提供很多無法編譯的範例,以及各種編譯器會顯示訊息的狀況。如果你執行任何範例的話,它很可能不會編譯通過!別忘了看看周遭的文字來瞭解該執行範例是不是故意出錯。Ferris 也會來幫助你分辨哪些程式碼本來就無法使用:

Ferris意義
此程式碼無法編譯!
此程式碼會恐慌!
此程式碼區塊包含不安全的程式碼。
此程式碼沒有產生預期的行為。

在大多數的情況下,我們會引導你將無法編譯的程式碼寫成正確的版本。

原始碼

產生本書的原始檔案可以在 GitHub 上找到。

開始入門

讓我們開始你的 Rust 旅途吧!千里之行始於足下,在此章節我們將討論:

  • 在 Linux、macOS 和 Windows 上安裝 Rust
  • 寫一支印出 Hello, world! 的程式
  • 使用 Rust 的套件管理工具暨建構系統 cargo

安裝教學

第一步是安裝 Rust,我們將會透過 rustup 安裝 Rust,這是個管理 Rust 版本及相關工具的命令列工具。你將會需要網路連線才能下載。

注意:如果你基於某些原因不想使用 rustup 的話,請前往安裝 Rust 頁面尋求其他選項。

以下步驟將會安裝最新的穩定版 Rust 編譯器。Rust 的穩定性能確保本書的所有範例在更新的 Rust 版本仍然能繼續編譯出來。輸出的結果可能會在不同版本間而有些微的差異,因為 Rust 時常會改善錯誤與警告訊息。換句話說,任何你所安裝的最新穩定版 Rust 都應該能夠正常運行本書的內容。

命令列標記

在本章節到整本書為止,我們將會顯示一些終端機會用到的命令。任一你會用到的命令都會始於 $。但你不需要去輸入 $,因為這通常代表每一命令列的起始位置。而沒有出現 $ 的行數,通常則代表前一行命列輸出的結果。除此之外,針對 PowerShell 的範例則將會使用 > 而不是 $

在 Linux 或 macOS 上安裝 rustup

如果你使用的是 Linux 或 macOS,請開啟終端機然後輸入以下命令:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

這道命令會下載一支腳本然後開始安裝 rustup 工具,接著安裝最新的穩定版 Rust。下載過程中可能會要求你輸入你的密碼。如果下載成功的話,將會出現以下內容:

Rust is installed now. Great!

除此之外,你還會需要某種類型的連結器(linker)。這通常在你的系統上都已經安裝了,但如果你嘗試編譯 Rust 程式卻遇到連結器無法執行的錯誤時,這代表你的系統並未安裝,而你需要自行安裝一個。C 編譯器通常都會帶有一個正確的連結器,你可以檢查你的平台文件,查看如何下載 C 編譯器。此外,一些常見的 Rust 套件也會依賴 C 的程式,所以也需要 C 編譯器。因此現在最好還是有安裝一個比較好。

在 Windows 上安裝 rustup

在 Windows 上請前往下載頁面並依照指示安裝 Rust。在安裝的某個過程中,你將會看到一個訊息要求說你還需要 C++ build tools for Visual Studio 2013 或更新的版本。取得這項工具最簡單的辦法是下載 Build Tools for Visual Studio。當你被問到要安裝哪些項目時,請確認有選擇「C++ build tools」,而且有包含 Windows 10 SDK 和英文語言包套件。

本書接下來使用的命令都相容於 cmd.exe 和 PowerShell。如果有特別不同的地方,我們會解釋該怎麼使用。

更新與解除安裝

當你透過 rustup 安裝完 Rust 後,要更新到最新版本的方法非常簡單。在你的 shell 中執行以下更新腳本即可:

$ rustup update

要解除安裝 Rust 與 rustup 的話,則在 shell 輸入以下解除安裝腳本:

$ rustup self uninstall

疑難排除

想簡單確認你是否有正確安裝 Rust 的話,請開啟 shell 然後輸入此命令:

$ rustc --version

你應該會看到已發佈的最新穩定版本號、提交雜湊(hash)以及提交日期如以下格式所示:

rustc x.y.z (abcabcabc yyyy-mm-dd)

如果你看到這則訊息代表你成功安裝 Rust 了!如果你沒有看到且你是在 Windows 上的話,請檢查 Rust 是否在你的 %PATH% 系統變數裡。如果都正確無誤,但還是無法執行 Rust 的話,你可以前往一些地方尋求協助。最簡單的辦法是前往官方 Rust Discord 的 #beginners 頻道詢問。在那裡你可以與其他 Rustaceans(這是我們常用稱呼自己取的暱稱)交談並取得協助。另外也有其他不錯的資源像是使用者討論區Stack Overflow。正體中文社群的話可以前往 rust-lang.tw,底下有 Facebook 或 Telegram 的連結一樣可以尋求協助。

本地端技術文件

安裝 Rust 的同時也會包含一份本地的技術文件副本,讓你可以離線閱讀。執行 rustup doc 就可以用你的瀏覽器開啟本地文件。

每當有任何型別或函式出現而你卻不清楚如何使用時,你就可以閱讀應用程式介面(API)技術文件來理解!

Hello, World!

現在你已經安裝好 Rust,讓我們開始來寫你的第一支 Rust 程式吧。當我們學習一門新的語言時,有一個習慣是寫一支印出「Hello, world!」到螢幕上的小程式,此章節將教你做一樣的事!

注意:本書將假設你已經知道命令列最基本的使用方法。Rust 對於你的編輯器、工具以及程式碼位於何處沒有特殊的要求,所以如果你更傾向於使用整合開發環境(IDE)的話,請儘管使用你最愛的 IDE。許多 IDE 都已經針對 Rust 提供某種程度的支援,請查看你所使用的 IDE 技術文件以瞭解詳情。最近,Rust 團隊正在積極專注在提升 IDE 的支援,而且進展十分迅速出色!

建立專案目錄

你將先建立一個目錄來儲存你的 Rust 程式碼。程式碼位於何處並不重要,但為了能好好練習書中的範例和專案,我們建議你可以在你的 home 目錄建立一個 projects 目錄然後將你所有的專案保存在此。

請開啟終端機然後輸入以下命令來建立 projects 目錄和另一個在 projects 目錄底下的真正要寫「Hello, world!」專案的目錄。

對於 Linux、macOS 和 Windows 的 PowerShell,請輸入:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

對於 Windows CMD,請輸入:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

編寫並執行 Rust 程式

接著,請產生一個全新原始碼檔案並命名為 main.rs。Rust 的文件檔案都會以 .rs 副檔名稱作為結尾。如果你用到不只一個單字的話,請用底線區隔開來。比方說,請使用 hello_world.rs 而不是 helloworld.rs

現在請開啟 main.rs 檔案然而後輸入範例 1-1 中的程式碼。

檔案名稱:main.rs

fn main() {
    println!("Hello, world!");
}

範例 1-1:印出「Hello, world!」的程式

儲存檔案然後回到你的終端機螢幕。在 Linux 或 macOS 上,請輸入以下命令來編譯並執行檔案:

$ rustc main.rs
$ ./main
Hello, world!

在 Windows 上則輸入 .\main.exe 而非 ./main

> rustc main.rs
> .\main.exe
Hello, world!

不管你的作業系統為何,終端機上應該都會出現 Hello, world!。如果你沒有看到,可以回到安裝章節中的「疑難排除」尋求協助。

如果 Hello, world! 有印出來,那麼恭喜你!你正式寫了一支 Rust 程式,所以你也正式成為 Rust 開發者了——歡迎加入!

分析這支 Rust 程式

讓我們來仔細瞧瞧你的「Hello, world!」程式實際上發生了什麼事。這是第一塊拼圖:

fn main() {

}

這幾行在 Rust 中定義了一個函式。main 是一個特別的函式:它是每個可執行的 Rust 程式永遠第一個執行的程式碼。第一行宣告了一個函式 main,它沒有參數也不回傳任何東西。如果有參數的話,它們會被加進括號 () 內。

再來,請注意到函式本體被囊括在大括號 {} 內,Rust 要求所有函式都用大括號包起來。一般來說,良好的程式碼風格會要求將前大括號置於宣告函式的同一行,並用一個空格區隔開來。

在本書撰寫的期間,有一支自動格式化的工具叫做 rustfmt 正在開發中。如果你想要在不同 Rust 專案之間統一標準風格的話,rustfmt 可以格式化你的程式成特定的風格。Rust 團隊計劃最終將此工具納入標準 Rust 發行版中,就像 rustc 一樣。所以依照你閱讀此書的時間點,它很可能已經安裝到你的電腦上了!請查看線上技術文件以瞭解詳情。

main 函式內有以下程式碼:


#![allow(unused)]
fn main() {
    println!("Hello, world!");
}

此行負責了整支程式要做的事:它將文字顯示在螢幕上。這邊有四個細節要注意。

首先,Rust 的排版風格是 4 個空格而非一個 tab。

第二,println! 會呼叫一支 Rust 巨集(macro)。如果是呼叫函式的話,那則會是 println(去掉 !)。我們會在第十九章討論更多巨集的細節。現在你只需要知道使用 ! 代表呼叫一支巨集而非一個正常的函式。

第三,"Hello, world!" 是一個字串,我們將此字串作為引數傳遞給 println!,然後該字串就會被顯示到螢幕上。

第四,我們用分號(;)作為該行結尾,代表此表達式的結束和下一個表達式的開始。多數的 Rust 程式碼都以分號做結尾。

編譯和執行是不同的步驟

你剛剛執行了一個新建立的程式,讓我們來檢查過程中的每一個步驟吧。

在你執行一支 Rust 程式前,你必須用 Rust 編譯器來編譯它,也就是輸入 rustc 命令然後加上你的原始檔案,像這樣子:

$ rustc main.rs

如果你已經有 C 或 C++ 的背景,你應該就會發現這和 gccclang 非常相似。編譯成功後,Rust 編譯器會輸出一個二進制執行檔(binary executable)。

在 Linux、macOS 和 Windows 上的 PowerShell,你可以在你的 shell 輸入 ls 來查看你的執行檔。在 Linux 和 macOS,你會看到兩個檔案。而在 Windows 上的 PowerShell,你會和使用 CMD 一樣看到三個檔案。

$ ls
main  main.rs

在 Windows 上的 CMD,你需要輸入:

> dir /B %= /B 選項代表只顯示檔案名稱 =%
main.exe
main.pdb
main.rs

這顯示了副檔名為 .rs 的原始碼檔案、執行檔(在 Windows 上為 main.exe;其他則為 main),然後在 Windows 上會再出現一個副檔名為 .pdb 的除錯資訊文件。在這裡,你就可以像這樣執行 mainmain.exe 檔案:

$ ./main # 在 Windows 上則是 .\main.exe

如果 main.rs 正是你的「Hello, world!」程式,這命令就會顯示 Hello, world! 到你的終端機。

如果你比較熟悉動態語言,像是 Ruby、Python 或 JavaScript,你可能會比較不習慣將編譯與執行程式分為兩個不同的步驟。Rust 是一門預先編譯(ahead-of-time compiled)的語言,代表你可以編譯完成後將執行檔送到其他地方,然後他們就算沒有安裝 Rust 一樣可以執行起來。但如果你給某個人 .rb.py.js 檔案,他們就需要 Ruby、Python 或 Javascript 分別都有安裝好。當然你在這些語言只需要一行命令就可以執行,在語言設計中這一切都只是取捨。

在簡單的程式使用 rustc 來編譯不會有什麼問題,但當你的專案成長時,你將會需要管理所有選擇並讓程式碼易於分享。接下來我們將介紹 Cargo 這項工具給你,它將協助你寫出真正的 Rust 程式。

Hello, Cargo!

Cargo 是 Rust 的建構系統與套件管理工具。大部分的 Rustaceans 都會用此工具來管理他們的專案,因為 Cargo 能幫你處理很多任務,像是建構你的程式碼、下載你程式碼所需要的依賴函式庫並建構它們。我們常簡稱程式碼所需要用到的函式庫為依賴(dependencies)

簡單的 Rust 程式像是我們目前所寫的不會有任何依賴。所以當我們用 Cargo 建構「Hello, world!」專案時,Cargo 只會用到建構程式碼的那部分。隨著你寫的 Rust 程式越來越複雜,你將會加入一些依賴函式庫來幫助你。而如果你使用 Cargo 的話,加入這些依賴就會簡單很多。

既然大多數的 Rust 專案都是用 Cargo,所以接下來本書也將假設你也使用 Cargo。Cargo 在你使用「安裝教學」的官方安裝連結來安裝 Rust 時就已經連同安裝好了。如果你是用其他方式下載 Rust 的話,想要檢查 Cargo 有沒有下載好可以透過你的終端機輸入:

$ cargo --version

如果你有看到版本號,那就代表你有安裝了!如果你看到錯誤訊息,像是 command not found,請查看你的安裝辦法的技術文件,尋找如何額外下載 Cargo。

使用 Cargo 建立專案

讓我們來用 Cargo 建立一個專案,並來比較它和我們原本的「Hello, world!」專案有什麼差別。請回到你的 projects 目錄(或者任何你決定存放程式碼的地方),然後在任何作業系統上輸入:

$ cargo new hello_cargo
$ cd hello_cargo

第一道命令會建立一個新的目錄叫做 hello_cargo。我們將我們的專案命名為 hello_cargo,然後 Cargo 就會產生相同名稱的目錄並產生所需的檔案。

進入 hello_cargo 然後顯示檔案的話,你會看到 Cargo 產生了兩個檔案和一個目錄: Cargo.toml 檔案以及一個 src 目錄,其內包含一個 main.rs 檔案。

它還會初始化成一個新的 Git repository 並附上 .gitignore 檔案。如果已經在 Git repository 內的話,執行 cargo new 則不會產生 Git 的檔案。你可以用 cargo new --vcs=git 覆寫這項行為。

注意:Git 是一個常見的版本控制系統。你可以加上 --vcs 來變更 cargo new 去使用不同的版本控制系統,或是不用任何版本控制系統。請執行 cargo new --help 來查看更多可使用的選項。

請用任何你喜歡的編輯器開啟 Cargo.toml,它應該會看起來和範例 1-2 差不多。

檔案名稱:Cargo.toml

[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your Name <[email protected]>"]
edition = "2018"

[dependencies]

範例 1-2:用 cargo new 產生的 Cargo.toml

此檔案用的是 TOMLTom’s Obvious, Minimal Language)格式,這是 Cargo 配置文件的格式。

第一行的 [package] 是一個段落(section)標題,說明以下的陳述式(statement)會配置這個套件。隨著我們加入更多資訊到此文件,我們也會加上更多段落。

接下來四行就是 Cargo 編譯你的程式所需的配置資訊:名稱、版本、誰寫的以及哪個 Rust edition 會用到。Cargo 會透過環境取得你的名字和電子郵件資訊,所以要是資訊不對的話,請現在編輯然後儲存檔案。我們會在附錄 E 介紹什麼是 edition

最後一行 [dependencies] 是用來列出你的專案會用到哪些依賴的段落。在 Rust 中,程式碼套件會被稱為 crates。我們在此專案還不需要任何其他 crate。但是我們會在第二章開始用到,屆時我們會再來介紹。

現在請開啟 src/main.rs 來看看:

檔案名稱:src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo 預設會為你產生一個「Hello, world!」程式,就像我們範例 1-1 寫的一樣!目前我們之前寫的專案與 Cargo 產生的程式碼不同的地方在於 Cargo 將程式碼放在 src 目錄底下,而且我們還有一個 Cargo.toml 配置文件在根目錄。

Cargo 預期你的原始檔案都會放在 src 目錄底下。專案的根目錄是用來放 README 檔案、授權條款、配置檔案以及其他與你的程式碼不相關的檔案。使用 Cargo 能夠幫助你組織你的專案,讓一切井然有序。

如果你的專案還沒開始使用 Cargo 的話,像是我們剛剛寫的「Hello, world!」專案,你只要將程式碼移入 src 然後產生正確的 Cargo.toml 檔案,就可以將它轉換成能夠使用 Cargo 的專案。

建構並執行 Cargo 專案

現在讓我們看看用 Cargo 產生的「Hello, world!」程式在建構和執行時有什麼差別!請在你的 hello_cargo 目錄下輸入以下命令來建構專案:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

此命令會產生一個執行檔 target/debug/hello_cargo(在 Windows 上則是 target\debug\hello_cargo.exe),而不是在你目前的目錄。你可以用以下命令運行執行檔:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

如果一切順利,Hello, world! 就會顯示在終端機上。第一次執行 cargo build 的話,還會在根目錄產生另一個新檔案:Cargo.lock。此檔案是用來追蹤依賴函式庫的確切版本。不過此專案沒有任何依賴,所以目前這個檔案看起來內容會有點少。你不會需要去手動更改此檔案,Cargo 會幫你管理這個檔案的內容。

我們剛用 cargo build 建構完專案並用 ./target/debug/hello_cargo 執行它。不過我們其實也可以只用一道命令 cargo run 來編譯程式碼並接著運行產生的執行檔:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

請注意到這次輸出的結果我們沒有看到 Cargo 有在編譯 hello_cargo 的跡象,這是因為 Cargo 可以知道檔案完全沒被更改過,所以它選擇直接執行二進制檔案。如果你有變更你的原始碼的話,Cargo 才會在執行前重新建構專案,你才會看到這樣的輸出結果:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo 還提供一道命令 cargo check,此命令會快速檢查你的程式碼,確保它能編譯通過但不會產生執行檔:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

為何你會不想要產生執行檔呢?這是因為 cargo check 省略了產生執行檔的步驟,所以它執行的速度比 cargo build 還來的快。如果你在寫程式時需要持續檢查的話,使用 cargo check 可以加快整體過程!所以許多 Rustaceans 都會在寫程式的過程中時不時執行 cargo check 來確保它能編譯。最後當他們準備好要使用執行檔時,才會用 cargo build

讓我們來回顧我們目前學到的 Cargo 內容:

  • 我們可以用 cargo build 建構專案。
  • 我們可以用 cargo run 同時建構並執行專案。
  • 我們可以用 cargo check 建構專案來檢查錯誤,但不會產生執行檔。
  • Cargo 會儲存建構結果在 target/debug 目錄底下,而不是放在與我們程式碼相同的目錄。

使用 Cargo 還有一項好處是在任何作業系統所使用的命令都是相同的,所以到這邊開始我們不再需要特別提供 Linux 和 macOS 相對於 Windows 不同的特殊命令。

建構發佈版本(Release)

當你的專案正式準備好要發佈的話,你可以使用 cargo build --release 來最佳化編譯結果。此命令會產生執行檔到 target/release 而不是 target/debug。最佳化可以讓你的 Rust 程式碼跑得更快,不過也會讓編譯的時間變得更久。這也是為何 Cargo 提供兩種不同的設定檔(profile):一個用來作為開發使用,讓你可以快速並經常重新建構;另一個用來最終產生你要給使用者運行的程式用,它通常不會需要重新建構且能盡所能地跑得越快越好。如果你要做基準化分析(benchmarking)來檢測程式運行時間的話,請確認執行的是 cargo build --release 並使用 target/release 底下的執行檔做檢測。

將 Cargo 視為常規

雖然在簡單的專案下,Cargo 比起只使用 rustc 的確沒辦法突顯出什麼價值。但是當你的程式變得越來越複雜時,它將證明它的用途。在擁有一堆 crate 的龐大專案下,讓 Cargo 來協調你的專案會來的簡單許多。

儘管 hello_cargo 是個小專案,但它使用了你未來的 Rust 生涯中真實情況下會用到的工具。事實上,所有存在的專案,你幾乎都可以用以下命令完成:使用 Git 下載專案、移至專案目錄然後建構完成。

$ git clone someurl.com/someproject
$ cd someproject
$ cargo build

有關 Cargo 的更多資訊,請查看它的技術文件

總結

你已經完成你的 Rust 旅途的第一步了!在本章節你學到了:

  • 使用 rustup 安裝最新穩定版 Rust
  • 更新到最新 Rust 版本
  • 開啟本地端安裝的技術文件
  • 直接使用 rustup 編寫並執行一支「Hello, world!」程式
  • 使用 Cargo 建立並執行一個新專案

接下來是時候來建立一個更實際的程式來熟悉 Rust 程式碼的讀寫了。所以在第二章我們將寫出一支猜謎遊戲的程式。如果你想直接學習 Rust 的常見程式設計概念的話,你可直接閱讀第三章,之後再回來看第二章。

設計猜謎遊戲程式

讓我們親自動手一同完成一項專案來開始上手 Rust 吧!本章節會介紹一些常見 Rust 概念,展示如何在實際程式中使用它們。你會學到 letmatch、方法、關聯函式、使用外部 crate 以及更多等等!之後的章節會更詳細地探討這些概念。在本章中,你會先練習基礎概念。

我們會實作個經典新手程式問題:猜謎遊戲。它的運作方式如下:程式會產生 1 到 100 之間的隨機整數。接著它會通知玩家猜一個數字。在輸入猜測數字之後,程式會回應猜測的數字太低或太高。如果猜對的話,遊戲就會顯示祝賀訊息並關閉。

設置新專案

要設置新專案的話,前往你在第一章建立的 projects 目錄並使用 Cargo 建立一個新的專案,如下所示:

$ cargo new guessing_game
$ cd guessing_game

第一道命令 cargo new 會接收專案名稱(guessing_game)作為引數(argument)。第二道命令會將目錄移至新專案中。

檢查看看產生的 Cargo.toml 檔案:

檔案名稱:Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <[email protected]>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

如果 Cargo 從你的環境取得的作者資訊不正確的話,請在檔案中修改並儲存。

如同你在第一章看到的,cargo new 會產生一支「Hello, world!」程式。請檢查 src/main.rs 檔案:

檔案名稱:src/main.rs

fn main() {
    println!("Hello, world!");
}

現在讓我們用 cargo run 命令同時完成編譯與執行「Hello, world!」程式:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

run 命令在你需要對專案快速疊代時會很有用,我們要寫的遊戲也是如此,在繼續下一步之前可以快速測試每一步。

請重新開啟 src/main.rs 檔案。你要寫的程式碼全都會位於此檔案中。

處理猜測

猜謎遊戲的第一個部分會要求使用者輸入數字、處理該輸入,並檢查該輸入是否符合格式。所以我們要先讓玩家能夠輸入猜測數字,請輸入範例 2-1 的程式碼至 src/main.rs

檔案名稱:src/main.rs

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

範例 2-1:取得使用者的猜測數字並顯示出來的程式

這段程式碼包含大量的資訊,所以讓我們一行一行來慢慢看吧。要取得使用者輸入並印出為輸出結果,我們需要將 io 輸入/輸出(input/output)函式庫引入作用域中。 io 函式庫來自標準函式庫(常稱為 std):

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

Rust 在預設情況下只會透過 prelude 來將一些型別引入每個程式的作用域中。如果你想使用的型別不在 prelude 的話,你需要顯式(explicit)地使用 use 陳述式(statement)將該型別引入作用域。std::io 函式庫能提供一系列實用的功能,這包含接收使用者輸入的能力。

如同你在第一章所見的,main 函式是程式的入口點(entry point):

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

fn 語法用來宣告新的函式(function),其中括號 () 說明此函式沒有任何參數,然後大括號 { 會作為函式本體的開頭。

同樣如第一章所學的,println! 是個能將字串顯示到螢幕上的巨集:

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

此程式碼會顯式提示訊息向使用者說明此遊戲該輸入什麼。

透過變數儲存數值

接著我們要建立一個變數來儲存使用者輸入,如以下所示:

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

現在程式變得越來越有趣了!在短短的這行當中有許多事情發生。先注意到這是個 let 陳述式,這用來建立一個變數(variable)。以下是另一個例子:

let foo = bar;

這行建立了一個新的變數叫做 foo 並將變數 bar 的數值綁定給它。在 Rust 中,變數預設是不可變的(immutable)。我們會在第三章的「變數與可變性」段落討論此概念。以下範例展示如何在變數名稱前面使用 mut 來讓變數成為可變的:

let foo = 5; // 不可變的
let mut bar = 5; // 可變的

注意:// 語法用來產生註解(comment)直到該行結束。Rust 會忽略註解中所有內容,這會在第三章進一步討論到。

讓我們回到猜謎遊戲程式。你現在就知道 let mut guess 會產生一個可變變數叫做 guess。在等號(=)的另一邊是要綁定給 guess 的數值,也就是呼叫 String::new 的結果,這是一個回傳新的 String 實例(instance)的函式。String 是個標準函式庫提供的字串型別,這是可增長的 UTF-8 編碼文字。

::new 中的 :: 語法代表 newString 型別的關聯函式(associated function)。關聯函式是針對型別的實作,在此例中就是 String,而不是針對 String 特定實例的實作。有些語言會稱之為靜態方法(static method)

new 函式建立一個新的空字串。你會在許多型別中找到 new 函式,因為這是函式建立某種新數值的常見名稱。

總結來說, let mut guess = String::new(); 這行會建立一個可變變數,且目前會得到一個新的空 String 實例。

回想一下我們在程式第一行透過 use std::io; 來包含標準函式庫中的輸入/輸出功能。現在我們要從 io 模組(module)呼叫 stdin 函式:

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

如果我們沒有將 use std::io 這行置於程式最一開始的位置,我們就得寫出 std::io::stdin 來呼叫函式。stdin 函式會回傳一個 std::io::Stdin 實例,這是代表終端機標準輸入控制代碼(handle)的型別。

而程式碼的下個部分 .read_line(&mut guess) 會對標準輸入控制代碼呼叫 read_line 方法(method)來取得使用者的輸入。我們還傳遞了一個引數(argument)給 read_line&mut guess

read_line 的任務是取得使用者在標準輸入寫入的任何內容,並放置到字串中,所以它才接收字串作為引數。字串引數需要是可變的,這樣該方法才能變更字串的內容成使用者的輸入。

& 說明此引數是個引用(reference),這讓程式中的多個部分可以取得此資料內容,但不需要每次都得複製資料到記憶體中。引用是個複雜的概念,而 Rust 其中一項主要優勢就是能夠輕鬆又安全地使用引用。你現在還不用知道一堆細節才能完成程式。現在你只需要知道引用和變數一樣,預設都是不可變的。因此你必須寫 &mut guess 而不是 &guess 才能讓它成為可變的。(第四章會再全面詳細解釋引用。)

使用 Result 型別處理可能的錯誤

我們要繼續處理這段程式碼。雖然我們已經討論到第三行了,這仍然是這段單一邏輯程式碼中的一部分。接下來的部分是此方法:

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

當你透過 .foo() 語法呼叫方法時,通常換行來寫並加上縮排,來拆開一串很長的程式碼會比較好。我們當然可以這樣寫:

io::stdin().read_line(&mut guess).expect("讀取行數失敗");

但是這麼長會很難閱讀,所以最好是能夠分段。現在讓我們來討論這行在做什麼。

如稍早提過的,read_line 會將使用者任何輸入轉換至我們傳入的字串,但它還回傳了一個數值,在此例中就是 io::Result。在 Rust 標準函式庫中有一系列的型別都叫做 Result,這包含泛型(generic)Result以及每個子模組(submodule)中的特別版本,像是 io::Result

Result 型別是種枚舉(enumerations),常稱為 enums。枚舉是種擁有固定集合數值的型別,而這些數值會被稱之為枚舉的變體(variants)。第六章會更詳細地介紹枚舉。

對於 Result 來說,其變體會是 OkErrOk 變體指的是該動作成功完成,且 Ok 內部會包含成功產生的數值。而 Err 變體代表動作失敗,且 Err 會包含該動作如何與為何會失敗的資訊。

這些 Result 型別的目的是要編碼錯誤處理資訊。Result 型別的數值與任何型別的數值一樣,它們都有定義些方法。io::Result 的實例有 expect 方法 讓你能呼叫。如果此 io::Result 實例數值為 Err 的話,expect 會讓程式當機並顯示作為引數傳給 expect 的訊息。如果 read_line 回傳 Err 的話,這可能就是從底層作業系統傳來的錯誤結果。如果此 io::Result 實例數值為 Ok 的話,expect 會接收 Ok 的回傳值並只回傳該數值,讓你可以使用。在此例中,數值將為使用者輸入進標準輸入介面的位元組數字。

如果你沒有呼叫 expect,程式仍能編譯,但你會收到一個警告:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `std::result::Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Rust 警告你沒有使用 read_line 回傳的 Result 數值,這意味著程式沒有處理可能發生的錯誤。

要解決此警告的正確方式是實際進行錯誤處理,但因為我們只想要當問題發生時直接讓程式當掉,所以你可以先使用 expect 就好。你會在第九章學到如何從錯誤中恢復。

透過 println! 佔位符印出數值

在結束大括號之前,目前程式碼中還有一行要來討論,也就是以下這行:

use std::io;

fn main() {
    println!("請猜測一個數字!");

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

此行會印出存有使用者輸入的字串。其中的大括號 {} 是個佔位符(placeholder):將 {} 想成是個小蟹鉗會夾住某個數值。你可以使用大括號印出一個以上的數值,第一個大括號會是格式化字串之後列出的第一個數值,第二個大括號會是第二個數值,以此類推。要呼叫 println! 來印數多個數值會如以下所示:


#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {} 而且 y = {}", x, y);
}

此程式碼會印出 x = 5 而且 y = 10

測試第一個部分

讓我們來測試猜謎遊戲中的第一個部分。請使用 cargo run 來執行它:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
請猜測一個數字!
請輸入你的猜測數字。
6
你的猜測數字:6

到目前為止,遊戲的第一個部分就完成了:我們取得了鍵盤的輸入然後顯示出來。

產生祕密數字

接下來,我們要產生一個能讓使用者猜看看的祕密數字。祕密數字每次都要不同,這樣遊戲才值得多玩幾次。讓我們使用 1 到 100 之間的隨機數字,這樣遊戲才不會太困難。Rust 的標準函式庫並不包含產生隨機數字的功能。然而,Rust 團隊有提供個 rand crate

使用 Crate 來取得更多功能

所謂的 crate 是一個 Rust 原始碼檔案的集合。我們正在寫的專案屬於二進制(binary) crate,也就會是個執行檔。而 rand crate 屬於函式庫(library) crate,這會包含讓其他程式能夠使用的程式碼。

Cargo 可以使用外部 crate 的功能正是它的亮點。在我們可以使用 rand 來寫程式碼前,我們需要修改 Cargo.toml 檔案來包含 rand crate 作為依賴函式庫(dependency)。開啟該檔案然後將以下行數加到 Cargo 自動產生的 [dependencies] 標頭(header)段落中最後一行下面:

檔案名稱:Cargo.toml

[dependencies]
rand = "0.5.5"

Cargo.toml 檔案中,標頭以下的所有內容都是該段落的一部分,一直到下個段落出現為止。[dependencies] 段落是告訴 Cargo 此專案要依賴哪些 crate,以及那些 crate 的版本為何。在此例中,我們透過語意化版本 0.5.5 來指定 rand crate。Cargo 能夠理解語意化版本(Semantic Versioning),有時也被稱之為 SemVer,這是一種定義版本數字的標準。數字 0.5.5 其實是 ^0.5.5 的縮寫,這代表「任何與版本 0.5.5 的公開 API 相容的版本」。

現在,在不改變任何程式碼的情況下,讓我們建構(build)專案吧,如範例 2-2 所示。

$ cargo build
    Updating crates.io index
  Downloaded rand v0.5.5
  Downloaded libc v0.2.62
  Downloaded rand_core v0.2.2
  Downloaded rand_core v0.3.1
  Downloaded rand_core v0.4.2
   Compiling rand_core v0.4.2
   Compiling libc v0.2.62
   Compiling rand_core v0.3.1
   Compiling rand_core v0.2.2
   Compiling rand v0.5.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

範例 2-2:在新增 rand crate 作為依賴後,執行 cargo build 的輸出

你可能會看到不同的版本數字(但多虧有 SemVer,它們都會與程式碼相容!)、不同的行數(依照作業系統可能會不同)以及每行順序可能會不相同。

現在你有了外部依賴,Cargo 會從 registry 取得所有 crate 的最新版本訊息,這是份 Crates.io 的資料副本。Crates.io 是個讓 Rust 生態系統中的每個人都能發佈它們的開源 Rust 專案並讓其他人使用的地方。

在更新 registry 之後,Cargo 會檢查 [dependencies] 段落並下載你還沒有的 crate。在此例中,雖然我們只有列出 rand 作為依賴,但 Cargo 還會下載 libcrand_core,因為 rand 需要這些才能運作。在下載完 crates 之後,Rust 會編譯依賴函式庫以及使用到它們的專案。

如果你立即再次執行 cargo build 且沒有作出任何改變的話,你除了 Finished 這行以外不會在收到任何輸出。Cargo 知道它已經下載並編譯依賴函式庫了,而且你沒有在 Cargo.toml 檔案中再做任何改變。Cargo 也知道你沒有修改任何程式碼,所以也不會再重新編譯它。既然沒事可做,它就只好馬上結束。

如果你開啟 src/main.rs 檔案,加些瑣碎的修改,然後儲存並再次建構的話,你會只看到兩行輸出:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs

這幾行表示 Cargo 只更新你對 src/main.rs 檔案的瑣碎修改進行建構。你的依賴沒變,所以 Cargo 知道它可以重複使用已經下載並編譯過的程式碼。它只重新建構了程式碼中的一小部分。

透過 Cargo.lock 檔案確保建構可以重現

Cargo 有個機制能確保任何人或你在任何時候重新建構程式碼時,都能產生相同結果。舉例來說,要是下一週 rand crate 發佈了版本 0.5.6,其包含重大程式錯誤更新,卻也有個會破壞你的程式碼的回歸(regression)問題,這時會發生什麼事呢?

此問題的答案位於 Cargo.lock 檔案中,這會在你第一次執行 cargo build 時建立,並位於 guessing_game 目錄中。當你第一次建構專案時,Cargo 會決定出符合情境的依賴函式庫版本,然後將它們寫入 Cargo.lock 檔案中。當你在未來建構專案時,Cargo 會看到 Cargo.lock 的存在並使用其指定的版本,而非重新再次決定該用哪些版本。這讓你有個能自動重現的建構方案。換句話說,你的專案仍會繼續使用 0.5.5 直到你顯式升級為止,這都多虧了 Cargo.lock 檔案。

升級 Crate 來取得新版本

當你真的想升級 crate 時,Cargo 有提供另一個命令 update,這會忽略 Cargo.lock 檔案並依據 Cargo.toml 指定的規格決定所有合適的最新版本。如果成功的話,Cargo 會將這些版本寫入 Cargo.lock 檔案中。

Cargo 預設只會尋找大於 0.5.5 且小於 0.6.0 的版本。如果 rand 有發佈兩個新版本 0.5.60.6.0,當你輸入 cargo update 時,你會看到以下結果:

$ cargo update
    Updating crates.io index
    Updating rand v0.5.5 -> v0.5.6

此時你也會注意到 Cargo.lock 檔案中的變更,指出你現在使用的 rand crate 版本為 0.5.6

如果你想使用 rand 版本 0.6.0 或任何版本 0.6.x 系列,你需要升級 Cargo.toml 檔案,如以下所示:

[dependencies]
rand = "0.6.0"

下次你執行 cargo build 時,Cargo 將會更新 crate registry ,並依據你指定的新版本來重新評估 rand 的確切版本。

Cargo其生態系統還有很多內容可以介紹,我們會在第十四章討論它們。但現在你只需要知道這些就好。Cargo 讓重複使用函式庫變得非常容易,讓 Rustaceans 可以組合許多套件寫出簡潔的專案。

產生隨機數字

現在既然你已經將 rand crate 加入 Cargo.toml 中,讓我們開始使用 rand 吧。下一步是更新 src/main.rs,如範例 2-3 所示。

檔案名稱:src/main.rs

use std::io;
use rand::Rng;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

範例 2-3:新增程式碼來產生隨機數字

首先我們加上 use 這行:use rand::RngRng 特徵(trait)定義了隨機數字產生器實作的方法,所以此特徵必須引入作用域,我們才能使用這些方法。第十章會詳細解釋特徵。

接著,我們在中間加上兩行。rand::thread_rng 函式會回傳我們要使用的特定隨機數字產生器:這會位於目前執行緒(thread)並由作業系統提供種子(seed)。然後我們對隨機數字產生器呼叫 gen_range 方法。此方法由 Rng 特徵所定義,而我們則是用 use rand::Rng 陳述式將此特徵引入作用域中。gen_range 方法接收兩個數字作為引數並產生一個在此範圍之間的隨機數字。這個範圍會包含下限但不會包含上限,所以我們需要指定 1101 來索取 1 到 100 之間的數字。

注意:你不可能憑空就知道該使用 crate 中的哪些特徵或是呼叫哪些方法與函式。crate 的使用方式就紀錄在每個 crate 的技術文件中。Cargo 另一大亮點就是你可以執行 cargo doc --open 命令,這會建構所有本地端依賴函式庫的技術文件,並在你的瀏覽器中開啟。舉例來說,如果你對 rand crate 的其他功能有興趣的話,你可以執行 cargo doc --open 然後點擊左側邊欄的 rand

我們在程式碼中間加上的第二行會印出祕密數字。這在開發程式時能用來測試它,不過在最終版本我們會刪除它。如果在遊戲一開始程式就印出答案的話跟本就沒有玩的必要了!

請嘗試執行程式幾次:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     Running `target/debug/guessing_game`
請猜測一個數字!
祕密數字為:7
請輸入你的猜測數字。
4
你的猜測數字:4

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
請猜測一個數字!
祕密數字為:83
請輸入你的猜測數字。
5
你的猜測數字:5

你應該會得到不同的隨機數字,而且它們都應該要在 1 到 100 的範圍內。做得好!

將猜測的數字與祕密數字做比較

現在我們有使用者的輸入與隨機數字,我們可以來比較它們了。這步驟顯示在範例 2-4。注意此程式碼還無法編譯,我們會解釋為什麼。

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --省略--
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("太小了!"),
        Ordering::Greater => println!("太大了!"),
        Ordering::Equal => println!("獲勝!"),
    }
}

範例 2-4:處理比較兩個數字後的可能數值

首先新加入的第一個部分是另一個 use 陳述式,這將 std::cmp::Ordering 型別從標準函式庫引入作用域中。就像 Result 一樣,Ordering 是另一個枚舉,但是 Ordering 的變體為 LessGreaterEqual。當你比較兩個數值時會有三種結果。

然後我們在底下加上五行程式碼來使用 Ordering 型別。cmp 方法會比較兩個數值,並能在任何可以比較的數值中進行呼叫。其取一個引用至任何你想做比較的數值,在此例中就是將 guesssecret_number 做比較。然後它會回傳我們透過 use 陳述式引入作用域的 Ordering 枚舉其中一個變體。我們使用 match 表達式來依據透過 guesssecret_number 呼叫 cmp 回傳的 Ordering 變體來決定下一步要做什麼。

match 表達式由分支(arms)所組成。分支包含一個模式(pattern)以及對應的程式碼,這在當 match 表達式開頭的數值能與該分支的模式配對時就能執行。Rust 會用 match 得到的數值依序遍歷每個分支中的模式。match 結構與模式是 Rust 中非常強大的特色,能讓你表達各種程式碼可能會遇上的情形,並確保你有將它們全部處理完。這些特色功能會在第六章與第十八章分別討論其細節。

讓我們看看在此例中使用 match 表達式時會發生什麼事。假設使用者猜測的數字是 50 而這次隨機產生的祕密數字是 38。當程式碼比較 50 與 38 時,cmp 方法會回傳 Ordering::Greater,因為 50 大於 38。match 表達式會取得 Ordering::Greater 數值並開始檢查每個分支的模式。它會先查看第一個分支的模式 Ordering::Less 並看出數值 Ordering::Greater 無法與 Ordering::Less 配對,所以它忽略該分支的程式碼,並移到下一個分支。而下個分支的模式 Ordering::Greater 能配對到 Ordering::Greater!所以該分支對應的程式碼就會執行並印出 太大了! 到螢幕上。最後 match 表達式就會結束,因為在此情境中它已經不需要再查看最後一個分支。

然而範例 2-4 的程式碼還無法編譯,讓我們嘗試看看:

$ cargo build
   Compiling libc v0.2.51
   Compiling rand_core v0.4.0
   Compiling rand_core v0.3.1
   Compiling rand v0.5.6
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `std::string::String`, found integer
   |
   = note: expected reference `&std::string::String`
              found reference `&{integer}`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game`.

To learn more, run the command again with --verbose.

錯誤的關鍵表示型別無法配對(mismatched types)。Rust 有個強力的靜態型別系統,但它也提供了型別推斷。當我們寫 let mut guess = String::new() 時,Rust 能夠推斷出 guess 應該要是 String 讓我們不必親自寫出型別。另一方面,secret_number 則是個數字型別。以下是一些可以包含數字 1 到 100 的數字型別:32 位元數字 i32、非帶號(unsigned) 32 位元數字 u32、64 位元數字 i64,以及更多等等。Rust 預設的數字型別為 i32,這就是 secret_number 的型別,除非你特地加上型別詮釋,Rust 才會推斷成不同的數字型別。此錯誤原因是因為 Rust 無法比較將字串與數字型別做比較。

所以我們要將程式從輸入讀取的 String 轉換成真正的數字型別,讓我們可以將其與祕密數字做比較。我們可以在 main 函式本體加上另一行程式碼:

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    // --省略--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    let guess: u32 = guess.trim().parse().expect("請輸入一個數字!");

    println!("你的猜測數字:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("太小了!"),
        Ordering::Greater => println!("太大了!"),
        Ordering::Equal => println!("獲勝!"),
    }
}

這行程式碼就是:

let guess: u32 = guess.trim().parse().expect("請輸入一個數字!");

我們建立了一個變數叫做 guess。小等一下,程式不是已經有個變數叫做 guess了嗎?的確是的,但 Rust 允許我們遮蔽(shadow)之前的 guess 數值成新的數值。此功能常用於當你想將一個數值從一個型別轉換成另一個型別的場合中。遮蔽讓我們可以重複使用 guess 變數名稱,而不必強迫我們得建立兩個不同的變數,舉例來說像是 guess_strguess。(第三章會詳細解釋遮蔽。)

我們將 guess 綁定給 guess.trim().parse() 表達式。表達式中的 guess 指的是原本擁有 String 來儲存輸入的 guessString 中的 trim 方法會去除開頭與結尾的任何空白字元。雖然 u32 只會包含數字字元,但使用者一定得按下 enter 才能滿足 read_line。當使用者按下 enter 時,字串結尾就會加上換行字元。舉例來說,如果使用者輸入 5 並按下 enter 的話,guess 看起來會像這樣:5\n\n 指的是「換行(newline)」,這是按下 enter 的結果。trim 方法能去除 \n,讓結果只會是 5

字串中的 parse 方法會解析字串成某種數字。因為此方法可以解析成各種數字型別,我們需要使用 let guess: u32 來告訴 Rust 我們想使用的確切數字型別。guess 後面的分號(:)告訴 Rust 我們會詮釋此變數的型別。Rust 有些內建的數字型別,這裡的 u32 是個非帶號(unsigned)的 32 位元整數。對於不大的正整數來說,這是不錯的預設選擇。你會在第三章學到其他數字型別。除此之外,在此範例程式中的 u32 詮釋與 secret_number 的比較意味著 Rust 也會將 secret_number 推斷成 u32。所以現在會有兩個相同型別的數值能做比較了!

parse 的呼叫很容易造成錯誤。舉例來說,如果字串包含 A👍% 的話,就不可能轉換成數字。因為它可能會失敗,parse 方法回傳的是 Result 型別,就和 read_line 方法一樣(在之前的「使用 Result 型別處理可能的錯誤」段落提及)。我們也會用相同的方式來處理此 Result,也就是呼叫 expect 方法。如果 parse 回傳 ResultErr 變體的話,由於它無法從字串建立數字,expect 的呼叫會讓遊戲當掉並顯示我們給予的訊息。如果 parse 能成功將字串轉成數字,它將會回傳 ResultOk 變體,而 expect 將會回傳 Ok 的內部數值。

現在讓我們執行程式!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/guessing_game`
請猜測一個數字!
祕密數字為:58
請輸入你的猜測數字。
  76
你的猜測數字:76
太大了!

不錯!儘管我們在猜測數字前加了一些空格,但程式仍能推斷出使用者猜測的是 76。多執行程式幾次來驗證不同種輸入產生的不同行為:像是正確猜出數字、猜測的數字太高或猜測的數字太低。

我們已經大致上將遊戲完成了,但使用者只能猜測一次。讓我們用迴圈來修改吧!

透過迴圈來允許多次猜測

loop 關鍵字會產生無限迴圈。我們加入此迴圈讓使用者可能有更多機會可以猜測:

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    // --省略--

    println!("祕密數字為:{}", secret_number);

    loop {
        println!("請輸入你的猜測數字。");

        // --省略--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取該行失敗");

        let guess: u32 = guess.trim().parse().expect("請輸入一個數字!");

        println!("你的猜測數字:{}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => println!("獲勝!"),
        }
    }
}

如同你所見,我們將輸入猜測提示以下的程式碼都移入迴圈中。請確保迴圈中的每一行有用四個空格來做縮排,然後再次執行程式。注意到這裡有個新問題,程式的確照我們所說的去做,但這代表它會不停地尋問要猜測的數字!看起來使用者無法離開遊戲!

使用者的確永遠可以使用快捷鍵 ctrl-c 來中斷程式。但還有其他辦法能逃離這個無限循環,如同在「將猜測的數字與祕密數字做比較」中討論 parse 時提到的,如果使用者輸入非數字答案的話,程式就會當掉。使用者可以利用此特性來離開,如以下所示:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
請猜測一個數字!
祕密數字為:59
請輸入你的猜測數字。
45
你的猜測數字:45
太小了!
請輸入你的猜測數字。
60
你的猜測數字:60
太大了!
請輸入你的猜測數字。
59
你的猜測數字:59
獲勝!
請輸入你的猜測數字。
quit
thread 'main' panicked at '請輸入一個數字!: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:999:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

輸入 quit 就能真的離開遊戲,但是其他非數字輸入也是如此。這並不是最理想的方案,我們想要在猜對數字時自動停止。

猜對後離開

讓我們加上 break 陳述式來在使用者獲勝時離開遊戲:

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    loop {
        println!("請輸入你的猜測數字。");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取該行失敗");

        let guess: u32 = guess.trim().parse().expect("請輸入一個數字!");

        println!("你的猜測數字:{}", guess);

        // --省略--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("獲勝!");
                break;
            }
        }
    }
}

獲勝! 之後加上 break 這行讓程式在使用者猜對祕密數字時可以離開迴圈。離開迴圈也意味著離開程式,因為此迴圈是 main 中的最後一個部分。

處理無效輸入

為了進一步改善遊戲體驗,當使用者的輸入不是數字時,我們不該讓程式直接當掉。遊戲程式可以忽略非數字來讓使用者繼續猜測。我們可以修改 guess 這段將 String 轉換成 u32 的程式碼,如範例 2-5 所示。

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    loop {
        println!("請輸入你的猜測數字。");

        let mut guess = String::new();

        // --省略--

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取該行失敗");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("你的猜測數字:{}", guess);

        // --省略--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("獲勝!");
                break;
            }
        }
    }
}

範例 2-5:忽略非數字的猜測並要求下一個猜測數字,而不是讓程式當掉

expect 的呼叫換成 match 表達式,通常就是從錯誤中當掉改成處理錯誤的方式。你應該還記得 parse 回傳的是 Result 型別,且 Result 是個枚舉,其變體為 OkErr。我們在此使用 match 表達式,如同我們對 cmp 方法回傳的 Ordering 處理方式一樣。

如果 parse 能成功將字串轉換成數字,它會回傳 Ok 數值內包含的結果數字。該 Ok 數值就會配對到第一個分支的模式,然後 match 表達式就會回傳 parse 產生並填入 Ok 內的 num 數值。該數字最後就會如我們所願變成我們建立的 guess 變數。

如果 parse 無法將字串轉換成數值的話,它會回傳包含與錯誤相關資訊的 Err 數值。該 Err 數值並不符合 match 的第一個分支模式 Ok(num),但它能配對到第二個分支。底線 _ 是個捕獲數值,在此例中,我們說我們想要配對到所有的 Err 數值,無論其中有什麼資訊在裡面。所以程式會執行第二條分支 continue,這告訴程式繼續 loop 下一個疊代並要求其他猜測數字。如此一來程式就能忽略所有 parse 可能會遇到的所有錯誤!

現在程式的每個部分都如我們所預期的了,讓我們試試看:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/debug/guessing_game`
請猜測一個數字!
祕密數字為:61
請輸入你的猜測數字。
10
你的猜測數字:10
太小了!
請輸入你的猜測數字。
99
你的猜測數字:99
太大了!
請輸入你的猜測數字。
foo
請輸入你的猜測數字。
61
你的猜測數字:61
獲勝!

太棒了!有了最後一項小修改,我們終於完成了猜謎遊戲。回想一下程式仍然會印出祕密數字。這在測試很有用,但在實際遊戲時就毀了樂趣了。讓我們刪除會印出祕密數字的 println!。範例 2-6 就是最終的程式碼。

檔案名稱:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    loop {
        println!("請輸入你的猜測數字。");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取該行失敗");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("你的猜測數字:{}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("獲勝!");
                break;
            }
        }
    }
}

範例 2-6:完整的猜謎遊戲程式碼

總結

此時此刻,你已經完成了猜謎遊戲。恭喜你!

此專案讓你能動手實踐並親自體驗許多 Rust 的新概念:letmatch、方法、關聯函式、外部 crate 的使用以及更多等等。在接下來陸續的章節,你將深入學習這些概念。第三章會涵蓋多數程式設計語言都有的概念,像是變數、資料型別與函式,以及如何在 Rust 中使用它們。第四章會探索所有權(ownership),這是 Rust 與其他語言最不同的特色。第五章會討論結構體(structs)與方法語法,而第六章會解釋枚舉。

常見程式設計概念

此章節涵蓋了幾乎所有程式語言都會出現的概念,以及如何在 Rust 使用。許多程式語言的核心概念都是相同的,所以此章節所介紹的概念也不是 Rust 所特有的。不過我們會用 Rust 的程式碼來討論它們,並解釋使用這些概念的慣例。

更明確來說,你將會學習到變數、基本型別、註解以及控制流程。這些基本概念會出現在每個 Rust 程式中,所以提早學習這些概念可以為你打下穩健的基礎。

關鍵字(Keywords)

Rust 程式語言和其他語言一樣會保留一系列的關鍵字(keywords)。請注意你將無法用這些字作為變數或函式名稱。大多數關鍵字都有特別意義,而你將會在你的 Rust 程式使用它們來處理不同的任務;而有一些目前則還沒有任何用途,但是在未來可能會被 Rust 加入所以作為保留。你可以在附錄 A 查看關鍵字列表。

變數與可變性

如同第二章提到的,變數預設是不可變的。這是 Rust 推動你能充分利用 Rust 提供的安全性和簡易並行性來寫程式的許多方法之一。不過,你還是有辦法能讓你的變數成為可變的。讓我們來探討為何 Rust 鼓勵你多多使用不可變,以及何時你會想要改為可變的。

當一個變數是不可變的,只要有數值綁定在一個名字上,你就無法改變其值。為了方便說明,讓我們使用 cargo new variablesprojects 目錄下產生一個新專案叫做 variables

再來在你的 variables 目錄下開啟 src/main.rs 然後覆蓋程式碼為以下內容,這是段還無法編譯的程式碼:

檔案名稱:src/main.rs

fn main() {
    let x = 5;
    println!("x 的數值為:{}", x);
    x = 6;
    println!("x 的數值為:{}", x);
}

儲存然後使用 cargo run 執行程式。你應該會收到一則錯誤訊息,如下所示:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         -
  |         |
  |         first assignment to `x`
  |         help: make this binding mutable: `mut x`
3 |     println!("x 的數值為:{}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

error: aborting due to previous error

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables`.

To learn more, run the command again with --verbose.

此範例顯示了編譯器如何協助你找到你程式碼的錯誤。雖然看到編譯器錯誤訊息總是令人感到沮喪,但這通常是為了讓你知道你的程式無法安全地完成你想讓它完成的任務。它們不代表你不是個優秀的程式設計師!有經驗的 Rustaceans 時常會與編譯器錯誤訊息打交道。

這則錯誤訊息表示錯誤發生的原因:「cannot assign twice to immutable variable x」,因為你嘗試第二次賦值給 x 變數。

當我們嘗試改變一個原先設計為不可變的變數時,能夠產生編譯時錯誤是很重要的。因為這樣的情況很容易導致程式錯誤。如果我們有一部分的程式碼在執行時認為某個數值絕對不會改變,但另一部分的程式碼卻更改了其值,那麼這就有可能讓前一部分的程式碼就可能以無法預測的方式運行。這樣的程式錯誤的起因是很難追蹤的,尤其是當第二部分的程式碼偶而才會改變其值。

在 Rust 中,編譯器會保證當你宣告一個數值不會被改變時,它就絕對不會被改變。這代表當你讀寫程式碼時,你不需要去追蹤該值可能會被改變,讓你的程式碼更容易推導。

但同時可變性也是非常有用的,變數只有預設是不可變的,就如同第二章一樣你可以在變數名稱前面加上 mut 讓它們可以成為可變的。除了允許改變其值之外,mut 向未來的讀取者表明了其他部分的程式碼將會改變此變數的數值。

舉例來說,讓我們改變 src/main.rs 成以下程式碼:

檔案內容:src/main.rs

fn main() {
    let mut x = 5;
    println!("x 的數值為:{}", x);
    x = 6;
    println!("x 的數值為:{}", x);
}

當你執行程式的話,我們會得到:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
x 的數值為:5
x 的數值為:6

當使用 mut 時,我們可以將 x 的數值從 5 改變為 6。有時候比起只有不可變變數,你會想要將某些變數改為可變的,讓它更容易編寫。

當然除了防止程式錯誤以外,這還有很多權衡取捨。舉例來說,當你擁有一個大型資料結構時,變更其值通常會比複製然後返回重新分配的實例還來的快。不過在比較小的資料結構,用函式程式語言的風格產生新的實例會比較容易思考,所以損失一些效能會比損失閱讀性來得好。

變數與常數的差異

不能夠變更數值的情況可能會讓你聯想到其他程式語言都有的概念:常數(constants)。和不可變變數一樣,常數會讓數值與名稱綁定且不允許被改變,但是不可變變數與常數還是有些差異。

首先,你無法在使用常數使用 mut,常數不是預設不可變,它們永遠都不可變。

如果你使用 const 宣告而非 let 的話,你必須指明型別。我們會在下一章「資料型別」詳細解釋型別與型別詮釋,所以現在先別擔心細節。你只需要先知道你永遠必須先詮釋常數的型別。

常數可以被定義在任一有效範圍,包含全域有效範圍。這讓它們非常有用,讓許多部分的程式碼都能夠知道它們。

最後一個差別是常數只能被常數表達式設置,而不能用函式的結果或任一在運行時產生的其他數值設置。

以下為一個常數名稱被宣告為 MAX_POINTS 的範例,它的數值被設為 100,000。(Rust 的常數命名規則為使用全部英文大寫並用底寫區隔每個單字,數值可以用底線區隔來改善可讀性):


#![allow(unused)]
fn main() {
const MAX_POINTS: u32 = 100_000;
}

在整支程式運行時,常數在它們的範圍內都是有效的,這讓它們在處理應用程式中需要被許多程式碼部份所知道的數值的情況下是非常好的選擇,像是一款遊戲中玩家能夠得到的最高分數或者光速的數值。

將會擴散到所有程式碼的數值定義為常數,對於幫助未來程式碼的維護者理解是非常好的選擇。這也讓未來需要更新數值的話,你知道需要修改寫死的地方就好。

遮蔽(Shadowing)

如同你在猜謎遊戲教學所看到的,在第二章「將猜測的數字與祕密數字做比較」你可以用之前的變數再次宣告新的變數,然後新的變數就會遮蔽之前的變數。Rustaceans 會說第一個變數被第二個變數所遮蔽了,這代表該變數被使用時會拿到第二個變數的數值。我們可以用 let 關鍵字來重複宣告相同的變數名稱來遮蔽一個變數:

檔案名稱:src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    let x = x * 2;

    println!("x 的數值為:{}", x);
}

此程式首先將 x 給予 5,然後它用 let x = 遮蔽了 x 變數取代了原本的變數變為 6。第三次的 let 陳述式一樣遮蔽了 x 讓它將原本的值乘與 2,讓 x 最終的數值為 12。當我們運行此程式時,當會輸出以下結果:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
x 的數值為:12

遮蔽與標記變數為 mut 是不一樣的,因為如果我們不小心重新賦值而沒有加上 let 關鍵字的話,是會產生編譯期錯誤的。使用 let 的話,我們可以作出一些改變,然後在這之後該變數仍然是不可變的。

另一個 mut 與遮蔽不同的地方是,我們能有效地再次運用 let 產生新的變數,可以在重新運用相同名稱時改變它的型別。舉例來說,當我們希望程式要求使用者顯示出字串間應該顯示多少空格,但同時我們又希望它被存為一個數字時,我們可以這樣做:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

這個範例是被允許的是因為第一次宣告 spaces 的變數雖然是一個字串型別,但在第二次宣告儘管用了同樣的名稱,但是我們卻能遮蔽成數字型別。遮蔽這項功能讓我們不必去宣告像是 spaces_strspaces_num,我們可以重複使用 spaces 這個變數名稱。不過,可變變數仍然是無法變更變數型別的,如果這樣做的話我們就會拿到編譯期錯誤:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

此錯誤訊息告訴我們我們不允許改變變數的型別:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables`.

To learn more, run the command again with --verbose.

現在我們講完變數了,讓我們看看它們可以擁有的資料型別吧。

資料型別

每個數值在 Rust 中都屬於某種資料型別,這告訴 Rust 何種資料被指定,好讓它能妥善處理資料。我們將討論兩種資料型別子集:純量(scalar)與複合(compound)。

請記住 Rust 是一門靜態型別語言,這代表它必須在編譯時知道所有變數的型別。編譯器通常能依據數值與我們使用的方式推導出我們想使用的型別。但有時候如果多種型別都有可能時,像是第二章的「將猜測的數字與祕密數字做比較」用到的 parseString 轉換成數字時,我們就需要像這樣加上型別詮釋:


#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("這不是數字!");
}

如果我們沒有加上型別詮釋的話,Rust 將會顯示以下錯誤訊息。這表示編譯器需要我們給予更多資訊才能夠知道我們想用何種型別:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("這不是數字!");
  |         ^^^^^ consider giving `guess` a type

error: aborting due to previous error

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations`.

To learn more, run the command again with --verbose.

你將會看到其他資料型別的各種型別詮釋。

純量型別

純量型別代表單一數值。Rust 有四種主要純量型別:整數、浮點數、布林以及字元。你應該在其他程式語言就看過它們了,讓我們來看看它們在 Rust 是怎麼使用的:

整數型別

整數是沒有小數點的數字。我們在第二章用到了一個整數型別 u32,此型別表示其擁有的數值應該是一個佔 32 位元大小的非帶號整數(帶號整數的話則是用 i 起頭而非 u)。表格 3-1 展示了 Rust 中內建的整數型別。帶號(signed)與非帶號(unsigned)的每一變體(比如 i16)都可以用來宣告一個整數數值。

表格 3-1:Rust 中的整數型別

長度帶號非帶號
8 位元i8u8
16 位元i16u16
32 位元i32u32
64 位元i64u64
128 位元i128u128
系統架構isizeusize

每個變體都可以是帶號或非帶號的,並且都有明確的大小。帶號非帶號的區別是數字能不能有負數,換句話說就是數字能否帶有正負符號,如果沒有的話那就只會出現正整數而已。就像在紙上寫數字一樣:當我們需要考慮符號時,我們就會在數字前面加上正負號;但如果我們只在意正整數的話,那它可以不帶符號。帶號數字是以二補數的方式儲存。

每一帶號變體可以儲存的數字範圍包含從 -(2n - 1) 到 2n - 1 - 1 以內的數字,n 就是該變體佔用的位元大小。所以一個 i8 可以儲存的數字範圍就是從 -(27) 到 27 - 1,也就是 -128 到 127。而非帶號可以儲存的數字範圍則是從 0 到 2n - 1,所以 u8 可以儲存的範圍是從 0 到 28 - 1,也就是 0 到 255。

另外,isizeusize 型別則是依據你程式運行的電腦架構來決定大小:如果你在 64 位元架構上的話就是 64 位元;如果你是 32 位元架構的話就是 32 位元。

你可以用表格 3-2 列的格式來寫出整數字面值(literals)。注意除了位元組(byte)的方式以外,所有的數字字面值都允許在最後面加上型別,比如說 57u8。另外也可以加上底線 _ 分隔方便閱讀,比如說 1_000

表格 3-2:Rust 中的整數字面值

數字字面值範例
十進制98_222
十六進制0xff
八進制0o77
二進制0b1111_0000
位元組(僅限u8b'A'

所以你該用哪些整數型別呢?如果你不確定的話,Rust 預設的型別通常就很好了,整數型別預設是 i32:此型別通常是最快的,甚至在 64 位元系統上也是。而你會用到 isizeusize 的主要時機是作為某些集合的索引。

整數溢位

假設你有個變數型別是 u8 可以儲存 0 到 255 的數值。如果你想要改變變數的值超出這個範圍的話,比方說像是 256,那麼就會發生整數溢位。Rust 有一些有趣的規則來處理這項行為。如果你是在除錯模式編譯的話,Rust 會包含整數溢位的檢查,造成你的程式在執行時恐慌(panic)。Rust 使用恐慌來表示程式因錯誤而結束,我們會在第九章的「對無法復原的錯誤使用 panic!段落討論更多造成恐慌的細節。

當你是在發佈模式下用 --release 來編譯的話,Rust 則不會加上整數溢位的檢查而造成恐慌。相反地,如果發生整數溢位的話,Rust 會作出二補數包裝的動作。簡單來說,超出最大值的數值可以被包裝成該型別的最低數值。以 u8 為例的話,256 會變成 0、257 會變成 1,以此類推。程式不會恐慌,但是該變數可能會得到一個不是你原本預期的數值。通常依靠整數溢位的行為仍然會被視為邏輯錯誤,如果你想要安全顯式表達這種行為的話,你可以使用標準函式庫的型別 Wrapping

浮點數型別

Rust 還有針對有小數點的浮點數提供兩種基本型別:f32f64,分別佔有 32 位元與 64 位元的大小。而預設的型別為 f64,因為現代的電腦處理的速度幾乎和 f32 一樣卻還能擁有更高的精準度。

以下為展示浮點數的範例:

檔案名稱:src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

浮點數是依照 IEEE-754 所定義的,f32 型別是單精度浮點數,而 f64 是倍精度浮點數。

數值運算

Rust 支援你所有想得到的數值型別基本運算:加法、減法、乘法、除法和取餘。以下程式碼展示出如何在 let 陳述式使用這些運算:

檔案名稱:src/main.rs

fn main() {
    // 加法
    let sum = 5 + 10;

    // 減法
    let difference = 95.5 - 4.3;

    // 乘法
    let product = 4 * 30;

    // 除法
    let quotient = 56.7 / 32.2;

    // 取餘
    let remainder = 43 % 5;
}

每一個陳述式中的表達式都使用了一個數學運算符號並計算出一個數值出來,賦值給該變數。附錄 B 有提供列表列出 Rust 所提供的所有運算子。

布林型別

如同其他多數程式語言一樣,Rust 中的布林型別有兩個可能的值:truefalse。布林值的大小為一個位元組。要在 Rust 中定義布林型別的話用 bool,如範例所示:

檔案名稱:src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // 型別詮釋的方式
}

布林值最常使用的方式之一是作為條件判斷,像是在 if 表達式中使用。我們將會在「控制流程」段落介紹如何在 Rust 使用 if 表達式。

字元型別

目前我們只討論了數字,但是 Rust 一樣也有支援字母。Rust 的 char 型別是最基本的字母型別,以下程式碼顯示了使用它的方法。(請注意到 char 字面值是用單引號賦值,宣告字串字面值時才是用雙引號)

檔案名稱:src/main.rs

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let heart_eyed_cat = '😻';
}

Rust 的 char 型別大小為四個位元組並表示為一個 Unicode 純量數值,這代表它能擁有的字元比 ASCII 還來的多。舉凡標音字母(Accented letters)、中文、日文、韓文、表情符號以及零長度空格都是 Rust char 的有效字元。Unicode 純量數值的範圍包含從 U+0000U+D7FF 以及 U+E000U+10FFFF。但是一個「字元」並不是真正的 Unicode 概念,所以你對於什麼是一個「字元」的看法可能會和 Rust 的 char 不一樣。我們將會在第八章的「透過字串儲存 UTF-8 編碼的文字」來討論此議題。

複合型別

複合型別可以組合數個數值為一個型別,Rust 有兩個基本複合型別:元組(tuples)和陣列(arrays)。

元組型別

元組是個將許多不同型別的數值合成一個複合型別的常見方法。元組擁有固定長度:一旦宣告好後,它們就無法增長或縮減。

我們建立一個元組的方法是寫一個用括號囊括起來的數值列表,每個值再用逗號分隔開來。元組的每一格都是一個獨立型別,不同數值不必是相同型別。以下範例我們也加上了型別詮釋,平時不一定要加上:

檔案名稱:src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

此變數 tup 就是整個元組,因為一個元組就被視為單一複合元素。要拿到元組中的每個獨立數值的話,我們可以用模式配對(pattern matching)來解構一個元組的數值,如以下所示:

檔案名稱:src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("y 的數值為:{}", y);
}

此程式先是建立了一個元組然後賦值給 tup,接著它用模式配對和 lettup 拆成三個個別的變數 xyz。這就叫做解構(destructuring),因為它將單一元組拆成了三個部分。最後程式將 y 的值印出來,也就是 6.4

除了用模式配對解構元組以外,我們也可以直接用句號(.)再加上數值的索引來取得元組內的元素。舉例來說:

檔案名稱:src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

此程式建立了元組 x,然後用它們個別的索引產生新的變數。和多數程式語言一樣,元組的第一個索引是 0。

陣列型別

另一種取得數個數值集合的方法是使用陣列。和元組不一樣的是,陣列中的每個型別必須是一樣的。Rust 中的陣列和一些其他語言的陣列會有點不同,因為 Rust 的陣列是固定長度的,就和元組一樣。

在 Rust 中,陣列的寫法是將數值寫在中括號內,每個數值再用逗號區隔開來:

檔案名稱:src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

當你想要你的資料被分配在堆疊(stack)而不是堆積(heap)的話,使用陣列是很好的選擇(我們會在第四章討論堆疊與堆積的內容)。或者當你想確定你永遠會取得固定長度的元素時也是。所以陣列不像向量(vector)型別那麼有彈性,向量是標準函式庫提供的集合型別,類似於陣列但允許變更長度大小。如果你不確定該用陣列或向量的話,通常你應該用向量就好。第八章將會討論更多向量的細節。

你會想用到陣列而非向量的時機,可能會像是以下要取得一年之中的每個月份的例子這樣。這樣的列表很可能永遠不需要新增或刪除月份,所以你可以選擇用陣列宣告,因為永遠只會有 12 個月份:


#![allow(unused)]
fn main() {
let months = ["一月", "二月", "三月", "四月", "五月", "六月", "七月",
              "八月", "九月", "十月", "十一月", "十二月"];
}

要詮釋陣列型別的話,你可以在中括號寫出型別和元素個數,並用分號區隔開來,如以下所示:


#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

i32 在此是每個元素的型別,在分號後面的數字 5 指的是此陣列有五個元素。

這種寫法和初始化陣列數值的另一種寫法很像:如果你想建立的陣列中每個元素數值都一樣的話,你可以指定一個數值後加上分號,最後寫出元素個數。如以下所示:


#![allow(unused)]
fn main() {
let a = [3; 5];
}

陣列 a 會包含 5 個元素,然後每個元素的初始化數值均為 3。這樣寫與 let a = [3, 3, 3, 3, 3]; 的寫法一樣,但比較簡潔。

獲取陣列元素

一個陣列是被分配在堆疊上的一整塊記憶體,你可以用索引來取得陣列的元素,比如:

檔案名稱:src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

在此範例中,變數 first 會得到數值 1,因為這是陣列索引 [0] 的數值。變數 second 則會從陣列索引 [1] 得到數值 2

無效的陣列元素存取

如果我們存取陣列之後的元素會發生什麼事呢?假設你修改範例成以下程式碼的話,雖然可以編譯通過但是在執行時則會出現錯誤:

檔案名稱:src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
    let index = 10;

    let element = a[index];

    println!("元素的數值為:{}", element);
}

使用 cargo run 執行程式會產生以下結果:

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

編譯期間沒有產生任何錯誤,但程式會產生執行時(runtime)錯誤並無法正確離開。當你嘗試使用索引存取元素時,Rust 會檢查你的索引是否小於陣列長度,如果索引大於或等於陣列長度的話,Rust 就會恐慌。

這是你第一個在實例中看到 Rust 安全原則給予的保障。在許多低階語言並不會提供這樣的檢查,所以當你提供不正確的索引時,無效的記憶體可能會被存取。Rust 會保護你免於這樣的錯誤風險,並立即離開程式,而不是允許記憶體存取並繼續。第九章將會討論更多有關 Rust 的錯誤處理方式。

函式

函式在 Rust 程式碼中無所不在。你已經見過一個語言最重要的函式了:main 函式是許多程式的入口點。此外你也看到了 fn 關鍵字能讓你宣告新的函式。

Rust 程式碼使用 snake case 式作為函式與變數名稱的慣例風格。在 snake case 中,所有的字母都是小寫,並用底線區隔單字。以下是一支包含函式定義範例的程式:

檔案名稱:src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("另一支函式。");
}

Rust 的函式定義從 fn 開始且在函式名稱後會有一組括號,大括號告訴編譯器函式本體的開始與結束位置。

我們可以輸入函式的名稱並加上括號來呼叫任何我們定義過的函式。因為 another_function 已經在程式中定義了,他就可以在 main 函式中呼叫。注意到我們是在原始碼中的 main 函式之後定義 another_function 的,我們當然也可以把它定義在前面。Rust 不在乎你的函式是在哪裡定義的,只需要知道它在某處有定義就好。

讓我們開啟一個新的專案叫做 functions 來進一步探索。請將 another_function 範例放入 src/main.rs 然後執行它。你應該會看到以下輸出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
另一支函式。

程式碼會按照 main 函式中的順序執行。首先,「Hello, world!」的訊息會先顯示出來,再來才會呼叫 another_function 並印出它的訊息。

函式參數

函式也可以被定義成擁有參數(parameters)的,這是函式簽名(signatures)中特殊的變數。當函式有參數時,你可以提供那些參數的確切數值。嚴格上來說,我們傳遞的數值會叫做引數(arguments)。但為了方便起見,通常大家不太會去在意兩者的區別。雖然函式定義時才叫參數,傳遞數值時叫做引數,但很多情況下都能被交換使用。

以下是加上參數後重新寫過的 another_function 範例:

檔案名稱:src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("x 的數值為:{}", x);
}

嘗試執行程式的話,你應該會看到以下輸出結果:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
x 的數值為:5

宣告 another_function 時有一個參數叫做 x,而 x 的型別被指定為 i32。當我們傳遞 5another_function 時,println! 巨集會將 5 置於格式化字串中的大括號的位置。

在函式簽名中,你必須宣告每個參數的型別,這是 Rust 刻意做下的設計決定:在函式定義中要求型別詮釋,代表編譯器幾乎不需要你在其他地方再提供資訊才能知道你要使用什麼型別。

如果你希望函式擁有數個參數,你可以用逗號區隔開來,像這樣:

檔案名稱:src/main.rs

fn main() {
    another_function(5, 6);
}

fn another_function(x: i32, y: i32) {
    println!("x 的數值為:{}", x);
    println!("y 的數值為:{}", y);
}

此範例建立了一個有兩個參數的函式,兩個都是 i32 型別。接著函式在印出兩個參數的數值,注意參數不必得是相同的型別,這只是我們在此範例這樣寫而已。

讓我們試著執行此程式碼,請覆蓋你的專案 functions 內的 src/main.rs 檔案內容為以上範例,然後用 cargo run 執行程式:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
x 的數值為:5
y 的數值為:6

因為我們呼叫函式時,將 5 給了 x 且將 6 給了 y,字串就會印出這些數值。

函式本體包含陳述式與表達式

函式本體是由一系列的陳述式(statements)並在最後可以選擇加上表達式(expression)來組成。目前我們只講了沒有用到表達式做結尾的函式。由於 Rust 是門基於表達式(expression-based)的語言,知道這樣的區別是很重要的。其他語言通常沒有這樣的區別,所以現在讓我們來看看陳述式和表達式有什麼不同,以及它們怎麼影響函式本體。

我們其實已經使用了很多次陳述式與表達式。陳述式(Statements)是進行一些動作的指令,且不回傳任何數值。表達式(Expressions)則是計算並產生數值。讓我們來看一些範例:

建立一個變數然後用 let 關鍵字賦值給它就是一道陳述式。在範例 3-1 中的 let y = 6; 就是個陳述式。

檔案名稱:src/main.rs

fn main() {
    let y = 6;
}

範例 3-1:包含一道陳述式的 main 函式宣告

此函式定義也是陳述式,整個範例就是本身就是一個陳述式。

陳述式不會回傳數值,因此你無法將 let 陳述式賦值給其他變數。如同以下程式碼所做的,你將會得到一個錯誤:

檔案名稱:src/main.rs

fn main() {
    let x = (let y = 6);
}

當你執行此程式時,你就會看到這樣的錯誤訊息:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: variable declaration using `let` is a statement

let y = 6 陳述式不回傳數值,所以 x 得不到任何數值。這就和其他語言有所不同,像是 C 或 Ruby,通常它們的賦值仍能回傳所得到的值。在那些語言,你可以寫 x = y = 6 同時讓 xy 都取得 6,但在 Rust 就不行。

表達式則會運算出些東西,並組合成你大部分所寫的 Rust 程式。先想想看一個簡單的數學運算比如 5 + 6,這就是個會算出 11 的表達式。表達式可以是陳述式的一部分:在範例 3-1 中 let y = 6;6 其實就是個算出 6 的表達式。呼叫函式也可以是表達式、呼叫巨集也是表達式、我們用 {} 產生的作用域也是表達式。舉例來說:

檔案名稱:src/main.rs

fn main() {
    let x = 5;

    let y = {
        let x = 3;
        x + 1
    };

    println!("y 的數值為:{}", y);
}

此表達式:

{
    let x = 3;
    x + 1
}

就是一個會回傳 4 的區塊,此值再用 let 陳述式賦值給 y。請注意到 x + 1 這行沒有加上分號,它和你目前看到的寫法有點不同,因為表達式結尾不會加上分號。如果你在此表達式加上分號的話,它就不會回傳數值。在我們繼續探討函式回傳值與表達式的同時請記住這一點。

函式回傳值

函式可以回傳數值給呼叫它們的程式碼,我們不會為回傳值命名,但我們會用箭頭(->)來宣告它們的型別。在 Rust 中,回傳值其實就是函式本體最後一行的表達式。你可以用 return 關鍵字加上一個數值來提早回傳函式,但多數函式都能用最後一行的表達式作為數值回傳。以下是一個有回傳數值的函式範例:

檔案名稱:src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("x 的數值為:{}", x);
}

five 函式中沒有任何函式呼叫、巨集甚至是 let 陳述式,只有一個 5。這在 Rust 中完全是合理的函式。請注意到函式的回傳型別也有指明,就是 -> i32。嘗試執行此程式的話,輸出結果就會像是這樣:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
x 的數值為:5

five 中的 5 就是函式的回傳值,這就是為何回傳型別是 i32。讓我們進一步研究細節,這邊有兩個重要的地方:首先這行 let x = five(); 顯示了我們用函式的回傳值作為變數的初始值。因為函式 five 回傳 5,所以這行和以下程式碼相同:


#![allow(unused)]
fn main() {
let x = 5;
}

再來,five 函式沒有參數但有定義回傳值的型別。所以函式本體只需有一個 5 就好,不需加上分號,這樣就能當做表達式回傳我們想要的數值。

讓我們在看另一個例子:

檔案名稱:src/main.rs

fn main() {
    let x = plus_one(5);

    println!("x 的數值為:{}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

執行此程式會顯示 x 的數值為:6,但如果我們在最後一行 x + 1 加上分號的話,就會將它從表達式變為陳述式。我們就會得到錯誤。

檔案名稱:src/main.rs

fn main() {
    let x = plus_one(5);

    println!("x 的數值為:{}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

編譯此程式就會產生以下錯誤:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: consider removing this semicolon

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions`.

To learn more, run the command again with --verbose.

錯誤訊息「mismatched types」就告訴了我們此程式碼的核心問題。plus_one 的函式定義說它會回傳 i32 但是陳述式不會回傳任何數值。我們用空元組 () 表示不會回傳任何值。因此沒有任何值被回傳,這和函式定義相牴觸,最後產生錯誤。在此輸出結果,Rust 提供了一道訊息來協助解決問題:它建議移除分號,這樣就能修正錯誤。

註解

所有程式設計師均致力於讓他們的程式碼易於閱讀,不過有時候額外的解釋還是需要的。這種情況下,開發者會在他們的程式碼留下一些筆記或是註解(comments),編譯器會忽略這些字,但其他人在閱讀程式碼時可能就會覺得很有幫助。

這是一個簡單地註解:


#![allow(unused)]
fn main() {
// 安安,你好
}

在 Rust 中,慣用的註解風格是用兩行斜線在加上一個空格起頭,然後註解就能一直寫到該行結束為止。如果註解會超過一行的話,你需要在每一行都加上 //,如下所示:


#![allow(unused)]
fn main() {
// 這邊處理的事情很複雜,長到
// 我們需要多行註解來能解釋!
// 希望此註解能幫助你理解。
}

註解也可以加在程式碼之後:

檔案名稱:src/main.rs

fn main() {
    let lucky_number = 7; // 幸運 777!
}

不過你會更常看到它們用用以下格式,註解會位於要說明的程式碼上一行:

檔案名稱:src/main.rs

fn main() {
    // 幸運 777!
    let lucky_number = 7;
}

Rust 還有另一種註解:技術文件註解。我們會在第十四章的「發佈 Crate 到 Crates.io」段落提到它。

控制流程

在大多數程式語言中,能夠決定依據某項條件是否為真來執行些程式碼,以及依據某項條件是否為真來重複執行些程式碼是非常基本的組成元件。在 Rust 程式碼中能讓你控制執行流程的常見方法有 if 表達式以及迴圈。

if 表達式

if 能讓你依照條件判斷對你的程式碼產生分支。基本上你提供一個條件然後就像是在說:「如果此條件符合的話,就執行這個程式碼區塊;如果沒有的話,就不要執行這段程式碼。」

請在你的 projects 目錄下建立一個新的專案叫做 branches 好讓我們來探討 if 表達式。接著請在 src/main.rs 檔案內輸入以下內容:

檔案名稱:src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("條件為真");
    } else {
        println!("條件為否");
    }
}

所有的 if 表達式都由 if 關鍵字開始再加上一個條件。在此例中的條件是判斷變數 number 是否小於 5。條件符合時所要執行的程式碼區塊被放在條件之後的大括號裡。與 if 表達式條件相關的程式碼段落有時也被稱為分支(arms),就像我們在第二章「將猜測的數字與祕密數字做比較」段落提到的 match 表達式的分支一樣。

另外,我們還可以選擇性地加上 else 表達式(就像範例寫的),讓條件不符時可以去執行另外一段程式碼。如果你沒有提供 else 表達式且條件為否的話,程式會直接略過 if 的程式碼區塊,接著執行後續的程式碼。

請嘗試執行此程式碼,你應該會看到以下輸出結果:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
條件為真

讓我們來變更 number 的值使條件變成 false,再來看看會發生什麼事:

fn main() {
    let number = 7;

    if number < 5 {
        println!("條件為真");
    } else {
        println!("條件為否");
    }
}

再跑一次程式,然後看看輸出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
條件為否

還有一件值得注意的是程式碼的條件判斷必須bool。如果條件不是 bool 的話,我們就會遇到錯誤。比方說,試試以下程式碼:

檔案名稱:src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("數字為三");
    }
}

這次 if 條件計算出數值 3,然後 Rust 丟出錯誤給我們:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches`.

To learn more, run the command again with --verbose.

錯誤訊息告訴我們 Rust 預期收到 bool 但是卻拿到整數。這和 Ruby 和 JavaScript 就不同,Rust 不會自動將非布林值型別轉換成布林值。你永遠必須顯式提供布林值給 if 作為它的條件判斷。舉例來說,如果我們希望 if 只會在數值不為 0 才執行,我們可以將 if 表達式改成以下範例:

檔案名稱:src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("數字不為零");
    }
}

執行此程式碼就會印出「數字不為零」。

使用 else if 處理多重條件

想要實現多重條件的話,你可以將 ifelse 組合成 else if 表達式。舉例來說:

檔案名稱:src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("數字可以被 4 整除");
    } else if number % 3 == 0 {
        println!("數字可以被 3 整除");
    } else if number % 2 == 0 {
        println!("數字可以被 2 整除");
    } else {
        println!("數字無法被 4、3、2 整除");
    }
}

程式有四種可能的分支,當你執行它時你應該會看到以下輸出結果:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
數字可以被 3 整除

當此程式執行時,他會依序檢查每一個 if 表達式,並執行第一個符合條件的程式碼段落。注意到雖然 6 的確可以除以 2,但我們沒有看到 數字可以被 2 整除,也沒有看到來自 else 那段的 數字無法被 4、3、2 整除。這是因為 Rust 只會執行第一個符合條件的區塊,而當它遇到時它就不會再檢查其他條件。

使用太多的 else if 表達式很容易讓你的程式碼變得凌亂,所以當你需要用到一個以上時,你可能會想要先重構程式碼看看。為此我們在第六章會介紹一個功能強大的 Rust 條件判斷結構叫做 match

let 陳述式中使用 if

因為 if 是表達式,所以我們可以像範例 3-2 這樣放在 let 陳述式的右邊。

檔案名稱:src/main.rs

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("數字結果為:{}", number);
}

範例 3-2:將 if 表達式的結果賦值給變數

變數 number 會得到 if 表達式運算出的數值。執行此程式看看會發生什麼事:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
數字結果為:5

你應該還記得程式碼區塊也可以是表達式且會回傳最後一行的數值,而且數字本身也是表達式。在此例中,if 表達式的值取決於哪段程式碼被執行。這代表可能成為最終結果的每一個 if 分支必須要是相同型別。在範例 3-2 中,各分支的型別都是 i32。如果型別不一致的話,如以下範例所示,我們會得到錯誤:

檔案名稱:src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "六" };

    println!("數字結果為:{}", number);
}

當我們嘗試編譯程式碼時,我們會得到錯誤。ifelse 分支的型別並不一致,而且 Rust 還確切指出程式出錯的地方在哪:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: if and else have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "六" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches`.

To learn more, run the command again with --verbose.

if 段落的表達式運算出整數,但 else 的區塊卻運算出字串。這樣行不通的原因是變數只能有一個型別。Rust 必須在編譯期間確切知道變數 number 的型別,這樣才能驗證它的型別在任何有使用到 number 的地方都是有效的。要是 number 只能在執行時知道的話,Rust 就沒辦法這樣做了。如果編譯器必須追蹤所有變數多種可能存在的型別,那就會變得非常複雜並無法為程式碼提供足夠的保障。

使用迴圈重複執行

重複執行同一段程式碼區塊時常是很有用的。針對這樣的任務,Rust 提供了多種產生迴圈(loops)的方式。一個迴圈會執行一段程式碼區塊,然後在結束時馬上回到區塊起始位置繼續執行。為了繼續探討迴圈,讓我們再開一個新專案 loops

Rust 提供三種迴圈:loopwhilefor。讓我們每個都嘗試看看吧。

使用 loop 重複執行程式碼

loop 關鍵字告訴 Rust 去反覆不停地執行一段程式碼直到你親自告訴它要停下來。

我們用以下範例示範,請修改你 loops 目錄下的 src/main.rs 檔案成以下程式碼:

檔案名稱:src/main.rs

fn main() {
    loop {
        println!("再一次!");
    }
}

當我們執行此程式時,我們會看到 再一次! 一直不停地重複顯示出來,直到我們手動停下程式為止。大多數的終端機都支援 ctrl-c 這個快捷鍵來中斷一支卡在無限迴圈的程式,你可以自己試試看:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/loops`
再一次!
再一次!
再一次!
再一次!
^C再一次!

^C 這個符號表示你按下了 ctrl-c。按照程式收到中斷訊號的時間點,你可能不會看到 再一次! 出現在 ^C 之後。

幸運的是 Rust 有提供另一個打破迴圈更可靠的方法。你可以在迴圈內加上 break 關鍵字告訴程式何時停止執行迴圈。回想一下我們在第二章「猜對後離開」段落就做過這樣的事,當使用者猜對正確數字而獲勝時就會離開程式。

從迴圈回傳數值

其中一種使用 loop 的用途是重試某些你覺得會失敗的動作,像是檢查一個執行緒是否已經完成其任務。不過這樣你可能就會想傳遞任務結果給之後的程式碼。要做到這樣的事,你可以在你要用來停下迴圈的 break 表達式內加上一個你想回傳數值,該值就會被停止的迴圈回傳,如以下所示:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

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

在迴圈之前,我們宣告了一個變數 counter 並初始化為 0,然後我們宣告了另一個變數 result 來取得迴圈回傳的值。在迴圈每一次的疊代中,我們將變數 counter 加上 1 並檢查它是否等於 10。如果是的話就用 break 關鍵字回傳 counter * 2。在迴圈結束後,我們用分號才結束這個賦值給 result 的陳述式。最後我們印出 result,而結果為 20。

使用 while 做條件迴圈

在程式中用條件判斷迴圈的執行通常是很有用的。當條件為真時,迴圈就繼續執行。當條件不再符合時,程式就用 break 停止迴圈。這樣的循環方法可以用 loopifelsebreak 組合出來。如果你想嘗試的話,你現在就可以自己寫寫看看。

但是這種模式非常常見,所以 Rust 有提供內建的結構稱為 while 迴圈。範例 3-3 就是使用 while 的例子:該程式會循環三次,每次計數都減一,然後在迴圈之後印出訊息並離開。

檔案名稱:src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{}!", number);

        number -= 1;
    }

    println!("升空!!!");
}

範例 3-3:使用 while 迴圈,當條件符合就持續執行程式碼

這樣消除了很多使用 loopifelsebreak 會有的巢狀結構,這樣可以更易閱讀。當條件為真的,程式碼就執行;不然的話,它就離開迴圈。

使用 for 遍歷集合

你可以用 while 來遍歷一個集合的元素,像是陣列等等。舉例來說,我們可以看看範例 3-4。

檔案名稱:src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("數值為:{}", a[index]);

        index += 1;
    }
}

範例 3-4:使用 while 遍歷集合的每個元素

程式在此對陣列的每個元素計數,它先從索引 0 開始,然後持續循環直到它抵達最後一個陣列索引為止(也就是 index < 5 不再為真)。執行此程式會印出陣列裡的每個元素:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
數值為:10
數值為:20
數值為:30
數值為:40
數值為:50

所有五個元素都如預期顯示在終端機上。儘管 index 會在某一刻達到 5,但是迴圈會在嘗試取得陣列第六個元素前就停止執行。

但這樣的方式是容易出錯的,我們可能取得錯誤的索引長度造成程式恐慌。這同時也使程式變慢,因為編譯器得在執行時的程式碼對迴圈中每次疊代的每個元素加上條件檢查。

所以更簡潔的替代方案是,你可以使用 for 迴圈來對集合的每個元素執行一些程式碼。for 迴圈的樣子就像範例 3-5 寫的這一樣。

檔案名稱:src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a.iter() {
        println!("數值為:{}", element);
    }
}

範例 3-5:使用 for 迴圈遍歷集合的每個元素

當我們執行此程式時,我們會看到和範例 3-4 一樣的結果。最重要的是,我們增加了程式的安全性,去除了造成程式錯誤的可能性。不會出現超出陣列大小或是讀取長度不足的風險。

比方說在範例 3-4 的程式碼,如果你變更陣列 a 的元素為只有 4 個,但忘記更新條件判斷為 while index < 4 的話,程式就會恐慌。使用 for 迴圈的話,我們變更陣列長度時,就不需要去記得更新其他程式碼。

for 迴圈的安全性與簡潔程度讓它成為 Rust 最常被使用的迴圈結構。就算你想執行的是依照次數循環的程式碼,像是範例 3-3 的 while 迴圈範例,多數 Rustaceans 還是會選擇 for 迴圈。要這麼做的方法是使用 Range,這是標準函式庫提供的型別,用來產生一連串的數字序列,從指定一個數字開始一直到另一個數字之前結束。

以下是我們用 for 迴圈來計數的另一種方式,它用了一個我們還沒講過的方法 rev,這可以用來反轉這個範圍:

檔案名稱:src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{}!", number);
    }
    println!("升空!!!");
}

這樣是不是看起來好讀許多?

總結

你做到了!這的確是篇大章節:你學到了變數、純量與複合資料型別、函式、註解、if 表達式以及迴圈!如果你想練習此章的概念,你可以試著打造以下程式:

  • 轉換攝氏與華氏溫度。
  • 產生第 n 個斐波那契數字。
  • 試著用重複的歌詞印出 Christmas carol 的 The Twelve Days of Christmas。

當你準備好後,我們就來探討一個其他語言不常見的概念:所有權。

理解所有權

所有權可以說是 Rust 最與眾不同的特色,這讓 Rust 不需要垃圾回收(garbage collector)就可以保障記憶體安全。因此理解 Rust 中的所有權如何運作是非常重要的。在本章節,我們將討論所有權以及一些相關的功能:借用、切片與 Rust 如何在記憶體分配資料。

什麼是所有權?

Rust 的核心特色就是所有權。雖然這項特色很容易解釋,但它卻深深影響整個語言的其他部分。

所有程式都需要在執行時管理它們使用記憶體的方式。有些語言會用垃圾回收機制,在程式執行時不斷尋找不再使用的記憶體;而有些程式,開發者必須親自分配和釋放記憶體。Rust 選擇了第三種方式:記憶體由所有權系統管理,且編譯器會在編譯時加上一些規則檢查。所有權的特性不會降低執行程式的速度。

因為所有權對許多程式設計師來說是個全新的觀念,所以的確需要花一點時間消化。好消息是隨著你越熟悉 Rust 與所有權系統的規則,你越能本能地開發出安全又高效的程式碼。加油,堅持下去!

當你理解所有權時,你就有一個穩健的基礎能夠理解那些使 Rust 獨特的功能。在本章節中,你將透過一些範例來學習所有權,我們會專注在一個非常常見的資料結構:字串。

堆疊(Stack)與堆積(Heap)

在許多程式語言中,你通常不需要去想到堆疊與堆積。但在像是 Rust 這樣的系統程式語言,資料是位於堆疊還是堆積就會有差,這會影響語言的行為也是你得作出某些特定決策的理由。在本章稍後討論所有權時,都會談到堆疊與堆積的關聯,所以這裡預先稍作解釋。

堆疊與堆積都是提供程式碼在執行時能夠使用的記憶體部分,但他們組成的方式卻不一樣。堆疊會按照它取得數值的順序依序存放它們,並以相反的順序移除數值。這通常稱為後進先出(last in, first out)。你可以把堆疊想成是盤子,當你要加入更多盤子,你會將它們疊在最上面。如果你要取走盤子的話,你也是從最上方拿走。想要從底部或中間,插入或拿走盤子都是不可行的!當我們要新增資料時,我們會稱呼為推入堆疊(pushing onto the stack),而移除資料則是叫做彈出堆疊(popping off the stack)

所有在堆疊上的資料都必須是已知固定大小。在編譯時屬於未知或可能變更大小的資料必須儲存在堆積。堆積就比較沒有組織,當你要將資料放入堆積,你得要求一定大小的空間。記憶體分配器(memory allocator)會找到一塊夠大的空位,標記為已佔用,然後回傳一個指標(pointer),指著該位置的位址。這樣的過程稱為在堆積上分配(allocating on the heap),或者有時直接簡稱為分配(allocating)就好。將數值放入堆疊不會被視為是在分配。因為指標是固定已知的大小,所以你可以存在堆疊上。但當你要存取實際資料時,你就得去透過指標取得資料。

你可以想像成是一個餐廳。當你進入餐廳時,你會告訴服務員你的團體有多少人,他就會將你們帶到足夠人數的餐桌。如果你的團體有人晚到的話,他們可以直接尋問你坐在哪而找到你。

將資料推入堆疊會比在堆積上分配還來的快,因為分配器不需要去搜尋哪邊才能存入新資料,其位置永遠在堆疊最上方。相對的,堆積就需要比較多步驟,分配器必須先找到一個夠大的空位來儲存資料,然後作下紀錄為下次分配做準備。

在堆積上取得資料也比在堆疊上取得來得慢,因為你需要用追蹤指標才找的到。現代的處理器如果在記憶體間跳轉越少的話速度就越快。讓我們繼續用餐廳做比喻,想像伺服器就是在餐廳為數個餐桌點餐。最有效率的點餐方式就是依照餐桌順序輪流點餐。如果幫餐桌 A 點了餐之後跑到餐桌 B 點,又跑回到 A 然後又跑到 B 的話,可以想像這是個浪費時間的過程。同樣的道理,處理器在處理任務時,如果處理的資料相鄰很近(就如同存在堆疊)的話,當然比相鄰很遠(如同存在堆積)來得快。要在堆積分配大量的空間同樣也很花時間。

當你的程式碼呼叫函式時,傳遞給函式的數值(可能包含指向堆積上資料的指標)與函式區域變數會被推入堆疊。當函式結束時,這些數值就會被彈出。

追蹤哪部分的程式碼用到了堆積上的哪些資料、最小化堆積上的重複資料、以及清除堆積上沒再使用的資料確保你不會耗盡空間,這些問題都是所有權系統要處理的。一旦你理解所有權後,你通常就不再需要經常考慮堆疊與堆積的問題,不過能理解所有權就是為了管理堆積有助於解釋為何它要這樣運作。

所有權規則

首先,讓我們先看看所有權規則。當我們在解釋說明時,請記得這些規則:

  • Rust 中每個數值都會有一個變數作為它的擁有者(owner)
  • 同時間只能有一個擁有者。
  • 當擁有者離開作用域時,數值就會被丟棄。

變數作用域

我們已經在第二章示範了一支 Rust 的程式。現在既然我們已經知道了基本語法,我們接下來就不再將 fn main() { 寫進程式碼範例範例中。所以你在參考時,請記得親自寫在 main 函式內。這樣一來,我們的範例可以更加簡潔,讓我們更加專注在細節而非樣板程式。

作為所有權的第一個範例,我們先來看變數的作用域(scope)。作用域是一些項目在程式內的有效範圍。假設我們有個像這樣的變數:


#![allow(unused)]
fn main() {
let s = "hello";
}

變數 s 是一個字串字面值(string literal),而字串數值是寫死在我們程式內。此變數的有效範圍是從它宣告開始一直到當前作用域結束為止。範例 4-1 註解了 s 在哪裡是有效的。

fn main() {
    {                      // s 在此處無效,因為它還沒宣告
        let s = "hello";   // s 在此開始視為有效

        // 使用 s
    }                      // 此作用域結束, s 不再有效
}

範例 4-1:變數與它在作用域的有效範圍

換句話說,這裡有兩個重要的時間點:

  • s 進入作用域時,它是有效的。
  • 它持續被視為有效直到它離開作用域為止。

目前為止,變數何時有效與作用域的關係都還跟其他程式語言相似。現在我們要以此基礎來介紹 String 型別

String 型別

要能夠解釋所有權規則,我們需要使用比第三章的「資料型別」介紹過的還複雜的型別才行。之前我們提到的型別都是儲存在堆疊上的,在作用域結束時就會從堆疊中彈出。但是我們想要觀察的是儲存在堆積上的資料,並研究 Rust 是如何知道要清理資料的。

我們會使用 String 作為範例並專注在 String 與所有權有關的部分。這些部分也適用於其他基本函式庫或你自己定義的複雜資料型別。我們會在第八章更深入探討 String

我們已經看過字串字面值(string literals),字串的數值是寫死在我們的程式內的。字串字面值的確很方便,但它不可能完全適用於我們使用文字時的所有狀況。其中一個原因是因為它是不可變的,另一個原因是並非所有字串值在我們編寫程式時就會知道。舉例來說,要是我們想要收集使用者的輸入並儲存它呢?對於這些情形,Rust 提供第二種字串型別 String。此型別是分配在堆積上的,所以可以儲存我們在編譯期間未知的一些文字。你可以從字串字面值使用 from 函式來建立一個 String,如以下所示:


#![allow(unused)]
fn main() {
let s = String::from("hello");
}

雙冒號(::)讓我們可以將 from 函式置於 String 型別的命名空間(namespace)底下,而不是取像是 string_from 這樣的名稱。我們將會在第五章的「方法語法」討論這個語法,並在第七章的「引用模組項目的路徑」討論模組(modules)與命名空間。

這種類型的字串是可以被改變的:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() 將字面值加到字串後面

    println!("{}", s); // 這會印出 `hello, world!`
}

所以這邊有何差別呢?為何 String 是可變的,但字面值卻不行?兩者最主要的差別在於它們對待記憶體的方式。

記憶體與分配

以字串字面值來說,我們在編譯時就知道它的內容,所以可以寫死在最終執行檔內。這就是為何字串字面值非常迅速且高效。但這些特性均來自於字串字面值的不可變性。不幸的是我們無法將編譯時未知大小的文字,或是執行程式時大小可能會改變的文字等對應記憶體塞進二進制檔案中。

而對於 String 型別來說,為了要能夠支援可變性、改變文字長度大小,我們需要在堆積上分配一塊編譯時未知大小的記憶體來儲存這樣的內容,這代表:

  • 記憶體分配器必須在執行時請求記憶體。
  • 我們不再需要這個 String 時,我們需要以某種方法將此記憶體還給分配器。

當我們呼叫 String::from 時就等於完成第一個部分,它的實作會請求分配一塊它需要的記憶體。這邊大概和其他程式語言都一樣。

不過第二部分就不同了。在擁有垃圾回收機制(garbage collector, GC)的語言中,GC 會追蹤並清理不再使用的記憶體,所以我們不用去擔心這件事。沒有 GC 的話,識別哪些記憶體不再使用並顯式呼叫程式碼釋放它們就是我們的責任了,就像我們請求取得它一樣。在以往的歷史我們可以看到要完成這件事是一項艱鉅的任務,如果我們忘了,那麼就等於在浪費記憶體。如果我們釋放的太早的話,我們則有可能會拿到無效的變數。要是我們釋放了兩次,那也會造成程式錯誤。我們必須準確無誤地配對一個 allocate 給剛好一個 free

Rust 選擇了一條不同的道路:當記憶體在擁有它的變數離開作用域時就會自動釋放。以下是我們解釋作用域的範例 4-1,但使用的是 String 而不是原本的字串字面值:

fn main() {
    {
        let s = String::from("hello"); // s 在此開始視為有效

        // 使用 s
    }                                  // 此作用域結束
                                       // s 不再有效
}

s 離開作用域時,我們就可以很自然地將 String 所需要的記憶體釋放回分配器。 當變數離開作用域時,Rust 會幫我們呼叫一個特殊函式。此函式叫做 drop,在這裡當時 String 的作者就可以寫入釋放記憶體的程式碼。Rust 會在大括號結束時自動呼叫 drop

注意:在 C++,這樣在項目生命週期結束時釋放資源的模式,有時被稱為資源取得即初始化(Resource Acquisition Is Initialization, RAII)。如果你已經用過 RAII 的模式,那麼你應該就會很熟悉 Rust 的 drop 函式。

這樣的模式對於 Rust 程式碼的編寫有很深遠的影響。雖然現在這樣看起來很簡單,但在更多複雜的情況下程式碼的行為可能會變得很難預測。像是當我們需要許多變數,所以得在堆積上分配它們的情況。現在就讓我們開始來探討這些情形。

變數與資料互動的方式:移動(Move)

數個變數在 Rust 中可以有許多不同方式來與相同資料進行互動。讓我們看看使用整數的範例 4-2。

fn main() {
    let x = 5;
    let y = x;
}

範例 4-2:將變數 x 的數值賦值給 y

我們大概可以猜到這做了啥:「x 取得數值 5,然後拷貝(copy)了一份 x 的值給 y。」所以我們有兩個變數 xy,而且都等於 5。這的確是我們所想的這樣,因為整數是已知且固定大小的簡單數值,所以這兩個數值 5 都會推入堆疊中。

現在讓我們看看 String 的版本:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

這和之前的程式碼非常相近,所以我們可能會認為它做的事也是一樣的:也就是第二行也會拿到一份 s1 拷貝的值給 s2。但事實上卻不是這樣。

請看看圖示 4-1 來瞭解 String 底下的架構到底長什麼樣子。一個 String 由三個部分組成,如圖中左側所示:一個指向儲存字串內容記憶體的指標、它的長度和它的容量。這些資料是儲存在堆疊上的,但圖右的內容則是儲存在堆積上。

String in memory

圖示 4-1:將數值 "hello" 賦值給 s1String 記憶體結構

長度指的是目前所使用的 String 內容在記憶體以位元組為單位所佔用的大小。而容量則是 String 從分配器以位元組為單位取得的總記憶體量。長度和容量的確是有差別的,但現在對我們來說還不太重要,你現在可以先忽略容量的問題。

當我們將 s1 賦值給 s2String 的資料會被拷貝,不過我們拷貝的是堆疊上的指標、長度和容量。我們不會拷貝指標指向的堆積資料。資料以記憶體結構表示的方式會如圖示 4-2 表示。

s1 and s2 pointing to the same value

圖示 4-2:s2 擁有一份 s1 的指標、長度和容量的記憶體結構

所以實際上的結構不會長的像圖示 4-3 這樣,如果 Rust 也會拷貝堆積資料的話,才會看起來像這樣。如果 Rust 這麼做的話,s2 = s1 的動作花費會變得非常昂貴。當堆積上的資料非常龐大時,對執行時的性能影響是非常顯著的。

s1 and s2 to two places

圖示 4-3:如果 Rust 也會拷貝堆積資料,s2 = s1 可能會長得樣子

稍早我們提到當變數離開作用域時,Rust 會自動呼叫 drop 函式並清理該變數在堆積上的資料。但圖示 4-2 顯示兩個資料指標都指向相同位置,這會造成一個問題。當 s2s1 都離開作用域時,它們都會嘗試釋放相同的記憶體。這被稱呼為雙重釋放(double free)錯誤,也是我們之前提過的錯誤之一。釋放記憶體兩次可能會導致記憶體損壞,進而造成安全漏洞。

為了保障記憶體安全,在此情況中 Rust 還會在做一件重要的事。與其嘗試拷貝分配的記憶體,Rust 會將 s1 視為無效。因此當 s1 離開作用域時,Rust 不需要釋放任何東西。請看看如果在 s2 建立之後繼續使用 s1 會發生什麼事,以下程式就執行不了:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

你會得到像這樣的錯誤,因為 Rust 會防止你使用無效的引用:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

如果你在其他語言聽過淺拷貝(shallow copy)深拷貝(deep copy)這樣的詞,拷貝指標、長度和容量而沒有拷貝實際內容這樣的概念應該就相近於淺拷貝。但因為 Rust 同時又無效化第一個變數,我們不會叫此為淺拷貝,而是稱此動作為移動(move)。在此範例我們會稱 s1 被移動s2,所以實際上發生的事長得像圖示 4-4 這樣。

s1 moved to s2

圖示 4-4:s1 無效後的記憶體結構

這樣就解決了問題!只有 s2 有效的話,當它離開作用域,就只有它會釋放記憶體,我們就完成所有動作了。

除此之外,這邊還表達了另一個設計決策:Rust 永遠不會自動將你的資料建立「深拷貝」。因此任何自動的拷貝動作都可以被視為是對執行效能影響很小的。

變數與資料互動的方式:克隆(Clone)

要是我們真的想深拷貝 String 在堆積上的資料而非僅是堆疊資料的話,我們可以使用一個常見的方法(method)叫做 clone。我們會在第五章講解方法語法,不過既然方法是很常見的程式語言功能,你很可能已經有些概念了。

以下是 clone 方法運作的範例:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

此程式碼能執行無誤,並明確作出了像圖示 4-3 這樣的行為,也就是堆積資料的確被複製了一份。

當你看到 clone 的呼叫,你就會知道有一些特定的程式碼被執行且消費可能是相對昂貴的。你可以很清楚地知道有些不同的行為正在發生。

只在堆疊上的資料:拷貝(Copy)

還有一個小細節我們還沒提到,也就是我們在使用整數時的程式碼。回想一下範例 4-2 是這樣寫的,它能執行而且是有效的:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

但這段程式碼似乎和我們剛學的互相矛盾:我們沒有呼叫 clone,但 x 卻仍是有效的,沒有移動到 y

原因是因為像整數這樣的型別在編譯時是已知大小,所以只會存在在堆疊上。所以要拷貝一份實際數值的話是很快的。這也讓我們沒有任何理由要讓 xy 建立後被無效化。換句話說,這邊沒有所謂淺拷貝與深拷貝的差別。所以這邊呼叫 clone 的話不會與平常的淺拷貝有啥不一樣,我們可以保持這樣就好。

Rust 有個特別的標記叫做 Copy 特徵(trait)可以用在標記像整數這樣存在堆疊上的型別(我們會在第十章討論什麼是特徵)。如果一個型別有 Copy 特徵的話,舊的變數在賦值後仍然會是有效的。如果一個型別有實作(implement)Drop 特徵的話,Rust 不會允許我們讓此型別擁有 Copy 特徵。如果我們對某個型別在數值離開作用域時,需要在做特別處理的話,我們對此型別標註 Copy 特徵會在編譯時期產生錯誤。想要瞭解如何為你的型別加上 Copy 的話,請參考附錄 C 「可推導的特徵」

所以哪些型別有 Copy 呢?你可以閱讀技術文件來知道哪些型別有,但基本原則是任何簡單地純量數值都可以有 Copy,且不需要分配記憶體或任何形式資源的型別也是有 Copy。以下是一些擁有 Copy 的型別:

  • 所有整數型別像是 u32
  • 布林型別 bool,它只有數值 truefalse
  • 所有浮點數型別像是 f64
  • 字元型別 char
  • 元組,不過包含的型別也都要有 Copy 才行。比如 (i32, i32) 就有 Copy,但 (i32, String) 則無。

所有權與函式

傳遞數值給函式這樣的語義和賦值給變數是類似的。傳遞變數給函式會是移動或拷貝,就像賦值一樣。範例 4-3 說明了變數如何進入且離開作用域。

檔案名稱:src/main.rs

fn main() {
    let s = String::from("hello");  // s 進入作用域

    takes_ownership(s);             // s 的值進入函式
                                    // 所以 s 也在此無效

    let x = 5;                      // x 進入作用域

    makes_copy(x);                  // x 本該移動進函式裡
                                    // 但 i32 有 Copy,所以 x 可繼續使用

} // x 在此離開作用域,接著是 s。但因為 s 的值已經被移動了
  // 它不會有任何動作

fn takes_ownership(some_string: String) { // some_string 進入作用域
    println!("{}", some_string);
} // some_string 在此離開作用域並呼叫 `drop`
  // 佔用的記憶體被釋放

fn makes_copy(some_integer: i32) { // some_integer 進入作用域
    println!("{}", some_integer);
} // some_integer 在此離開作用域,沒有任何動作發生

範例 4-3:具有所有權的函式

如果我們嘗試在呼叫 takes_ownership 後使用 s,Rust 會拋出編譯時期錯誤。這樣的靜態檢查可以免於我們犯錯。你可以試試看在 main 裡哪裡可以使用 sx,以及所有權規則如何防止你寫錯。

回傳值與作用域

回傳值一樣能轉移所有權,範例 4-4 和範例 4-3 一樣有加上註解說明。

檔案名稱:src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership 移動它的回傳值給 s1

    let s2 = String::from("hello");     // s2 進入作用域

    let s3 = takes_and_gives_back(s2);  // s2 移入 takes_and_gives_back
                                        // 該函式又將其回傳值移到 s3
} // s3 在此離開作用域並釋放
  // s2 離開作用域但已被移走,沒有任何動作發生
  // s1 離開作用域並釋放

fn gives_ownership() -> String {             // gives_ownership 會將他的回傳值
                                             // 移動給呼叫它的函式

    let some_string = String::from("hello"); // some_string 進入作用域

    some_string                              // 回傳 some_string 並移動給
                                             // 呼叫它的函式
}

// takes_and_gives_back 會取得一個 String 然後回傳它
fn takes_and_gives_back(a_string: String) -> String { // a_string 進入作用域

    a_string  // 回傳 a_string 並移動給呼叫的函式
}

範例 4-4:轉移回傳值的所有權

變數的所有權每次都會遵從相同的模式:賦值給其他變數就會移動。當擁有堆積資料的變數離開作用域時,該數值就會被 drop 清除,除非該資料被移動到其他變數所擁有。

在每個函式取得所有權在回傳所有權的確有點囉唆。要是我們可以讓函式使用一個數值卻不取得所有權呢?要是我們想重複使用同個值,但每次都要傳入再傳出實在是很麻煩。而且我們有時也會想要讓函式回傳一些它們自己產生的值。

其中一個方法是可以用元組,如範例 4-5 所示。

檔案名稱:src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("'{}' 的長度為 {}。", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 回傳 String 的長度

    (s, length)
}

範例 4-5:回傳參數的所有權

但這實在太繁瑣,而且這樣的情況是很常見的。幸運的是 Rust 有提供一個概念叫做引用(references)

引用與借用

我們在範例 4-5 使用元組的問題在於,我們必須回傳 String 給呼叫的函式,我們才能繼續在呼叫 calculate_length 之後繼續使用 String,因為 String 會被傳入 calculate_length

以下是我們定義並使用 calculate_length 時,在參數改用引用物件而非取得所有權的程式碼:

檔案名稱:src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("'{}' 的長度為 {}。", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

首先你會注意到原先變數宣告與函式回傳值會用到元組的地方都被更改了。再來注意到我們傳遞的是 &s1calculate_length,然後在定義時我們是取 &String 而非 String。這些「&」符號就是引用(references),它們允許你不必獲取所有權來引用它。以下用圖示 4-5 示意。

&String s pointing at String s1

圖示 4-5:顯示 &String s 指向 String s1 的示意圖

注意:使用 & 引用的反向動作是解引用(dereferencing),使用的是解引用運算符號 *。我們會在第八章看到一些解引用的範例並在第 15 章詳細解釋解引用。

讓我們進一步看看函式的呼叫:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("'{}' 的長度為 {}。", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&s1 語法讓我們可以建立一個指向 s1 數值的引用,但不會擁有它。因為它並沒有所有權,它所指向的資料在引用離開作用域時並不會被丟棄。

同樣地,函式簽名也是用 & 說明參數 s 是個引用。讓我們加一些註解在範例上:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("'{}' 的長度為 {}。", s1, len);
}

fn calculate_length(s: &String) -> usize { // s 個 String 的引用
    s.len()
} // s 在此離開作用域,但因為它沒有它所指向的資料的所有權
  // 沒有任何動作發生

變數 s 有效的作用域和任何函式參數的作用域一樣,但當引用離開作用域時,我們不會丟棄任何它指向的資料,因為我們沒有所有權。當函式使用引用作為參數而非實際數值時,我們不需要回傳數值來還所有權,因為我們不曾擁有過。

我們會稱呼函式用引用作為參數叫做借用(borrowing)。就像現實世界一樣,如果有人擁有每項東西,他可以借用給你。當你使用完後,你就還給他。

所以要是我們嘗試修改我們借用的東西會如何呢?請試試範例 4-6 的程式碼。直接劇透你:它執行不了的!

檔案名稱:src/main.rs

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

範例 4-6:嘗試修改借用的值

以下是錯誤訊息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- help: consider changing this to be a mutable reference: `&mut std::string::String`
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

error: aborting due to previous error

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

如同變數預設是不可變,引用也是一樣的。我們不被允許修改我們引用的值。

可變引用

我們可以修正這項錯誤,只要在範例 4-6 做一點小修改就好:

檔案名稱:src/main.rs

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

首先我們將 s 加上了 mut,然後我們用 &mut s 建立了一個可變引用,然後以 some_string: &mut String 來接收這個可變引用。

但是可變引用有個很大的限制:在特定作用域中的一個特定資料只能有一個可變引用。所以以下程式碼就會失敗:

檔案名稱:src/main.rs

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

以下是錯誤資訊:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 | 
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

這項限制允許了可變行為,但是同時也受到一定程度的約束。這通常是新 Rustaceans 遭受挫折的地方,因為多數語言都會任你去改變其值。

這項限制的好處是 Rust 可以在編譯時期就防止資料競爭(data races)。資料競爭和競爭條件(race condition)類似,它會由以下三種行為引發:

  • 同時有兩個以上的指標存取同個資料。
  • 至少有一個指標在寫入資料。
  • 沒有針對資料的同步存取機制。

資料競爭會造成未定義行為(undefined behavior),而且在執行時你通常是很難診斷並修正的。Rust 能夠阻止這樣的問題發生,因為它不會讓有資料競爭的程式碼編譯通過!

如往常一樣,我們可以用大括號來建立一個新的作用域來允許多個可變引用,只要不是同時擁有就好:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 離開作用域,所以建立新的引用也不會有問題

    let r2 = &mut s;
}

類似的規則也存在於可變引用和不可變引用的組合中,以下程式碼就會產生錯誤:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 沒問題
    let r2 = &s; // 沒問題
    let r3 = &mut s; // 很有問題!

    println!("{}, {}, and {}", r1, r2, r3);
}

以下是錯誤訊息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

哇!看來我們也不可以在擁有不可變引用的同時擁有可變引用。擁有不可變引用的使用者可不希望有人暗地裡突然改變了值!不過數個不可變引用是沒問題的,因為所有在讀取資料的人都無法影響其他人閱讀資料。

請注意引用的作用域始於它被宣告的地方,一直到它最後一次引用被使用為止。舉例來說以下程式就可以編譯,因為不可變引用最後一次的使用在可變引用宣告之前:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 沒問題
    let r2 = &s; // 沒問題
    println!("{} and {}", r1, r2);
    // r1 和 r2 從此不再使用
    
    let r3 = &mut s; // 沒問題
    println!("{}", r3);
}

不可變引用 r1r2 的作用域在 println! 之後結束。這是它們最後一次使用到的地方,也就是在宣告可變引用 r3 之前。它們的作用域沒有重疊,所以程式碼是允許的。

雖然借用錯誤有時是令人沮喪的,但請記得這是 Rust 編譯器希望提前(在編譯時而非執行時)指出潛在程式錯誤並告訴你問題的源頭在哪。這樣你就不必親自追蹤為何你的資料跟你預期的不一樣。

迷途引用

在有指標的語言中,通常都很容易不小心產生迷途指標(dangling pointer)。當資源已經被釋放但指標卻還留著,這樣的指標指向的地方很可能就已經被別人所有了。相反地,在 Rust 中編譯器會保證引用絕不會是迷途引用:如果你有某些資料的引用,編譯器會確保資料不會在引用結束前離開作用域。

讓我們來嘗試產生迷途指標,看看 Rust 怎麼產生編譯期錯誤:

檔案名稱:src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

以下是錯誤訊息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ help: consider giving it a 'static lifetime: `&'static`
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from

error: aborting due to previous error

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

此錯誤訊息包含了一個我們還沒介紹的功能:生命週期(lifetimes)。我們會在第十章詳細討論生命週期。就算我們先不管生命週期的部分,錯誤訊息仍然告訴了我們程式出錯的關鍵點:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from.

讓我們進一步看看我們的 dangle 程式碼每一步發生了什麼:

檔案名稱:src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // 回傳 String 的迷途引用

    let s = String::from("hello"); // s 是個新 String

    &s // 我們回傳 String s 的引用
} // s 在此會離開作用域並釋放,它的記憶體就不見了。
  // 危險!

因為 s 是在 dangle 內產生的,當 dangle 程式碼結束時,s 會被釋放。但我們卻嘗試回傳引用。此引用會指向一個已經無效的 String。這看起來不太優!Rust 不允許我們這麼做。

解決的辦法是直接回傳 String 就好:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

這樣就沒問題了。所有權轉移了出去,沒有任何值被釋放。

引用規則

讓我們來回顧我們討論到的引用規則:

  • 在任何時候,我們要嘛只能有一個可變引用,要嘛可以有任意數量的不可變引用。
  • 引用必須永遠有效。

接下來我們要來看看一個不太一樣的引用型別:切片(slices)。

切片型別

另一種沒有所有權的資料型別是切片(slice)。切片讓你可以引用一串集合中的元素序列,而並非引用整個集合。

以下是個小小的程式問題:寫一支接收字串的函式並回傳第一個找到的單字,如果函式沒有在字串找到空格的話,就代表整個字串就是一個單字,所以就回傳整個字串。

先來想想看函式簽名該長怎樣:

fn first_word(s: &String) -> ?

此函式 first_word 有一個參數 &String。我們不需要取得所有權,所以這是合理的。但我們該回傳啥呢?我們目前還沒有方法能夠描述一個字串的其中一部分。不過我們可以回傳該單字的最後一個索引。讓我們像範例 4-7 這樣試試看。

檔案名稱:src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

範例 4-7:函式 first_word 回傳參數 String 第一個單字最後的索引

因為我們需要遍歷 String 的每個元素並檢查該值是否為空格,我們要用 as_bytes 方法將 String 轉換成一個位元組陣列:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

接下來我們使用 iter 方法對位元組陣列建立一個疊代器(iterator):

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

我們會在第十三章討論疊代器的細節。現在我們只需要知道 iter 是個能夠回傳集合中每個元素的方法,然後 enumerate 會將 iter 的結果包裝起來回傳成元組。enumerate 回傳的元組中的第一個元素是索引,第二個才是元素的引用。這樣比我們自己計算索引還來的方便。

既然 enumerate 回傳的是元組,我們可以用模式配對來解構元組,就像在 Rust 其他地方使用的方式一樣。所以在 for 迴圈中,我們指定了一個模式讓 i 取得索引然後 &item 取得元組中的位元組。因為我們從用 .iter().enumerate() 取得引用的,所以在模式中我們用的是 & 來獲取。

for 迴圈裡面我們使用字串字面值的語法搜尋位元組是不是空格。如果我們找到空格的話,我們就回傳該位置。不然我們就用 s.len() 回傳整個字串的長度:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

我們現在有了一個能夠找到字串第一個單字結尾索引的辦法,但還有一個問題。我們回傳的是一個獨立的 usize,它套用在 &String 身上才有意義。換句話說,因為它是個與 String 沒有直接關係的數值,我們無法保證它在未來還是有效的。參考一下使用了範例 4-7 中函式 first_word 的範例 4-8:

檔案名稱:src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word 取得數值 5

    s.clear(); // 這會清空 String,這就等於 ""

    // word 仍然是數值 5 ,但是我們已經沒有相等意義的字串了
    // 擁有 5 的變數 word 現在完全沒意義!
}

範例 4-8:先儲存呼叫函式 first_word的結果再變更 String 的內容

此程式可以成功編譯沒有任何錯誤,而且我們在呼叫 s.clear() 後仍然能使用 word。因為 words 並沒有直接的關係,word 在之後仍能繼續保留 5。我們可以用 s 取得 5 並嘗試取得第一個單字。但這樣就會是程式錯誤了,因為 s 的內容自從我們賦值 5word 之後的內容已經被改變了。

要隨時留意 word 會不會與 s 的資料脫鉤是很煩瑣的且容易出錯!要是我們又寫了個函式 second_word,管理這些索引會變得非常難以管控!我們會不得不將函式簽名改成這樣:

fn second_word(s: &String) -> (usize, usize) {

現在我們得同時紀錄起始結束的索引,而且我們還產生了更多與原本數值沒辦法直接相關的計算結果。我們現在有三個非直接相關的變數需要保持同步。

幸運的是 Rust 為此提供了一個解決辦法:字串切片(String slice)。

字串切片

字串切片String 其中一部分的引用,它長得像這樣:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

這和取得整個 String 的引用相似,但是加上了 [0..5]。所以與其引用整個 String,這個只引用了一部分的String

我們可以像這樣 [起始索引..結束索引] 用中括號加上一個範圍來建立切片。起始索引 是切片的第一個位置,而 結束索引 在索引結尾之後的位置(所以不包含此值)。在內部的切片資料結構會儲存起始位置,以及 結束索引起始索引 相減後的長度。所以用 let world = &s[6..11]; 作為例子的話, world 就會是個切片,包含一個指標指向 s 第七個位元組和一個長度數值 5

圖示 4-6 就是此例的示意圖。

world containing a pointer to the 6th byte of String s and a length 5

圖示 4-6:指向部分 String 的字串切片

要是你想用 Rust 指定範圍的語法 .. 從第一個索引(也就是零)開始的話,你可以省略兩個句點之前的值。換句話說,以下兩個是相等的:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

同樣地,如果你的切片包含 String 的最後一個位元組的話,你同樣能省略最後一個數值。這代表以下都是相等的:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

如果你要獲取整個字串的切片,你甚至能省略兩者的數值,以下都是相等的:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

注意:字串切片的索引範圍必須是有效的 UTF-8 字元界限。如果你嘗試從一個多位元組字元(multibyte character)中產生字串切片,你的程式就會回傳錯誤。為了方便介紹字串切片,本章只使用了 ASCII 字元而已。 我們會在第八章的「使用 String 儲存 UTF-8 編碼的文字」做更詳盡的討論。

有了這些資訊,讓我們用切片來重寫 first_word 吧。對於「字串字面值」的的回傳型別我們會寫 &str

檔案名稱:src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

我們如同範例 4-7 一樣用判斷第一個空格取得了單字結尾的索引。當我們找到第一個空格,我們用字串的初始索引與當前空格的索引作為初始與結束索引來回傳字串切片。

現在當我們呼叫 first_word,我們就會取得一個與原本資料有直接相關的數值。此數值是由切片的起始位置即切片中的元素個數組成。

這樣函式 second_word 一樣也可以回傳切片:

fn second_word(s: &String) -> &str {

我們現在有個不可能出錯且更直觀的 API,因為編譯器會確保 String 的引用會是有效的。還記得我們在範例 4-8 的錯誤嗎?就是那個當我們取得單字結尾索引,但字串卻已清空變成無效的錯誤。那段程式碼邏輯是錯誤的,卻不會馬上顯示錯誤。要是我們持續嘗試用該索引存取空字串的話,問題才會浮現。切片可以讓這樣的程式錯誤無所遁形,並及早讓我們知道我們程式碼有問題。使用切片版本 first_word 的程式碼的話就會出現編譯期錯誤:

檔案名稱:src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // 錯誤!

    println!("第一個單字為:{}", word);
}

以下是錯誤訊息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 | 
18 |     s.clear(); // 錯誤!
   |     ^^^^^^^^^ mutable borrow occurs here
19 | 
20 |     println!("第一個單字為:{}", word);
   |                                       ---- immutable borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership`.

To learn more, run the command again with --verbose.

回憶一下借用規則,要是我們有不可變引用的話,我們就不能取得可變引用。因為 clear 會縮減 String,它必須是可變引用。這樣一來 Rust 就不允許,並讓編譯失敗。Rust 不僅讓我們的 API 更容易使用,還想辦法讓所有錯誤在編譯期就消除!

字串字面值就是切片

回想一下我們講說字串字面值是怎麼存在二進制檔案的。現在既然我們已經知道切片,我們就能知道更清楚理解字串字面值:


#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

此處 s 的型別是 &str:它是指向二進制檔案某部份的切片。這也是為何字串字面值是不可變的,&str 是個不可變引用。

字串切片作為參數

知道你可以取得字面值的切片與 String 數值後,我們可以再改善一次 first_word。也就是它的簽名表現:

fn first_word(s: &String) -> &str {

較富有經驗的 Rustacean 會用範例 4-9 的方式編寫函式簽名,因為這讓該函式可以同時接受 &String&str 的數值。

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word 適用於 `String` 的切片
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word 適用於字串字面值
    let word = first_word(&my_string_literal[..]);

    // 因為字串字面值本來就是切片
    // 沒有切片語法也是可行的!
    let word = first_word(my_string_literal);
}

範例 4-9:使用字串切片作為參數 s 來改善函式 first_word

如果我們有字串字面值的話,我們可以直接傳遞。如果我們有 String 的話,我可以們傳遞整個 String 的切片。定義函式的參數為字串字面值而非 String 可以讓我們的 API 更通用且不會失去去任何功能:

檔案名稱:src/main.rs

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word 適用於 `String` 的切片
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word 適用於字串字面值
    let word = first_word(&my_string_literal[..]);

    // 因為字串字面值本來就是切片
    // 沒有切片語法也是可行的!
    let word = first_word(my_string_literal);
}

其他切片

字串切片如你所想的一樣是特別針對字串的。但是我們還有更通用的切片型別。請考慮以下陣列:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

就像我們引用一部分的字串一樣,我們可以這樣引用一部分的字串:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];
}

此切片的型別為 &[i32],它和字串運作的方式一樣,儲存了切片的第一個元素以及總長度。你以後會對其他集合也使用這樣的切片。我們會在第八章討論這些集合的更多細節。

總結

所有權、借用與切片的概念讓 Rust 可以在編譯時期就確保記憶體安全。Rust 程式語言讓你和其他程式語言一樣控制你的記憶體使用方式,但是會在擁有者離開作用域時自動清除擁有的資料,讓你不必在編寫或除錯額外的程式碼。

所有權影響了 Rust 很多其它部分執行的方式,所以我們在書中之後討論這些概念。讓我們繼續到第五章,看看如何用 struct 將資料組合在一起。

透過結構體組織相關資料

struct結構體(structure)是個讓你命名並封裝數個相關數值為單一組合的自定型別。如果你熟悉物件導向語言的話,struct 就像是物件的資料屬性。在本章節,我們會比較元組與結構體的差別,介紹如何使用結構體,並討論如何定義結構體資料的方法與相關函式行為。結構體與將會在第六章提到的枚舉(enum)是 Rust 產生新型別的基本元件,它們能充分利用 Rust 的編譯時型別檢查。

定義與實例化結構體

結構體(Structs)和我們在第三章討論過的元組類似。和元組一樣,結構體的每個部分可以是不同的型別。但與元組不同的地方是,你必須為每個資料部分命名以便表達每個數值的意義。因為有了這些名稱,結構體通常比元組還來的有彈性:你不需要依賴資料的順序來指定或存取實例中的值。

欲定義結構體,我們輸入關鍵字 struct 並為整個結構體命名。結構體的名稱需要能夠描述其所組合出的資料意義。然後在大括號內,我們對每個資料部分定義名稱與型別,我們會稱為欄位(fields)。舉例來說,範例 5-1 定義了一個儲存使用者帳號的結構體。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {}

範例 5-1:User 結構體定義

要在我們定義後使用該結構體,我們可以指定每個欄位的實際數值來建立結構體的實例(instance)。要建立實例的話,我們先寫出結構體的名稱再加上大括號,裡面會包含數個 key: value 的配對。key 是每個欄位的名稱,而 value 就是你想給予欄位的數值。欄位的順序可以不用和定義結構體時的順序一樣。換句話說,結構體的定義比較像是型別的通用樣板,然後實例會依據此樣板插入特定資料來將產生型別的數值。比如說,我們可以像範例 5-2 這樣宣告一個特定使用者。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };
}

範例 5-2:產生一個 User 結構體的實例

要取得結構體中特定數值的話,我們可以使用句點。如果我們只是想要此使用者的電子郵件信箱,我們可以在任何我們想使用此值的地方使用 user1.email。如果該實例可變的話,我們可以使用句點並賦值給該欄位來改變其值。範例 5-3 顯示了如何改變一個可變 User 實例中 email 欄位的值。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let mut user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    user1.email = String::from("[email protected]");
}

範例 5-3:改變 Useremail 欄位的值

請注意整個實例必須是可變的,Rust 不允許我們只標記特定欄位是可變的。再來,就像任何表達式一樣,我們可以在函式本體最後的表達式中,建立一個新的結構體實例作為回傳值。

範例 5-4 展示了 build_user 函式會依據給予的電子郵件和使用者名稱來回傳 User 實例。而 active 欄位取得數值 truesign_in_count 取得數值 1

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("[email protected]"),
        String::from("someusername123"),
    );
}

範例 5-4:build_user 函式取得電子郵件與使用者名稱並回傳 User 實例

函式參數名稱與結構體欄位名稱相同是非常合理的,但是要重複寫 emailusername 的欄位名稱與變數就有點冗長了。如果結構體有更多欄位的話,重複寫這些名稱就顯得有些煩人了。幸運的是,我們的確有更方便的語法!

當變數與欄位名稱相同時使用欄位初始化簡寫語法

由於範例 5-4 的參數名稱與結構體欄位名稱相同,我們可以使用欄位初始化簡寫(field init shorthand)語法來重寫 build_user,讓它的結果相同但不必重複寫出 emailusername,如範例 5-5 所示。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("[email protected]"),
        String::from("someusername123"),
    );
}

範例 5-5:build_user 函式使用欄位初始化簡寫,因為參數 emailusername 結構體欄位相同

在此我們建立了 User 結構體的實例,它有一個欄位叫做 email。我們希望用 build_user 函式中的參數 email 賦值給 email 欄位。然後因為 email 欄位與 email 參數有相同的名稱,我們只要寫 email 就好,不必寫 email: email

使用結構體更新語法從其他結構體建立實例

通常我們也會從舊的實例來產生新的實例,不過修改一些欄位數值,這時你可以使用結構體更新語法(struct update syntax)

首先範例 5-6 顯示了我們沒有使用更新語法來建立新的 User 實例 user2。我們設置了新的數值給 emailusername,但其他欄位就使用我們在範例 5-2 建立的 user1 相同的值。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("[email protected]"),
        username: String::from("anotherusername567"),
        active: user1.active,
        sign_in_count: user1.sign_in_count,
    };
}

範例 5-6:從 user1 一些值中建立新的 User 實例

使用結構體更新語法,我們可以用較少的程式碼達到相同的效果,如範例 5-7 所示。.. 語法表示剩下沒指明的欄位都會取得與所提供的實例相同的值。

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("[email protected]"),
        username: String::from("anotherusername567"),
        ..user1
    };
}

範例 5-7:對新的 User 實例設置新的 emailusername 數值,但其他欄位使用與 user1 變數實例中欄位相同的值

範例 5-7 的程式碼一樣產生了有不同 emailusernameuser2 實例,但是有與 user1 相同的 activesign_in_count

使用無名稱欄位的元組結構體來建立不同型別

你還可以定義結構體讓它長得像是元組那樣,我們稱作元組結構體(tuple structs)。元組結構體仍然有定義整個結構的名稱,但是它們的欄位不會有名稱,它們只會有欄位型別而已。元組結構體的用途在於當你想要為元組命名,好讓它跟其他不同型別的元組作出區別,以及對常規結構體每個欄位命名是冗長且不必要的時候。

要定義一個元組結構體,一樣先從 struct 關鍵字開始,其後再接著要定義的元組。舉例來說,以下是兩個使用元組結構體定義的 ColorPoint

fn main() {
    struct Color(i32, i32, i32);
    struct Point(i32, i32, i32);

    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

注意 blackorigin 屬於不同型別,因為它們是不同的元組結構體實例。每個你定義的結構體都是專屬於自己的型別,就算它們的欄位型別一摸一樣。舉例來說,一個參數為 Color 的函式就無法接受 Point 引數,就算它們的型別都是三個 i32 的組合。除此之外,元組結構體實例的行為和元組類似:你可以將它們解構為獨立部分,你也可以使用 . 加上索引來取得每個數值等等。

無任何欄位的類單元結構體

你也可以定義沒有任何欄位的結構體!這些叫做類單元結構體(unit-like structs),因為它們的行為就很像 () 單元型別(unit type)。類單元結構體很適合用在當你要實作一個特徵(trait)或某種型別,但你沒有任何需要儲存在型別中的資料。我們會在第十章討論特徵。

結構體資料的所有權

在範例 5-1 的 User 結構體定義中,我們使用了擁有所有權的 String 型別,而不是 &str 字串切片型別。這邊是故意這樣選擇的,因為我們希望解構體的實例可以擁有它所有的資料,並在整個結構體都有效時資料也是有效的。

要在結構體中儲存別人擁有的資料引用是可行的,但這會用到生命週期(lifetimes),我們在第十章才會談到。生命週期能確保資料引用在結構體存在期間都是有效的。要是你沒有使用生命週期來用結構體儲存引用的話,會如以下出錯:

檔案名稱:src/main.rs

struct User {
    username: &str,
    email: &str,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: "[email protected]",
        username: "someusername123",
        active: true,
        sign_in_count: 1,
    };
}

編譯器會抱怨它需要生命週期標記:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:2:15
  |
2 |     username: &str,
  |               ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
 --> src/main.rs:3:12
  |
3 |     email: &str,
  |            ^ expected lifetime parameter

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs`.

To learn more, run the command again with --verbose.

在第十章,我們將會討論如何修正這樣的錯誤,好讓你可以在結構體中儲存引用。但現在的話,我們先用有所有權的 String 而非 &str 引用來避免錯誤。

使用結構體的程式範例

為了瞭解我們何時會想要使用結構體,讓我們來寫一支計算長方形面積的程式。我們會先從單一變數開始,再慢慢重構成使用結構體。

讓我們用 Cargo 建立一個新的專案 rectangles ,它將接收長方形的長度與寬度,然後計算出長方形的面積。範例 5-8 展示了在我們專案底下 src/main.rs 用其中一種方式寫出來的小程式。

檔案名稱:src/main.rs

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "長方形的面積為 {} 平方像素。",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

範例 5-8:使用變數 width 和 height 計算長方形面積

現在使用 cargo run 執行程式:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/structs`
長方形的面積為 1500 平方像素。

雖然範例 5-8 可以執行並呼叫 area 函式計算出長方形的面積,但我們可以做得更好。寬度與長度是互相關聯的,因為它們在一起剛好定義了一個長方形。

此程式碼的問題在 area 的函式簽名就能看出來:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "長方形的面積為 {} 平方像素。",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

area 函式應該要計算長方形的面積,但是我們寫的函式有兩個參數。參數之間是有關聯的,但是它在我們的程式中沒有表現出來。要是能將寬度與長度組合起來的話,會更容易閱讀與管理。我們可以使用我們在第三章提到的「元組型別」

使用元組重構

範例 5-9 展示了我們的程式用元組的另一種寫法。

檔案名稱:src/main.rs

fn main() {
    let rect1 = (30, 50);

    println!(
        "長方形的面積為 {} 平方像素。",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

範例 5-9:使用元組指定長方形的寬度與長度

一方面來說,此程式的確比較好。元組讓我們增加了一些結構,而我們現在只需要傳遞一個引數。但另一方面來說,此版本的閱讀性反而更差。元組無法命名它的元素,所以我們在計算時反而更難讀懂,我們傳得只是元組的索引。

我們在計算面積時,哪個值是寬度還是長度的確不重要。但如果我們要顯示出來的話,這就很重要了!我們會需要記住元組索引 0width 然後元組索引 1height。如果有其他人要維護這段程式碼的話,他就也得知道並記住這件事才行。但事實上是我們很常忘記這樣數值的意義並導致錯誤發生,因為我們無法從程式碼推導出資料的意義。

使用結構體重構:賦予更多意義

我們可以用結構體來為資料命名以賦予其意義。我們可以將元組轉換成一個有整體名稱且內部資料也都有名稱的資料型別,如範例 5-10 所示。

檔案名稱:src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "長方形的面積為 {} 平方像素。",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

範例 5-10:定義 Rectangle 結構體

我們在此定義了一個結構體叫做 Rectangle。在大括號內,我們定義了 widthheight 的欄位,兩者型別皆為 u32。然後在 main 中,我們建立了一個寬度為 30 長度為 50 的 Rectangle 實例。

現在我們的 area 函式使需要一個參數 rectangle,其型別為 Rectangle 結構體實例的不可變借用。如同第四章提到的,我們希望借用結構體而非取走其所有權。這樣一來,main 能保留它的所有權並讓 rect1 繼續使用,這也是為何我們要在要呼叫函式的簽名中使用 &

area 函式能夠存取 Rectangle 中的 widthheight 欄位。我們的 area 函式簽名由可以表達出我們想要做的事情了:使用 widthheight 欄位來計算 Rectangle 的面積。這能表達出寬度與長度之間的關係,並且給了它們容易讀懂的名稱,而不是像元組那樣用索引 01。這樣清楚多了。

使用推導特徵實現更多功能

現在要是能夠在我們除錯程式時能夠印出 Rectangle 的實例並看到它所有的欄位數值就更好了。範例 5-11 嘗試使用我們之前章節提到的 println! 巨集,但是卻無法執行。

檔案名稱:src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}

範例 5-11:嘗試印出 Rectangle 實例

當我們編譯此程式碼時,我們會得到以下錯誤訊息:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

println! 巨集預設可以做各式各樣的格式化,大括號告訴 println! 要使用 Display 特徵的格式化方式:其輸出結果是用來給最終使用者使用的。我們目前遇過的基本型別預設都會實作 Display,因為它們也只有一種顯示方式(像是 1)能夠給使用者。但是對結構體來說 println! 要怎麼格式化輸出結果就會有點不明確了,因為顯示的方式就很有多種。是要加上頓號嗎?是要印出大括號嗎?所有的欄位都要顯示出來嗎?基於這些不確定因素,Rust 不會去猜我們要的是什麼,所以結構體預設並沒有 Display 的實作。

如果我們繼續閱讀錯誤訊息,我們會得到一些有幫助的資訊:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

讓我們來試試看吧!println! 巨集的呼叫方式現在看起來應該會像這樣 println!("rect1 is {:?}", rect1);。在 println! 內加上 :? 這樣的標記指的是我們想要使用 Debug 特徵來作為輸出格式方式。Debug 特徵讓我們能印出對開發者有幫助的資訊,好讓我們在除錯程式時可以看到它的數值。

但是要是編譯這樣的程式的話,哎呀!我們卻還是會得到錯誤:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Debug`

不過同樣地,編譯器又給了我們有用的資訊:

   = help: the trait `std::fmt::Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`

Rust 的確有印出除錯資訊的功能,但是我們要針對我們的結構體顯式實作出來才會有對應的功能。為此我們可以在結構體前加上 #[derive(Debug)],如範例 5-12 所示。

檔案名稱:src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

範例 5-12:加上推導(derive) Debug 特徵的標記並印出 Rectangle 實例的格式化資訊

現在當我們執行程式,我們不會在得到錯誤了,而且我們可以看到格式化後的輸出結果:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/structs`
rect1 is Rectangle { width: 30, height: 50 }

漂亮!雖然這不是非常好看的輸出格式,但是它的確顯示了實例中所有的欄位數值,這對我們除錯時會非常有用。不過如果我們的結構體非常龐大的話,我們會希望輸出格式可以比較好閱讀。為此我們可以在 println! 的字串使用 {:#?} 而非 {:?}。當我們使用 {:#?} 風格的話,輸出結果會長得像這樣:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/structs`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Rust 提供了一些特徵並讓我們可以用 derive 標記來為自定型別加入些實用的功能。這類的特徵與其行為都列在附錄 C。我們會在第十章介紹如何定義自定特徵,且如何實作這些特徵的自訂行為。

我們的函式 area 最後就非常清楚明白了,它只會計算長方形的面積。這樣的行為要是能夠緊貼著我們的 Rectangle 結構體,因為這樣一來它就不會相容於其他型別。讓我們看看我們如何繼續重構我們的程式碼,接下來我們可以將函式 area 轉換為 Rectangle 型別的方法(method)

方法語法

方法(Methods)和函式類似,它們都用 fn 關鍵字並加上它們名稱來宣告,它們都有參數與回傳值,然後它們包含一些程式碼能夠在其他地方呼叫它們。不過,方法與函式不同的地方在於它們是針對結構體定義的(或是枚舉和特徵物件,我們會在第六章與第十七章分別介紹它們),且它們第一個參數永遠是 self,這代表的是呼叫該方法的結構體實例。

定義方法

讓我們把 Rectangle 作為參數的 area 函式轉換成定義在 Rectangle 內的 area 方法,如範例 5-13 所示。

檔案名稱:src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "長方形的面積為 {} 平方像素。",
        rect1.area()
    );
}

範例 5-13:在 Rectangle 中定義 area 方法

要定義 Rectangle 中的方法,我們先從 impl(implementation) 區塊開始。再來將 area 移入 impl 的大括號中,並將簽名中的第一個參數(在此例中是唯一一個)與其本體中用到的地方改成 self。在 main 中我們原先使用 rect1 作為引數呼叫的 area,可以改成使用方法語法(method syntax)來呼叫 Rectanglearea 方法。方法語法在實例後面呼叫,我們在其之後加上句點、方法名稱、括號然後任何所需的引數。

area 的簽名中,我們使用 &self 而非 rectangle: &Rectangle,這是因為此方法位於 impl Rectangle 底下,Rust 知道 self 的型別為 Rectangle。請注意我們仍然在 self 使用 &,如同我們之前用的 &Rectangle。方法可以取走 self 的所有權、像這裡一樣借用不可變的 self 或借用可變的 self,如同其他參數一樣。

我們之所以選擇 &self 的原因和我們在之前函式版本的 &Rectangle 一樣,我們不想取得所有權,只想讀取結構體的資料,而非寫入它。如果我們想要透過方法改變實例的數值的話,我們會使用 &mut self 作為第一個參數。而只使用 self 取得所有權的方法更是非常少見,這種使用技巧通常是為了想改變 self 成你想要的樣子,並且希望能避免原本被改變的實例繼續被呼叫。

使用方法而非函式最大的好處是,除了可以使用方法語法而不必在方法簽名重複 self 的型別之外,其更具組織性。我們將所有一個型別所能做的事都放入 impl 區塊中了,而不必讓未來的使用者在茫茫函式庫中尋找 Rectangle 的功能。

-> 運算子跑去哪了?

在 C 與 C++ 中,我們有兩種呼叫方式的運算元:我們會用 . 來直接呼叫物件的方法;用 -> 來呼叫需要先解引用的物件。換句話說,如果 object 是指標的話,object->something() 就會像是(*object).something()

Rust 沒有提供 -> 這樣的運算子。相反地 Rust 有個功能叫做自動引用與解引用(automatic referencing and dereferencing)。呼叫方法是 Rust 少數會有這樣行為的地方。

運作方式如下:當你呼叫方法像是 object.something() 時,Rust 會自動加上&&mut*,以便符合方法簽名。換句話說,以下範例是相同的:


#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

第一個呼叫簡潔多了,這種自動引用的行為之所以可行是因為方法有明確的 self 引用型別。依據接收者的方法名稱,Rust 可以知道該方法是在讀取(&self)、可變的(&mut self)或是會消耗的(self)。而 Rust 之所以允許借用方法接收者成隱式的原因,是因為這可以讓所有權更易讀懂。

擁有更多參數的方法

讓我們來練習再實作另一個 Rectangle 的方法。這次我們要 Rectangle 的實例可以接收另一個 Rectangle 實例,要是 self 本身可以包含另一個 Rectangle 的話我們就回傳 true,不然的話就回傳 false。也就是我們希望定一個方法 can_hold 如範例 5-14 所示。

檔案名稱:src/main.rs

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("rect1 能容納 rect2 嗎?{}", rect1.can_hold(&rect2));
    println!("rect1 能容納 rect3 嗎?{}", rect1.can_hold(&rect3));
}

範例 5-14:使用一個還沒定義完的方法 can_hold

然後我們預期的輸出結果會如以下所示,因為 rect2 的兩個維度都比 rect1 小,但 rect3rect1 寬:

rect1 能容納 rect2 嗎?true
rect1 能容納 rect3 嗎?false

我們知道我們要定義方法的話,它一定得在 impl Rectangle 區塊底下。方法的名稱會叫做 can_hold。它會取得另一個 Rectangle 的不可變引用作為參數。我們可以從程式碼呼叫方法的地方來知道參數的可能的型別:rect1.can_hold(&rect2) 傳遞了 &rect2,這是一個 rect2 的不可變引用,同時也是 Rectangle 的實例。這是合理的,因為我們只需要讀取 rect2(而不是寫入,寫入代表我們需要可變引用),且我們希望 main 能夠保持 rect2 的所有權,好讓我們之後能在繼續使用它來呼叫 can_hold 方法。can_hold 的回傳值會是布林值,然後實作細節會是檢查 self 的寬度與長度是否都大於其他 Rectangle 的寬度與長度。讓我們加入範例 5-13 的 can_hold 方法到 impl 區塊中,如範例 5-15 所示。

檔案名稱:src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("rect1 能容納 rect2 嗎?{}", rect1.can_hold(&rect2));
    println!("rect1 能容納 rect3 嗎?{}", rect1.can_hold(&rect3));
}

範例 5-15:在 Rectangle 中實作了取得其他 Rectangle 作為參數的 can_hold 方法

當我們用範例 5-14 的 main 函式執行此程式碼的話,我們會得到預期的輸出結果。方法可以在參數 self 之後接收更多參數,而那些參數就和函式中的參數用法一樣。

關聯函式

impl 區塊另一個實用的功能是,我們允許在 impl 內定義函式且無需以 self 作為參數。這叫做關聯函式(associated functions),因為它們與結構體是相關的。它們仍然是函式而非方法,因為它們沒有用到結構體的實例。你已經用到了 String::from 此關聯函式。

關聯函式很常用作建構子,來產生新的結構體實例。舉例來說,我們可以提供一個只接收一個維度作為參數的關聯函式,讓它賦值給寬度與長度,讓我們可以用 Rectangle 來產生正方形,而不必提供兩次相同的值:

檔案名稱:src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

要呼叫關聯函式的話,我們使用 :: 語法並加上結構體的名稱。比方說 let sq = Rectangle::square(3);。此函式用結構體名稱作為命名空間,:: 語法可以用在關聯函式以及模組的命名空間,我們會在第七章介紹模組。

多重 impl 區塊

每個結構體都允許有數個 impl 區塊。舉例來說,範例 5-15 與範例 5-16 展示的程式碼是一樣的,它讓每個方法都有自己的 impl 區塊。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("rect1 能容納rect2?{}", rect1.can_hold(&rect2));
    println!("rect1 能容納rect3?{}", rect1.can_hold(&rect3));
}

範例 5-16:使用多重 impl 來重寫範例 5-15

這邊我們的確沒有將方法拆為 impl 區塊的理由,不過這樣的語法是合理的。我們會在第十章介紹泛型型別與特徵,看到多重 impl 區塊是非常實用的案例。

總結

結構體讓你可以自訂對你的領域有意義的型別。使用結構體的話,你可以讓每個資料部分與其他部分具有相關性,並為每個部分讓程式更好讀懂。方法讓你可以為你的結構體實例指定特定行為,然後關聯函式讓你可以在沒有實例的情況下,將特定功能置入結構體的命名空間。

但是結構體並不是自訂型別的唯一方法:讓我們看下去 Rust 的枚舉功能,讓你的工具箱可以再多一項可以使用的工具。

枚舉與模式配對

在本章節中,我們將討論枚舉(enumerations),有時也被簡寫為 enums。枚舉讓你定義一個能夠列舉其可能變體(variants)的型別。首先,我們會定義並使用枚舉來展示枚舉如何將其數據組織起來。再來,我們會來探討一個特定的實用枚舉:Option,其代表該值為某些東西不然就是什麼都沒有。然後我們會看看 match 表達式的模式配對是怎麼運作的,讓它能夠針對枚舉中不同數值執行不同的程式碼。最後,我們會介紹 if let 這個結構,能讓你處理用簡潔又方便的方式處理枚舉。

枚舉是許多語言中都有提供的功能,不過它們能做的事在不同語言而異。Rust 的枚舉最接近於函式語言的代數型別(algebraic data types),像是 F#、OCaml 與 Haskell。

定義枚舉

讓我們看一個程式碼表達的例子,來看看為何此時用枚舉會比結構體更恰當且實用。假設我們要使用 IP 位址,而且現在有兩個主要的標準能使用 IP 位址:IPv4 與 IPv6。這些是我們的程式碼可能會遇到的 IP 位址,我們可以枚舉(enumerate)出所有可能的變體,這正是枚舉的由來。

任何 IP 位址可以是第四版或第六版,但不是同時存在。IP 位址這樣的特性非常適合使用枚舉資料結構,因為枚舉的值只能是其中一個變體。第四版與第六版同時都屬於 IP 位址,所以當有程式碼要處理任何類型的 IP 位址時,它們都應該被視為相同型別。

要表達這樣的概念,我們可以定義 IpAddrKind 枚舉和列出 IP 位址可能的類型 V4V6。這些稱為枚舉的變體(variants):

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind 現在成了能在我們程式碼任何地方使用的自訂資料型別。

枚舉數值

我們可以像這樣建立兩個不同變體的 IpAddrKind 實例:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

注意變體會位於枚舉命名空間底下,所以我們用兩個冒號來標示。這樣的好處在於 IpAddrKind::V4IpAddrKind::V6 都是同型別 IpAddrKind。比方說,我們就可以定義一個接收任 IpAddrKind 的函式:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

然後我們可以用任意變體呼叫此函式:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

使用枚舉還有更多好處。我們再進一步想一下我們的 IP 位址型別還沒有辦法儲存實際的 IP 位址資料,我們現在只知道它是哪種類型。考慮到你已經學會第五章的結構體,你應該會像範例 6-1 這樣解決問題。

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

範例 6-1:使用 struct 儲存 IP 位址的資料與 IpAddrKind 的變體

我們在這裡定義了一個有兩個欄位的結構體 IpAddr:欄位 kind 擁有 IpAddrKind(我們上面定義過的枚舉)型別,address 欄位則是 String 型別。再來我們有兩個此結構體的實例。第一個 home 擁有 IpAddrKind::V4 作為 kind 的值,然後位址資料是 127.0.0.1。第二個實例 loopback 擁有 IpAddrKind 另一個變體 V6 作為 kind 的值,且有 ::1 作為位址資料。我們用結構體來組織 kindaddress 的值在一起,讓變體可以與數值相關。

我們可以用另一種更簡潔的方式來定義枚舉就好,而不必使用結構體加上枚舉。枚舉內的每個變體其實都能擁有數值。以下這樣新的定義方式讓 IpAddrV4V6 都能擁有與其相關的 String 數值:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

我們將資料直接附加到枚舉的每個變體上,這樣就不再用結構體。

改使用枚舉而非結構體的話還有另一項好處:每個變體可以擁有不同型別與資料的數量。第四版的 IP 位址永遠只會有四個 0 到 255 的數字部分,如果我們想要讓 V4 儲存四個 u8,但 V6 位址仍保持 String 不變的話,我們在結構體是無法做到的。枚舉可以輕鬆勝任:

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

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

我們展示了許多種定義儲存第四版與第六版 IP 位址資料結構的方式,不過需要儲存 IP 位址並編碼成不同類型的案例實在太常見了,所以標準函式庫已經幫我們定義好了!讓我們看看標準函式庫是怎麼定義 IpAddr 的:它有和我們一模一樣的枚舉變體,不過變體各自儲存的資料是另外兩個不同的結構體,兩個定義的內容均不相同:


#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --省略--
}

struct Ipv6Addr {
    // --省略--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

此程式碼展示了你可以將任何資料類型放入枚舉的變體中:字串、數字型別、結構體都可以。你甚至可以再包含另一個枚舉!另外標準函式庫內的型別常常沒有你想得那麼複雜。

請注意雖然標準函式庫已經有定義 IpAddr,但我們還是可以使用並建立我們自己定義的型別,而且不會產生衝突,因為我們還沒有將標準函式庫的定義匯入到我們的作用域中。我們會在第七章討論如何將型別匯入作用域內。

讓我們再看看範例 6-2 的另一個枚舉範例,這次的變體有各式各樣的型別。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

範例 6-2:Message 枚舉的變體各自擁有不同的型別與數值數量

此枚舉有四個不同型別的變體:

  • Quit 沒有包含任何資料。
  • Move 包含了一個匿名結構體
  • Write 包含了一個 String
  • ChangeColor 包含了三個 i32

如同範例 6-2 這樣定義枚舉變體和定義不同類型的結構體很像,只不過枚舉不使用 struct 關鍵字,而且所有的變體都會在 Message 型別底下。以下的結構體可以包含與上方枚舉變體定義過的資料:

struct QuitMessage; // 類單元結構體
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 元組結構體
struct ChangeColorMessage(i32, i32, i32); // 元組結構體

fn main() {}

但是如果我們使用不同結構體且各自都有自己的型別的話,我們就無法像範例 6-2 那樣將 Message 視為單一型別,輕鬆在定義函式時接收訊息所有可能的類型。

枚舉和結構體還有一個地方很像:如同我們可以對結構體使用 impl 定義方法,我們也可以對枚舉定義方法。以下範例顯示我們可以對 Message 枚舉定義一個 call 方法:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // 在此定義方法本體
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

方法本體使用 self 來取得我們呼叫方法的值。在此例中,我們建立了一個變數 m 並取得 Message::Write(String::from("hello")),而這就會是當我們執行 m.call()call 方法內會用到的 self

讓我們再看看另一個標準函式庫內非常常見且實用的枚舉:Option

Option 枚舉相對於空值的優勢

在之前的段落,我們看到了如何使用 IpAddr 枚舉,來讓我們利用 Rust 的型別系統來為我們程式中的資料附加更多資訊。在此段落我們將來研究 Option,這是在標準函式庫中定義的另一種枚舉。Option 廣泛運用在許多場合,它能表示一個數值可能有某個東西,或者什麼都沒有。在型別系統中表達這樣的概念可以讓編譯器檢查我們是否都處理完我們該處理的情況了。這樣的功能可以防止其他程式語言中極度常見的程式錯誤。

程式語言設計通常要考慮哪些功能是你要的,但同時哪些功能是你不要的也很重要。Rust 沒有像其他許多語言都有空值。空值(Null)代表的是沒有任何數值。在有空值的語言,所有變數都有兩種可能:空值或非空值。

而其發明者 Tony Hoare 在他 2009 的演講「空引用:造成數十億損失的錯誤」(“Null References: The Billion Dollar Mistake”)中提到:

我稱它為我十億美元級的錯誤。當時我正在為一門物件導向語言設計第一個全方位的引用型別系統。我當時的目標是透過編譯器自動檢查來確保所有的引用都是安全的。但我無法抗拒去加入空引用的誘惑,因為實作的方式實在太簡單了。這導致了無數的錯誤、漏洞與系統當機,在接下來的四十年中造成了大概數十億美金的痛苦與傷害。

空值的問題在於,如果你想在非空值使用空值的話,你會得到某種錯誤。由於空值與非空值的特性無所不在,你會很容易犯下這類型的錯誤。

但有時候能夠表達「空(null)」的概念還是很有用的:空值代表目前的數值因為某些原因而無效或缺少。

所以問題不在於概念本身,而在於如何實作。所以 Rust 並沒有空值,但是它有一個枚舉可以表達出這樣的概念,也就是一個值可能是存在或不存在的。此枚舉就是 Option<T>,它是在標準函式庫中這樣定義的


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Option<T> 實在太實用了,所以它早已加進 prelude 中,你不需要特地匯入作用域中。除此之外,它的變體也是如此,你可以直接使用 SomeNone 而不必加上 Option:: 的前綴。Option<T> 仍然就只是個枚舉, Some(T)None 仍然是Option<T> 型別的變體。

<T> 語法是我們還沒介紹到的 Rust 功能。它是個泛型型別參數,我們會在第十章正式介紹泛型(generics)。現在你只需要知道 <T> 指的是 Option 枚舉中的 Some 變體可以是任意型別。以下是使用 Option 來包含數字與字串型別的範例:

fn main() {
    let some_number = Some(5);
    let some_string = Some("一個字串");

    let absent_number: Option<i32> = None;
}

如果我們是用 None 而非 Some 的話,我們就需要告訴 Rust Option<T> 的確切型別,因為編譯器無法從 None 值推斷出 Some 變體該擁有何種型別。

當我們有 Some 值時,我們會知道數值是存在的而且就位於 Some 內。當我們有 None 值時,在某種意義上它代表該值是空的,我們沒有有效的數值。所以為何 Option<T> 會比用空值來得好呢?

簡單來說因為 Option<T>TT 可以是任意型別)是不同的型別,編譯器不會允許我們像一般有效的值那樣來使用 Option<T>。舉例來說,以下範例是無法編譯的,因為這是將 i8Option<i8> 相加:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

如果我們執行此程式,我們會得到以下錯誤訊息:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `std::option::Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + std::option::Option<i8>`
  |
  = help: the trait `std::ops::Add<std::option::Option<i8>>` is not implemented for `i8`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums`.

To learn more, run the command again with --verbose.

這樣其實很好!此錯誤訊息事實上指的是 Rust 不知道如何將 i8Option<i8> 相加,因為它們是不同的型別。當我們在 Rust 中有個型別像是 i8,編譯器將會確保我們永遠會擁有有效數值。我們可以很放心地使用該值,而不必檢查是不是空的。我們只有在使用 Option<i8> (或者任何其他要使用的型別)時才需要去擔心會不會沒有值。然後編譯器會確保我們在使用該值前,有處理過該有的條件。

換句話說,你必須將 Option<T> 轉換為 T 你才能對 T 做運算。 這通常就能幫助我們抓到空值最常見的問題:認為某值不為空,但它其實就是空值。

不用再擔心一個非空值是不是不正確的,可以讓你對你寫的程式碼更有信心。要讓一個值變成可能為空的話,你必須顯式建立成對應型別的 Option<T>。然後當你要使用該值時,你就得顯式處理數值是否為空的條件。只要一個數值的型別不是 Option<T>,你就可以安全地認定該值不為空。這是 Rust 刻意考慮的設計決定,限制無所不在的空值,並增強 Rust 程式碼的安全性。

所以當我們有一個數值型別 Option<T>,我們要怎麼從 Some 變體取出 T,好讓我們可以使用該值呢?Option<T> 枚舉有大量實用的方法可以在不同的場合下使用。你可以在它的技術文件查閱。更加熟悉 Option<T> 的方法十分益於你接下來的 Rust 旅程。

整體來說,要使用 Option<T> 數值的話,你要讓程式碼可以處理每個變體。你會希望有一些程式碼只會在當我們有 Some(T) 時執行,然後這些程式碼允許使用內部的 T。你會希望有另一部分的程式碼能在只有 None 時執行,且這些程式碼不會拿到有效的 T 數值。match 表達式正是處理此枚舉行為的控制流結構:它會針對不同的枚舉變體執行不同的程式碼,而且程式碼可以使用配對到的數值資料。

match 控制流運算子

Rust 有個功能非常強大的控制流運算子叫做 match,你可以使用一系列模式來配對數值並依據配對到的模式來執行對應的程式。模式(Patterns)可以是字面數值、變數名稱、通配符(wildcards)和其他更多元件來組成。第十八章會涵蓋所有不同類型的模式,以及它們的用途。match 強大的地方在於模式表達的清楚程度以及編譯器會確保所有可能的情況都處理了。

你可以想像 match 表達式成一個硬幣分類機器:硬幣會滑到不同大小的軌道,然後每個硬幣會滑入第一個符合大小的軌道。同樣地,數值會依序遍歷 match 的每個模式,然後進入第一個「配對」到該數值的模式所在的程式碼區塊,並在執行過程中使用。

既然我們都提到硬幣了,就讓我們用它們來作為 match 的範例吧!我們可以寫一個接收未知美國硬幣的函式,以類似驗鈔機的方式,決定它是何種硬幣並以美分作為單位回傳其值。如範例 6-3 所示。

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

範例 6-3:枚舉以及用枚舉變體作為模式的 match 表達式

讓我們一一介紹 value_in_cents 函式中 match 的每個部分。首先我們使用 match 並加上一個表達式,在此例的話就是指 coin。這和 if 中表達式的用法很像。不過差別在於 if 中的表達式必須回傳布林值,而在此它可以是任何型別。在此範例中 coin 的型別是我們在第一行定義的枚舉 Coin

接下來是 match 的分支,每個分支有兩個部分:一個模式以及對應的程式碼。這邊第一個分支的模式是 Coin::Penny 然後 => 會將模式與要執行的程式碼分開來,而在此例的程式碼就只是個 1。每個分支之間由逗號區隔開來。

match 表達式執行時,他會將計算的數據結果依序與每個分支的模式做比較。如果有模式配對到該值的話,其對應的程式碼就會執行。如果該模式與數值不符的話,就繼續執行下一個分支,就像硬幣分類機器。

每個分支對應的程式碼都是表達式,然後在配對到的分支中表達式的數值結果就會是整個 match 表達式的回傳值。

如果配對分支的程式碼很短的話,通常就不需要用到的大括號,像是範例 6-3 每個分支就只回傳一個數值。如果你想要在配對分支執行多行程式碼的話,你就可以用大括號。舉例來說,以下程式會在每次配對到 Coin::Penny 時印出「幸運幣!」再回傳程式碼區塊最後的數值 1

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("幸運幣!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

綁定數值的模式

另一項配對分支的實用功能是它們可以綁定配對模式中部分的數值,這讓我們可以取出枚舉變體中的數值。

舉例來說,讓我們改變我們其中一個枚舉變體成擁有資料。從 1999 年到 2008 年,美國在鑄造 25 美分硬幣時,其中一側會有 50 個州不同的設計。不過其他的硬幣就沒有這樣的設計,只有 25 美分會有特殊值而已。我們可以改變我們的 enum 中的 Quarter 變體成儲存 UsState 數值,如範例 6-4所示。

#[derive(Debug)] // 這讓我們可以顯示每個州
enum UsState {
    Alabama,
    Alaska,
    // --省略--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

範例 6-4:修改 Coin 枚舉的 Quarter 變體來包含一個 UsState 數值

讓我們想像我們有一個朋友想要收集所有 50 州的 25 美分硬幣。當我們在排序零錢的同時,我們會在拿到 25 美分時喊出該硬幣對應的州,好讓我們的朋友知道,如果他沒有的話就可以納入收藏。

在此程式中的配對表達式中,我們在 Coin::Quarter 變體的配對模式中新增了一個變數 state。當 Coin::Quarter 配對符合時,變數 state 會綁定該 25 美分的數值,然後我們就可以在分支程式碼中使用 state,如以下所示:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --省略--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("此 25 美分所屬的州為 {:?}!", state);
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

如果我們呼叫 value_in_cents(Coin::Quarter(UsState::Alaska)) 的話,coin 就會是 Coin::Quarter(UsState::Alaska)。當我們比較每個配對分支時,我們會到 Coin::Quarter(state) 的分支才配對成功。此時 state 綁定的數值就會是 UsState::Alaska。我們就可以在 println! 表達式中使用該綁定的值,以此取得 Coin 枚舉中 Quarter 變體內的值。

配對 Option<T>

在上一個段落,我們想要在使用 Option<T> 時取得 Some 內部的 T 值。如同枚舉 Coin,我們一樣可以使用 match 來處理 Option<T>!相對於比較硬幣,我們要比較的是 Option<T> 的變體,不過 match 表達式運作的方式一模一樣。

假設我們要寫個接受 Option<i32> 的函式,而且如果內部有值的話就將其加上 1。如果內部沒有數值的話,該函式就回傳 None 且不再嘗試做任何動作。

match 所賜,這樣的函式很容易寫出來,長得就像範例 6-5。

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

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

範例 6-5:對 Option<i32> 使用 match 表達式的函式

讓我們來仔細分析 plus_one 第一次的執行結果。當我們呼叫 plus_one(five) 時,plus_one 本體中的變數 x 會擁有 Some(5)。我們接著就拿去和每個配對分支比較。

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

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) 並不符合 None 這樣的模式,所以我們繼續進行下一個分支。

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

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) 有符合 Some(i) 這樣的模式嗎?這是當然的囉!我們有相同的變體。i 會綁定 Some 中的值,所以 i 會取得 5。接下來配對分支中的程式碼就會執行,我們將 1 加入 i 並產生新的 Some 其內部的值就會是 6

現在讓我們看看範例 6-5 第二次的 plus_one 呼叫,這次的 xNone。我們進入 match 然後比較第一個分支。

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

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

配對成功!因為沒有任何數值可以相加,程式就停止並在 => 之後馬上回傳 None。因為第一個分支就配對成功了,沒有其他的分支需要再做比較。

match 與枚舉組合起來在很多地方都很實用。你將會在許多 Rust 程式碼看到這樣的模式,使用 match 配對枚舉,綁定內部的資料,然後執行對應的程式碼。一開始使用的確會有點陌生,但當你熟悉以後,你會希望所有語言都能提供這樣的功能。這一直是使用者最愛的功能之一。

配對必須是徹底的

我們還有一個 match 的細節要討論,今天要是我們像這樣寫了一個有錯誤的 plus_one 函式版本,它會無法編譯:

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

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

我們沒有處理到 None 的情形,所以此程式碼會產生錯誤。幸運的是這是 Rust 能夠抓到的錯誤。如果我們嘗試編譯此程式的話,我們會得到以下錯誤:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
  = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms

error: aborting due to previous error

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums`.

To learn more, run the command again with --verbose.

Rust 發現我們沒有考慮到所有可能條件,而且還知道我們少了哪些模式!Rust 中的配對必須是徹底exhaustive)的:我們必須列舉出所有可能的情形,程式碼才能夠被視為有效。尤其是在 Option<T> 的情況下,當 Rust 防止我們忘記處理 None 的情形時,它也使我們免於以為擁有一個有效實際上卻是空的值。因此要造成之前提過的十億美元級錯誤在這邊基本上是不可能的。

_ 佔位符

Rust 還有一個模式可以讓我們不必列出所有可能的數值,只需要使用此模式就好。舉例來說 u8 可能的數值為 0 到 255,如果我們只在意數值 1、3、5 和 7,我們就不會想要列出 0、2、4、6、8、9 以及剩下一直到 255 的每個值。幸運的是,我們不需要這樣做,我們可以使用特殊模式 _

fn main() {
    let some_u8_value = 0u8;
    match some_u8_value {
        1 => println!("一"),
        3 => println!("三"),
        5 => println!("five"),
        7 => println!("seven"),
        _ => (),
    }
}

_ 模式會配對任意數值,將它置於所有分支之後,_ 就會配對剩下尚未指明的可能情形。() 只是一個單位數值,所以在 _ 的分支沒有任何事情會發生。所以我們可以說我們不想針對 _ 佔位符(placeholder)之前沒有列出的可能情形,做任何動作。

不過有時候我們只在意其中一種情形的話, match 表達式的確會有點囉唆。針對此情形,Rust 提供 if let

而更多有關配對模式的內容可以在第十八章查閱。

透過 if let 簡化控制流

if let 語法讓你可以用 iflet 的組合來以比較不冗長的方式,來處理只在乎其中一種模式而忽略其餘的數值。現在考慮一支程式如範例 6-6 所示,我們配對 Option<u8> 的值,但只想在數值為 3 時執行程式。

fn main() {
    let some_u8_value = Some(0u8);
    match some_u8_value {
        Some(3) => println!("三"),
        _ => (),
    }
}

範例 6-6:match 只在數值為 Some(3) 時執行程式

我們想在 Some(3) 配對到時做些事情,但不想管其他 Some<u8> 的值或是 None。為了滿足 match 表達式,我們必須在只處理一種變體的分支後面,再加上 _ => ()。這樣就加了不少樣板程式碼。

不過我們可以使用 if let 以更精簡的方式寫出來,以下程式碼的行為就與範例 6-6 的 match 一樣:

fn main() {
    let some_u8_value = Some(0u8);
    if let Some(3) = some_u8_value {
        println!("三");
    }
}

if let 接收一個模式與一個表達式,然後用等號區隔開來。它與 match 的運作方式相同,表達式的意義與 match 相同,然後前面的模式就是第一個分支。

使用 if let 可以少打些字、減少縮排以及不用寫多餘的樣板程式碼。不過你就少了 match 強制的徹底窮舉檢查。要何時選擇 match 還是 if let 得依據你在的場合是要做什麼事情,以及在精簡度與徹底檢查之間做取捨。

換句話說,你可以想像 if letmatch 的語法糖(syntax sugar),它只會配對一種模式來執行程式碼並忽略其他數值。

我們也可以在 if let 之後加上 elseelse 之後的程式碼區塊等同於 match 表達式中 _ 情形的程式碼區塊。這樣一來的 if letelse 組合就等同於 match 了。回想一下範例 6-4 的 Coin 枚舉定義, Quarter 變體擁有數值 UsState。如果我們希望統計所有不是 25 美分的硬幣的同時,也能繼續回報 25 美分所屬的州的話,我們可以用 match 像這樣寫:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --省略--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("此 25 美分所屬的州為 {:?}!", state),
        _ => count += 1,
    }
}

或是我們也可以用 if letelse 表達式這樣寫:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --省略--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("此 25 美分所屬的州為 {:?}!", state);
    } else {
        count += 1;
    }
}

如果你的程式碼邏輯遇到使用 match 表達會太囉唆的話,記得 if let 也在你的 Rust 工具箱中供你使用。

總結

我們現在涵蓋了如何使用枚舉來建立一系列枚舉數值的自訂型別。我們展示了標準函式庫的 Option<T> 型別如何用型別系統來預防錯誤。當枚舉數值其內有資料時,你可以依照你想處理的情況數量,使用 matchif let 來取出並使用那些數值。

你的 Rust 程式碼現在能夠使用結構體與枚舉來表達你所相關研究領域的概念了。在你的 API 建立自訂型別可以確保型別安全,編譯器會保證你的函式只會取得該函式預期的型別數值。

接下來為了提供組織完善且直觀的的 API 供你的使用者使用,並只表達出使用者確切所需要的內容,我們需要瞭解 Rust 的模組。

透過套件、Crate 與模組管理成長中的專案

當你寫的程式規模更大時,組織你的程式碼就很重要。因為用你的腦袋要記住整個程式碼是幾乎不可能的。要是能組織相關功能的程式碼並將它們分成明確功能的話,你就能清楚地找到實作特定功能的程式碼,以及該在哪裏修改該功能的行為。

我們之前寫過的程式都只在一個檔案內的一個模組(module)中。隨著專案成長,我們可以組織程式碼,拆成數個模組與數個檔案。一個套件(package)可以包含數個二進制 crate 以及選擇性提供一個函式庫 crate。隨著套件增長,你可以取出不同的部分作為獨立的 crate,成為對外的依賴函式庫。此章節將會介紹這些所有概念。對於非常龐大的專案,需要一系列的關聯套件組合在一起的話,Cargo 有提供工作空間(workspaces),我們會在第十四章的「Cargo 工作空間」做介紹。

除了為了組織功能以外,對實作細節進行封裝可以讓你的程式碼在頂層更好使用。一旦你實作了某項功能,其他程式就可以用程式碼的公開介面呼叫該程式碼,而不必去知道它實作如何運作。你在寫程式碼時會去定義哪些部分是給其他程式碼公開使用的,以及哪些部分是私底下你可以任意修改的實作細節。這能再減少你的腦袋需要煩惱的細節數量。

還有一個概念需要再提一次,也就是作用域(scope):程式碼需要能被定義在「作用域內」並要能夠指明此作用域。當讀取寫入或編譯程式碼時,程式設計師與編譯器需要知道特定地點的名稱,才能知道其內的變數、函式、結構體、枚舉、常數或其他任何有意義的項目。你可以建立作用域,並改變其在作用域內與作用域外的名稱。你無法在同個作用域內擁有兩個相同名稱的項目。我們可以使用一些工具來解決名稱衝突的問題。

Rust 有一系列的功能能讓你管理你的程式碼組織,包含哪些細節能對外提供、哪些細節是私有的,以及程式中每個作用域的名稱為何。這些功能有時會統一稱作模組系統(module system),其中包含:

  • 套件(Package): 讓你建構、測試並分享 crate 的 Cargo 功能
  • Crates: 產生函式庫或執行檔的模組集合
  • 模組(Modules) 與 use: 讓你控制組織、作用域與路徑的隱私權
  • 路徑(Paths): 對一個項目的命名方式,像是一個結構體、函式或模組

在本章節中,我們會涵蓋所有這些功能,討論它們如何互動,並解釋如何使用它們來管理作用域。在讀完後,你應該就會對模組系統有紮實的認知,並能夠對作用域的使用駕輕就熟!

套件與 Crates

首先我們要介紹的第一個模組系統部分為套件與 crates。一個 crate 指的是一個二進制執行檔或函式庫。crate 的源頭會是一個原始檔案,讓 Rust 的編譯器可以作為起始點並組織 crate 模組的地方(我們會在「定義模組來控制作用域與隱私權」的段落更加解釋模組)。套件(package)則是提供一系列功能的一或數個 crate。一個套件會包含一個 Cargo.toml 檔案來解釋如何建構那些 crate。

套件依據一些規則來組成。一個套件必須包含零或一個函式庫 crate,不能再更多。它可以包含多少二進制執行檔 crate 都沒關係,但一定得至少提供一個 crate(無論是函式庫或二進制執行檔)。

讓我們看看當我們建立一個套件時發生了什麼事。首先我們先輸入 cargo new 命令:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

當我們輸入命令時,Cargo 會建立一個 Cargo.toml 檔案並以此作為套件依據。查看 Cargo.toml 的內容時,你會發現沒有提到 src/main.rs,這是因為 Cargo 遵循一個常規,也就是 src/main.rs 就是與套件同名的 二進制 crate 的 crate 源頭。同樣地,Cargo 也會知道如果套件目錄包含 src/lib.rs的話,則該套件就會包含與套件同名的函式庫 crate。Cargo 會將 crate 源頭檔案傳遞給 rustc 來建構函式庫或二進制執行檔。

我們在此的套件只有包含 src/main.rs 代表它只有一個同名的二進制 crate 叫做 my-project。如果套件包含 src/main.rssrc/lib.rs 的話,它就有兩個 crate:一個函式庫與一個二進制執行檔,兩者都與套件同名。一個套件可以有多個二進制 crate,只要將檔案放在 src/bin 目錄底下就好,每個檔案會被視為獨立的二進制 crate。

Crate 會將相關的程式碼組織在一個作用域內,好讓其能易於分享給其他專案。舉例來說,我們在第二章使用到的 rand crate 就提供了產生隨機數值的功能。我們可以將 rand crate 引入我們的專案,讓我們可以在我們的專案使用這項功能。所有 rand crate 提供的功能都可以透過 crate 的名稱 rand 來索取。

將 crate 的功能維持在各自的作用域內能清楚地表達特定功能是定義在我們自己的 crate 還是 rand crate 的,以防止可能的衝突。舉例來說,rand crate 提供了一個特徵叫做 Rng,我們也可以在我們自己的 crate 中定義一個 struct 叫做 Rng。由於 crate 的功能都位於它所屬的作用域的命名空間底下,當我們加入 rand 作為依賴時,編譯器不會搞不清楚是哪個 Rng 被使用。在我們的 crate 中,它指的是我們定義的 struct Rng。而要使用 rand crate 的 Rng 特徵的話,我們得這樣使用 rand::Rng

接下來讓我們繼續討論模組系統吧!

定義模組來控制作用域與隱私權

在此段落,我們將討論模組以及其他模組系統的部分,像是路徑(paths)允許你來命名項目,而 use 關鍵字可以將路徑引入作用域,再來 pub 關鍵字可以讓指定的項目對外公開。我們還會討論到 as 關鍵字、外部套件以及全域(glob)運算子。現在讓我們先專注在模組吧!

模組(Modules)能讓我們在 crate 內組織程式碼成數個群組以便使用且增加閱讀性。模組也能控制項目的隱私權,也就是該項目能否被外部程式碼公開(public)使用,或者只作為內部私有(private)實作細節,對外是無法使用的。

舉例來說,讓我們建立一個提供餐廳功能的函式庫 crate。我們定義一個函式簽名不過本體會是空的,好讓我們專注在程式組織,而非餐廳程式碼的實作。

在餐飲業中,餐廳有些地方會被稱作前台(front of house)而其他部分則是後台(back of house)。前台是消費者的所在區域,這裡是安排顧客座位、點餐並結帳、吧台調酒的地方。而後台則是主廚與廚師工作的廚房、洗碗工洗碗以及經理管理行政工作的地方。

要讓我們的 crate 像真正的餐廳一樣的話,我們可以組織函式進入模組中。要建立一個新的函式庫叫做 restaurant 的話,請執行 cargo new --lib restaurant。然後將範例 7-1 的程式碼放入 src/lib.rs 中,這定義了一些模組與函式簽名。

檔案名稱:src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

fn main() {}

範例 7-1:front_of_house 模組包含了其他擁有函式的模組

我們用 mod 關鍵字加上模組的名稱(在此例為 front_of_house)來定義一個模組,並用大括號涵蓋模組的本體。在模組中,我們可以再包含其他模組,在此例中我們包含了 hostingserving。模組還能包含其他項目,像是結構體、枚舉、常數、特徵、或像是 範例 7-1 的函式。

使用模組的話,我們就能將相關的定義組合起來,並用名稱指出會與它們互相關聯。程式設計師在使用此程式碼時就能快速找到他們想使用的定義,因為他們就不必遍歷所有的定義,只要觀察依據組合起來的模組名稱就好。要對此程式碼增加新功能的開發者也能知道該將程式碼放在哪裡,以維持程式碼的組織。

稍早我們提到說 src/main.rssrc/lib.rs 屬於 crate 的源頭。之所以這樣命名的原因是因為這兩個文件的內容都會在 crate 源頭模組架構中組成一個模組叫做 crate,這樣的結構稱之為模組樹(module tree)

範例 7-2 顯示了範例 7-1 的模組樹架構。

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

範例 7-2:範例 7-1 的模組樹

此樹顯示了有些模組是包含在其他模組內的(比方說 hosting 就在 front_of_house 底下)。此樹也顯示了有些模組是其他模組的同輩(siblings),代表它們是在同模組底下定義的(hostingserving 都在 front_of_house 底下定義)。繼續沿用家庭來譬喻的話,如果模組 A 被包含在模組 B 中,我們會說模組 A 是模組 B 的下一代(child),而模組 B 是模組 A 的上一代(parent)。注意到整個模組樹的根是一個隱性模組叫做 crate

模組樹可能會讓你想到電腦中檔案系統的目錄樹,這是一個非常恰當的比喻!就像檔案系統中的目錄,你使用模組來組織你的程式碼。而且就像目錄中的檔案,我們需要有方法可以找到我們的模組。

引用模組項目的路徑

要展示 Rust 如何從模組樹中找到一個項目,我們要使用和查閱檔案系統時一樣的路徑方法。如果我們想要呼叫函式,我們需要知道它的路徑:

路徑可以有兩種形式:

  • 絕對路徑(absolute path)是從 crate 的源頭開始找起,用 crate 的名稱或 crate 作為起頭。
  • 相對路徑(relative path)則是從本身的模組開始,使用 selfsuper或是當前模組的標識符(identifiers)。

無論是絕對或相對路徑其後都會接著一或多個標識符,並使用雙冒號(::)區隔開來。

讓我們回頭看看範例 7-1,我們要如何呼叫函式 add_to_waitlist 呢?這就和問函式 add_to_waitlist 在哪的問題是一樣的。在範例 7-3,我們移除了一些模組與函式來精簡程式碼的呈現方式。我們會展示兩種從 crate 源頭定義的 eat_at_restaurant 函式內呼叫 add_to_waitlist 的方法。eat_at_restaurant 函式是我們函式庫 crate 公開 API 的一部分,所以我們會加上 pub 關鍵字。在「使用 pub 關鍵字公開路徑」的段落中,我們會提到更多 pub 的細節。請注意此範例還不能編譯,我們等等會解釋原因。

檔案名稱:src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 絕對路徑
    crate::front_of_house::hosting::add_to_waitlist();

    // 相對路徑
    front_of_house::hosting::add_to_waitlist();
}

範例 7-3:使用絕對與相對路徑呼叫 add_to_waitlist 函式

我們在 eat_at_restaurant 中第一次呼叫 add_to_waitlist 函式的方式是用絕對路徑。add_to_waitlist 函式和 eat_at_restaurant 都是在同一個 crate 底下,所以我們可以使用 crate 關鍵字來作為絕對路徑的開頭。

crate 之後, 我們接續加上對應的模組直到抵達 add_to_waitlist。你可以想像一個有相同架構的檔案系統,然後我們指定 /front_of_house/hosting/add_to_waitlist 這樣的路徑來執行 add_to_waitlist 程式。使用 crate 這樣的名稱作為 crate 源頭的開始,就像在你的 shell 使用 / 作為檔案系統的根一樣。

而我們第二次在 eat_at_restaurant 呼叫 add_to_waitlist 的方式是使用相對路徑。 路徑的起頭是 front_of_house,因為它和 eat_at_restaurant 都被定義在模組樹的同一層中。這裡相對應的檔案系統路徑就是 front_of_house/hosting/add_to_waitlist。使用一個名稱作為開頭通常就是代表相對路徑。

何時該用相對或絕對路徑是你在你的專案中要做的選擇。選擇的依據通常會看你移動程式碼位置時,是會連帶它們一起移動,或是分開移動到不同地方。舉例來說,如果我們同時將 front_of_house 模組和 eat_at_restaurant 函式移入另一個模組叫做 customer_experience 的話,就會需要修改 add_to_waitlist 的絕對路徑,但是相對路徑就可以原封不動。而如果我們只單獨將 eat_at_restaurant 函式移入一個叫做 dining 模組的話,add_to_waitlist 的絕對路徑就不用修改,但相對路徑就需要更新。我們通常會傾向於指定絕對路徑,因為分別移動程式碼定義與項目呼叫的位置通常是比較常見的。

讓我們嘗試編譯範例 7-3 並看看為何不能編譯吧!以下範例 7-4 是我們得到的錯誤資訊。

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant`.

To learn more, run the command again with --verbose.

範例 7-4:範例 7-3 嘗試編譯程式碼出現的錯誤

錯誤訊息表示 hosting 模組是私有的。換句話說,我們指定 hosting 模組與 add_to_waitlist 函式的路徑是正確的,但是因為它沒有私有部分的存取權,所以 Rust 不讓我們使用。

模組不僅用來組織你的程式碼,它們還定義了 Rust 的隱私界限(privacy boundary):這是條封裝實作細節讓外部程式碼無法看到、呼叫或依賴的界限。所以你想要建立私有的函式或結構體,你可以將它們放入模組內。

Rust 隱私權的運作方式是預設所有的項目(函式、方法、結構體、枚舉、模組與常數)都是私有的。上層模組的項目無法使用下層模組的私有項目,但下層模組能使用它們上方所有模組的項目。這麼做的原因是因為下層模組用來實現實作細節,而下層模組應該要能夠看到在自己所定義的地方的其他內容。讓我們繼續用餐廳做比喻的話,我們可以想像隱私權規則就像是餐廳的後臺辦公室。對餐廳顧客來說裡面發生什麼事情都是未知的,但是辦公室經理可以知道經營餐廳時的所有事物。

Rust 選擇這樣的模組系統,讓內部實作細節預設都是隱藏起來的。這樣一來,你就能知道內部哪些程式碼需要修改,而不會破壞到外部的程式碼。不過你可以使用 pub 關鍵字來讓下層模組內部的一些程式碼公開給上層模組來使用。

使用 pub 關鍵字公開路徑

讓我們再執行一次範例 7-4 的錯誤,它告訴我們 hosting 模組是私有的。我們希望上層模組中的 eat_at_restaurant 函式可以呼叫下層模組的 add_to_waitlist 函式,所以我們將 hosting 模組加上 pub 關鍵字,如範例 7-5 所示。

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 絕對路徑
    crate::front_of_house::hosting::add_to_waitlist();

    // 相對路徑
    front_of_house::hosting::add_to_waitlist();
}

範例 7-5:宣告 hosting 模組為 pub 好讓 eat_at_restaurant 可以使用

不幸的是範例 7-5 的程式碼仍然回傳了另一個錯誤,如範例 7-6 所示。

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
 --> src/lib.rs:9:37
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                                     ^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:12:30
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant`.

To learn more, run the command again with --verbose.

範例 7-6:編譯範例 7-5 時產生的錯誤

到底發生了什麼事?在 mod hosting 之前加上 pub 關鍵字確實公開了模組。有了這項修改後,我們的確可以在取得 front_of_house 的後繼續進入 hosting。但是 hosting 的所有內容仍然是私有的。模組中的 pub 關鍵字只會讓該模組公開讓上層模組使用而已。

範例 7-6 的錯誤訊息表示 add_to_waitlist 函式是私有的。隱私權規則如同模組一樣適用於結構體、枚舉、函式與方法。

讓我們在 add_to_waitlist 的函式定義加上 pub 公開它吧,如範例 7-7 所示。

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 絕對路徑
    crate::front_of_house::hosting::add_to_waitlist();

    // 相對路徑
    front_of_house::hosting::add_to_waitlist();
}

fn main() {}

範例 7-7:將 mod hostingfn add_to_waitlist 都加上 pub 關鍵字,讓我們可以從 eat_at_restaurant 呼叫函式

現在程式碼就能成功編譯了!讓我們看看絕對與相對路徑,以及再次檢查為何 pub 關鍵字是如何遵守隱私權規則,讓我們可以在 add_to_waitlist 取得這些路徑。

在絕對路徑中,我們始於 crate,這是 crate 模組樹的跟。再來 front_of_house 模組被定義在 crate 源頭中,front_of_house 模組不是公開,但因為 eat_at_restaurant 函式被定義在與 front_of_house 同一層模組中(也就是 eat_at_restaurantfront_of_house 同輩(siblings)),我們可以從 eat_at_restaurant 引用 front_of_house。接下來是有 pub 標記的 hosting 模組,我們可以取得 hosting 的上層模組,所以我們可以取得 hosting。最後 add_to_waitlist 函式也有 pub 標記而我們可以取得它的上層模組,所以整個程式呼叫就能執行了!

而在相對路徑中,基本邏輯與絕對路徑一樣,不過第一步有點不同。我們不是從 crate 源頭開始,路徑是從 front_of_house 開始。front_of_houseeat_at_restaurant 被定義在同一層模組中,所以從 eat_at_restaurant 開始定義的相對路徑是有效的。再來因為 hostingadd_to_waitlist 都有 pub 標記,其餘的路徑也都是可以進入的,所以此函式呼叫也是有效的!

使用 super 作為相對路徑的開頭

我們還可以在路徑開頭使用 super 來建構從上層模組出發的相對路徑。這就像在檔案系統中使用 .. 作為路徑開頭一樣。不過為何我們想要這樣做呢?

請考慮範例 7-8 的程式碼,這模擬了一個主廚修正一個錯誤的訂單,並親自提供給顧客的場景。函式 fix_incorrect_order 呼叫了函式 serve_order,不過這次是使用 super 來指定 serve_order 的路徑:

檔案名稱:src/lib.rs

fn serve_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::serve_order();
    }

    fn cook_order() {}
}

fn main() {}

範例 7-8:使用 super 作為呼叫函式路徑的開頭

fix_incorrect_order 函式在 back_of_house 模組中,所以我們可以使用 super 前往 back_of_house 的上層模組,在此例的話就是源頭 crate。然後在此時我們就能找到 serve_order。成功!我們認定 back_of_house 模組與 serve_order 函式應該會維持這樣相同的關係,在我們要組織 crate 的模組樹時,它們理當一起被移動。因此我們使用 super 讓我們在未來程式碼被移動到不同模組時,我們不用更新太多程式路徑。

公開結構體與枚舉

我們也可以使用 pub 來公開結構體與枚舉,但是我們有些額外細節要考慮到。如果我們在結構體定義之前加上 pub 的話,我們的確能公開結構體,但是結構體內的欄位仍然會是私有的。我們可以視情況決定每個欄位要不要公開。在範例 7-9 我們定義了一個公開的結構體 back_of_house::Breakfast 並公開欄位 toast,不過將欄位 seasonal_fruit 維持是私有的。這次範例模擬的情境是,餐廳顧客可以選擇早餐要點什麼類型的麵包,但是由主廚視庫存與當季食材來決定提供何種水果。餐廳提供的水果種類隨季節變化很快,所以顧客無法選擇或預先知道他們會拿到何種水果。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("桃子"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // 點夏季早餐並選擇黑麥麵包
    let mut meal = back_of_house::Breakfast::summer("黑麥");
    // 我們想改成全麥麵包
    meal.toast = String::from("全麥");
    println!("我想要{}麵包,謝謝", meal.toast);

    // 接下來這行取消註解的話,我們就無法編譯通過
    // 我們無法擅自更改餐點搭配的季節水果
    // meal.seasonal_fruit = String::from("藍莓");
}
}

範例 7-9:一個有些欄位公開而有些是私有欄位的結構體

因為 back_of_house::Breakfast 結構體中的 toast 欄位是公開的,在 eat_at_restaurant 中我們可以加上句點來對 toast 欄位進行讀寫。注意我們不能在 eat_at_restaurant 使用 seasonal_fruit 欄位,因為它是私有的。請嘗試解開修改 seasonal_fruit 欄位數值的那行程式註解,看看你會獲得什麼錯誤!

另外因為 back_of_house::Breakfast 擁有私有欄位,該結構體必須提供一個公開的關聯函式(associated function)才有辦法產生 Breakfast 的實例(我們在此例命名為 summer)。如果 Breakfast 沒有這樣的函式的話,我們就無法在 eat_at_restaurant 建立 Breakfast 的實例,因為我們無法在 eat_at_restaurant 設置私有欄位 seasonal_fruit 的數值。

接下來,如果我們公開枚舉的話,那它所有的變體也都會公開。我們只需要在 enum 關鍵字之前加上 pub 就好,如範例 7-10 所示。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
}

範例 7-10:公開枚舉會讓其所有變體也公開

因為我們公開了 Appetizer 枚舉,我們可以在 eat_at_restaurant 使用 SoupSalad。枚舉的變體沒有全部都公開的話,通常會讓枚舉很不好用。要用 pub 標註所有的枚舉變體都公開的話又很麻煩。所以公開枚舉的話,預設就會公開其變體。相反地,結構體不讓它的欄位全部都公開的話,通常反而比較實用。因此結構體欄位的通用原則是預設為私有,除非有 pub 標註。

我們還有一個 pub 的使用情境還沒提到,也就是我們模組系統最後一項功能:use 關鍵字。我們接下來會先解釋 use,再來研究如何組合 pubuse

透過 use 關鍵字引入路徑

我們目前呼叫函式的路徑都很冗長、重複且不方便。舉例來說範例 7-7 我們在考慮要使用絕對或相對路徑來呼叫 add_to_waitlist 函式時,每次想要呼叫 add_to_waitlist 我們都得指明 front_of_house 以及 hosting。幸運的是,我們有簡化過程的辦法。我們可以使用 use 關鍵字將路徑引入作用域,然後就像它們是本地項目一樣來呼叫它們。

在範例 7-11 中,我們引入了 crate::front_of_house::hosting 模組進 eat_at_restaurant 函式的作用域中,所以我們要呼叫函式 add_to_waitlist 的話我們只需要指明 hosting::add_to_waitlist

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

fn main() {}

範例 7-11:使用 use 將模組引入

使用 use 將路徑引入作用域就像是在檔案系統中產生符號連結一樣(symbolic link)。在 crate 源頭加上 use crate::front_of_house::hosting 後,hosting 在作用域內就是個有效的名稱了。使用 use 的路徑也會檢查隱私權,就像其他路徑一樣。

你也可以使用 use 加上相對路徑來引入項目。範例 7-12 就展示了如何指明相對路徑來達到與範例 7-11 一樣的結果。

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use self::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

fn main() {}

範例 7-12:使用 use 與相對路徑將項目引入作用域

建立慣用的 use 路徑

在範例 7-11 你可能會好奇為何我們指明 use crate::front_of_house::hosting 然後在 eat_at_restaurant 呼叫,而不是直接用 use 指明 add_to_waitlist 函式的整個路徑就好。像範例 7-13 這樣寫。

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
    add_to_waitlist();
    add_to_waitlist();
}

fn main() {}

範例 7-13:使用 useadd_to_waitlist 函式引入作用域,但這較不符合習慣

雖然範例 7-11 與範例 7-13 都能完成相同的任務,但是範例 7-11 使用 use 將函式引入作用域的方法比較符合習慣用法。使用 use 將函式的上層模組引入作用域,讓我們必須在呼叫函式時得指明對應模組。這樣清楚知道該函式並非本地定義的,同時一樣能簡化路徑。範例 7-13 的程式碼會不清楚 add_to_waitlist 是在哪定義的。

另一方面,如果是要使用 use 引入結構體、枚舉或其他項目的話,直接指明完整路徑反而是符合習慣的方式。範例 7-14 顯示了將標準函式庫的 HashMap 引入二進制 crate 作用域的習慣用法。

檔案名稱:src/main.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

範例 7-14:引入 HashMap 進作用域的習慣用法

此習慣沒什麼強硬的理由:就只是大家已經習慣這樣的用法來讀寫 Rust 的程式碼。

這樣的習慣有個例外,那就是如果我們將兩個相同名稱的項目使用 use 陳述式引入作用域時,因為 Rust 不會允許。範例 7-15 展示了如何引入兩個同名但屬於不同模組的 Result 型別進作用域中並使用的方法。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --省略--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --省略--
    Ok(())
}
}

範例 7-15:要將兩個同名的型別引入相同作用域的話,必須使用它們所屬的模組

如同你所見使用對應的模組可以分辨出是在使用哪個 Result 型別。如果我們直接指明 use std::fmt::Resultuse std::io::Result 的話,我們會在同一個作用域中擁有兩個 Result 型別,這樣一來 Rust 就無法知道我們想用的 Result 是哪一個。

使用 as 關鍵字提供新名稱

要在相同作用域中使用 use 引入兩個同名型別的話,還有另一個辦法。在路徑之後,我們可以用 as 指定一個該型別在本地的新名稱,或者說別名。範例 7-16 展示重寫了範例 7-15,將其中一個 Result 型別使用 as 重新命名。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --省略--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --省略--
    Ok(())
}
}

範例 7-16:使用 as 將型別引入作用域的同時重新命名

在第二個 use 陳述式,我們選擇了將 std::io::Result 型別重新命名為 IoResult,這樣就不會和同樣引入作用域內 std::fmtResult 有所衝突。範例 7-15 與 範例 7-16 都屬於習慣用法,你可以選擇你比較喜歡的方式!

使用 pub use 重新匯出名稱

當我們使用 use 關鍵字將名稱引入作用域時,該有效名稱在新的作用域中是私有的。要是我們希望呼叫我們這段程式碼時,也可以使用這個名稱的話(就像該名稱是在此作用域內定義的),我們可以組合 pubuse。這樣的技巧稱之為重新匯出(re-exporting),因為我們將項目引入作用域,並同時公開給其他作用域引用。

範例 7-17 將範例 7-11 在源頭模組中原本的 use 改成 pub use

檔案名稱:src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

fn main() {}

範例 7-17:使用 pub use 使名稱公開給任何程式的作用域中引用

使用 pub use 可以讓外部程式碼以 hosting::add_to_waitlist 的方式來呼叫函式 add_to_waitlist。如果我們沒有指明 pub use,函式 eat_at_restaurant 仍可以在它的作用域呼叫 hosting::add_to_waitlist,但外部程式碼就無法利用這個新的路徑。

當程式碼的內部結構與使用程式的開發者對於該領域所想像的結構不同時,重新匯出會很有用。我們再次用餐廳做比喻的話就像是,經營餐廳的人可能會想像餐廳是由「前台」與「後台」所組成,但光顧的顧客可能不會用這些術語來描繪餐廳的每個部分。使用 pub use 的話,我們可以用某種架構寫出程式碼,再以不同的架構對外公開。這樣讓我們的的函式庫可以完整的組織起來,且對開發函式庫的開發者與使用函式庫的開發者都提供友善的架構。

使用外部套件

在第二章我們寫了一支猜謎遊戲專案時,有用到一個外部套件叫做 rand 來取得隨機數字。要在專案內使用 rand 的話,我們會在 Cargo.toml 加上此行:

檔案名稱:Cargo.toml

[dependencies]
rand = "0.5.5"

Cargo.toml 新增 rand 作為依賴函式庫會告訴 Cargo 要從 crates.io 下載 rand 以及其他相關的依賴,讓我們可專案可以使用 rand

接下來要將 rand 的定義引入我們套件的作用域的話,我們加上一行 use 後面接著 crate 的名稱 rand 然後列出我們想要引入作用域的項目。回想一下在第二章「產生隨機數字」的段落,我們將 Rng 特徵引入作用域中,並呼叫函式 rand::thread_rng

use std::io;
use rand::Rng;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取該行失敗");

    println!("你的猜測數字:{}", guess);
}

Rust 社群成員在 crates.io 發佈了不少套件可供使用,要將這些套件引入到你的套件的步驟是一樣的。在你的套件的 Cargo.toml 檔案列出它們,然後使用 use 將這些 crate 內的項目引入作用域中。

請注意到標準函式庫(std)對於我們的套件來說也是一個外部 crate。由於標準函式庫會跟著 Rust 語言發佈,所以我們不需要更改 Cargo.toml 來包含 std。但是我們仍然需使用 use 來將它的項目引入我們套件的作用域中。舉例來說,要使用 HashMap 我們可以這樣寫:


#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

這是個用標準函式庫的 crate 名稱 std 起頭的絕對路徑。

使用巢狀路徑來清理大量的 use 行數

如果我們要使用在相同 crate 或是相同模組內定義的數個項目,針對每個項目都單獨寫一行的話,會佔據我們檔案內很多空間。舉例來說,範例 2-4 中的猜謎遊戲我們用了這兩個 use 陳述式來引入作用域中:

檔案名稱:src/main.rs

use rand::Rng;
// --省略--
use std::cmp::Ordering;
use std::io;
// --省略--

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取行數失敗");

    println!("你的猜測數字:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("太小了!"),
        Ordering::Greater => println!("太大了!"),
        Ordering::Equal => println!("獲勝!"),
    }
}

我們可以改使用巢狀路徑(nested paths)來只用一行就能將數個項目引入作用域中。我們先指明相同路徑的部分,加上雙冒號,然後在大括號內列出各自不同的路徑部分,如範例 7-18 所示。

檔案名稱:src/main.rs

use rand::Rng;
// --省略--
use std::{cmp::Ordering, io};
// --省略--

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("祕密數字為:{}", secret_number);

    println!("請輸入你的猜測數字。");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("讀取行數失敗");

    let guess: u32 = guess.trim().parse().expect("請輸入一個數字!");

    println!("你的猜測數字:{}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("太小了!"),
        Ordering::Greater => println!("太大了!"),
        Ordering::Equal => println!("獲勝!"),
    }
}

範例 7-18:使用巢狀路徑引入有部分相同前綴的數個路徑至作用域中

在較大的程式中,使用巢狀路徑將相同 crate 或相同模組中的許多項目引入作用域,可以大量減少 use 陳述式的數量!

我們可以在路徑中的任何部分使用巢狀路徑,這在組合兩個享有相同子路徑的 use 陳述式時非常有用。舉例來說,範例 7-19 顯示了兩個 use 陳述式:一個將 std::io 引入作用域,另一個將 std::io::Write 引入作用域。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
use std::io;
use std::io::Write;
}

範例 7-19:兩個 use 陳述式且其中一個是另一個的子路徑

這兩個路徑的相同部分是 std::io,這也是整個第一個路徑。要將這兩個路徑合為一個 use 陳述式的話,我們可以在巢狀路徑使用 self,如範例 7-20 所示。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
use std::io::{self, Write};
}

範例 7-20:組合範例 7-19 的路徑為一個 use 陳述式

此行就會將 std::iostd::io::Write 引入作用域。

全域運算子

如果我們想要將在一個路徑中所定義的所有公開項目引入作用域的話,我們可以在指明路徑之後加上全域(glob)運算子 *


#![allow(unused)]
fn main() {
use std::collections::*;
}

use 陳述式會將 std::collections 定義的所有公開項目都引入作用域中。不過請小心使用全域運算子!它容易讓我們無法分辨作用域內的名稱,以及程式中使用的名稱是從哪定義來的。

全域運算子很常用在 tests 模組下,將所有東西引入測試中。我們會在第十一章的「如何寫測試」段落來討論。 全域運算子也常拿來用在 prelude 模式中,你可以查閱標準函式庫的技術文件來瞭解此模式的更多資訊。

將模組拆成不同檔案

本章節目前所有的範例將數個模組定義在同一個檔案中。當模組增長時,你可能會想要將它們的定義拆開到別的檔案中,好讓程式碼容易瀏覽。

舉例來說,讓我修將範例 7-17 中的 front_of_house 模組移到它自己的檔案 src/front_of_house.rs,然後在 crate 源頭檔案加上這個模組,如範例 7-21 所示。在此例中,源頭檔案為 src/lib.rs 不過這步驟在二進制執行檔 crate 的 src/main.rs 一樣可行。

檔案名稱:src/lib.rs

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

範例 7-21:宣告 front_of_house 模組,其本體位於 src/front_of_house.rs

然後 front_of_house 模組的本體會定義在 src/front_of_house.rs,如範例 7-22 所示。

檔案名稱:src/front_of_house.rs

pub mod hosting {
    pub fn add_to_waitlist() {}
}

範例 7-22:front_of_house 模組的定義位於 src/front_of_house.rs

mod front_of_house 之後用分號而不是大括號會告訴 Rust 讀取其他與模組同名的檔案以取得模組內容。讓我們繼續將範例中的 hosting 模組也取出並移到它自己的檔案中,我們可以變更 src/front_of_house.rs 成只包含 hosting 模組的宣告

檔案名稱:src/front_of_house.rs

pub mod hosting;

然後我們建立一個目錄 src/front_of_house 以及一個檔案 src/front_of_house/hosting.rs 來包含 hosting 模組的定義:

檔案名稱:src/front_of_house/hosting.rs


#![allow(unused)]
fn main() {
pub fn add_to_waitlist() {}
}

雖然定義都被移到不同的檔案了,但模組樹維持不變,而且在 eat_at_restaurant 的函式呼叫方式也不用做任何更改。此技巧讓你可以將增長中的模組移到新的檔案。

另外 src/lib.rs 內的 pub use crate::front_of_house::hosting 陳述式沒有改變,在檔案作為 crate 的一部分來編譯時,使用 use 的方式也沒有改變。mod 關鍵字能宣告模組,然後 Rust 會去同名的檔案尋找該模組的程式碼。

總結

Rust 讓你能夠將套件拆成數個 crate,然後 crate 能在分成數個模組,好讓你可以從一個模組內指定其他模組的項目。而你可以使用絕對或相對路徑來達成。這些路徑可以用 use 陳述式來引入作用域,讓你可以在該作用域用更短的路徑來多次呼叫該項目。模組程式碼預設為私有的,但你可以使用 pub 關鍵字公開它的定義內容。

在下個章節,我們將探討在標準函式庫中的一些資料結構集合,讓你可以利用它們寫出整潔有組織的程式碼。

常見集合

Rust 的標準函式庫提供一些非常實用的資料結構稱之為集合(collections)。多數其他資料型別只會呈現一個特定數值,但是集合可以包含數個數值。不像內建的陣列與元組型別,這些集合指向的資料位於堆積上,代表資料的數量不必在編譯期就知道,而且可以隨著程式執行增長或縮減。每種集合都有不同的能力以及消耗,依照你的情形選擇適當的集合,是一項你會隨著開發時間漸漸掌握的技能。在本章節我們會介紹三種在 Rust 程式中十分常用的集合:

  • 向量(Vector)允許你接二連三地儲存數量不定的數值。
  • 字串(String)是字元的集合。我們在之前就提過 String 型別,本章會正式深入介紹。
  • 雜湊映射(Hash map)允許你將值(value)與特定的鍵(key)相關聯。這是從一種更通用的資料結構映射(map)衍生出來的特定實作。

想瞭解更多標準函式庫提供的集合種類的話,歡迎查閱技術文件

我們將討論如何建立與更新向量、字串與雜湊映射,以及它們的所長。

透過向量儲存列表

我們第一個要來看的集合是 Vec<T> 常稱為向量(vector)。向量允許你在一個資料結構儲存不止一個數值,而且該結構的記憶體會接連排列所有數值。它們很適合用來處理你手上的項目列表,像是一個檔案中每行的文字,或是購物車內每項物品。

建立新的向量

要建立一個新的空向量的話,我們可以呼叫 Vec::new 函式,如範例 8-1 所示。

fn main() {
    let v: Vec<i32> = Vec::new();
}

範例 8-1建立一個儲存數值型別為 i32 的空向量

注意到我們在此加了型別詮釋。因為我們沒有對此向量插入任何數值,Rust 不知道我們想儲存什麼類型的元素。這是一項重點,向量是用泛型(generics)實作,我們會在第十章說明如何為你自己的型別使用泛型。現在我們只需要知道標準函式庫提供的 Vec<T> 型別可以持有任意型別,然後當特定向量要持有特定型別時,該型別會標示在尖括號內。在範例 8-1,我們告訴 Rust 在 v 中的 Vec<T> 會持有 i32 型別的元素。

在更實際的程式碼中,當你插入數值時,Rust 通常都能推導出型別來。所以你不太常會需要指明型別詮釋。建立 Vec<T> 的同時進行初始化是很常見的,為此 Rust 提供了 vec! 以便使用。此巨集會建立一個新的向量並取得你提供的數值。在範例 8-2 中,我們建立了一個新的 Vec<i32> 並擁有數值 123。整數型別為 i32 是因為這是預設整數型別,如同我們在第三章的「資料型別」 段落提到的一樣。

fn main() {
    let v = vec![1, 2, 3];
}

範例 8-2:建立一個擁有數值的新向量

因為我們給予了初始的 i32 數值,Rust 可以推導出 v 的型別為 Vec<i32>,所以型別詮釋就不是必要的了。接下來,讓我們看看如何修改向量。

更新向量

要在建立向量之後新增元素的話,我們可以使用 push 方法,如範例 8-3 所示。

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

範例 8-3:使用 push 方法來新增數值到向量

與其他變數一樣,如果我們想要變更其數值的話,我們需要使用 mut 關鍵字使它成為可變的,如同第三章提到的一樣。我們插入的數值所屬型別均為 i32,然後 Rust 可以從資料推導,所以我們不必指明 Vec<i32>

釋放向量的同時也會釋放其元素

就像其它 struct 一樣,向量會在作用域結束時被釋放,如範例 8-4 所示。

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // 使用 v 做些事情
    } // <- v 在此離開作用域並釋放
}

範例 8-4:顯示向量及其元素在哪裡被釋放

當向量被釋放時,其所有內容也都會被釋放,代表它持有的那些整數都會被清除。這雖然聽起來很直觀,但是當我們開始引用向量中的元素時可能就會變得有點複雜。讓我們看看怎麼處理這種情形吧!

讀取向量元素

現在你知道如何建立、更新與刪除向量,接下來就是要知道如何讀取他們的內容了。要引用向量儲存的數值有兩種方式。為了更加清楚說明此範例,我們詮釋了函式回傳值的型別。

範例 8-5 顯示了取得向量中數值的方法,可以是用索引語法或者 get 方法。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("第三個元素為 {}", third);

    match v.get(2) {
        Some(third) => println!("第三個元素為 {}", third),
        None => println!("第三個元素並不存在。"),
    }
}

範例 8-5:使用索引語法或 get 方法來取得向量項目

我們要注意兩個地方。首先,我們使用了索引數值 2 來獲取第三個元素:向量可以用數字來索引,從零開始計算。第二,使用 &[] 會給我們一個引用,而使用 get 方法加上一個索引作為引數,則會給我們 Option<&T>

Rust 有兩種取得元素引用的方式,所以能以此決定程式的行為。像是當你使用了一個索引但向量卻沒有對應的元素的情況。讓我們看看一個範例,我們有一個向量擁有五個元素,但我們嘗試用索引 100 來取得對應數值,如範例 8-6 所示。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

範例 8-6:嘗試對只有五個元素的向量取得索引 100 的值

當我們執行程式時,第一個 [] 方法會讓程式恐慌,因為它引用了不存在的元素。此方法適用於當你希望一有無效索引時就讓程式崩潰的狀況。

當你使用 get 方法來索取向量不存在的索引時,它會回傳 None 而不會恐慌。如果正常情況下偶而會不小心存取超出向量範圍索引的話,你就會想要只用此方法。你的程式碼就會有個邏輯專門處理 Some(&element)None,如同第六章所述。舉例來說,可能會有由使用者輸入的索引。如果他不小心輸入太大的數字的話,程式可以回傳 None,你可以告訴使用者目前向量有多少項目,並讓他們可以再輸入一次。這會比直接讓程式崩潰還來的友善,他們可能只是不小心打錯而已!

當程式有個有效引用時,借用檢查器(borrow checker)會貫徹所有權以及借用規則(如第四章所述)來確保此引用及其他對向量內容的引用都是有效的。回想一下有個規則是我們不能在同個作用域同時擁有可變與不可變引用。這個規則一樣適用於範例 8-7,在此我們有一個向量第一個元素的不可變引用,然後我們嘗試在向量後方新增元素。如果我們嘗試在此動作後繼續使用第一個引用的話,程式會無法執行:

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("第一個元素為 {}", first);
}

範例 8-7:在持有一個項目的引用時,還嘗試對向量新增元素

編譯此程式會得到以下錯誤:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 | 
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("第一個元素為 {}", first);
  |                                          ----- immutable borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections`.

To learn more, run the command again with --verbose.

範例 8-7 的程式碼看起來好像能執行。為何第一個元素的引用要在意向量的最後端發生了什麼事呢?此錯誤其實跟向量運作的方式有關:在向量後方新增元素時,如果當前向量的空間不夠在塞入另一個值的話,可能會需要分配新的記憶體並複製舊的元素到新的空間中。這樣一來,第一個元素的索引可能就會指向已經被釋放的記憶體,借用規則會防止程式遇到這樣的情形。

注意:關於 Vec<T> 型別更多的實作細節,歡迎查閱「The Rustonomicon」

遍歷向量的元素

如果我們想要依序存取向量中每個元素的話,我們可以遍歷所有元素而不必用索引一個一個取得。範例 8-8 闡釋了如何使用 for 迴圈來取得一個 i32 向量中每個元素的不可變引用並印出他們。

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{}", i);
    }
}

範例 8-8:使用 for 迴圈遍歷向量中每個元素

我們還可以遍歷可變向量中的每個元素取得可變引用來改變每個元素。像是範例 8-9 就使用 for 迴圈來為每個元素加上 50

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

範例 8-9:遍歷向量中的元素取得可變引用

要改變可變引用指向的數值,在使用 += 運算子之前,我們需要使用解引用運算子(*)來取得 i 的數值。我們會在第十五章的「使用解引用運算子追蹤指標的數值」段落來講解更多解引用運算子的細節。

使用枚舉來儲存多種型別

在本章的一開始,我們說向量只能儲存同型別的數值。這在某些情況會很不方便,一定會有場合是要儲存不同型別到一個列表中的。幸運的是,枚舉的變體是定義在相同的枚舉型別,所以當我們需要在向量儲存不同型別的元素時,我們可以用枚舉來定義!

舉例來說,假設我們想從表格中的一行取得數值,但是有些行內的列會包含整數、浮點數以及一些字串。我們可以定義一個枚舉,其變體會持有不同的數值型別,然後所有的枚舉變體都會被視為相同型別:就是它們的枚舉。接著我們就可以建立一個擁有此枚舉型別的向量,最終達成持有不同型別。如範例 8-10 所示。

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("藍色")),
        SpreadsheetCell::Float(10.12),
    ];
}

範例 8-10:用 enum 定義儲存不同型別的枚舉並作為向量的型別

Rust 需要在編譯時期知道向量的型別以及要在堆積上用到多少記憶體才能儲存每個元素。這樣做第二個好處是我們能顯式知道哪些型別可以放入向量中。如果 Rust 允許向量一次持有任意型別的話,在對向量中每個元素進行處理時,可能就會有一或多種型別會產生錯誤。使用枚舉和 match 表達式讓 Rust 可以在編譯期間確保每個可能的情形都已經處理完善了,如同第六章提到的一樣。

當你在寫程式時,如果你無法確切知道執行時程式所處理的所有型別的話,枚舉就不管用了。這時使用特徵物件會比較好,我們會在第十七章再來解釋。

現在我們已經講了一些向量常見的用法,有時間的話記得到向量的 API 技術文件瞭解標準函式庫中 Vec<T> 所有實用的方法。舉例來說,除了 push 方法以外,還有個 pop 方法可以移除並回傳最後一個元素。接下來讓我們看看下一個集合型別:String

透過字串儲存 UTF-8 編碼的文字

我們已經在第四章提到字串(String),但現在我們要更加深入探討。Rustaceans 初學者常常會卡在三個環節:Rust 傾向於回報可能的錯誤、字串的資料結構比開發者所熟悉的還要複雜,以及 UTF-8。這些要素讓來自其他程式語言背景的開發者會遇到一些困難。

我們會在集合章節討論字串的原因是,字串本身就是位元組的集合,且位元組作為文字呈現時,它會提供一些實用的方法。在此段落我們將和其他集合型別一樣討論 String 的操作,像是建立、更新與讀取。我們還會討論到 String 與其他集合不一樣的地方,像是 String 的索引就比其他集合還複雜,因為它會依據人們對於 String 資料型別的理解而有所不同。

什麼是字串?

首先我們要好好定義字串(String)這個術語。Rust 在核心語言中只有一個字串型別,那就是字串切片 str,它通常是以借用的形式存在 &str。在第四章中我們提到字串切片是一個針對存在某處的 UTF-8 編碼資料的引用。舉例來說,字串字面值(String literals)就儲存在程式的二進制檔案中,因此就是字串切片。

String 型別是 Rust 標準函式庫所提供的型別,並不是核心語言內建的型別,它是可增長的、可變的、可擁有所有權的 UTF-8 編碼字串型別。當 Rustaceans 提及 Rust 中的「字串」時,他們通常指的是 String 以及字串切片 &str 型別,而不只是其中一種型別。雖然此段落大部分都在討論 String,這兩個型別都時常用在 Rust 的標準函式庫中,且 String 與字串切片都是 UTF-8 編碼的。

Rust 的標準函式庫還包含了其他種類的字串型別,像是 OsStringOsStrCString 以及 CStr。函式庫 crates 更可以提供儲存字串資料的更多選項。你應該會注意到這些型別的結尾都是 StringStr,它們分別代表擁有所有權與借用的變體。就像你之前看到的 Stringstr 型別一樣。這些字串型別可以儲存不同編碼的文字或者以不同的記憶體形式呈現。我們不會在本章節討論這些字串型別,要是你想知道如何或何時使用它們的話,你可以查閱它們的 API 技術文件。

建立新的字串

許多 Vec<T> 可使用的方法在 String 也都能用,像是用 new 函式建立新的字串,如範例 8-11 所示。

fn main() {
    let mut s = String::new();
}

範例 8-11:建立新的空 String

此行會建立新的字串叫做 s,我們之後可以再寫入資料。不過通常我們會希望建立字串的同時能夠初始化資料。為此我們可以使用 to_string 方法,任何有實作 Display 特徵的型別都可以使用此方法,就像字串字面值的使用方式一樣。範例 8-12 就展示了兩種例子。

fn main() {
    let data = "初始內容";

    let s = data.to_string();

    // 此方法也能直接用於字面值上
    let s = "初始內容".to_string();
}

範例 8-12:從字串字面值使用 to_string 方法來建立 String

此程式碼建立了一個字串內容為 初始內容

我們也可以用函式 String::from 從字串字面值建立 String。範例 8-13 的程式碼和使用 to_string 的範例 8-12 效果一樣。

fn main() {
    let s = String::from("初始內容");
}

範例 8-13:使用函式 String::from 從字串字面值建立 String

因為字串用在許多地方,我們可以使用許多不同的通用字串 API 供我們選擇。有些看起來似乎是多餘的,但是它們都有一席之地的!在上面的範例中 String::fromto_string 都在做相同的事,所以你的選擇在於喜好風格上。

另外記得字串是 UTF-8 編碼的,所以我們可以包含任何正確編碼的資料,如範例 8-14 所示。

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

範例 8-14:用字串儲存各種語言打招呼的文字

以上全是合理的 String 數值。

更新字串

就和 Vec<T> 一樣,如果你插入更多資料的話,String 可以增長大小並變更其內容。除此之外你也可以使用 + 運算子或 format! 巨集來串接 String 數值。

使用 push_strpush 追加字串

我們可以使用 push_str 方法來追加一個字串切片使字串增長,如範例 8-15 所示。

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

範例 8-15:使用 push_str 方法向 String 追加字串切片

在這兩行之後,s 會包含 foobarpush_str 方法取得的是字串切片因為我們並不需要取得參數的所有權。舉例來說範例 8-16 就說明了如果 s2 在追加其內容給 s1 之後卻不能使用的話,就很不妙了。

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {}", s2);
}

範例 8-16:在內容追加給 String 後繼續使用字串切片

如果 push_str 方法會取得 s2 的所有權,我們就無法在最後一行印出其數值了。幸好這段程式碼是可以執行的!

push 方法會取得一個字元作為參數並加到 String 上。範例 8-17 顯示了一個使用 push 方法將字母 l 加到 String 的程式碼。

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

範例 8-17:使用 push 將一個字元加到 String

此程式碼的結果就是 s 會包含 lol

使用 + 運算子或 format! 巨集串接字串

你通常會想要組合兩個字串在一起,其中一種方式是用 + 運算子。如範例 8-18 所示。

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // 注意到 s1 被移動因此無法再被使用
}

範例 8-18:使用 + 運算子組合兩個 String 數值成一個新的 String 數值

程式碼最後的字串 s3 就會獲得 Hello, world!s1 之所以在相加後不再有效,以及 s2 是使用引用的原因,都和我們使用 + 運算子時呼叫的方法簽名有關。+ 運算子使用的是 add 方法,其簽名會長得像這樣:

fn add(self, s: &str) -> String {

這不全是標準函式庫中實際的簽名,在標準函式庫中 add 是用泛型(generics)定義。我們在此看到的是使用實際型別指明泛型的 add 簽名。我們會在第十章討論到泛型。此簽名給了一些我們需要瞭解 + 運算子的一些線索。

首先 s2& 代表我們是將第二個字串的引用與第一個字串相加,因為函式 add 中的參數 s 說明我們只能將 &strString 相加,我們無法將兩個 String 數值相加。但等等 &s2&String 才對,並非 add 第二個參數所指定的 &str。為何範例 8-18 可以編譯呢?

我們可以在 add 的呼叫中使用 &s2 的原因是因為編譯器可以強制(coerce) &String 引數轉換成 &str。當我們我們呼叫 add 方法時,Rust 強制解引用(deref coercion)讓 &s2 變成 &s2[..]。我們會在第十五章深入探討強制解引用。因為 add 不會取得 s 參數的所有權,s2 在此運算後仍然是個有效的 String

再來,我們可以看到 add 的簽名會取得 self 的所有權,因為 self 沒有 &。這代表範例 8-18 的 s1 會移動到 add 的呼叫內,在之後就不再有效。所以雖然 let s3 = s1 + &s2; 看起來像是它拷貝了兩個字串的值並產生了一個新的,但此陳述式實際上是取得 s1 的所有權、追加一份 s2 的複製內容、然後回傳最終結果的所有權。換句話說,雖然它看起來像是產生了很多拷貝,但實際上並不是。此實作反而比較有效率。

如果我們需要串接數個字串的話,+ 運算子的行為看起來就顯得有點笨重了:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

此時 s 會是 tic-tac-toe。有這麼多的 +" 字元,我們很難看清楚發生什麼事。如果要完成更複雜的字串組合的話,我們可以使用 format! 巨集:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{}-{}-{}", s1, s2, s3);
}

此程式碼一樣能設置 s 為to tic-tac-toeformat! 巨集運作的方式和 println! 一樣,但不會將輸出結果顯示在螢幕上,它做的是回傳內容的 String。使用 format! 的程式碼版本看起來比較好讀懂,而且不會取走任何參數的所有權。

索引字串

在其他許多程式語言中,使用索引引用字串來取得獨立字元是有效且常見的操作。然而在 Rust 中如果你嘗試對 String 使用索引語法的話,你會得到錯誤。請看看範例 8-19 這段無效的程式碼。

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

範例 8-19:嘗試在字串使用索引語法

此程式會有以下錯誤結果:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `std::string::String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `std::string::String` cannot be indexed by `{integer}`
  |
  = help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections`.

To learn more, run the command again with --verbose.

錯誤訊息與提示告訴了我們 Rust 字串並不支援索引。但為何不支援呢?要回答此問題,我們需要先討論 Rust 如何儲存字串進記憶體的。

內部呈現

String 基本上就是 Vec<u8> 的封裝。讓我們看看範例 8-14 中一些正確編碼為 UTF-8 字串的例子,像是這一個:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

在此例中 len 會是 4,也就是向量儲存的字串「Hola」長度為 4 個位元組。每個字母在用 UTF-8 編碼時長度均為 1 個位元組。那接下來這段呢?(請注意字串的開頭是西里爾字母 Ze 的大寫,而不是阿拉伯數字 3)

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

你可能會以為這字串的長度為 12,然而 Rust 給的答案卻是 24。這是將「Здравствуйте」用 UTF-8 編碼後的位元組長度,因為該字串的每個 Unicode 純量都佔據兩個位元組。因此字串位元組的索引不會永遠都能對應到有效的 Unicode 純量數值。我們用以下無效的 Rust 程式碼進一步說明:

let hello = "Здравствуйте";
let answer = &hello[0];

answer 的數值會是多少呢?會是第一個字母 З 嗎?當經過 UTF-8 編碼時,З 的第一個位元組會是 208 然後第二個是 151。所以 answer 實際上會拿到 208,但 208 本身又不是個有效字元。回傳 208 可能不會是使用者想要的,他們希望的應該是此字串的第一個字母,但這是 Rust 在位元組索引 0 唯一能回傳的資料。就算字串都只包含拉丁字母,使用者通常也不會希望看到位元組數值作為回傳值。如果 &"hello"[0] 是有效程式碼且會回傳位元組數值的話,它會回傳的是 104 並非 h。為了預防回傳意外數值進而導致無法立刻察覺的錯誤,Rust 不會成功編譯這段程式碼,並在開發過程前期就杜絕誤會發生。

位元組、純量數值與形素群集!我的天啊!

UTF-8 還有一個重點是在 Rust 中我們實際上可以有三種觀點來理解字串:位元組、純量數值(scalar values)以及形素群集(grapheme clusters,最接近人們常說的「字母」)。

如果我們觀察用天成體寫的印度語「नमस्ते」,它存在向量中的 u8 數值就會長這樣:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

這 18 個位元組是電腦最終儲存的資料?如果我們用 Unicode 純量數值觀察的話,也就是 Rust 的 char 型別,這些位元組會組成像這樣:

['न', 'म', 'स', '्', 'त', 'े']

這邊有六個 char 數值,但第四個和第六個卻不是字母,它們是單獨存在不具任何意義的變音符號。最後如果我們以形素群集的角度來看的話,我們就會得到一般人所說的構成此印度語的四個字母:

["न", "म", "स्", "ते"]

Rust 提拱多種不同的方式來解釋電腦中儲存的原始字串資料,讓每個程式無論是何種人類語言的資料,都可以選擇它們需要的呈現方式。

Rust 還有一個不允許索引 String 來取得字元的原因是因為,索引運算必須永遠預期是花費常數時間(O(1))。但在 String 上無法提供這樣的效能保證,因為 Rust 會需要從索引的開頭遍歷每個內容才能決定多少有效字元存在。

字串切片

索引字串通常不是個好點子,因為字符串索引要回傳的型別是不明確的,是要一個位元組數值、一個字元、一個形素群集還是一個字串切片呢。因此如果你真的想要使用索引建立字串切片的話,Rust 會要你更明確些。要明確指定你的索引與你想要的字串切片,與其在 [] 只使用一個數字來索引,你可以在 [] 指定一個範圍來建立包含特定位元組的字串切片:


#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

s 在此會是 &str 並包含字串前 4 個位元組。稍早我們提過這些字元各佔 2 個位元組,所以這裡的 s 就是 Зд

那如果我們只用 &hello[0..1] 呢?答案是 Rust 會和在向量中取得無效索引一樣在執行時恐慌:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/libcore/str/mod.rs:2069:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

你在使用範圍來建立字串切片時要格外小心,因為這樣做有可能會使你的程式崩潰。

遍歷字串的方法

幸運的是你有其他方法來取得字串的元素。

如果你需要對每個獨立的 Unicode 純量型別做運算的話,最好的方式是使用 chars 方法。對「नमस्ते」呼叫 chars 會將六個擁有 char 型別的數值拆開並回傳,這樣一來你就可以遍歷每個元素:


#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

此程式碼會顯示以下輸出:

न
म
स
्
त
े

bytes 方法會回傳每個原始位元組,可能會在某些場合適合你:


#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

此程式碼會印出此 String 的 18 個位元組:

224
164
// --省略--
165
135

請確定你已經瞭解有效的 Unicode 純量數值可能不止佔 1 個位元組。

而要從字串取得形素群集的話就非常複雜了,所以標準函式庫並未提供這項功能。如果你需要的話,crates.io 上會有提供這項功能的 crate。

字串並不簡單

總結來說,字串是很複雜的。不同的程式語言會選擇不同的決定來呈現給程式設計師。Rust 選擇正確處理 String 的方式作為所有 Rust 程式的預設行為,這也代表開發者在處理 UTF-8 資料時需要多加考量。這樣的取捨的確對比其他程式語言來說,增加了不少字串的複雜程度,但是這能讓你在開發週期免於處理非 ASCII 字元相關的錯誤。

讓我們接下去開一個較簡單地集合吧:雜湊映射(hash maps)!

透過雜湊映射儲存鍵值配對

我們最後一個常見的集合是雜湊映射(hash map)HashMap<K, V> 型別會儲存一個鍵(key)型別 K 對應到一個數值(value)型別 V。它透過雜湊函式(hashing function)來決定要將這些鍵與值放在記憶體何處。許多程式語言都有支援這種類型的資料結構,不過通常它們會提供不同的名稱,像是 hash、map、object、hash table、dictionary 或 associative array 等等。

雜湊映射適合用於當你不想像向量那樣用索引搜尋資料,而是透過一個可以為任意型別的鍵來搜尋的情況。舉例來說,在比賽中我們可以使用雜湊映射來儲存每隊的分數,每個鍵代表隊伍名稱,而每個值代表隊伍分數。給予一個隊伍名稱,你就能取得該隊伍分數。

我們會在此段落介紹雜湊映射的基本 API,但還有很多實用的函式定義在標準函式庫的 HashMap<K, V> 中,所以別忘了查閱標準函式庫的技術文件來瞭解更多資訊。

建立新的雜湊映射

你可以用 new 建立一個空的雜湊映射並用 insert 加入新元素。在範例 8-20 我們追蹤兩支隊伍的分數,分別為藍隊與黃隊。藍隊初始分數有 10 分,黃隊則有 50 分。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("藍隊"), 10);
    scores.insert(String::from("黃隊"), 50);
}

範例 8-20:建立新的雜湊映射並插入一些鍵值

注意到我們需要先使用 use 將標準函式庫的 HashMap 集合引入。在我們介紹的三個常見集合中,此集合是最少被用到的,所以它並沒有包含在 prelude 內讓我們能自動引用。雜湊映射也沒有像前者那麼多標準函式庫提供的支援,像是內建建構它們的巨集。

和向量一樣,雜湊映射會將它們的資料儲存在堆積上。此 HashMap 得鍵是 String 型別而值是 i32 型別。和向量一樣,雜湊函式宣告後就都得是同類的,所有的鍵都必須是同型別,且所有的值也都必須是同型別。

另一種建構雜湊映射的方式為使用疊代器並在一個元組組成的向量中使用 collect 方法,其中每個元組都包含一個鍵與值的配對。我們會在第十三章的「使用疊代器來處理一系列的項目」段落中深入探討疊代器與它們相關的方法。collect 方法會將收集的資料轉換成其他集合型別,包含 HashMap。舉例來說,如果我們有兩個向量分別是隊伍名稱與隊伍分數的話,我們可以使用 zip 方法來產生由元組組成的向量,其中「藍隊」會與 10 配對,以此類推。然後我們就能用 collect 方法將元組向量轉換成雜湊映射,如範例 8-21 所示。

fn main() {
    use std::collections::HashMap;

    let teams = vec![String::from("藍隊"), String::from("黃隊")];
    let initial_scores = vec![10, 50];

    let mut scores: HashMap<_, _> =
        teams.into_iter().zip(initial_scores.into_iter()).collect();
}

範例 8-21:從隊伍列表與分數列表來產生雜湊映射

HashMap<_, _> 的型別詮釋是必要的,因為 collect 可以產生不同種類的資料結構,而除非你指明不然 Rust 無法知道你要何種型別。但在指明鍵值型別的參數中,我們卻使用底線。這是因為 Rust 可以依據向量 的資料型別推導出雜湊映射的型別。在範例 8-21 中的鍵型別就會是 String 然後值的型別就會是 i32,如同範例 8-20 的型別一樣。

雜湊映射與所有權

像是 i32 這種有實作 Copy 特徵的型別其數值可以被拷貝進雜湊映射之中。但對於像是 String 這種擁有所有權的數值則會被移動到雜湊映射,並成為該數值新的擁有者,如範例 8-22 所示。

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("藍隊");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name 和 field_value 在這之後就不能使用了,你可以試著使用它們並看看編譯器回傳什麼錯誤
}

範例 8-22:展示當鍵值插入雜湊映射後就會擁有它們

我們之後就無法使用變數 field_namefield_value,因為它們的值已經透過呼叫 insert 被移入雜湊映射之中。

如果我們插入雜湊映射的數值是引用的話,該值就不會被移動到雜湊映射之中。不過該值的引用就必須一直有效,至少直到該雜湊映射離開作用域為止。我們會在第十章的“透過生命週期驗證引用” 段落討落更多細節。

取得雜湊映射的數值

我們可以透過 get 方法並提供鍵來取得其在雜湊映射對應的值,如範例 8-23 所示。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("藍隊"), 10);
    scores.insert(String::from("黃隊"), 50);

    let team_name = String::from("藍隊");
    let score = scores.get(&team_name);
}

範例 8-23:取得雜湊映射中藍隊的分數

score 在此將會是對應藍隊的分數,而且結果會是 Some(&10)。結果是使用 Some 的原因是因為 get 回傳的是 Option<&V>。如果雜湊映射中該鍵沒有對應值的話,get 就會回傳 None。所以程式會需要透過我們在第六章談到的方式處理 Option

我們也可以使用 for 迴圈用類似的方式來遍歷雜湊映射中每個鍵值配對:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("藍隊"), 10);
    scores.insert(String::from("黃隊"), 50);

    for (key, value) in &scores {
        println!("{}: {}", key, value);
    }
}

此程式會以任意順序印出每個配對:

黃隊: 50
藍隊: 10

更新雜湊映射

雖然鍵值配對的數量可以增加,但每個鍵同一時間就只能有一個對應的值而已。當你想要改變雜湊映射的資料的話,你必須決定如何處理當一個鍵已經有一個值的情況。你可以不管舊的值,直接用新值取代。你也可以保留舊值、忽略新值,只有在該鍵尚未擁有對應數值時才賦值給它。或者你也可以將舊值與新值組合起來。讓我們看看分別怎麼處理吧!

覆蓋數值

如果我們在雜湊映射插入一個鍵值配對,然後又在相同鍵插入不同的數值的話,該鍵相對應的數值就會被取代。如範例 8-24 雖然我們呼叫了兩次 insert,但是雜湊映射只會保留一個鍵值配對,因為我們向藍隊的鍵插入了兩次數值。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("藍隊"), 10);
    scores.insert(String::from("藍隊"), 25);

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

範例 8-24:替換某個特定鍵對應的數值

此程式碼會印出 {"藍隊": 25},原本的數值 10 會被覆蓋。

只在鍵沒有值的情況下插入數值

通常檢查某個特定的鍵有沒有數值,如果沒有的話才插入數值是很常見的。雜湊映射提供了一個特別的 API 叫做 entry 讓你可以用想要檢查的鍵作為參數。entry 方法的回傳值是一個枚舉叫做 Entry,它代表了一個可能存在或不存在的數值。假設我們想要檢查黃隊的鍵有沒有對應的數值。如果沒有的話,我們想插入 50。而對藍隊也一樣。使用 entry API 的話,程式碼會長得像範例 8-25。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("藍隊"), 10);

    scores.entry(String::from("黃隊")).or_insert(50);
    scores.entry(String::from("藍隊")).or_insert(50);

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

範例 8-25:使用 entry 方法在只有該鍵尚無任何數值時插入數值

Entry 中的 or_insert 方法定義了如果 Entry 的鍵有對應的數值的話,就回傳該值的可變引用;如果沒有的話,那就插入參數作為新數值,並回傳此值的可變引用。這樣的技巧比我們親自寫邏輯還來的清楚,而且更有利於借用檢查器的檢查。

執行範例 8-25 的程式碼會印出 {"黃隊": 50, "藍隊": 10}。第一次 entry 的呼叫會對黃隊插入數值 50,因為黃隊尚未有任何數值。第二次 entry 的呼叫則不會改變雜湊映射,因為藍隊已經有數值 10。

依據舊值更新數值

雜湊映射還有另一種常見的用法是,依照鍵的舊數值來更新它。舉例來說,範例 8-26 展示了一支如何計算一些文字內每個單字各出現多少次的程式碼。我們使用雜湊映射,鍵為單字然後值為我們每次追蹤計算對應單字出現多少次的次數。如果我們是第一次看到該單字的話,我們插入數值 0。

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

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

範例 8-26:使用雜湊映射儲存單字與次數來計算每個字出現的次數

此程式碼會印出 {"world": 2, "hello": 1, "wonderful": 1}or_insert 方法會回傳該鍵對應數值的可變引用(&mut V)。在此我們將可變引用儲存在 count 變數中,所以要賦值的話,我們必須先使用 * 來解引用(dereference)count。可變引用會在 for 結束時離開作用域,所以所有的改變都是安全的且符合借用規則。

雜湊函式

HashMap 預設是使用一種「密碼學安全(cryptographically strong)」1的雜湊函式(hashing function),這可以抵禦阻斷服務(Denial of Service, DoS)的攻擊。這並不是最快的雜湊演算法,但為了提升安全性而犧牲一點效能是值得的。如果你做評測時覺得預設的雜湊函式太慢無法滿足你的需求的話,你可以指定不同的 hasher 來切換成其他雜湊函式。Hasher 是一個有實作 BuildHasher 特徵的型別。我們會在第十章討論到特徵以及如何實作它們。你不必從頭自己實作一個 hasher,crates.io 上有其他 Rust 使用者分享的函式庫,其中就有不少提供許多常見雜湊演算法的 hasher 實作。

總結

當你的程式需要儲存、取得、修改資料時,向量、字串與雜湊映射可以提供大量的功能。以下是一些你應該能夠解決的練習題:

  • 給予一個整數列表,請使用向量並回傳算數平均數、中位數(排序列表後正中間的值)以及眾數(出現最多次的值,雜湊映射在此應該會很有用)。
  • 將字串轉換成 pig latin。每個單字的第一個字母為子音的話,就將該字母移到單字後方,並加上「ay」,所以「first」會變成「irst-fay」。而單字第一個字母為母音的話,就在單字後方加上「hay」,所以「apple」會變成「apple-hay」。請注意要考慮到 UTF-8 編碼!
  • 使用雜湊映射與向量來建立文字介面,讓使用者能新增員工名字到公司內的一個部門。舉來來說「將莎莉加入工程部門」或「將阿米爾加入業務部門」。然後讓使用者可以索取一個部門所有的員工列表,或是依據部門用字點順序排序,取得公司內所有的員工。

標準函式庫的 API 技術文件有詳細介紹向量、字串與雜湊映射的所有方法,這對於這些練習題應該會很有幫助!

我們現在已經開始遇到有可能會運作失敗的複雜程式了,所以接下來正是來討論錯誤處理的時候!

錯誤處理

Rust 對可靠性的注重也包含錯誤處理。錯誤是軟體開發中不可避免的一環,所以 Rust 有一些特色能夠處理發生錯誤的情形。在許多情況下,Rust 要求你要任知道可能出錯的地方,並在編譯前採取行動。這樣的要求能讓你的程式更穩定,確保你能發現錯誤並在程式碼發佈到生產環境前妥善處理它們!

Rust 將錯誤分成兩大類:可復原的(recoverable)和不可復原的(unrecoverable)錯誤。像是找不到檔案這種可復原的錯誤,回報問題給使用者並重試是很合理的。而不可復原的錯誤就會是程式錯誤的跡象,像是嘗試取得陣列結尾之後的位置。

許多語言不會區分這兩種錯誤,並以相同的方式處理,使用像是例外(exceptions)這樣統一的機制處理。Rust 沒有例外處理機制,取而代之的是它對可復原的錯誤提供 Result<T, E> 型別,對不可復原的錯誤使用 panic! 將程式停止執行。本章節會先介紹 panic! 再來討論 Result<T, E> 數值的回傳。除此之外,我們也將探討何時該從錯誤中復原,何時該選擇停止程式。

對無法復原的錯誤使用 panic!

有時候壞事就是會發生在你的程式中,這本來就是你沒辦法全部避免的。在這種情況,Rust 有提供 panic! 巨集。當 panic! 巨集執行時,你的程式就會印出程式出錯的訊息,展開並清理堆疊,然後離開程式。這常用來處理當程式遇到某種錯誤時,開發者不清楚如何處理該錯誤的狀況。

恐慌時該解開堆疊還是直接終止

當恐慌(panic)發生時,程式預設會開始做解開(unwind)堆疊的動作,這代表 Rust 會回溯整個堆疊,並清理每個它遇到的函式資料。但是這樣回溯並清理的動作很花力氣。另一種方式是直接終止(abort)程式而不清理,程式使用的記憶體會需要由作業系統來清理。如果你需要你的專案產生的二進制檔案越小越好,你可以從解開切換成終止,只要在 Cargo.toml 檔案中的 [profile] 段落加上 panic = 'abort' 就好。舉例來說,如果你希望在發佈模式(release mode)恐慌時直接終止,那就加上:

[profile.release]
panic = 'abort'

讓我們先在小程式內試試呼叫 panic!

檔案名稱:src/main.rs

fn main() {
    panic!("◢▆▅▄▃ 崩╰(〒皿〒)╯潰▃▄▅▆◣");
}

當你執行程式時,你會看到像這樣的結果:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at '◢▆▅▄▃ 崩╰(〒皿〒)╯潰▃▄▅▆◣', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

panic! 的呼叫導致印出了最後兩行的錯誤訊息。第一行顯示了我們的恐慌訊息以及該恐慌是在原始碼何處發生的:src/main.rs:2:5 指的是它發生在我們的 src/main.rs 檔案第二行第五個字元。

在此例中,該行指的就是我們寫的程式碼。如果我們查看該行,我們會看到 panic! 巨集的呼叫。在其他情形,panic! 的呼叫可能會發生在我們呼叫的程式碼內,所以錯誤訊息回報的檔案名稱與行數可能就會是其他人呼叫 panic! 巨集的程式碼,而不是因為我們的程式碼才導致 panic! 的呼叫。我們可以在呼叫 panic! 程式碼的地方使用 backtrace 來找出出現問題的地方。接下來我們就會深入瞭解 backtrace。

使用 panic! Backtrace

讓我們看看另一個例子,這是函式庫發生錯誤而呼叫 panic!,而不是來自於我們在程式碼自己呼叫的巨集。範例 9-1 是個嘗試從向量取得元素的例子。

檔案名稱:src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

範例 9-1:嘗試取得超出向量長度的元素,進而導致 panic! 被呼叫

我們在這邊嘗試取得向量中第 100 個元素(不過因為索引從零開始,所以是索引 99),但是它只有 3 個元素。在此情況下,Rust 就會恐慌。使用 [] 會回傳元素,但是如果你傳遞了無效的索引,Rust 就回傳不了正確的元素。

在 C 中,嘗試讀取資料結構結束之後的元素屬於未定義行為。你可能會得到該記憶體位置對應其資料結構的元素,即使該記憶體完全不屬於該資料結構。這就稱做緩衝區過讀(buffer overread)而且會導致安全漏洞。攻擊者可能故意操縱該索引來取得在資料結構後面他們原本不應該讀寫的值。

為了保護你的程式免於這樣的漏洞,如果你嘗試用一個不存在的索引讀取元素的話,Rust 會停止執行並拒絕繼續運作下去。讓我們嘗試執行並看看會如何:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/slice/mod.rs:2806:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

此錯誤指向了一個不是我們寫的檔案 libcore/slice/mod.rs。這是實作 slice 的 Rust 原始碼。當我們在我們的 v 使用 [] 時就會執行 libcore/slice/mod.rs 內的程式碼,而這正是 panic! 實際發生的地方。

下一行提示告訴我們可以設置 RUST_BACKTRACE 環境變數來取得 backtrace 以知道錯誤發生時到底發生什麼事。backtrace 是一個函式列表,指出得到此錯誤時到底依序呼叫了哪些函式。Rust 的 backtraces 運作方式和其他語言一樣:讀取 backtrace 關鍵是從最一開始讀取直到你看到你寫的檔案。那就會是問題發生的源頭。你寫的程式碼以上的行數就是你所呼叫的程式,而以下則是其他呼叫你的程式碼的程式。這些行數可能還會包含 Rust 核心程式碼、標準函式庫程式碼,或是你所使用的 crate。我們設置 RUST_BACKTRACE 環境變數的值不為 0,來嘗試取得 backtrace 吧。你應該會看到和範例 9-2 類似的結果。

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/slice/mod.rs:2806:10
stack backtrace:
   0: backtrace::backtrace::libunwind::trace
             at /Users/runner/.cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/libunwind.rs:88
   1: backtrace::backtrace::trace_unsynchronized
             at /Users/runner/.cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/mod.rs:66
   2: std::sys_common::backtrace::_print_fmt
             at src/libstd/sys_common/backtrace.rs:84
   3: <std::sys_common::backtrace::_print::DisplayBacktrace as core::fmt::Display>::fmt
             at src/libstd/sys_common/backtrace.rs:61
   4: core::fmt::ArgumentV1::show_usize
   5: std::io::Write::write_fmt
             at src/libstd/io/mod.rs:1426
   6: std::sys_common::backtrace::_print
             at src/libstd/sys_common/backtrace.rs:65
   7: std::sys_common::backtrace::print
             at src/libstd/sys_common/backtrace.rs:50
   8: std::panicking::default_hook::{{closure}}
             at src/libstd/panicking.rs:193
   9: std::panicking::default_hook
             at src/libstd/panicking.rs:210
  10: std::panicking::rust_panic_with_hook
             at src/libstd/panicking.rs:471
  11: rust_begin_unwind
             at src/libstd/panicking.rs:375
  12: core::panicking::panic_fmt
             at src/libcore/panicking.rs:84
  13: core::panicking::panic_bounds_check
             at src/libcore/panicking.rs:62
  14: <usize as core::slice::SliceIndex<[T]>>::index
             at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/slice/mod.rs:2806
  15: core::slice::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/slice/mod.rs:2657
  16: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/liballoc/vec.rs:1871
  17: panic::main
             at src/main.rs:4
  18: std::rt::lang_start::{{closure}}
             at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libstd/rt.rs:67
  19: std::rt::lang_start_internal::{{closure}}
             at src/libstd/rt.rs:52
  20: std::panicking::try::do_call
             at src/libstd/panicking.rs:292
  21: __rust_maybe_catch_panic
             at src/libpanic_unwind/lib.rs:78
  22: std::panicking::try
             at src/libstd/panicking.rs:270
  23: std::panic::catch_unwind
             at src/libstd/panic.rs:394
  24: std::rt::lang_start_internal
             at src/libstd/rt.rs:51
  25: std::rt::lang_start
             at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libstd/rt.rs:67
  26: panic::main

範例 9-2:當 RUST_BACKTRACE 設置時,透過呼叫 panic! 產生的 backtrace

輸出結果有點多啊!你看到的實際輸出可能會因你的作業系統與 Rust 版本而有所不同。要取得這些資訊的 backtrace,除錯符號(debug symbols)必須啟用。當我們在使用 cargo buildcargo run 且沒有加上 --release 時,除錯符號預設是啟用的。

在範例 9-2 的輸出結果中,第 17 行的 backtrace 指向了我們專案中產生問題的地方:src/main.rs 中的第四行。如果我們不想讓程式恐慌,我們就要來調查我們所寫的程式中第一個被錯誤訊息指向的位置。在範例 9-1 中,我們為了顯示如何使用 backtrace,故意寫出會恐慌的程式碼。要修正的方法就是不要向只有 3 個元素的向量要求取得索引 99 的值。當在未來你的程式碼恐慌時,你會需要知道是程式碼中的什麼動作造成的、什麼數值導致恐慌以及正確的程式碼該怎麼處理。

我們會在本章節「要 panic! 還是不要 panic!的段落中再回來看 panic! 並研究何時該與不該使用 panic! 來處理錯誤條件。接下來,我們要看如何使用 Result來處理可回復的錯誤。

Result 與可復原的錯誤

大多數的錯誤沒有嚴重到需要讓整個程式停止執行。有時候當函式失敗時,你是可以輕易理解並作出反應的。舉例來說,如果你嘗試開啟一個檔案,但該動作卻因為沒有該檔案而失敗的話,你可能會想要建立檔案,而不是終止程序。

回憶一下第二章的「使用 Result 型別處理可能的錯誤」提到 Result 枚舉的定義有兩個變體 OkErr,如以下所示:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

TE 是泛型型別參數,我們會在第十章深入討論泛型。你現在需要知道的是 T 代表我們在成功時會在 Ok 變體回傳的型別,而 E 則代表失敗時在 Err 變體會回傳的錯誤型別。因為 Result 有這些泛型型別參數,我們可以將 Result 型別和標準函式庫運用到它的函式用在許多不同場合,讓成功與失敗時回傳的型別不相同。

讓我們呼叫一個可能會失敗的函式並回傳 Result 型別。在範例 9-3 我們嘗試開啟一個檔案。

檔案名稱:src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

範例 9-3:嘗試開啟一個檔案

我們怎麼知道 File::open 會回傳 Result呢?我們可以查閱標準函式庫的 API 技術文件,或者我們也可以親自去問編譯器!如果我們給予 f 一個型別詮釋,但是我們知道它和函式回傳值並不相同,接著嘗試編譯程式碼的話,編譯器會告訴我們型別不符。錯誤訊息會告訴我們 f 該有何種型別。讓我們試試看!我們知道 File::open 的回傳型別不是 u32,所以讓我們改變 let f 成這樣:

use std::fs::File;

fn main() {
    let f: u32 = File::open("hello.txt");
}

嘗試編譯的話會給我們以下輸出結果:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |            ---   ^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `std::result::Result`
  |            |
  |            expected due to this
  |
  = note: expected type `u32`
             found enum `std::result::Result<std::fs::File, std::io::Error>`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `error-handling`.

To learn more, run the command again with --verbose.

這告訴我們函式 File::open 的回傳型別為 Result<T, E>。泛型參數 T 在此已經被指明成功時會用到的型別 std::fs::File,也就是檔案的控制代碼(handle)。用於錯誤時的 E 型別則是 std::io::Error

這樣的回傳型別代表 File::open 的呼叫在成功時會回傳我們可以讀寫的檔案控制代碼,但該函式呼叫也可能失敗。舉例來說,該檔案可能會不存在,或者我們沒有檔案的存取權限。File::open 需要有某種方式能告訴我們它的結果是成功或失敗,並回傳檔案控制代碼或是錯誤資訊。這樣的資訊正是 Result 枚舉想表達的。

如果 File::open 成功的話,變數 f 的數值就會獲得包含檔案控制代碼的 Ok 實例。如果失敗的話,f 的值就會是包含為何產生該錯誤的資訊的 Err 實例。

我們需要讓範例 9-3 的程式碼依據 File::open 回傳不同的結果採取不同的動作。範例 9-4 展示了其中一種處理 Result 的方式,我們使用第六章提到的 match 表達式。

檔案名稱:src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("開啟檔案時發生問題:{:?}", error),
    };
}

範例 9-4:使用 match 表達式來處理回傳的 Result 變體

Option 枚舉一樣,Result 枚舉與其變體都會透過 prelude 引入作用域,所以我們不需要指明 Result::,可以直接在 match 的分支中使用 OkErr 變體。

我們在此告訴 Rust 結果是 Ok 的話,就回傳 Ok 變體中內部的 file,然後我們就可以將檔案控制代碼賦值給變數 f。在 match 之後,我們就可以適用檔案控制代碼來讀寫。

match 的另一個分支則負責處理我們從 File::open 中取得的 Err 數值。在此範例中,我們選擇呼叫 panic! 巨集。如果檔案 hello.txt 不存在我們當前的目錄的話,我們就會執行此程式碼,接著就會看到來自 panic! 巨集的輸出結果:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at '開啟檔案時發生問題:Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

如往常一樣,此輸出告訴我們哪裡出錯了。

配對不同種的錯誤

範例 9-4 的程式碼不管 File::open 為何失敗都會呼叫 panic!。我們希望做的是依據不同的錯誤原因採取不同的動作,如果 File::open 是因為檔案不存在的話,我們想要建立檔案並回傳新檔案的控制代碼。如果 File::open 是因為其他原因失敗的話,像是我們沒有開啟檔案的權限,我們仍然要像範例 9-4 這樣呼叫 panic!。範例 9-5 就這樣對 match 表達式加了更多條件。

檔案名稱:src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("建立檔案時發生問題:{:?}", e),
            },
            other_error => {
                panic!("開啟檔案時發生問題:{:?}", other_error)
            }
        },
    };
}

範例 9-5:針對不同種類的錯誤採取不同動作

File::openErr 變體的回傳型別為 io::Error,這是標準函式庫提供的結構體。此結構體有個 kind 方法讓我們可以取得 io::ErrorKind 數值。標準函式庫提供的枚舉 io::ErrorKind 有從 io 運算可能發生的各種錯誤。我們想處理的變體是 ErrorKind::NotFound,這指的是我們嘗試開啟的檔案還不存在。所以我們對 f 配對並在用 error.kind() 繼續配對下去。

我們從內部配對檢查 error.kind() 的回傳值是否是 ErrorKind 枚舉中的 NotFound 變體。如果是的話,我們就嘗試使用 File::create 建立檔案。不過 File::create 也可能會失敗,所以我們需要第二個內部 match 表達式來處理。如果檔案無法建立的話,我們就會印出不同的錯誤訊息。第二個分支的外部 match 分支保持不變,如果程式遇到其他錯誤的話就會恐慌。

我們用的 match 的確有點多!match 表達式雖然很實用,不過它的行為非常基本。在第十三章你會學到閉包(closure),Result<T, E> 型別有很多接收閉包並採用 match 實作的方法。使用那些方法可以讓你的程式碼更簡潔。更熟練的 Rustacean 可能會像這樣寫範例 9-5 的程式數碼:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("建立檔案時發生問題:{:?}", error);
            })
        } else {
            panic!("開啟檔案時發生問題:{:?}", error);
        }
    });
}

雖然此程式碼的行為和範例 9-5 一樣,但他沒有包含任何 match 表達式而且更易閱讀。當你讀完第十三章後,別忘了回來看看此範例,並查閱標準函式庫中的 unwrap_or_else 方法。除此方法以外,還有更多方法可以來解決處理錯誤時龐大的 match 表達式。

錯誤發生時產生恐慌的捷徑:unwrapexpect

雖然 match 已經足以勝任指派的任務了,但它還是有點冗長,而且可能無法正確傳遞錯誤的嚴重性。Result<T, E> 型別有非常多的輔助方法來執行不同的任務。其中一個方法就是 unwrap,這是和我們在範例 9-4 所寫的 match 表達式一樣,擁有類似效果的捷徑方法。如果 Result 的值是 Ok 變體,unwrap會回傳 Ok 裡面的值;如果 ResultErr 變體的話,unwrap 會呼叫 panic! 巨集。以下是使用 unwrap 的方式:

檔案名稱:src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}

如果我們沒有 hello.txt 這個檔案並執行此程式碼的話,我們會看到從 unwrap 方法所呼叫的 panic! 回傳訊息:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

還有另一個方法 expectunwrap 類似,不過能讓我們選擇 panic! 回傳的錯誤訊息。使用 expect 而非 unwrap 並提供完善的錯誤訊息可以表明你的意圖,讓追蹤恐慌的源頭更容易。expect 的語法看起來就像這樣:

檔案名稱:src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("開啟 hello.txt 失敗");
}

我們使用 expect 的方式和 unwrap 一樣,不是回傳檔案控制代碼就是呼叫 panic! 巨集。使用 expect 呼叫 panic! 時的錯誤訊息會是我們傳遞給 expect 的參數,而不是像 unwrap 使用 panic! 預設的訊息。訊息看起來就會像這樣:

thread 'main' panicked at '開啟 hello.txt 失敗: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

由於此錯誤訊息指明了我們想表達的訊息「開啟 hello.txt 失敗」,我們比較能知道此錯誤訊息是從哪裡發生的。如果我們在多處使用 unwrap,我們會需要一些時間才能理解 unwrap 是從哪裡引發恐慌的,因為 unwrap 很可能會顯示相同的訊息。

傳播錯誤

當你在寫某函式實作時,要是它的呼叫的程式碼可能會失敗,與其直接在此函式處理錯誤,你可以回傳錯誤給呼叫此程式的程式碼,由它們決定如何處理。這稱之為傳播(propagating)錯誤並讓呼叫者可以有更多的控制權,因為比起你程式碼當下的內容,回傳的錯誤可能提供更多資訊與邏輯以利處理。

舉例來說,範例 9-6 展示了一個從檔案讀取使用者名稱的函式。如果檔案不存在或無法讀取的話,此函式會回傳該錯誤給呼叫此函式的程式碼。

檔案名稱:src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let f = File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}
}

範例 9-6:使用 match 回傳錯誤給呼叫者的函式

此函式還能在更簡化,但我們要先繼續手動處理來進一步探討錯誤處理,最後我們會展示最精簡的方式。讓我們先看看此函式的回傳型別 Result<String, io::Error>。這代表此函式回傳的型別為 Result<T, E>,而泛型型別 T 已經指明為實際型別 String 然後泛型型別 E 也已經指明為實際型別 io::Error。如果函式正確無誤的話,程式碼會呼叫此函式並收到擁有 StringOk 數值。如果程式遇到任何問題的話,呼叫此函式的程式碼就會獲得擁有包含相關問題發生資訊的 io::Error 實例的 Err 數值。我們選擇 io::Error 作為函式的回傳值是因為它正是 File::open 函式和 read_to_string 方法失敗時的回傳的錯誤型別。

函式本體從呼叫 File::open 開始,然後我們使用 match 回傳 Result 數值,就和範例 9-4 的 match 類似,但與其在 Err 情形時呼叫 panic!,我們儘早回傳 File::open 的錯誤型別給呼叫者。如果 File::open 成功的話,我們就將檔案控制代碼賦值給變數 f 並繼續執行下去。

接著我們在變數 s 建立新的 String 並對檔案控制代碼 f 呼叫 read_to_string 方法來讀取檔案內容至 sread_to_string 也會回傳 Result 因為它也可能失敗,就算 File::open 是執行成功的。所以我們需要另一個 match 來處理該 Result,如果 read_to_string 成功的話,我們的函式就是成功的,然後在 Ok 回傳 s 中該檔案的使用者名稱。如果 read_to_string 失敗的話,我們就像處理 File::openmatch 一樣回傳錯誤值。不過我們不需要顯式寫出 return,因為這是函式中的最後一個表達式。

呼叫此程式碼的程式就會需要處理包含使用者名稱的 Ok 數值以及包含 io::ErrorErr 數值。我們不會知道呼叫此程式碼的人會如何處理這些數值。舉例來說,如果呼叫此程式碼而獲得錯誤的話,它可能選擇呼叫 panic! 讓程式崩潰,或者使用預設的使用者名稱從檔案以外的地方尋找該使用者。所以我們傳播所有成功或錯誤的資訊給呼叫者,讓它們能妥善處理。

這樣傳播錯誤的模式是非常常見的,所以 Rust 提供了 ? 來簡化流程。

傳播錯誤的捷徑:? 運算子

範例 9-7 是另一個 read_username_from_file的實作,擁有和範例 9-6 一樣的效果,不過這次使用了 ? 運算子。

檔案名稱:src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}
}

範例 9-7:使用 ? 運算子回傳錯誤給呼叫者的函式

定義在 Result 數值後的 ? 運作方式幾乎與範例 9-6 的 match 表達式處理 Result 的方式一樣。如果 Result 的數值是 Ok 的話,Ok 內的數值就會從此表達式回傳,然後程式就會繼續執行。如果數值是 Err 的話,Err 就會使用 return 關鍵字作為整個函式的回傳值回傳,讓錯誤數值可以傳遞給呼叫者的程式碼。

不過範例 9-6 的 match 表達式做的事和 ? 運算子做的事還是有不同的地方:? 運算子呼叫所使用的錯誤數值會傳遞到 from 函式中,這是定義在標準函式庫的 From 特徵中,用來將錯誤從一種型別轉換另一種型別。當 ? 運算子呼叫 from 函式時,接收到的錯誤型別會轉換成目前函式回傳值的錯誤型別。這在當函式要回傳一個錯誤型別來代表所有函式可能的失敗是很有用的,即使可能會失敗的原因有很多種。只要每個錯誤型別都有實作 from 函式來將自己轉換成要回傳的錯誤型別,? 運算子就能自動作轉換。

在範例 9-7 中,在 File::open 的結尾中 ? 回傳 Ok 中的數值給變數 f。如果有錯誤發生時,? 運算子會提早回傳整個函式並將 Err 的數值傳給呼叫的程式碼。同理也適用在呼叫 read_to_string 結尾的 ?

? 運算子可以消除大量樣板程式碼並讓函式實作更簡單。我們還可以再進一步將方法直接串接到 ? 後來簡化程式碼,如範例 9-8 所示。

檔案名稱:src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}
}

範例 9-8:在 ? 運算子後方串接方法呼叫

我們將建立新 String 的變數 s 移到函式的開頭,這部分沒有任何改變。再來與建立變數 f 的地方不同的是,我們直接將 read_to_string 串接到 File::open("hello.txt")? 的結果後方。我們在 read_to_string 呼叫的結尾還是有 ?,然後我們還是在 File::openread_to_string 成功沒有失敗時,回傳包含 sOk 數值。函式達成的效果仍然與範例 9-6 與 9-7 相同。這只是一個比較不同但慣用的寫法。

說到此函式不同的寫法,範例 9-9 展示了另一個更短的寫法。

檔案名稱:src/main.rs


#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

範例 9-9:使用 fs::read_to_string 而不是開啟檔案後才讀取

讀取檔案至字串中算是個常見動作,所以 Rust 提供了一個方便的函式 fs::read_to_string 來開啟檔案、建立新的 String、讀取檔案內容、將內容放入該 String 並回傳它。不過使用 fs::read_to_string 就沒有機會讓我們來解釋所有的錯誤處理,所以我們一開始才用比較長的寫法。

? 運算子可以用在回傳 Result 的函式

? 可以用在回傳型別為 Result 的函式,因為它與範例 9-6 的 match 表達式有相同的運作方式。matchreturn Err(e) 會要求 Result 作為回傳型別。所以函式回傳值也需要是 Result 才能與此 return 相容。

讓我們看看如果我們在 main 使用 ? 運算子的話會發生什麼事情,你應該還記得此函式的回傳型別為 ()

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

當我們編譯此程式碼時,我們會獲得以下錯誤訊息:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `std::ops::Try`)
 --> src/main.rs:4:13
  |
3 | / fn main() {
4 | |     let f = File::open("hello.txt")?;
  | |             ^^^^^^^^^^^^^^^^^^^^^^^^ cannot use the `?` operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `std::ops::Try` is not implemented for `()`
  = note: required by `std::ops::Try::from_error`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling`.

To learn more, run the command again with --verbose.

此錯誤告訴我們只能在回傳型別為 ResultOption 或其他有實作 std::ops::Try 的型別的函式才能使用 ? 運算子。如果你寫的程式碼函式中沒有回傳任何這些型別,而你想要在呼叫回傳型別為 Result<T, E> 的函式使用 ?,你有兩種解決辦法。第一個辦法是如果沒有任何限制阻止你的話,你可以變更你函式的回傳型別為 Result<T, E>。另一個則是依據你認為合適的情形下使用 match 或任何一種 Result<T, E> 的方法來處理 Result<T, E>

main 是特別的函式,所以我們必須限制它的回傳型別。其中一種 main 的有效回傳型別是 (),而為了方便,它還能有另一個有效回傳型別 Result<T, E>,如以下所示:

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

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

Box<dyn Error> 型別使用了特徵物件(trait object)我們會在第十七章的「允許不同型別數值的特徵物件」討論到。現在你可以將 Box<dyn Error> 視為它是「任何種類的錯誤」。這樣 main 中的回傳型別就會允許 ? 了。

現在我們已經討論了呼叫 panic! 與回傳 Result 的細節。現在讓我們回到何時該使用何種辦法的主題上吧。

要 panic! 還是不要 panic!

所以你該如何決定何時要呼叫 panic! 還是要回傳 Result 呢?當程式碼恐慌時,就沒有任何回復的方式。你可以在任何錯誤場合呼叫 panic!,無論是可能或不可能復原的情況。不過這樣你就等於替呼叫你的程式碼的呼叫者做出決定,讓情況變成無法復原的錯誤了。當你選擇回傳 Result 數值,你將決定權交給呼叫者的程式碼。呼叫者可能會選擇符合當下場合的方式嘗試復原錯誤,或者它可以選擇 Err 內的數值是不可回復的,所以它就呼叫 panic! 讓你原本可回復的錯誤轉成不可回復。因此,當你定義可能失敗的函式時預設回傳 Result 是不錯的選擇。

在少數情況下,程式碼恐慌會比回傳 Result 來得恰當。讓我們來探討為何在範例、程式碼原型與測試選擇恐慌會比較好。然後我們將討論到一種編譯器無法辨別出不可能失敗,但人類的你卻可以的情況。本章節會總結一些通用指導原則來決定何時在函式庫程式碼中恐慌。

範例、程式碼原型與測試

當你在寫解釋一些概念的範例時,寫出完善錯誤處理的範例,反而會讓範例變得較不清楚。在範例中,使用像是 unwrap 這樣會恐慌的方法可以被視為是一種要求使用者自行決定如何處理錯誤的表現,因為他們可以依據程式碼執行的方式來修改此方法。

同樣地 unwrap 與 expect 方法也很適用在試做原型,你可以在決定準備開始處理錯誤前使用它們。它們會留下清楚的痕跡,當你準備好要讓程式碼更穩固時,你就能回來修改。

如果有方法在測試內失敗時,你會希望整個測試都失敗,就算該方法不是要測試的功能。因為 panic! 會將測試標記為失敗,所以在此呼叫 unwrap 或 expect 是很正確的。

當你知道的比編譯器還多的時候

如果你知道一些編譯器不知道的邏輯的話,直接在 Result 呼叫 unwrap 來直接取得 Ok 的數值是很有用的。你還是會有個 Result 數值需要做處理,你呼叫的程式碼還是有機會失敗的,就算在你的特定場合中邏輯上是不可能的。如果你能保證在親自審閱程式碼後,你絕對不可能會有 Err 變體的話,那麼呼叫 unwrap 是完全可以接受的。以下範例就是如此:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1".parse().unwrap();
}

我們傳遞寫死的字串來建立 IpAddr 的實例。我們可以看出 127.0.0.1 是完全合理的 IP 位址,所以這邊我們可以直接 unwrap。不過使用寫死的合理字串並不會改變 parse 方法的回傳型別,我們還是會取得 Result 數值,編譯器仍然會要我們處理 Result 並認為 Err 變體是有可能發生的。因為編譯器並沒有聰明到可以看出此字串是個有效的 IP 位址。如果 IP 位址的字串是來自使用者輸入而非我們寫死進程式的話,它的確有可能會失敗,這時我們就得要認真處理 Result 了。

錯誤處理的指導原則

當你的程式碼可能會導致嚴重狀態的話,就建議讓你的程式恐慌。這裡的嚴重狀態是指一些假設、保證、協議或不可變性被打破時的狀態,像是當你的程式碼有無效的數值、互相矛盾的數值或缺少數值。另外還加上以下情形:

  • 該嚴重狀態並非平時預期會發生的。
  • 你的程式在此時需要避免這種嚴重狀態。
  • 你所使用的型別沒有適合的方式能夠處理此嚴重狀態。

如果有人呼叫了你的程式碼卻傳遞了不合理的數值,最好的辦法是呼叫 panic! 並警告使用函式庫的人他們程式碼錯誤發生的位置,好讓他們在開發時就能修正。同樣地,panic! 也適合用於如果你呼叫了你無法掌控的外部程式碼,然後它回傳了你無法修正的無效狀態。

不過如果失敗是可預期的,回傳 Result 就會比呼叫 panic! 來得好。類似的例子有,語法分析器 (parser)收到格式錯誤的資訊,或是 HTTP 請求回傳了一個狀態,告訴你已經達到請求上限了。在這樣的案例,回傳 Result 代表失敗是預期有時會發生的,而且呼叫者必須決定如何處理。

當你的程式碼針對數值進行運算時,你的程式需要先驗證該數值,如果數值無效的話就要恐慌。這是基於安全原則,嘗試對無效資料做運算的話可能會導致你的程式碼產生漏洞。這也是標準函式庫在你嘗試取得超出界限的記憶體存取會呼叫 panic! 的主要原因。嘗試取得不屬於當前資料結構的記憶體是常見的安全問題。函式通常都會訂下一些合約(contracts),它們的行為只有在輸入資料符合特定要求時才帶有保障。當違反合約時恐慌是十分合理的,因為違反合約就代表這是呼叫者的錯誤,這不是你的程式碼該主動處理的錯誤。事實上,呼叫者也沒有任何合理的理由來復原這樣的錯誤。函式的合約應該要寫在函式的技術文件中解釋,尤其是違反時會恐慌的情況。

然而要在你的函式寫一大堆錯誤檢查有時是很冗長且麻煩的。幸運的是你可以利用 Rust 的型別系統(以及編譯器的型別檢查)來幫你完成檢驗。如果你的函式用特定型別作為參數的話,你就可以認定你的程式邏輯是編輯器已經幫你確保你拿到的數值是有效的。舉例來說,如果你有個一型別而非 Option 的話,你的程式就會預期取得某個值而不是沒拿到值。你的程式就不必處理 SomeNone 這兩個變體情形,它只會有一種情況並絕對會拿到數值。要是有人沒有傳遞任何值給你的函式會根本無法編譯,所以你的函式就不需要在執行時做檢查。另一個例子是使用非帶號整數像是 u32 來確保參數不會是負數。

建立自訂型別來驗證

讓我們來試著使用 Rust 的型別系統來進一步確保我們擁有有效數值,並建立自訂型別來驗證。回想一下第二章的猜謎遊戲,我們的程式碼要使用者從 1 到 100 之間猜一個數字。在開始與祕密數字做比較之前,我們從未驗證使用者輸入的值,我們只驗證了它是否為正的。在這種情況帶來的後果還不算嚴重:我們還是會顯示「太大」或「太小」。但是我們可以改善這段來引導使用者輸入有效數值,並在使用者輸入時猜了超出範圍的數字或字母時呈現不同行為。

我們可以將輸入的猜測分析改成 i32 而非 u32 來允許負數,並檢查數字是否在範圍內,如以下所示:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    loop {
        // --省略--

        println!("請輸入你的猜測數字。");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取行數失敗");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("祕密數字介於 1 到 100 之間。");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --省略--
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("獲勝!");
                break;
            }
        }
    }
}

if 表達式檢查我們的數值是否超出範圍,如果是的話就告訴使用者問題原因,並呼叫 continue 來進行下一次的猜測循環,要求再猜一次。在 if 表達式之後我們就能用已經知道範圍是在 1 到 100 的 guess 與祕密數字做比較。

不過這並非理想解決方案:如果程式必定要求數值一定要是 1 到 100,而且我們有很多函式都有此需求的話,在每個函式都檢查就太囉唆了(而且可能會影響效能)。

對此我們可以建立一個新的型別,並且建立一個驗證產生實例的函式,這樣我們就不必在每個地方都做驗證。這樣一來函式就可以安全地以這個新型別作為簽名,並放心地使用收到的數值。範例 9-10 顯示了定義 Guess 型別的例子,它的 new 函式只會在接收值為 1 到 100 時才會建立 Guess 實例。


#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("猜測數字必須介於 1 到 100 之間,你輸入的是 {}。", value);
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

範例 9-10:只會擁有 1 到 100 的 Guess 型別

首先我們定義了一個結構體叫做 Guess,其欄位叫做 value 並會持有 i32。這就是數字會被儲存的地方。

接著我們實作一個 Guess 的關聯函式叫做 new 來建立 Guess 的值。new 函式定義的參數叫做 value 並擁有型別 i32,且最後會回傳 Guess。函式 new 本體中的程式碼會驗證 value 確保它位於 1 到 100 之間。如果 value 沒有通過驗證,我們呼叫 panic! 來警告呼叫此程式碼的開發者,他們可能有需要修正的程式錯誤,因為使用超出範圍的 value 來建立 Guess 違反了 Guess::new 的合約。Guess::new 會恐慌的情況需要在公開的 API 技術文件中提及。我們會在第十四章討論如何寫出技術文件並在 API 技術文件中指出可能發生 panic! 的情形。如果 value 通過驗證的話,我們就建立一個新的 Guess 並將參數 value 賦值給 value 欄位,最後回傳 Guess

接著我們實作了個方法叫做 value,它會借用 self 且沒有任何參數,並會回傳 i32。這種方法有時會被稱為 getter,因為它的目的是從它的欄位中取得一些資料並回傳它。此公開方法是必要的,因為 Guess 結構體中的 value 欄位是私有的。將 Guess 結構體的 value 欄位設為私有是很重要的,這樣就無法直接設置 value ,模組外的程式碼必須使用 Guess::new 函式來建立 Guess 的實例,因而確保 Guess 不可能會有沒有經過 Guess::new 函式驗證的 value

這樣當函式的參數或回傳值只能是數字 1 到 100 的話,它的簽名就能使用或回傳 Guess 而不是 i32,因此就不必在它的本體內做任何額外檢查。

總結

Rust 的錯誤檢查功能的設計旨在協助你寫出可靠的程式碼。panic! 巨集告訴你的程式遇到了它無法處理的狀態,並讓你告訴程序停止,而不是繼續嘗試使用無效或不正確的數值。Result 枚舉使用 Rust 的型別系統來指出可能會失敗的運算,並讓你的程式碼有辦法回復。你可以使用 Result 來告訴使用你的程式碼的呼叫者,他們需要處理可能成功與失敗的情形。在適當的場合使用 panic!Result 能讓你的程式碼在不可避免的問題中更加可靠。

現在你已經看過標準函式庫中 OptionResult 使用泛型的優勢了,就讓我們來討論泛型如何運作的,以及你如何在程式碼中使用它們。

泛型型別、特徵與生命週期

每個程式語言都有能夠高效處理概念複製的工具。在 Rust 此工具就是泛型(generics)。泛型是實際型別或其他屬性的抽象替代。當我們在寫程式碼時,我們可以表達泛型的行為,或是它們與其他泛型有何關聯,而不必在編譯與執行程式時知道它們實際上是什麼。

類似於函式有辦法能接收多種未知數值作為參數來執行相同程式碼,函式也可以接受一些泛型型別參數,而不是實際型別像是 i32String。事實上我們已經在第六章的 Option<T>、第八章的 Vec<T>HashMap<K, V> 以及第九章的 Result<T, E> 使用過泛型了。在本章節,你將會探索如何用泛型定義你自己的型別、函式與方法!

首先我們會先檢視如何提取參數來減少重複的程式碼。接著我們會以相同的技巧使用泛型將兩個只有參數型別不同的函式轉變成泛型函式。我們還會解釋如何在結構體和枚舉使用泛型型別。

再來你會學會如何使用特徵(traits)來定義共同行為。你可以組合特徵與泛型型別來限制泛型型別只適用在有特定行為的型別,而不是任意型別。

最後我們會來介紹生命週期(lifetimes),一種能讓編譯器知道引用如何互相關聯的泛型。生命週期讓我們能在許多情況下借用數值,同時能確保編譯器會檢查這些引用是有效的。

提取函數來減少重複性

在我們深入泛型語法之前,讓我們先來看如何不用泛型型別的情況下,用提取函式的方式減少重複的程式碼。之後我們就會用此方式來提取泛型函式!和你透過找出重複的程式碼來提取程式一樣,你也將找出重複的函式來轉成泛型。

讓我們考慮一支尋找列表中最大數字的小程式,如範例 10-1 所示。

檔案名稱:src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("最大數字為 {}", largest);
    assert_eq!(largest, 100);
}

範例 10-1:在數字列表中尋找最大數字的程式碼

此程式碼儲存整數列表到變數 number_list 並將列表第一個數字放入變數 largest。接著它會遍歷列表中的所有元素,如果目前數字比 largest 內儲存的數字還大的話,它就會替代成該變數的值。.不過如果目前數值小於或等於最大值的話,變數就不會被改變,程式會接續檢查列表中的下一個數字。在考慮完列表中的所有數字後,largest 就應該會拿到最大數字,在此例就是 100。

要從兩個不同的數字列表中找到最大值的話,我們可以重複範例 10-1 的程式碼,然後在程式中兩個不同的地方使用相同的邏輯,如範例 10-2 所示。

檔案名稱:src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("最大數字為 {}", largest);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("最大數字為 {}", largest);
}

範例 10-2:在兩個數字列表中尋找最大值

雖然這樣的程式碼能執行,寫出重複的程式碼很囉唆而且容易出錯。我們可以每次變更此程式碼時就得更新程式碼中許多地方。

要去除重複的部分,我們可以建立一層抽象,定義一個可以處理任意整數列表作為參數的函式。這樣的解決辦法讓我們的程式更清晰,而且讓我們能抽象表達出從列表中尋找最大值這樣的概念。

在範例 10-3 我們提取了尋找最大值的程式碼成一個函式叫做 largest。不像範例 10-1 只能從特定列表尋找最大值,此程式可以從兩個不同的列表尋找最大值。

檔案名稱:src/main.rs

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("最大數字為 {}", result);
    assert_eq!(result, &100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("最大數字為 {}", result);
    assert_eq!(result, &6000);
}

範例 10-3:抽象出尋找最大值的概念並用在兩個不同的列表

largest 函式有個參數 list 可以代表我們傳遞給函式的 i32 型別切片。所以當我們呼叫此函式時,程式可以依據我們傳入的特定數值執行。

總結來說,以下是我們將範例 10-2 的程式碼轉換成範例 10-3 的步驟:

  1. 找出重複的程式碼。
  2. 將重複的程式碼提取置函式本體內,並指定函式簽名輸入與回傳數值。
  3. 更新重複使用程式碼的實例,改呼叫我們定義的函式。

接著我們將以相同的步驟來使用泛型來減少重複的程式碼。就像函式本體可以抽象出 list 而不用特定數值,泛型允許程式碼執行抽象型別。

舉例來說,假設我們有兩個函式:一個會找出 i32 型別切片中的最大值而另一個會找出 char 型別切片的最大值。我們要如何刪除重複的部分呢?讓我們拭目以待!

泛型資料型別

我們可以使用泛型(generics)來建立項目的定義,像是函式簽名或結構體,讓我們在之後可以使用在不同的實際資料型別。讓我們先看看如何使用泛型定義函式、枚舉與方法。然後我們會在來看泛型對程式碼的效能影響如何。

在函式中定義

當要使用泛型定義函數時,我們通常會將泛型置於函式簽名中指定參數與回傳值資料型別的位置。這樣做能讓我們的程式碼更具彈性並向呼叫者提供更多功能,同時還能防止重複程式碼。

接續我們 largest 函式的例子,範例 10-4 展示了兩個都在切片上尋找最大值的函式。

檔案名稱:src/main.rs

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("最大數字為 {}", result);
    assert_eq!(result, &100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("最大字元為 {}", result);
    assert_eq!(result, &'y');
}

範例 10-4:兩個名稱與其簽名中的型別都不同的函式

largest_i32 函式和我們在範例 10-3 提取的函式一樣都是尋找切片中最大的 i32。而 largest_char 函式則尋找切片中最大的 char。函式本體都擁有相同的程式碼,讓我以讓我們來開始用泛型型別參數來消除重複的部分,轉變成只有一個函式吧。

要在我們新定義的函式中參數化型別的話,我們需要為參數型別命名,就和我們在函式中的參數數值所做的一樣。你可以用任何標識符來命名型別參數名稱。但我們習慣上會用 T,因為 Rust 的參數名稱都盡量很短,常常只會有一個字母,而且 Rust 對於型別命名的慣用規則是駝峰式大小寫(CamelCase)。所以 T 作為「type」的簡稱是大多數 Rust 程式設計師的選擇。

當我們在函式本體使用參數時,我們必須在簽名中宣告參數名稱,編譯器才能之當該名稱代表什麼。同樣地,當我們要在函式簽名中使用型別參數名稱,我們必須在使用前宣告該型別參數名稱。要定義泛型 largest 函式的話,我們在函式名稱與參數列表之間加上尖括號,其內就是型別名稱的宣告,如以下所示:

fn largest<T>(list: &[T]) -> &T {

我們可以這樣理解定義:函式 largest 有泛型型別 T,此函式有一個參數叫做 list,它的型別為數值 T 的切片。largest 函式會回傳與型別 T 相同型別的值。

範例 10-5 顯示了使用泛型資料型別於函式簽名組合出的 largest 函式。此範例還展示了我們如何依序用 i32char 的切片呼叫函式。注意此程式碼尚未能編譯,不過我們會在本章之後修改它。

檔案名稱:src/main.rs

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("最大數字為 {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("最大字元為 {}", result);
}

範例 10-5:使用函式型別參數定義的 largest 函式,但現在還不能編譯

如果我們現在就編譯程式碼的話,我們會得到此錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- T
  |            |
  |            T
  |
  = note: `T` might need a bound for `std::cmp::PartialOrd`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

註釋中提到了 std::cmp::PartialOrd 這個特徵(trait)。我們會在下個段落來討論特徵。現在此錯誤告訴我們 largest 本體無法適用於所有可能的 T 型別,因為我們想要在本體中比較型別 T 的數值,我們只能在能夠排序的型別中做比較。要能夠比較的話,標準函式庫有提供 std::cmp::PartialOrd 特徵讓你可以針對你的型別來實作(請查閱附錄 C 來瞭解更多此特徵的細節)。你會在「特徵作為參數」的段落學習到如何指定特定泛型型別擁有特定特徵。不過先讓先我們探索其他泛型型別參數使用的方式。

在結構體中定義

我們一樣能以 <> 語法來對結構體中一或多個欄位使用泛型型別參數。範例 10-6 顯示了如何定義 Point<T> 結構體並讓 xy 可以是任意型別數值。

檔案名稱:src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

範例 10-6:Point<T> 結構體的 xy 會有型別 T 的數值

在結構體定義使用泛型的語法與函式定義類似。首先,我們在結構體名稱後方加上尖括號,並在其內宣告型別參數名稱。接著我們能在原本指定實際資料型別的地方,使用泛型型別來定義結構體。

注意到我們使用了一個泛型型別來定義 Point<T>,此定義代表 Point<T> 是某型別 T 下之通用的,而且欄位 xy 擁有相同型別,無論最終是何種型別。如果我們用不同的型別數值來建立 Point<T> 實例,我們的程式碼會無法編譯,如範例 10-7 所示。

檔案名稱:src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

範例 10-7:欄位 xy 必須是相同型別,因為它們擁有相同的泛型資料型別 T

在此例中,當我們賦值 5 給 x 時,我們讓編譯器知道 Point<T> 實例中的泛型型別 T 會是整數。然後我們將 4.0 賦值給 y,這應該要和 x 有相同型別,所以我們會獲得以下錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

要將結構體 Pointxy 定義成擁有不同型別確仍然是泛型的話,我們可以使用多個泛型型別參數。舉例來說,在範例 10-8 我們改變了 Point 的定義為擁有兩個泛型型別 TUx 擁有型別 Ty 擁有型別 U

檔案名稱:src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

範例 10-8:Point<T, U> 擁有兩個泛型惜別,所以 xy 可以有不同的型別數值

現在這些所有的 Point 實例都是允許的了!你要在定義中使用多少泛型型別參數都沒問題,但用太多的話會讓你的程式碼難以閱讀。當你的程式碼需要使用大量泛型的話,通常代表你的程式碼需要重新組織成更小的元件。

在枚舉中定義

如同結構體一樣,我們可以定義枚舉讓它們的變體擁有泛型資料型別。讓我們看看我們在第六章標準函式庫提供的 Option<T> 枚舉:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

此定義現在對你來說應該就說的通了。如同你所看到的 Option<T> 枚舉有個泛型型別參數 T 以及兩個變體:Some 擁有型別 T 的數值;而 None 則是不具任何數值的變體。使用 Option<T> 枚舉我們可以表達出一個可能擁有的數值這樣的抽象概念。而且因為 Option<T> 是泛型,不管可能的數值型別為何,我們都能使用此抽象。

枚舉也能有數個泛型型別。我們在第九章所使用枚舉 Result 的定義就是個例子:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result 枚舉有兩個泛型型別 TE 且有兩個變體:Ok 擁有型別 T 的數值;而 Err 擁有型別 E 的數值。這樣的定義讓我們很方便能表達 Result 枚舉可能擁有一個成功的數值(返回型別 T 的數值)或失敗的數值(回傳型別為 E 的錯誤值)。事實上這就是我們在範例 9-3 開啟檔案的方式,當我們成功開啟檔案時的 T 就會是型別 std::fs::File,然後當開啟檔案會發生問題時 E 就會是型別 std::io::Error

當你發現你的程式碼有許多結構體或枚舉都只有儲存的值有所不同時,你可以使用泛型行別來避免重複。

在方法中定義

我們可以對結構體或枚舉定義方法(如第五章所述)並也可以使用泛型型別來定義。範例 10-9 展示了我們在範例 10-6 定義的結構體 Point<T> 並實作了一個叫做 x 的方法。

檔案名稱:src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

範例 10-9:在 Point<T> 結構體實作一個方法叫叫做 x,其會回傳 x 欄位中型別為 T 的引用

我們在這 Point<T> 定義了一個方法叫做 x 並回傳欄位 x 的資料引用。

注意到我們需要在 impl 宣告 T,這樣才代表我們指的是在型別 Point<T> 實作方法。在 impl 之後宣告泛型型別 T,Rust 可以識別出 Point 尖括號內的型別為泛型型別而非實際型別。

舉例來說,我們可以只針對 Point<f32> 的實例來實作方法,而非適用於任何泛型型別的 Point<T> 實例。在範例 10-10 我們使用了實例型別 f32 而沒有在 impl 宣告任何型別。

檔案名稱:src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

範例 10-10:一個只適用於擁有泛型 T 結構體其中的特定實際型別的 impl 區塊

此程式碼代表 Point<f32> 會有個方法叫做 distance_from_origin 但其他 Point<T> 只要 T 不是型別 f32 的實例都不會定義此方法。此方法測量我們的點距離座標 (0.0, 0.0) 有多遠並使用只有浮點數型別能使用的數學運算。

在結構體定義中的泛型型別參數不總會是和結構體方法簽名中的會是相同型別。舉例來說,範例 10-11 在範例 10-8 的 Point<T, U> 定義了一個方法 mixup。該方法會取得另一個 Point 作為參數,不過其可能會與我們呼叫的 mixup 方法中self Point 的型別有所相異。此方法使用 selfPoint(型別為 T)的 x 值與由參數傳進來的 Point(型別為 W)的 y 值。

檔案名稱:src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

範例 10-11:結構體定義中使用不同的泛型型別的方法

main 中,我們定義了一個 Point,其 x 型別為 i32(數值為 5),y 型別為 f64(數值為 10.4)。變數 p2 是個 Point 結構體,x 為字串切片(數值為 "Hello"),ychar(數值為 c)。在 p1 呼叫 mixup 並加上引數 p2 的話會給我們 p3,它的 x 會有型別 i32,因為 x 來自 p1。而且變數 p3 還會有型別為 chary,因為 y 來自 p2println! 巨集的呼叫就會顯示 p3.x = 5, p3.y = c

此例是是為了展示一些泛型參數是透過 impl 宣告而有些則是透過方法定義來得。在此,泛型參數 TU 是宣告在 impl 之後,因為它們與結構體定義有關聯。而在 fn mixup 之後宣告的泛型參數只和該方法有關。

使用泛型的程式碼效能

你可能會好奇當你使用泛型型別參數會不會有執行時的消耗。好消息是 Rust 實作泛型的方式讓你使用泛型的的程式碼跑得不會比使用實際型別還來的慢。

Rust 在編譯時對使用泛型的程式碼進行單態化(monomorphization)。單態化是個讓泛型程式碼轉換成特定程式碼的過程,在編譯時填入實際的型別。

在此過程中,編譯器會做與我們在範例 10-5 建立泛型函式相反的事:編譯器檢查所有泛型程式碼被呼叫的地方,並依據泛型程式碼被呼叫的情況產生實際型別的程式碼。

讓我們看看這在標準函式庫的枚舉 Option<T> 中是怎麼做到的:


#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

當 Rust 編譯此程式碼時中,他會進行單態化。在此過程中,會讀取 Option<T> 實例中使用的數值並識別出兩種 Option<T>:一種是 i32 而另一種是 f64。接著它就會將 Option<T> 的泛型定義展開為 Option_i32Option_f64,以此替換函式定義為特定型別。

單態化的版本看起來會像這樣,泛型 Option<T> 會被替換成編譯器定義的特定定義:

檔案名稱:src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

因為 Rust 會編譯泛型程式碼成個別實例的特定型別,我們使用泛型就不會造成任何執行時消耗。當程式執行時,它就會和我們親自寫重複定義的版本一樣。單態化的過程讓 Rust 的泛型在執行時十分有效率。

特徵:定義共同行為

特徵(trait)會告訴 Rust 編譯器特定型別與其他型別共享的功能。我們可以使用特徵定義來抽象出共同行為。我們可以使用特徵界限(trait bounds)來指定泛型為擁有特定行為的任意型別。

注意:特徵類似於其他語言常稱作介面(interfaces)的功能,但還是有些差異。

定義特徵

一個型別的行為包含我們對該型別可以呼叫的方法。如果我們可以對不同型別呼叫相同的方法,這些型別就能定義共同行為了。特徵定義是一個將方法簽名統整起來,來達成一些目的而定義一系列行為的方法。

舉例來說,如果我們有數個結構體各自擁有不同種類與不同數量的文字:結構體 NewsArticle 儲存特定地點的新聞故事,然後 Tweet 則有最多 280 字元的內容,且有個欄位來判斷是全新的推文、轉推或其他推文的回覆。

我們想要建立個多媒體資料庫來顯示可能存在 NewsArticleTweet 實例的資料總結。要達成此目的的話,我們需要每個型別的總結,且我們需要呼叫該實例的 summarize 方法來索取總結。範例 10-12 顯示了表達此行為的 Summary 特徵定義。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String;
}
}

範例 10-12:Summary 特徵包含 summarize 方法所定義的行為

我們在此使用 trait 關鍵字定義一個特徵,其名稱為 Summary。在大括號中,我們宣告方法簽名來描述有實作此特徵的型別行為,在此例就是 fn summarize(&self) -> String

在方法簽名之後,我們並沒有加上大括號提供實作細節,而是使用分號。每個有實作此特徵的型別必須提供其自訂行為的方法本體。編譯器會強制要求任何有 Summary 特徵的型別都要有定義相同簽名的 summarize 方法。

特徵本體中可以有多個方法,每行會有一個方法簽名並都以分號做結尾。

為型別實作特徵

現在我們已經用 Summary 特徵定義了所需的行為。我們可以在我們多媒體資料庫的型別中實作它。範例 10-13 顯示了 NewsArticle 結構體實作 Summary 特徵的方式,其使用頭條、作者、位置來建立 summerize 的回傳值。至於結構體 Tweet,我們使用使用者名稱加上整個推文的文字來定義 summarize,因為推文的內容長度已經被限制在 280 個字元以內了。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} {} 著 ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
}

範例 10-13:在型別 NewsArticleTweet 實作 Summary 特徵

為一個型別實作一個特徵類似於實作一般的方法。不同的地方在於在 impl 之後我們加上的是想要實作的特徵,然後在用 for 關鍵字加上我們想要實作特徵的型別名稱。在 impl 的區塊內我們置入該特徵所定義的方法簽名,我們使用大括號並填入方法本體來為對特定型別實作出特徵方法的指定行為。

在實作完後,我們就能像呼叫正常方法一樣,來呼叫 NewsArticleTweet 實例的方法,如以下所示:

use chapter10::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 則新推文:{}", tweet.summarize());
}

此程式碼會印出「1 則新推文:horse_ebooks: of course, as you probably already know, people」。

注意到因為我們將範例 10-13 的 Summary 特徵、NewsArticleTweet 型別都定義在 lib.rs ,所以它們都在同個作用域下。如果我們說此 lib.rs 對應的 crate 叫做 aggregator,然後有人想要使用我們 crate 的功能來對他們函式庫作用域中定義的結構體實作 Summary 特徵的話。他們會需要將該特徵引入作用域,可以像這樣指定 use aggregator::Summary;,如此一來就能對他們的型別實作 SummarySummary 特徵一樣也必須是公開的才能讓其他 crate 使用。這就是為何我們在範例 10-12 的 trait 前面就加上 pub 關鍵字。

實作特徵時有一個限制,那就是我們只能在該特徵或該型別位於我們的 crate 時,才能對型別實作特徵。舉例來說我們可以對自訂型別像是 Tweet 來實作標準函式庫的 Display 特徵來為我們 crate aggregator 增加更多功能。因為 Tweet 位於我們的 aggregator crate 裡面。我們也可以在我們的 crate aggregator 內對 Vec<T> 實作 Summary。因為特徵 Summary 也位於我們的 aggregator crate 裡面。

但是我們無法對外部型別實作外部特徵。舉例來說我們無法在我們的 aggregator crate 裡面對 Vec<T> 實作 Display 特徵。因為 DisplayVec<T> 都定義在標準函式庫中,並沒有在我們 aggregator crate 裡面。此限制叫做「連貫性(coherence)」是程式屬性的一部分。更具體來說我們會稱作「孤兒原則(orphan rule)」,因為上一代(parent)型別不存這在。此原則能確保其他人的程式碼不會破壞你的程式碼,反之亦然。沒有此原則的話,兩個 crate 可以都對相同型別實作相同特徵,然後 Rust 就會不知道該用哪個實作。

預設實作

有時候對特徵內的一些或所有方法定義預設行為是很實用的,而不必要求每個型別都實作所有方法。然後當我們對特定型別實作特徵時,我們可以保留或覆蓋每個方法的預設行為。

範例 10-14 展示如何在 Summary 特徵內指定 summarize 方法的預設字串,而不必像範例 10-12 只定義了方法簽名。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(閱讀更多...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
}

範例 10-14:Summary 特徵定義了 summarize 方法的預設實作

要使用預設實作來總結 NewsArticle 而不是定義自訂實作的話,我們可以指定一個空的 impl 區塊,像是 impl Summary for NewsArticle {}

我們沒有直接對 NewsArticle 定義 summarize 方法,因為我們使用的是預設實作並聲明對 NewsArticle 實作 Summary 特徵。所以最後我們仍然能在 NewsArticle 實例中呼叫 summarize,如以下所示:

use chapter10::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("有新文章發佈!{}", article.summarize());
}

此程式碼會印出 有新文章發佈!(閱讀更多...)

建立 summarize 的預設實作不會影響範例 10-13 中 Tweet 實作的 Summary。因為要取代預設實作的語法,與當沒有預設實作時實作特徵方法的語法是一樣的。

預設實作也能呼叫同特徵中的其他方法,就算那些方法沒有預設實作。這樣一來,特徵就可以提供一堆實用的功能,並要求實作者只需處理一小部分就好。舉例來說,我們可以定義 Summary 特徵,使其擁有一個必須要實作的summarize_author 方法,以及另一個擁有預設實作會呼叫 summarize_author 的方法:


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(從 {} 閱讀更多...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}
}

要使用這個版本的 Summary,我們只需要在對型別實作特徵時定義 summarize_author 就好:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(從 {} 閱讀更多...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

在我們定義 summarize_author 之後,我們可以在結構體 Tweet 的實例呼叫 summarize,然後 summarize 的預設實作會呼叫我們提供的 summarize_author。因為我們已經定義了summarize_author,且 Summary 特徵有提供 summarize 方法的預設實作,所以我們不必在寫任何程式碼。

use chapter10::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 則新推文:{}", tweet.summarize());
}

此程式碼會印出 1 則新推文:(從 @horse_ebooks 閱讀更多...)

注意要是對相同方法覆寫實作的話,就無法呼叫預設實作。

特徵作為參數

現在你知道如何定義與實作特徵,我們可以來探討如何使用特徵來定義函式來接受多種不同的型別。

舉例來說,在範例 10-13 我們對 NewsArticleTweet 實作了 Summary 特徵。我們可以定義一個函式 notify 使用它自己的參數 item 來呼叫 summarize 方法,所以此參數的型別預期有實作 Summary 特徵。 為此我們可以使用 impl Trait 語法,如以下所示:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} {} 著 ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("頭條新聞!{}", item.summarize());
}

與其在 item 參數指定實際型別,我們用的是 impl 關鍵字並加上特徵名稱。這樣此參數就會接受任何有實作指定特徵的型別。在 notify 本體中我們就可以用 item 呼叫 Summary 特徵的任何方法,像是 summarize。我們可以呼叫 notify 並傳遞任何 NewsArticleTweet 的實例。但如果用其他型別像是 Stringi32 來呼叫此程式碼的話會無法編譯,因為那些型別沒有實作 Summary

特徵界限語法

impl Trait 語法看起來很直觀,不過它其實是一個更長格式的語法糖,這個格式稱之為「特徵界限(trait bound)」,它長得會像這樣:

pub fn notify<T: Summary>(item: &T) {
    println!("頭條新聞!{}", item.summarize());
}

此格式等同於之前段落的範例,只是比較長一點。我們將特徵界限置於泛型型別參數的宣告中,在尖括號內接在冒號之後。

impl Trait 語法比較方便,而且在簡單的案例中可以讓程式碼比較簡潔;特徵界限語法則適合用於其他比較複雜的案例。舉例來說我們可以有兩個有實作 Summary 的參數,使用 impl Trait 語法看起來會像這樣:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

如果我們想要此函式允許 item1item2 是不同型別的話,使用 impl Trait 的確是正確的(只要它們都有實作 Summary)。不過如果我們希望兩個參數都是同一型別的話,我們就得使用特徵界限來表達,如以下所示:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

泛型型別 T 作為 item1item2 的參數會限制函式,讓傳遞給 item1item2 參數的數值型別必須相同。

透過 + 來指定多個特徵界限

我們也可以指定不只一個特徵界限。假設我們還想要 notify 中的 item 不只能夠呼叫 summarize 方法,還能顯示格式化訊息的話,我們可以在 notify 定義中指定 item 必須同時要有 DisplaySummary。這可以使用 + 語法來達成:

pub fn notify(item: &(impl Summary + Display)) {

+ 也能用在泛型型別的特徵界限中:

pub fn notify<T: Summary + Display>(item: &T) {

有了這兩個特徵界限,notify 本體就能呼叫 summarize 以及使用 {} 來格式化 item

透過 where 來使特徵界限更清楚

使用太多特徵界限也會帶來壞處。每個泛型都有自己的特徵界限,所以有數個泛型型別的函式可以在函式名稱與參數列表之間包含大量的特徵界限資訊,讓函式簽名難以閱讀。因此 Rust 有提供另一個在函式簽名之後指定特徵界限的語法 where。所以與其這樣寫:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

我們可以這樣寫 where 的語法,如以下所示:

fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

此函式簽名就沒有這麼複雜了,函式名稱、參數列表與回傳型別能靠得比較近,就像沒有一堆特徵界限的函式一樣。

返回有實作特徵的型別

我們也能在回傳的位置使用 impl Trait 語法來回傳某個有實作特徵的型別數值,如以下所示:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} {} 著 ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

impl Summary 作為回傳型別的同時,我們在函式 returns_summarizable 指定回傳有實作 Summary 特徵的型別而不必指出實際型別。在此例中,returns_summarizable 回傳 Tweet,但呼叫此函式的程式碼不會知道。

回傳一個只有指定所需實作特徵的型別在閉包(closures)與疊代器(iterators)中非常有用,我們會在第十三章介紹它們。閉包與疊代器能建立只有編譯器知道的型別,或是太長而難以指定的型別。impl Trait 語法允許你不用寫出很長的型別,而是只要指定函數會回傳有實作 Iterator 特徵的型別就好。

然而如果你使用 impl Trait 的話,你就只能回傳單一型別。舉例來說此程式碼指定回傳型別為 impl Summary ,但是寫說可能會回傳 NewsArticleTweet 的話就會無法執行:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} {} 著 ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

寫說可能返回 NewsArticleTweet 的話是不被允許的,因為 impl Trait 語法會限制在編譯器中最終決定的型別。我們會在第十七章的「允許不同型別數值的特徵物件」來討論如何寫出這種行為的函式。

透過特徵界限修正 largest 函式

現在你既然已經知道如何使用泛型型別參數來指定你想使用的行為,就讓我們回到範例 10-5 來使用泛型型別參數來修正 largest 函式的定義吧!上次我們試著執行此程式時,我們獲得這樣的錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- T
  |            |
  |            T
  |
  = note: `T` might need a bound for `std::cmp::PartialOrd`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

largest 我們想要用大於(>)運算子比較兩個型別的為 T 的數值。由於該運算子是從標準函式庫中的特徵 std::cmp::PartialOrd 的預設方法所定義的,我們希望在 T 中加上 PartialOrd 的特徵界限,讓函式可以比較任意型別的切片。我們不需要將 PartialOrd 引入作用域因為它由 prelude 提供。請變更 largest 的簽名如以下所示:

fn largest<T: PartialOrd>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("最大數字為 {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("最大字元為 {}", result);
}

這次編譯程式碼時,我們會得到不同的錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0508]: cannot move out of type `[T]`, a non-copy slice
 --> src/main.rs:2:23
  |
2 |     let mut largest = list[0];
  |                       ^^^^^^^
  |                       |
  |                       cannot move out of here
  |                       move occurs because `list[_]` has type `T`, which does not implement the `Copy` trait
  |                       help: consider borrowing here: `&list[0]`

error[E0507]: cannot move out of a shared reference
 --> src/main.rs:4:18
  |
4 |     for &item in list {
  |         -----    ^^^^
  |         ||
  |         |data moved here
  |         |move occurs because `item` has type `T`, which does not implement the `Copy` trait
  |         help: consider removing the `&`: `item`

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0507, E0508.
For more information about an error, try `rustc --explain E0507`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

此錯誤的關鍵在 cannot move out of type [T], a non-copy slice。在我們非泛型版本的函式 largest 中,我們只有嘗試尋找 i32char 的最大值。如同第四章「只在堆疊上的資料:拷貝(Copy)」段落所提到的,像 i32char 這樣的型別是已知大小可以存在堆疊上,所以它們有實作 Copy 特徵。但當我們建立泛型函式 largest 時,list 參數就有可能拿到沒有實作 Copy 特徵的型別。隨後導致我們無法將 list[0] 移出給變數 largest,最後產生錯誤。

要限制此程式碼只允許有實作 Copy 特徵的型別,我們可以再 T 的特徵界限中加上 Copy!範例 10-15 展示了泛型函式 largest 完整的程式碼,只要我們傳遞給函式的切片數值型別有實作 PartialOrd Copy 特徵的話(像是 i32char),就能編譯成功。

檔案名稱:src/main.rs

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("最大數字為 {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("最大字元為 {}", result);
}

範例 10-15:一個適用於任何實作 PartialOrdCopy 特徵的泛型的 largest 函式

如果我們不想要限制函式 largest 只接受實作 Copy 特徵的型別,我們可以在 T 中改指定 Clone 而非 Copy。這樣當我們想要 largest 取得所有權,我們就可以克隆切片的數值。使用 clone 函式代表我們對於像是 String 這樣擁有堆積資料的型別,可能會產生更多堆積分配。而如果我們處理的資料很龐大的話,堆積分配的速度可能就會很慢。

另一種實作 largest 的方法是我們可以來回傳切片中 T 數值的引用。如果我們將回傳型別改成 &T 而非 T,也就是改變函式本體來回傳引用的話,我們就不需要 CloneCopy 特徵界限,也能避免堆積分配。請試著自己實作這個解決辦法看看吧!

透過特徵界限來選擇性實作方法

在有使用泛型型別參數 impl 區塊中使用特徵界限,我們可以選擇性地對有實作特定特徵的型別來時錯方法。舉例來說,範例 10-16 的 Pair<T> 只有在其內部型別 T 有實作能夠做比較的 PartialOrd 特徵以及能夠顯示在螢幕的 Display 特徵的話,才會實作 cmp_display 方法。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
}

範例 10-16:依據特徵界限來選擇性地在泛型型別實作方法

我們還可以對有實作其他特徵的型別選擇性地來實作特徵。對滿足特徵界限的型別實作特徵會稱之為毯子實作(blanket implementations),這被廣泛地用在 Rust 標準函式庫中。舉例來說,標準函式庫會對任何有實作 Display 特徵的型別實作 ToString。標準函式庫中的 impl 區塊會有類似這樣的程式碼:

impl<T: Display> ToString for T {
    // --省略--
}

因為標準函式庫有此毯子實作,我們可以在任何有實作 Display 特徵的型別呼叫 ToString 特徵的 to_string 方法。舉例來說,我們可以像這樣將整數轉變成對應的 String 數值,因為整數有實作 Display


#![allow(unused)]
fn main() {
let s = 3.to_string();
}

毯子實作在特徵技術文件的「Implementors」段落有做說明。

特徵與特徵界限讓我們能使用泛型型別參數來減少重複的程式碼的同時,告訴編譯器該泛型型別該擁有何種行為。編譯器可以利用特徵界限資訊來檢查程式碼提供的實際型別有沒有符合特定行為。在動態語言中,我們要是呼叫一個該型別沒有的方法的話,我們會在執行時才發生錯誤。但是 Rust 將此錯誤移到編譯期間,讓我們必須在程式能夠執行之前確保有修正此問題。除此之外,我們還不用寫在執行時檢查此行為的程式碼,因為我們已經在編譯時就檢查了。這麼做我們可以在不失去泛型彈性的情況下,提升效能。

另一種我們已經看過的泛型為生命週期(lifetimes)。不同於確保一個型別有沒有我們要的行為,生命週期確保我們在需要引用的時候,它們都是有效的。讓我們來看看生命週期是怎麼做到的。

透過生命週期驗證引用

我們在第四章的「引用與借用」段落沒談到的是,Rust 中的每個引用都有個生命週期(lifetime),這是決定該引用是否有效的作用域。大多情況下生命週期是隱式且可推導出來得,就像大多情況下型別是可推導出來的。當多種型別都有可能時,我們就得詮釋型別。同樣地,當生命週期的引用能以不同方式關聯的話,我們就得詮釋生命週期。Rust 要求我們用泛型生命週期參數來詮釋引用之間的關係,以確保實際在執行時的引用絕對是有效的。

生命週期的概念與其他程式語言有的工具都大相徑庭,這讓生命週期成為 Rust 最獨特的特色。雖然我們不會在此章涵蓋所有生命週期的內容,但是我們講些你可能遇到生命週期的常見場景,來讓你更加熟悉這個概念。

透過生命週期預防迷途引用

生命週期最主要的目的就是要預防迷途引用(dangling references),其會導致程式引用到其他資料,而非它原本想要的引用。請看一下範例 10-17 的程式,它有一個外部作用域與內部作用域。

fn main() {
    {
        let r;

        {
            let x = 5;
            r = &x;
        }

        println!("r: {}", r);
    }
}

範例 10-17:嘗試使用其值已經離開作用域的引用

注意:範例 10-17、10-18 與 10-24 宣告變數時都沒有給予初始數值,所以變數名稱可以存在於外部作用域。乍看之下這似乎違反 Rust 不存在空值的原則。但是如果我們嘗試在賦值前使用變數的話,我們就會獲得編譯期錯誤,這證明 Rust 的確不允許空值。

外部作用域宣告了一個沒有初始值的變數 r,然後內部作用域宣告了一個初始值為 5 的變數 x。在內部作用域中,我們嘗試將 x 的引用賦值給 r。然後內部作用域結束後,我們嘗試印出 r。此程式碼不會編譯成功,因為數值 r 指向的數值在我們嘗試使用它時已經離開作用域。以下是錯誤訊息。

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
  --> src/main.rs:7:17
   |
7  |             r = &x;
   |                 ^^ borrowed value does not live long enough
8  |         }
   |         - `x` dropped here while still borrowed
9  | 
10 |         println!("r: {}", r);
   |                           - borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

變數 x 「存在的不夠久」。原因是因為當內部作用域在第 7 行結束時,x 會離開作用域。但是 r 卻還在外部作用域中有效,我們會說的「活得比較久」。如果 Rust 允許此程式碼可以執行的話,r 就會引用到 x 離開作用域後被釋放的記憶體位置,然後我們嘗試對 r 做的事情都不會是正確的了。所以 Rust 如何決定此程式碼無效呢?它使用了借用檢查器。

借用檢查器

Rust 編譯器有個借用檢查器(borrow checker)會比較作用域來檢測所有的借用是否有效。範例 10-18 顯示了範例 10-17 的程式碼,但加上了變數生命週期的詮釋。

fn main() {
    {
        let r;                // ---------+-- 'a
                              //          |
        {                     //          |
            let x = 5;        // -+-- 'b  |
            r = &x;           //  |       |
        }                     // -+       |
                              //          |
        println!("r: {}", r); //          |
    }                         // ---------+
}

範例 10-18:變數 rx 的生命週期詮釋,分別以 'a'b 作為表示

我們在此定義 r 的生命週期詮釋為 'ax 的生命週期為 'b。如同你所見,內部的 'b 區塊比外部的 'a 生命週期區塊還小。在編譯期間,Rust 會比較兩個生命週期的大小,並看出 r 有生命週期 'a 但它引用的記憶體有生命週期 'b。程式被回絕的原因是因為 'b'a 還短:被引用的對象比引用者存在的時間還短。

範例 10-19 修正了此程式碼讓它不會存在迷途引用,並能夠正確編譯。

fn main() {
    {
        let x = 5;            // ----------+-- 'b
                              //           |
        let r = &x;           // --+-- 'a  |
                              //   |       |
        println!("r: {}", r); //   |       |
                              // --+       |
    }                         // ----------+
}

範例 10-19:一個有效引用,因為資料比引用的生命週期還長

x 在此有生命週期 'b,此時它比 'a 還長。這代表 r 可以引用 x,因為 Rust 知道 r 的引用在 x 是有效的時候永遠是有效的。

現在你知道引用的生命週期,以及 Rust 如何分析生命週期以確保引用永遠有效了。讓我們來探索函式中參數與回傳值的泛型生命週期。

函式中的泛型生命週期

讓我們寫個回傳兩個字串切片中較長者的函式。此函式會回傳兩個字串切片並回傳一個字串切片。在我們實作 longest 函式後,範例 10-20 的程式碼應該要印出 最長的字串為 abcd

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("最長的字串為 {}", result);
}

範例 10-20:main 函式呼叫 longest 函式來找出兩個字串切片中較長的

注意我們需要函式接收的字串切片屬於引用,因為我們不希望 longest 函式會取得它參數的所有權。第四章的「字串切片作為參數」段落有提到為何範例 10-20 的參數正是我們所想要使用的參數。

如果我們嘗試實作 longest 函式時,如範例 10-21 所示,它不會編譯過。

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("最長的字串為 {}", result);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

範例 10-21:回傳兩個字串中較長者的 longest 函式實作,不過無法編譯成功

我們會看到以下關於生命週期的錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

提示文字表示回傳型別需要有一個泛型生命週期參數,因為 Rust 無法辨別出回傳的引用指的是 x 還是 y。事實上,我們也不知道,因為函式本體中的 if 區塊會回傳 x 引用而 else 區塊會回傳 y 引用!

當我們定義函式時,我們不知道傳遞進此函式的實際數值會是什麼,所以我們不知道到底是 ifelse 的區塊會被執行。我們也不知道傳遞進來的引用實際的生命週期為何,所以我們無法像範例 10-18 和 10-19 那樣觀察作用域,來判定我們回傳的引用會永遠有效。要修正此錯誤,我們要加上泛型生命週期參數來定義引用之間的關係,讓借用檢查器能夠進行分析。

生命週期詮釋語法

生命週期詮釋不會改變引用能存活多久。就像當函式簽名指定了一個泛型型別參數時,函式便能夠接受任意型別一樣。函式可以指定一個泛型生命週期參數,這樣函式就能接受任何生命週期。生命週期詮釋描述了數個引用的生命週期之間互相的關係,而不會影響其生命週期。

生命週期詮釋的語法有一點不一樣:生命週期參數的名稱必須以撇號(')作為開頭,通常全是小寫且很短,就像泛型型別一樣。大多數的人會使用名稱 'a。我們將生命週期參數置於引用的 & 之後,並使用空格區隔詮釋與引用的型別。

以下是一些例子:沒有生命週期參數的 i32 引用、有生命週期 'ai32 引用以及有生命週期 'ai32 可變引用。

&i32        // 一個引用
&'a i32     // 一個有顯式生命週期的引用
&'a mut i32 // 一個有顯式生命週期的可變引用

只有自己一個生命週期本身沒有多少意義,因為該詮釋是為了告訴 Rust 數個引用的泛型生命週期參數之間互相的關係。舉例來說,我們有個函式其參數 first 是個 i32 的引用而生命週期為 'a。此函式還有另一個參數 second 是另一個 i32 的引用而且生命週期也是 'a。生命週期詮釋意味著引用 firstsecond 必須與此泛型生命週期存活的一樣久。

函式簽名中的生命週期詮釋

現在讓我們研究 longest 函式中的生命週期詮釋吧。如同泛型型別參數,我們需要在函式名稱與參數列表之間的尖括號內宣告泛型生命週期參數。我們想在此簽名表達的是所有參數與回傳值的引用都必須有相同的生命週期。我們將生命週期命名為 'a 然後將它加到每個引用,如範例 10-22 所示。

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("最長的字串為 {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

範例 10-22:longest 函式定義指定所有簽名中的引用必須有相同的生命週期 'a

此程式碼能夠編譯成功並產生我們希望在範例 10-20 的 main 函式中得到的結果。

此函式簽名告訴 Rust 它有個生命週期 'a,函式的兩個參數都是字串切片,並且會有生命週期'a。此函式簽名還告訴了 Rust 從函式回傳的字串切片也會和生命週期 'a 存活的一樣久。實際上它代表 longest 函式回傳引用的生命週期與傳入時字串長度較短的引用的生命週期一樣。這些約束是我們希望 Rust 去強制執行的。記住當我們在此函式簽名指定生命週期參數時,我們不會變更任何傳入或傳出數值的生命週期。我們只是告訴借用檢查器應該要拒絕任何沒有服從這些約束的數值。注意到 longest 函式不需要知道 xy 實際上會活多久,只需要知道有某個作用域會用 'a 取代來滿足此簽名。

當要在函式詮釋生命週期時,詮釋會位於函式簽名中,而不是函式本體。Rust 可以不用任何協助就能分析函式中的程式碼。然而當函式擁有傳入或傳出外部程式碼的引用時,Rust 無法自己判別出參數與回傳值的生命週期。每次函式呼叫時的生命週期可能都不一樣。這就是為何我們得親自詮釋生命週期。

當我們向 longest 傳入實際引用時,'a 實際替代的生命週期為 x 作用域與 y 作用域重疊得部分。換句話說,泛型生命週期 'a 取得的生命週期會等於 xy 的生命週期中較短的。因為我們將回傳引用詮釋了相同的生命週期參數 'a,回傳引用的生命週期也會保證在 xy 的生命週期較短的結束前有效。

讓我們來看看如何透過傳入不同實際生命週期的引用來使生命週期詮釋能約束 longest 函式,如範例 10-23 所示。

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("很長的長字串");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("最長的字串為 {}", result);
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

範例 10-23使用 longest 函式並傳入 String 數值的引用,但兩個參數的實際生命週期均不相同

在此例中 string1 在外部作用域結束前都有效,而 string2 在內部作用域結束前都有效,然後 result 會取得某個有效引用直到內部作用域結束為止。執行此程式的話,你會看到借用檢查器認可此程式碼,它會編譯成功然後印出 最長的字串為 很長的長字串

接下來,讓我們寫一個範例能要求 result 生命週期的引用必須是兩個引數中較短的才行。我們會移動變數 result 的宣告到外部作用域,但保留變數 result 的賦值與 string2 一樣在內部作用域。然後我們也將使用到 resultprintln! 移到外部作用域,緊接在內部作用域結束之後。如範例 10-24 所示,此程式碼會編譯不過。

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("很長的長字串");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("最長的字串為 {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

範例 10-24:嘗試在 string2 離開作用域後使用 result

當我們嘗試編譯此程式碼,我們會看到以下錯誤:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("最長的字串為 {}", result);
  |                                          ------ borrow later used here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

錯誤訊息表示要讓 resultprintln! 陳述式有效的話,string2 必須在外部作用域結束前都是有效的。Rust 會知道是因為我們在函式的參數與回傳值使用相同的生命週期 'a 來詮釋。

身為人類我們能看出此程式碼的 string1 字串長度的確比 string2 長,因此 result 會包含 string1 的引用。因為 string1 尚未離開作用域,所以 string1 的引用在 println! 陳述式中仍然是有效的才對。然而編譯器在此情形會無法看出引用是有效的。所以我們才告訴 Rust longest 函式回傳引用的生命週期等同於傳入引用中較短的生命週期。這樣一來借用檢查器就會否決範例 10-24 的程式碼,因為它可能會有無效的引用。

歡迎嘗試設計更多採用不同數值與不同生命週期的引用作為 longest 函式參數與回傳值的實驗,並在編譯前假設你的實驗會不會通過借用檢查器,然後看看你的理解是不是正確的!

深入理解生命週期

你要指定生命週期參數的方式取決於函式的行為。舉例來說如果我們改變函式 longest 的實作為永遠只回傳第一個參數而不是最長的字串切片,我們就不需要在參數 y 指定生命週期。以下的程式碼就能編譯:

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("最長的字串為 {}", result);
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

在此例中,我們指定生命週期參數 'a 給參數 x 與回傳型別,但參數 y 則沒有,因為 y 的生命週期與 x 和回傳型別的生命週期之間沒有任何關係。

當函式回傳引用時,回傳型別的生命週期參數必須符合其中一個參數的生命週期參數。如果回傳引用沒有和任何參數有關聯的話,代表它引用的是函式本體中的數值。但這會是迷途引用,因為該數值會在函式結尾離開作用域。請看看以下嘗試在函式 longest 的實作做法,它並不會編譯成功:

檔案名稱:src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("最長的字串為 {}", result);
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("超長的字串");
    result.as_str()
}

我們在這邊雖然有對回傳型別指定生命週期參數 'a,但此實作還是會失敗,因為回傳值的生命週期與參數的生命週期完全無關。以下是我們獲得的錯誤訊息:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

error: aborting due to previous error

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10`.

To learn more, run the command again with --verbose.

問題在於 result 會離開作用域並在 longest 函式結尾被清除。我們卻嘗試從函式中回傳 result 的引用。我們無法指定生命週期參數來改變迷途引用,而且 Rust 不會允許我們將建立迷途引用。在此例中,最好的解決辦法是回傳有所有權的資料型別而非引用,並讓呼叫的函式自行決定如何清理數值。

總結來說,生命週期語法是用來連接函式中不同參數與回傳值的生命週期。一旦連結起來,Rust 就可以獲得足夠的資訊來確保記憶體安全的運算並防止會產生迷途指標或違反記憶體安全的操作。

結構體定義中的生命週期詮釋

目前為止,我們只定義過擁有所有權的結構體。結構體其實也能持有引用,不過我們會需要在結構體定義中每個引用加上生命週期詮釋。範例 10-25 有個持有字串切片的結構體 ImportantExcerpt

檔案名稱:src/main.rs

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("叫我以實瑪利。多年以前...");
    let first_sentence = novel.split('.').next().expect("找不到'.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

範例 10-25:擁有引用的結構體,所以它的定義需要加上生命週期詮釋

此結構體有個欄位 part 並擁有字串切片引用。如同泛型資料型別,我們在結構體名稱之後的尖括號內宣告泛型生命週期參數,所以我們就可以在結構體定義的本體中使用生命週期參數。此詮釋代表 ImportantExcerpt 的實例不能比它持有的欄位 part 活得還久。

main 函式在此產生一個結構體 ImportantExcerpt 的實例並持有一個引用,其為變數 novel 所擁有的 String 中的第一個句子的引用。novel 的資料是在 ImportantExcerpt 實例之前建立的。除此之外,novelImportantExcerpt 離開作用域之前不會離開作用域,所以 ImportantExcerpt 實例中的引用是有效的。

生命週期省略

你已經學到了每個引用都有個生命週期,而且你需要在有使用引用的函式與結構體中指定生命週期參數。然而在第四章的範例 4-9 我們有函式可以不詮釋生命週期並照樣編譯成功,我們在範例 10-26 再展示一次。

檔案名稱:src/lib.rs

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

範例 10-26:在範例 4-9 定義過的函式,雖然其參數與回傳值均為引用,卻仍可編譯成功

此函式可以不用生命週期詮釋仍照樣編譯過是有歷史因素的:在早期版本的 Rust(1.0 之前),此程式碼是無法編譯的,因為每個引用都得有顯式生命週期。在當時的情況下,此函式簽名會長得像這樣:

fn first_word<'a>(s: &'a str) -> &'a str {

在寫了大量的 Rust 程式碼後,Rust 團隊發現 Rust 開發者會在特定情況反覆輸入同樣的生命週期詮釋。這些情形都是可預期的,而且可以遵循一些明確的模式。開發者將這些模式加入編譯器的程式碼中,所以借用檢查器可以依據這些情況自行推導生命週期,而讓我們不必顯式詮釋。

這樣的歷史值得提起的原因是因為很可能會有更多明確的模式被找出來並加到編譯器中,意味著未來對於生命週期詮釋的要求會更少。

被寫進 Rust 引用分析的模式被稱作生命週期省略規則(lifetime elision rules)。這些不是程式設計師要遵守的規則,而是一系列編譯器能去考慮的情形。而如果你的程式碼符合這些情形時,你就不必顯式寫出生命週期。

省略規則無法提供完整的推導。如果 Rust 能明確套用規則,但在這之後還是有引用存在模棱兩可的生命週期,編譯器就無法猜出剩餘引用的生命週期。在此情況,編譯器不會亂猜,它會回傳錯誤給你,你可以指定生命週期詮釋來指明引用之間的關係。

在函式或方法參數上的生命週期稱為輸入生命週期(input lifetimes),而在回傳值的生命週期則稱為輸出生命週期(output lifetimes)

當引用沒有顯式詮釋生命週期時,編譯器會用三項規則來推導它們。第一個規則適用於輸入生命週期,而第二與第三個規則適用於輸出生命週期。如果編譯器處理完這三個規則,卻仍有引用無法推斷出生命週期時,編譯器就會停止並回傳錯誤。適用於 fn 定義的規則一樣適用於 impl 區塊。

第一個規則是每個引用都會有自己的生命週期參數。換句話說,一個函式只有一個參數的話,就只會有一個生命週期:fn foo<'a>(x: &'a i32);一個函式有兩個參數的話,就會有分別兩個生命週期參數:fn foo<'a, 'b>(x: &'a i32, y: &'b i32),以此類推。

第二個規則是如果剛好只有一個輸入生命週期參數,該參數就會賦值給所有輸出生命週期參數:fn foo<'a>(x: &'a i32) -> &'a i32

第三個規則是如果有多個輸入生命週期參數,但其中一個是 &self&mut self,由於這是方法,self 的生命週期會賦值給所有輸出生命週期參數。此規則讓方法更容易讀寫,因為不用寫更多符號出來。

讓我們假裝我們是編譯器。我們會檢查這些規則並找出範例 10-26 中函式 first_word 簽名中引用的生命週期。簽名的引用一開始沒有任何生命週期:

fn first_word(s: &str) -> &str {

接著編譯器檢查第一個規則,指明每個參數都有自己的生命週期。我們如往常一樣指定 'a,所以簽名就會變成:

fn first_word<'a>(s: &'a str) -> &str {

然後第二個規則也是用因為這裡剛好就一個輸入生命週期而已。第二個規則指明只有一個輸入生命週期的話,就會賦值給所有其他輸出生命週期。所以簽名現在變成這樣:

fn first_word<'a>(s: &'a str) -> &'a str {

現在此函式所有的引用都有生命週期了,而且編譯器可以繼續分析,不必要求程式設計師在此詮釋函式簽名的生命週期。

讓我們再看看一個例子,這次是範例 10-21 一開始沒有任何生命週期參數的 longest 函式:

fn longest(x: &str, y: &str) -> &str {

讓我們先檢查第一項規則:每個參數都有自己的生命週期。這次我們有兩個參數,所以我們有兩個生命週期:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

你可以看出來第二個規則並不適用,因為我們有不止一個輸入生命週期。而第三個也不適用,因為 longest 是函式而非方法,其參數不會有 self 。遍歷這三個規則下來,我們仍然無法推斷出回傳型別的生命週期。這就是為何我們嘗試編譯範例 10-21 的程式碼會出錯的原因:編譯器遍歷生命週期省略規則,但仍然無法推導出簽名中所有引用的生命週期。

因為第三個規則僅適用於方法簽名,我們接下來就會看看這種情況時的生命週期,看看為何第三個規則讓我們不必常常在方法簽名詮釋生命週期。

在方法定義中的生命週期詮釋

當我們在有生命週期的結構體上實作方法時,其語法類似於我們在範例 10-11 中泛型型別參數的語法。 宣告並使用生命週期參數的地方會依據它們是否與結構體欄位或方法參數與回傳值相關。

結構體欄位的生命週期永遠需要宣告在 impl 關鍵字後方以及結構體名稱後方,因為這些生命週期是結構體型別的一部分。

impl 區塊中方法簽名的引用可能會與結構體欄位的引用生命週期綁定,或者它們可能是互相獨立的。除此之外,生命週期省略規則常常可以省略方法簽名中的生命週期詮釋。讓我們看看範例 10-25 定義過的 ImportantExcerpt 來作為範例。

首先我們使用一個方叫做 level 其參數只有 self 的引用而回傳值是 i32,這不是任何引用:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("請注意:{}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("叫我以實瑪利。多年以前...");
    let first_sentence = novel.split('.').next().expect("找不到'.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

生命週期參數宣告在 impl 之後,而且也要在型別名稱之後加上。但是我們不必在 self 的引用加上生命週期詮釋,因為其適用於第一個省略規則。

以下是第三個生命週期省略規則適用的地方:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("請注意:{}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("叫我以實瑪利。多年以前...");
    let first_sentence = novel.split('.').next().expect("找不到'.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

這裡有兩個輸入生命週期,所以 Rust 用第一個生命週期省略規則給予 &selfannouncement 它們自己的生命週期。然後因為其中一個參數是 &self,回傳型別會取得 &self 的生命週期,如此一來所有的生命週期都推導出來了。

靜態生命週期

其中有個特殊的生命週期 'static 我們需要進一步討論,這是指該引用可以存活在整個程式期間。所有的字串字面值都有 'static 生命週期,我們可以這樣詮釋:


#![allow(unused)]
fn main() {
let s: &'static str = "我有靜態生命週期。";
}

此字串的文字會直接儲存在程式的二進制檔案中,所以永遠有效。因此所有的字串字面值的生命週期都是 'static

你有時可能會看到錯誤訊息建議使用 'static 生命週期。但在你對引用指明 'static 生命週期前,最好想一下該引用的生命週期是否真的會存在於整個程式期間。就算它可以,你可能也得考慮是不是該活得這麼久。大多數的情況,程式問題都來自於嘗試建立迷途引用或可用的生命週期不符。這樣的情況下,應該是要實際嘗試解決問題,而不是指明 'static 生命週期。

組合泛型型別參數、特徵界限與生命週期

讓我們用一個函式來總結泛型型別參數、特徵界限與生命週期的語法!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("最長的字串為 {}", result);
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("公告!{}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

這是範例 10-22 會回傳兩個字串切片較長者的 longest 函式。不過現在它有個額外的參數 ann,使用的是泛型型別 T,它可以是任何在 where 中所指定有實作 Display 特徵的型別。此額外參數會在函式比較兩個字串切片前印出來,這也是為何需要 Display 特徵界限。因為生命週期也是一種泛型,生命週期參數 'a 與泛型型別參數 T 都宣告在函式名稱後的尖括號內。

總結

我們在此章節涵蓋了許多內容!現在你已經知道泛型型別參數、特徵與特徵界限以及泛型生命週期參數,你已經準備好能寫出適用於許多不同情況且不重複的程式碼了。泛型型別參數讓你可以讓程式碼適用於不同型別;特徵與特徵界限確保就算型別為泛型,它們都會有相同的行為。你還學到了使用生命週期詮釋確保此如此彈性的程式碼不會造成迷途引用。而且這些分析都發生在編譯期間,完全不影響執行時效能!

不管你信不信,本章節還有很多延伸主題可以導論,像是第十七章就會討論特徵物件(trait objects),這是另一個使用特徵的方法。另外還有一些更複雜的場合會涉及到更進階的生命週期詮釋。對此你可能就會想閱讀 Rust Reference。接下來,你將學習如何在 Rust 寫測試,讓你可以確保程式碼能如期執行。

編寫自動化測試

Edsger W. Dijkstra 曾在 1972 年的演講「謙遜的程式設計師」中提到:「程式測試是個證明程式錯誤存在非常有效的方法,但要證明它不存在卻反而顯得十分無力。」這不代表我們不應該盡可能地做測試!

程式碼的正確性意謂著我們的程式碼可以如我們的預期執行。Rust 就被設計為特別注重程式的正確性,但正確性是很複雜且難以證明的。Rust 的型別系統就承擔了很大一部分的負擔,但是型別系統還是沒辦法抓到所有不正確的地方。所以 Rust 在語言內提供了編寫自動化程式測試的支援。

舉例來說,假設我們要寫個程式叫做 add_two,其會將傳入任意數字加上 2。此函式簽名接受整數作為參數並回傳一個整數作為結果。當我們實作並編譯函式時,Rust 會做所有你已經學過的型別檢查與借用檢查,來確保像是我們不會中傳入 String 數值或任意無效引用至此函式。但 Rust 無法檢查其是否能執行我們預期此函式會完成的任務,也就是回傳加上 2 的參數。說不定它會將參數加上 10 或減 50!這就是我們要做測試的地方。

舉例來說,我們可以寫測試來判定當我們傳入 3 給函式 add_two 時,回傳值是不是 5。我們可以再變更我們的程式碼時來執行這些測試,以確保原本就正確的行為不會被改變。

測試是個複雜的技能,雖然我們無法在一個章節就涵蓋如何寫出好測試的細節,但我們還是會討論 Rust 測試功能機制。我們會介紹當你寫測時時可以用的詮釋與巨集、執行測試時的預設行為與選項以及如何組織測試成單元測試與整合測試。

如何寫測試

測試是一種 Rust 函式來驗證非測試程式碼是否以預期的方式執行。測試函式的本體通常會做三件動作:

  1. 設置任何所需要的資料或狀態。
  2. 執行你希望測試的程式碼
  3. 判定結果是否與你預期的相符。

讓我們看看 Rust 特地提供給測試的功能:包含 test 屬性(attribute)、一些巨集以及 should_panic 屬性。

測試函式剖析

最簡單的形式來看,測試在 Rust 中就是附有 test 屬性的函式。屬性(Attributes)是一種關於某段 Rust 程式碼的詮釋資料(metadata),其中一個例子是我們在第五章使用的 derive 屬性。要將一個函式轉換成測試函式,在 fn 前一行加上 #[test] 即可。當你用 cargo test 命令來執行你的測試時,Rust 會建構一個測試執行檔並執行標有 test 屬性的程式,並回報每個測試函式是否通過或失敗。

當我們用 Cargo 建立新的函式庫專案時,同時會自動建立一個擁有測試函式的測試模組。此模組能協助我們開始寫測試,讓你不必在每次建立新專案時,尋找特定結構體與測試函式的語法。你可以新增多少測試函式與多少測試模組都沒問題!

我們將會透過實驗測試產生的樣板而非實際測試任何程式碼,來探索測試如何運作的每個環節。然後我們會寫些現實世界會寫得測試,呼叫我們寫的程式碼並判定其行為是否正確。

讓我們建立個函式庫專案叫做 adder

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

函式庫專案 adder 中的 src/lib.rs 檔案內容會長得像範例 11-1 所示。

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

fn main() {}

範例 11-1:透過 cargo new 自動產生的測試模組與函式

現在我們先忽略開頭前兩行並專注在函式,看看它執行的。注意到 fn 上一行的 #[test] 詮釋:此屬性指出這是測試函式,所以測試者會知道此函式是用來測試的。我們也可以在 tests 模組中加入非測試函式來協助設置常見場景或是執行常見運算,所以我們需要在想要測試的函式前加上 #[test] 屬性。

函式本體使用 assert_eq! 巨集來判定 2 + 2 等於 4。此判定是作為典型測試的範例格式。讓我們執行它來看看此測試是否會通過。

cargo test 命令會執行專案中的所有測試,如範例 11-2 所示。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

範例 11-2:執行自動產生的測試的輸出結果

Cargo 會編譯並執行測試。在 CompilingFinishedRunning 之後會出現 running 1 test 此行。下一行會顯示自動產生的測試函式 it_works 以及測試執行的結果 ok。再來可以看到整體總結,test result: ok. 代表所有測試都有通過,然後 1 passed; 0 failed 指出所有測試成功或失敗的數量。

因為我們尚未有任何會忽略的程式碼,所以總結會顯示 0 ignored。我們也沒有過濾會執行的測試,所以總結最後顯示 0 filtered out。我們會在下個段落 「控制程式如何執行」 來討論忽略與過濾測試。

0 measured 的統計數值是指評測效能的效能測試。效能測試(Benchmark tests)在本書撰寫時,仍然僅在 nightly Rust 可用。請查閱效能測試的技術文件來瞭解詳情。

測試輸出結果的下一部分,也就是 Doc-tests adder,是指任何技術文件測試的結果。我們還沒有任何技術文件測試,但是 Rust 可以編譯在 API 技術文件中的任何程式碼範例。此功能能幫助我們將技術文件與程式碼保持同步!我們會在第十四章的 「將技術文件註解作為測試」段落討論如何寫技術文件測試。現在我們會先忽略 Doc-tests 的輸出結果。

讓我們變更程式碼的名稱來看看測試輸出會變成什麼。將 it_works 函式變更名稱,像是以下改成 exploration 這樣:

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

fn main() {}

然後再執行一次 cargo test,輸出會顯示 exploration 而非 it_works

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

讓我們在加上另一個測試,不過這次我們要讓測試失敗!測試會在測試函式恐慌時失敗,每個測試會跑在新的執行緒(thread)上,然後當主執行緒看到測試執行緒死亡時,就會將該測試標記為失敗的。我們有在第九章提及引發恐慌最簡單的辦法,那就是呼叫 panic! 巨集。將它寫入新的測試 another 中,所以你在 src/lib.rs 的檔案中會看到向範例 11-3 這樣。

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("此測試會失敗");
    }
}

fn main() {}

範例 11-3:新增第二個會失敗的測試,因為我們會呼叫 panic! 巨集

使用 cargo test 再執行一次測試,輸出結果應該會像範例 11-4 這樣,顯示出我們的 exploration 測試通過但 another 失敗。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running target/debug/deps/adder-92948b65e88960b4

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----
thread 'main' panicked at '此測試會失敗', src/lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

範例 11-4:其中一個測試通過,而另一個失敗的輸出結果

test tests::another 這行會顯示 FAILED 而非 ok。在獨立結果與總結之間出現了兩個新的段落,第一個段落會顯示每個測試失敗的原因細節。在此例中,another 因為 src/lib.rs 檔案中第十行的恐慌 panicked at '此測試會失敗' 而失敗。下一個段落則是會列出所有失敗的測試,要是測試很多且失敗測試輸出結果很長的話,此資訊就很實用。我們可以使用失敗測試的名稱來只執行這個測試以便除錯。我們會在「控制程式如何執行」段落討論更多執行測試的方法。

總結會顯示在最後一行,在此例中它表示我們有一個測試結果是 FAILED。也就是我們有一個測試通過,一個測試失敗。

現在你知道測試結果在不同場合看起來的樣子,讓我們來看看除了 panic! 以外對測試也很有幫助的巨集吧。

透過 assert! 巨集檢查結果

標準函式庫提供的 assert! 巨集可以在你要確保測試中的一些條件評估為 true 時使用。我們給予 assert! 巨集一個引數來計算出布林值。如果數值為 trueassert! 不會做任何動作然後測試就會通過。如果數值為 falseassert! 巨集會呼叫 panic! 巨集導致測試失敗。使用 assert! 巨集能幫助我們檢查我們的程式碼是否以我們預期的方式運作。

在第五章的範例 5-15,我們有結構體 Rectangle 與方法 can_hold,我們在範例 11-5 再看一次。讓我們將此程式碼寫入 src/lib.rs 檔案中,並寫些對它使用 assert! 巨集的測試。

檔案名稱:src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {}

範例 11-5:第五章中的結構體 Rectangle 與其方法 can_hold

can_hold 方法會回傳布林值,這代表它是 assert! 巨集的絕佳展示機會。在範例 11-6 中,我們寫了個測試來練習 can_hold 方法,我們建立了一個寬度為 8 長度為 7 的 Rectangle 實例,並判定它可以包含另一個寬度為 5 長度為 1 的 Rectangle 實例。

檔案名稱:src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

fn main() {}

範例 11-6:一支檢查一個大長方形是否能包含一個小長方形的 can_hold 測試

注意到我們已經在 tests 模組中加了一行 use super::*;tests 和一般的模組一樣都遵循我們在第七章「引用模組項目的路徑」提及的常見能見度規則。因為 tests 模組是內部模組,我們需要將外部模組的程式碼引入內部模組的作用域中。我們使用全域運算子(glob)讓外部模組定義的所有程式碼在此 tests 模組都可以使用。

我們將我們的測試命名為 larger_can_hold_smaller,然後我們建立兩個我們需要用到的 Rectangle 實例。然後我們呼叫 assert! 巨集並將 larger.can_hold(&smaller) 的結果傳給它。此表達式應該要回傳 true,所以我們的程式應該會通過。讓我們看看結果吧!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running target/debug/deps/rectangle-6584c4561e48942e

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

它通過了!讓我們再加另一個測試,這是是判定小長方形無法包含大長方形:

檔案名稱:src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        // --省略--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

fn main() {}

因為函式 can_hold 的正確結果在此例為 false,我們需要將該結果反轉後才能傳給 assert! 巨集。因此我們的測試在 can_hold 回傳 false 時才會通過:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running target/debug/deps/rectangle-6584c4561e48942e

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

兩個測試都過了!現在讓我們看看當我們在程式碼中引入程式錯誤的話,測試結果會為何。讓我們來改變 can_hold 方法的實作將比較時的大於符號改成小於符號:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --省略--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

fn main() {}

執行測試的話現在就會顯示以下結果:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running target/debug/deps/rectangle-6584c4561e48942e

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'main' panicked at 'assertion failed: larger.can_hold(&smaller)', src/lib.rs:28:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

我們的測試抓到了錯誤!因為 larger.width 是 8 而 smaller.width 是 5,can_hold 比較寬度時現在會回傳 false,因為 8 沒有比 5 小。

透過 assert_eq!assert_ne! Macros測試相等

有一種常見的測試程式的方式是將程式碼的結果與你預期程式碼會回傳的數值做比較,檢查它們是否相等。你可以使用 assert! 巨集並傳入使用 == 運算子的表達式來辦到。不過這種測試方法是很常見的,所以標準函式庫提供了一對巨集 assert_eq!assert_ne! 來讓你能更方便地測試。這兩個巨集分別比較兩個引數是否相等或不相等。如果判定失敗的話,它們還會印出兩個數值,讓我們能清楚看到為何測試失敗。相對地,assert! 巨集只會說明它在 == 表達式中取得 false 值,而不會告訴你導致 false 的那兩個值。

在範例 11-7 中,我們寫了個函式叫做 add_two 並對參數加上 2 然後回傳為結果。然後我們使用 assert_eq! 巨集來測試此函式。

檔案名稱:src/lib.rs

pub fn add_two(a: i32) -> i32 {
    a + 2
}

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

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

fn main() {}

範例 11-7:使用 assert_eq! 巨集測試函式 add_two

讓我們檢查後它的確通過了!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

我們給予 assert_eq! 巨集的第一個引數 4 與呼叫 add_two(2) 的結果相等。測試的結果為 test tests::it_adds_two ... okok 就代表我們的測試通過了!

讓我們在我們的程式碼引入個錯誤,看看使使用 assert_eq! 的測試失敗時看起來為何。變更函式 add_two 的實作改成加 3

pub fn add_two(a: i32) -> i32 {
    a + 3
}

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

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

fn main() {}

再執行一次測試:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

我們的測試抓到了錯誤!it_adds_two 測試失敗了,並顯示assertion failed: `(left == right)` 然後接著顯示 left4right5。此訊息非常有用,且能幫助我們開始除錯,它代表 assert_eq! 的引數 left4 但是擁有 add_two(2) 的引數 right 卻是 5

注意到在有些語言或測試框架中,判定兩個數值是否相等的函式的參數會稱作 expectedactual,然後它們會因為指定的引數順序而有差。但在 Rust 中它們被稱為 leftright,且我們預期的值與測試中程式碼產生的值之間的順序沒有任何影響。我們可以在此程式這樣寫判定 assert_eq!(add_two(2), 4),而錯誤訊息就會顯示成 assertion failed: `(left == right)`,然後 left 會是 5right 會是 4

assert_ne! 巨集會在我們給予的兩個值不相等時通過,相等時失敗。此巨集適用於當我們不確定一個數值會是什麼樣子,但是我們確定知道如果我們程式如預期執行的話,該數值不會是某種樣子。舉例來說,如果我們要測試一個保證會以某種形式更改其輸入的函式,但輸入變更的方式是依照我們執行程式時的當天是星期幾來決定,此時最好的判定方式就是檢查函式的輸出不等於輸入。

assert_eq!assert_ne! 巨集底下分別使用了 ==!= 運算子。當判定失敗時,巨集會透過除錯格式化資訊來顯示它們的引數,代表要比較的數值必須要實作 PartialEqDebug 特徵。所有的基本型別與大多數標準函式庫中提供的型別都有實作這些特徵。對於你自己定義的結構體與枚舉,你需要實作 PartialEq,這樣該型別的數值才能判定相等或不相等。你需要實作 Debug 來顯示判定失敗時的數值。因為這兩個特徵都是可推導的特徵,就像第五章的範例 5-12 所寫的那樣,我們通常只要在你定義的結構體或枚舉前加上 #[derive(PartialEq, Debug)] 的詮釋就好。你可以查閱附錄 C 「可推導的特徵」 來發現更多可推導的特徵。

加入自訂失敗訊息

你可以寫一個一個與失敗訊息一同顯示的自訂訊息,作為 assert!assert_eq!assert_ne! 巨集的選擇性引數。任何指定在 assert! 一個必要引數或 assert_eq!assert_ne! 兩個必要引數後方的任何引數都會傳給 format! 巨集(我們在第八章「使用 + 運算子或 format! 巨集串接字串」的段落討論過),所以你可以傳入一個包含 {} 佔位符(placeholder)的格式化字串以及其對應的數值。自訂訊息可以用來紀錄判定的意義,當測試失敗時,你可以更清楚知道程式碼的問題。

舉例來說,假設我們有個函式會以收到的名字向人們打招呼,而且我們希望測試我們傳入的名字有出現在輸出:

檔案名稱:src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("哈囉{}!", name)
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("卡爾");
        assert!(result.contains("卡爾"));
    }
}

fn main() {}

此函式的要求還沒完全確定,而我們招呼開頭的文字 哈囉 很可能會在之後改變。我們決定當需求改變時,我們不想要得同時更新測試。所以我們不打算檢查 greeting 函式回傳的整個數值,我們只需要判定輸出有沒有包含輸入參數。

讓我們將錯誤引進程式中吧,將 greeting 改成不會包含 name 然後看看測試會怎麼失敗:

pub fn greeting(name: &str) -> String {
    String::from("哈囉!")
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("卡爾");
        assert!(result.contains("卡爾"));
    }
}

fn main() {}

執行此程式會產生以下錯誤:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running target/debug/deps/greeter-170b942eb5bf5e3a

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at 'assertion failed: result.contains("卡爾")', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

此結果指出判定失敗以及發生的位置。現在要是錯誤訊息可以提供我們從 greeting 函式取得的數值就更好了。讓我們來在測試函式中加入自訂訊息,該訊息會是個格式化字串,並有個佔位符(placeholder)來填入我們從 greeting 函式取得的確切數值:

pub fn greeting(name: &str) -> String {
    String::from("哈囉!")
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("卡爾");
        assert!(
            result.contains("卡爾"),
            "打招呼時並沒有喊出名稱,其數值為 `{}`",
            result
        );
    }
}

現在當我們執行測試,我們能從錯誤訊息得到更多資訊:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.93s
     Running target/debug/deps/greeter-170b942eb5bf5e3a

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at '打招呼時並沒有喊出名稱,其數值為 `Hello!`', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

我們可以看到我們實際從測試輸出拿到的數值,這能幫助我們除錯找到實際發生什麼,而不只是預期會是什麼。

透過 should_panic 檢查恐慌

除了檢查我們的程式碼有沒有回傳我們預期的正確數值,檢查我們的程式碼有沒有如我們預期處理錯誤條件也是很重要的。舉例來說,考慮我們在第九章範例 9-10 建立的 Guess 型別。其他使用 Guess 的程式碼保證會拿到數值為 1 到 100 的 Guess 實例。我們可以寫個會恐慌的程式,嘗試用範圍之外的數字建立 Guess 實例。

為此我們可以加上另一個屬性 should_panic 到我們的測試函式。此屬性讓函式的程式碼恐慌時才會通過測試,反之如果函式的程式碼沒有恐慌的話測試就會失敗。

範例 11-8 展示一支檢查 Guess::new 是否以我們預期的錯誤條件出錯的測試。

檔案名稱:src/lib.rs

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("猜測數字必須介於 1 到 100 之間,你輸入的是 {}。", value);
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

fn main() {}

範例 11-8:測試造成 panic! 的條件

我們將 #[should_panic] 屬性置於 #[test] 屬性之後與測試函式之前。讓我們看看測試通過的結果:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running target/debug/deps/guessing_game-57d70c3acb738f4d

running 1 test
test tests::greater_than_100 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

看起來不錯!現在讓我們將錯誤引入程式碼中,移除會讓 new 函式在數值大於 100 會恐慌的程式碼:

pub struct Guess {
    value: i32,
}

// --省略--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("猜測數字必須介於 1 到 100 之間,你輸入的是 {}。", value);
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

fn main() {}

當我們執行範例 11-8 的測試,它就會失敗:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running target/debug/deps/guessing_game-57d70c3acb738f4d

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

我們在此情況得到的訊息並不是很有用,但是當我們查看測試函式,我們會看到它詮釋了 #[should_panic]。這個測試失敗代表測試函式內的程式碼沒有造成恐慌。

使用 should_panic 的測試可能會有點模棱兩可,因為它們只代表該程式碼會造成某種恐慌而已。should_panic 測試只要是有恐慌都會通過,就算是不同於我們預期發生的恐慌而造成的也一樣。要讓測試 should_panic 更精準的話,我們可以加上選擇性的 expected 參數到 should_panic 中。這樣測試就會確保錯誤訊息會包含我們所寫的文字。舉例來說,範例 11-9 更改了 Guessnew 函式會依據數值太大或大小而有不同的錯誤訊息。

檔案名稱:src/lib.rs

pub struct Guess {
    value: i32,
}

// --省略--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "猜測數字必須大於等於 1,取得的數值是 {}。",
                value
            );
        } else if value > 100 {
            panic!(
                "猜測數字必須小於等於 100,取得的數值是 {}。",
                value
            );
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic(expected = "猜測數字必須小於等於 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

fn main() {}

範例 11-9:只在造成 panic! 的特定錯誤訊息會通過的測試

此測試會通過是因為我們在 should_panic 屬性加上的 expected 就是 Guess::new 函式恐慌時的子字串。我們也可以指定整個恐慌訊息,在此例的話就是 猜測數字必須小於等於 100,取得的數值是 200。。你在 should_panic 所指定的預期參數取決於該恐慌訊息是獨特或動態的,以及你希望你的測試要多精準。在此例中,恐慌訊息的子訊息就足以確認測試函式中的程式碼會執行 else if value > 100 的分支。

為了觀察擁有 expected 訊息的 should_panic 失敗時會發生什麼事。讓我同樣再次將錯誤引入程式中,將 if value < 1else if value > 100 的區塊本體對調:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "猜測數字必須小於等於 100,取得的數值是 {}。",
                value
            );
        } else if value > 100 {
            panic!(
                "猜測數字必須大於等於 1,取得的數值是 {}。",
                value
            );
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic(expected = "猜測數字必須小於等於 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

這次當我們執行 should_panic 測試,它就會失敗:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running target/debug/deps/guessing_game-57d70c3acb738f4d

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'main' panicked at '猜測數字必須大於等於 1,取得的數值是 200。', src/lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
note: panic did not contain expected string
      panic message: `"猜測數字必須大於等於 1,取得的數值是 200。"`,
 expected substring: `"猜測數字必須小於等於 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

錯誤訊息表示此程式碼的確有如我們預期地恐慌,但是恐慌訊息並沒有包含預期的字串 '猜測數字必須小於等於 100'。在此例我們的會得到的恐慌訊息為 猜測數字必須大於等於 1,取得的數值是 200。這樣我們就能尋找錯誤在哪了!

在測試中使用 Result<T, E>

目前為止,我們的測試在失敗時就會恐慌。我們也可以寫出使用 Result<T, E> 的測試!以下是範例 11-1 的測試,不過重寫成 Result<T, E> 的版本並回傳 Err 而非恐慌:

#![allow(unused_variables)]
fn main() {}

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

it_works 函式現在有個回傳型別 Result<(), String>。在函式本體中,我們不再呼叫 assert_eq! 巨集,而是當測試成功時回傳 Ok(()),當程式失敗時回傳存有 StringErr

測試中回傳 Result<T, E> 讓你可以在測試本體中使用問號運算子,這樣能方便地寫出任何運算回傳 Err 時該失敗的測試。

不過你就不能將 #[should_panic] 詮釋用在使用 Result<T, E> 的測試。當程式該失敗時,你必須直接回傳 Err 數值。

現在你知道了各種寫測試的方法,讓我們看看執行程式時發生了什麼事,並探索我們可以對 cargo test 使用的選項。

控制程式如何執行

就像 cargo run 會編譯你的程式碼並執行產生的二進制檔案,cargo test 會在測試模式編譯你的程式碼並執行產生的測試二進制檔案。你可以指定命令列選項來改變 cargo test 的預設行為。舉例來說 cargo test 預設行為產生的二進制執行檔會平行執行所有測試並獲取測試執行時產生的輸出,讓測試各自的輸出結果不會顯示出來,以更容易讀取相關測試的結果。

有些命令列選項用於 cargo test 而有些則用於產生的測試二進制檔案。要分開這兩種引數,你可以先寫奧用於 cargo test 的引數然後加上 -- 分隔線來區隔要用於測試二進制檔案的引數。執行 cargo test --help 可以顯示你能用在 cargo test 的選項,而執行 cargo test -- --help 在則會顯示你在 -- 之後能用的選項。

平行或接續執行測試

當你執行數個測試時,它們預設會使用執行緒(thread)來平行執行。這樣測試可以更快完成,讓你可以從你或其他人的程式碼更快獲得回饋。因為測試是同時一起執行的,請確保你的測試並不依賴其他測試或是共享的狀態。這包含共享環境,像是目前的工作目錄或是環境變數。

舉例來說,假設你的每個測試都會執行些程式碼會在硬碟上產生一個檔案叫做 test-output.txt 並將一些資料寫入檔案中。然後每個測試讀取檔案中的資料,並判定該檔案有沒有包含特定的值,而這個值在每個測試都不相同。因為測試同時執行,其中的測試可以能覆蓋其他測試寫入與讀取的內容。這樣其他測試就會失敗,並不是因為程式碼不正確,而是因為平行執行時該測試會被其他測試所影響。其中一個解決辦法是確保每個測試都寫入不同的檔案,或者也可以選擇一次只執行一個測試。

如果你不想平行執行測試,或者你想要能更加掌控使用的執行緒數量,你可以傳遞 --test-threads 的選項以及你希望在測試執行檔使用的執行緒數量。請看一下以下範例:

$ cargo test -- --test-threads=1

我們將測試執行緒設為 1,告訴程式不要做任何平行化。使用一條執行緒執行測試會比平行執行它們還來的久,但是如果測試有共享狀態的話,它們就會不互相影響到對方了。

顯示函式輸出結果

如果測試通過的話,Rust 的測試函式庫預設會獲取所有印出的標準輸出。舉例來說,如果我們在測試中呼叫 println! 然後測試通過的話,我們不會在終端機看到 println! 的輸出,我們只會看到一行表達測試通過的訊息。如果測試失敗,我們才會看到所有印出的標準輸出與失敗訊息。

舉例來說,範例 11-10 有個蠢蠢的函式只會印出它的參數並回傳 10,以及一個會通過的測試與一個會失敗的測試。

檔案名稱:src/lib.rs

fn main() {}

fn prints_and_returns_10(a: i32) -> i32 {
    println!("我得到的數值為 {}", a);
    10
}

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

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(10, value);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(5, value);
    }
}

範例 11-10:測試會呼叫 println! 的函式

當我們使用 cargo test 執行這些程式時,我們會看到以下輸出結果:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running target/debug/deps/silly_function-160869f38cff9166

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
我得到的數值為 8
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

注意到此輸出結果我們看不到 我得到的數值為 4,這是當測試通過時印出的訊息。這個輸出被獲取走了。而測試會失敗的標準輸出 我得到的數值為 8 則會出現在測試總結輸出的段落上,並同時顯示錯誤發生的原因。

如果我們希望在測試通過時也能看到印出的數值,我們可以用 --show-output 告訴 Rust 也在成功的測試顯示輸出結果。

$ cargo test -- --show-output

當我們使用 --show-output 再次執行範例 11-10 的話,我們就能看到以下輸出:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished test [unoptimized + debuginfo] target(s) in 0.60s
     Running target/debug/deps/silly_function-160869f38cff9166

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
我得到的數值為 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
我得到的數值為 8
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

透過名稱來執行部分測試

有時執行完整所有的測試會很花時間。如果你正專注於程式碼的特定部分,你可能會想要只執行與該程式碼有關的測試。你可以向 cargo test 傳遞你想要執行的測試名稱作為引數。

為了解釋如何執行部分測試,我們將為 add_two 函式建立三個測試,如範例 11-11 所示,然後選擇其中一個執行。

檔案名稱:src/lib.rs


#![allow(unused)]
fn main() {
pub fn add_two(a: i32) -> i32 {
    a + 2
}

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

    #[test]
    fn add_two_and_two() {
        assert_eq!(4, add_two(2));
    }

    #[test]
    fn add_three_and_two() {
        assert_eq!(5, add_two(3));
    }

    #[test]
    fn one_hundred() {
        assert_eq!(102, add_two(100));
    }
}
}

範例 11-11:三個名稱不同的測試

如果我們沒有傳遞任何引數來執行測試的話,如我們前面看過的一樣,所有測試會平行執行:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running target/debug/deps/adder-92948b65e88960b4

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

執行單獨一個測試

我們可以傳遞任何測試函式的名稱給 cargo test 來只執行該測試:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.69s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out

只有名稱為 one_hundred 的測試會執行,其他兩個的名稱並不符合。測試輸出會在總結的最後顯示 2 filtered out 告訴我們除了命令列執行的測試以外,還有更多其他測試。

我們無法用此方式指定多個測試名稱,只有第一個傳給 cargo test 有用。但我們有其他方式能執行數個測試。

過濾執行數個測試

我們可以指定部分測試名稱,然後任何測試名稱中有相符的就會被執行。舉例來說,因為我們有兩個測試的名稱都包含 add,我們可以透過執行 cargo test add 來執行這兩個測試:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running target/debug/deps/adder-92948b65e88960b4

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out

此命令會執行所有名稱中包含 add 的測試,並過濾掉 one_hundred 的測試名稱。另外測試所在的模組也屬於測試名稱中,所以我們可以透過過濾模組名稱來執行該模組的所有測試。

忽略某些測試除非特別指定

有時候有些特定的測試執行會花非常多時間,所以你可能希望在執行 cargo test 時能排除它們。與其列出所有你想要的測試作為引數,你可以在花時間的測試前加上 ignore 屬性詮釋來排除它們,如以下所示:

檔案名稱:src/lib.rs

#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
    // 會執行一小時的程式碼
}

fn main() {}

對於想排除的測試,我們在 #[test] 之後我們加上 #[ignore]。現在當我們執行我們的測試時,it_works 會執行但 expensive_test 就不會:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.60s
     Running target/debug/deps/adder-92948b65e88960b4

running 2 tests
test expensive_test ... ignored
test it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

expensive_test 函式會列在 ignored,如果我們希望只執行被忽略的測試,我們可以使用 cargo test -- --ignored

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

透過控制哪些測試能執行,你能夠確保快速執行 cargo test。當你有時間能夠執行 ignored 的測試時,你可以執行 cargo test -- --ignored 來等待結果。

測試組織架構

如同本章開頭提到的,測試是個複雜的領域,不同的人可能使用不同的術語與組織架構。Rust 社群將測試分為兩大分類術語:單元測試(unit tests)整合測試(integration tests)。單元測試比較小且較專注,傾向在隔離環境中一次只測試一個模組,且能夠測試私有介面。整合測試對於你的函式庫來說是個完全外部的程式碼,所以會如其他外部程式碼一樣使用你的程式碼,只能使用公開介面且每個測試可能會有數個模組。

這兩種測試都很重要,且能確保函式庫每個部分能在分別或一起執行的情況下,如你預期的方式運作。

單元測試

單元測試的目的是要在隔離其他程式碼的狀況下測試每個程式碼單元,迅速查明程式碼有沒有如預期或非預期的方式運作。你會將單元測試放在 src 目錄中每個你要測試的程式同個檔案下。我們常見的做法是在每個檔案建立一個模組 tests 來包含測試函式,並用 cfg(test) 來詮釋模組。

測試模組與 #[cfg(test)]

測試模組上的 #[cfg(test)] 詮釋會告訴 Rust 當你執行 cargo test 才會編譯並執行測試程式碼。而不是當你執行 cargo build。當你想要建構函式庫時,這能節省編譯時間並降低編譯出的檔案所佔的空間,因為這些測試沒有被包含到。整合測試位於不同目錄,所以它們不需要 #[cfg(test)]。但是因為單元測試與程式碼位於相同的檔案下,你需要使用 #[cfg(test)] 來指明它們不應該被包含在編譯結果。

回想一下本章節第一個段落中我們建立了一個新專案 adder,並用 Cargo 為我們產生以下程式碼:

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

fn main() {}

此程式碼是自動產生的測試模組。cfg 屬性代表的是 configuration 並告訴 Rust 以下項目只有在給予特定配置選項時才會被考慮。在此例中配置選項是 test,這是 Rust 提供用來編譯與執行測試的選項。使用 cfg 屬性的話,Cargo 只有在我們透過 cargo test 執行測試時才會編譯我們的測試程式碼。這包含此模組能可能需要的輔助函式,以及用 #[test] 詮釋的測試函式。

測試私有函式

在測試領域的社群中對於是否應該直接測試私有函式一直存在著爭議,而且有些其他語言會讓測試私有函式變得很困難,甚至不可能。不管你認為哪個論點比較理想,Rust 的隱私權規則還是能讓你測試私有函式。考慮以下範例 11-12 擁有私有函式 internal_adder 的程式碼。

檔案名稱:src/lib.rs

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

fn main() {}

範例 11-12:測試私有函式

注意到函式 internal_adder 沒有標記為 pub,但是因為測試也只是 Rust 的程式碼,且 tests 也只是另一個模組,你可以將 internal_adder 引入測試的作用域並呼叫它。如果你不認為私有函式不應該測試,Rust 也沒有什麼好阻止你的地方。

整合測試

在 Rust 中,整合測試對你的函式庫來說是完全外部的程式。它們使用你的函式庫的方式與其他程式碼一樣,所以它們只能呼叫屬於函式庫中公開 API 的函式。它們的目的是要測試你的函式庫屬個部分一起運作時有沒有正確無誤。單獨運作無誤的程式碼單元可能會在整合時出現問題,所以整合測試的程式碼的涵蓋率也很重要。要建立整合測試,你需要先有個 tests 目錄。

tests 目錄

我們在專案目錄最上層在 src 旁建立一個 tests 目錄。Cargo 知道要從此目錄來尋找整合測試。我們接著就可以在此目錄建立多少個測試都沒問題,Cargo會編譯每個檔案成獨立的 crate。

讓我們來建立一個整合測試,將範例 11-12 的程式碼保留在 src/lib.rs 檔案中,然後建立一個 tests 目錄、一個叫做 tests/integration_test.rs 的檔案並輸入範例 11-13 的程式碼。

檔案名稱:tests/integration_test.rs

use adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

範例 11-13:adder crate 中函式的整合測試

我們在程式最上方加了 use adder,這在單元測試是不需要的。這裡要用到的原因是因為 tests 目錄的每個檔案都是獨立的 crate,所以我們需要將函式庫引入每個測試 crate 的作用域中。

我們不用對 tests/integration_test.rs 的任何程式碼詮釋 #[cfg(test)]。Cargo 會特別對待 tests 目錄並只在我們執行 cargo test 時,編譯此目錄的檔案。現在請執行 cargo test

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.73s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/integration_test-82e7799c1bc62298

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

輸出結果中有三個段落,包含單元測試、整合測試與技術文件測試。第一個段落的單元測試與我們看過的相同:每行會是每個單元測試(在此例是我們在範例 11-12 寫得 internal)最後附上單元測試的總結。

整合測試段落從 Running target/debug/deps/integration_test-ce99bcc2479f4607 開始(最後的雜湊值(hash)可能會與你的輸出不同),接著每行會是每個整合測試的測試函式,最後在 Doc-tests adder 段落開始前的那一行則是整合測試的總結結果。

當我們加入更多單元測試時,單元測試段落就會顯示更多結果。同樣地當我們將更多測試函式加入整合測試檔案內的話,該整合測試段落就會顯示更多結果。每個整合測試檔案會有自己的段落,如果如果我們在 tests 目錄加入更多檔案的話,就會出現更多整合測試段落。

我們一樣能用測試函式的名稱來作為 cargo test 的引數,來執行特定整合測試。要執行特定整合測試檔案內的所有測試,可以用 --test 作為 cargo test 的引數並加上檔案名稱:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.64s
     Running target/debug/deps/integration_test-82e7799c1bc62298

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

此命令會只執行 tests/integration_test.rs 檔案內的測試。

整合測試的子模組

隨著你加入的整合測試越多,你可能會想要在 tests 目錄下產生更多檔案來協助組織它們。舉例來說,你以用測試函式測試的功能來組織它們。如同稍早提到的,tests 目錄下的每個檔案都會編譯成自己獨立的 crate。

將每個整合測試檔案視為獨立的 crate 有助於建立不同的作用域,這就像是使用者使用你的 crate 的可能環境。然而這也代表 tests 目錄的檔案不會和 src 的檔案行為一樣,也就是你在第七章學到如何拆開程式碼成模組與檔案的部分。

當你希望擁有一些能協助數個整合測試檔案的輔助函式,並遵循第七章的「將模組拆成不同檔案」段落來提取它們到一個通用模組時,你就會發現 tests 目錄下的檔案行為是不同的。舉例來說,我們建立了 tests/common.rs 並寫了一個函式 setup,然後我們希望 setup 能被不同測試檔案的數個測試函式呼叫:

檔案名稱:tests/common.rs


#![allow(unused)]
fn main() {
pub fn setup() {
    // 在此設置測試函式庫會用到的程式碼
}
}

當我們再次執行程式時,我們會看到測試輸出多了一個 common.rs 檔案的段落,就算該檔案沒有包含任何測試函式,而且我們也還沒有在任何地方呼叫 setup 函式:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.89s
     Running target/debug/deps/adder-92948b65e88960b4

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/common-7064e1b6d2e271be

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/integration_test-82e7799c1bc62298

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

common 出現在測試結果並顯示 running 0 tests 並不是我們想做的事。我們只是想要分享一些程式碼給其他整合測試檔案而已。

要防止 common 出現在測試輸出,我們不該建立 tests/common.rs,而是要建立 tests/common/mod.rs。這是另一個 Rust 知道的常用命名手段。這樣命名檔案的話會告訴 Rust 不要將 common 模組視為整合測試檔案。當我們將 setup 函式程式碼移到 tests/common/mod.rs 並刪除 tests/common.rs 檔案時,原本的段落就不會再出現在測試輸出。tests 目錄下子目錄的檔案不會被編譯成獨立 crate 或在測試輸出顯示段落。

在我們建立 tests/common/mod.rs 之後,我們可以將它以模組的形式用在任何整合測試檔案中。以下是在 tests/integration_test.rsit_adds_two 測試中呼叫函式 setup 的範例:

檔案名稱:tests/integration_test.rs

use adder;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}

注意到 mod common; 的宣告與我們在範例 7-21 說明的模組宣告方式一樣。然而後在測試函式中,我們就可以呼叫函式 common::setup()

二進制執行檔 Crate 的整合測試

如果我們的專案是只包含 src/main.rs 檔案的二進制執行檔 crate 而沒有 src/lib.rs 檔案的話,我們無法在 tests 目錄下建立整合測試,也無法將 src/main.rs 檔案中定義的函式透過 use 陳述式引入作用域。只有函式庫 crate 能公開函式給其他 crate 使用,二進制 crate 只用於獨自執行。

這也是為何 Rust 專案為二進制執行檔提供直白的 src/main.rs 檔案並允許呼叫 src/lib.rs 檔案中的邏輯程式碼。使用這樣子的架構的話,整合測試可以透過 use 來測試函式庫 crate,並讓重點功能可以公開使用。如果重點功能可以運作的話,那 src/main.rs 檔案中剩下的程式碼部分也能夠如期執行,而這一小部分就不必特定做測試。

總結

Rust 的測試功能提供了判定程式碼怎樣才算正常運作的方法,以確保它能以你預期的方式運作,就算當你做了改變時也是如此。單元測試分別測試函式庫中每個不同的部分,且能測試私有實作細節。整合測試檢查函式庫數個部分一起執行時是否正確無誤,且它們使用函式庫公開 API 來測試程式碼的行為與外部程式碼使用的方式一樣。雖然 Rust 型別系統與所有權規則能避免某些種類的程式錯誤,測試還是減少邏輯程式錯誤的重要辦法,讓你的程式碼能如預期行為運作。

讓我們統整此章節以及之前的章節所學到的知識來寫一支專案吧!

I/O 專案:建立一個命令列程式

本章節用來回顧你目前學過的許多技能,並探索些標準函式庫中的更多功能。我們會來建立個命令列工作來處理檔案與命令列輸入/輸出,以此練習些你已經掌握的 Rust 概念。

Rust 的速度、安全、單一二進制輸出與跨平台支援使其成為建立命令列工具的絕佳語言。所以在我們的專案中,我們要寫出我們自己的經典命令列工具 grepglobally search a regular expression and print)。在最簡單的使用場合中,grep 會搜尋指定檔案中的指定字串。為此 grep 會接收一個檔案名稱與一個字串作為其引數。然後它會讀取檔案、在該檔案中找到包含字串引數的行數,並印出這些行數。

在過程中,我們會展示如何讓我們的命令列工具和其他許多命令列工具一樣使用終端機的功能。我們會讀取一個環境變數的數值來讓使用者可以配置此工具的行為。我們還會將錯誤訊息在控制台中的標準錯誤(stderr)顯示而非標準輸出(stdout)。所以舉例來說,使用者可以將成功的標準輸出重新導向至一個檔案,並仍能在螢幕上看到錯誤訊息。

其中一位 Rust 社群成員 Andrew Gallant 已經有建立個功能完善且十分迅速的 grep 版本,叫做 ripgrep。相比之下,我們的 grep 版本會相對簡單許多,但此章節能給你些背景知識,來幫你理解像是 ripgrep 等真實專案。

我們的 grep 專案會組合你所學過的各種概念:

我們還會簡單介紹閉包、疊代器與特徵物件,這些在第十三章第十七章會做詳細介紹。

接受命令列引數

一如往常我們用 cargo new 建立新的專案,我們將我們的專案命名為 minigrep 來與很可能已經在你系統中的 grep 工具做區別。

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

第一項任務是要讓 minigrep 能接收兩個命令列引數:檔案名稱與欲搜尋的字串。也就是說,我們想要能夠使用 cargo run、欲搜尋的字串與要被搜尋的檔案路徑來執行程式,如以下所示:

$ cargo run searchstring example-filename.txt

但現在由 cargo new 產生的程式還無法處理我們給予的引數。crates.io 有些函式庫可以幫助程式接收命令列中的引數,但有鑑於你要學習此概念,讓我們親自來實作一個。

讀取引數數值

要讓 minigrep 能夠讀取我們傳入的命令列引數數值,我們需要使用 Rust 標準函式庫中提供的函式,也就是 std::env::args。此函式會回傳一個包含我們傳給 minigrep 的命令列引數的疊代器(iterator)。我們會在第十三章詳細解釋疊代器。現在你只需要知道疊代器的兩項重點:疊代器會產生一系列的數值,然後我們可以對疊代器呼叫 collect 方法來將其轉換成像是向量的集合,來包含疊代器產生的所有元素。

使用範例 21-1 的程式碼能讓你的 minigrep 程式能夠讀取任何傳入的命令列引數,然後收集數值成一個向量。

檔案名稱:src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

範例 12-1:收集命令列引數至向量中並顯示它們

首先我們透過 use 陳述式將 std::env 模組引入作用域,讓我們可以使用它的 args 函式。注意到 std::env::args 函式位於兩層模組下。如同我們在第七章談過的,如果我們要用的函式模組路徑超過一層以上的話,通常就會將上層模組引入作用域中,而不是函式本身。這樣的話,我們可以輕鬆使用 std::env 中的其他函式。而且這也比直接加上 use std::env::args 然後只使用 args 來呼叫函式還要明確些,因為 args 容易被誤認成是由目前模組定義的函式。

args 函式與無效的 Unicode

值得注意的是如果任何引數包含無效 Unicode 的話,std::env::args 就會恐慌。如果你的程式想要接受包含無效 Unicode 引數的話,請改使用 std::env::args_os。該函式回傳會產生 OsString 數值的疊代器,而非 String 數值。我們出於簡單方便所以在此使用 std::env::args,因為 OsString 在不同平台中數值會有所差異,且會比 String 數值還要難處理。

我們在 main 中的第一行呼叫 env::args,然後馬上使用 collect 來將疊代器轉換成向量,這會包含疊代器產生的所有數值。我們可以使用 collect 函式來建立許多種集合,所以我們顯式詮釋 args 的型別來指定我們想要字串向量。雖然我們很少需要在 Rust 中詮釋型別,collect 是其中一個你常常需要詮釋的函式,因為 Rust 無法推斷出你想要何種集合。

最後,我們使用除錯格式 :? 來顯示向量。讓我們先嘗試不用引數來執行程式碼,再用兩個引數來執行:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
["target/debug/minigrep"]
$ cargo run needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
["target/debug/minigrep", "needle", "haystack"]

值得注意的是向量中第一個數值為 "target/debug/minigrep",這是我們的執行檔名稱。這與 C 的引數列表行為相符,讓程式在執行時能使用它們被呼叫的名稱路徑。存取程式名稱通常是很實用的,像是你能將它顯示在訊息中,或是依據程式被呼叫的命令列別名還改變程式的行為。但考慮本章節的目的,我們會忽略它並只儲存我們想要的兩個引數。

將引數數值儲存至變數

顯示向量中的引數數值能說明程式能夠取得命令列引數指定的數值。現在我們想要將這兩個引數存入變數中,讓我們可以在接下來的程式中使用數值,如範例 12-2 所示。

檔案名稱:src/main.rs

use std::env;

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

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

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

範例 12-2:建立變數來儲存搜尋引數與檔案名稱引數

如我們印出向量時所看到的,向量的第一個數值 args[0] 會是程式名稱,所以我們從引數 1 開始。minigrep 接收的第一個引數會是我們要搜尋的字串,所以我們將第一個引數的引用賦值給變數 query。第二個引數會是檔案名稱,所以我們將第二個引數的引用賦值給 filename

我們暫時印出這些變數的數值來證明程式碼運作無誤。讓我們用引數 testsample.txt 來再次執行程式:

$ cargo run test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
搜尋 test
目標檔案為 sample.txt

很好,程式能執行!我們想要的引數數值都有儲存至正確的變數中。之後我們會對特定潛在錯誤的情形來加上一些錯誤處理,像是當使用者沒有提供引數的情況。現在我們先忽略這樣的情況,並開始加上讀取檔案的功能。

讀取檔案

現在我們要加上能夠讀取 filename 中命令列引數指定的檔案功能。首先我們需要有個檔案範本能讓我們測試,要確保 minigrep 執行無誤的最佳檔案範本就是文字文件,其中由數行重複的單字組成少量文字。範例 12-3 Emily Dickinson 的詩就是不錯的選擇!在專案根目錄建立一個檔案叫做 poem.txt,然後輸入此詩 「I’m Nobody! Who are you?」

檔案名稱: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!

範例 12-3:以 Emily Dickinson 的詩作為絕佳測試範本

有了這些文字,接著修改 src/main.rs 來加上讀取檔案的程式碼,如範例 12-4 所示。

檔案名稱:src/main.rs

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

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

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

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

    let contents = fs::read_to_string(filename)
        .expect("讀取檔案時發生了錯誤");

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

範例 12-4:讀取第二個引數指定的檔案內容

首先,我們加上另一個 use 陳述式來將標準函式庫中的另一個相關部分引入:我們需要 std::fs 來處理檔案。

main 中,我們加上新的陳述式:fs::read_to_string 會接收 filename、開啟該檔案並回傳檔案內容的 Result<String>

在陳述式之後,我們再次加上暫時的 println! 陳述式來在讀取檔案之後,顯示 contents 的數值,讓我們能檢查程式目前運作無誤。

讓我們用任何字串作為第一個命令列引數(因為我們還沒實作搜尋的部分)並與 poem.txt 檔案作為第二個引數來執行此程式碼:

$ cargo run the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     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!

很好!程式碼有讀取並印出檔案內容。但此程式碼有些缺陷。main 函式負責太多事情了,通常如果每個函式都只負責一件事的話,函式才能清楚直白且易於維護。另一個問題是我們盡可能地處理錯誤。由於程式還很小,此缺陷不算什麼大問題,但隨著程式增長時,這會越來越難清楚地修正。在開發程式時盡早重構是很好的做法,因為重構少量的程式碼會比較簡單。接下來就讓我們開始吧。

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

為了改善我們的程式,我們需要修正四個問題,這與程式架構與如何處理潛在錯誤有關。

首先,我們的 main 函式會處理兩件任務:它得解析引數並讀取檔案。對於這麼小的函式來說,這不是大問題。然而,要是我們持續在 main 增加我們的程式,main 函式中要處理的任務就會增加。要是一個函式有這麼多責任,它就會越來越難理解、越難測試並且難在不破壞其他部分的情況下做改變。我們最好能將不同功能拆開,讓每個函式只負責一項任務。

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

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

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

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

分開二進制專案的任務

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

  • 將你的程式分成 main.rs 與 a lib.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, filename) = parse_config(&args);

    // --省略--

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

    let contents = fs::read_to_string(filename)
        .expect("讀取檔案時發生了錯誤");

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

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

    (query, filename)
}

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

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

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

集結配置數值

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

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

注意:當使用複雜型別會比較理想時,卻仍使用原始數值的反模式(anti-pattern)被稱之為原始型別偏執(primitive obsession)

範例 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.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

    // --省略--

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

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

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

    Config { query, filename }
}

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

我們定義了一個結構體 Config 其欄位有 queryfilenameparse_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 變數中,並更新之前分別使用變數 queryfilename 的程式碼段落來改使用 Config 結構體中的欄位。

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

建立 Config 的建構子

目前我們將負責解析命令列引數的邏輯從 main 移至 parse_config 函式。這樣做能幫助我們理解 queryfilename 數值是相關的,且此關係應該要能在我們的程式碼中表達出來。然後我們增加了結構體 Config 來描述 queryfilename 的相關性,並在 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.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

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

    // --省略--
}

// --省略--

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

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

        Config { query, filename }
    }
}

範例 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 不夠長的話,程式就會恐慌並顯示比 indexout of bounds 還清楚的錯誤訊息。

檔案名稱: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.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

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

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

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

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

        Config { query, filename }
    }
}

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

此程式碼類似於我們在範例 9-10 寫的 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-10 的技巧並不是最好的選擇,如同第九章所提及的panic! 的呼叫比較屬於程式設計問題,而不是使用問題。我們可以改使用第九章的其他技巧,像是回傳 Result來表達是成功還是失敗。

new 回傳 Result 而非呼叫 panic!

我們可以回傳 Result 數值,在成功時包含 Config 的實例並在錯誤時描述問題原因。當 Config::newmain 溝通時,我們可以使用 Result 型別來表達這裡有問題發生。然後我們改變 main 來將 Err 變體轉換成適當的錯誤訊息給使用者,而不是像呼叫 panic! 時出現圍繞著 thread 'main'RUST_BACKTRACE 的文字。

範例 12-9 顯示我們得改變 Config::new 的回傳值並讓函式本體回傳 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.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

我們的 new 函式現在會回傳 Result,在成功時會有 Config 實例,而在錯誤時會有個 &'static str。回憶一下第十章中「靜態生命週期」的段落曾解釋 &'static str 是字串字面值的型別,這正是我們目前的錯誤訊息型別。

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

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

呼叫 Config::new 並處理錯誤

為了能處理錯誤情形並印出對使用者友善的訊息,我們需要更新 main 來處理 Config::new 回傳的 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::new(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

    // --省略--

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

    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

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

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

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

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

        Ok(Config { query, filename })
    }
}

範例 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::new(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

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

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.filename)
        .expect("讀取檔案時發生了錯誤");

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

// --省略--

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

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

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

        Ok(Config { query, filename })
    }
}

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

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

run 函式回傳錯誤

隨著剩餘程式邏輯都移至 run 函式,我們可以像範例 12-9 的 Config::new 一樣來改善錯誤處理。不同於讓程式呼叫 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::new(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

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

    run(config);
}

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

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

    Ok(())
}

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

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

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

        Ok(Config { query, filename })
    }
}

範例 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 `std::result::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

    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::new 的技巧來處理錯誤,不過會有一些差別:

檔案名稱: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::new(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

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

    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.filename)?;

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

    Ok(())
}

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

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

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

        Ok(Config { query, filename })
    }
}

我們使用 if let 而非 unwrap_or_else 來檢查 run 是否有回傳 Err 數值,並以此呼叫 process::exit(1)run 函式沒有回傳數值,所以我們不必像處理 Config::new 得用 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::new 的函式定義

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 filename: String,
}

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

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

        Ok(Config { query, filename })
    }
}

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

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

    Ok(())
}

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

我們對許多項目都使用了 pub 關鍵字,這包含 Config 與其欄位,以及其 new 方法,還有 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::new(&args).unwrap_or_else(|err| {
        println!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

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

    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 中進行。

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

透過測試驅動開發完善函式庫功能

現在我們提取邏輯到 src/lib.rs 並在 src/main.rs 留下引數收集與錯誤處理的任務,現在對程式碼中的核心功能進行測試會簡單許多。我們可以使用各種引數直接呼叫函式來檢查回傳值,而不用從命令列呼叫我們的執行檔。歡迎自行對 Config::newrun 函式的功能寫些測試。

在此段落中,我們會在 minigrep 程式中利用試驅動開發(Test-driven development, TDD)來新增搜尋邏輯。此程式開發技巧遵循以下步驟:

  1. 寫出一個會失敗的測試並執行它來確保它失敗的原因如你所預期。
  2. 寫出或修改足夠的程式碼來讓新測試可以通過。
  3. 重構你新增或變更的程式碼並確保測試仍能持續通過。
  4. 重複第一步!

此流程是編寫軟體的許多方式之一,但 TDD 也有助於程式碼的設計。在寫出能通過測試的程式碼之前先寫好測試能夠協助在開發過程中維持高測試覆蓋率。

我們將用測試驅動功能的實作,而要實作的功能就是在檔案內容中找到欲搜尋的字串,並產生符合查詢字串的行數列表。我們會在一個叫做 search 的函式新增此功能。

編寫失敗的測試

讓我們移除 src/lib.rssrc/main.rs 中用來檢查程式行為的 println! 陳述式,因為我們不再需要它們了。然後在 src/lib.rs 中,我們加上 tests 模組與一個測試函式,如我們在第十一章所做的一樣。測試函式會指定我們希望 search 函式所能擁有的行為,它會接收搜尋字串與一段要被搜尋的文字,然後它只回傳文字中包含該搜尋字串的行數。範例 12-15 展示了此測試,但還不能編譯。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

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

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

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

fn main() {}

範例 12-15:建立一個我們預期 search 函式該有的行為的失敗測試

此測試搜尋字串 "duct"。而要被搜尋的文字有三行,只有一行包含 "duct"。我們判定 search 函式回傳的數值只會包含我們預期的那一行。

我們還無法執行此程式並觀察其失敗,因為測試還無法編譯,search 函式根本還不存在!所以現在我們要加上足夠的程式碼讓測試可以編譯並執行,而我們要加上的是 search 函式的定義並永遠回傳一個空的向量,如範例 12-16 所示。然後測試應該就能編譯並失敗,因為空向量並不符合包含 "safe, fast, productive." 此行的向量。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

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

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

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

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

fn main() {}

範例 12-16:定義足夠的 search 函式讓我們的測試能夠編譯

值得注意的是在 search 的簽名中需要定義一個顯式的生命週期 'a,並用於 contents 引數與回傳值。回想一下在第十章中生命週期參數會連結引數生命週期與回傳值生命週期。在此例中,我們指明回傳值應包含字串切片且其會引用 contents 引數的切片(而非引數 query)。

換句話說,我們告訴 Rust search 函式回傳的資料會跟傳遞給 search 函式的引數 contents 資料存活的一樣久。這點很重要!被切片引用的資料必須有效,這樣其引用才會有效。如果編譯器假設是在建立 query 而非 contents 的字串切片,它的安全檢查就會不正確。

如果我們忘記詮釋生命週期並嘗試編譯此函式,我們會得到以下錯誤:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                                                   ^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep`.

To learn more, run the command again with --verbose.

Rust 無法知道這兩個引數哪個才是我們需要的,所以我們得告訴它。由於引數 contents 包含所有文字且我們想要回傳符合條件的部分文字,所以我們知道 contents 引數要用生命週期語法與回傳值做連結。

其他程式設計語言不會要求你要在簽名中連結引數與回傳值。雖然這看起來有點奇怪,但久而久之就會越來越簡單。你可能會想要將此例與第十章的「透過生命週期驗證引用」段落做比較。

現在讓我們執行測試:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 0.97s
     Running target/debug/deps/minigrep-4672b652f7794785

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `["safe, fast, productive."]`,
 right: `[]`', src/lib.rs:44:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

很好!測試如我們所預期地失敗。接下來我們要讓測試通過!

寫出讓測試成功的程式碼

目前我們的測試會失敗,因為我們永遠只回傳一個空向量。要修正並實作 search,我們的程式需要完成以下步驟:

  • 遍歷內容的每一行。
  • 檢查該行是否包含我們要搜尋的字串。
  • 如果有的話,將它加入我們要回傳的數值列表。
  • 如果沒有的話,不做任何事。
  • 回傳符合的結果列表。

讓我們來完成每個步驟,先從遍歷每一行開始。

透過 lines 方法來遍歷每一行

Rust 有個實用的方法能逐步處理字串的每一行,這方法就叫 lines,而使用方式就如範例 12-17 所示。注意此例還不法編譯。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // 對每行做些事情
    }
}

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

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

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

範例 12-17:在 contents 中遍歷每一行

lines 方法會回傳疊代器(iterator)。我們會在第十三章詳細解釋疊代器,不過回想一下你在範例 3-5就看過疊代器的用法了,我們對疊代器使用 for 迴圈來對集合中的每個項目執行一些程式碼。

檢查每行是否有要搜尋的字串

接著,我們要檢查目前的行數是否有包含我們要搜尋的字串。幸運的是,字串有個好用的方法叫做 contains 能幫我處理這件事!在 search 函式中加上方法 contains 的呼叫,如範例 12-18 所示。注意這仍然無法編譯。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // 對每行做些事情
        }
    }
}

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

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

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

範例 12-18:增加檢查行數是否包含 query 字串的功能

儲存符合條件的行數

我們也需要有個方式能儲存包含搜尋字串的行數。為此我們可以在 for 迴圈前建立一個可變向量然後對向量呼叫 push 方法來儲存 line。在 for 迴圈之後,我們回傳向量,如範例 12-19 所示。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

pub 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
}

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

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

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

範例 12-19:儲存符合的行數讓我們可以回傳它們

現在 search 函式應該只會回傳包含 query 的行數,而我們的測試也該通過。讓我們執行測試:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 1.22s
     Running target/debug/deps/minigrep-4672b652f7794785

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/minigrep-caf9dbee196c78b9

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

我們的測試通過了,所以我們確定它運作無誤!

在此刻之後,我們可以考慮重構搜尋函式的實作,並確保測試能通過以維持功能不變。搜尋函式的程式碼並沒有很糟,但它沒有用到疊代器中的一些實用功能優勢。我們會在第十三章詳細探討疊代器之後,再回過頭來看這個例子,來看看如何改善。

run 函式中使用 search 函式

現在 search 函式能夠執行且也有測試過了,我們需要從 run 函式呼叫 search。我們需要將 config.query 數值與 run 從檔案讀取到的 contents 傳給 search 函式。然後 run 會印出 search 回傳的每一行:

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

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

    Ok(())
}

pub 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
}

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

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

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

我們仍會使用 for 迴圈來取得 search 回傳的每一行並顯示出來。

現在整支程式應該都能執行了!讓我們來試試看。首先用一個只會在 Emily Dickinson 的詩中回傳剛好一行的單字「frog」:

$ cargo run frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

酷喔!現在讓我們試試看能符合多行的單字,像是「body」:

$ cargo run body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I’m nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

最後,讓我們確保使用詩中沒出現的單字來搜尋時,我們不會得到任何一行,像是「monomorphization」:

$ cargo run monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

漂亮!我們建立了一個屬於自己的迷你經典工具,並學到了很多如何架構應用程式的知識。我們也學了一些檔案輸入與輸出、生命週期、測試與命令列解析。

為了讓此專案更完整,我們會簡單介紹如何使用環境變數,以及如何印出到標準錯誤(standard error),這兩項在寫命令列程式時都很實用。

處理環境變數

我們會新增一個額外的功能來改善 minigrep:使用者可以透過環境變數來啟用不區分大小寫的搜尋功能。我們可以將此功能設為命令列選項並要求使用者每次需要時就要加上它,但是這次我們選擇使用環境變數。這樣一來能讓使用者設置環境變數一次就好,然後在該終端機 session 中所有的搜尋都會是不區分大小寫的。

寫個不區分大小寫的 search 函式的失敗測試

我們想新增個 search_case_insensitive 函式在環境變數啟用時呼叫它。我們將繼續遵守 TDD 流程,所以第一步一樣是先寫個會失敗的測試。我們會為新函式 search_case_insensitive 新增一個測試,並將舊測試從 one_result 改名為 case_sensitive 以便清楚兩個測試的差別,如範例 12-20 所示。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

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

    Ok(())
}

pub 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 12-20:為準備加入的不區分大小寫函式新增個失敗測試

注意到我們也修改了舊測試 contents。我們新增了一行使用大寫 D 的 "Duct tape.",當我們以區分大小寫來搜尋時,就不會符合要搜尋的 "duct"。這樣變更舊測試能確保我們沒有意外破壞我們已經實作好的區分大小寫的功能。此測試應該要能通過,並在我們實作不區分大小寫的搜尋時仍能繼續通過。

新的不區分大小寫的搜尋測試使用 "rUsT" 來搜尋。在我們準備要加入的 search_case_insensitive 函式中,要搜尋的 "rUsT" 應該要能符合有大寫 R 的 "Rust:" 以及 "Trust me." 這幾行,就算兩者都與搜尋字串有不同的大小寫。這是我們的失敗測試而且它還無法編譯,因為我們還沒有定義 search_case_insensitive 函式。歡迎自行加上一個永遠回傳空向量的骨架實作,就像我們在範例 12-16 所做的一樣,然後看看測試能不能編譯過並失敗。

實作 search_case_insensitive 函式

範例 12-21 顯示的 search_case_insensitive 函式會與 search 函式幾乎一樣。唯一的不同在於我們將 query 與每個 line 都變成小寫,所以無論輸入引數是大寫還是小寫,當我們在檢查行數是否包含搜尋的字串時,它們都會是小寫。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

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

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 12-21:定義 search_case_insensitive 並在比較前將搜尋字串與行數均改為小寫

首先我們將 query 字串變成小寫並儲存到同名的遮蔽變數中。我們必須呼叫對要搜尋的字串 to_lowercase,這樣無論使用者輸入的是 "rust""RUST""Rust""rUsT",我們都會將字串視為 "rust" 並以此來不區分大小寫。雖然 to_lowercase 能處理基本的 Unicode,但它不會是 100% 準確的。如果我們是在寫真正的應用程式的話,我們需要處理更多條件,但在此段落是為了理解環境變數而非 Unicode,所以我們維持這樣寫就好。

注意到 query 現在是個 String 而非字串切片,因為呼叫 to_lowercase 會建立新的資料而非引用現存的資料。假設要搜尋的字串是 "rUsT" 的話,該字串切片並沒有包含小寫的 ut 能讓我們來使用,所以我們必須分配一個包含 "rust" 的新 String。現在當我們將 query 作為引數傳給 contains 方法時,我們需要加上「&」,因為 contains 所定義的簽名接收的是一個字串切片。

接著,在我們檢查是否包含小寫的 query 前,我們對每個 line 加上 to_lowercase 的呼叫。現在我們將 linequery 都轉換成小寫了。我們可以不區分大小寫來找到符合的行數。

讓我們來看看實作是否能通過測試:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 1.33s
     Running target/debug/deps/minigrep-4672b652f7794785

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/minigrep-caf9dbee196c78b9

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

很好!測試通過。現在讓我們從 run 函式呼叫新的 search_case_insensitive 函式。首先,我們要在 Config 中新增一個配置選項來切換區分大小寫與不區分大小寫之間的搜尋。新增此欄位會造成編譯錯誤,因為我們還沒有初始化該欄位:

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

注意到我們新增了 case_sensitive 欄位並存有布林值。接著,我們需要 run 函式檢查 case_sensitive 欄位的數值並以此決定要呼叫 search 函式或是 search_case_insensitive 函式,如範例 12-22 所示。不過目前還無法編譯。

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 12-22:依據 config.case_sensitive 的數值來呼叫 searchsearch_case_insensitive

最後,我們需要檢查環境變數。處理環境變數的函式位於標準函式庫中的 env 模組中,所以我們可以在 src/lib.rs 最上方加上 use std::env; 來將該模組引入作用域。然後我們使用 env 模組中的 var 函式來檢查一個叫做 CASE_INSENSITIVE 的環境變數,如範例 12-23 所示。

檔案名稱:src/lib.rs

use std::env;
// --省略--

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

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

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

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

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

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

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 12-23:檢查環境變數 CASE_INSENSITIVE

我們在此建立了一個新的變數 case_sensitive。要設置其數值,我們可以呼叫 env::var 函式並傳入環境變數 CASE_INSENSITIVE 的名稱。env::var 函式會回傳 Result,如果有設置環境變數的話,這就會是包含環境變數數值的成功 Ok變體;如果環境變數沒有設置的話,這就會回傳 Err 變體。

我們在 Result 使用 is_err 方法來檢查是否為錯誤,如果是的話就代表沒有設置,也意味著它使用區分大小寫的搜尋。如果 CASE_INSENSITIVE 環境變數有設置成任何數值的話,is_err 會回傳否,所以程式就會進行不區分大小寫的搜尋。我們不在乎環境變數的數值,只在意它有沒有被設置而已,所以我們使用 is_err 來檢查而非使用 unwrapexpect 或其他任何我們看過的 Result 方法。

我們將變數 case_sensitive 的數值傳給 Config 實例,讓 run 函式可以讀取該數值並決定該呼叫 search 還是 search_case_insensitive,如範例 12-22 所實作的一樣。

讓我們試看看吧!首先,我們先不設置環境變數並執行程式來搜尋 to,任何包含小寫單字「to」的行數都應要符合:

$ cargo run to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

看起來運作仍十分正常!現在,讓我們設置 CASE_INSENSITIVE1 並執行程式來搜尋相同的字串 to

如果你使用的是 PowerShell,你需要將設置變數與執行程式分為不同的命令:

PS> $Env:CASE_INSENSITIVE=1; cargo run to poem.txt

這會在你的 shell session 中設置 CASE_INSENSITIVE。它可以透過 Remove-Item cmdlet 來取消設置:

PS> Remove-Item Env:CASE_INSENSITIVE

我們應該會得到包含可能有大寫的「to」的行數:

$ CASE_INSENSITIVE=1 cargo run to poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

太好了,我們也取得了包含「To」的行數!我們的 minigrep 程式現在可以進行不區分大小寫的搜尋並以環境變數配置。現在你知道如何使用命令列引數或環境變數來管理設置選項了。

有些程式允許同時使用引數環境變數來配置。在這種情況下,程式會決定各種選項的優先層級。你想要練習的話,嘗試使用命令列引數與環境變數來控制不區分大小寫的選項。並在程式執行時,其中一個設置為區分大小寫,而另一個為不區分大小寫時,自行決定該優先使用命令列引數還是環境變數。

std::env 模組還包含很多處理環境變數的實用功能,歡迎查閱其官方文件來瞭解有哪些可用。

將錯誤訊息寫入標準錯誤而非標準輸出

目前我們使用 println! 巨集來將所有的輸出顯示到終端機。大多數的終端機都提供兩種輸出方式:用於通用資訊的標準輸出(standard output, stdout以及用於錯誤訊息的標準錯誤(standard error, stderr。這樣的區別讓使用者可以選擇將程式的成功輸出導向到一個檔案中,並仍能在螢幕上顯示錯誤訊息。

println! 巨集只能夠印出標準輸出,所以我們得用其他方式來印出標準錯誤。

檢查該在哪裡寫錯誤

首先,我們先來觀察 minigrep 目前寫到標準輸出的顯示內容,其中包含任何我們想改寫成標準錯誤的錯誤訊息。我們會將標準輸出重新導向至一個檔案並故意產生一個錯誤。因為我們不會重新導向標準錯誤,所以任何傳送至標準錯誤的內容會繼續顯示在螢幕上。

命令列程式應該要傳送錯誤訊息至標準錯誤,讓我們可以在重新導向標準輸出至檔案時,仍能在螢幕上看到錯誤訊息。所以我們的程式目前並不符合預期:我們會看到它儲存錯誤訊息輸出到檔案中!

要觀察此行為的方式是透過 > 來執行程式並加上檔案名稱 output.txt,這是我們要重新導向標準輸出到的地方。我們不會傳遞任何引數,這樣就應該會造成錯誤:

$ cargo run > output.txt

> 語法告訴 shell 要將標準輸出的內容寫入 output.txt 而不是顯示在螢幕上。我們沒有看到應顯示在螢幕上的錯誤訊息,這代表它一定跑到檔案中了。以下是 output.txt 包含的內容:

解析引數時出現問題:引數不足

是的,我們的錯誤訊息印到了標準輸出。像這樣的錯誤訊息印到標準錯誤會比較好,這樣才能只讓成功執行的資料存至檔案中。讓我們來修正吧。

將錯誤印出至標準錯誤

我們會使用範例 12-24 的程式碼來改變錯誤訊息印出的方式。由於我們在本章前幾篇的重構,所有印出錯誤訊息的程式碼都位於 main 函式中。標準函式庫有提供 eprintln! 巨集來印到標準錯誤,所以讓我們變更兩個原本呼叫 println! 來印出錯誤的段落來改使用 eprintln!

檔案名稱:src/main.rs

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

use minigrep::Config;

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

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

    if let Err(e) = minigrep::run(config) {
        eprintln!("應用程式錯誤:{}", e);

        process::exit(1);
    }
}

範例 12-24:使用 eprintln! 來將錯誤訊息印至標準錯誤而非標準輸出

println! 變更成 eprintln! 之後,讓我們以相同方式再執行程式一次,沒有任何引數並用 > 重新導向標準輸出:

$ cargo run > output.txt
解析引數時出現問題:引數不足

現在我們看到錯誤顯示在螢幕上而且 output.txt 裡什麼也沒只有,這正是命令列程式所預期的行為。

讓我們加上不會產生錯誤的引數來執行程式,並仍衝心導向標準輸出至檔案中,如以下所示:

$ cargo run to poem.txt > output.txt

我們在終端機不會看到任何輸出,而 output.txt 會包含我們的結果:

檔案名稱:output.txt

Are you nobody, too?
How dreary to be somebody!

這說明我們現在有對成功的輸出使用標準輸出,而且有妥善地將錯誤輸出傳至標準錯誤。

總結

本章節回顧了你目前所學的一些重要概念,並介紹了如何在 Rust 中進行常見的 I/O 操作。透過使用命令列引數、檔案、環境變數與用來印出錯誤的 eprintln! 巨集,你現在已經準備好能寫出命令列應用程式了。透過使用前幾章的概念,你的程式的組織架構會非常穩固、資料都能有效率地儲存至適當的資料結構、完善地處理錯誤,並通過測試檢驗。

接下來,我們要探討些 Rust 受到函式語言啟發的功能:閉包與疊代器。

函式語言功能:疊代器與閉包

Rust 的設計靈感啟發自許多現有的語言與技術,其中一個影響十分顯著的就是函式程式設計(functional programming)。以函式風格的程式設計通常包含將函式視為數值並作為引數傳遞、將它們從其他函式回傳、將它們賦值給變數以便之後使用,以及更多。

在本章節中,我們不會討論哪些才是屬於函式程式設計或哪些不是,而是介紹一些 Rust 中類似於許多語言常視為函式語言特色的功能。

更明確來說,我們會涵蓋:

  • 閉包(Closures):類似函式的結構並可以賦值給變數
  • 疊代器(Iterators):遍歷一系列元素的方法
  • 如何用這兩種功能來改善第十二章的 I/O 專案
  • 這兩個功能的效能(先偷偷跟你說:它們比你想像的還要快!)

其他 Rust 已經在其他章節提到的功能像是模式配對與枚舉也都有被函式風格所影響。掌握閉包與疊代器是寫出符合語言風格與高效 Rust 程式碼中重要的一環,所以我們將用一章來介紹它們。

函式語言功能:疊代器與閉包

Rust 的閉包(closures)是個你能賦值給變數或作為其他函式引數的匿名函式。你可以在某處建立閉包,然後在不同的地方呼叫閉包並執行它。而且不像函式,閉包可以從它們所定義的作用域中獲取數值。我們將會解釋這些閉包功能如何允許程式碼重用以及自訂行為。

透過閉包建立抽象行為

讓我們處理一個範例是當儲存閉包並在之後才執行是很實況的情況。在過程中,我們會討論到閉包語法、型別推導以及特徵。

讓我們考慮以下假設情境:我們在一家新創公司上班並正在推出一支會產生自訂重訓方案的應用程式。其後端就是用 Rust 寫的,且產生重訓方案的演算法有很多因素要考量,像是使用者的年齡、身高體重指數、健身喜好、最近鍛鍊的項目以及他們指定的重訓強度。此例中實際使用的演算法並不重要,重要的是此運算會花費數秒鐘。我們只想要在我們需要時呼叫此演算法並只會呼叫一次,讓使用者不會等待太久。

我們會模擬這個假設的演算法為函式 simulated_expensive_calculation,如範例 13-1 所示,他會印出 緩慢計算中...、等待兩秒鐘,然後回傳我們傳入的數值。

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("緩慢計算中...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn main() {}

範例 13-1:一支作為假想需要運算 2 秒鐘的函式

接下來 main 函式會包含此健身應用程式中最重要的部分。此函式代表當使用者請求健身方案時應用程式會呼叫的程式碼。由於應用程式的前端與我們的閉包使用並沒有任何關聯,我們將會用寫死的數值來代表我們程式的輸入並印出輸出結果。

必要的輸入如以下所示:

  • 使用者想要的重訓強度,用來指明他們想要的訓練是低強度訓練或是高強度訓練
  • 一個用來產生重訓方案變化的隨機數值

輸出結果會是建議的重訓方案,範例 13-2 展示了我們使用的 main 函式。

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("緩慢計算中...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn generate_workout(intensity: u32, random_number: u32) {}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-2:一支有寫死的數值來模擬使用者輸入與隨機生成數字的 main 函式

為了方便我們將變數 simulated_user_specified_value 寫死為 10 且變數 simulated_random_number 寫死為 7。在實際程式中,我們會從應用程式前端取得強度數字,並用 rand crate 來產生隨機數字,如同第二章猜謎遊戲所做的一樣。main 函式會用模擬的輸入數值呼叫 generate_workout 函式。

現在我們有了內容,讓我們看看演算法吧。範例 13-3 的函式 generate_workout 包含了應用程式的業務邏輯,也就是我們在此例最在意的地方。此例中接下來的程式碼都在此函式中進行:

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("緩慢計算中...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn generate_workout(intensity: u32, random_number: u32) {
    if intensity < 25 {
        println!(
            "今天請做 {} 下伏地挺身!",
            simulated_expensive_calculation(intensity)
        );
        println!(
            "然後請做 {} 下仰臥起坐!",
            simulated_expensive_calculation(intensity)
        );
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!(
                "今天請慢跑 {} 分鐘!",
                simulated_expensive_calculation(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-3:依據輸入印出重訓方案並呼叫函式 simulated_expensive_calculation 的業務邏輯

範例 13-3 的程式碼會多次呼叫計算緩慢的函式。第一個 if 區塊會呼叫 simulated_expensive_calculation 兩次,然後在 else 區塊內的 if 不會呼叫它,然後第二個 else 會呼叫它一次。

generate_workout 函式預期的行為是先檢查使用者想要低強度的重訓方案(也就是強度低於 25)或者高強度的方案(大於等於 25)。

低強度重訓方案會依據我們麼體的複雜演算法來建議一些伏地挺身和仰臥起坐。

如果使用者想要高強度重用,就會有額外的邏輯:如果應用程式產生的隨機數字是 3 的話,應用程式會建議休息並多喝水;如果不是的話,使用者會依據複雜演算法得到數分鐘的跑步訓練。

此程式碼能夠應付業務邏輯了,但是假設未來資料科學團隊決定要求我們需要更改我們呼叫 simulated_expensive_calculation 函式的方式。為了簡化這些更新步驟,我們想要重構此程式碼好讓 simulated_expensive_calculation 只會呼叫一次。同時我們也想要去掉我們目前呼叫兩次的多餘程式碼,我們不希望再對此程序加上更多的函式呼叫。也就是說,我們不希望在沒有需要取得結果時呼叫程式碼,且我們希望它只會被呼叫一次。

透過函式重構

我們可以用許多方式重構此重訓程式。首先我們先將重複呼叫 simulated_expensive_calculation 的地方改成變數,如範例 13-4 所示。

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("緩慢計算中...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_result = simulated_expensive_calculation(intensity);

    if intensity < 25 {
        println!("今天請做 {} 下伏地挺身!", expensive_result);
        println!("然後請做 {} 下仰臥起坐!", expensive_result);
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!("今天請慢跑 {} 分鐘!", expensive_result);
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-4:提取 simulated_expensive_calculation 的呼叫到同個位置並用變數 expensive_result 儲存結果

此變更統一了所有 simulated_expensive_calculation 的呼叫並解決第一個 if 區塊重複呼叫函式兩次的問題。不幸的是,現在我們一定得呼叫此函式並在所有情形下都得等待,這包含沒有使用到此結果的 if 區塊。

我們想在程式某處定義程式碼,並在我們確實需要它時執行程式碼就好。這就是閉包能使用的場合!

透過閉包重構來儲存程式碼

與其在 if 區塊之前就呼叫 simulated_expensive_calculation 函式,我們可以定義一個閉包並將閉包存入變數中,而不是儲存函式呼叫的結果,如範例 13-5 所示。我們可以將 simulated_expensive_calculation 的本體移入這個閉包中。

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("緩慢計算中...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("今天請做 {} 下伏地挺身!", expensive_closure(intensity));
        println!("然後請做 {} 下仰臥起坐!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!(
                "今天請慢跑 {} 分鐘!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-5:定義閉包並存入 expensive_closure 變數中

閉包定義位於 expensive_closure 賦值時 = 後面的部分。要定義閉包,我們先從一對直線(|)開始,其內我們會指定閉包的參數,選擇此語法的原因是因為這與 Smalltalk 和 Ruby 的閉包定義類似。此閉包有一個參數 num,如果我們想要不止一個的話,我們可以用逗號來分隔,像是這樣 |param1, param2|

在參數之後,我們加上大括號來作為閉包的本體,不過如果閉包本體只是一個表達式的話就不必這樣寫。在閉包結束後,也就是大括號之後,我們要加上分號才能完成 let 陳述式的動作。在閉包本體最後一行的回傳數值就會是當閉包被呼叫時的回傳數值,因為該行沒有以分號做結尾,就像函式本體一樣。

注意到此 let 陳述式代表 expensive_closure 包含了匿名函式的定義,而不是呼叫匿名函式的回傳數值。回想一下我們使用閉包是為了讓我們能在某處定義程式碼、儲存這段程式碼然後在之後別的地方呼叫它。我們想呼叫的程式碼現在儲存在 expensive_closure 中。

有了閉包定義,我們就可以變更 if 區塊內的程式碼呼叫閉包來執行其程式碼並取得結果數值。我們呼叫閉包的方式與呼叫函式一樣:我們指定握有閉包定義的變數名稱,然後在括號內加上我們想使用的引數數值,如範例 13-6 所示:

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("緩慢計算中...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("今天請做 {} 下伏地挺身!", expensive_closure(intensity));
        println!("然後請做 {} 下仰臥起坐!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!(
                "今天請慢跑 {} 分鐘!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-6:呼叫我們定義的 expensive_closure

現在耗時的計算只會在一處呼叫了,而且我們只會在需要結果時才會執行該程式碼。

然而我們又重新引入了範例 13-3 其中一個問題,我們仍然會在第一個 if 區塊呼叫閉包兩次,也就是會呼叫耗時的程式碼兩次,讓使用者需要多等一倍的時間。我們可以透過在 if 區塊內建立變數並取得呼叫閉包的結果來修正這個問題,但是閉包還能提供我們另一種解決辦法。我們稍後會介紹這個解決辦法。但在這之前讓我們先討論為何閉包定義中不用型別詮釋,以及與閉包相關的特徵。

閉包型別推導與詮釋

閉包不必像 fn 函式那樣要求你要詮釋參數或回傳值的型別。函式需要型別詮釋是因為它們是顯式公開給使用者的介面。嚴格定義此介面是很重要的,這能確保每個人同意函式使用或回傳的數值型別為何。但是閉包並不是為了對外公開使用,它們儲存在變數且沒有名稱能公開給我們函式庫的使用者。

閉包通常很短,而且只與小範圍內的程式碼有關,而非適用於任何場合。有了這樣限制的環境,編譯器能可靠地推導出參數與回傳值的型別,如同其如何推導出大部分的變數型別一樣。

要求開發者得為這些小小的匿名函式詮釋型別的話會變得很惱人且非常多餘,因為編譯器早就有足夠的資訊能推導出來了。

至於變數的話,雖然不是必要的,但如果我們希望能夠增加閱讀性與清楚程度,我們還是可以加上型別詮釋。要對我們在範例 13-5 定義的閉包詮釋型別的話,會如以下範例 13-7 所定義的所示:

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("緩慢計算中...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("今天請做 {} 下伏地挺身!", expensive_closure(intensity));
        println!("然後請做 {} 下仰臥起坐!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!(
                "今天請慢跑 {} 分鐘!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-7:對閉包加上選擇性的參數與回傳值型別詮釋

加上型別詮釋後,閉包的語法看起來就更像函式的語法了。以下對一個參數加 1 的函式定義語法與有相同行為的閉包的比較表。我們加了一些空格來對齊相對應的部分。這顯示了閉包語法和函式語法有多類似,只是改用直線以及有些語法是選擇性的。

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

第一行顯示的是函式定義,而第二行則顯示有完成型別詮釋的閉包定義。第三行移除了閉包定義的型別詮釋,然後第四行移除了大括號,因為閉包本體只有一個表達式,所以這是選擇性的。這些都是有效的定義,並會在被呼叫時產生相同行為。而 add_one_v3add_one_v4 一定要被呼叫,這樣編譯器才能從它們的使用方式中推導出型別。

閉包定義會對每個參數與它們的回傳值推導出一個實際型別。舉例來說,範例 13-8 展示一支只會將收到的參數作為回傳值的閉包定義。此閉包並沒有什麼意義,純粹作為範例解釋。注意到我們沒有在定義中加上任何型別詮釋。如果我們嘗試呼叫閉包兩次,一次使用 String 作為引數,而另一次使用 u32 的話,我們就會得到錯誤。

檔案名稱:src/main.rs

fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}

範例 13-8:嘗試呼叫被推導出兩個不同型別的閉包

編譯器會給我們以下錯誤:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |                             ^
  |                             |
  |                             expected struct `std::string::String`, found integer
  |                             help: try using a conversion method: `5.to_string()`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example`.

To learn more, run the command again with --verbose.

當我們第一次使用 String 數值呼叫 example_closure 時,編譯器會推導 x 與閉包回傳值的型別為 String。這樣 example_closure 閉包內的型別就會鎖定,然後我們如果對同樣的閉包嘗試使用不同的型別的話,我們就會得到型別錯誤。

透過泛型參數與 Fn 特徵儲存閉包

讓我們回到我們的重訓生成應用程式。在範例 13-6 中,我們的程式碼仍然會呼叫耗時的閉包不止一次。其中一個解決此問題的選項是將耗時閉包的結果存入變數中,並在我們需要結果的地方使用該變數,而不是再呼叫閉包一次。不過此方法可能會增加很多重複的程式碼。

幸運的是我們還有另一個解決辦法。我們可以建立一個結構體來儲存閉包以及呼叫閉包的結果數值。此結構體只會在我們需要結果數值時執行閉包,然後它會獲取結果數值,所以我們的程式碼就不必負責儲存要重複使用的結果。你可能會聽過這種模式叫做記憶化(memoization)惰性求值(lazy evaluation)

要定義一個結構體儲存一個閉包,我們需要指定閉包的型別,因為結構體定義需要知道它每個欄位的型別。每個閉包實例都有自己獨特的匿名型別,也就是說就算有兩個閉包的簽名一模一樣,它們的型別還是會被視為不同的。要定義有使用閉包的結構體、枚舉或函式參數的話,我們可以使用在第十章所提到的泛型與特徵界限。

標準函式庫有提供 Fn 特徵,所有閉包都有實作至少以下一種特徵:FnFnMutFnOnce。我們會在「透過閉包獲取環境」段落中討論這些特徵的不同。在此例中,我們可以使用 Fn 特徵。

我們在 Fn 特徵界限加上了型別來表示閉包參數與回傳值必須擁有的型別。在此例中,我們的閉包參數型別為 u32 且回傳 u32,所以我們指定的特徵界限為 Fn(u32) -> u32

範例 13-9 顯示了擁有一個閉包與一個 Option 結果數值的 Cacher 結構體定義。

檔案名稱:src/main.rs

struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    calculation: T,
    value: Option<u32>,
}

fn main() {}

範例 13-9:定義結構體 Cachercalculation 會存有閉包且 value 存放 Option 結果

Cacher 結構體有個欄位 calculation 其泛型型別為 TT 的特徵界限指定這是一個使用 Fn 特徵的閉包。任何我們想存入的 calculation 欄位的閉包都必須只有一個 u32 參數(在 Fn 後方的括號內指定)以及回傳一個 u32(在 -> 之後指定)。

注意:函式也會實作這三個 Fn 特徵。如果我們想做的事情不需要獲取環境數值,我們可以使用實現 Fn 特徵的函式而非閉包。

value 欄位型別為 Option<u32>。在我們執行閉包前,value 會是 None。當有程式碼使用 Cacher 要求取得閉包結果時,Cacher 就會在那時候執行閉包並以 Some 變體儲存結果到 value 欄位。然後如果有程式碼再次要求閉包結果時,我們就不必再執行閉包一次,可以靠 Cacher 回傳 Some 變體內的結果。

我們討論這個有關 value 欄位的邏輯定義就如範例 13-10 所示。

檔案名稱:src/main.rs

struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    calculation: T,
    value: Option<u32>,
}

impl<T> Cacher<T>
where
    T: Fn(u32) -> u32,
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

fn main() {}

範例 13-10:Cacher 的快取(Caching)邏輯

我們想要 Cacher 來管理結構體的欄位數值,而不是讓呼叫者有機會直接改變這些欄位的數值,所以這些欄位是私有的。

Cacher::new 函式接收一個泛型參數 T,其特徵界限與我們在 Cacher 結構體定義的是相同的。接著 Cacher::new 回傳一個 Cacher 實例,其 calculation 欄位擁有指定的閉包而 value 欄位則是 None,因為我們還沒有執行閉包。

當呼叫者需要閉包計算的結果時,不是直接呼叫閉包,而是呼叫 value 方法。此方法會檢查我們的 self.value 是否已經有個結果數值在 Some 內。如果有的話,它會回傳 Some 內的數值而不用再次執行閉包。

如果 self.valueNone,程式碼會呼叫存在 self.calculation 的閉包、儲存結果到 self.value 以便未來使用,並回傳數值。

範例 13-11 展示我們如何在範例 13-6 的 generate_workout 函式中使用此 Cacher 結構體。

檔案名稱:src/main.rs

use std::thread;
use std::time::Duration;

struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    calculation: T,
    value: Option<u32>,
}

impl<T> Cacher<T>
where
    T: Fn(u32) -> u32,
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

fn generate_workout(intensity: u32, random_number: u32) {
    let mut expensive_result = Cacher::new(|num| {
        println!("緩慢計算中...");
        thread::sleep(Duration::from_secs(2));
        num
    });

    if intensity < 25 {
        println!("今天請做 {} 下伏地挺身!", expensive_result.value(intensity));
        println!("然後請做 {} 下仰臥起坐!", expensive_result.value(intensity));
    } else {
        if random_number == 3 {
            println!("今天休息!別忘了多喝水!");
        } else {
            println!(
                "今天請慢跑 {} 分鐘!",
                expensive_result.value(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

範例 13-11:在函式 generate_workout 使用 Cacher 來抽象化快取邏輯

不同於將閉包儲存給變數,我們建立一個新的 Cacher 實例來儲存閉包。然後在每個我們需要結果的地方,我們呼叫 Cacher 實例的 value 方法。我們要呼叫 value 方法幾次都行,或者不叫也行,無論如何耗時計算最多就只會被執行一次。

請嘗試從範例 13-2 的 main 函式執行此程式。變更 simulated_user_specified_valuesimulated_random_number 的數值來驗證看看在所有情況下與數個 ifelse 區塊中,緩慢計算中... 只會出現一次且只有在需要時才會出現。Cacher 負責確保我們不會呼叫超過耗時計算所需的邏輯,讓 generate_workout 可以專注在業務邏輯。

Cacher 實作的限制

快取數值是個廣泛實用的行為,我們可能會希望在程式碼中的其他不同閉包也使用到。然而目前Cacher 的實作有兩個問題可能會在不同場合重複使用變得有點困難。

第一個問題是 Cacher 實例假設它永遠會從方法 value 的參數 arg 中取得相同數值,所以說以下 Cacher 的測試就會失敗:

struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    calculation: T,
    value: Option<u32>,
}

impl<T> Cacher<T>
where
    T: Fn(u32) -> u32,
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

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

    #[test]
    fn call_with_different_values() {
        let mut c = Cacher::new(|a| a);

        let v1 = c.value(1);
        let v2 = c.value(2);

        assert_eq!(v2, 2);
    }
}

此測試透過一個回傳傳入值的閉包建立一個新的 Cacher 實例。我們透過一個 arg 數值 1 與另一個 arg 數值 2 來呼叫此 Cacher 實例的 value 方法兩次,且我們預期 arg 為 2 的 value 會回傳 2。

使用範例 13-9 和範例 13-10 的 Cacher 實作執行此測試的話,測試會在 assert_eq! 失敗並附上此訊息:

$ cargo test
   Compiling cacher v0.1.0 (file:///projects/cacher)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running target/debug/deps/cacher-4116485fb32b3fff

running 1 test
test tests::call_with_different_values ... FAILED

failures:

---- tests::call_with_different_values stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/lib.rs:43:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::call_with_different_values

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

問題在於我們第一次會使用 1 呼叫 c.valueCacher 實例會儲存 Some(1)self.value。因此無論我們再傳入任何值給 value 方法,它永遠只會回傳 1。

我們可以嘗試將 Cacher 改成儲存雜湊映射(hash map)而非單一數值。雜湊映射的鍵會是傳入的 arg 數值,而雜湊映射的值則是用該鍵呼叫閉包的結果。所以不同於查看 self.valueSome 還是 None 值, value 函式將會查看 arg 有沒有在雜湊映射內,而如果有的話就會傳對應數值。如果沒有的話,Cacher 會呼叫閉包並儲存 arg 數值與對應的結果數值到雜湊映射中。

第二個問題是目前的 Cacher 實作只會接受參數型別為 u32 並回傳 u32 的閉包。舉例來說,我們可能會想要快取給予字串並回傳 usize 的閉包結果數值。要修正此問題,你可以嘗試加上更多泛型參數來增加 Cacher 功能的彈性。

透過閉包獲取環境

在重訓生成範例中,我們只將閉包作為行內匿名函式。但是閉包還有個函式所沒有的能力:它們可以獲取它們的環境並取得在它們所定義的作用域內的變數。

範例 13-12 有一個儲存在變數 equal_to_x 的閉包,其使用變數 x 來取得閉包周圍的環境。

檔案名稱:src/main.rs

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

範例 13-12:引用周圍作用域中變數的閉包範例

x 在此雖然不是 equal_to_x 的參數,equal_to_x 閉包卻允許使用變數 x,因為它與 equal_to_x 都定義在同個作用域。

我們用函式就做不到,如果我們嘗試執行以下範例,我們的程式碼會無法編譯:

檔案名稱:src/main.rs

fn main() {
    let x = 4;

    fn equal_to_x(z: i32) -> bool {
        z == x
    }

    let y = 4;

    assert!(equal_to_x(y));
}

我們得到以下錯誤:

$ cargo run
   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0434]: can't capture dynamic environment in a fn item
 --> src/main.rs:5:14
  |
5 |         z == x
  |              ^
  |
  = help: use the `|| { ... }` closure form instead

error: aborting due to previous error

For more information about this error, try `rustc --explain E0434`.
error: could not compile `equal-to-x`.

To learn more, run the command again with --verbose.

編譯器甚至會提醒我們這只有閉包才能做到!

當閉包從它的環境獲取數值時,它會在閉包本體中使用記憶體來儲存這個數值。這種儲存記憶體的方式會產生額外開銷。在更常見的場合中,也就是不需要獲取程式碼的環境時,我們並不希望產生這種開銷。因為函式並不允許獲取它們的環境,定義與使用函式就不會產生這種開銷。

閉包可以用三種方式獲取它們的環境,這剛好能對應到函式取得參數的三種方式:取得所有權、可變借用與不可變借用。這就被定義成以下三種 Fn 特徵:

  • FnOnce 會消耗周圍作用域中,也就是閉包的環境,所獲取變數。要消耗掉所獲取的變數,閉包必須取得這些變數的所有權並在定義時將它們移入閉包中。特徵名稱中的 Once 指的是因為閉包無法取得相同變數的所有權一次以上,所以它只能被呼叫一次。
  • FnMut 可以改變環境,因為它取得的是可變的借用數值。
  • Fn 則取得環境中不可變的借用數值。

當你建立閉包時,Rust 會依據閉包如何使用環境的數值來推導該使用何種特徵。所有的閉包都會實作 FnOnce 因為它們都可以至少被呼叫一次。不會移動獲取變數的閉包還會實作 FnMut,最後不需要向獲取變數取得可變引用的閉包會再實作 Fn。在範例 13-12 中,equal_to_x 閉包會取得 x 的不可變借用(所以 equal_to_x 擁有 Fn 特徵),因為閉包本體只會讀取 x 數值。

如果你希望強制閉包會取得周圍環境數值的所有權,你可以在參數列表前使用 move 關鍵字。此技巧在要將閉包傳給新的執行緒以便將資料移動到新執行緒時會很實用。

當我們在第十六章討論並行的時候,我們會遇到更多 move 閉包的範例。現在的話可以先看看範例 13-12 怎麼使用 move 關鍵字到閉包定義中 ,並使用向量而非整數,因為整數可以被拷貝而不是移動。注意此程式還不能編譯過。

檔案名稱:src/main.rs

fn main() {
    let x = vec![1, 2, 3];

    let equal_to_x = move |z| z == x;

    println!("無法在此使用 x:{:?}", x);

    let y = vec![1, 2, 3];

    assert!(equal_to_x(y));
}

我們會獲得以下錯誤:

$ cargo run
   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0382]: borrow of moved value: `x`
 --> src/main.rs:6:40
  |
2 |     let x = vec![1, 2, 3];
  |         - move occurs because `x` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait
3 | 
4 |     let equal_to_x = move |z| z == x;
  |                      --------      - variable moved due to use in closure
  |                      |
  |                      value moved into closure here
5 | 
6 |     println!("無法在此使用 x:{:?}", x);
  |                                        ^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `equal-to-x`.

To learn more, run the command again with --verbose.

當閉包定義時,數值 x 會移入閉包中,因為我們加上了 move 關鍵字。閉包因此取得 x 的所有權,然後 main 就會不允許 xprintln! 陳述式中使用。移除此例的 println! 就能修正問題。

大多數要指定 Fn 特徵界限時,你可以先從 Fn 開始,然後編譯器會依據閉包本體的使用情況來告訴你該使用 FnMutFnOnce

接下來為了講解閉包獲取環境的行為很適合用於函式參數的情形,讓我們移至下個主題:疊代器。

使用疊代器來處理一系列的項目

疊代器(Iterator)模式讓你可以對一個項目序列依序進行某些任務。一個疊代器負責遍歷序每個項目以及序列何時結束的邏輯。當你使用疊代器,你不需要自己實作這些邏輯。

在 Rust 中疊代器是惰性(lazy)的,代表除非你呼叫方法來使用疊代器,不然它們不會有任何效果。舉例來說,範例 13-13 的程式碼會透過 Vec<T> 定義的方法 iter 從向量v1 建立一個疊代器來遍歷它的項目。此程式碼本身沒有啥實用之處。

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}

範例 13-13:建立一個疊代器

一旦我們建立了疊代器,我們可以有很多使用它的方式。在第三章的範例 3-5 中,我們在 for 迴圈中使用疊代器來對每個項目執行一些程式碼,雖然我們當時沒有詳細解釋 iter 是在做什麼。

範例 13-14 區隔了疊代器的建立與使用疊代器 for 迴圈。疊代器儲存在變數 v1_iter,且在此時沒有任何遍歷的動作發生。當使用 v1_iter 疊代器的 for 迴圈被呼叫時,疊代器中的每個元素才會在迴圈中每次疊代中使用,以此印出每個數值。

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("取得:{}", val);
    }
}

範例 13-14:在 for 迴圈使用疊代器

在標準函式庫沒有提供疊代器的語言中,你可能會用別種方式寫這個相同的函式,像是先從一個變數 0 作為索引開始、使用該變數索引向量來獲取數值,然後在迴圈中增加變數的值直到它抵達向量的總長。

疊代器會為你處理這些所有邏輯,減少重複且你可能會搞砸的程式碼。疊代器還能讓你靈活地將相同的邏輯用於不同的序列,而不只是像向量這種你能進行索引的資料結構。讓我們研究看看疊代器怎麼辦到的。

Iterator 特徵與 next 方法

所有的疊代器都會實作定義在標準函式庫的 Iterator 特徵。特徵的定義如以下所示:


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // 以下省略預設實作
}
}

注意到此定義使用了一些新的語法:type ItemSelf::Item,這是此特徵定義的關聯型別(associated type)。我們會在第十九章進一步探討關聯型別。現在你只需要知道此程式碼表示要實作 Iterator 特徵的話,你還需要定義 Item 型別,而此 Item 型別會用在方法 next 的回傳型別中。換句話說,Item 型別會是從疊代器回傳的型別。

Iterator 型別只要求實作者定義一個方法:next 方法會用 Some 依序回傳疊代器中的每個項目,並在疊代器結束時回傳 None

我們可以直接在疊代器呼叫 next 方法。範例 13-15 展示從向量建立的疊代器重複呼叫 next 每次會得到什麼數值。

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}

fn main() {}

範例 13-15:對疊代器呼叫 next 方法

注意到 v1_iter 需要是可變的:在疊代器上呼叫 next 方法會改變疊代器內部用來紀錄序列位置的狀態。換句話說,此程式碼消耗或者說使用了疊代器。每次 next 的呼叫會從疊代器消耗一個項目。而我們不必在 for 迴圈指定 v1_iter 為可變是因為迴圈會取得 v1_iter 的所有權並在內部將其改為可變。

另外還要注意的是我們從 next 呼叫取得的是向量中數值的不可變引用。iter 方法會從疊代器中產生不可變引用。如果我們想要一個取得 v1 所有權的疊代器,我們可以呼叫 into_iter 而非 iter。同樣地,如果我們想要遍歷可變引用,我們可以呼叫 iter_mut 而非 iter

消耗疊代器的方法

標準函式庫提供的 Iterator 特徵有一些不同的預設實作方法,你可以查閱標準函式庫的 Iterator 特徵 API 技術文件來找到這些方法。其中有些方法就是在它們的定義呼叫 next 方法,這就是為何當你實作 Iterator 特徵時需要提供 next 方法的實作。

會呼叫 next 的方法被稱之為消耗配接器(consuming adaptors),因為呼叫它們會使用掉疊代器。其中一個例子就是方法 sum,這會取得疊代器的所有權並重複呼叫 next 來遍歷所有項目,因而消耗掉疊代器。隨著遍歷的過程中,他會將每個項目加到總計中,並在疊代完成時回傳總計數值。範例 13-16 展示了一個使用 sum 方法的測試:

檔案名稱:src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}

fn main() {}

範例 13-16:呼叫 sum 方法來取得疊代器中所有項目的總計數值

我們在呼叫 sum 之後就不允許使用 v1_iter 因為 sum 取得了疊代器的所有權。

產生其他疊代器的方法

而其他定義在 Iterator 特徵的方法則叫做疊代配接器(iterator adaptors),它們能讓你變更疊代器成其他種類的疊代器。你可以串接數個疊代配接器的呼叫來組織一系列複雜的動作並仍能保持閱讀性。不過因為所有的疊代器都是惰性的,你需要呼叫一個消耗配接器方法來取得疊代配接器呼叫的結果。

範例 13-17 呼叫了疊代器的疊代配接器方法 map,這可以取得一個閉包來對每個項目進行處理以產生一個新的疊代器。閉包會將向量中的每個項目加 1 來產生新的疊代器。不過此程式碼會產生一個警告:

檔案名稱:src/main.rs

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}

範例 13-17:呼叫疊代配接器 map 來建立新的疊代器

我們獲得的警告如以下所示:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `std::iter::Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_must_use)]` on by default
  = note: iterators are lazy and do nothing unless consumed

    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

範例 13-17 的程式碼不會做任何事情,我們指定的閉包沒有被呼叫到半次。警告提醒了我們原因:疊代配接器是惰性的,我們必須在此消耗疊代器才行。

要修正並消耗此疊代器,我們將使用 collect 方法,這是我們在範例 12-1 搭配 env::args 使用的方法。此方法會消耗疊代器並收集結果數值至一個資料型別集合。

在範例 13-18 中,我們將遍歷 map 呼叫所產生的疊代器結果數值收集到一個向量中。此向量最後會包含原本向量每個項目都加 1 的數值。

檔案名稱:src/main.rs

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}

範例 13-18:呼叫方法 map 來建立新的疊代器並呼叫 collect 方法來消耗新的疊代器來產生向量

因為 map 接受一個閉包,我們可以對每個項目指定任何我們想做的動作。這是一個展示如何使用閉包來自訂行為,同時又能重複使用 Iterator 特徵提供的遍歷行為的絕佳例子。

使用閉包獲取它們的環境

現在我們介紹了疊代器,我們可以展示一個透過使用 filter 疊代配接器與閉包獲取它們環境的常見範例。疊代器中的 filter 方法會接受一個使用疊代器的每個項目並回傳布林值的閉包。如果閉包回傳 true,該數值就會被包含在 filter 產生的疊代器中;如果閉包回傳 false,該數值就保不會被包含在結果疊代器中。

在範例 13-19 中我們使用 filter 與一個從它的環境獲取變數 shoe_size 的閉包來遍歷一個有 Shoe 結構體實例的集合。它會回傳只有符合指定大小的鞋子:

檔案名稱:src/lib.rs

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_my_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

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

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("運動鞋"),
            },
            Shoe {
                size: 13,
                style: String::from("涼鞋"),
            },
            Shoe {
                size: 10,
                style: String::from("靴子"),
            },
        ];

        let in_my_size = shoes_in_my_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("運動鞋")
                },
                Shoe {
                    size: 10,
                    style: String::from("靴子")
                },
            ]
        );
    }
}

fn main() {}

範例 13-19:使用 filter 方法與一個獲取 shoe_size 的閉包

函式 shoes_in_my_size 會取得鞋子向量的所有權以及一個鞋子大小作為參數。它會回傳只有符合指定大小的鞋子向量。

shoes_in_my_size 的本體中,我們呼叫 into_iter 來建立一個會取得向量所有權的疊代器。然後我們呼叫 filter 來將該疊代器轉換成只包含閉包回傳為 true 的元素的新疊代器。

閉包會從環境獲取 shoe_size 參數並比較每個鞋子數值的大小,讓只有符合大小的鞋子保留下來。最後呼叫 collect 來收集疊代器回傳的數值進一個函式會回傳的向量。

此測試顯示了當我們呼叫 shoes_in_my_size 時,我們會得到我們指定相同大小的鞋子。

透過 Iterator 特徵建立我們自己的疊代器

我們已經顯示了你可以對向量呼叫 iterinto_iteriter_mut 來建立疊代器。你也可以從標準函式庫的其他集合型別產生疊代器,像是雜湊映射等等。你也可以透過對你自己的型別實作 Iterator 特徵來建立任何你所希望的疊代器。如同之前提到的,你唯一需要提供的方法定義就是 next 方法。一旦你完成,你就可以使用 Iterator 特徵提供的所有預設實作方法!

作為展示,讓我們建立一個只會從 1 數到 5 的疊代器。首先,我們要建立個擁有一些數值的結構體。然後我們對此結構體實作 Iterator 特徵將它變成一個疊代器,並在實作中使用其值。

範例 13-20 有個結構體 Counter 的定義以及能夠產生 Counter 實例的關聯函式 new

檔案名稱:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

fn main() {}

範例 13-20:定義結構體 Counter 與關聯函式 new,這能建立一個初始值 count 為 0 的 Counter 結構體

Counter 結構體只有一個欄位 count,此欄位擁有一個 u32 數值來追蹤我們遍歷 1 到 5 的當前位置。count 欄位是私有的,因為我們希望 Counter 的實作會管理此數值。函式 new 強制建立新實例的行為永遠會從 count 欄位為 0 時開始。

接下來我們對我們的 Counter 型別實作 Iterator 特徵,定義 next 方法本體來指定疊代器的使用行為,如範例 13-21 所示:

檔案名稱:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

fn main() {}

範例 13-21:在我們的 Counter 結構體實作 Iterator 特徵

我們將疊代器的關聯型別 Item 設為 u32,代表疊代器將會回傳 u32 數值。一樣先別擔心關聯型別,我們會在第十九章討論到。

我們希望我們的疊代器對目前的狀態加 1,所以我們將 count 初始化為 0,這樣它就會先回傳 1。如果 count 的值小於 5,next 就會增加 count 的值並用 Some 回傳目前數值。一旦 count 等於 5,我們的疊代器就會停止增加 count 並永遠回傳傳 None

使用 Counter 疊代器的 next 方法

一旦我們實作了 Iterator 特徵,我們就有一個疊代器了!範例 13-22 的測試展示我們可以對我們的 Counter 結構體直接呼叫 next 方法來使用疊代器的功能,就像我們在範例 13-15 對向量建立的疊代器使用的方式一樣。

檔案名稱:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

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

    #[test]
    fn calling_next_directly() {
        let mut counter = Counter::new();

        assert_eq!(counter.next(), Some(1));
        assert_eq!(counter.next(), Some(2));
        assert_eq!(counter.next(), Some(3));
        assert_eq!(counter.next(), Some(4));
        assert_eq!(counter.next(), Some(5));
        assert_eq!(counter.next(), None);
    }
}

fn main() {}

範例 13-22:測試 next 方法實作的功能

此測試建立了一個新的 Counter 實例給變數 counter 並重複呼叫 next,驗證我們實作的疊代器是否行為如我們預期的一樣:回傳數值 1 到 5。

使用其他 Iterator 特徵方法

我們透過定義 next 方法來實作 Iterator 特徵,所以我們現在可使用在標準函式庫提供的 Iterator 特徵中所任何有預設實作的方法了,因為它們都使用到了 next 的方法功能。

舉例來說,如果我們因為某些原因想要取得一個 Counter 實例的數值與另一個 Counter 實例去掉第一個值的數值來做配對、對每個配對相乘、保留結果可以被 3 整除的值,最後將所有結果數值相加,我們可以這樣寫,如範例 13-23 所示:

檔案名稱:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

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

    #[test]
    fn calling_next_directly() {
        let mut counter = Counter::new();

        assert_eq!(counter.next(), Some(1));
        assert_eq!(counter.next(), Some(2));
        assert_eq!(counter.next(), Some(3));
        assert_eq!(counter.next(), Some(4));
        assert_eq!(counter.next(), Some(5));
        assert_eq!(counter.next(), None);
    }

    #[test]
    fn using_other_iterator_trait_methods() {
        let sum: u32 = Counter::new()
            .zip(Counter::new().skip(1))
            .map(|(a, b)| a * b)
            .filter(|x| x % 3 == 0)
            .sum();
        assert_eq!(18, sum);
    }
}

fn main() {}

範例 13-23:對我們的 Counter 疊代器使用各式各樣的 Iterator 特徵方法

注意到 zip 只會產生四個配對,理論上的 (5, None) 配對是不會產生出來的,因為 zip 會在它的其中一個輸入疊代器回傳 None 時就回傳 None

這些所有呼叫都是可行的,因為我們已經定義了 next 運作的行為,而標準函式庫會提供其他呼叫 next 方法的預設實作。

改善我們的 I/O 專案

有了疊代器這樣的新知識,我們可以使用疊代器來改善第十二章的 I/O 專案,讓程式碼更清楚與簡潔。我們來看看疊代器如何改善 Config::new 函式與 search 函式的實作。

使用疊代器移除 clone

在範例 12-6 中,我們加了些程式碼來取得 String 數值的切片並透過索引切片與克隆數值來產生 Config 實例,讓 Config 結構體能擁有其數值。在範例 13-24 中,我們重現了範例 12-23 的 Config::new 函式實作:

檔案名稱:src/lib.rs

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

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

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

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

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

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

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 13-24:重現範例 12-23 的 Config::new 函式

當時我們說先不用擔心 clone 呼叫帶來的效率問題,因為我們會在之後移除它們。現在正是絕佳時機!

我們在此需要 clone 的原因為我們的參數 args 是擁有 String 元素的切片,但是 new 函式並不擁有 args。要回傳 Config 實例的所有權,我們必須克隆數值給 Configqueryfilename 欄位,Config 實例才能擁有其值。

有了我們新學到的疊代器,我們可以改變 new 函式來取得疊代器的所有權來作為引數,而非借用切片。我們會來使用疊代器的功能,而不是檢查切片長度並索引特定位置。這能讓 Config::new 函式的意圖更清楚,因為疊代器會存取數值。

一旦 Config::new 取得疊代器的所有權並不在使用借用的索引動作,我們就可以從疊代器中移動 String 的數值至 Config 而非呼叫 clone 來產生新的分配。

直接使用回傳的疊代器

請開啟你的 I/O 專案下的 src/main.rs 檔案,這應該會看起來像這樣:

檔案名稱:src/main.rs

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

use minigrep::Config;

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

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

    // --省略--

    if let Err(e) = minigrep::run(config) {
        eprintln!("應用程式錯誤:{}", e);

        process::exit(1);
    }
}

我們會改變範例 12-24 的 main 函式開頭段落成範例 13-25 的程式碼。這在我們更新 Config::new 之前都還無法編譯。

檔案名稱:src/main.rs

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

use minigrep::Config;

fn main() {
    let config = Config::new(env::args()).unwrap_or_else(|err| {
        eprintln!("解析引數時出現問題:{}", err);
        process::exit(1);
    });

    // --省略--

    if let Err(e) = minigrep::run(config) {
        eprintln!("應用程式錯誤:{}", e);

        process::exit(1);
    }
}

範例 13-25:傳遞 env::args 的回傳值給 Config::new

env::args 函式回傳的是疊代器!與其收集疊代器的數值成一個向量再傳遞切片給 Config::new,現在我們可以直接傳遞 env::args 回傳的疊代器所有權給 Config::new

接下來,我們需要更新 Config::new 的定義。在 I/O 專案的 src/lib.rs 檔案中,讓我們變更 Config::new 的簽名成範例 13-26 的樣子。這還無法編譯,因為我們需要更新函式本體。

檔案名稱:src/lib.rs

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

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

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

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

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

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

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

範例 13-26:更新 Config::new 的簽名來接收疊代器

標準函式庫技術文件顯示 env::args 函式回傳的疊代器型別為 std::env::Args。我們更新了 Config::new 函式的簽名,讓參數 args 的型別為 std::env::Args 而非 &[String]。因為我們取得了 args 的所有權,而且我們需要將 args 成為可變的讓我們可以疊代它,所以我們將關鍵字 mut 加到 args 的參數指定使其成為可變的。

使用 Iterator 特徵方法而非索引

接下來,我們要修正 Config::new 的本體。標準函式庫還提到了 std::env::Args 有實作 Iterator 特徵,所以我們知道我們可以對它呼叫 next 方法!範例 13-27 更新了範例 12-23 的程式碼來使用 next 方法:

檔案名稱:src/lib.rs

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

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

impl Config {
    pub fn new(mut args: env::Args) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("沒有取得欲搜尋的字串"),
        };

        let filename = match args.next() {
            Some(arg) => arg,
            None => return Err("沒有取得檔案名稱"),
        };

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

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

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub 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 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

fn main() {}

範例 13-27:變更 Config::new 的本體來使用疊代器方法

我們還記得 env::args 回傳的第一個數值會是程式名稱。我們想要忽略該值並取得下個數值,所以我們第一次呼叫 next 時不會對回傳值做任何事。再來我們才會呼叫 next 來取得我們想要的數值置入 Config 中的 query 欄位。如果 next 回傳 Some 的話,我們使用 match 來提取數值。如果它回傳 None 的話,這代表引數不足,所以我們提早用 Err 數值回傳。我們對 filename 數值也做一樣的事。

透過疊代配接器讓程式碼更清楚

我們也可以對 I/O 專案中的 search 函式利用疊代器的優勢,範例 13-28 重現了範例 12-19 的程式碼:

檔案名稱:src/lib.rs

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

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

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

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

        Ok(Config { query, filename })
    }
}

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

    Ok(())
}

pub 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
}

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

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

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

範例 13-28:範例 12-19 的 search 函式實作

我們可以使用疊代配接器(iterator adaptor)方法讓此程式碼更精簡。這樣做也能避免我們產生過程中的 results 可變向量。函式程式設計風格傾向於最小化可變狀態的數量使程式碼更加簡潔。移除可變狀態還在未來有機會讓搜尋可以平行化,因為我們不需要去管理 results 向量的並行存取。範例 13-29 展示了此改變:

檔案名稱:src/lib.rs

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

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

impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("沒有取得欲搜尋的字串"),
        };

        let filename = match args.next() {
            Some(arg) => arg,
            None => return Err("沒有取得檔案名稱"),
        };

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

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

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

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub 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
}

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

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

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

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

範例 13-29:對 search 函式實作使用疊代配接器方法

回想一下 search 函式的目的是要回傳 contents 中所有包含 query 的行數。類似於範例 13-19 的 filter 範例,此程式碼使用 filter 配接器來只保留 line.contains(query) 回傳為 true 的行數。我們接著就可以用 collect 收集符合的行數成另一個向量。這樣簡單多了!你也可以對 search_case_insensitive 函式使用疊代器方法做出相同的改變。

接下來的邏輯問題是在你自己的程式碼中你應該與為何要使用哪種風格呢:是要原本範例 13-28 的程式碼,還是範例 13-29 使用疊代器的版本呢?大多數的 Rust 程式設計師傾向於使用疊代器。一開始的確會有點難上手,不過一旦你熟悉各種疊代配接器與它們的用途後,疊代器就會很好理解了。不同於用迴圈迂迴處理每一步並建構新的向量,疊代器能更專注在迴圈的高階抽象上。這能抽象出常見的程式碼,並能更容易看出程式碼中的重點部位,比如疊代器中每個元素要過濾的條件。

但是這兩種實作真的完全相等嗎?你的直覺可能會假設低階的迴圈可能更快些。讓我們來討論效能吧。

比較效能:迴圈 vs. 疊代器

為了決定該使用迴圈還是疊代器,你需要知道哪個 search 函式的版本比較快:是顯式 for 迴圈的版本,還是疊代器的版本。

我們可以透過讀取整本 Sir Arthur Conan Doyle 寫的 The Adventures of Sherlock HolmesString 中並搜尋內容中的 the 來進行評測。以下為針對 search 函式使用 for 迴圈與使用疊代器的版本評測(benchmark):

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

疊代器版本竟然比較快一些!我們在此不會解釋評測的程式碼,因為這裡的重點不再於證明這兩種版本是一樣快的,而是要理解這兩種實作對效能的影響。

要做更全面的評測的話,你應該要檢查使用不同大小的不同文字來作為 contents、不同單字與不同長度來作為 query,以及所有各式各樣的可能性。這邊的重點在於:疊代器雖然是高階抽象,但其編譯出來的程式碼與你親自寫出低階的程式碼幾乎相同。疊代器是 Rust 其中一種零成本抽象(zero-cost abstractions),這指的是使用的抽象不會在執行時有額外的開銷。這是 C++ 的初始設計暨實作者 Bjarne Stroustrup 在 “Foundations of C++” (2012) 書中所定義的零開銷(zero-overhead)的概念:

大致上來說,C++ 的實作遵守著零開銷的原則:你沒有使用到的話,你就不必買單。而且你有使用到的話,你不可能在寫出更好的程式碼。

作為另一個例子,以下程式碼是音訊解碼器的其中一個段落。解碼演算碼使用線性預測數學運算,並依據之前樣本的線性函式來預估未來的數值。此程式碼使用一連串的疊代器來對作用域中的三個變數進行數學運算:資料切片 buffer、長度為 12 的 coefficients 陣列以及要偏移的數量 qlp_shift。我們在範例中宣告變數但沒有給予任何數值,雖然只看此程式碼的確沒有什麼意義,但是這仍然是個現實世界中的其中一個簡例,可以來看出 Rust 如何將高階的想法轉換成低階的程式碼。

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

要計算 prediction 的數值的話,此程式碼會遍歷 coefficients 的 12 個數值並使用 zip 方法將係數數值與 buffer 中之前 12 個數值做配對。然後在每個配對中,我們將數值相乘,然後相加所有結果,最後對總和往右偏移 qlp_shift 位。

像音訊解碼器這種的應用程式運算通常最注重效率。我們在此建立了一個疊代器,使用兩個配接器,然後消化數值。這段 Rust 程式碼會產生什麼樣的組合語言程式碼呢?在本書撰寫時,它會編譯出與你自己手寫一樣的組合語言。遍歷 coefficients 的數值完全不需用到迴圈:Rust 知道一共有 12 次疊代,所以它會「展開(unroll)」迴圈。展開是一種優化方式,這會移除迴圈控制程式碼並改產生針對迴圈中每次疊代的重複程式碼。

所有的係數都會存在暫存器中,這意味著存取數值會非常地快。在執行時不會有對陣列的界限檢查。這些所有 Rust 能做的優化讓產生的程式碼可以十分迅速。現在既然你已經知道了,你就可以自在地使用疊代器與閉包!它們可以寫出高階的程式碼,但不會犧牲執行時的效能。

總結

閉包與疊代是是 Rust 啟發自函式程式語言的概念。它們讓 Rust 在表達高階概念的同時,仍能擁有低階程式碼的效能。閉包與疊代器的實作對執行時效能不會有影響。這是 Rust 竭力提供零成本抽象的目標之一。

現在我們改善了 I/O 專案的可讀性,讓我們看看 cargo 一些更多的功能,來幫助我們分享專案給全世界。

更多關於 Cargo 與 Crates.io 的內容

目前我們只使用了 Cargo 最基本的功能來建構、執行與測試我們的程式碼,但它還能做更多事。在本章節中我們將討論這些其他的進階功能,你將瞭解如何做到以下動作:

  • 透過發佈設定檔來自訂你的建構
  • 發佈函式庫到 crates.io
  • 透過工作空間組織大型專案
  • crates.io 安裝二進制執行檔
  • 使用自訂命令擴展 Cargo 的功能

Cargo 能做的事還不止本章會介紹到的,所以想要知道它所有功能的話,歡迎查閱它的技術文件

透過發佈設定檔自訂建構

在 Rust 中發佈設定檔(release profiles)是個預先定義好並可用不同配置選項來自訂的設定檔,能讓程式設計師掌控更多選項來編譯程式碼。每個設定檔的配置彼此互相獨立。

Cargo 有兩個主要的設定檔:dev 設定檔會在當你對 Cargo 執行 cargo build 時所使用;release 設定檔會在當你對 Cargo 執行 cargo build --release 時所使用。dev 設定檔預設定義為適用於開發時使用,而release 設定檔預設定義為適用於發佈時使用。

你可能會覺得這些設定檔名稱很眼熟,因為它們就已經顯示在輸出結果過:

$ cargo build
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
$ cargo build --release
    Finished release [optimized] target(s) in 0.0s

此建構輸出顯示的 devrelease 代表編譯器會使用不同的設定檔。

當專案的 Cargo.toml 中沒有任何 [profile.*] 段落的話,Cargo 就會使用每個設定檔的預設設置。透過對你想要自訂的任何設定檔加上 [profile.*] 段落,你可以覆寫任何預設設定的子集。舉例來說,以下是 devrelease 設定檔中 opt-level 設定的預設數值:

檔案名稱:Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level 設定控制了 Rust 對程式碼進行優化的程度,範圍從 0 到 3。提高優化程度會增加編譯時間,所以如果你在開發過程中得時常編譯程式碼的話,你會比較想要編譯快一點,就算結果程式碼會執行的比較慢。這就是 devopt-level 預設為 0 的原因。當你準備好要發佈你的程式碼時,則最好花多點時間來編譯。你只需要在發佈模式編譯一次,但你的編譯程式則會被執行很多次,所以發佈模式選擇花費多點編譯時間來讓程式跑得比較快。這就是 releaseopt-level 預設為 3 的原因。

你可以在 Cargo.toml 加上不同的數值來覆蓋任何預設設定。舉例來說,如果我們希望在開發設定檔使用優化等級 1 的話,我們可以在專案的 Cargo.toml 檔案中加上這兩行:

檔案名稱:Cargo.toml

[profile.dev]
opt-level = 1

這樣就會覆蓋預設設定 0。現在當我們執行 cargo build,Cargo 就會使用 dev 設定檔的預設值以及我們自訂的 opt-level。因為我們將 opt-level 設為 1,Cargo 會比原本的預設進行更多優化,但沒有發佈建構那麼多。

對於完整的設置選項與每個設定檔的預設列表,請查閱 Cargo 的技術文件

發佈 Crate 到 Crates.io

我們已經使用過 crates.io 的套件來作為我們專案的依賴函式庫,但是你也可以發佈你自己的套件來將你的程式碼提供給其他人使用。crates.io 會發行套件的原始碼,所以它主要用來託管開源程式碼。

Rust 與 Cargo 有許多功能可以幫助其他人更容易找到並使用你發佈的套件。我們會介紹其中一些功能並解釋如何發佈套件。

寫上有幫助的技術文件註解

準確地加上套件的技術文件有助於其他使用者知道如何及何時使用它們,所以投資時間在寫技術文件上是值得的。在第三章我們提過如何使用兩條斜線 // 來加上 Rust 程式碼註解。Rust 還有個特別的註解用來作為技術文件,俗稱為技術文件註解(documentation comment),這能用來產生 HTML 技術文件。這些 HTML 顯示公開 API 項目中技術文件註解的內容,讓對此函式庫有興趣的開發者知道如何使用你的 crate,而不需知道 crate 是如何實作的。

技術文件註解使用三條斜線 /// 而不是兩條,並支援 Markdown 符號來格式化文字。技術文件註解位於它們對應項目的上方。範例 14-1 顯示了 my_crate crate 中 add_one 的技術文件註解:

檔案名稱:src/lib.rs

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

範例 14-1:函式的技術文件註解

我們在這裡加上了解釋函式 add_one 行為的描述、加上一個標題為 Examples 的段落並附上展示如何使用 add_one 函式的程式碼。我們可以透過執行 cargo doc 來從技術文件註解產生 HTML 技術文件。此命令會執行隨著 Rust 一起發佈的工具 rustdoc,並在 target/doc 目錄下產生 HTML 技術文件。

為了方便起見,你可以執行 cargo doc --open 來建構當前 crate 的 HTML 技術文件(以及 crate 所有依賴的技術文件)並在網頁瀏覽器中開啟結果。導向到函式 add_one 而你就能看到技術文件註解是如何呈現的,如圖示 14-1 所示:

Rendered HTML documentation for the `add_one` function of `my_crate`

圖示 14-1:函式 add_one 的 HTML 技術文件

常見技術文件段落

我們在範例 14-1 使用 # Examples Markdown 標題來在 HTML 中建立一個標題為「Examples」的段落。以下是 crate 技術文件中常見的段落標題:

  • Panics:該函式可能會導致恐慌的可能場合。函式的呼叫者不希望他們的程式恐慌的話,就要確保他們沒有在這些情況下呼叫該函式。
  • Errors:如果函式回傳 Result,解釋發生錯誤的可能種類以及在何種條件下可能會回傳這些錯誤有助於呼叫者,讓他們可以用不同方式來寫出處理不同種錯誤的程式碼。
  • Safety: 如果呼叫的函式是 unsafe 的話(我們會在第十九章討論不安全的議題),就必須要有個段落解釋為何該函式是不安全的,並提及函式預期呼叫者要確保哪些不變條件(invariants)。

大多數的技術文件註解不全都需要這些段落,但這些是呼叫程式碼的人可能有興趣瞭解的內容,你可以作為提醒你的檢查列表。

將技術文件註解作為測試

在技術文件註解加上範例程式碼區塊有助於解釋如何使用你的函式庫,而且這麼做還有個額外好處:執行 cargo test 也會將你的技術文件視為測試來執行!在技術文件加上範例的確是最佳示範,但是如果程式碼在技術文件寫完之後變更的話,該範例可能就會無法執行了。如果我們對範例 14-1 中有附上技術文件的函式 add_one 執行 cargo test 的話,我們會看見測試結果有以下這樣的段落:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

現在如果我們變更函式或範例使其內的 assert_eq! 會恐慌並再次執行 cargo test 的話,我們會看到技術文件測試能互獲取錯誤,告訴我們範例與程式碼已經不同不了!

包含項目結構的註解

還有另一種技術文件註解的風格為 //!,這是對其包含該註解的項目所加上的技術文件,而不是對註解後的項目所加上的技術文件。我通常將此技術文件註解用於 crate 源頭檔(通常為 src/lib.rs)或模組來對整個 crate 或模組加上技術文件。

舉例來說,如果我們希望能加上技術文件來描述包含 add_one 函式的 my_crate 目的,我們可以用 //!src/lib.rs 檔案開頭加上技術文件註解,如範例 14-2 所示:

檔案名稱:src/lib.rs

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --省略--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

範例 14-2:描述整個 my_crate crate 的技術文件

注意到 //! 最後一行之後並沒有緊貼任何程式碼,因為我們是用 //! 而非 /// 來下註解,我們是對包含此註解的整個項目加上技術文件,而不是此註解之後的項目。在此例中,包含此註解的項目為 src/lib.rs 檔案,也就是 crate 的源頭。這些註解會描述整個 crate。

當我們執行 cargo doc --open,這些註解會顯示在 my_crate 技術文件的首頁,位於 crate 公開項目列表的上方,如圖示 14-2 所示:

Rendered HTML documentation with a comment for the crate as a whole

圖示 14-2:my_crate 的技術文件,包含描述整個 crate 的註解

項目中的技術文件註解可以用來分別描述 crate 和模組。用它們來將解釋容器整體的目的有助於你的使用者瞭解該 crate 的程式碼組織架構。

透過 pub use 匯出理想的公開 API

在第七章中,我們介紹了如何使用 mod 關鍵字來組織我們的程式碼成模組、如何使用 pub 關鍵字來公開項目,以及如何使用 use 關鍵字在將項目引入作用域。然而在開發 crate 時的架構雖然對你來說是合理的,但對你的使用者來說可能就不是那麼合適了。你可能會希望用有數個層級的分層架構來組織你的程式碼,但是要是有人想使用你定義在分層架構裡的型別時,它們可能就很難發現這些型別的存在。而且輸入 use my_crate::some_module::another_module::UsefulType; 是非常惱人的,我們會希望輸入 use my_crate::UsefulType; 就好。

公開 API 的架構是發佈 crate 時要考量到的一大重點。使用 crate 的人可能並沒有你那麼熟悉其中的架構,而且如果你的 crate 模組分層越深的話,他們可能就難以找到他們想使用的部分。

好消息是如果你的架構不便於其他函式庫所使用的話,你不必重新組織你的內部架構:你可以透過使用 pub use選擇重新匯出(re-export)項目來建立一個不同於內部私有架構的公開架構。重新匯出會先取得某處的公開項目,再從其他地方使其公開,讓它像是被定義在其他地方一樣。

舉例來說,我們建立了一個函式庫叫做 art 來模擬藝術概念。在函式庫中有兩個模組:kinds 模組包含兩個枚舉 PrimaryColorSecondaryColor;而 utils 模組包含一個函式 mix,如範例 14-3 所示:

檔案名稱:src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --省略--
        SecondaryColor::Orange
    }
}

fn main() {}

範例 14-3:函式庫 art 有兩個模組項目 kindsutils

圖示 14-3 顯示了此 crate 透過 cargo doc 產生的技術文件首頁:

Rendered documentation for the `art` crate that lists the `kinds` and `utils` modules

圖示 14-3:art 的技術文件首頁陳列了 kindsutils 模組

注意到 PrimaryColorSecondaryColor 型別沒有列在首頁,而函式 mix 也沒有。我們必須點擊 kindsutils 才能看到它們。

其他依賴此函式庫的 crate 需要使用 use 陳述式來將 art 的項目引入作用域中,並指定當前模組定義的架構。範例 14-4 顯示了從 art crate 使用 PrimaryColormix 項目的 crate 範例:

檔案名稱:src/main.rs

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

範例 14-4:一個使用 art 並匯出內部架構項目的 crate

範例 14-4 中使用 art crate 的程式碼作者必須搞清楚 PrimaryColor 位於 kinds 模組中而 mix 位於 utils 模組中。art crate 的模組架構對開發 art crate 的開發者才比較有意義,對使用 art crate 的開發者來說就沒那麼重要。內部架構是為了組織 crate 的不同部分至 kinds 模組與 utils 模組,這對想要知道如何使用 art crate 的人來說沒有提供什麼有用的資訊。art crate 模組架構還容易造成混淆,因為開發者得自己搞清楚要從何處找起。而且這樣的架構也很不方便,因為開發者必須在 use 陳述式中指定每個模組名稱。

要從公開 API 移除內部架構,我們可以修改範例 14-3 中 art crate 的程式碼,並加上 pub use 陳述式來在頂層重新匯出項目,如範例 14-5 所示:

檔案名稱:src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --省略--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --省略--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}

範例 14-5:加上 pub use 陳述式來重新匯出項目

cargo doc 對此 crate 產生的 API 技術文件現在就會顯示與連結重新匯出的項目到首頁中,如圖示 14-4 所示。讓PrimaryColorSecondaryColor 型別以及函式 mix 更容易被找到。

Rendered documentation for the `art` crate with the re-exports on the front page

圖示 14-:art 的技術文件首頁會連結重新匯出的結果

art crate 使用者仍可以看到並使用範例 14-3 的內部架構,如範例 14-4 所展示的方式,或者它們可以使用像範例 14-5 這樣更方便的架構,如範例 14-6 所示:

檔案名稱:src/main.rs

use art::mix;
use art::PrimaryColor;

fn main() {
    // --省略--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

範例 14-6:使用從 art crate 重新匯出項目的程式

如果你有許多巢狀模組(nested modules)的話,在頂層透過 pub use 重新匯出型別可以大大提升使用 crate 的體驗。

提供實用的公開 API 架構更像是一門藝術而不只是科學,而你可以一步步來尋找最適合使用者的 API 架構。使用 pub use 可以給你更多組織 crate 內部架構的彈性,並將內部架構與你要呈現給使用者的介面互相解偶(decouple)。你可以觀察一些你安裝過的程式碼,看看它嗎的內部架構是不是不同於它們的公開 API。

設定 Crates.io 帳號

在你可以發佈任何 crate 之前,你需要建立一個 crates.io 的帳號並取得一個 API token。請前往 crates.io 的首頁並透過 GitHub 帳號來登入(GitHub 目前是必要的,但未來可能會支援其他建立帳號的方式)一旦你登入好了之後,到你的帳號設定 https://crates.io/me/ 並索取你的 API key,然後用這個 API key 來執行 cargo login 命令,如以下所示:

$ cargo login abcdefghijklmnopqrstuvwxyz012345

此命令會傳遞你的 API token 給 Cargo 並儲存在本地的 ~/.cargo/credentials。注意此 token 是個祕密(secret),千萬不要分享給其他人。如果你因為任何原因分享給任何人的話,你最好撤銷掉並回到 crates.io 產生新的 token。

新增詮釋資料到新的 Crate

現在你已經有個帳號,然後讓我們假設你有個 crate 想要發佈。在發佈之前,你需要對你的 crate 加上一些詮釋資料(metadata),也就是在 crate 的 Cargo.toml 檔案中 [package] 的段落內加上更多資料。

你的 crate 必須要有個獨特的名稱。雖然你在本地端開發 crate 時,你的 crate 可以是任何你想要的名稱。但是 crates.io 上的 crate 名稱採先搶先贏制。一旦有 crate 名稱被取走了,其他人就不能再使用該名稱來發佈 crate。在嘗試發佈 crate 前,最好先在 crates.io 上搜尋你想使用的名稱。如果該名稱已被其他 crate 使用,你就需要想另一個名稱,並在 Cargo.toml 檔案中 [package] 段落的 name 欄位使用新的名稱來發佈,如以下所示:

檔案名稱:Cargo.toml

[package]
name = "guessing_game"

當你選好獨特名稱後,此時執行 cargo publish 來發佈 crate 的話,你會得到以下警告與錯誤:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--省略--
error: api errors (status 200 OK): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for how to upload metadata

原因是因為你還缺少一些關鍵資訊:描述與授權條款是必須的,所以人們才能知道你的 crate 在做什麼以及在何種情況下允許使用。要修正此錯誤,你就需要將這些資訊加到 Cargo.toml 檔案中。

加上一兩句描述,它就會顯示在你的 crate 的搜尋結果中。至於 license 欄位,你需要給予 license identifier valueLinux Foundation’s Software Package Data Exchange (SPDX) 有列出你可以使用的標識符數值。舉例來說,要指定你的 crate 使用 MIT 授權條款的話,就加上 MIT 標識符:

檔案名稱:Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

如果你想使用沒有出現在 SPDX 的授權條款,你需要將該授權條款的文字儲存在一個檔案中、將該檔案加入你的專案中並使用 license-file 來指定該檔案名稱,而不使用 license

你的專案適合使用什麼樣的授權條款超出了本書的範疇。不過 Rust 社群中許多人都會用 MIT OR Apache-2.0 雙授權條款作為它們專案的授權方式,這和 Rust 的授權條款一樣。這也剛好展示你也可以用 OR 指定數個授權條款,讓你的專案擁有數個不同的授權方式。

有了當你用 cargo new 建立 crate 時就會產生的獨特名稱、版本與作者資訊,以及你的手動加入的描述與授權條款,已經準備好發佈的 Cargo.toml 檔案會如以下所示:

檔案名稱:Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <[email protected]>"]
edition = "2018"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

Cargo 技術文件還介紹了其他你可以指定的詮釋資料,讓你的 crate 更容易被其他人發掘並使用。

發佈至 Crates.io

現在你已經建立了帳號、儲存了 API token、選擇了 crate 的獨特名稱並指定了所需的詮釋資料,你現在已經準備好發佈了!發佈 crate 會上傳一個指定版本到 crates.io 供其他人使用。

發佈 crate 時請格外小心,因為發佈是會永遠存在的。該版本無法被覆寫,而且程式碼無法被刪除。crates.io 其中一個主要目標就是要作為儲存程式碼的永久伺服器,讓所有依賴 crates.io 的 crate 的專案可以持續正常運作。允許刪除版本會讓此目標幾乎無法達成。不過你能發佈的 crate 版本不會有數量限制。

再次執行 cargo publish 命令,這次就應該會成功了:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

恭喜!你現在將你的程式碼分享給 Rust 社群了,任何人現在都可以輕鬆將你的 crate 加到他們的專案中作為依賴了。

對現有 Crate 發佈新版本

當你對你的 crate 做了一些改變並準備好發佈新版本時,你可以變更 Cargo.toml 中的 version 數值,並再發佈一次。請使用語意化版本規則依據你作出的改變來決定下一個妥當的版本數字。接著執行 cargo publish 來上傳新版本。

透過 cargo yank 移除 Crates.io 的版本

雖然你無法刪除 crate 之前的版本,你還是可以防止任何未來的專案加入它們作為依賴。這在 crate 版本因某些原因而被破壞時會很有用。在這樣的情況下,Cargo 支援撤回(yanking) crate 版本。

撤回一個版本能防止新專案用該版本作為依賴,同時允許現存依賴它的專案能夠繼續下載並依賴該版本。實際上,撤回代表所有專案的 Cargo.lock 都不會被破壞,且任何未來產生的 Cargo.lock 檔案不會使用被撤回的版本。

要撤回一個 crate 的版本,執行 cargo yank 並指定你想撤回的版本:

$ cargo yank --vers 1.0.1

而對命令加上 --undo 的話,你還可以在復原撤回的動作,允許其他專案可以再次依賴該版本:

$ cargo yank --vers 1.0.1 --undo

撤回並不會刪除任何程式碼。舉例來說,撤回此功能並不會刪除任何不小心上傳的祕密訊息。如果真的出現這種情形,你必須立即重設那些資訊。

Cargo 工作空間

在第十二章中,我們建立的套件包含一個二進制執行檔 crate 與一個函式庫 crate。隨著專案開發,你可能會發現函式庫 crate 變得越來越大,而你可能會想要將套件拆成數個函式庫 crate。針對這種情形,Cargo 提供了一個功能叫做工作空間(workspaces)能來幫助管理並開發數個相關的套件。

建立工作空間

工作空間是一系列的共享相同 Cargo.lock 與輸出目錄的套件。讓我們建立個使用工作空間的專案,我們會使用簡單的程式碼,好讓我們能專注在工作空間的架構上。組織工作空間的架構有很多種方式,我們會顯示其中一種常見的方式。我們的工作空間將會包含一個二進制執行檔與兩個函式庫。執行檔會提供主要功能,並依賴其他兩個函式庫。其中一個函式庫會提供函式 add_one,而另一個函式庫會提供函式 add_two。這三個 crate 會包含在相同的工作空間中,我們先從建立工作空間的目錄開始:

$ mkdir add
$ cd add

接著在 add 目錄中,我們建立會設置整個工作空間的 Cargo.toml 檔案。此檔案不會有 [package] 段落或是我們在其他 Cargo.toml 檔案看過的詮釋資料。反之,他會使用一個 [workspace] 段落作為起始,讓我們可以透過指定二進制 crate 的套件路徑來將它加到工作空間的成員中。在此例中,我們的路徑是 adder

檔案名稱:Cargo.toml

[workspace]

members = [
    "adder",
]

接下來我們會在 add 目錄下執行 cargo new 來建立 adder 二進制 crate:

$ cargo new adder
     Created binary (application) `adder` package

在這個階段,我們已經可以執行 cargo build 來建構工作空間。目錄 add 底下的檔案應該會看起來像這樣:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

工作空間在頂層有一個 target 目錄用來儲存編譯結果。adder 套件不會有自己的 target 目錄。就算我們在 adder 目錄底下執行 cargo build,編譯結果仍然會在 add/target 底下而非 add/adder/target。Cargo 之所以這樣組織工作空間的 target 目錄是因為工作空間的 crate 是會彼此互相依賴的。 如果每個 crate 都有自己的 target 目錄,每個 crate 就得重新編譯工作空間中的其他每個 crate 才能將編譯結果放入它們自己的 target 目錄。共享 target 目錄的話,crate 可以避免不必要的重新建構。

在工作空間中建立第二個套件

接下來讓我們在工作空間中建立另一個套件成員 add-one。請修改頂層 Cargo.toml 來指定 add-one 的路徑到 members 列表中:

檔案名稱:Cargo.toml

[workspace]

members = [
    "adder",
    "add-one",
]

然後產生新的函式庫 crate add-one

$ cargo new add-one --lib
     Created library `add-one` package

add 目錄現在應該要擁有這些目錄與檔案:

├── Cargo.lock
├── Cargo.toml
├── add-one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

add-one/src/lib.rs 檔案中,讓我們加上一個函式 add_one

檔案名稱:add-one/src/lib.rs


#![allow(unused)]
fn main() {
pub fn add_one(x: i32) -> i32 {
    x + 1
}
}

現在在我們在工作空間中有另一個套件了,我們可以讓我們 adder 套件的執行檔依賴擁有函式庫的 add-one 套件。首先,我們需要將 add-one 的路徑依賴加到 adder/Cargo.toml

檔案名稱:adder/Cargo.toml

[dependencies]

add-one = { path = "../add-one" }

Cargo 不會假設工作空間下的 crate 會彼此依賴,我們我們要指定 crate 彼此之間依賴的關係。

接著讓我們在 adder 內使用 add-one crate 的 add_one 函式。開啟 adder/src/main.rs 檔案並在最上方加上 use 來將 add-one 函式庫引入作用域。然後變更 main 函式來呼叫 add_one 函式,如範例14-7 所示。

檔案名稱:adder/src/main.rs

use add_one;

fn main() {
    let num = 10;
    println!(
        "你好,世界!{} 加一會是 {}!",
        num,
        add_one::add_one(num)
    );
}

範例 14-7:在 adder crate 中使 add-one 函式庫 crate

讓我們在頂層的 add 目錄執行 cargo build 來建構工作空間吧!

$ cargo build
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s

要執行 add 目錄的二進制 crate,我們可以透過 -p 加上套件名稱使用 cargo run 來執行我們想要在工作空間中指定的套件:

$ cargo run -p adder
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/adder`
你好,世界!10 加一會是 11!

這就會執行 adder/src/main.rs 的程式碼,其依賴於 add-one crate。

在工作空間中依賴外部套件

注意到工作空間只有在頂層有一個 Cargo.lock 檔案,而不是在每個 crate 目錄都有一個 Cargo.lock。這確保所有的 crate 都對所有的依賴使用相同的版本。如果我們加了 rand 套件到 adder/Cargo.tomladd-one/Cargo.toml 檔案中,Cargo 會將兩者的版本解析為同一個 rand 版本並記錄到同個 Cargo.lock 中。確保工作空間所有 crate 都會使用相同依賴代表工作空間中的 crate 永遠都彼此相容。讓我們將 rand crate 加到 add-one/Cargo.toml 檔案的 [dependencies] 段落中,使 add-one crate 可以使用 rand crate:

檔案名稱:add-one/Cargo.toml

[dependencies]
rand = "0.5.5"

我們現在就可以將 use rand; 加到 add-one/src/lib.rs 檔案中,接著在 add 目錄下執行 cargo build 來建構整個工作空間就會引入並編譯 rand crate:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.5.5
   --省略--
   Compiling rand v0.5.6
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 10.18s

頂層的 Cargo.lock 現在就包含 add-onerand 作為依賴的資訊。不過就算我們能在工作空間的某處使用 rand,並不代表我們可以在工作空間的其他 crate 中使用它,除非它們的 Cargo.toml 也加上了 rand。舉例來說,如果我們將 use rand; 加到 adder/src/main.rs 檔案中想讓 adder 套件也使用的話,我們就會得到錯誤:

$ cargo build
  --省略--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no `rand` external crate

要修正此問題,只要修改 adder 套件的 Cargo.toml 檔案,指示它也加入 rand 作為依賴就好了。這樣建構 adder 套件就會將在 Cargo.lock 中將 rand 加入 adder 的依賴,但是沒有額外的 rand 會被下載。Cargo 會確保工作空間中每個套件的每個 crate 都會使用相同的 rand 套件版本。在工作空間中使用相同版本的 rand 可以節省空間,因為我們就不會重複下載並能確保工作空間中的 crate 彼此可以互相兼容。

在工作空間中新增測試

讓我們再進一步加入一個測試函式 add_one::add_oneadd_one crate 之中:

檔案名稱:add-one/src/lib.rs


#![allow(unused)]
fn main() {
pub fn add_one(x: i32) -> i32 {
    x + 1
}

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

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}
}

現在在頂層的 add 目錄執行 cargo test

$ cargo test
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.27s
     Running target/debug/deps/add_one-f0253159197f7841

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/adder-49979ff40686fa8e

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests add-one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

輸出的第一個段落顯示了 add-one crate 中的 it_works 測試通過。下一個段落顯示 adder crate 沒有任何測試,然後最後一個段落顯示 add-one 中沒有任何技術文件測試。在像工作空間這樣的架構下執行 cargo test 就會執行工作空間內的所有 crate 測試。

我們也可以在頂層目錄使用 -p 並指定我們想測試的 crate 名稱來測試工作空間中特定的 crate:

$ cargo test -p add-one
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running target/debug/deps/add_one-b3235fea9a156f74

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests add-one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

此輸出顯示 cargo test 只執行了 add-one crate 的測試並沒有執行 adder crate 的測試。

如果你想要發佈工作空間的 crate 到 crates.io,工作空間中的每個 crate 必須分別獨自發佈。cargo publish 命令並沒有 --all 或是 -p 之類的選項,所以你必須移動到每個 crate 的目錄並執行 cargo publish,這樣工作空間中的每個 crate 才會發佈出去。

之後想嘗試練習的話,你可以在工作空間中在加上 add-two crate,方式和 add-one crate 類似!

隨著你的專案成長,你可以考慮使用工作空間:拆成各個小部分比一整塊大程式還更容易閱讀。再者,如果需要經常同時修改的話,將 crate 放在同個工作空間中更易於彼此的協作。

透過 cargo install 從 Crates.io 安裝二進制執行檔

cargo install 命令讓你能本地安裝並使用二進制執行檔 crates。這並不是打算要取代系統套件,這是為了方便讓 Rust 開發者可以安裝 crates.io 上分享的工具。注意你只能安裝有二進制目標的套件。二進制目標(binary target)是在 crate 有 src/main.rs 檔案或其他指定的二進制檔案時,所建立的可執行程式。而相反地,函式庫目標就無法單獨執行,因為它提供給其他程式使用的函式庫。通常 crate 都會提供 README 檔案說明此 crate 是函式庫還是二進制目標,或者兩者都是。

所有透過 cargo install 安裝的二進制檔案都儲存在安裝根目錄的 bin 資料夾中。如果你是用 rustup.rs 安裝 Rust 且沒有任何自訂設置的話,此目錄會是 $HOME/.cargo/bin。請確定該目錄有在你的 $PATH 中,這樣才能夠執行 cargo install 安裝的程式。

舉例來說,第十二章我們提到有個 Rust 版本的 grep 工具叫做 ripgrep 能用來搜尋檔案。如果我們想要安裝 ripgrep 的話,我們可以執行以下命令:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v11.0.2
  Downloaded 1 crate (243.3 KB) in 0.88s
  Installing ripgrep v11.0.2
--省略--
   Compiling ripgrep v11.0.2
    Finished release [optimized] target(s) in 3m 10s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v11.0.2` (executable `rg`)

輸出的最後兩行顯示了二進制檔案的安裝位置與名稱,在 ripgrep 此例中就是 rg。如稍早提到的,只要你的 $PATH 有包含安裝目錄,你就可以執行 rg --help 並開始使用更快更鏽的搜尋檔案工具!

透過自訂命命來擴展 Cargo 的功能

Cargo 的設計能讓你在不用修改 Cargo 的情況下擴展新的子命令。如果你 $PATH 中有任何叫做 cargo-something 的二進制檔案,你就可以用像是執行 Cargo 子命令的方式 cargo something 來執行它。像這樣的自訂命令在你執行 cargo --list 時也會顯示出來。能夠透過 cargo install 來安裝擴展插件並有如內建 Cargo 工具般來執行使用是 Cargo 設計上的一大方便優勢!

總結

透過 Cargo 與 crates.io 分享程式碼是讓 Rust 生態系統能適用於許多不同任務的重要部分之一。Rust 的標準函式庫既小又穩定,但是 crate 可以很容易地分享、使用,並在語言本身不同的時間線來進行改善。千萬別吝嗇於分享你認為實用的程式碼到 crates.io,其他人可能也會覺得它很有幫助!

智慧指標

指標(pointer)是一個將變數儲存記憶體位址的通用概念。此位址引用,或者說是「指向」一些其他資料。Rust 中最常見的指標種類就是第四章介紹的引用(reference)。引用以 & 符號作為指示並借用它們指向的數值。它們除了引用資料以外,沒有其他的特殊能力。此外,它們也沒有任何額外開銷,所以這是我們最常使用到的指標種類。

另一方面,智慧指標(Smart pointers)是個不止會有像是指標的行為,還會包含擁有的詮釋資料與能力。智慧指標的概念並不是 Rust 獨有的,智慧指標起源於 C++ 且也都存在於其他語言。在 Rust 中,定義在標準函式庫中的不同智慧指標不止能引用,還具備更多的功能。其中一個我們會在本章會探索到的就是引用計數(reference counting)智慧指標型別。此指標允許一個資料可以有多個擁有者,並追蹤擁有者的數量,當沒有任何擁有者時,就清除資料。

在 Rust 中,我們有所有權與借用的概念,所以引用與智慧指標之間還有有一項差別。引用是只有借用資料的指標,但智慧指標在很多時候都擁有它們指向的資料。

我們已經在本書中遇過一些智慧指標了,像是第八章的 StringVec<T>,雖然當時我們沒有稱呼它們為智慧指標。這些型別都算是智慧指標,因為它們都擁有一些記憶體並允許你操控它們。它們也有詮釋資料(像是容量)以及額外的能力或保障(像是 String 確保其資料永遠是有效的 UTF-8)。

智慧指標通常都使用結構體實作。要區分智慧指標與一般結構體的差別為智慧指標會實作 DerefDrop 特徵。Deref 特徵允許智慧指標結構體的實例表現的像是引用一樣,讓你可以寫出能用在引用與智慧指標的程式碼。Drop 特徵允許你自訂當智慧指標實例離開作用域時要執行的程式碼。在本章節我們會討論這兩個特徵並解釋為何它們對智慧指標很重要。

有鑑於智慧指標在 Rust 是個常用的通用設計模式,本章不會涵蓋每一個現有的智慧指標。許多函式庫也都會提供它們自己的智慧指標,你甚至能寫個你自己的。我們會提及標準函式庫中最常用到的智慧指標:

  • Box<T> 將數值分配到堆積上
  • Rc<T>, 引用計數型別來允許資料能有數個擁有者
  • Ref<T>RefMut<T> 透過 RefCell<T> 來取得,這是在執行時而非編譯時強制執行借用規則的型別

除此之外,我們還會涵蓋到內部可變性(interior mutability)模式,這讓不可變引用的型別能提供改變內部數值的 API。我們還會討論引用循環(reference cycles)為何會導致記憶體泄漏以及如何預防它們。

讓我們開始吧!

使用 Box<T> 指向堆積上的資料

最直白的智慧指標是 box 其型別為 Box<T>。Box 允許你儲存資料到堆積上,而不是堆疊。留在堆疊上的會是指向堆積資料的指標。你可以回顧第四章瞭解堆疊與堆積的差別。

Box 沒有額外的效能開銷,就只是將它們的資料儲存在堆積上而非堆疊而已。不過相對地它們也沒有多少額外功能。你大概會在這些場合用到它們:

  • 當你有個型別無法在編譯時期確定大小,而你又想在需要知道確切大小的情況下使用該型別的數值
  • 當你有個龐大的資料,而你想要轉移所有權並確保資料不會被拷貝。
  • 當你想要擁有某個值,但你只在意該型別有實作特定的特徵,而不再是何種特定型別

我們會在「透過 Box 建立遞迴型別」段落解說第一種情形。而在第二種情形,轉移龐大的資料的所有權可能會很花費時間,因為在堆疊上的話會拷貝所有資料。要改善此情形,我們可以用 box 將龐大的資料儲存在堆積上。這樣就只有少量的指標資料在堆疊上被拷貝,而其引用的資料仍然保留在堆積上的同個位置。第三種情況被稱之為特徵物件(trait object),第十七章會花整個「允許不同型別數值的特徵物件」段落來討論此議題。所以你在此學到的到第十七章會再次用上!

使用 Box<T> 儲存資料到堆積上

在我們討論 Box<T> 的使用場合前,我們會先介紹語法以及如何對 Box<T> 內儲存的數值進行互動。

範例 15-1 顯示如何使用 box 在堆積上儲存一個 i32 數值:

檔案名稱:src/main.rs

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

範例 15-1:使用 box 在堆積上儲存一個 i32 數值

我們定義了變數 b 其數值為 Box 分配在堆積上指向的數值 5。程式在此例會印出 b = 5,在此例中我們可以用在堆疊上相同的方式取得 box 的資料。就像任何有所有權的數值一樣,當 box 離開作用域時會釋放記憶體,在此例就是當 b 抵達 main 結尾的時候。釋放記憶體作用於 box(儲存在堆疊上)以及其所指向的資料(儲存在堆積上)。

將單一數值放在堆積上的確沒什麼用處,所以你不會對這種類型經常使用 box。在大多數情況下將像 i32 這種單一數值預設儲存在堆疊的確比較適合。

透過 Box 建立遞迴型別

在編譯時期,Rust 需要知道一個型別佔用的空間有多少。其中一種無法在編譯期間知道大小的型別就是遞迴型別(recursive type),其值的一部分可以是相同型別的另一個值。由於這種巢狀數值理論上可以無限循環下去,Rust 無法知道一個遞迴型別的數值需要多大的空間。然而 box 則有已知大小,所以將 box 填入遞迴型別定義中,你就可以有遞迴型別了。

讓我們來探索 cons list,這是個在函式程式語言中常見的資料型別,很適合作為遞迴型別的範例。我們要定義的 cons list 型別除了遞迴的部分以外都很直白,因此這個例子的概念在往後你遇到更複雜的遞迴型別時會很實用。

更多關於 Cons List 的資訊

cons list 是個起源於 Lisp 程式設計語言與其方言的資料結構。在 Lisp 中,cons 函式(「construct function」的縮寫)會從兩個引數建構一個新的配對,而這通常是一個數值與另一個配對,而這些配對就包含了列表中的配對。

cons 函式的概念在往後成了常見的函式語言術語:「將 x cons 到 y」通常代表的是建立一個新的容器實例,將元素 x 置於此容器的開頭,而後方則是連接到容器 y

每個 cons list 的項目都包含兩個元素:目前項目的數值與下一個項目。列表中的最後一個項目只會包含一個數值叫做 Nil,並不會再連接下一個項目。cons list 透過遞迴呼叫 cons 函式來產生。表示遞迴終止條件的名稱為 Nil。注意這和第六章提到的「null」或「nil」的概念不全然相同,這些代表的是無效或空缺的數值。

雖然函式程式設計語言很常使用 cons lists,但在 Rust 中 cons lists 卻不是常見的資料結構。大多數當你在 Rust 需要項目列表時,Vec<T> 會是比較好的選擇。而其他時候夠複雜的遞迴資料型別確實在各種特殊情形會很實用,不過先從 cons list 開始的話,我們可以專注探討 box 如何讓我們定義遞迴資料型別。

範例 15-2 包含了 cons list 的枚舉定義。注意到此程式碼還不能編譯過,因為 List 型別並沒有以已知大小,我們接下來會繼續說明。

檔案名稱:src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

範例 15-2:第一次嘗試定義一個枚舉來代表有 i32 數值的 cons list 資料結構

注意:我們定義的 cons list 只有 i32 數值是為了範例考量。我們當然可以使用第十章討論過的泛型來定義它,讓 cons list 定義的型別可以儲存任何型別數值。

使用 List 型別來儲存 1, 2, 3 列表的話會如範例 15-3 的程式碼所示:

檔案名稱:src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

範例 15-3:使用 List 枚舉儲存列表 1, 2, 3

第一個 Cons 值會得到 1 與另一個 List 數值。此 List 數值是另一個 Cons 數值且持有 2 與另一個 List 數值。此 List 數值是另一個 Cons 數值且擁有 3 與一個 List 數值,其就是最後的 Nil,這是傳遞列表結尾訊號的非遞迴變體。

如果我們嘗試編譯範例 15-3 的程式碼,我們會得到範例 15-4 的錯誤:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `List` representable

error[E0391]: cycle detected when processing `List`
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which again requires processing `List`, completing the cycle
  = note: cycle used when computing dropck types for `Canonical { max_universe: U0, variables: [], value: ParamEnvAnd { param_env: ParamEnv { caller_bounds: [], reveal: UserFacing, def_id: None }, value: List } }`

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list`.

To learn more, run the command again with --verbose.

範例 15-4:嘗試定義遞迴枚舉所得到的錯誤

錯誤顯示此型別的「大小為無限」,原因是因為我們定義的 List 有個變體是遞迴:它直接存有另一個相同類型的數值。所以 Rust 無法判別出它需要多少空間才能儲存一個 List 的數值。讓我進一步研究為何我們會得到這樣的錯誤,首先來看 Rust 如何決定要分配多少空間來儲存非遞迴型別。

計算非遞迴型別的大小

回想一下第六章中,當我們在討論枚舉定義時,我們在範例 6-2 定義的 Message 枚舉:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

要決定一個 Message 數值需要分配多少空間,Rust 會遍歷每個變體來看哪個變體需要最大的空間。Rust 會看到 Message::Quit 不佔任何空間、Message::Move 需要能夠儲存兩個 i32 的空間,以此類推。因為只有一個變體會被使用,一個 Message 數值所需的最大空間就是其最大變體的大小。

將此對應到當 Rust 嘗試檢查像是範例 15-2 的 List 枚舉來決定遞迴型別需要多少空間時,究竟會發生什麼事。編譯器先從查看 Cons 的變體開始,其存有一個 i32 型別與一個 List 型別。因此 Cons 需要的空間大小為 i32 的大小加上 List 的大小。為了要瞭解 List 型別需要的多少記憶體,編譯器在進一步看它的變體,也是從 Cons 變體開始。Cons 變體存有一個型別 i32 與一個型別 List,而這樣的過程就無限處理下去,如圖示 15-1 所示。

An infinite Cons list

圖示 15-1:無限個 List 包含著無限個 Cons 變體

使用 Box<T> 取得已知大小的遞迴型別

Rust 無法判別出遞迴定義型別要分配多少空間,所以編譯器給了範例 15-4 的錯誤,但是此錯誤有提供實用的建議:

  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `List` representable

在此建議中,「indirection」代表與其直接儲存數值,我們可以變更資料結構,間接儲存指向數值的指標。

因為 Box<T> 是個指標,Rust 永遠知道 Box<T> 需要多少空間:指標的大小不會隨著指向的資料數量而改變。這代表我們可以將 Box<T> 存入 Cons 變體而非直接儲存另一個 List 數值。Box<T> 會指向另一個存在於堆積上的 List 數值而不是存在 Cons 變體中。概念上我們仍然有建立一個持有其他列表的列表,但此實作更像是將項目接著另一個項目排列,而非包含另一個在內。

我們可以改變範例 15-2 的 List 枚舉定義以及範例 15-3 List 的使用方式,將其寫入範例 15-5,這次就能夠編譯過了:

檔案名稱:src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

範例 15-5:使用 Box<T> 定義的 List 就有已知大小

Cons 變體需要的大小為 i32 加上儲存 box 指標的空間。Nil 變體沒有儲存任何數值,所以它需要的空間比 Cons 變體少。現在我們知道任何 List 數值會佔的空間都是一個 i32 加上 box 指標的大小。透過使用 box,我們打破了無限遞迴,所以編譯器可以知道儲存一個 List 數值所需要的大小。圖示 15-2 顯示了 Cons 變體看起來的樣子。

A finite Cons list

圖示 15-2:不再是無限大小的 List,因為其 Cons 存的是 Box

Boxes 只提供了間接儲存與堆積分配,它們沒有其他任何特殊功能,比如我們等下就會看到的其他智慧指標型別。它們也沒有任何因這些特殊功能產生的額外效能開銷,所以它們很適合用於像是 cons list 這種我們只需要間接儲存的場合。我們在第十七章還會再介紹到更多 box 的使用情境。

Box<T> 型別是智慧指標是因為它有實作 Deref 特徵,讓 Box<T> 的數值可以被視為引用所使用。當 Box<T> 數值離開作用域時,該 box 指向的堆積資料也會被清除,因為其有 Drop 特徵實作。讓我們來探討這兩種特徵的細節吧。這兩種特徵對於本章將會討論的其他智慧指標型別所提供的功能,將會更加重要。

透過 Deref 特徵將智慧指標視為一般引用

實作 Deref 特徵讓你可以自訂解引用運算子(dereference operator) * 的行為(這不是相乘或全域運算子)。透過這種方式實作 Deref 的智慧指標可以被視為正常引用來對待,這樣操作引用的程式碼也能用在智慧指標中。

讓我們先看解引用運算子如何在正常引用中使用。然後我們會嘗試定義一個行為類似 Box<T> 的自定型別,並看看為何解引用運算子無法像引用那樣用在我們新定義的型別。我們將會探討如何實作 Deref 特徵使智慧指標能像類似引用的方式運作。接著我們會看看 Rust 的強制解引用(deref coercion)功能並瞭解它如何處理引用與智慧指標。

注意:我們即將定義的 MyBox<T> 型別與真正的 Box<T> 有一項很大的差別,就是我們的版本不會將其資料儲存在堆積上。我們在此例會專注在 Deref 上,所以資料實際上儲存在何處,並沒有比指標相關行為來得重要。

使用解引用運算子追蹤指標的數值

一般的引用是一種指標,其中一種理解指標的方式是看成一個會指向存於某處數值的箭頭。在範例 15-6 中我們建立了數值 i32 的引用,接著使用解引用運算子來追蹤引用的資料:

檔案名稱:src/main.rs

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

範例 15-6:使用解引用運算子來追蹤數值 i32 的引用

變數 x 存有 i32 數值 5。我們將 y 設置為 x 的引用。我們可以判定 x 等於 5。不過要是我們想要判定 y 數值的話,我們需要使用 *y 來追蹤引用指向的數值(也就是解引用)。一旦我們解引用 y,我們就能取得 y 指向的整數數值並拿來與 5 做比較。

如果我們嘗試寫說 assert_eq!(5, y); 的話,我們會得到此編譯錯誤:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in a macro outside of the current crate (in Nightly builds, run with -Z external-macro-backtrace for more info)

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example`.

To learn more, run the command again with --verbose.

比較一個數字與一個數字的引用是不允許的,因為它們是不同的型別。我們必須使用解引用運算子來追蹤其指向的數值。

像引用般使用 Box<T>

我們將範例 15-6 的引用改用 Box<T> 重寫。解引用運算子的使用方式如範例 15-7 所示:

檔案名稱:src/main.rs

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

範例 15-7:對 Box<i32> 使用解引用運算子

範例 15-7 與範例 15-6 唯一的差別在於這裡我們設置 y 為一個指向 x 的拷貝數值的 box 實例,而不是指向 x 數值的引用。在最後的判定中,我們可以對 box 的指標使用解引用運算子,跟我們對當 y 還是引用時所做的動作一樣。接下來,我們要來探討 Box<T> 有何特別之處,讓我們可以對自己定義的 box 型別也可以使用解引用運算子。

定義我們自己的智慧指標

讓我們定義一個與標準函式庫所提供的 Box<T> 型別類似的智慧指標,並看看智慧指標預設行為與引用有何不同。然後我們就會來看能夠使用解引用運算子的方式。

Box<T> 本質上就是定義成只有一個元素的元組結構體,所以範例 15-8 用相同的方式來定義 MyBox<T>。我們也定義了 new 函式來對應於 Box<T>new 函式。

檔案名稱:src/main.rs

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

範例 15-8:定義 MyBox<T> 型別

我們定義了一個結構體叫做 MyBox 並宣告一個泛型參數 T,因為我們希望我們的型別能存有任何型別的數值。MyBox 是個只有一個元素型別為 T 的元組結構體。MyBox::new 函式接受一個參數型別為 T 並回傳存有該數值的 MyBox 實例。

讓我們將範例 15-7 的 main 函式加到範例 15-8 並改成使用我們定義的 MyBox<T> 型別而不是原本的 Box<T>。範例 15-9 的程式碼無法編譯,因為 Rust 不知道如何解引用MyBox

檔案名稱:src/main.rs

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

範例 15-9:嘗試像使用 Box<T> 和引用一樣的方式來使用 MyBox<T>

以下是編譯結果出現的錯誤:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

error: aborting due to previous error

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example`.

To learn more, run the command again with --verbose.

我們的 MyBox<T> 型別無法解引用因為我們還沒有對我們的型別實作該能力。要透過 * 運算子來解引用的話,我們要實作 Deref 特徵。

透過實作 Deref 特徵來將一個型別能像引用般對待

如同第十章講過的,要實作一個特徵的話,我們需要提供該特徵要求的方法實作。標準函式庫所提供的 Deref 特徵要求我們實作一個方法叫做 deref,這會借用 self 並回傳內部資料的引用。範例 15-10 包含了對 MyBox 定義加上的 Deref 實作:

檔案名稱:src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

範例 15-10:對 MyBox<T> 實作 Deref

type Target = T; 語法定義了一個供 Deref 特徵使用的關聯型別。關聯型別與宣告泛型參數會有一點差別,但是你現在先不用擔心它們,我們會在第十九章深入探討。

我們對 deref 的方法本體加上 &self.0deref 就可以回傳一個引用讓我們可以使用 * 運算子取得數值。範例 15-9 的 main 函式現在對 MyBox<T> 數值的 * 呼叫就可以編譯了,而且判定也會通過!

沒有 Deref 特徵的話,編譯器只能解引用 & 的引用。deref 方法讓編譯器能夠從任何有實作 Deref 的型別呼叫 deref 方法取得 & 引用,而它就可以進一步解引用獲取數值。

當我們在範例 15-9 中輸入 *y 時,Rust 背後實際上是執行此程式碼:

*(y.deref())

Rust 將 * 運算子替換為方法 deref 的呼叫再進行普通的解引用,所以我們不必煩惱何時該或不該呼叫 deref 方法。此 Rust 特性讓我們可以對無論是引用或是有實作 Deref 的型別都能寫出一致的程式碼。

deref 方法會回傳一個數值引用,以及括號外要再加上普通解引用的原因,都是因為所有權系統。如果 deref 方法直接回傳數值而非引用數值的話,該數值就會移出 self。我們不希望在此例或是大多數使用解引用運算子的場合下,取走 MyBox<T> 內部數值的所有權。

注意到每次我們在程式碼中使用 * 時,* 運算子被替換成 deref 方法呼叫,然後再呼叫 * 剛好一次。因為 * 運算子不會被無限遞迴替換,我們能剛好取得型別 i32 並符合範例 15-9 assert_eq! 中與 5 的判定。

函式與方法的隱式強制解引用

*強制解引用(Deref coercion)是一個 Rust 針對函式或方法的引數的便利設計。強制解引用只適用於有實作 Deref 特徵的型別。強制解引用會將一個型別轉換成另一個型別的引用。舉例來說,強制解引用可以轉換 &String&str,因為 String 有實作 Deref 特徵並能用它來回傳 str。當我們將某個特定型別數值的引用作為引數傳入一個函式或方法,但該函式或方法所定義的參數卻不相符時,強制解引用就會自動發生,並進行一系列的 deref 方法呼叫,將我們提供的型別轉換成參數所需的型別。

Rust 會加入強制解引用的原因是因為程式設計師在寫函式與方法呼叫時,就不必加上許多顯式引用 & 與解引用 *。強制解引用還讓我們可以寫出能同時用於引用或智慧指標的程式碼。

為了展示強制解引用,讓我們使用範例 15-8 定義的 MyBox<T> 型別以及範例 15-10 所加上的 Deref 實作。範例 15-11 有個函式定義且有個字串切片作為參數:

檔案名稱:src/main.rs

fn hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {}

範例 15-11:hello 函式且有參數 name 其型別為 &str

我們可以使用字串切片作為引數來呼叫函式 hello,比方說 hello("Rust");。強制解引用讓我們可以透過 MyBox<String> 型別數值的引用來呼叫 hello,如範例 15-12 所示:

檔案名稱:src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

範例 15-12:利用強制解引用透過 MyBox<String> 數值的引用來呼叫 hello

我們在此使用 &m 作為引數來呼叫函式 hello,這是 MyBox<String> 數值的引用。因為我們在範例 15-10 有對 MyBox<T> 實作 Deref 特徵,Rust 可以呼叫 deref&MyBox<String> 變成 &String。標準函式庫對 String 也有實作 Deref 並會回傳字串切片,這可以在 Deref 的 API 技術文件中看到。所以 Rust 會在呼叫 deref 一次來將 &String 變成 &str,這樣就符合函式 hello 的定義了。

如果 Rust 沒有實作強制解引用的話,我們就得用範例 15-13 的方式才能辦到範例 15-12 使用型別 &MyBox<String> 的數值來呼叫 hello 的動作。

檔案名稱:src/main.rs

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

範例 15-13:如果 Rust 沒有強制解引用,我們就得這樣寫程式碼

(*m) 會將 MyBox<String> 解引用成 String,然後 &[..] 會從 String 中取得等於整個字串的字串切片,這就符合 hello 的簽名。沒有強制解引用的程式碼就難以閱讀、寫入或是理解,因為有太多的符號參雜其中。強制解引用能讓 Rust 自動幫我們做這些轉換。

當某型別有定義 Deref 特徵時,Rust 會分析該型別並重複使用 Deref::deref 直到能取得與參數型別相符的引用。Deref::deref 需要呼叫的次數會在編譯時期插入,所以使用強制解引用沒有任何的執行時開銷!

強制解引用如何處理可變性

類似於你如何使用 the Deref 特徵來覆蓋不可變引用的 * 運算子,你也可以使用 DerefMut 特徵來覆蓋可變引用的 * 運算子。

當 Rust 發現型別與特徵實作符合以下三種情況時,它就會進行強制解引用:

  • &T&UT: Deref<Target=U>
  • &mut T&mut UT: DerefMut<Target=U>
  • &mut T&UT: Deref<Target=U>

前兩個除了可變性之外是相同的。第一個情況表示如果你有個 &TT 有實作 Deref 到某個型別 U,你就可以直接得到 &U。第二種情況指的的則是對可變引用的強制解引用。

第三種情況比較棘手:Rust 也能強制將可變引用轉為一個不可變引用。但反過來是不可行的:不可變引用永遠不可能強制解引用成可變引用。由於借用規則,如果你有個可變引用,該可變引用必須是該資料的唯一引用(不然程式無法編譯)。轉換可變引用成不可變引用不會破壞借用規則。轉換不可變引用成可變引用的話,就需要此不可變引用是該資料的唯一引用,但借用規則無法做擔保。因此 Rust 無法將不可變引用轉換成可變引用。

透過 Drop 特徵執行清除程式碼

第二個對智慧指標模式很重要的特徵是 Drop,這讓你能自訂數值離開作用域時的行為。你可以對任何型別實作 Drop 特徵,然後你指定的程式碼就能用來釋放像是檔案或網路連線等資源。我們在智慧指標的章節介紹 Drop 的原因是因為 Drop 特徵的功能幾乎永遠會在實作智慧指標時用到。舉例來說,當 Box<T> 離開作用域時,它會釋放該 box 在堆積上指向的記憶體空間。

在某些語言中,當程式設計師使用完智慧指標的實例後,每次都得呼叫釋放記憶體與資源的程式碼。如果他們忘記的話,系統可能就會過載並崩潰。在 Rust 中你可以對數值離開作用域時指定一些程式碼,然後編譯器就會自動插入此程式碼。所以你就不用每次在特定型別實例使用完時,在程式的每個地方都寫上清理程式碼。而且你還不會泄漏資源!

透過實作 Drop 特徵我們可以指定當數值離開作用域時要執行的程式碼。Drop 特徵會要求我們實作一個方法叫做 drop,這會取得 self 的可變引用。為了觀察 Rust 何時會呼叫 drop,讓我們先用 println! 陳述式實作 drop

範例 15-14 的結構體 CustomSmartPointer 只有一個功能那就是在實例離開作用域時印出 Dropping CustomSmartPointer!。此範例能夠展示 Rust 何時會執行 drop 函式。

檔案名稱:src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("釋放 CustomSmartPointer 的資料 `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("我的東東"),
    };
    let d = CustomSmartPointer {
        data: String::from("其他東東"),
    };
    println!("CustomSmartPointers 建立完畢。");
}

範例 15-14:CustomSmartPointer 結構體實作了會放置清理程式碼的 Drop 特徵

Drop 特徵包含在 prelude 中,所以我們不需要特地引入作用域。我們對 CustomSmartPointer 實作 Drop 特徵並提供會呼叫 println!drop 方法實作。drop 的函式本體用來放置你想要在型別實例離開作用域時執行的邏輯。我們在此印出一些文字來展示 Rust 如何呼叫 drop

main 中,我們建立了兩個 CustomSmartPointer 實例並印出 CustomSmartPointers 建立完畢。在 main 結尾,我們的 CustomSmartPointer 實例會離開作用域,然後 Rust 就會呼叫我們放在 drop 方法的程式碼,也就是印出我們的最終訊息。注意到我們不需要顯式呼叫 drop 方法。

當我們執行此程式時,我們會看到以下輸出:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers 建立完畢。
釋放 CustomSmartPointer 的資料 `其他東東`!
釋放 CustomSmartPointer 的資料 `我的東東`!

當我們的實例離開作用域時,Rust 會自動呼叫 drop,呼叫我們指定的程式碼。變數會以與建立時相反的順序被釋放,所以 d 會在 c 之前被釋放。此範例給了我們一個觀察 drop 如何執行的視覺化指引,通常你會指定該型別所需的清除程式碼,而不是印出訊息。

透過 std::mem::drop 提早釋放數值

不幸的是,我們無法直接了當地取消自動 drop 的功能。停用 drop 通常是不必要的,整個 Drop 的目的本來就是要能自動處理。不過有些時候你可能會想要提早清除數值。其中一個例子是使用智慧指標來管理鎖:你可能會想要強制呼叫 drop 方法來釋放鎖,好讓作用域中的其他程式碼可以取得該鎖。Rust 不會讓你手動呼叫 Drop 特徵的 drop 方法。不過如果你想要一個數值在離開作用域前就被釋放的話,你可以使用標準函式庫提供的 std::mem::drop 函式來呼叫。

如果我們嘗試修改範例 15-14 的 main 函式來手動呼叫 Drop 特徵的 drop 方法,如範例 15-15 所示,我們會得到編譯錯誤:

檔案名稱:src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("釋放 CustomSmartPointer 的資料 `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("某些資料"),
    };
    println!("CustomSmartPointer 建立完畢。");
    c.drop();
    println!("CustomSmartPointer 在 main 結束前就被釋放了。");
}

範例 15-15:嘗試呼叫 Drop 特徵的 drop 方法來手動提早清除

當我們嘗試編譯此程式碼,我們會獲得以下錯誤:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed

error: aborting due to previous error

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example`.

To learn more, run the command again with --verbose.

此錯誤訊息表示我們不允許顯式呼叫 drop。錯誤訊息使用了一個術語解構子(destructor),這是通用程式設計術語中表達會清除實例的函式。解構子對應的術語就是建構子(constructor),這會建立實例。Rust 中的 drop 函式就是一種特定的解構子。

Rust 不讓我們顯式呼叫 drop,因為 Rust 還是會在 main 結束時自動呼叫 drop。這樣可能會導致重複釋放(double free)的錯誤,因為 Rust 可能會嘗試清除相同的數值兩次。

當數值離開作用域時我們無法停用自動插入的 drop,而且我們無法顯式呼叫 drop 方法,所以如果我必須強制讓一個數值提早清除的話,我們可以用 std::mem::drop 函式。

std::mem::drop 函式不同於 Drop 中的 drop 方法,我們將我們想要強制提早釋放的數值作為引數傳遞並呼叫它。此函式也包含在 prelude,所以我們可以修改範例 15-15 的 main 來呼叫 drop 函式,如範例 15-16 所示:

檔案名稱:src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("釋放 CustomSmartPointer 的資料 `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("某些資料"),
    };
    println!("CustomSmartPointer 建立完畢。");
    drop(c);
    println!("CustomSmartPointer 在 main 結束前就被釋放了。");
}

範例 15-16:在數值離開作用域前呼叫 std::mem::drop 來顯示釋放數值

執行此程式會印出以下結果:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer 建立完畢。
釋放 CustomSmartPointer 的資料 `某些資料`!
CustomSmartPointer 在 main 結束前就被釋放了。

釋放 CustomSmartPointer 的資料 `某些資料`! 這段文字會在 CustomSmartPointer 建立完畢。CustomSmartPointer 在 main 結束前就被釋放了。 文字之間印出,顯示 drop 方法會在那時釋放 c

你可以在許多地方使用 Drop 特徵實作所指定的程式碼,讓清除實例變得方便又安全。舉例來說,你可以用它來建立你自己的記憶體分配器!透過 Drop 特徵與 Rust 的所有權系統,你不必去擔心要記得清理,因為 Rust 會自動處理。

你也不必擔心會意外清理仍在使用的數值:所有權系統會確保所有引用永遠有效,並確保當數值不再需要使用時只會呼叫 drop 一次。

現在你看過 Box<T> 以及一些智慧指標的特性了,讓我們來看看一些其他定義在標準函式庫的智慧指標吧。

Rc<T> 引用計數智慧指標

在大多數的場合,所有權是很明確的:你能確切知道哪些變數擁有哪些數值。然而還是有些情況會需要讓一個數值能有數個擁有者。舉例來說,在圖資料結構中數個邊可能就會指向同個節點,而該節點概念上就被所有指向它的邊所擁有。節點直到沒有任何邊指向它時才會被清除。

要有多重所有權的話,Rust 有一個型別叫做 Rc<T>,這是引用計數(reference counting)的簡寫。Rc<T> 型別會追蹤引用其數值的數量來決定該數值是否還在使用中。如果數值沒有任何引用的話,該數值就可以被清除,因為不會產生任何無效引用。

想像 Rc<T> 是個在客廳裡的電視,當有人進入客廳要看電視時,它們就會打開它。其他人也能進來觀看電視。當最後一個人離開客廳時,它們會關掉電視,因為沒有任何人會再看了。如果當其他人還在看電視時,有人關掉了它,其他在看電視的人肯定會生氣。

Rc<T> 型別的使用時機在於當我們想要在堆積上分配一些資料給程式中數個部分讀取,但是我們無法在編譯時期決定哪個部分會最後一個結束使用數值的部分。如果我們知道哪個部分會最後結束的話,我們可以將那個部分作為資料的擁有者就好,然後正常的所有權規則就會在編譯時生效。

注意到 Rc<T> 只適用於單一執行緒(single-threaded)的場合。當我們在第十六章討論並行(concurrency)時,我們會介紹如何在多執行緒程式達成引用計數。

使用 Rc<T> 來分享資料

讓我們回顧範例 15-5 的 cons list 範例。回想一下我們當時適用 Box<T> 定義。這次我們會建立兩個列表,它們會同時共享第三個列表的所有權。概念上會如圖示 15-3 所示:

Two lists that share ownership of a third list

圖示 15-3:兩個列表 bc 共享第三個列表 a 的所有權

我們會建立列表 a 來包含 5 然後是 10。然後我們會在建立兩個列表:b 以 3 為開頭而 c 以 4 為開頭。bc 列表會同時連接包含 5 與 10 的第一個列表 a。換句話說,兩個列表會同時共享包含 5 與 10 的第一個列表。

嘗試使用 Box<T> 來定義這種情境的 List 的話會無法成功,如範例 15-17 所示:

檔案名稱:src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

範例 15-17:展示我們無法用 Box<T>