Rust 程式設計語言

title-page.md
commit a2bd349f8654f5c45ad1f07394225f946954b8ef

Steve Klabnik 和 Carol Nichols,以及來自 Rust 社區的貢獻(Rust 中文社區翻譯)

本書假設你使用 Rust 1.41.0 或更新的版本,且在所有的項目中的 Cargo.toml 文件中通過 edition="2018" 採用 Rust 2018 Edition 規範。請查看 第一章的 “安裝” 部分 了解如何安裝和升級 Rust,並查看新的 附錄 E 了解版本相關的訊息。

Rust 程式設計語言的 2018 Edition 包含許多的改進使得 Rust 更為工程化並更為容易學習。本書的此次疊代包括了很多反映這些改進的修改:

  • 第七章 “使用包、Crate 和模組管理不斷增長的項目” 基本上被重寫了。模組系統和路徑(path)的工作方式變得更為一致。
  • 第十章新增了名為 “trait 作為參數” 和 “返回實現了 trait 的類型” 部分來解釋新的 impl Trait 語法。
  • 第十一章新增了一個名為 “在測試中使用 Result<T, E>” 的部分來展示如何使用 ? 運算符來編寫測試
  • 第十九章的 “高級生命週期” 部分被移除了,因為編譯器的改進使得其內容變得更為少見。
  • 之前的附錄 D “宏” 得到了補充,包括了過程宏並移動到了第十九章的 “宏” 部分。
  • 附錄 A “關鍵字” 也介紹了新的原始標識符(raw identifiers)功能,這使得採用 2015 Edition 編寫的 Rust 代碼可以與 2018 Edition 互通。
  • 現在的附錄 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 發行。

前言

foreword.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

雖然不是那麼明顯,但 Rust 程式設計語言的本質在於 賦能empowerment):無論你現在編寫的是何種代碼,Rust 能讓你在更為廣泛的程式領域走得更遠,寫出自信。

比如,“系統層面”(“systems-level”)的工作,涉及記憶體管理、數據表示和並發等底層細節。從傳統角度來看,這是一個神秘的程式領域,只為浸淫多年的極少數人所觸及,也只有他們能避開那些惡名昭彰的陷阱。即使謹慎的實踐者,亦唯恐代碼出現漏洞、崩潰或損壞。

Rust 破除了這些障礙,其消除了舊的陷阱並提供了伴你一路同行的友好、精良的工具。想要 “深入” 底層控制的程式設計師可以使用 Rust,無需冒著常見的崩潰或安全漏洞的風險,也無需學習時常改變的工具鏈的最新知識。其語言本身更是被設計為自然而然的引導你編寫出在運行速度和記憶體使用上都十分高效的可靠代碼。

已經在從事編寫底層代碼的程式設計師可以使用 Rust 來提升抱負。例如,在 Rust 中引入並行是相對低風險的操作:編譯器會為你捕獲經典的錯誤。同時你可以自信的採取更為積極的最佳化,而不會意外引入崩潰或漏洞。

但 Rust 並不局限於底層系統編程。其表現力和工效足以令人愉悅的編寫出 CLI 應用、web server 和很多其他類型的代碼 —— 在本書中你會看到兩個簡單範例。使用 Rust 能將你在一個領域中學習的技能延伸到另一個領域;你可以學習 Rust 來編寫 web 應用,接著將同樣的技能應用到你的 Raspberry Pi(樹莓派)上。

本書全面介紹了 Rust 為用戶賦予的能力。其內容平易近人,致力於幫助你提升 Rust 的知識,並且提升你作為程式設計師整體的理解與自信。那麼讓我們準備深入學習 Rust 吧(打開新世界的大門 :)) —— 歡迎加入 Rust 社區!

— Nicholas Matsakis 和 Aaron Turon

介紹

ch00-00-introduction.md
commit 0aa307c7d79d2cbf83cdf5d47780b2904e9cb03f

注意:本書的版本與出版的 The Rust Programming Language 和電子版的 No Starch Press 一致

歡迎閱讀 “Rust 程式設計語言”,一本介紹 Rust 的書。Rust 程式設計語言能幫助你編寫更快、更可靠的軟體。在程式語言設計中,高層工程學和底層控制往往不能兼得;Rust 則試圖挑戰這一矛盾。透過權衡強大的技術能力與優秀的開發體驗,Rust 允許你控制底層細節(比如記憶體使用),並免受以往進行此類控制所經受的所有煩惱。

誰會使用 Rust

Rust 因多種原因適用於很多開發者。讓我們討論幾個最重要的群體。

開發者團隊

Rust 被證明是可用於大型的、擁有不同層次系統編程知識的開發者團隊間協作的高效工具。底層代碼中容易出現種種隱晦的 bug,在其他程式語言中,只能透過大量的測試和經驗豐富的開發者細心的代碼評審來捕獲它們。在 Rust 中,編譯器充當了守門員的角色,它拒絕編譯存在這些難以捕獲的 bug 的代碼,這其中包括並發 bug。通過與編譯器合作,團隊將更多的時間聚焦在程序邏輯上,而不是追蹤 bug。

Rust 也為系統編程世界帶來了現代化的開發工具:

  • Cargo,內建的依賴管理器和構建工具,它能輕鬆增加、編譯和管理依賴,並使其在 Rust 生態系統中保持一致。
  • Rustfmt 確保開發者遵循一致的代碼風格。
  • Rust Language Server 為集成開發環境(IDE)提供了強大的代碼補全和內聯錯誤訊息功能。

透過使用 Rust 生態系統中的這些和其他工具,開發者可以在編寫系統層面代碼時保持高生產力。

學生

Rust 適用於學生和有興趣學習系統概念的人。通過 Rust,很多人已經了解了操作系統開發等主題。社區非常歡迎和樂於解答學生們的問題。通過本書的努力,Rust 團隊希望系統概念能被更多人了解,特別是編程新手。

公司

數以百計的公司,無論規模大小,都在生產中使用Rust來完成各種任務。這些任務包括命令行工具、web 服務、DevOps 工具、嵌入式設備、影音分析與轉檔、加密貨幣(cryptocurrencies)、生物訊息學(bioinformatics)、搜尋引擎、物聯網(internet of things, IOT)程序、機器學習,甚至還包括 Firefox 瀏覽器的大部分內容。

開源開發者

Rust 適用於希望構建 Rust 程式語言、社區、開發工具和庫的開發者。我們很樂意您為 Rust 語言做貢獻。

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

Rust 適用於追求程式語言的速度與穩定性的開發者。所謂速度,是指你用 Rust 開發出的程序運行速度,以及 Rust 提供的程序開發速度。Rust 的編譯器檢查確保了增加功能和重構代碼時的穩定性。這與缺少這些檢查的語言形成鮮明對比,開發者通常害怕修改那些脆弱的遺留代碼。力求零開銷抽象(zero-cost abstractions),把高級的特性編譯成底層的代碼,這樣寫起來很快,運行起來也很快,Rust 致力於使安全的代碼也同樣快速。

Rust 語言也希望能支持很多其他用戶,這裡提及的只是最大的利益相關者。總的來講,Rust 最重要的目標是消除數十年來程式設計師不得不做的權衡:安全 生產力,速度 人機交互的順暢度(ergonomics)。請嘗試 Rust,看看這個選擇是否適合你。

本書是寫給誰的

本書假設你已經使用其他程式語言編寫過代碼,但並不假設你使用的是何種語言。我們嘗試使這些參考資料能廣泛的適用於來自很多不同編程背景的開發者。我們不會花費很多時間討論編程 什麼或者如何理解它。如果編程對於你來說是完全陌生的,你最好先閱讀專門介紹編程的書籍。

如何閱讀本書

總體來說,本書假設你會從頭到尾順序閱讀。稍後的章節建立在之前章節概念的基礎上,同時之前的章節可能不會深入討論某個主題的細節;通常稍後的章節會重新討論這些主題。

你會在本書中發現兩類章節:概念章節和項目章節。在概念章節中,我們學習 Rust 的某個方面。在項目章節中,我們應用目前所學的知識一同構建小的程序。第二、十二和二十章是項目章節;其餘都是概念章節。

第一章介紹如何安裝 Rust,如何編寫 “Hello, world!” 程序,以及如何使用 Rust 的包管理器和構建工具 Cargo。第二章是 Rust 語言的實戰介紹。我們會站在較高的層次介紹一些概念,而將詳細的介紹放在稍後的章節中。如果你希望立刻就動手實踐一下,第二章正好適合你。開始閱讀時,你甚至可能希望略過第三章,它介紹了 Rust 中類似其他程式語言中的功能,並直接閱讀第四章學習 Rust 的所有權系統。然而,如果你是特別重視細節的學習者,並傾向於在繼續之前學習每一個細節,你可能希望略過第二章並直接閱讀第三章,並在想要構建項目來實踐這些細節時再回來閱讀第二章。

第五章討論結構體和方法,第六章介紹枚舉、match 表達式和 if let 控制流結構。在 Rust 中,你將使用結構體和枚舉創建自訂類型。

第七章你會學習 Rust 的模組系統和私有性規則來組織代碼和公有應用程式介面(Application Programming Interface, API)。第八章討論了一些標準庫提供的常見集合數據結構,比如 可變長數組(vector)、字串和哈希 map。第九章探索了 Rust 的錯誤處理哲學和技術。

第十章深入介紹泛型、trait 和生命週期,他們提供了定義出適用於多種類型的代碼的能力。第十一章全部關於測試,即使 Rust 有安全保證,也需要測試確保程序邏輯正確。第十二章,我們構建了屬於自己的在文件中搜尋文本的命令行工具 grep 的子集功能實現。為此會利用之前章節討論的很多概念。

第十三章探索了閉包和疊代器:Rust 中來自函數式程式語言的功能。第十四章會更深層次的理解 Cargo 並討論向他人分享庫的最佳實踐。第十五章討論標準庫提供的智慧指針以及啟用這些功能的 trait。

第十六章會學習不同的並發編程模型,並討論 Rust 如何助你無畏的編寫多執行緒程序。第十七章著眼於比較 Rust 風格與你可能熟悉的面向對象編程原則。

第十八章是關於模式和模式匹配的參考章節,它是在Rust程序中表達思想的有效方式。第十九章是一個高級主題大雜燴,包括 unsafe Rust、宏和更多關於生命週期、 trait、類型、函數和閉包的內容。

第二十章將會完成一個項目,我們會實現一個底層的、多執行緒的 web server!

最後是一些附錄,包含了一些關於語言的參考風格格式的實用訊息。附錄 A 介紹了 Rust 的關鍵字。附錄 B 介紹 Rust 的運算符和符號。附錄 C 介紹標準庫提供的派生 trait。附錄 D 涉及了一些有用的開發工具,附錄 E 介紹了 Rust 的不同版本。

怎樣閱讀本書都不會有任何問題:如果你希望略過一些內容,請繼續!如果你發現疑惑可能會再跳回之前的章節。請隨意閱讀。

學習 Rust 的過程中一個重要的部分是學習如何閱讀編譯器提供的錯誤訊息:它們會指導你編寫出能工作的代碼。為此,我們會提供很多不能編譯的範例代碼,以及各個情況下編譯器會展示的錯誤訊息。請注意如果隨便輸入並運行隨機的範例代碼,它們可能無法編譯!請確保閱讀任何你嘗試運行的範例周圍的內容,檢視他們是否有意寫錯。Ferris 也會幫助你區別那些有意無法工作的代碼:

Ferris意義
這些程式碼不能編譯!
這些程式碼會 panic!
這些程式碼塊包含不安全(unsafe)代碼。
這些程式碼不會產生期望的行為。

在大部分情況,我們會指引你將任何不能編譯的代碼糾正為正確版本。

原始碼

生成本書的原始碼可以在 GitHub 上找到。

譯者註:本譯本的 GitHub 倉庫,歡迎 Issue 和 PR :)

入門指南

ch01-00-getting-started.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

讓我們開始 Rust 之旅!有很多內容需要學習,但每次旅程總有起點。在本章中,我們會討論:

  • 在 Linux、macOS 和 Windows 上安裝 Rust
  • 編寫一個列印 Hello, world! 的程序
  • 使用 Rust 的包管理器和構建系統 cargo

安裝

ch01-01-installation.md
commit bad683bb7dcd06ef7f5f83bad3a25b1706b7b230

第一步是安裝 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 上,前往 https://www.rust-lang.org/install.html 並按照說明安裝 Rust。在安裝過程的某個步驟,你會收到一個訊息說明為什麼需要安裝 Visual Studio 2013 或更新版本的 C++ build tools。獲取這些 build tools 最方便的方法是安裝 Build Tools for Visual Studio 2019。當被問及需要安裝什麼的時候請確保選擇 ”C++ build tools“,並確保包括了 Windows 10 SDK 和英文語言包(English language pack)組件。

本書的餘下部分會使用能同時執行於 cmd.exe 和 PowerShell 的命令。如果存在特定差異,我們會解釋使用哪一個。

更新和卸載

通過 rustup 安裝了 Rust 之後,很容易更新到最新版本。在 shell 中運行如下更新腳本:

$ rustup update

為了卸載 Rust 和 rustup,在 shell 中運行如下卸載腳本:

$ rustup self uninstall

故障排除(Troubleshooting)

要檢查是否正確安裝了 Rust,打開 shell 並運行如下行:

$ rustc --version

你應能看到已發布的最新穩定版的版本號、提交哈希和提交日期,顯示為如下格式:

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

如果出現這些內容,Rust 就安裝成功了!如果並沒有看到這些訊息,並且使用的是 Windows,請檢查 Rust 是否位於 %PATH% 系統變數中。如果一切正確但 Rust 仍不能使用,有許多地方可以求助。最簡單的是 位於 Rust 官方 Discord 上的 #beginners 頻道。在這裡你可以和其他 Rustacean(Rust 用戶的稱號,有自嘲意味)聊天並尋求幫助。其它厲害的資源包括用戶論壇Stack Overflow

譯者:恭喜入坑!(此處應該有掌聲!)

本地文件

安裝程式也自帶一份文件的本地拷貝,可以離線閱讀。運行 rustup doc 在瀏覽器中查看本地文件。

任何時候,如果你不確定標準庫中的類型或函數的用途和用法,請查閱應用程式介面(application programming interface,API)文件!

Hello, World!

ch01-02-hello-world.md
commit f63a103270ec8416899675a9cdb1c5cf6d77a498

既然安裝好了 Rust,我們來編寫第一個 Rust 程序。當學習一門新語言的時候,使用該語言在螢幕上列印 Hello, world! 是一項傳統,我們將沿用這一傳統!

注意:本書假設你熟悉基本的命令行操作。Rust 對於你的編輯器、工具,以及代碼位於何處並沒有特定的要求,如果你更傾向於使用集成開發環境(IDE),而不是命令行,請儘管使用你喜歡的 IDE。目前很多 IDE 已經不同程度的支持 Rust;查看 IDE 文件了解更多細節。最近,Rust 團隊已經致力於提供強大的 IDE 支持,而且進展飛速!

創建項目目錄

首先創建一個存放 Rust 代碼的目錄。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 個空格,而不是 1 個製表符(tab)。

第二,println! 調用了一個 Rust 宏(macro)。如果是調用函數,則應輸入 println(沒有!)。我們將在第十九章詳細討論宏。現在你只需記住,當看到符號 ! 的時候,就意味著調用的是宏而不是普通函數。

第三,"Hello, world!" 是一個字串。我們把這個字串作為一個參數傳遞給 println!,字串將被列印到螢幕上。

第四,該行以分號結尾(;),這代表一個表達式的結束和下一個表達式的開始。大部分 Rust 代碼行以分號結尾。

編譯和運行是彼此獨立的步驟

你剛剛運行了一個新創建的程序,那麼讓我們檢查此過程中的每一個步驟。

在運行 Rust 程序之前,必須先使用 Rust 編譯器編譯它,即輸入 rustc 命令並傳入源檔案名稱,如下:

$ rustc main.rs

如果你有 C 或 C++ 背景,就會發現這與 gccclang 類似。編譯成功後,Rust 會輸出一個二進位制的可執行文件。

在 Linux、macOS 或 Windows 的 PowerShell 上,在 shell 中輸入 ls 命令可以看見這個可執行文件。在 Linux 和 macOS,你會看到兩個文件。在 Windows PowerShell 中,你會看到同使用 CMD 相同的三個文件。

$ ls
main  main.rs

在 Windows 的 CMD 上,則輸入如下內容:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

這展示了副檔名為 .rs 的源文件、可執行文件(在 Windows 下是 main.exe,其它平台是 main),以及當使用 CMD 時會有一個包含除錯訊息、副檔名為 .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 實現(運行時環境,VM)。不過在這些語言中,只需要一句命令就可以編譯和運行程序。這一切都是語言設計上的權衡取捨。

僅僅使用 rustc 編譯簡單程序是沒問題的,不過隨著項目的增長,你可能需要管理你項目的各個方面,並讓代碼易於分享。接下來,我們要介紹一個叫做 Cargo 的工具,它會幫助你編寫真實世界中的 Rust 程序。

Hello, Cargo!

ch01-03-hello-cargo.md
commit f63a103270ec8416899675a9cdb1c5cf6d77a498

Cargo 是 Rust 的構建系統和包管理器。大多數 Rustacean 們使用 Cargo 來管理他們的 Rust 項目,因為它可以為你處理很多任務,比如構建代碼、下載依賴庫並編譯這些庫。(我們把代碼所需要的庫叫做 依賴dependencies))。

最簡單的 Rust 程序,比如我們剛剛編寫的,沒有任何依賴。所以如果使用 Cargo 來構建 “Hello, world!” 項目,將只會用到 Cargo 構建代碼的那部分功能。在編寫更複雜的 Rust 程序時,你將添加依賴項,如果使用 Cargo 啟動項目,則添加依賴項將更容易。

由於絕大多數 Rust 項目使用 Cargo,本書接下來的部分假設你也使用 Cargo。如果使用 “安裝” 部分介紹的官方安裝包的話,則自帶了 Cargo。如果透過其他方式安裝的話,可以在終端輸入如下命令檢查是否安裝了 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 目錄,以及位於 src 目錄中的 main.rs 文件。它也在 hello_cargo 目錄初始化了一個 git 倉庫,以及一個 .gitignore 文件。

注意:Git 是一個常用的版本控制系統(version control system, VCS)。可以通過 --vcs 參數使 cargo new 切換到其它版本控制系統(VCS),或者不使用 VCS。運行 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 的內容

這個文件使用 TOML (Tom's Obvious, Minimal Language) 格式,這是 Cargo 配置文件的格式。

第一行,[package],是一個片段(section)標題,表明下面的語句用來配置一個包。隨著我們在這個文件增加更多的訊息,還將增加其他片段(section)。

接下來的四行設置了 Cargo 編譯程序所需的配置:項目的名稱、版本、作者以及要使用的 Rust 版本。Cargo 從環境中獲取你的名字和 email 訊息,所以如果這些訊息不正確,請修改並保存此文件。附錄 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、license 訊息、配置文件和其他跟代碼無關的文件。使用 Cargo 幫助你保持項目乾淨整潔,一切井井有條。

如果沒有使用 Cargo 開始項目,比如我們創建的 Hello,world! 項目,可以將其轉化為一個 Cargo 項目。將代碼放入 src 目錄,並創建一個合適的 Cargo.toml 文件。

構建並運行 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 # 或者在 Windows 下為 .\target\debug\hello_cargo.exe
Hello, world!

如果一切順利,終端上應該會列印出 Hello, world!。首次運行 cargo build 時,也會使 Cargo 在項目根目錄創建一個新文件: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 buildcargo check 構建項目。
  • 可以使用 cargo run 一步構建並運行項目。
  • 有別於將構建結果放在與原始碼相同的目錄,Cargo 會將其放到 target/debug 目錄。

使用 Cargo 的一個額外的優點是,不管你使用什麼操作系統,其命令都是一樣的。所以從現在開始本書將不再為 Linux 和 macOS 以及 Windows 提供相應的命令。

發布(release)構建

當項目最終準備好發布時,可以使用 cargo build --release 來最佳化編譯項目。這會在 target/release 而不是 target/debug 下生成可執行文件。這些最佳化可以讓 Rust 代碼運行的更快,不過啟用這些最佳化也需要消耗更長的編譯時間。這也就是為什麼會有兩種不同的配置:一種是為了開發,你需要經常快速重新構建;另一種是為用戶構建最終程序,它們不會經常重新構建,並且希望程序運行得越快越好。如果你在測試代碼的運行時間,請確保運行 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
  • 打開本地安裝的文件
  • 直接通過 rustc 編寫並運行 Hello, world! 程序
  • 使用 Cargo 創建並運行新項目

是時候透過構建更實質性的程序來熟悉讀寫 Rust 代碼了。所以在第二章我們會構建一個猜猜看遊戲程序。如果你更願意從學習 Rust 常用的程式概念開始,請閱讀第三章,接著再回到第二章。

編寫 猜猜看 遊戲

ch02-00-guessing-game-tutorial.md
commit c427a676393d001edc82f1a54a3b8026abcf9690

讓我們一起動手完成一個項目,來快速上手 Rust!本章將介紹 Rust 中一些常用概念,並透過真實的程序來展示如何運用它們。你將會學到 letmatch、方法、關聯函數、外部 crate 等知識!後續章節會深入探討這些概念的細節。在這一章,我們將做基礎練習。

我們會實現一個經典的新手編程問題:猜猜看遊戲。它是這麼工作的:程序將會隨機生成一個 1 到 100 之間的隨機整數。接著它會請玩家猜一個數並輸入,然後提示猜測是大了還是小了。如果猜對了,它會列印祝賀訊息並退出。

準備一個新項目

要創建一個新項目,進入第一章中創建的 projects 目錄,使用 Cargo 新建一個項目,如下:

$ cargo new guessing_game
$ cd guessing_game

第一個命令,cargo new,它獲取項目的名稱(guessing_game)作為第一個參數。第二個命令進入到新創建的項目目錄。

看看生成的 Cargo.toml 文件:

檔案名: Cargo.toml

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

[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.50 secs
     Running `target/debug/guessing_game`
Hello, world!

當你需要在項目中快速疊代時,run 命令就能派上用場,正如我們在這個遊戲項目中做的,在下一次疊代之前快速測試每一次疊代。

重新打開 src/main.rs 文件。我們將會在這個文件中編寫全部的代碼。

處理一次猜測

猜猜看程序的第一部分請求和處理用戶輸入,並檢查輸入是否符合預期的格式。首先,允許玩家輸入猜測。在 src/main.rs 中輸入範例 2-1 中的代碼。

檔案名: src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

範例 2-1:獲取用戶猜測並列印的代碼

這些程式碼包含很多訊息,我們一行一行地過一遍。為了獲取用戶輸入並列印結果作為輸出,我們需要將 io(輸入/輸出)庫引入當前作用域。io 庫來自於標準庫(也被稱為 std):

use std::io;

默認情況下,Rust 將 prelude 模組中少量的類型引入到每個程序的作用域中。如果需要的類型不在 prelude 中,你必須使用 use 語句顯式地將其引入作用域。std::io 庫提供很多有用的功能,包括接收用戶輸入的功能。

如第一章所提及,main 函數是程序的入口點:

fn main() {

fn 語法聲明了一個新函數,() 表明沒有參數,{ 作為函數體的開始。

第一章也提及了 println! 是一個在螢幕上列印字串的宏:

println!("Guess the number!");

println!("Please input your guess.");

這些程式碼僅僅列印提示,介紹遊戲的內容然後請求用戶輸入。

使用變數儲存值

接下來,創建一個儲存用戶輸入的地方,像這樣:

let mut guess = String::new();

現在程序開始變得有意思了!這一小行程式碼發生了很多事。注意這是一個 let 語句,用來創建 變數variable)。這裡是另外一個例子:

let foo = bar;

這行程式碼新建了一個叫做 foo 的變數並把它綁定到值 bar 上。在 Rust 中,變數預設是不可變的。我們將會在第三章的 “變數與可變性” 部分詳細討論這個概念。下面的例子展示了如何在變數名前使用 mut 來使一個變數可變:

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

注意:// 語法開始一個注釋,持續到行尾。Rust 忽略注釋中的所有內容,第三章將會詳細介紹注釋。

讓我們回到猜猜看程序中。現在我們知道了 let mut guess 會引入一個叫做 guess 的可變變數。等號(=)的右邊是 guess 所綁定的值,它是 String::new 的結果,這個函數會返回一個 String 的新實例。String 是一個標準庫提供的字串類型,它是 UTF-8 編碼的可增長文本塊。

::new 那一行的 :: 語法表明 newString 類型的一個 關聯函數associated function)。關聯函數是針對類型實現的,在這個例子中是 String,而不是 String 的某個特定實例。一些語言中把它稱為 靜態方法static method)。

new 函數創建了一個新的空字串,你會發現很多類型上有 new 函數,因為它是創建類型實例的慣用函數名。

總結一下,let mut guess = String::new(); 這一行創建了一個可變變數,當前它綁定到一個新的 String 空實例上。

回憶一下,我們在程序的第一行使用 use std::io; 從標準庫中引入了輸入/輸出功能。現在調用 io 庫中的函數 stdin

io::stdin().read_line(&mut guess)
    .expect("Failed to read line");

如果程序的開頭沒有 use std::io 這一行,可以把函數調用寫成 std::io::stdinstdin 函數返回一個 std::io::Stdin 的實例,這代表終端標準輸入句柄的類型。

代碼的下一部分,.read_line(&mut guess),調用 read_line 方法從標準輸入句柄獲取用戶輸入。我們還向 read_line() 傳遞了一個參數:&mut guess

read_line 的工作是,無論用戶在標準輸入中鍵入什麼內容,都將其存入一個字串中,因此它需要字串作為參數。這個字串參數應該是可變的,以便 read_line 將用戶輸入附加上去。

& 表示這個參數是一個 引用reference),它允許多處代碼訪問同一處數據,而無需在記憶體中多次拷貝。引用是一個複雜的特性,Rust 的一個主要優勢就是安全而簡單的操縱引用。完成當前程序並不需要了解如此多細節。現在,我們只需知道它像變數一樣,預設是不可變的。因此,需要寫成 &mut guess 來使其可變,而不是 &guess。(第四章會更全面的解釋引用。)

使用 Result 類型來處理潛在的錯誤

我們還沒有完全分析完這行程式碼。雖然這是單獨一行程式碼,但它是邏輯行(雖然換行了但仍是語句)的一部分。後一部分是這個方法:

.expect("Failed to read line");

當使用 .foo() 語法調用方法時,透過換行加縮進來把長行拆開是明智的。我們完全可以這樣寫:

io::stdin().read_line(&mut guess).expect("Failed to read line");

不過,過長的行難以閱讀,所以最好拆開來寫,兩個方法調用占兩行。現在來看看這行程式碼做了什麼。

之前提到了 read_line 將用戶輸入附加到傳遞給它的字串中,不過它也返回一個值——在這個例子中是 io::Result。Rust 標準庫中有很多叫做 Result 的類型:一個通用的 Result 以及在子模組中的特化版本,比如 io::Result

Result 類型是 枚舉enumerations,通常也寫作 enums。枚舉類型持有固定集合的值,這些值被稱為枚舉的 成員variants)。第六章將介紹枚舉的更多細節。

Result 的成員是 OkErrOk 成員表示操作成功,內部包含成功時產生的值。Err 成員則意味著操作失敗,並且包含失敗的前因後果。

這些 Result 類型的作用是編碼錯誤處理訊息。Result 類型的值,像其他類型一樣,擁有定義於其上的方法。io::Result 的實例擁有 expect 方法。如果 io::Result 實例的值是 Errexpect 會導致程序崩潰,並顯示當做參數傳遞給 expect 的訊息。如果 read_line 方法返回 Err,則可能是來源於底層操作系統錯誤的結果。如果 io::Result 實例的值是 Okexpect 會獲取 Ok 中的值並原樣返回。在本例中,這個值是用戶輸入到標準輸入中的位元組數。

如果不調用 expect,程序也能編譯,不過會出現一個警告:

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

Rust 警告我們沒有使用 read_line 的返回值 Result,說明有一個可能的錯誤沒有處理。

消除警告的正確做法是實際編寫錯誤處理代碼,不過由於我們就是希望程序在出現問題時立即崩潰,所以直接使用 expect。第九章會學習如何從錯誤中恢復。

使用 println! 占位符列印值

除了位於結尾的大括號,目前為止就只有這一行程式碼值得討論一下了,就是這一行:

println!("You guessed: {}", guess);

這行程式碼列印存儲用戶輸入的字串。第一個參數是格式化字串,裡面的 {} 是預留在特定位置的占位符。使用 {} 也可以列印多個值:第一對 {} 使用格式化字串之後的第一個值,第二對則使用第二個值,依此類推。調用一次 println! 列印多個值看起來像這樣:


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

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

這行程式碼會列印出 x = 5 and y = 10

測試第一部分代碼

讓我們來測試一下猜猜看遊戲的第一部分。使用 cargo run 運行:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

至此為止,遊戲的第一部分已經完成:我們從鍵盤獲取輸入並列印了出來。

生成一個秘密數字

接下來,需要生成一個秘密數字,好讓用戶來猜。秘密數字應該每次都不同,這樣重複玩才不會乏味;範圍應該在 1 到 100 之間,這樣才不會太困難。Rust 標準庫中尚未包含隨機數功能。然而,Rust 團隊還是提供了一個 rand crate

使用 crate 來增加更多功能

記住,crate 是一個 Rust 代碼包。我們正在構建的項目是一個 二進位制 crate,它生成一個可執行文件。 rand crate 是一個 庫 crate,庫 crate 可以包含任意能被其他程序使用的代碼。

Cargo 對外部 crate 的運用是其真正閃光的地方。在我們使用 rand 編寫程式碼之前,需要修改 Cargo.toml 文件,引入一個 rand 依賴。現在打開這個文件並在底部的 [dependencies] 片段標題之下添加:

檔案名: Cargo.toml

[dependencies]

rand = "0.5.5"

Cargo.toml 文件中,標題以及之後的內容屬同一個片段,直到遇到下一個標題才開始新的片段。[dependencies] 片段告訴 Cargo 本項目依賴了哪些外部 crate 及其版本。本例中,我們使用語義化版本 0.5.5 來指定 rand crate。Cargo 理解語義化版本(Semantic Versioning)(有時也稱為 SemVer),這是一種定義版本號的標準。0.5.5 事實上是 ^0.5.5 的簡寫,它表示 “任何與 0.5.5 版本公有 API 相相容的版本”。

現在,不修改任何代碼,構建項目,如範例 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.53 s

範例 2-2: 將 rand crate 添加為依賴之後運行 cargo build 的輸出

可能會出現不同的版本號(多虧了語義化版本,它們與代碼是相容的!),同時顯示順序也可能會有所不同。

現在我們有了一個外部依賴,Cargo 從 registry 上獲取所有包的最新版本訊息,這是一份來自 Crates.io 的數據拷貝。Crates.io 是 Rust 生態環境中的開發者們向他人貢獻 Rust 開源項目的地方。

在更新完 registry 後,Cargo 檢查 [dependencies] 片段並下載缺失的 crate 。本例中,雖然只聲明了 rand 一個依賴,然而 Cargo 還是額外獲取了 libcrand_core 的拷貝,因為 rand 依賴 libcrand_core 來正常工作。下載完成後,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 有一個機制來確保任何人在任何時候重新構建代碼,都會產生相同的結果:Cargo 只會使用你指定的依賴版本,除非你又手動指定了別的。例如,如果下週 rand crate 的 0.5.6 版本出來了,它修復了一個重要的 bug,同時也含有一個會破壞代碼運行的缺陷,這時會發生什麼事呢?

這個問題的答案是 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 crate 發布了兩個新版本,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

如果想要使用 0.6.0 版本的 rand 或是任何 0.6.x 系列的版本,必須像這樣更新 Cargo.toml 文件:

[dependencies]

rand = "0.6.0"

下一次執行 cargo build 時,Cargo 會從 registry 更新可用的 crate,並根據你指定的新版本重新計算。

第十四章會講到 Cargo 及其生態系統 的更多內容,不過目前你只需要了解這麼多。通過 Cargo 復用庫文件非常容易,因此 Rustacean 能夠編寫出由很多包組裝而成的更輕巧的項目。

生成一個隨機數

你已經把 rand crate 添加到 Cargo.toml 了,讓我們開始使用 rand。下一步是更新 src/main.rs,如範例 2-3 所示。

檔案名: src/main.rs

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

fn main() {
    println!("Guess the number!");

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

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

範例 2-3:添加生成隨機數的代碼

首先,我們新增了一行 useuse rand::RngRng 是一個 trait,它定義了隨機數生成器應實現的方法,想使用這些方法的話,此 trait 必須在作用域中。第十章會詳細介紹 trait。

接下來,我們在中間還新增加了兩行。rand::thread_rng 函數提供實際使用的隨機數生成器:它位於當前執行執行緒的本地環境中,並從操作系統獲取 seed。接下來,調用隨機數生成器的 gen_range 方法。這個方法由剛才引入到作用域的 Rng trait 定義。gen_range 方法獲取兩個數字作為參數,並生成一個範圍在兩者之間的隨機數。它包含下限但不包含上限,所以需要指定 1101 來請求一個 1 和 100 之間的數。

注意:你不可能憑空就知道應該 use 哪個 trait 以及該從 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.53 secs
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

你應該能得到不同的隨機數,同時它們應該都是在 1 和 100 之間的。做得漂亮!

比較猜測的數字和秘密數字

現在有了用戶輸入和一個隨機數,我們可以比較它們。這個步驟如範例 2-4 所示。注意這段代碼還不能通過編譯,我們稍後會解釋。

檔案名: src/main.rs

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

fn main() {

    // ---snip---

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

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

新代碼的第一行是另一個 use,從標準庫引入了一個叫做 std::cmp::Ordering 的類型。同 Result 一樣, Ordering 也是一個枚舉,不過它的成員是 LessGreaterEqual。這是比較兩個值時可能出現的三種結果。

接著,底部的五行新代碼使用了 Ordering 類型,cmp 方法用來比較兩個值並可以在任何可比較的值上調用。它獲取一個被比較值的引用:這裡是把 guesssecret_number 做比較。 然後它會返回一個剛才通過 use 引入作用域的 Ordering 枚舉的成員。使用一個 match 表達式,根據對 guesssecret_number 調用 cmp 返回的 Ordering 成員來決定接下來做什麼。

一個 match 表達式由 分支(arms) 構成。一個分支包含一個 模式pattern)和表達式開頭的值與分支模式相匹配時應該執行的代碼。Rust 獲取提供給 match 的值並挨個檢查每個分支的模式。match 結構和模式是 Rust 中強大的功能,它體現了代碼可能遇到的多種情形,並幫助你確保沒有遺漏處理。這些功能將分別在第六章和第十八章詳細介紹。

讓我們看看使用 match 表達式的例子。假設用戶猜了 50,這時隨機生成的秘密數字是 38。比較 50 與 38 時,因為 50 比 38 要大,cmp 方法會返回 Ordering::GreaterOrdering::Greatermatch 表達式得到的值。它檢查第一個分支的模式,Ordering::LessOrdering::Greater並不匹配,所以它忽略了這個分支的代碼並來到下一個分支。下一個分支的模式是 Ordering::Greater正確 匹配!這個分支關聯的代碼被執行,在螢幕列印出 Too big!match 表達式就此終止,因為該場景下沒有檢查最後一個分支的必要。

然而,範例 2-4 的代碼並不能編譯,可以嘗試一下:

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

error: aborting due to previous error
Could not compile `guessing_game`.

錯誤的核心表明這裡有 不匹配的類型mismatched types)。Rust 有一個靜態強類型系統,同時也有類型推斷。當我們寫出 let guess = String::new() 時,Rust 推斷出 guess 應該是 String 類型,並不需要我們寫出類型。另一方面,secret_number,是數字類型。幾個數字類型擁有 1 到 100 之間的值:32 位數字 i32;32 位無符號數字 u32;64 位數字 i64 等等。Rust 預設使用 i32,所以它是 secret_number 的類型,除非增加類型訊息,或任何能讓 Rust 推斷出不同數值類型的訊息。這裡錯誤的原因在於 Rust 不會比較字串類型和數字類型。

所以我們必須把從輸入中讀取到的 String 轉換為一個真正的數字類型,才好與秘密數字進行比較。這可以通過在 main 函數體中增加如下兩行程式碼來實現:

檔案名: src/main.rs

// --snip--

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse()
        .expect("Please type a number!");

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

這兩行新代碼是:

let guess: u32 = guess.trim().parse()
    .expect("Please type a number!");

這裡創建了一個叫做 guess 的變數。不過等等,不是已經有了一個叫做 guess 的變數了嗎?確實如此,不過 Rust 允許用一個新值來 隱藏shadowguess 之前的值。這個功能常用在需要轉換值類型之類的場景。它允許我們復用 guess 變數的名字,而不是被迫創建兩個不同變數,諸如 guess_strguess 之類。(第三章會介紹 shadowing 的更多細節。)

我們將 guess 綁定到 guess.trim().parse() 表達式上。表達式中的 guess 是包含輸入的原始 String 類型。String 實例的 trim 方法會去除字串開頭和結尾的空白字元。u32 只能由數字字元轉換,不過用戶必須輸入 enter 鍵才能讓 read_line 返回,然而用戶按下 enter 鍵時,會在字串中增加一個換行(newline)符。例如,用戶輸入 5 並按下 enterguess 看起來像這樣:5\n\n 代表 “換行”,確認鍵。trim 方法消除 \n,只留下 5

字串的 parse 方法 將字串解析成數字。因為這個方法可以解析多種數字類型,因此需要告訴 Rust 具體的數字類型,這裡通過 let guess: u32 指定。guess 後面的冒號(:)告訴 Rust 我們指定了變數的類型。Rust 有一些內建的數字類型;u32 是一個無符號的 32 位整型。對於不大的正整數來說,它是不錯的類型,第三章還會講到其他數字類型。另外,程序中的 u32 註解以及與 secret_number 的比較,意味著 Rust 會推斷出 secret_number 也是 u32 類型。現在可以使用相同類型比較兩個值了!

parse 調用很容易產生錯誤。例如,字串中包含 A👍%,就無法將其轉換為一個數字。因此,parse 方法返回一個 Result 類型。像之前 “使用 Result 類型來處理潛在的錯誤” 討論的 read_line 方法那樣,再次按部就班的用 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.43 secs
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

漂亮!即便是在猜測之前添加了空格,程序依然能判斷出用戶猜測了 76。多運行程序幾次,輸入不同的數字來檢驗不同的行為:猜一個正確的數字,猜一個過大的數字和猜一個過小的數字。

現在遊戲已經大體上能玩了,不過用戶只能猜一次。增加一個循環來改變它吧!

使用循環來允許多次猜測

loop 關鍵字創建了一個無限循環。將其加入後,用戶可以反覆猜測:

檔案名: src/main.rs

// --snip--

    println!("The secret number is: {}", secret_number);

    loop {
        println!("Please input your guess.");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

如上所示,我們將提示用戶猜測之後的所有內容放入了循環。確保 loop 循環中的代碼多縮進四個空格,再次運行程序。注意這裡有一個新問題,因為程序忠實地執行了我們的要求:永遠地請求另一個猜測,用戶好像無法退出啊!

用戶總能使用 ctrl-c 終止程式。不過還有另一個方法跳出無限循環,就是 “比較猜測與秘密數字” 部分提到的 parse:如果用戶輸入的答案不是一個數字,程序會崩潰。用戶可以利用這一點來退出,如下所示:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:785
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/guess` (exit code: 101)

輸入 quit 確實退出了程序,同時其他任何非數字輸入也一樣。然而,這並不理想,我們想要當猜測正確的數字時遊戲能自動退出。

猜測正確後退出

讓我們增加一個 break 語句,在用戶猜對時退出遊戲:

檔案名: src/main.rs

// --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

通過在 You win! 之後增加一行 break,用戶猜對了神秘數字後會退出循環。退出循環也意味著退出程序,因為循環是 main 的最後一部分。

處理無效輸入

為了進一步改善遊戲性,不要在用戶輸入非數字時崩潰,需要忽略非數字,讓用戶可以繼續猜測。可以通過修改 guessString 轉化為 u32 那部分代碼來實現,如範例 2-5 所示:

檔案名: src/main.rs

// --snip--

io::stdin().read_line(&mut guess)
    .expect("Failed to read line");

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

println!("You guessed: {}", guess);

// --snip--

範例 2-5: 忽略非數字的猜測並重新請求數字而不是讓程序崩潰

expect 調用換成 match 語句,是從遇到錯誤就崩潰轉換到真正處理錯誤的慣用方法。須知 parse 返回一個 Result 類型,而 Result 是一個擁有 OkErr 成員的枚舉。這裡使用的 match 表達式,和之前處理 cmp 方法返回 Ordering 時用的一樣。

如果 parse 能夠成功的將字串轉換為一個數字,它會返回一個包含結果數字的 Ok。這個 Ok 值與 match 第一個分支的模式相匹配,該分支對應的動作返回 Ok 值中的數字 num,最後如願變成新創建的 guess 變數。

如果 parse 能將字串轉換為一個數字,它會返回一個包含更多錯誤訊息的 ErrErr 值不能匹配第一個 match 分支的 Ok(num) 模式,但是會匹配第二個分支的 Err(_) 模式:_ 是一個通配符值,本例中用來匹配所有 Err 值,不管其中有何種訊息。所以程序會執行第二個分支的動作,continue 意味著進入 loop 的下一次循環,請求另一個猜測。這樣程序就有效的忽略了 parse 可能遇到的所有錯誤!

現在萬事俱備,只需運行 cargo run

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

太棒了!再有最後一個小的修改,就能完成猜猜看遊戲了:還記得程序依然會列印出秘密數字。在測試時還好,但正式發布時會毀了遊戲。刪掉列印秘密數字的 println!。範例 2-6 為最終代碼:

檔案名: src/main.rs

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

fn main() {
    println!("Guess the number!");

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

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

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

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

範例 2-6:猜猜看遊戲的完整代碼

總結

此時此刻,你順利完成了猜猜看遊戲。恭喜!

本項目通過動手實踐,向你介紹了 Rust 新概念:letmatch、方法、關聯函數、使用外部 crate 等等,接下來的幾章,你會繼續深入學習這些概念。第三章介紹大部分程式語言都有的概念,比如變數、數據類型和函數,以及如何在 Rust 中使用它們。第四章探索所有權(ownership),這是一個 Rust 同其他語言大不相同的功能。第五章討論結構體和方法的語法,而第六章側重解釋枚舉。

常見編程概念

ch03-00-common-programming-concepts.md
commit 1f49356cb21cbc27bc5359bfe655d26757d4b137

本章介紹一些幾乎所有程式語言都有的概念,以及它們在 Rust 中是如何工作的。很多程式語言的核心概念都是共通的,本章中展示的概念都不是 Rust 所特有的,不過我們會在 Rust 上下文中討論它們,並解釋使用這些概念的慣例。

具體來說,我們將會學習變數、基本類型、函數、注釋和控制流。每一個 Rust 程序中都會用到這些基礎知識,提早學習這些概念會讓你在起步時就打下堅實的基礎。

關鍵字

Rust 語言有一組保留的 關鍵字keywords),就像大部分語言一樣,它們只能由語言本身使用。記住,你不能使用這些關鍵字作為變數或函數的名稱。大部分關鍵字有特殊的意義,你將在 Rust 程序中使用它們完成各種任務;一些關鍵字目前沒有相應的功能,是為將來可能添加的功能保留的。可以在附錄 A 中找到關鍵字的列表。

變數和可變性

ch03-01-variables-and-mutability.md
commit d69b1058c660abfe1d274c58d39c06ebd5c96c47

第二章中提到過,變數預設是不可改變的(immutable)。這是推動你以充分利用 Rust 提供的安全性和簡單並發性來編寫程式碼的眾多方式之一。不過,你仍然可以使用可變變數。讓我們探討一下 Rust 為何及如何鼓勵你利用不可變性,以及何時你會選擇不使用不可變性。

當變數不可變時,一旦值被綁定一個名稱上,你就不能改變這個值。為了對此進行說明,使用 cargo new variables 命令在 projects 目錄生成一個叫做 variables 的新項目。

接著,在新建的 variables 目錄,打開 src/main.rs 並將代碼替換為如下代碼,這些程式碼還不能編譯:

檔案名: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

保存並使用 cargo run 運行程序。應該會看到一條錯誤訊息,如下輸出所示:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

這個例子展示了編譯器如何幫助你找出程序中的錯誤。雖然編譯錯誤令人沮喪,但那只是表示程序不能安全的完成你想讓它完成的工作;並 不能 說明你不是一個好程式設計師!經驗豐富的 Rustacean 們一樣會遇到編譯錯誤。

錯誤訊息指出錯誤的原因是 不能對不可變變數 x 二次賦值cannot assign twice to immutable variable x),因為你嘗試對不可變變數 x 賦第二個值。

在嘗試改變預設為不可變的值時,產生編譯時錯誤是很重要的,因為這種情況可能導致 bug。如果一部分代碼假設一個值永遠也不會改變,而另一部分代碼改變了這個值,第一部分代碼就有可能以不可預料的方式運行。不得不承認這種 bug 的起因難以跟蹤,尤其是第二部分代碼只是 有時 會改變值。

Rust 編譯器保證,如果聲明一個值不會變,它就真的不會變。這意味著當閱讀和編寫程式碼時,不需要追蹤一個值如何和在哪可能會被改變,從而使得代碼易於推導。

不過可變性也是非常有用的。變數只是默認不可變;正如在第二章所做的那樣,你可以在變數名之前加 mut 來使其可變。除了允許改變值之外,mut 向讀者表明了其他代碼將會改變這個變數值的意圖。

例如,讓我們將 src/main.rs 修改為如下代碼:

檔案名: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

現在運行這個程序,出現如下內容:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

通過 mut,允許把綁定到 x 的值從 5 改成 6。在一些情況下,你會想用可變變數,因為與只用不可變變數相比,它會讓代碼更容易編寫。

除了防止出現 bug 外,還有很多地方需要權衡取捨。例如,使用大型數據結構時,適當地使用可變變數,可能比複製和返回新分配的實例更快。對於較小的數據結構,總是創建新實例,採用更偏向函數式的程式風格,可能會使代碼更易理解,為可讀性而犧牲性能或許是值得的。

變數和常量的區別

不允許改變值的變數,可能會使你想起另一個大部分程式語言都有的概念:常量constants)。類似於不可變變數,常量是綁定到一個名稱的不允許改變的值,不過常量與變數還是有一些區別。

首先,不允許對常量使用 mut。常量不光默認不能變,它總是不能變。

聲明常量使用 const 關鍵字而不是 let,並且 必須 註明值的類型。在下一部分,“數據類型” 中會介紹類型和類型註解,現在無需關心這些細節,記住總是標註類型即可。

常量可以在任何作用域中聲明,包括全局作用域,這在一個值需要被很多部分的代碼用到時很有用。

最後一個區別是,常量只能被設置為常量表達式,而不能是函數調用的結果,或任何其他只能在運行時計算出的值。

這是一個聲明常量的例子,它的名稱是 MAX_POINTS,值是 100,000。(Rust 常量的命名規範是使用下劃線分隔的大寫字母單詞,並且可以在數字字面值中插入下劃線來提升可讀性):


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

在聲明它的作用域之中,常量在整個程序生命週期中都有效,這使得常量可以作為多處代碼使用的全局範圍的值,例如一個遊戲中所有玩家可以獲取的最高分或者光速。

將遍布於應用程式中的寫死值聲明為常量,能幫助後來的代碼維護人員了解值的意圖。如果將來需要修改寫死值,也只需修改匯聚於一處的寫死值。

隱藏(Shadowing)

正如在第二章猜猜看遊戲的 “比較猜測的數字和秘密數字” 中所講,我們可以定義一個與之前變數同名的新變數,而新變數會 隱藏 之前的變數。Rustacean 們稱之為第一個變數被第二個 隱藏 了,這意味著使用這個變數時會看到第二個值。可以用相同變數名稱來隱藏一個變數,以及重複使用 let 關鍵字來多次隱藏,如下所示:

檔案名: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    let x = x * 2;

    println!("The value of x is: {}", x);
}

這個程序首先將 x 綁定到值 5 上。接著通過 let x = 隱藏 x,獲取初始值並加 1,這樣 x 的值就變成 6 了。第三個 let 語句也隱藏了 x,將之前的值乘以 2x 最終的值是 12。運行這個程序,它會有如下輸出:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/variables`
The value of x is: 12

隱藏與將變數標記為 mut 是有區別的。當不小心嘗試對變數重新賦值時,如果沒有使用 let 關鍵字,就會導致編譯時錯誤。透過使用 let,我們可以用這個值進行一些計算,不過計算完之後變數仍然是不變的。

mut 與隱藏的另一個區別是,當再次使用 let 時,實際上創建了一個新變數,我們可以改變值的類型,但復用這個名字。例如,假設程序請求用戶輸入空格字元來說明希望在文本之間顯示多少個空格,然而我們真正需要的是將輸入存儲成數位(多少個空格):


#![allow(unused)]
fn main() {
let spaces = "   ";
let spaces = spaces.len();
}

這裡允許第一個 spaces 變數是字串類型,而第二個 spaces 變數,它是一個恰巧與第一個變數同名的嶄新變數,是數字類型。隱藏使我們不必使用不同的名字,如 spaces_strspaces_num;相反,我們可以復用 spaces 這個更簡單的名字。然而,如果嘗試使用 mut,將會得到一個編譯時錯誤,如下所示:

let mut spaces = "   ";
spaces = spaces.len();

這個錯誤說明,我們不能改變變數的類型:

error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected &str, found usize
  |
  = note: expected type `&str`
             found type `usize`

現在我們已經了解了變數如何工作,讓我們看看變數可以擁有的更多數據類型。

數據類型

ch03-02-data-types.md
commit 6598d3abac05ed1d0c45db92466ea49346d05e40

在 Rust 中,每一個值都屬於某一個 數據類型data type),這告訴 Rust 它被指定為何種數據,以便明確數據處理方式。我們將看到兩類數據類型子集:標量(scalar)和複合(compound)。

記住,Rust 是 靜態類型statically typed)語言,也就是說在編譯時就必須知道所有變數的類型。根據值及其使用方式,編譯器通常可以推斷出我們想要用的類型。當多種類型均有可能時,比如第二章的 “比較猜測的數字和秘密數字” 使用 parseString 轉換為數字時,必須增加類型註解,像這樣:


#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

這裡如果不添加類型註解,Rust 會顯示如下錯誤,這說明編譯器需要我們提供更多訊息,來了解我們想要的類型:

error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^
  |         |
  |         cannot infer type for `_`
  |         consider giving `guess` a type

你會看到其它數據類型的各種類型註解。

標量類型

標量scalar)類型代表一個單獨的值。Rust 有四種基本的標量類型:整型、浮點型、布爾類型和字元類型。你可能在其他語言中見過它們。讓我們深入了解它們在 Rust 中是如何工作的。

整型

整數 是一個沒有小數部分的數字。我們在第二章使用過 u32 整數類型。該類型聲明表明,它關聯的值應該是一個占據 32 比特位的無符號整數(有符號整數類型以 i 開頭而不是 u)。表格 3-1 展示了 Rust 內建的整數類型。在有符號列和無符號列中的每一個變體(例如,i16)都可以用來聲明整數值的類型。

表格 3-1: Rust 中的整型

長度有符號無符號
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

每一個變體都可以是有符號或無符號的,並有一個明確的大小。有符號無符號 代表數字能否為負值,換句話說,數字是否需要有一個符號(有符號數),或者永遠為正而不需要符號(無符號數)。這有點像在紙上書寫數字:當需要考慮符號的時候,數字以加號或減號作為前綴;然而,可以安全地假設為正數時,加號前綴通常省略。有符號數以補碼形式(two’s complement representation) 存儲。

每一個有符號的變體可以儲存包含從 -(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 中的任何一種形式編寫數字字面值。注意除 byte 以外的所有數字字面值允許使用類型後綴,例如 57u8,同時也允許使用 _ 做為分隔符以方便讀數,例如1_000

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

數字字面值例子
Decimal (十進位制)98_222
Hex (十六進位制)0xff
Octal (八進位制)0o77
Binary (二進位制)0b1111_0000
Byte (單位元組字元)(僅限於u8)b'A'

那麼該使用哪種類型的數字呢?如果拿不定主意,Rust 的默認類型通常就很好,數字類型預設是 i32:它通常是最快的,甚至在 64 位系統上也是。isizeusize 主要作為某些集合的索引。

整型溢出

比方說有一個 u8 ,它可以存放從零到 255 的值。那麼當你將其修改為 256 時會發生什麼事呢?這被稱為 “整型溢出”(“integer overflow” ),關於這一行為 Rust 有一些有趣的規則。當在 debug 模式編譯時,Rust 檢查這類問題並使程序 panic,這個術語被 Rust 用來表明程序因錯誤而退出。第九章 panic! 與不可恢復的錯誤” 部分會詳細介紹 panic。

在 release 構建中,Rust 不檢測溢出,相反會進行一種被稱為二進位制補碼包裝(two’s complement wrapping)的操作。簡而言之,256 變成 0257 變成 1,依此類推。依賴整型溢出被認為是一種錯誤,即便可能出現這種行為。如果你確實需要這種行為,標準庫中有一個類型顯式提供此功能,Wrapping

浮點型

Rust 也有兩個原生的 浮點數floating-point numbers)類型,它們是帶小數點的數字。Rust 的浮點數類型是 f32f64,分別占 32 位和 64 位。默認類型是 f64,因為在現代 CPU 中,它與 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 表達式。在 “控制流”(“Control Flow”) 部分將介紹 if 表達式在 Rust 中如何工作。

字元類型

目前為止只使用到了數字,不過 Rust 也支持字母。Rust 的 char 類型是語言中最原生的字母類型,如下代碼展示了如何使用它。(注意 char 由單引號指定,不同於字串使用雙引號。)

檔案名: src/main.rs

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

Rust 的 char 類型的大小為四個位元組(four bytes),並代表了一個 Unicode 標量值(Unicode Scalar Value),這意味著它可以比 ASCII 表示更多內容。在 Rust 中,拼音字母(Accented letters),中文、日文、韓文等字元,emoji(繪文字)以及零長度的空白字元都是有效的 char 值。Unicode 標量值包含從 U+0000U+D7FFU+E000U+10FFFF 在內的值。不過,“字元” 並不是一個 Unicode 中的概念,所以人直覺上的 “字元” 可能與 Rust 中的 char 並不符合。第八章的 “使用字串存儲 UTF-8 編碼的文本” 中將詳細討論這個主題。

複合類型

複合類型Compound types)可以將多個值組合成一個類型。Rust 有兩個原生的複合類型:元組(tuple)和數組(array)。

元組類型

元組是一個將多個其他類型的值組合進一個複合類型的主要方式。元組長度固定:一旦聲明,其長度不會增大或縮小。

我們使用包含在圓括號中的逗號分隔的值列表來創建一個元組。元組中的每一個位置都有一個類型,而且這些不同值的類型也不必是相同的。這個例子中使用了可選的類型註解:

檔案名: src/main.rs

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

tup 變數綁定到整個元組上,因為元組是一個單獨的複合元素。為了從元組中獲取單個值,可以使用模式匹配(pattern matching)來解構(destructure)元組值,像這樣:

檔案名: src/main.rs

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

    let (x, y, z) = tup;

    println!("The value of y is: {}", y);
}

程序首先創建了一個元組並綁定到 tup 變數上。接著使用了 let 和一個模式將 tup 分成了三個不同的變數,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。

數組類型

另一個包含多個值的方式是 數組array)。與元組不同,數組中的每個元素的類型必須相同。Rust 中的數組與一些其他語言中的數組不同,因為 Rust 中的數組是固定長度的:一旦聲明,它們的長度不能增長或縮小。

Rust 中,數組中的值位於中括號內的逗號分隔的列表中:

檔案名: src/main.rs

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

當你想要在棧(stack)而不是在堆(heap)上為數據分配空間(第四章將討論棧與堆的更多內容),或者是想要確保總是有固定數量的元素時,數組非常有用。但是數組並不如 vector 類型靈活。vector 類型是標準庫提供的一個 允許 增長和縮小長度的類似數組的集合類型。當不確定是應該使用數組還是 vector 的時候,你可能應該使用 vector。第八章會詳細討論 vector。

一個你可能想要使用數組而不是 vector 的例子是,當程序需要知道一年中月份的名字時。程序不大可能會去增加或減少月份。這時你可以使用數組,因為我們知道它總是包含 12 個元素:


#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

可以像這樣編寫數組的類型:在方括號中包含每個元素的類型,後跟分號,再後跟數組元素的數量。


#![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!("The value of element is: {}", element);
}

使用 cargo run 運行程式碼後會產生如下結果:

$ cargo run
   Compiling arrays v0.1.0 (file:///projects/arrays)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     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` for a backtrace.

編譯並沒有產生任何錯誤,不過程序會出現一個 運行時runtime)錯誤並且不會成功退出。當嘗試用索引訪問一個元素時,Rust 會檢查指定的索引是否小於數組的長度。如果索引超出了數組長度,Rust 會 panic,這是 Rust 術語,它用於程序因為錯誤而退出的情況。

這是第一個在實戰中遇到的 Rust 安全原則的例子。在很多底層語言中,並沒有進行這類檢查,這樣當提供了一個不正確的索引時,就會訪問無效的記憶體。透過立即退出而不是允許記憶體訪問並繼續執行,Rust 讓你避開此類錯誤。第九章會討論更多 Rust 的錯誤處理。

函數

ch03-03-how-functions-work.md
commit 669a909a199bc20b913703c6618741d8b6ce1552

函數遍布於 Rust 代碼中。你已經見過語言中最重要的函數之一:main 函數,它是很多程序的入口點。你也見過 fn 關鍵字,它用來聲明新函數。

Rust 代碼中的函數和變數名使用 snake case 規範風格。在 snake case 中,所有字母都是小寫並使用下劃線分隔單詞。這是一個包含函數定義範例的程序:

檔案名: src/main.rs

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

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Rust 中的函數定義以 fn 開始並在函數名後跟一對圓括號。大括號告訴編譯器哪裡是函數體的開始和結尾。

可以使用函數名後跟圓括號來調用我們定義過的任意函數。因為程序中已定義 another_function 函數,所以可以在 main 函數中調用它。注意,原始碼中 another_function 定義在 main 函數 之後;也可以定義在之前。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.28 secs
     Running `target/debug/functions`
Hello, world!
Another function.

main 函數中的代碼會按順序執行。首先,列印 “Hello, world!” 訊息,然後調用 another_function 函數並列印它的訊息。

函數參數

函數也可以被定義為擁有 參數parameters),參數是特殊變數,是函數簽名的一部分。當函數擁有參數(形參)時,可以為這些參數提供具體的值(實參)。技術上講,這些具體值被稱為參數(arguments),但是在日常交流中,人們傾向於不區分使用 parameterargument 來表示函數定義中的變數或調用函數時傳入的具體值。

下面被重寫的 another_function 版本展示了 Rust 中參數是什麼樣的:

檔案名: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {}", x);
}

嘗試運行程序,將會輸出如下內容:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21 secs
     Running `target/debug/functions`
The value of x is: 5

another_function 的聲明中有一個命名為 x 的參數。x 的類型被指定為 i32。當將 5 傳給 another_function 時,println! 宏將 5 放入格式化字串中大括號的位置。

在函數簽名中,必須 聲明每個參數的類型。這是 Rust 設計中一個經過慎重考慮的決定:要求在函數定義中提供類型註解,意味著編譯器不需要你在代碼的其他地方註明類型來指出你的意圖。

當一個函數有多個參數時,使用逗號分隔,像這樣:

檔案名: src/main.rs

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

fn another_function(x: i32, y: i32) {
    println!("The value of x is: {}", x);
    println!("The value of y is: {}", 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.31 secs
     Running `target/debug/functions`
The value of x is: 5
The value of y is: 6

因為我們使用 5 作為 x 的值,6 作為 y 的值來調用函數,因此列印出這兩個字串及相應的值。

包含語句和表達式的函數體

函數體由一系列的語句和一個可選的結尾表達式構成。目前為止,我們只介紹了沒有結尾表達式的函數,不過你已經見過作為語句一部分的表達式。因為 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!("The value of y is: {}", 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!("The value of x is: {}", x);
}

five 函數中沒有函數調用、宏、甚至沒有 let 語句——只有數字 5。這在 Rust 中是一個完全有效的函數。注意,也指定了函數返回值的類型,就是 -> i32。嘗試運行程式碼;輸出應該看起來像這樣:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
     Running `target/debug/functions`
The value of x is: 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!("The value of x is: {}", x);
}

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

運行程式碼會列印出 The value of x is: 6。但如果在包含 x + 1 的行尾加上一個分號,把它從表達式變成語句,我們將看到一個錯誤。

檔案名: src/main.rs

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

    println!("The value of x is: {}", x);
}

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

運行程式碼會產生一個錯誤,如下:

error[E0308]: mismatched types
 --> src/main.rs:7:28
  |
7 |   fn plus_one(x: i32) -> i32 {
  |  ____________________________^
8 | |     x + 1;
  | |          - help: consider removing this semicolon
9 | | }
  | |_^ expected i32, found ()
  |
  = note: expected type `i32`
             found type `()`

主要的錯誤訊息,“mismatched types”(類型不匹配),揭示了代碼的核心問題。函數 plus_one 的定義說明它要返回一個 i32 類型的值,不過語句並不會返回值,使用空元組 () 表示不返回值。因為不返回值與函數定義相矛盾,從而出現一個錯誤。在輸出中,Rust 提供了一條訊息,可能有助於糾正這個錯誤:它建議刪除分號,這會修復這個錯誤。

注釋

ch03-04-comments.md
commit 75a77762ea2d2ab7fa1e9ef733907ed727c85651

所有程式設計師都力求使其代碼易於理解,不過有時還需要提供額外的解釋。在這種情況下,程式設計師在原始碼中留下記錄,或者 注釋comments),編譯器會忽略它們,不過閱讀代碼的人可能覺得有用。

這是一個簡單的注釋:


#![allow(unused)]
fn main() {
// hello, world
}

在 Rust 中,注釋必須以兩道斜槓開始,並持續到本行的結尾。對於超過一行的注釋,需要在每一行前都加上 //,像這樣:


#![allow(unused)]
fn main() {
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
}

注釋也可以在放在包含代碼的行的末尾:

檔案名: src/main.rs

fn main() {
    let lucky_number = 7; // I’m feeling lucky today
}

不過你更經常看到的是以這種格式使用它們,也就是位於它所解釋的代碼行的上面一行:

檔案名: src/main.rs

fn main() {
    // I’m feeling lucky today
    let lucky_number = 7;
}

Rust 還有另一種注釋,稱為文件注釋,我們將在 14 章的 “將 crate 發布到 Crates.io” 部分討論它。

控制流

ch03-05-control-flow.md
commit af34ac954a6bd7fc4a8bbcc5c9685e23c5af87da

根據條件是否為真來決定是否執行某些程式碼,以及根據條件是否為真來重複運行一段代碼是大部分程式語言的基本組成部分。Rust 代碼中最常見的用來控制執行流的結構是 if 表達式和循環。

if 表達式

if 表達式允許根據條件執行不同的代碼分支。你提供一個條件並表示 “如果條件滿足,運行這段代碼;如果條件不滿足,不運行這段代碼。”

projects 目錄新建一個叫做 branches 的項目,來學習 if 表達式。在 src/main.rs 文件中,輸入如下內容:

檔案名: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

所有的 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.31 secs
     Running `target/debug/branches`
condition was true

嘗試改變 number 的值使條件為 false 時看看會發生什麼事:

let number = 7;

再次運行程序並查看輸出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
condition was false

另外值得注意的是代碼中的條件 必須bool 值。如果條件不是 bool 值,我們將得到一個錯誤。例如,嘗試運行以下代碼:

檔案名: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

這裡 if 條件的值是 3,Rust 拋出了一個錯誤:

error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected bool, found integer
  |
  = note: expected type `bool`
             found type `{integer}`

這個錯誤表明 Rust 期望一個 bool 卻得到了一個整數。不像 Ruby 或 JavaScript 這樣的語言,Rust 並不會嘗試自動地將非布爾值轉換為布爾值。必須總是顯式地使用布爾值作為 if 的條件。例如,如果想要 if 代碼塊只在一個數字不等於 0 時執行,可以把 if 表達式修改成下面這樣:

檔案名: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

運行程式碼會列印出 number was something other than zero

使用 else if 處理多重條件

可以將 else if 表達式與 ifelse 組合來實現多重條件。例如:

檔案名: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

這個程序有四個可能的執行路徑。運行後應該能看到如下輸出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
number is divisible by 3

當執行這個程序時,它按順序檢查每個 if 表達式並執行第一個條件為真的代碼塊。注意即使 6 可以被 2 整除,也不會輸出 number is divisible by 2,更不會輸出 else 塊中的 number is not divisible by 4, 3, or 2。原因是 Rust 只會執行第一個條件為真的代碼塊,並且一旦它找到一個以後,甚至都不會檢查剩下的條件了。

使用過多的 else if 表達式會使代碼顯得雜亂無章,所以如果有多於一個 else if 表達式,最好重構代碼。為此,第六章會介紹一個強大的 Rust 分支結構(branching construct),叫做 match

let 語句中使用 if

因為 if 是一個表達式,我們可以在 let 語句的右側使用它,例如在範例 3-2 中:

檔案名: src/main.rs

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

    println!("The value of number is: {}", number);
}

範例 3-2:將 if 表達式的返回值賦給一個變數

number 變數將會綁定到表示 if 表達式結果的值上。運行這段代碼看看會出現什麼:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
     Running `target/debug/branches`
The value of number is: 5

記住,代碼塊的值是其最後一個表達式的值,而數字本身就是一個表達式。在這個例子中,整個 if 表達式的值取決於哪個代碼塊被執行。這意味著 if 的每個分支的可能的返回值都必須是相同類型;在範例 3-2 中,if 分支和 else 分支的結果都是 i32 整型。如果它們的類型不匹配,如下面這個例子,則會出現一個錯誤:

檔案名: src/main.rs

fn main() {
    let condition = true;

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

    println!("The value of number is: {}", number);
}

當編譯這段代碼時,會得到一個錯誤。ifelse 分支的值類型是不相容的,同時 Rust 也準確地指出在程序中的何處發現的這個問題:

error[E0308]: if and else have incompatible types
 --> src/main.rs:4:18
  |
4 |       let number = if condition {
  |  __________________^
5 | |         5
6 | |     } else {
7 | |         "six"
8 | |     };
  | |_____^ expected integer, found &str
  |
  = note: expected type `{integer}`
             found type `&str`

if 代碼塊中的表達式返回一個整數,而 else 代碼塊中的表達式返回一個字串。這不可行,因為變數必須只有一個類型。Rust 需要在編譯時就確切的知道 number 變數的類型,這樣它就可以在編譯時驗證在每處使用的 number 變數的類型是有效的。Rust 並不能夠在 number 的類型只能在運行時確定的情況下工作;這樣會使編譯器變得更複雜而且只能為代碼提供更少的保障,因為它不得不記錄所有變數的多種可能的類型。

使用循環重複執行

多次執行同一段代碼是很常用的,Rust 為此提供了多種 循環loops)。一個循環執行循環體中的代碼直到結尾並緊接著回到開頭繼續執行。為了實驗一下循環,讓我們新建一個叫做 loops 的項目。

Rust 有三種循環:loopwhilefor。我們每一個都試試。

使用 loop 重複執行程式碼

loop 關鍵字告訴 Rust 一遍又一遍地執行一段代碼直到你明確要求停止。

作為一個例子,將 loops 目錄中的 src/main.rs 文件修改為如下:

檔案名: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

當運行這個程序時,我們會看到連續的反覆列印 again!,直到我們手動停止程式。大部分終端都支持一個快捷鍵,ctrl-c,來終止一個陷入無限循環的程序。嘗試一下:

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

符號 ^C 代表你在這按下了ctrl-c。在 ^C 之後你可能看到也可能看不到 again! ,這取決於在接收到終止信號時代碼執行到了循環的何處。

幸運的是,Rust 提供了另一種更可靠的退出循環的方式。可以使用 break 關鍵字來告訴程序何時停止循環。回憶一下在第二章猜猜看遊戲的 “猜測正確後退出” 部分使用過它來在用戶猜對數字贏得遊戲後退出程序。

從循環返回

loop 的一個用例是重試可能會失敗的操作,比如檢查執行緒是否完成了任務。然而你可能會需要將操作的結果傳遞給其它的代碼。如果將返回值加入你用來停止循環的 break 表達式,它會被停止的循環返回:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

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

    println!("The result is {}", 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 = number - 1;
    }

    println!("LIFTOFF!!!");
}

範例 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!("the value is: {}", a[index]);

        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.32 secs
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

數組中的所有五個元素都如期被列印出來。儘管 index 在某一時刻會到達值 5,不過循環在其嘗試從數組獲取第六個值(會越界)之前就停止了。

但這個過程很容易出錯;如果索引長度不正確會導致程序 panic。這也使程序更慢,因為編譯器增加了運行時代碼來對每次循環的每個元素進行條件檢查。

作為更簡潔的替代方案,可以使用 for 循環來對一個集合的每個元素執行一些程式碼。for 循環看起來如範例 3-5 所示:

檔案名: src/main.rs

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

    for element in a.iter() {
        println!("the value is: {}", element);
    }
}

範例 3-5:使用 for 循環遍歷集合中的元素

當運行這段代碼時,將看到與範例 3-4 一樣的輸出。更為重要的是,我們增強了代碼安全性,並消除了可能由於超出數組的結尾或遍歷長度不夠而缺少一些元素而導致的 bug。

例如,在範例 3-4 的代碼中,如果從數組 a 中移除一個元素但忘記將條件更新為 while index < 4,代碼將會 panic。使用 for 循環的話,就不需要惦記著在改變數組元素個數時修改其他的代碼了。

for 循環的安全性和簡潔性使得它成為 Rust 中使用最多的循環結構。即使是在想要循環執行程式碼特定次數時,例如範例 3-3 中使用 while 循環的倒數計時例子,大部分 Rustacean 也會使用 for 循環。這麼做的方式是使用 Range,它是標準庫提供的類型,用來生成從一個數字開始到另一個數字之前結束的所有數字的序列。

下面是一個使用 for 循環來倒數計時的例子,它還使用了一個我們還未講到的方法,rev,用來反轉 range:

檔案名: src/main.rs

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

這段代碼看起來更帥氣不是嗎?

總結

你做到了!這是一個大章節:你學習了變數、標量和複合數據類型、函數、注釋、 if 表達式和循環!如果你想要實踐本章討論的概念,嘗試構建如下程序:

  • 相互轉換攝氏與華氏溫度。
  • 生成 n 階斐波那契數列。
  • 列印聖誕頌歌 “The Twelve Days of Christmas” 的歌詞,並利用歌曲中的重複部分(編寫循環)。

當你準備好繼續的時候,讓我們討論一個其他語言中 並不 常見的概念:所有權(ownership)。

認識所有權

ch04-00-understanding-ownership.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

所有權(系統)是 Rust 最為與眾不同的特性,它讓 Rust 無需垃圾回收(garbage collector)即可保障記憶體安全。因此,理解 Rust 中所有權如何工作是十分重要的。本章,我們將講到所有權以及相關功能:借用、slice 以及 Rust 如何在記憶體中布局數據。

什麼是所有權?

ch04-01-what-is-ownership.md
commit e81710c276b3839e8ec54d5f12aec4f9de88924b

Rust 的核心功能(之一)是 所有權ownership)。雖然該功能很容易解釋,但它對語言的其他部分有著深刻的影響。

所有運行的程序都必須管理其使用計算機記憶體的方式。一些語言中具有垃圾回收機制,在程序運行時不斷地尋找不再使用的記憶體;在另一些語言中,程式設計師必須親自分配和釋放記憶體。Rust 則選擇了第三種方式:透過所有權系統管理記憶體,編譯器在編譯時會根據一系列的規則進行檢查。在運行時,所有權系統的任何功能都不會減慢程序。

因為所有權對很多程式設計師來說都是一個新概念,需要一些時間來適應。好消息是隨著你對 Rust 和所有權系統的規則越來越有經驗,你就越能自然地編寫出安全和高效的代碼。持之以恆!

當你理解了所有權,你將有一個堅實的基礎來理解那些使 Rust 獨特的功能。在本章中,你將透過完成一些範例來學習所有權,這些範例基於一個常用的數據結構:字串。

棧(Stack)與堆(Heap)

在很多語言中,你並不需要經常考慮到棧與堆。不過在像 Rust 這樣的系統程式語言中,值是位於棧上還是堆上在更大程度上影響了語言的行為以及為何必須做出這樣的抉擇。我們會在本章的稍後部分描述所有權與棧和堆相關的內容,所以這裡只是一個用來預熱的簡要解釋。

棧和堆都是代碼在運行時可供使用的記憶體,但是它們的結構不同。棧以放入值的順序存儲值並以相反順序取出值。這也被稱作 後進先出last in, first out)。想像一下一疊盤子:當增加更多盤子時,把它們放在盤子堆的頂部,當需要盤子時,也從頂部拿走。不能從中間也不能從底部增加或拿走盤子!增加數據叫做 進棧pushing onto the stack),而移出數據叫做 出棧popping off the stack)。

棧中的所有數據都必須占用已知且固定的大小。在編譯時大小未知或大小可能變化的數據,要改為存儲在堆上。堆是缺乏組織的:當向堆放入數據時,你要請求一定大小的空間。操作系統在堆的某處找到一塊足夠大的空位,把它標記為已使用,並返回一個表示該位置地址的 指針pointer)。這個過程稱作 在堆上分配記憶體allocating on the heap),有時簡稱為 “分配”(allocating)。將數據推入棧中並不被認為是分配。因為指針的大小是已知並且固定的,你可以將指針存儲在棧上,不過當需要實際數據時,必須訪問指針。

想像一下去餐館就座吃飯。當進入時,你說明有幾個人,餐館員工會找到一個夠大的空桌子並領你們過去。如果有人來遲了,他們也可以透過詢問來找到你們坐在哪。

入棧比在堆上分配記憶體要快,因為(入棧時)操作系統無需為存儲新數據去搜索記憶體空間;其位置總是在棧頂。相比之下,在堆上分配記憶體則需要更多的工作,這是因為操作系統必須首先找到一塊足夠存放數據的記憶體空間,並接著做一些記錄為下一次分配做準備。

訪問堆上的數據比訪問棧上的數據慢,因為必須透過指針來訪問。現代處理器在記憶體中跳轉越少就越快(快取)。繼續類比,假設有一個服務員在餐廳裡處理多個桌子的點菜。在一個桌子報完所有菜後再移動到下一個桌子是最有效率的。從桌子 A 聽一個菜,接著桌子 B 聽一個菜,然後再桌子 A,然後再桌子 B 這樣的流程會更加緩慢。出於同樣原因,處理器在處理的數據彼此較近的時候(比如在棧上)比較遠的時候(比如可能在堆上)能更好的工作。在堆上分配大量的空間也可能消耗時間。

當你的代碼調用一個函數時,傳遞給函數的值(包括可能指向堆上數據的指針)和函數的局部變數被壓入棧中。當函數結束時,這些值被移出棧。

跟蹤哪部分代碼正在使用堆上的哪些數據,最大限度的減少堆上的重複數據的數量,以及清理堆上不再使用的數據確保不會耗盡空間,這些問題正是所有權系統要處理的。一旦理解了所有權,你就不需要經常考慮棧和堆了,不過明白了所有權的存在就是為了管理堆數據,能夠幫助解釋為什麼所有權要以這種方式工作。

所有權規則

首先,讓我們看一下所有權的規則。當我們通過舉例說明時,請謹記這些規則:

  1. Rust 中的每一個值都有一個被稱為其 所有者owner)的變數。
  2. 值在任一時刻有且只有一個所有者。
  3. 當所有者(變數)離開作用域,這個值將被丟棄。

變數作用域

我們已經在第二章完成一個 Rust 程序範例。既然我們已經掌握了基本語法,將不會在之後的例子中包含 fn main() { 代碼,所以如果你是一路跟過來的,必須手動將之後例子的代碼放入一個 main 函數中。這樣,例子將顯得更加簡明,使我們可以關注實際細節而不是樣板代碼。

在所有權的第一個例子中,我們看看一些變數的 作用域scope)。作用域是一個項(item)在程序中有效的範圍。假設有這樣一個變數:


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

變數 s 綁定到了一個字串字面值,這個字串值是寫死進程式碼中的。這個變數從聲明的點開始直到當前 作用域 結束時都是有效的。範例 4-1 的注釋標明了變數 s 在何處是有效的。


#![allow(unused)]
fn main() {
{                      // s 在這裡無效, 它尚未聲明
    let s = "hello";   // 從此處起,s 是有效的

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

範例 4-1:一個變數和其有效的作用域

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

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

目前為止,變數是否有效與作用域的關係跟其他程式語言是類似的。現在我們在此基礎上介紹 String 類型。

String 類型

為了示範所有權的規則,我們需要一個比第三章 “數據類型” 中講到的都要複雜的數據類型。前面介紹的類型都是存儲在棧上的並且當離開作用域時被移出棧,不過我們需要尋找一個存儲在堆上的數據來探索 Rust 是如何知道該在何時清理數據的。

這裡使用 String 作為例子,並專注於 String 與所有權相關的部分。這些方面也同樣適用於標準庫提供的或你自己創建的其他複雜數據類型。在第八章會更深入地講解 String

我們已經見過字串字面值,即被寫死進程序裡的字串值。字串字面值是很方便的,不過它們並不適合使用文本的每一種場景。原因之一就是它們是不可變的。另一個原因是並非所有字串的值都能在編寫程式碼時就知道:例如,要是想獲取用戶輸入並存儲該怎麼辦呢?為此,Rust 有第二個字串類型,String。這個類型被分配到堆上,所以能夠存儲在編譯時未知大小的文本。可以使用 from 函數基於字串字面值來創建 String,如下:


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

這兩個冒號(::)是運算符,允許將特定的 from 函數置於 String 類型的命名空間(namespace)下,而不需要使用類似 string_from 這樣的名字。在第五章的 “方法語法”(“Method Syntax”) 部分會著重講解這個語法而且在第七章的 “路徑用於引用模組樹中的項” 中會講到模組的命名空間。

可以 修改此類字串 :


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

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

println!("{}", s); // 將列印 `hello, world!`
}

那麼這裡有什麼區別呢?為什麼 String 可變而字面值卻不行呢?區別在於兩個類型對記憶體的處理上。

記憶體與分配

就字串字面值來說,我們在編譯時就知道其內容,所以文本被直接寫死進最終的可執行文件中。這使得字串字面值快速且高效。不過這些特性都只得益於字串字面值的不可變性。不幸的是,我們不能為了每一個在編譯時大小未知的文本而將一塊記憶體放入二進位制文件中,並且它的大小還可能隨著程序運行而改變。

對於 String 類型,為了支持一個可變,可增長的文本片段,需要在堆上分配一塊在編譯時未知大小的記憶體來存放內容。這意味著:

  • 必須在運行時向操作系統請求記憶體。
  • 需要一個當我們處理完 String 時將記憶體返回給操作系統的方法。

第一部分由我們完成:當調用 String::from 時,它的實現 (implementation) 請求其所需的記憶體。這在程式語言中是非常通用的。

然而,第二部分實現起來就各有區別了。在有 垃圾回收garbage collectorGC)的語言中, GC 記錄並清除不再使用的記憶體,而我們並不需要關心它。沒有 GC 的話,識別出不再使用的記憶體並調用代碼顯式釋放就是我們的責任了,跟請求記憶體的時候一樣。從歷史的角度上說正確處理記憶體回收曾經是一個困難的程式問題。如果忘記回收了會浪費記憶體。如果過早回收了,將會出現無效變數。如果重複回收,這也是個 bug。我們需要精確的為一個 allocate 配對一個 free

Rust 採取了一個不同的策略:內存在擁有它的變數離開作用域後就被自動釋放。下面是範例 4-1 中作用域例子的一個使用 String 而不是字串字面值的版本:


#![allow(unused)]
fn main() {
{
    let s = String::from("hello"); // 從此處起,s 是有效的

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

這是一個將 String 需要的記憶體返回給操作系統的很自然的位置:當 s 離開作用域的時候。當變數離開作用域,Rust 為我們調用一個特殊的函數。這個函數叫做 drop,在這裡 String 的作者可以放置釋放記憶體的代碼。Rust 在結尾的 } 處自動調用 drop

注意:在 C++ 中,這種 item 在生命週期結束時釋放資源的模式有時被稱作 資源獲取即初始化Resource Acquisition Is Initialization (RAII))。如果你使用過 RAII 模式的話應該對 Rust 的 drop 函數並不陌生。

這個模式對編寫 Rust 代碼的方式有著深遠的影響。現在它看起來很簡單,不過在更複雜的場景下代碼的行為可能是不可預測的,比如當有多個變數使用在堆上分配的記憶體時。現在讓我們探索一些這樣的場景。

變數與數據交互的方式(一):移動

Rust 中的多個變數可以採用一種獨特的方式與同一數據交互。讓我們看看範例 4-2 中一個使用整型的例子。


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

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

我們大致可以猜到這在幹什麼:“將 5 綁定到 x;接著生成一個值 x 的拷貝並綁定到 y”。現在有了兩個變數,xy,都等於 5。這也正是事實上發生了的,因為整數是有已知固定大小的簡單值,所以這兩個 5 被放入了棧中。

現在看看這個 String 版本:


#![allow(unused)]
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:另一個 s2 = s1 時可能的記憶體表現,如果 Rust 同時也拷貝了堆上的數據的話

之前我們提到過當變數離開作用域後,Rust 自動調用 drop 函數並清理變數的堆記憶體。不過圖 4-2 展示了兩個數據指針指向了同一位置。這就有了一個問題:當 s2s1 離開作用域,他們都會嘗試釋放相同的記憶體。這是一個叫做 二次釋放double free)的錯誤,也是之前提到過的記憶體安全性 bug 之一。兩次釋放(相同)記憶體會導致記憶體汙染,它可能會導致潛在的安全漏洞。

為了確保記憶體安全,這種場景下 Rust 的處理有另一個細節值得注意。與其嘗試拷貝被分配的記憶體,Rust 則認為 s1 不再有效,因此 Rust 不需要在 s1 離開作用域後清理任何東西。看看在 s2 被創建之後嘗試使用 s1 會發生什麼事;這段代碼不能運行:

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

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

你會得到一個類似如下的錯誤,因為 Rust 禁止你使用無效的引用。

error[E0382]: use of moved value: `s1`
 --> src/main.rs:5:28
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`, which does
  not implement the `Copy` trait

如果你在其他語言中聽說過術語 淺拷貝shallow copy)和 深拷貝deep copy),那麼拷貝指針、長度和容量而不拷貝數據可能聽起來像淺拷貝。不過因為 Rust 同時使第一個變數無效了,這個操作被稱為 移動move),而不是淺拷貝。上面的例子可以解讀為 s1移動 到了 s2 中。那麼具體發生了什麼事,如圖 4-4 所示。

s1 moved to s2

圖 4-4:s1 無效之後的記憶體表現

這樣就解決了我們的問題!因為只有 s2 是有效的,當其離開作用域,它就釋放自己的記憶體,完畢。

另外,這裡還隱含了一個設計選擇:Rust 永遠也不會自動創建數據的 “深拷貝”。因此,任何 自動 的複製可以被認為對運行時性能影響較小。

變數與數據交互的方式(二):複製

如果我們 確實 需要深度複製 String 中堆上的數據,而不僅僅是棧上的數據,可以使用一個叫做 clone 的通用函數。第五章會討論方法語法,不過因為方法在很多語言中是一個常見功能,所以之前你可能已經見過了。

這是一個實際使用 clone 方法的例子:


#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();

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

這段代碼能正常運行,並且明確產生圖 4-3 中行為,這裡堆上的數據 確實 被複製了。

當出現 clone 調用時,你知道一些特定的代碼被執行而且這些程式碼可能相當消耗資源。你很容易察覺到一些不尋常的事情正在發生。

只在棧上的數據:拷貝

這裡還有一個沒有提到的小竅門。這些程式碼使用了整型並且是有效的,他們是範例 4-2 中的一部分:


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

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

但這段代碼似乎與我們剛剛學到的內容相矛盾:沒有調用 clone,不過 x 依然有效且沒有被移動到 y 中。

原因是像整型這樣的在編譯時已知大小的類型被整個存儲在棧上,所以拷貝其實際的值是快速的。這意味著沒有理由在創建變數 y 後使 x 無效。換句話說,這裡沒有深淺拷貝的區別,所以這裡調用 clone 並不會與通常的淺拷貝有什麼不同,我們可以不用管它。

Rust 有一個叫做 Copy trait 的特殊註解,可以用在類似整型這樣的存儲在棧上的類型上(第十章詳細講解 trait)。如果一個類型擁有 Copy trait,一個舊的變數在將其賦值給其他變數後仍然可用。Rust 不允許自身或其任何部分實現了 Drop trait 的類型使用 Copy trait。如果我們對其值離開作用域時需要特殊處理的類型使用 Copy 註解,將會出現一個編譯時錯誤。要學習如何為你的類型增加 Copy 註解,請閱讀附錄 C 中的 “可派生的 trait”

那麼什麼類型是 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 的值移動到函數裡 ...
                                    // ... 所以到這裡不再有效

    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 將傳入字串並返回該值
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!("The length of '{}' is {}.", s2, len);
}

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

    (s, length)
}

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

但是這未免有些形式主義,而且這種場景應該很常見。幸運的是,Rust 對此提供了一個功能,叫做 引用references)。

引用與借用

ch04-02-references-and-borrowing.md
commit 4f19894e592cd24ac1476f1310dcf437ae83d4ba

範例 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!("The length of '{}' is {}.", s1, len);
}

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

首先,注意變數聲明和函數返回值中的所有元組代碼都消失了。其次,注意我們傳遞 &s1calculate_length,同時在函數定義中,我們獲取 &String 而不是 String

這些 & 符號就是 引用,它們允許你使用值但不獲取其所有權。圖 4-5 展示了一張示意圖。

&String s pointing at String s1

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

注意:與使用 & 引用相反的操作是 解引用dereferencing),它使用解引用運算符,*。我們將會在第八章遇到一些解引用運算符,並在第十五章詳細討論解引用。

仔細看看這個函數調用:


#![allow(unused)]
fn main() {
fn calculate_length(s: &String) -> usize {
    s.len()
}
let s1 = String::from("hello");

let len = calculate_length(&s1);
}

&s1 語法讓我們創建一個 指向s1 的引用,但是並不擁有它。因為並不擁有這個值,當引用離開作用域時其指向的值也不會被丟棄。

同理,函數簽名使用 & 來表明參數 s 的類型是一個引用。讓我們增加一些解釋性的注釋:


#![allow(unused)]
fn main() {
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:嘗試修改借用的值

這裡是錯誤:

error[E0596]: cannot borrow immutable borrowed content `*some_string` as mutable
 --> error.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- use `&mut String` here to make mutable
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ cannot borrow as mutable

正如變數預設是不可變的,引用也一樣。(默認)不允許修改引用的值。

可變引用

我們通過一個小調整就能修復範例 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

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

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

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

錯誤如下:

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

這個限制允許可變性,不過是以一種受限制的方式允許。新 Rustacean 們經常難以適應這一點,因為大部分語言中變數任何時候都是可變的。

這個限制的好處是 Rust 可以在編譯時就避免數據競爭。數據競爭data race)類似於競態條件,它可由這三個行為造成:

  • 兩個或更多指針同時訪問同一數據。
  • 至少有一個指針被用來寫入數據。
  • 沒有同步數據訪問的機制。

數據競爭會導致未定義行為,難以在運行時追蹤,並且難以診斷和修復;Rust 避免了這種情況的發生,因為它甚至不會編譯存在數據競爭的代碼!

一如既往,可以使用大括號來創建一個新的作用域,以允許擁有多個可變引用,只是不能 同時 擁有:


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

{
    let r1 = &mut s;

} // r1 在這裡離開了作用域,所以我們完全可以創建一個新的引用

let r2 = &mut s;
}

類似的規則也存在於同時使用可變與不可變引用中。這些程式碼會導致一個錯誤:

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

let r1 = &s; // 沒問題
let r2 = &s; // 沒問題
let r3 = &mut s; // 大問題

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

錯誤如下:

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

哇哦!我們 不能在擁有不可變引用的同時擁有可變引用。不可變引用的用戶可不希望在他們的眼皮底下值就被意外的改變了!然而,多個不可變引用是可以的,因為沒有哪個只能讀取數據的人有能力影響其他人讀取到的數據。

注意一個引用的作用域從聲明的地方開始一直持續到最後一次使用為止。例如,因為最後一次使用不可變引用在聲明可變引用之前,所以如下代碼是可以編譯的:

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 編譯器在提前指出一個潛在的 bug(在編譯時而不是在運行時)並精準顯示問題所在。這樣你就不必去跟蹤為何數據並不是你想像中的那樣。

懸垂引用(Dangling References)

在具有指針的語言中,很容易透過釋放記憶體時保留指向它的指針而錯誤地生成一個 懸垂指針dangling pointer),所謂懸垂指針是其指向的記憶體可能已經被分配給其它持有者。相比之下,在 Rust 中編譯器確保引用永遠也不會變成懸垂狀態:當你擁有一些數據的引用,編譯器確保數據不會在其引用之前離開作用域。

讓我們嘗試創建一個懸垂引用,Rust 會透過一個編譯時錯誤來避免:

檔案名: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

這裡是錯誤:

error[E0106]: missing lifetime specifier
 --> main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is
  no value for it to be borrowed from
  = help: consider giving it a 'static lifetime

錯誤訊息引用了一個我們還未介紹的功能:生命週期(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 dangle() -> &String { // dangle 返回一個字串的引用

    let s = String::from("hello"); // s 是一個新字串

    &s // 返回字串 s 的引用
} // 這裡 s 離開作用域並被丟棄。其記憶體被釋放。
  // 危險!

因為 s 是在 dangle 函數內創建的,當 dangle 的代碼執行完畢後,s 將被釋放。不過我們嘗試返回它的引用。這意味著這個引用會指向一個無效的 String,這可不對!Rust 不會允許我們這麼做。

這裡的解決方法是直接返回 String


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

    s
}
}

這樣就沒有任何錯誤了。所有權被移動出去,所以沒有值被釋放。

引用的規則

讓我們概括一下之前對引用的討論:

  • 在任意給定時間,要嘛 只能有一個可變引用,要嘛 只能有多個不可變引用。
  • 引用必須總是有效的。

接下來,我們來看看另一種不同類型的引用:slice。

Slice 類型

ch04-03-slices.md
commit 9fcebe6e1b0b5e842285015dbf093f97cd5b3803

另一個沒有所有權的數據類型是 slice。slice 允許你引用集合中一段連續的元素序列,而不用引用整個集合。

這裡有一個編程小習題:編寫一個函數,該函數接收一個字串,並返回在該字串中找到的第一個單詞。如果函數在該字串中並未找到空格,則整個字串就是一個單詞,所以應該返回整個字串。

讓我們考慮一下這個函數的簽名:

fn first_word(s: &String) -> ?

first_word 函數有一個參數 &String。因為我們不需要所有權,所以這沒有問題。不過應該返回什麼呢?我們並沒有一個真正獲取 部分 字串的辦法。不過,我們可以返回單詞結尾的索引。試試如範例 4-7 中的代碼。

檔案名: src/main.rs


#![allow(unused)]
fn main() {
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()
}
}

範例 4-7:first_word 函數返回 String 參數的一個位元組索引值

因為需要逐個元素的檢查 String 中的值是否為空格,需要用 as_bytes 方法將 String 轉化為位元組數組:

let bytes = s.as_bytes();

接下來,使用 iter 方法在位元組數組上創建一個疊代器:

for (i, &item) in bytes.iter().enumerate() {

我們將在第十三章詳細討論疊代器。現在,只需知道 iter 方法返回集合中的每一個元素,而 enumerate 包裝了 iter 的結果,將這些元素作為元組的一部分來返回。enumerate 返回的元組中,第一個元素是索引,第二個元素是集合中元素的引用。這比我們自己計算索引要方便一些。

因為 enumerate 方法返回一個元組,我們可以使用模式來解構,就像 Rust 中其他任何地方所做的一樣。所以在 for 循環中,我們指定了一個模式,其中元組中的 i 是索引而元組中的 &item 是單個位元組。因為我們從 .iter().enumerate() 中獲取了集合元素的引用,所以模式中使用了 &

for 循環中,我們透過位元組的字面值語法來尋找代表空格的位元組。如果找到了一個空格,返回它的位置。否則,使用 s.len() 返回字串的長度:

    if item == b' ' {
        return i;
    }
}

s.len()

現在有了一個找到字串中第一個單詞結尾索引的方法,不過這有一個問題。我們返回了一個獨立的 usize,不過它只在 &String 的上下文中才是一個有意義的數字。換句話說,因為它是一個與 String 相分離的值,無法保證將來它仍然有效。考慮一下範例 4-8 中使用了範例 4-7 中 first_word 函數的程序。

檔案名: 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(); // 這清空了字串,使其等於 ""

    // word 在此處的值仍然是 5,
    // 但是沒有更多的字串讓我們可以有效地應用數值 5。word 的值現在完全無效!
}

範例 4-8:存儲 first_word 函數調用的返回值並接著改變 String 的內容

這個程序編譯時沒有任何錯誤,而且在調用 s.clear() 之後使用 word 也不會出錯。因為 words 狀態完全沒有聯繫,所以 word 仍然包含值 5。可以嘗試用值 5 來提取變數 s 的第一個單詞,不過這是有 bug 的,因為在我們將 5 保存到 word 之後 s 的內容已經改變。

我們不得不時刻擔心 word 的索引與 s 中的數據不再同步,這很囉嗦且易出錯!如果編寫這麼一個 second_word 函數的話,管理索引這件事將更加容易出問題。它的簽名看起來像這樣:

fn second_word(s: &String) -> (usize, usize) {

現在我們要跟蹤一個開始索引 一個結尾索引,同時有了更多從數據的某個特定狀態計算而來的值,但都完全沒有與這個狀態相關聯。現在有三個飄忽不定的不相關變數需要保持同步。

幸運的是,Rust 為這個問題提供了一個解決方法:字串 slice。

字串 slice

字串 slicestring slice)是 String 中一部分值的引用,它看起來像這樣:


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

let hello = &s[0..5];
let world = &s[6..11];
}

這類似於引用整個 String 不過帶有額外的 [0..5] 部分。它不是對整個 String 的引用,而是對部分 String 的引用。

可以使用一個由中括號中的 [starting_index..ending_index] 指定的 range 創建一個 slice,其中 starting_index 是 slice 的第一個位置,ending_index 則是 slice 最後一個位置的後一個值。在其內部,slice 的數據結構存儲了 slice 的開始位置和長度,長度對應於 ending_index 減去 starting_index 的值。所以對於 let world = &s[6..11]; 的情況,world 將是一個包含指向 s 第 7 個位元組(從 1 開始)的指針和長度值 5 的 slice。

圖 4-6 展示了一個圖例。

world containing a pointer to the 6th byte of String s and a length 5

圖 4-6:引用了部分 String 的字串 slice

對於 Rust 的 .. range 語法,如果想要從第一個索引(0)開始,可以不寫兩個點號之前的值。換句話說,如下兩個語句是相同的:


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

let slice = &s[0..2];
let slice = &s[..2];
}

依此類推,如果 slice 包含 String 的最後一個位元組,也可以捨棄尾部的數字。這意味著如下也是相同的:


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

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

也可以同時捨棄這兩個值來獲取整個字串的 slice。所以如下亦是相同的:


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

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

注意:字串 slice range 的索引必須位於有效的 UTF-8 字元邊界內,如果嘗試從一個多位元組字元的中間位置創建字串 slice,則程序將會因錯誤而退出。出於介紹字串 slice 的目的,本部分假設只使用 ASCII 字元集;第八章的 “使用字串存儲 UTF-8 編碼的文本” 部分會更加全面的討論 UTF-8 處理問題。

在記住所有這些知識後,讓我們重寫 first_word 來返回一個 slice。“字串 slice” 的類型聲明寫作 &str

檔案名: src/main.rs


#![allow(unused)]
fn main() {
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[..]
}
}

我們使用跟範例 4-7 相同的方式獲取單詞結尾的索引,通過尋找第一個出現的空格。當找到一個空格,我們返回一個字串 slice,它使用字串的開始和空格的索引作為開始和結束的索引。

現在當調用 first_word 時,會返回與底層數據關聯的單個值。這個值由一個 slice 開始位置的引用和 slice 中元素的數量組成。

second_word 函數也可以改為返回一個 slice:

fn second_word(s: &String) -> &str {

現在我們有了一個不易混淆且直觀的 API 了,因為編譯器會確保指向 String 的引用持續有效。還記得範例 4-8 程序中,那個當我們獲取第一個單詞結尾的索引後,接著就清除了字串導致索引就無效的 bug 嗎?那些程式碼在邏輯上是不正確的,但卻沒有顯示任何直接的錯誤。問題會在之後嘗試對空字串使用第一個單詞的索引時出現。slice 就不可能出現這種 bug 並讓我們更早的知道出問題了。使用 slice 版本的 first_word 會拋出一個編譯時錯誤:

檔案名: src/main.rs

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

    let word = first_word(&s);

    s.clear(); // 錯誤!

    println!("the first word is: {}", word);
}

這裡是編譯錯誤:

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(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {}", word);
   |                                       ---- immutable borrow later used here

回憶一下借用規則,當擁有某值的不可變引用時,就不能再獲取一個可變引用。因為 clear 需要清空 String,它嘗試獲取一個可變引用。Rust不允許這樣做,因而編譯失敗。Rust 不僅使得我們的 API 簡單易用,也在編譯時就消除了一整類的錯誤!

字串字面值就是 slice

還記得我們講到過字串字面值被儲存在二進位制文件中嗎?現在知道 slice 了,我們就可以正確地理解字串字面值了:


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

這裡 s 的類型是 &str:它是一個指向二進位制程序特定位置的 slice。這也就是為什麼字串字面值是不可變的;&str 是一個不可變引用。

字串 slice 作為參數

在知道了能夠獲取字面值和 String 的 slice 後,我們對 first_word 做了改進,這是它的簽名:

fn first_word(s: &String) -> &str {

而更有經驗的 Rustacean 會編寫出範例 4-9 中的簽名,因為它使得可以對 String 值和 &str 值使用相同的函數:

fn first_word(s: &str) -> &str {

範例 4-9: 透過將 s 參數的類型改為字串 slice 來改進 first_word 函數

如果有一個字串 slice,可以直接傳遞它。如果有一個 String,則可以傳遞整個 String 的 slice。定義一個獲取字串 slice 而不是 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` 的 slice
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word 中傳入字串字面值的 slice
    let word = first_word(&my_string_literal[..]);

    // 因為字串字面值 **就是** 字串 slice,
    // 這樣寫也可以,即不使用 slice 語法!
    let word = first_word(my_string_literal);
}

其他類型的 slice

字串 slice,正如你想像的那樣,是針對字串的。不過也有更通用的 slice 類型。考慮一下這個數組:


#![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];
}

這個 slice 的類型是 &[i32]。它跟字串 slice 的工作方式一樣,透過存儲第一個集合元素的引用和一個集合總長度。你可以對其他所有集合使用這類 slice。第八章講到 vector 時會詳細討論這些集合。

總結

所有權、借用和 slice 這些概念讓 Rust 程序在編譯時確保記憶體安全。Rust 語言提供了跟其他系統程式語言相同的方式來控制你使用的記憶體,但擁有數據所有者在離開作用域後自動清除其數據的功能意味著你無須額外編寫和除錯相關的控制代碼。

所有權系統影響了 Rust 中很多其他部分的工作方式,所以我們還會繼續講到這些概念,這將貫穿本書的餘下內容。讓我們開始第五章,來看看如何將多份數據組合進一個 struct 中。

使用結構體組織相關聯的數據

ch05-00-structs.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

struct,或者 structure,是一個自訂數據類型,允許你命名和包裝多個相關的值,從而形成一個有意義的組合。如果你熟悉一門面向對象語言,struct 就像對象中的數據屬性。在本章中,我們會對比元組與結構體的異同,示範結構體的用法,並討論如何在結構體上定義方法和關聯函數來指定與結構體數據相關的行為。你可以在程序中基於結構體和枚舉(enum)(在第六章介紹)創建新類型,以充分利用 Rust 的編譯時類型檢查。

定義並實例化結構體

ch05-01-defining-structs.md
commit f617d58c1a88dd2912739a041fd4725d127bf9fb

結構體和我們在第三章討論過的元組類似。和元組一樣,結構體的每一部分可以是不同類型。但不同於元組,結構體需要命名各部分數據以便能清楚的表明其值的意義。由於有了這些名字,結構體比元組更靈活:不需要依賴順序來指定或訪問實例中的值。

定義結構體,需要使用 struct 關鍵字並為整個結構體提供一個名字。結構體的名字需要描述它所組合的數據的意義。接著,在大括號中,定義每一部分數據的名字和類型,我們稱為 欄位field)。例如,範例 5-1 展示了一個存儲用戶帳號訊息的結構體:


#![allow(unused)]
fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}
}

範例 5-1:User 結構體定義

一旦定義了結構體後,為了使用它,透過為每個欄位指定具體值來創建這個結構體的 實例。創建一個實例需要以結構體的名字開頭,接著在大括號中使用 key: value 鍵-值對的形式提供欄位,其中 key 是欄位的名字,value 是需要存儲在欄位中的數據值。實例中欄位的順序不需要和它們在結構體中聲明的順序一致。換句話說,結構體的定義就像一個類型的通用模板,而實例則會在這個模板中放入特定數據來創建這個類型的值。例如,可以像範例 5-2 這樣來聲明一個特定的用戶:


#![allow(unused)]
fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

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 欄位的值:


#![allow(unused)]
fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

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:改變 User 實例 email 欄位的值

注意整個實例必須是可變的;Rust 並不允許只將某個欄位標記為可變。另外需要注意同其他任何表達式一樣,我們可以在函數體的最後一個表達式中構造一個結構體的新實例,來隱式地返回這個實例。

範例 5-4 顯示了一個 build_user 函數,它返回一個帶有給定的 email 和使用者名稱的 User 結構體實例。active 欄位的值為 true,並且 sign_in_count 的值為 1


#![allow(unused)]
fn main() {
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,
    }
}
}

範例 5-4:build_user 函數獲取 email 和使用者名稱並返回 User 實例

為函數參數起與結構體欄位相同的名字是可以理解的,但是不得不重複 emailusername 欄位名稱與變數有些囉嗦。如果結構體有更多欄位,重複每個名稱就更加煩人了。幸運的是,有一個方便的簡寫語法!

變數與欄位同名時的欄位初始化簡寫語法

因為範例 5-4 中的參數名與欄位名都完全相同,我們可以使用 欄位初始化簡寫語法field init shorthand)來重寫 build_user,這樣其行為與之前完全相同,不過無需重複 emailusername 了,如範例 5-5 所示。


#![allow(unused)]
fn main() {
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,
    }
}
}

範例 5-5:build_user 函數使用了欄位初始化簡寫語法,因為 emailusername 參數與結構體欄位同名

這裡我們創建了一個新的 User 結構體實例,它有一個叫做 email 的欄位。我們想要將 email 欄位的值設置為 build_user 函數 email 參數的值。因為 email 欄位與 email 參數有著相同的名稱,則只需編寫 email 而不是 email: email

使用結構體更新語法從其他實例創建實例

使用舊實例的大部分值但改變其部分值來創建一個新的結構體實例通常是很有幫助的。這可以通過 結構體更新語法struct update syntax)實現。

首先,範例 5-6 展示了不使用更新語法時,如何在 user2 中創建一個新 User 實例。我們為 emailusername 設置了新的值,其他值則使用了實例 5-2 中創建的 user1 中的同名值:


#![allow(unused)]
fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

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:創建 User 新實例,其使用了一些來自 user1 的值

使用結構體更新語法,我們可以透過更少的代碼來達到相同的效果,如範例 5-7 所示。.. 語法指定了剩餘未顯式設置值的欄位應有與給定實例對應欄位相同的值。


#![allow(unused)]
fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

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 中的代碼也在 user2 中創建了一個新實例,其有不同的 emailusername 值不過 activesign_in_count 欄位的值與 user1 相同。

使用沒有命名欄位的元組結構體來創建不同的類型

也可以定義與元組(在第三章討論過)類似的結構體,稱為 元組結構體tuple structs)。元組結構體有著結構體名稱提供的含義,但沒有具體的欄位名,只有欄位的類型。當你想給整個元組取一個名字,並使元組成為與其他元組不同的類型時,元組結構體是很有用的,這時像常規結構體那樣為每個欄位命名就顯得多餘和形式化了。

要定義元組結構體,以 struct 關鍵字和結構體名開頭並後跟元組中的類型。例如,下面是兩個分別叫做 ColorPoint 元組結構體的定義和用法:


#![allow(unused)]
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 類型。類單元結構體常常在你想要在某個類型上實現 trait 但不需要在類型中存儲數據的時候發揮作用。我們將在第十章介紹 trait。

結構體數據的所有權

在範例 5-1 中的 User 結構體的定義中,我們使用了自身擁有所有權的 String 類型而不是 &str 字串 slice 類型。這是一個有意而為之的選擇,因為我們想要這個結構體擁有它所有的數據,為此只要整個結構體是有效的話其數據也是有效的。

可以使結構體存儲被其他對象擁有的數據的引用,不過這麼做的話需要用上 生命週期lifetimes),這是一個第十章會討論的 Rust 功能。生命週期確保結構體引用的數據有效性跟結構體本身保持一致。如果你嘗試在結構體中存儲一個引用而不指定生命週期將是無效的,比如這樣:

檔案名: 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,
    };
}

編譯器會抱怨它需要生命週期標識符:

error[E0106]: missing lifetime specifier
 -->
  |
2 |     username: &str,
  |               ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
 -->
  |
3 |     email: &str,
  |            ^ expected lifetime parameter

第十章會講到如何修復這個問題以便在結構體中存儲引用,不過現在,我們會使用像 String 這類擁有所有權的類型來替代 &str 這樣的引用以修正這個錯誤。

一個使用結構體的範例程序

ch05-02-example-structs.md
commit 9cb1d20394f047855a57228dc4cbbabd0a9b395a

為了理解何時會需要使用結構體,讓我們編寫一個計算長方形面積的程序。我們會從單獨的變數開始,接著重構程序直到使用結構體替代他們為止。

使用 Cargo 新建一個叫做 rectangles 的二進位制程序,它獲取以像素為單位的長方形的寬度和高度,並計算出長方形的面積。範例 5-8 顯示了位於項目的 src/main.rs 中的小程序,它剛剛好實現此功能:

檔案名: src/main.rs

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

範例 5-8:透過分別指定長方形的寬和高的變數來計算長方形面積

現在使用 cargo run 運行程序:

The area of the rectangle is 1500 square pixels.

雖然範例 5-8 可以運行,並在調用 area 函數時傳入每個維度來計算出長方形的面積,不過我們可以做的更好。寬度和高度是相關聯的,因為他們在一起才能定義一個長方形。

這些程式碼的問題突顯在 area 的簽名上:

fn area(width: u32, height: u32) -> u32 {

函數 area 本應該計算一個長方形的面積,不過函數卻有兩個參數。這兩個參數是相關聯的,不過程序本身卻沒有表現出這一點。將長度和寬度組合在一起將更易懂也更易處理。第三章的 “元組類型” 部分已經討論過了一種可行的方法:元組。

使用元組重構

範例 5-9 展示了使用元組的另一個程序版本。

檔案名: src/main.rs

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

範例 5-9:使用元組來指定長方形的寬高

在某種程度上說,這個程序更好一點了。元組幫助我們增加了一些結構性,並且現在只需傳一個參數。不過在另一方面,這個版本卻有一點不明確了:元組並沒有給出元素的名稱,所以計算變得更費解了,因為不得不使用索引來獲取元組的每一部分:

在計算面積時將寬和高弄混倒無關緊要,不過當在螢幕上繪製長方形時就有問題了!我們必須牢記 width 的元組索引是 0height 的元組索引是 1。如果其他人要使用這些程式碼,他們必須要搞清楚這一點,並也要牢記於心。很容易忘記或者混淆這些值而造成錯誤,因為我們沒有在代碼中傳達數據的意圖。

使用結構體重構:賦予更多意義

我們使用結構體為數據命名來為其賦予意義。我們可以將我們正在使用的元組轉換成一個有整體名稱而且每個部分也有對應名字的數據類型,如範例 5-10 所示:

檔案名: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

範例 5-10:定義 Rectangle 結構體

這裡我們定義了一個結構體並稱其為 Rectangle。在大括號中定義了欄位 widthheight,類型都是 u32。接著在 main 中,我們創建了一個具體的 Rectangle 實例,它的寬是 30,高是 50。

函數 area 現在被定義為接收一個名叫 rectangle 的參數,其類型是一個結構體 Rectangle 實例的不可變借用。第四章講到過,我們希望借用結構體而不是獲取它的所有權,這樣 main 函數就可以保持 rect1 的所有權並繼續使用它,所以這就是為什麼在函數簽名和調用的地方會有 &

area 函數訪問 Rectangle 實例的 widthheight 欄位。area 的函數簽名現在明確的闡述了我們的意圖:使用 Rectanglewidthheight 欄位,計算 Rectangle 的面積。這表明寬高是相互聯繫的,並為這些值提供了描述性的名稱而不是使用元組的索引值 01 。結構體勝在更清晰明了。

通過派生 trait 增加實用功能

如果能夠在除錯程序時列印出 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 是一個 trait,它允許我們以一種對開發者有幫助的方式列印結構體,以便當我們除錯代碼時能看到它的值。

這樣調整後再次運行程序。見鬼了!仍然能看到一個錯誤:

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:增加註解來派生 Debug trait,並使用除錯格式列印 Rectangle 實例

現在我們再運行這個程序時,就不會有任何錯誤,並會出現如下輸出:

rect1 is Rectangle { width: 30, height: 50 }

好極了!這並不是最漂亮的輸出,不過它顯示這個實例的所有欄位,毫無疑問這對除錯有幫助。當我們有一個更大的結構體時,能有更易讀一點的輸出就好了,為此可以使用 {:#?} 替換 println! 字串中的 {:?}。如果在這個例子中使用了 {:#?} 風格的話,輸出會看起來像這樣:

rect1 is Rectangle {
    width: 30,
    height: 50
}

Rust 為我們提供了很多可以通過 derive 註解來使用的 trait,他們可以為我們的自訂類型增加實用的行為。附錄 C 中列出了這些 trait 和行為。第十章會介紹如何透過自訂行為來實現這些 trait,同時還有如何創建你自己的 trait。

我們的 area 函數是非常特殊的,它只計算長方形的面積。如果這個行為與 Rectangle 結構體再結合得更緊密一些就更好了,因為它不能用於其他類型。現在讓我們看看如何繼續重構這些程式碼,來將 area 函數協調進 Rectangle 類型定義的 area 方法 中。

方法語法

ch05-03-method-syntax.md
commit a86c1d315789b3ca13b20d50ad5005c62bdd9e37

方法 與函數類似:它們使用 fn 關鍵字和名稱聲明,可以擁有參數和返回值,同時包含在某處調用該方法時會執行的代碼。不過方法與函數是不同的,因為它們在結構體的上下文中被定義(或者是枚舉或 trait 對象的上下文,將分別在第六章和第十七章講解),並且它們第一個參數總是 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!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

範例 5-13:在 Rectangle 結構體上定義 area 方法

為了使函數定義於 Rectangle 的上下文中,我們開始了一個 impl 塊(implimplementation 的縮寫)。接著將 area 函數移動到 impl 大括號中,並將簽名中的第一個(在這裡也是唯一一個)參數和函數體中其他地方的對應參數改成 self。然後在 main 中將我們先前調用 area 方法並傳遞 rect1 作為參數的地方,改成使用 方法語法method syntax)在 Rectangle 實例上調用 area 方法。方法語法獲取一個實例並加上一個點號,後跟方法名、圓括號以及任何參數。

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++ 語言中,有兩個不同的運算符來調用方法:. 直接在對象上調用方法,而 -> 在一個對象的指針上調用方法,這時需要先解引用(dereference)指針。換句話說,如果 object 是一個指針,那麼 object->something() 就像 (*object).something() 一樣。

Rust 並沒有一個與 -> 等效的運算符;相反,Rust 有一個叫 自動引用和解引用automatic referencing and dereferencing)的功能。方法調用是 Rust 中少數幾個擁有這種行為的地方。

他是這樣工作的:當使用 object.something() 調用方法時,Rust 會自動為 object 添加 &&mut* 以便使 object 與方法簽名匹配。也就是說,這些程式碼是等價的:


#![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 能完全包含第二個長方形則返回 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!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

範例 5-14:使用還未實現的 can_hold 方法

同時我們希望看到如下輸出,因為 rect2 的兩個維度都小於 rect1,而 rect3rect1 要寬:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

因為我們想定義一個方法,所以它應該位於 impl Rectangle 塊中。方法名是 can_hold,並且它會獲取另一個 Rectangle 的不可變借用作為參數。透過觀察調用方法的代碼可以看出參數是什麼類型的:rect1.can_hold(&rect2) 傳入了 &rect2,它是一個 Rectangle 的實例 rect2 的不可變借用。這是可以理解的,因為我們只需要讀取 rect2(而不是寫入,這意味著我們需要一個不可變借用),而且希望 main 保持 rect2 的所有權,這樣就可以在調用這個方法後繼續使用它。can_hold 的返回值是一個布爾值,其實現會分別檢查 self 的寬高是否都大於另一個 Rectangle。讓我們在範例 5-13 的 impl 塊中增加這個新的 can_hold 方法,如範例 5-15 所示:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
#[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
    }
}
}

範例 5-15:在 Rectangle 上實現 can_hold 方法,它獲取另一個 Rectangle 實例作為參數

如果結合範例 5-14 的 main 函數來運行,就會看到期望的輸出。在方法簽名中,可以在 self 後增加多個參數,而且這些參數就像函數中的參數一樣工作。

關聯函數

impl 塊的另一個有用的功能是:允許在 impl 塊中定義 self 作為參數的函數。這被稱為 關聯函數associated functions),因為它們與結構體相關聯。它們仍是函數而不是方法,因為它們並不作用於一個結構體的實例。你已經使用過 String::from 關聯函數了。

關聯函數經常被用作返回一個結構體新實例的構造函數。例如我們可以提供一個關聯函數,它接受一個維度參數並且同時作為寬和高,這樣可以更輕鬆的創建一個正方形 Rectangle 而不必指定兩次同樣的值:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}
}

使用結構體名和 :: 語法來調用這個關聯函數:比如 let sq = Rectangle::square(3);。這個方法位於結構體的命名空間中::: 語法用於關聯函數和模組創建的命名空間。第七章會講到模組。

多個 impl

每個結構體都允許擁有多個 impl 塊。例如,範例 5-16 中的代碼等同於範例 5-15,但每個方法有其自己的 impl 塊。


#![allow(unused)]
fn main() {
#[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
    }
}
}

範例 5-16:使用多個 impl 塊重寫範例 5-15

這裡沒有理由將這些方法分散在多個 impl 塊中,不過這是有效的語法。第十章討論泛型和 trait 時會看到實用的多 impl 塊的用例。

總結

結構體讓你可以創建出在你的領域中有意義的自訂類型。通過結構體,我們可以將相關聯的數據片段聯繫起來並命名它們,這樣可以使得代碼更加清晰。方法允許為結構體實例指定行為,而關聯函數將特定功能置於結構體的命名空間中並且無需一個實例。

但結構體並不是創建自訂類型的唯一方法:讓我們轉向 Rust 的枚舉功能,為你的工具箱再添一個工具。

枚舉和模式匹配

ch06-00-enums.md
commit a5a03d8f61a5b2c2111b21031a3f526ef60844dd

本章介紹 枚舉enumerations),也被稱作 enums。枚舉允許你通過列舉可能的 成員variants) 來定義一個類型。首先,我們會定義並使用一個枚舉來展示它是如何連同數據一起編碼訊息的。接下來,我們會探索一個特別有用的枚舉,叫做 Option,它代表一個值要嘛是某個值要嘛什麼都不是。然後會講到在 match 表達式中用模式匹配,針對不同的枚舉值編寫相應要執行的代碼。最後會介紹 if let,另一個簡潔方便處理代碼中枚舉的結構。

枚舉是一個很多語言都有的功能,不過不同語言中其功能各不相同。Rust 的枚舉與 F#、OCaml 和 Haskell 這樣的函數式程式語言中的 代數數據類型algebraic data types)最為相似。

定義枚舉

ch06-01-defining-an-enum.md
commit a5a03d8f61a5b2c2111b21031a3f526ef60844dd

讓我們看看一個需要訴諸於代碼的場景,來考慮為何此時使用枚舉更為合適且實用。假設我們要處理 IP 地址。目前被廣泛使用的兩個主要 IP 標準:IPv4(version four)和 IPv6(version six)。這是我們的程序可能會遇到的所有可能的 IP 地址類型:所以可以 枚舉 出所有可能的值,這也正是此枚舉名字的由來。

任何一個 IP 地址要嘛是 IPv4 的要嘛是 IPv6 的,而且不能兩者都是。IP 地址的這個特性使得枚舉數據結構非常適合這個場景,因為枚舉值只可能是其中一個成員。IPv4 和 IPv6 從根本上講仍是 IP 地址,所以當代碼在處理適用於任何類型的 IP 地址的場景時應該把它們當作相同的類型。

可以通過在代碼中定義一個 IpAddrKind 枚舉來表現這個概念並列出可能的 IP 地址類型,V4V6。這被稱為枚舉的 成員variants):


#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}
}

現在 IpAddrKind 就是一個可以在代碼中使用的自訂數據類型了。

枚舉值

可以像這樣創建 IpAddrKind 兩個不同成員的實例:


#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
}

注意枚舉的成員位於其標識符的命名空間中,並使用兩個冒號分開。這麼設計的益處是現在 IpAddrKind::V4IpAddrKind::V6 都是 IpAddrKind 類型的。例如,接著可以定義一個函數來獲取任何 IpAddrKind


#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}

fn route(ip_type: IpAddrKind) { }
}

現在可以使用任一成員來調用這個函數:


#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}

fn route(ip_type: IpAddrKind) { }

route(IpAddrKind::V4);
route(IpAddrKind::V6);
}

使用枚舉甚至還有更多優勢。進一步考慮一下我們的 IP 地址類型,目前沒有一個存儲實際 IP 地址 數據 的方法;只知道它是什麼 類型 的。考慮到已經在第五章學習過結構體了,你可能會像範例 6-1 那樣處理這個問題:


#![allow(unused)]
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:將 IP 地址的數據和 IpAddrKind 成員存儲在一個 struct

這裡我們定義了一個有兩個欄位的結構體 IpAddrIpAddrKind(之前定義的枚舉)類型的 kind 欄位和 String 類型 address 欄位。我們有這個結構體的兩個實例。第一個,home,它的 kind 的值是 IpAddrKind::V4 與之相關聯的地址數據是 127.0.0.1。第二個實例,loopbackkind 的值是 IpAddrKind 的另一個成員,V6,關聯的地址是 ::1。我們使用了一個結構體來將 kindaddress 打包在一起,現在枚舉成員就與值相關聯了。

我們可以使用一種更簡潔的方式來表達相同的概念,僅僅使用枚舉並將數據直接放進每一個枚舉成員而不是將枚舉作為結構體的一部分。IpAddr 枚舉的新定義表明了 V4V6 成員都關聯了 String 值:


#![allow(unused)]
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"));
}

我們直接將數據附加到枚舉的每個成員上,這樣就不需要一個額外的結構體了。

用枚舉替代結構體還有另一個優勢:每個成員可以處理不同類型和數量的數據。IPv4 版本的 IP 地址總是含有四個值在 0 和 255 之間的數字部分。如果我們想要將 V4 地址存儲為四個 u8 值而 V6 地址仍然表現為一個 String,這就不能使用結構體了。枚舉則可以輕易處理的這個情況:


#![allow(unused)]
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 {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

這些程式碼展示了可以將任意類型的數據放入枚舉成員中:例如字串、數字類型或者結構體。甚至可以包含另一個枚舉!另外,標準庫中的類型通常並不比你設想出來的要複雜多少。

注意雖然標準庫中包含一個 IpAddr 的定義,仍然可以創建和使用我們自己的定義而不會有衝突,因為我們並沒有將標準庫中的定義引入作用域。第七章會講到如何導入類型。

來看看範例 6-2 中的另一個枚舉的例子:它的成員中內嵌了多種多樣的類型:


#![allow(unused)]
fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
}

範例 6-2:一個 Message 枚舉,其每個成員都存儲了不同數量和類型的值

這個枚舉有四個含有不同類型的成員:

  • Quit 沒有關聯任何數據。
  • Move 包含一個匿名結構體。
  • Write 包含單獨一個 String
  • ChangeColor 包含三個 i32

定義一個如範例 6-2 中所示那樣的有關聯值的枚舉的方式和定義多個不同類型的結構體的方式很相像,除了枚舉不使用 struct 關鍵字以及其所有成員都被組合在一起位於 Message 類型下。如下這些結構體可以包含與之前枚舉成員中相同的數據:


#![allow(unused)]
fn main() {
struct QuitMessage; // 類單元結構體
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 元組結構體
struct ChangeColorMessage(i32, i32, i32); // 元組結構體
}

不過,如果我們使用不同的結構體,由於它們都有不同的類型,我們將不能像使用範例 6-2 中定義的 Message 枚舉那樣,輕易的定義一個能夠處理這些不同類型的結構體的函數,因為枚舉是單獨一個類型。

結構體和枚舉還有另一個相似點:就像可以使用 impl 來為結構體定義方法那樣,也可以在枚舉上定義方法。這是一個定義於我們 Message 枚舉上的叫做 call 的方法:


#![allow(unused)]
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 來獲取調用方法的值。這個例子中,創建了一個值為 Message::Write(String::from("hello")) 的變數 m,而且這就是當 m.call() 運行時 call 方法中的 self 的值。

讓我們看看標準庫中的另一個非常常見且實用的枚舉:Option

Option 枚舉和其相對於空值的優勢

在之前的部分,我們看到了 IpAddr 枚舉如何利用 Rust 的類型系統在程序中編碼更多訊息而不單單是數據。接下來我們分析一個 Option 的案例,Option 是標準庫定義的另一個枚舉。Option 類型應用廣泛因為它編碼了一個非常普遍的場景,即一個值要嘛有值要嘛沒值。從類型系統的角度來表達這個概念就意味著編譯器需要檢查是否處理了所有應該處理的情況,這樣就可以避免在其他程式語言中非常常見的 bug。

程式語言的設計經常要考慮包含哪些功能,但考慮排除哪些功能也很重要。Rust 並沒有很多其他語言中有的空值功能。空值Null )是一個值,它代表沒有值。在有空值的語言中,變數總是這兩種狀態之一:空值和非空值。

Tony Hoare,null 的發明者,在他 2009 年的演講 “Null References: The Billion Dollar Mistake” 中曾經說到:

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

我稱之為我十億美元的錯誤。當時,我在為一個面向對象語言設計第一個綜合性的面向引用的類型系統。我的目標是透過編譯器的自動檢查來保證所有引用的使用都應該是絕對安全的。不過我未能抵抗住引入一個空引用的誘惑,僅僅是因為它是這麼的容易實現。這引發了無數錯誤、漏洞和系統崩潰,在之後的四十多年中造成了數十億美元的苦痛和傷害。

空值的問題在於當你嘗試像一個非空值那樣使用一個空值,會出現某種形式的錯誤。因為空和非空的屬性無處不在,非常容易出現這類錯誤。

然而,空值嘗試表達的概念仍然是有意義的:空值是一個因為某種原因目前無效或缺失的值。

問題不在於概念而在於具體的實現。為此,Rust 並沒有空值,不過它確實擁有一個可以編碼存在或不存在概念的枚舉。這個枚舉是 Option<T>,而且它定義於標準庫中,如下:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Option<T> 枚舉是如此有用以至於它甚至被包含在了 prelude 之中,你不需要將其顯式引入作用域。另外,它的成員也是如此,可以不需要 Option:: 前綴來直接使用 SomeNone。即便如此 Option<T> 也仍是常規的枚舉,Some(T)None 仍是 Option<T> 的成員。

<T> 語法是一個我們還未講到的 Rust 功能。它是一個泛型類型參數,第十章會更詳細的講解泛型。目前,所有你需要知道的就是 <T> 意味著 Option 枚舉的 Some 成員可以包含任意類型的數據。這裡是一些包含數字類型和字串類型 Option 值的例子:


#![allow(unused)]
fn main() {
let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;
}

如果使用 None 而不是 Some,需要告訴 Rust Option<T> 是什麼類型的,因為編譯器只通過 None 值無法推斷出 Some 成員保存的值的類型。

當有一個 Some 值時,我們就知道存在一個值,而這個值保存在 Some 中。當有個 None 值時,在某種意義上,它跟空值具有相同的意義:並沒有一個有效的值。那麼,Option<T> 為什麼就比空值要好呢?

簡而言之,因為 Option<T>T(這裡 T 可以是任何類型)是不同的類型,編譯器不允許像一個肯定有效的值那樣使用 Option<T>。例如,這段代碼不能編譯,因為它嘗試將 Option<i8>i8 相加:

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

如果運行這些程式碼,將得到類似這樣的錯誤訊息:

error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
 -->
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + std::option::Option<i8>`
  |

很好!事實上,錯誤訊息意味著 Rust 不知道該如何將 Option<i8>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 控制流運算符

ch06-02-match.md
commit b374e75f1d7b743c84a6bb1ef72579a6588bcb8a

Rust 有一個叫做 match 的極為強大的控制流運算符,它允許我們將一個值與一系列的模式相比較,並根據相匹配的模式執行相應代碼。模式可由字面值、變數、通配符和許多其他內容構成;第十八章會涉及到所有不同種類的模式以及它們的作用。match 的力量來源於模式的表現力以及編譯器檢查,它確保了所有可能的情況都得到處理。

可以把 match 表達式想像成某種硬幣分類器:硬幣滑入有著不同大小孔洞的軌道,每一個硬幣都會掉入符合它大小的孔洞。同樣地,值也會通過 match 的每一個模式,並且在遇到第一個 “符合” 的模式時,值會進入相關聯的代碼塊並在執行中被使用。

因為剛剛提到了硬幣,讓我們用它們來作為一個使用 match 的例子!我們可以編寫一個函數來獲取一個未知的硬幣,並以一種類似驗鈔機的方式,確定它是何種硬幣並返回它的美分值,如範例 6-3 中所示。


#![allow(unused)]
fn main() {
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,
    }
}
}

範例 6-3:一個枚舉和一個以枚舉成員作為模式的 match 表達式

拆開 value_in_cents 函數中的 match 來看。首先,我們列出 match 關鍵字後跟一個表達式,在這個例子中是 coin 的值。這看起來非常像 if 使用的表達式,不過這裡有一個非常大的區別:對於 if,表達式必須返回一個布爾值,而這裡它可以是任何類型的。例子中的 coin 的類型是範例 6-3 中定義的 Coin 枚舉。

接下來是 match 的分支。一個分支有兩個部分:一個模式和一些程式碼。第一個分支的模式是值 Coin::Penny 而之後的 => 運算符將模式和將要運行的代碼分開。這裡的代碼就僅僅是值 1。每一個分支之間使用逗號分隔。

match 表達式執行時,它將結果值按順序與每一個分支的模式相比較。如果模式匹配了這個值,這個模式相關聯的代碼將被執行。如果模式並不匹配這個值,將繼續執行下一個分支,非常類似一個硬幣分類器。可以擁有任意多的分支:範例 6-3 中的 match 有四個分支。

每個分支相關聯的代碼是一個表達式,而表達式的結果值將作為整個 match 表達式的返回值。

如果分支代碼較短的話通常不使用大括號,正如範例 6-3 中的每個分支都只是返回一個值。如果想要在分支中運行多行程式碼,可以使用大括號。例如,如下代碼在每次使用Coin::Penny 調用時都會列印出 “Lucky penny!”,同時仍然返回代碼塊最後的值,1


#![allow(unused)]
fn main() {
enum Coin {
   Penny,
   Nickel,
   Dime,
   Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
}

綁定值的模式

匹配分支的另一個有用的功能是可以綁定匹配的模式的部分值。這也就是如何從枚舉成員中提取值的。

作為一個例子,讓我們修改枚舉的一個成員來存放數據。1999 年到 2008 年間,美國在 25 美分的硬幣的一側為 50 個州的每一個都印刷了不同的設計。其他的硬幣都沒有這種區分州的設計,所以只有這些 25 美分硬幣有特殊的價值。可以將這些訊息加入我們的 enum,通過改變 Quarter 成員來包含一個 State 值,範例 6-4 中完成了這些修改:


#![allow(unused)]
fn main() {
#[derive(Debug)] // 這樣可以可以立刻看到州的名稱
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}
}

範例 6-4:Quarter 成員也存放了一個 UsState 值的 Coin 枚舉

想像一下我們的一個朋友嘗試收集所有 50 個州的 25 美分硬幣。在根據硬幣類型分類零錢的同時,也可以報告出每個 25 美分硬幣所對應的州名稱,這樣如果我們的朋友沒有的話,他可以將其加入收藏。

在這些程式碼的匹配表達式中,我們在匹配 Coin::Quarter 成員的分支的模式中增加了一個叫做 state 的變數。當匹配到 Coin::Quarter 時,變數 state 將會綁定 25 美分硬幣所對應州的值。接著在那個分支的代碼中使用 state,如下:


#![allow(unused)]
fn main() {
#[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!("State quarter from {:?}!", state);
            25
        },
    }
}
}

如果調用 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> ,如果其中含有一個值,將其加一。如果其中沒有值,函數應該返回 None 值,而不嘗試執行任何操作。

得益於 match,編寫這個函數非常簡單,它將看起來像範例 6-5 中這樣:


#![allow(unused)]
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 表達式的函數

匹配 Some(T)

讓我們更仔細地檢查 plus_one 的第一行操作。當調用 plus_one(five) 時,plus_one 函數體中的 x 將會是值 Some(5)。接著將其與每個分支比較。

None => None,

Some(5) 並不匹配模式 None,所以繼續進行下一個分支。

Some(i) => Some(i + 1),

Some(5)Some(i) 匹配嗎?當然匹配!它們是相同的成員。i 綁定了 Some 中包含的值,所以 i 的值是 5。接著匹配分支的代碼被執行,所以我們將 i 的值加一併返回一個含有值 6 的新 Some

接著考慮一下範例 6-5 中 plus_one 的第二個調用,這裡 xNone。我們進入 match 並與第一個分支相比較。

None => None,

匹配上了!這裡沒有值來加一,所以程序結束並返回 => 右側的值 None,因為第一個分支就匹配到了,其他的分支將不再比較。

match 與枚舉相結合在很多場景中都是有用的。你會在 Rust 代碼中看到很多這樣的模式:match 一個枚舉,綁定其中的值到一個變數,接著根據其值執行程式碼。這在一開始有點複雜,不過一旦習慣了,你會希望所有語言都擁有它!這一直是用戶的最愛。

匹配是窮盡的

match 還有另一方面需要討論。考慮一下 plus_one 函數的這個版本,它有一個 bug 並不能編譯:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        Some(i) => Some(i + 1),
    }
}

我們沒有處理 None 的情況,所以這些程式碼會造成一個 bug。幸運的是,這是一個 Rust 知道如何處理的 bug。如果嘗試編譯這段代碼,會得到這個錯誤:

error[E0004]: non-exhaustive patterns: `None` not covered
 -->
  |
6 |         match x {
  |               ^ pattern `None` not covered

Rust 知道我們沒有覆蓋所有可能的情況甚至知道哪些模式被忘記了!Rust 中的匹配是 窮盡的exhaustive):必須窮舉到最後的可能性來使代碼有效。特別的在這個 Option<T> 的例子中,Rust 防止我們忘記明確的處理 None 的情況,這使我們免於假設擁有一個實際上為空的值,這造成了之前提到過的價值億萬的錯誤。

_ 通配符

Rust 也提供了一個模式用於不想列舉出所有可能值的場景。例如,u8 可以擁有 0 到 255 的有效的值,如果我們只關心 1、3、5 和 7 這幾個值,就並不想必須列出 0、2、4、6、8、9 一直到 255 的值。所幸我們不必這麼做:可以使用特殊的模式 _ 替代:


#![allow(unused)]
fn main() {
let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}
}

_ 模式會匹配所有的值。透過將其放置於其他分支之後,_ 將會匹配所有之前沒有指定的可能的值。() 就是 unit 值,所以 _ 的情況什麼也不會發生。因此,可以說我們想要對 _ 通配符之前沒有列出的所有可能的值不做任何處理。

然而,match 在只關心 一個 情況的場景中可能就有點囉嗦了。為此 Rust 提供了if let

if let 簡單控制流

ch06-03-if-let.md
commit a86c1d315789b3ca13b20d50ad5005c62bdd9e37

if let 語法讓我們以一種不那麼冗長的方式結合 iflet,來處理只匹配一個模式的值而忽略其他模式的情況。考慮範例 6-6 中的程序,它匹配一個 Option<u8> 值並只希望當值為 3 時執行程式碼:


#![allow(unused)]
fn main() {
let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three"),
    _ => (),
}
}

範例 6-6:match 只關心當值為 Some(3) 時執行程式碼

我們想要對 Some(3) 匹配進行操作但是不想處理任何其他 Some<u8> 值或 None 值。為了滿足 match 表達式(窮盡性)的要求,必須在處理完這唯一的成員後加上 _ => (),這樣也要增加很多樣板代碼。

不過我們可以使用 if let 這種更短的方式編寫。如下代碼與範例 6-6 中的 match 行為一致:


#![allow(unused)]
fn main() {
let some_u8_value = Some(0u8);
if let Some(3) = some_u8_value {
    println!("three");
}
}

if let 獲取通過等號分隔的一個模式和一個表達式。它的工作方式與 match 相同,這裡的表達式對應 match 而模式則對應第一個分支。

使用 if let 意味著編寫更少代碼,更少的縮進和更少的樣板代碼。然而,這樣會失去 match 強制要求的窮盡性檢查。matchif let 之間的選擇依賴特定的環境以及增加簡潔度和失去窮盡性檢查的權衡取捨。

換句話說,可以認為 if letmatch 的一個語法糖,它當值匹配某一模式時執行程式碼而忽略所有其他值。

可以在 if let 中包含一個 elseelse 塊中的代碼與 match 表達式中的 _ 分支塊中的代碼相同,這樣的 match 表達式就等同於 if letelse。回憶一下範例 6-4 中 Coin 枚舉的定義,其 Quarter 成員也包含一個 UsState 值。如果想要計數所有不是 25 美分的硬幣的同時也報告 25 美分硬幣所屬的州,可以使用這樣一個 match 表達式:


#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
   Alabama,
   Alaska,
}

enum Coin {
   Penny,
   Nickel,
   Dime,
   Quarter(UsState),
}
let coin = Coin::Penny;
let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {:?}!", state),
    _ => count += 1,
}
}

或者可以使用這樣的 if letelse 表達式:


#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
   Alabama,
   Alaska,
}

enum Coin {
   Penny,
   Nickel,
   Dime,
   Quarter(UsState),
}
let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {:?}!", state);
} else {
    count += 1;
}
}

如果你的程序遇到一個使用 match 表達起來過於囉嗦的邏輯,記住 if let 也在你的 Rust 工具箱中。

總結

現在我們涉及到了如何使用枚舉來創建有一系列可列舉值的自訂類型。我們也展示了標準庫的 Option<T> 類型是如何幫助你利用類型系統來避免出錯的。當枚舉值包含數據時,你可以根據需要處理多少情況來選擇使用 matchif let 來獲取並使用這些值。

你的 Rust 程序現在能夠使用結構體和枚舉在自己的作用域內表現其內容了。在你的 API 中使用自訂類型保證了類型安全:編譯器會確保你的函數只會得到它期望的類型的值。

為了向你的用戶提供一個組織良好的 API,它使用起來很直觀並且只向用戶暴露他們確實需要的部分,那麼現在就讓我們轉向 Rust 的模組系統吧。

使用包、Crate和模組管理不斷增長的項目

ch07-00-managing-growing-projects-with-packages-crates-and-modules.md
commit 879fef2345bf32751a83a9e779e0cb84e79b6d3d

當你編寫大型程序時,組織你的代碼顯得尤為重要,因為你想在腦海中通曉整個程序,那幾乎是不可能完成的。通過對相關功能進行分組和劃分不同功能的代碼,你可以清楚在哪裡可以找到實現了特定功能的代碼,以及在哪裡可以改變一個功能的工作方式。

到目前為止,我們編寫的程序都在一個文件的一個模組中。伴隨著項目的增長,你可以透過將代碼分解為多個模組和多個文件來組織代碼。一個包可以包含多個二進位制 crate 項和一個可選的 crate 庫。伴隨著包的增長,你可以將包中的部分代碼提取出來,做成獨立的 crate,這些 crate 則作為外部依賴項。本章將會涵蓋所有這些概念。對於一個由一系列相互關聯的包組合而成的超大型項目,Cargo 提供了 “工作空間” 這一功能,我們將在第十四章的 “Cargo Workspaces” 對此進行講解。

除了對功能進行分組以外,封裝實現細節可以使你更高級地重用代碼:你實現了一個操作後,其他的代碼可以透過該代碼的公共介面來進行調用,而不需要知道它是如何實現的。你在編寫程式碼時可以定義哪些部分是其他代碼可以使用的公共部分,以及哪些部分是你有權更改實現細節的私有部分。這是另一種減少你在腦海中記住項目內容數量的方法。

這裡有一個需要說明的概念 “作用域(scope)”:代碼所在的嵌套上下文有一組定義為 “in scope” 的名稱。當閱讀、編寫和編譯代碼時,程式設計師和編譯器需要知道特定位置的特定名稱是否引用了變數、函數、結構體、枚舉、模組、常量或者其他有意義的項。你可以創建作用域,以及改變哪些名稱在作用域內還是作用域外。同一個作用域內不能擁有兩個相同名稱的項;可以使用一些工具來解決名稱衝突。

Rust 有許多功能可以讓你管理代碼的組織,包括哪些內容可以被公開,哪些內容作為私有部分,以及程序每個作用域中的名字。這些功能。這有時被稱為 “模組系統(the module system)”,包括:

  • Packages): Cargo 的一個功能,它允許你構建、測試和分享 crate。
  • Crates :一個模組的樹形結構,它形成了庫或二進位制項目。
  • 模組Modules)和 use: 允許你控制作用域和路徑的私有性。
  • 路徑path):一個命名例如結構體、函數或模組等項的方式

本章將會涵蓋所有這些概念,討論它們如何交互,並說明如何使用它們來管理作用域。到最後,你會對模組系統有深入的了解,並且能夠像專業人士一樣使用作用域!

包和 crate

ch07-01-packages-and-crates.md
commit 879fef2345bf32751a83a9e779e0cb84e79b6d3d

模組系統的第一部分,我們將介紹包和 crate。crate 是一個二進位制項或者庫。crate root 是一個源文件,Rust 編譯器以它為起始點,並構成你的 crate 的根模組(我們將在 “定義模組來控制作用域與私有性” 一節深入解讀)。package) 是提供一系列功能的一個或者多個 crate。一個包會包含有一個 Cargo.toml 文件,闡述如何去構建這些 crate。

包中所包含的內容由幾條規則來確立。一個包中至多 只能 包含一個庫 crate(library crate);包中可以包含任意多個二進位制 crate(binary 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,且 src/lib.rs 是 crate 根。crate 根文件將由 Cargo 傳遞給 rustc 來實際構建庫或者二進位制項目。

在此,我們有了一個只包含 src/main.rs 的包,意味著它只含有一個名為 my-project 的二進位制 crate。如果一個包同時含有 src/main.rssrc/lib.rs,則它有兩個 crate:一個庫和一個二進位制項,且名字都與包相同。透過將文件放在 src/bin 目錄下,一個包可以擁有多個二進位制 crate:每個 src/bin 下的文件都會被編譯成一個獨立的二進位制 crate。

一個 crate 會將一個作用域內的相關功能分組到一起,使得該功能可以很方便地在多個項目之間共享。舉一個例子,我們在 第二章 使用的 rand crate 提供了生成隨機數的功能。透過將 rand crate 加入到我們項目的作用域中,我們就可以在自己的項目中使用該功能。rand crate 提供的所有功能都可以通過該 crate 的名字:rand 進行訪問。

將一個 crate 的功能保持在其自身的作用域中,可以知曉一些特定的功能是在我們的 crate 中定義的還是在 rand crate 中定義的,這可以防止潛在的衝突。例如,rand crate 提供了一個名為 Rng 的特性(trait)。我們還可以在我們自己的 crate 中定義一個名為 Rngstruct。因為一個 crate 的功能是在自身的作用域進行命名的,當我們將 rand 作為一個依賴,編譯器不會混淆 Rng 這個名字的指向。在我們的 crate 中,它指向的是我們自己定義的 struct Rng。我們可以通過 rand::Rng 這一方式來訪問 rand crate 中的 Rng 特性(trait)。

接下來讓我們來說一說模組系統!

定義模組來控制作用域與私有性

ch07-02-defining-modules-to-control-scope-and-privacy.md
commit 34b089627cca09a73ce92a052222304bff0056e3

在本節,我們將討論模組和其它一些關於模組系統的部分,如允許你命名項的 路徑paths);用來將路徑引入作用域的 use 關鍵字;以及使項變為公有的 pub 關鍵字。我們還將討論 as 關鍵字、外部包和 glob 運算符。現在,讓我們把注意力放在模組上!

模組 讓我們可以將一個 crate 中的代碼進行分組,以提高可讀性與重用性。模組還可以控制項的 私有性,即項是可以被外部代碼使用的(public),還是作為一個內部實現的內容,不能被外部代碼使用(private)。

在餐飲業,餐館中會有一些地方被稱之為 前台front of house),還有另外一些地方被稱之為 後台back of house)。前台是招待顧客的地方,在這裡,店主可以為顧客安排座位,服務員接受顧客下單和付款,調酒師會製作飲品。後台則是由廚師工作的廚房,洗碗工的工作地點,以及經理做行政工作的地方組成。

我們可以將函數放置到嵌套的模組中,來使我們的 crate 結構與實際的餐廳結構相同。通過執行 cargo new --lib restaurant,來創建一個新的名為 restaurant 的庫。然後將範例 7-1 中所羅列出來的代碼放入 src/lib.rs 中,來定義一些模組和函數。

Filename: src/lib.rs


#![allow(unused)]
fn main() {
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn server_order() {}

        fn take_payment() {}
    }
}
}

範例 7-1:一個包含了其他內建了函數的模組的 front_of_house 模組

我們定義一個模組,是以 mod 關鍵字為起始,然後指定模組的名字(本例中叫做 front_of_house),並且用花括號包圍模組的主體。在模組內,我們還可以定義其他的模組,就像本例中的 hostingserving 模組。模組還可以保存一些定義的其他項,比如結構體、枚舉、常量、特性、或者函數。

透過使用模組,我們可以將相關的定義分組到一起,並指出他們為什麼相關。程式設計師可以透過使用這段代碼,更加容易地找到他們想要的定義,因為他們可以基於分組來對代碼進行導航,而不需要閱讀所有的定義。程式設計師向這段代碼中添加一個新的功能時,他們也會知道代碼應該放置在何處,可以保持程序的組織性。

在前面我們提到了,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 的隱式模組下。

這個模組樹可能會令你想起電腦上文件系統的目錄樹;這是一個非常恰當的比喻!就像文件系統的目錄,你可以使用模組來組織你的代碼。並且,就像目錄中的文件,我們需要一種方法來找到模組。

路徑用於引用模組樹中的項

ch07-03-paths-for-referring-to-an-item-in-the-module-tree.md
commit cc6a1ef2614aa94003566027b285b249ccf961fa

來看一下 Rust 如何在模組樹中找到一個項的位置,我們使用路徑的方式,就像在文件系統使用路徑一樣。如果我們想要調用一個函數,我們需要知道它的路徑。

路徑有兩種形式:

  • 絕對路徑absolute path)從 crate 根開始,以 crate 名或者字面值 crate 開頭。
  • 相對路徑relative path)從當前模組開始,以 selfsuper 或當前模組的標識符開頭。

絕對路徑和相對路徑都後跟一個或多個由雙冒號(::)分割的標識符。

讓我們回到範例 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() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    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();
   |                     ^^^^^^^

範例 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 函數,因此我們使用 pub 關鍵字來標記 hosting 模組,如範例 7-5 所示。

檔案名: src/lib.rs

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

範例 7-5: 使用 pub 關鍵字聲明 hosting 模組使其可在 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();
   |                              ^^^^^^^^^^^^^^^

範例 7-6: 構建範例 7-5 出現的編譯器錯誤

發生了什麼事?在 mod hosting 前添加了 pub 關鍵字,使其變成公有的。伴隨著這種變化,如果我們可以訪問 front_of_house,那我們也可以訪問 hosting。但是 hosting內容contents) 仍然是私有的;這表明使模組公有並不使其內容也是公有的。模組上的 pub 關鍵字只允許其父模組引用它。

範例 7-6 中的錯誤說,add_to_waitlist 函數是私有的。私有性規則不但應用於模組,還應用於結構體、枚舉、函數和方法。

讓我們繼續將 pub 關鍵字放置在 add_to_waitlist 函數的定義之前,使其變成公有。如範例 7-7 所示。

檔案名: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    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 根開始。然後 crate 根中定義了 front_of_house 模組。front_of_house 模組不是公有的,不過因為 eat_at_restaurant 函數與 front_of_house 定義於同一模組中(即,eat_at_restaurantfront_of_house 是兄弟),我們可以從 eat_at_restaurant 中引用 front_of_house。接下來是使用 pub 標記的 hosting 模組。我們可以訪問 hosting 的父模組,所以可以訪問 hosting。最後,add_to_waitlist 函數被標記為 pub ,我們可以訪問其父模組,所以這個函數調用是有效的!

在相對路徑,其邏輯與絕對路徑相同,除了第一步:不同於從 crate 根開始,路徑從 front_of_house 開始。front_of_house 模組與 eat_at_restaurant 定義於同一模組,所以從 eat_at_restaurant 中開始定義的該模組相對路徑是有效的。接下來因為 hostingadd_to_waitlist 被標記為 pub,路徑其餘的部分也是有效的,因此函數調用也是有效的!

使用 super 起始的相對路徑

我們還可以使用 super 開頭來構建從父模組開始的相對路徑。這麼做類似於文件系統中以 .. 開頭的語法。我們為什麼要這樣做呢?

考慮一下範例 7-8 中的代碼,它模擬了廚師更正了一個錯誤訂單,並親自將其提供給客戶的情況。fix_incorrect_order 函數通過指定的 super 起始的 serve_order 路徑,來調用 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("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal
    // meal.seasonal_fruit = String::from("blueberries");
}
}

範例 7-9: 帶有公有和私有欄位的結構體

因為 back_of_house::Breakfast 結構體的 toast 欄位是公有的,所以我們可以在 eat_at_restaurant 中使用點號來隨意的讀寫 toast 欄位。注意,我們不能在 eat_at_restaurant 中使用 seasonal_fruit 欄位,因為 seasonal_fruit 是私有的。嘗試去除那一行修改 seasonal_fruit 欄位值的代碼的注釋,看看你會得到什麼錯誤!

還請注意一點,因為 back_of_house::Breakfast 具有私有欄位,所以這個結構體需要提供一個公共的關聯函數來構造 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 關鍵字將名稱引入作用域

ch07-04-bringing-paths-into-scope-with-the-use-keyword.md
commit 6d3e76820418f2d2bb203233c61d90390b5690f1

到目前為止,似乎我們編寫的用於調用函數的路徑都很冗長且重複,並不方便。例如,範例 7-7 中,無論我們選擇 add_to_waitlist 函數的絕對路徑還是相對路徑,每次我們想要調用 add_to_waitlist 時,都必須指定front_of_househosting。幸運的是,有一種方法可以簡化這個過程。我們可以使用 use 關鍵字將路徑一次性引入作用域,然後調用該路徑中的項,就如同它們是本地項一樣。

在範例 7-11 中,我們將 crate::front_of_house::hosting 模組引入了 eat_at_restaurant 函數的作用域,而我們只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中調用 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 在作用域中就是有效的名稱了,如同 hosting 模組被定義於 crate 根一樣。通過 use 引入作用域的路徑也會檢查私有性,同其它路徑一樣。

你還可以使用 use 和相對路徑來將一個項引入作用域。範例 7-12 展示了如何指定相對路徑來取得與範例 7-11 中一樣的行為。

檔案名: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use 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 中調用 hosting::add_to_waitlist ,而不是通過指定一直到 add_to_waitlist 函數的 use 路徑來得到相同的結果,如範例 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 {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
}

範例 7-15: 使用父模組將兩個具有相同名稱的類型引入同一作用域

如你所見,使用父模組可以區分這兩個 Result 類型。如果我們是指定 use std::fmt::Resultuse std::io::Result,我們將在同一作用域擁有了兩個 Result 類型,當我們使用 Result 時,Rust 則不知道我們要用的是哪個。

使用 as 關鍵字提供新的名稱

使用 use 將兩個同名類型引入同一作用域這個問題還有另一個解決辦法:在這個類型的路徑後面,我們使用 as 指定一個新的本地名稱或者別名。範例 7-16 展示了另一個編寫範例 7-15 中代碼的方法,通過 as 重命名其中一個 Result 類型。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
}

範例 7-16: 使用 as 關鍵字重命名引入作用域的類型

在第二個 use 語句中,我們選擇 IoResult 作為 std::io::Result 的新名稱,它與從 std::fmt 引入作用域的 Result 並不衝突。範例 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 useeat_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 定義引入項目包的作用域,我們加入一行 use 起始的包名,它以 rand 包名開頭並列出了需要引入作用域的項。回憶一下第二章的 “生成一個隨機數” 部分,我們曾將 Rng trait 引入作用域並調用了 rand::thread_rng 函數:

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1, 101);
}

crates.io 上有很多 Rust 社區成員發布的包,將其引入你自己的項目都需要一道相同的步驟:在 Cargo.toml 列出它們並通過 use 將其中定義的項引入項目包的作用域中。

注意標準庫(std)對於你的包來說也是外部 crate。因為標準庫隨 Rust 語言一同分發,無需修改 Cargo.toml 來引入 std,不過需要通過 use 將標準庫中定義的項引入項目包的作用域中來引用它們,比如我們使用的 HashMap


#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

這是一個以標準庫 crate 名 std 開頭的絕對路徑。

嵌套路徑來消除大量的 use

當需要引入很多定義於相同包或相同模組的項時,為每一項單獨列出一行會占用原始碼很大的空間。例如猜猜看章節範例 2-4 中有兩行 use 語句都從 std 引入項到作用域:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::cmp::Ordering;
use std::io;
// ---snip---
}

相反,我們可以使用嵌套路徑將相同的項在一行中引入作用域。這麼做需要指定路徑的相同部分,接著是兩個冒號,接著是大括號中的各自不同的路徑部分,如範例 7-18 所示。

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::{cmp::Ordering, io};
// ---snip---
}

範例 7-18: 指定嵌套的路徑在一行中將多個帶有相同前綴的項引入作用域

在較大的程序中,使用嵌套路徑從相同包或模組中引入很多項,可以顯著減少所需的獨立 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 運算符將所有的公有定義引入作用域

如果希望將一個路徑下 所有 公有項引入作用域,可以指定路徑後跟 *,glob 運算符:


#![allow(unused)]
fn main() {
use std::collections::*;
}

這個 use 語句將 std::collections 中定義的所有公有項引入當前作用域。使用 glob 運算符時請多加小心!Glob 會使得我們難以推導作用域中有什麼名稱和它們是在何處定義的。

glob 運算符經常用於測試模組 tests 中,這時會將所有內容引入作用域;我們將在第十一章 “如何編寫測試” 部分講解。glob 運算符有時也用於 prelude 模式;查看 標準庫中的文件 了解這個模式的更多細節。

將模組分割進不同文件

ch07-05-separating-modules-into-different-files.md
commit a5a5bf9d6ea5763a9110f727911a21da854b1d90

到目前為止,本章所有的例子都在一個文件中定義多個模組。當模組變得更大時,你可能想要將它們的定義移動到單獨的文件中,從而使代碼更容易閱讀。

例如,我們從範例 7-17 開始,將 front_of_house 模組移動到屬於它自己的文件 src/front_of_house.rs 中,通過改變 crate 根文件,使其包含範例 7-21 所示的代碼。在這個例子中,crate 根文件是 src/lib.rs,這也同樣適用於以 src/main.rs 為 crate 根文件的二進位制 crate 項。

檔案名: 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

src/front_of_house.rs 會獲取 front_of_house 模組的定義內容,如範例 7-22 所示。

檔案名: src/front_of_house.rs


#![allow(unused)]
fn main() {
pub mod hosting {
    pub fn add_to_waitlist() {}
}
}

範例 7-22: 在 src/front_of_house.rs 中定義 front_of_house 模組

mod front_of_house 後使用分號,而不是代碼塊,這將告訴 Rust 在另一個與模組同名的文件中載入模組的內容。繼續重構我們例子,將 hosting 模組也提取到其自己的文件中,僅對 src/front_of_house.rs 包含 hosting 模組的聲明進行修改:

檔案名: src/front_of_house.rs


#![allow(unused)]
fn main() {
pub mod hosting;
}

接著我們創建一個 src/front_of_house 目錄和一個包含 hosting 模組定義的 src/front_of_house/hosting.rs 文件:

檔案名: src/front_of_house/hosting.rs

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 關鍵字使其定義變為公有。

接下來,讓我們看看一些標準庫提供的集合數據類型,你可以利用它們編寫出漂亮整潔的代碼。

常見集合

ch08-00-common-collections.md
commit 820ac357f6cf0e866e5a8e7a9c57dd3e17e9f8ca

Rust 標準庫中包含一系列被稱為 集合collections)的非常有用的數據結構。大部分其他數據類型都代表一個特定的值,不過集合可以包含多個值。不同於內建的數組和元組類型,這些集合指向的數據是儲存在堆上的,這意味著數據的數量不必在編譯時就已知,並且還可以隨著程序的運行增長或縮小。每種集合都有著不同功能和成本,而根據當前情況選擇合適的集合,這是一項應當逐漸掌握的技能。在這一章裡,我們將詳細的了解三個在 Rust 程序中被廣泛使用的集合:

  • vector 允許我們一個挨著一個地儲存一系列數量可變的值
  • 字串string)是字元的集合。我們之前見過 String 類型,不過在本章我們將深入了解。
  • 哈希 maphash map)允許我們將值與一個特定的鍵(key)相關聯。這是一個叫做 map 的更通用的數據結構的特定實現。

對於標準庫提供的其他類型的集合,請查看文件

我們將討論如何創建和更新 vector、字串和哈希 map,以及它們有什麼特別之處。

vector 用來儲存一系列的值

ch08-01-vectors.md
commit 76df60bccead5f3de96db23d97b69597cd8a2b82

我們要講到的第一個類型是 Vec<T>,也被稱為 vector。vector 允許我們在一個單獨的數據結構中儲存多於一個的值,它在記憶體中彼此相鄰地排列所有的值。vector 只能儲存相同類型的值。它們在擁有一系列項的場景下非常實用,例如文件中的文本行或是購物車中商品的價格。

新建 vector

為了創建一個新的空 vector,可以調用 Vec::new 函數,如範例 8-1 所示:


#![allow(unused)]
fn main() {
let v: Vec<i32> = Vec::new();
}

範例 8-1:新建一個空的 vector 來儲存 i32 類型的值

注意這裡我們增加了一個類型註解。因為沒有向這個 vector 中插入任何值,Rust 並不知道我們想要儲存什麼類型的元素。這是一個非常重要的點。vector 是用泛型實現的,第十章會涉及到如何對你自己的類型使用它們。現在,所有你需要知道的就是 Vec 是一個由標準庫提供的類型,它可以存放任何類型,而當 Vec 存放某個特定類型時,那個類型位於角括號中。在範例 8-1 中,我們告訴 Rust v 這個 Vec 將存放 i32 類型的元素。

在更實際的代碼中,一旦插入值 Rust 就可以推斷出想要存放的類型,所以你很少會需要這些類型註解。更常見的做法是使用初始值來創建一個 Vec,而且為了方便 Rust 提供了 vec! 宏。這個宏會根據我們提供的值來創建一個新的 Vec。範例 8-2 新建一個擁有值 123Vec<i32>


#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
}

範例 8-2:新建一個包含初值的 vector

因為我們提供了 i32 類型的初始值,Rust 可以推斷出 v 的類型是 Vec<i32>,因此類型註解就不是必須的。接下來讓我們看看如何修改一個 vector。

更新 vector

對於新建一個 vector 並向其增加元素,可以使用 push 方法,如範例 8-3 所示:


#![allow(unused)]
fn main() {
let mut v = Vec::new();

v.push(5);
v.push(6);
v.push(7);
v.push(8);
}

範例 8-3:使用 push 方法向 vector 增加值

如第三章中討論的任何變數一樣,如果想要能夠改變它的值,必須使用 mut 關鍵字使其可變。放入其中的所有值都是 i32 類型的,而且 Rust 也根據數據做出如此判斷,所以不需要 Vec<i32> 註解。

丟棄 vector 時也會丟棄其所有元素

類似於任何其他的 struct,vector 在其離開作用域時會被釋放,如範例 8-4 所標註的:


#![allow(unused)]
fn main() {
{
    let v = vec![1, 2, 3, 4];

    // 處理變數 v

} // <- 這裡 v 離開作用域並被丟棄
}

範例 8-4:展示 vector 和其元素於何處被丟棄

當 vector 被丟棄時,所有其內容也會被丟棄,這意味著這裡它包含的整數將被清理。這可能看起來非常直觀,不過一旦開始使用 vector 元素的引用,情況就變得有些複雜了。下面讓我們處理這種情況!

讀取 vector 的元素

現在你知道如何創建、更新和銷毀 vector 了,接下來的一步最好了解一下如何讀取它們的內容。有兩種方法引用 vector 中儲存的值。為了更加清楚的說明這個例子,我們標註這些函數返回的值的類型。

範例 8-5 展示了訪問 vector 中一個值的兩種方式,索引語法或者 get 方法:


#![allow(unused)]
fn main() {
let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
println!("The third element is {}", third);

match v.get(2) {
    Some(third) => println!("The third element is {}", third),
    None => println!("There is no third element."),
}
}

列表 8-5:使用索引語法或 get 方法來訪問 vector 中的項

這裡有兩個需要注意的地方。首先,我們使用索引值 2 來獲取第三個元素,索引是從 0 開始的。其次,這兩個不同的獲取第三個元素的方式分別為:使用 &[] 返回一個引用;或者使用 get 方法以索引作為參數來返回一個 Option<&T>

Rust 有兩個引用元素的方法的原因是程序可以選擇如何處理當索引值在 vector 中沒有對應值的情況。作為一個例子,讓我們看看如果有一個有五個元素的 vector 接著嘗試訪問索引為 100 的元素時程序會如何處理,如範例 8-6 所示:


#![allow(unused)]
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:嘗試訪問一個包含 5 個元素的 vector 的索引 100 處的元素

當運行這段代碼,你會發現對於第一個 [] 方法,當引用一個不存在的元素時 Rust 會造成 panic。這個方法更適合當程序認為嘗試訪問超過 vector 結尾的元素是一個嚴重錯誤的情況,這時應該使程序崩潰。

get 方法被傳遞了一個數組外的索引時,它不會 panic 而是返回 None。當偶爾出現超過 vector 範圍的訪問屬於正常情況的時候可以考慮使用它。接著你的代碼可以有處理 Some(&element)None 的邏輯,如第六章討論的那樣。例如,索引可能來源於用戶輸入的數字。如果它們不慎輸入了一個過大的數字那麼程序就會得到 None 值,你可以告訴用戶當前 vector 元素的數量並再請求它們輸入一個有效的值。這就比因為輸入錯誤而使程序崩潰要友好的多!

一旦程序獲取了一個有效的引用,借用檢查器將會執行所有權和借用規則(第四章講到)來確保 vector 內容的這個引用和任何其他引用保持有效。回憶一下不能在相同作用域中同時存在可變和不可變引用的規則。這個規則適用於範例 8-7,當我們獲取了 vector 的第一個元素的不可變引用並嘗試在 vector 末尾增加一個元素的時候,這是行不通的:

let mut v = vec![1, 2, 3, 4, 5];

let first = &v[0];

v.push(6);

println!("The first element is: {}", first);

範例 8-7:在擁有 vector 中項的引用的同時向其增加一個元素

編譯會給出這個錯誤:

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!("The first element is: {}", first);
  |                                          ----- immutable borrow later used here

範例 8-7 中的代碼看起來應該能夠運行:為什麼第一個元素的引用會關心 vector 結尾的變化?不能這麼做的原因是由於 vector 的工作方式:在 vector 的結尾增加新元素時,在沒有足夠空間將所有所有元素依次相鄰存放的情況下,可能會要求分配新記憶體並將老的元素拷貝到新的空間中。這時,第一個元素的引用就指向了被釋放的記憶體。借用規則阻止程式陷入這種狀況。

注意:關於 Vec<T> 類型的更多實現細節,在 https://doc.rust-lang.org/stable/nomicon/vec.html 查看 “The Nomicon”

遍歷 vector 中的元素

如果想要依次訪問 vector 中的每一個元素,我們可以遍歷其所有的元素而無需通過索引一次一個的訪問。範例 8-8 展示了如何使用 for 循環來獲取 i32 值的 vector 中的每一個元素的不可變引用並將其列印:


#![allow(unused)]
fn main() {
let v = vec![100, 32, 57];
for i in &v {
    println!("{}", i);
}
}

範例 8-8:通過 for 循環遍歷 vector 的元素並列印

我們也可以遍歷可變 vector 的每一個元素的可變引用以便能改變他們。範例 8-9 中的 for 循環會給每一個元素加 50


#![allow(unused)]
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
    *i += 50;
}
}

範例8-9:遍歷 vector 中元素的可變引用

為了修改可變引用所指向的值,在使用 += 運算符之前必須使用解引用運算符(*)獲取 i 中的值。第十五章的 “透過解引用運算符追蹤指針的值” 部分會詳細介紹解引用運算符。

使用枚舉來儲存多種類型

在本章的開始,我們提到 vector 只能儲存相同類型的值。這是很不方便的;絕對會有需要儲存一系列不同類型的值的用例。幸運的是,枚舉的成員都被定義為相同的枚舉類型,所以當需要在 vector 中儲存不同類型值時,我們可以定義並使用一個枚舉!

例如,假如我們想要從電子表格的一行中獲取值,而這一行的有些列包含數字,有些包含浮點值,還有些是字串。我們可以定義一個枚舉,其成員會存放這些不同類型的值,同時所有這些枚舉成員都會被當作相同類型,那個枚舉的類型。接著可以創建一個儲存枚舉值的 vector,這樣最終就能夠儲存不同類型的值了。範例 8-10 展示了其用例:


#![allow(unused)]
fn main() {
enum SpreadsheetCell {
    Int(i32),
    Float(f64),
    Text(String),
}

let row = vec![
    SpreadsheetCell::Int(3),
    SpreadsheetCell::Text(String::from("blue")),
    SpreadsheetCell::Float(10.12),
];
}

範例 8-10:定義一個枚舉,以便能在 vector 中存放不同類型的數據

Rust 在編譯時就必須準確的知道 vector 中類型的原因在於它需要知道儲存每個元素到底需要多少記憶體。第二個好處是可以準確的知道這個 vector 中允許什麼類型。如果 Rust 允許 vector 存放任意類型,那麼當對 vector 元素執行操作時一個或多個類型的值就有可能會造成錯誤。使用枚舉外加 match 意味著 Rust 能在編譯時就保證總是會處理所有可能的情況,正如第六章講到的那樣。

如果在編寫程式時不能確切無遺地知道運行時會儲存進 vector 的所有類型,枚舉技術就行不通了。相反,你可以使用 trait 對象,第十七章會講到它。

現在我們了解了一些使用 vector 的最常見的方式,請一定去看看標準庫中 Vec 定義的很多其他實用方法的 API 文件。例如,除了 push 之外還有一個 pop 方法,它會移除並返回 vector 的最後一個元素。讓我們繼續下一個集合類型:String

使用字串存儲 UTF-8 編碼的文本

ch08-02-strings.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

第四章已經講過一些字串的內容,不過現在讓我們更深入地了解它。字串是新晉 Rustacean 們通常會被困住的領域,這是由於三方面理由的結合:Rust 傾向於確保暴露出可能的錯誤,字串是比很多程式設計師所想像的要更為複雜的數據結構,以及 UTF-8。所有這些要素結合起來對於來自其他語言背景的程式設計師就可能顯得很困難了。

在集合章節中討論字串的原因是,字串就是作為位元組的集合外加一些方法實現的,當這些位元組被解釋為文本時,這些方法提供了實用的功能。在這一部分,我們會講到 String 中那些任何集合類型都有的操作,比如創建、更新和讀取。也會討論 String 與其他集合不一樣的地方,例如索引 String 是很複雜的,由於人和計算機理解 String 數據方式的不同。

什麼是字串?

在開始深入這些方面之前,我們需要討論一下術語 字串 的具體意義。Rust 的核心語言中只有一種字串類型:str,字串 slice,它通常以被借用的形式出現,&str。第四章講到了 字串 slice:它們是一些儲存在別處的 UTF-8 編碼字串數據的引用。比如字串字面值被儲存在程序的二進位制輸出中,字串 slice 也是如此。

稱作 String 的類型是由標準庫提供的,而沒有寫進核心語言部分,它是可增長的、可變的、有所有權的、UTF-8 編碼的字串類型。當 Rustacean 們談到 Rust 的 “字串”時,它們通常指的是 String 和字串 slice &str 類型,而不僅僅是其中之一。雖然本部分內容大多是關於 String 的,不過這兩個類型在 Rust 標準庫中都被廣泛使用,String 和字串 slice 都是 UTF-8 編碼的。

Rust 標準庫中還包含一系列其他字串類型,比如 OsStringOsStrCStringCStr。相關庫 crate 甚至會提供更多儲存字串數據的選擇。看到這些由 String 或是 Str 結尾的名字了嗎?這對應著它們提供的所有權和可借用的字串變體,就像是你之前看到的 Stringstr。舉例而言,這些字串類型能夠以不同的編碼,或者記憶體表現形式上以不同的形式,來存儲文本內容。本章將不會討論其他這些字串類型,更多有關如何使用它們以及各自適合的場景,請參見其API文件。

新建字串

很多 Vec 可用的操作在 String 中同樣可用,從以 new 函數創建字串開始,如範例 8-11 所示。


#![allow(unused)]
fn main() {
let mut s = String::new();
}

範例 8-11:新建一個空的 String

這新建了一個叫做 s 的空的字串,接著我們可以向其中裝載數據。通常字串會有初始數據,因為我們希望一開始就有這個字串。為此,可以使用 to_string 方法,它能用於任何實現了 Display trait 的類型,字串字面值也實現了它。範例 8-12 展示了兩個例子。


#![allow(unused)]
fn main() {
let data = "initial contents";

let s = data.to_string();

// 該方法也可直接用於字串字面值:
let s = "initial contents".to_string();
}

範例 8-12:使用 to_string 方法從字串字面值創建 String

這些程式碼會創建包含 initial contents 的字串。

也可以使用 String::from 函數來從字串字面值創建 String。範例 8-13 中的代碼代碼等同於使用 to_string


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

範例 8-13:使用 String::from 函數從字串字面值創建 String

因為字串應用廣泛,這裡有很多不同的用於字串的通用 API 可供選擇。其中一些可能看起來多餘,不過都有其用武之地!在這個例子中,String::from.to_string 最終做了完全相同的工作,所以如何選擇就是風格問題了。

記住字串是 UTF-8 編碼的,所以可以包含任何可以正確編碼的數據,如範例 8-14 所示。


#![allow(unused)]
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 值。

更新字串

String 的大小可以增加,其內容也可以改變,就像可以放入更多數據來改變 Vec 的內容一樣。另外,可以方便的使用 + 運算符或 format! 宏來拼接 String 值。

使用 push_strpush 附加字串

可以通過 push_str 方法來附加字串 slice,從而使 String 變長,如範例 8-15 所示。


#![allow(unused)]
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}

範例 8-15:使用 push_str 方法向 String 附加字串 slice

執行這兩行程式碼之後,s 將會包含 foobarpush_str 方法採用字串 slice,因為我們並不需要獲取參數的所有權。例如,範例 8-16 展示了如果將 s2 的內容附加到 s1 之後,自身不能被使用就糟糕了。


#![allow(unused)]
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);
}

範例 8-16:將字串 slice 的內容附加到 String 後使用它

如果 push_str 方法獲取了 s2 的所有權,就不能在最後一行列印出其值了。好在代碼如我們期望那樣工作!

push 方法被定義為獲取一個單獨的字元作為參數,並附加到 String 中。範例 8-17 展示了使用 push 方法將字母 l 加入 String 的代碼。


#![allow(unused)]
fn main() {
let mut s = String::from("lo");
s.push('l');
}

範例 8-17:使用 push 將一個字元加入 String 值中

執行這些程式碼之後,s 將會包含 “lol”。

使用 + 運算符或 format! 宏拼接字串

通常你會希望將兩個已知的字串合併在一起。一種辦法是像這樣使用 + 運算符,如範例 8-18 所示。


#![allow(unused)]
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 使用泛型定義。這裡我們看到的 add 的簽名使用具體類型代替了泛型,這也正是當使用 String 值調用這個方法會發生的。第十章會討論泛型。這個簽名提供了理解 + 運算那微妙部分的線索。

首先,s2 使用了 &,意味著我們使用第二個字串的 引用 與第一個字串相加。這是因為 add 函數的 s 參數:只能將 &strString 相加,不能將兩個 String 值相加。不過等一下 —— 正如 add 的第二個參數所指定的,&s2 的類型是 &String 而不是 &str。那麼為什麼範例 8-18 還能編譯呢?

之所以能夠在 add 調用中使用 &s2 是因為 &String 可以被 強轉coerced)成 &str。當add函數被調用時,Rust 使用了一個被稱為 解引用強制多態deref coercion)的技術,你可以將其理解為它把 &s2 變成了 &s2[..]。第十五章會更深入的討論解引用強制多態。因為 add 沒有獲取參數的所有權,所以 s2 在這個操作後仍然是有效的 String

其次,可以發現簽名中 add 獲取了 self 的所有權,因為 self 沒有 使用 &。這意味著範例 8-18 中的 s1 的所有權將被移動到 add 調用中,之後就不再有效。所以雖然 let s3 = s1 + &s2; 看起來就像它會複製兩個字串並創建一個新的字串,而實際上這個語句會獲取 s1 的所有權,附加上從 s2 中拷貝的內容,並返回結果的所有權。換句話說,它看起來好像生成了很多拷貝,不過實際上並沒有:這個實現比拷貝要更高效。

如果想要級聯多個字串,+ 的行為就顯得笨重了:


#![allow(unused)]
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! 宏:


#![allow(unused)]
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3);
}

這些程式碼也會將 s 設置為 “tic-tac-toe”。format!println! 的工作原理相同,不過不同於將輸出列印到螢幕上,它返回一個帶有結果內容的 String。這個版本就好理解的多,並且不會獲取任何參數的所有權。

索引字串

在很多語言中,透過索引來引用字串中的單獨字元是有效且常見的操作。然而在 Rust 中,如果你嘗試使用索引語法訪問 String 的一部分,會出現一個錯誤。考慮一下如範例 8-19 中所示的無效代碼。

let s1 = String::from("hello");
let h = s1[0];

範例 8-19:嘗試對字串使用索引語法

這段代碼會導致如下錯誤:

error[E0277]: the trait bound `std::string::String: std::ops::Index<{integer}>` is not satisfied
 -->
  |
3 |     let h = s1[0];
  |             ^^^^^ the type `std::string::String` cannot be indexed by `{integer}`
  |
  = help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

錯誤和提示說明了全部問題:Rust 的字串不支持索引。那麼接下來的問題是,為什麼不支持呢?為了回答這個問題,我們必須先聊一聊 Rust 是如何在記憶體中儲存字串的。

內部表現

String 是一個 Vec<u8> 的封裝。讓我們看看範例 8-14 中一些正確編碼的字串的例子。首先是這一個:


#![allow(unused)]
fn main() {
let len = String::from("Hola").len();
}

在這裡,len 的值是 4 ,這意味著儲存字串 “Hola” 的 Vec 的長度是四個位元組:這裡每一個字母的 UTF-8 編碼都占用一個位元組。那下面這個例子又如何呢?(注意這個字串中的首字母是西里爾字母的 Ze 而不是阿拉伯數字 3 。)


#![allow(unused)]
fn main() {
let len = String::from("Здравствуйте").len();
}

當問及這個字元是多長的時候有人可能會說是 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。為了避免返回意外的值並造成不能立刻發現的 bug,Rust 根本不會編譯這些程式碼,並在開發過程中及早杜絕了誤會的發生。

位元組、標量值和字形簇!天啊!

這引起了關於 UTF-8 的另外一個問題:從 Rust 的角度來講,事實上有三種相關方式可以理解字串:位元組、標量值和字形簇(最接近人們眼中 字母 的概念)。

比如這個用梵文書寫的印度語單詞 “नमस्ते”,最終它儲存在 vector 中的 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 必須從開頭到索引位置遍歷來確定有多少有效的字元。

字串 slice

索引字串通常是一個壞點子,因為字串索引應該返回的類型是不明確的:位元組值、字元、字形簇或者字串 slice。因此,如果你真的希望使用索引創建字串 slice 時,Rust 會要求你更明確一些。為了更明確索引並表明你需要一個字串 slice,相比使用 [] 和單個值的索引,可以使用 [] 和一個 range 來創建含特定位元組的字串 slice:


#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

這裡,s 會是一個 &str,它包含字串的頭四個位元組。早些時候,我們提到了這些字母都是兩個位元組長的,所以這意味著 s 將會是 “Зд”。

如果獲取 &hello[0..1] 會發生什麼事呢?答案是:Rust 在運行時會 panic,就跟訪問 vector 中的無效索引時一樣:

thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/libcore/str/mod.rs:2188:4

你應該小心謹慎的使用這個操作,因為這麼做可能會使你的程序崩潰。

遍歷字串的方法

幸運的是,這裡還有其他獲取字串元素的方式。

如果你需要操作單獨的 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
// --snip--
165
135

不過請記住有效的 Unicode 標量值可能會由不止一個位元組組成。

從字串中獲取字形簇是很複雜的,所以標準庫並沒有提供這個功能。crates.io 上有些提供這樣功能的 crate。

字串並不簡單

總而言之,字串還是很複雜的。不同的語言選擇了不同的向程式設計師展示其複雜性的方式。Rust 選擇了以準確的方式處理 String 數據作為所有 Rust 程序的默認行為,這意味著程式設計師們必須更多的思考如何預先處理 UTF-8 數據。這種權衡取捨相比其他語言更多的暴露出了字串的複雜性,不過也使你在開發生命週期後期免於處理涉及非 ASCII 字元的錯誤。

現在讓我們轉向一些不太複雜的集合:哈希 map!

哈希 map 儲存鍵值對

ch08-03-hash-maps.md
commit 85b02530cc749565c26c05bf1b3a838334e9717f

最後介紹的常用集合類型是 哈希 maphash map)。HashMap<K, V> 類型儲存了一個鍵類型 K 對應一個值類型 V 的映射。它通過一個 哈希函數hashing function)來實現映射,決定如何將鍵和值放入記憶體中。很多程式語言支持這種數據結構,不過通常有不同的名字:哈希、map、對象、哈希表或者關聯數組,僅舉幾例。

哈希 map 可以用於需要任何類型作為鍵來尋找數據的情況,而不是像 vector 那樣通過索引。例如,在一個遊戲中,你可以將每個團隊的分數記錄到哈希 map 中,其中鍵是隊伍的名字而值是每個隊伍的分數。給出一個隊名,就能得到他們的得分。

本章我們會介紹哈希 map 的基本 API,不過還有更多吸引人的功能隱藏於標準庫在 HashMap<K, V> 上定義的函數中。一如既往請查看標準庫文件來了解更多訊息。

新建一個哈希 map

可以使用 new 創建一個空的 HashMap,並使用 insert 增加元素。在範例 8-20 中我們記錄兩支隊伍的分數,分別是藍隊和黃隊。藍隊開始有 10 分而黃隊開始有 50 分:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
}

範例 8-20:新建一個哈希 map 並插入一些鍵值對

注意必須首先 use 標準庫中集合部分的 HashMap。在這三個常用集合中,HashMap 是最不常用的,所以並沒有被 prelude 自動引用。標準庫中對 HashMap 的支持也相對較少,例如,並沒有內建的構建宏。

像 vector 一樣,哈希 map 將它們的數據儲存在堆上,這個 HashMap 的鍵類型是 String 而值類型是 i32。類似於 vector,哈希 map 是同質的:所有的鍵必須是相同類型,值也必須都是相同類型。

另一個構建哈希 map 的方法是使用一個元組的 vector 的 collect 方法,其中每個元組包含一個鍵值對。collect 方法可以將數據收集進一系列的集合類型,包括 HashMap。例如,如果隊伍的名字和初始分數分別在兩個 vector 中,可以使用 zip 方法來創建一個元組的 vector,其中 “Blue” 與 10 是一對,依此類推。接著就可以使用 collect 方法將這個元組 vector 轉換成一個 HashMap,如範例 8-21 所示:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let teams  = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();
}

範例 8-21:用隊伍列表和分數列表創建哈希 map

這裡 HashMap<_, _> 類型註解是必要的,因為可能 collect 很多不同的數據結構,而除非顯式指定否則 Rust 無從得知你需要的類型。但是對於鍵和值的類型參數來說,可以使用下劃線占位,而 Rust 能夠根據 vector 中數據的類型推斷出 HashMap 所包含的類型。

哈希 map 和所有權

對於像 i32 這樣的實現了 Copy trait 的類型,其值可以拷貝進哈希 map。對於像 String 這樣擁有所有權的值,其值將被移動而哈希 map 會成為這些值的所有者,如範例 8-22 所示:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// 這裡 field_name 和 field_value 不再有效,
// 嘗試使用它們看看會出現什麼編譯錯誤!
}

範例 8-22:展示一旦鍵值對被插入後就為哈希 map 所擁有

insert 調用將 field_namefield_value 移動到哈希 map 中後,將不能使用這兩個綁定。

如果將值的引用插入哈希 map,這些值本身將不會被移動進哈希 map。但是這些引用指向的值必須至少在哈希 map 有效時也是有效的。第十章 “生命週期與引用有效性” 部分將會更多的討論這個問題。

訪問哈希 map 中的值

可以通過 get 方法並提供對應的鍵來從哈希 map 中獲取值,如範例 8-23 所示:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name);
}

範例 8-23:訪問哈希 map 中儲存的藍隊分數

這裡,score 是與藍隊分數相關的值,應為 Some(10)。因為 get 返回 Option<V>,所以結果被裝進 Some;如果某個鍵在哈希 map 中沒有對應的值,get 會返回 None。這時就要用某種第六章提到的方法之一來處理 Option

可以使用與 vector 類似的方式來遍歷哈希 map 中的每一個鍵值對,也就是 for 循環:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
    println!("{}: {}", key, value);
}
}

這會以任意順序列印出每一個鍵值對:

Yellow: 50
Blue: 10

更新哈希 map

儘管鍵值對的數量是可以增長的,不過任何時候,每個鍵只能關聯一個值。當我們想要改變哈希 map 中的數據時,必須決定如何處理一個鍵已經有值了的情況。可以選擇完全無視舊值並用新值代替舊值。可以選擇保留舊值而忽略新值,並只在鍵 沒有 對應值時增加新值。或者可以結合新舊兩值。讓我們看看這分別該如何處理!

覆蓋一個值

如果我們插入了一個鍵值對,接著用相同的鍵插入一個不同的值,與這個鍵相關聯的舊值將被替換。即便範例 8-24 中的代碼調用了兩次 insert,哈希 map 也只會包含一個鍵值對,因為兩次都是對藍隊的鍵插入的值:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

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

範例 8-24:替換以特定鍵儲存的值

這會列印出 {"Blue": 25}。原始的值 10 則被覆蓋了。

只在鍵沒有對應值時插入

我們經常會檢查某個特定的鍵是否有值,如果沒有就插入一個值。為此哈希 map 有一個特有的 API,叫做 entry,它獲取我們想要檢查的鍵作為參數。entry 函數的返回值是一個枚舉,Entry,它代表了可能存在也可能不存在的值。比如說我們想要檢查黃隊的鍵是否關聯了一個值。如果沒有,就插入值 50,對於藍隊也是如此。使用 entry API 的代碼看起來像範例 8-25 這樣:


#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

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

範例 8-25:使用 entry 方法只在鍵沒有對應一個值時插入

Entryor_insert 方法在鍵對應的值存在時就返回這個值的可變引用,如果不存在則將參數作為新值插入並返回新值的可變引用。這比編寫自己的邏輯要簡明的多,另外也與借用檢查器結合得更好。

運行範例 8-25 的代碼會列印出 {"Yellow": 50, "Blue": 10}。第一個 entry 調用會插入黃隊的鍵和值 50,因為黃隊並沒有一個值。第二個 entry 調用不會改變哈希 map 因為藍隊已經有了值 10

根據舊值更新一個值

另一個常見的哈希 map 的應用場景是找到一個鍵對應的值並根據舊的值更新它。例如,範例 8-26 中的代碼計數一些文本中每一個單詞分別出現了多少次。我們使用哈希 map 以單詞作為鍵並遞增其值來記錄我們遇到過幾次這個單詞。如果是第一次看到某個單詞,就插入值 0


#![allow(unused)]
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:通過哈希 map 儲存單詞和計數來統計出現次數

這會列印出 {"world": 2, "hello": 1, "wonderful": 1}or_insert 方法事實上會返回這個鍵的值的一個可變引用(&mut V)。這裡我們將這個可變引用儲存在 count 變數中,所以為了賦值必須首先使用星號(*)解引用 count。這個可變引用在 for 循環的結尾離開作用域,這樣所有這些改變都是安全的並符合借用規則。

哈希函數

HashMap 預設使用一種 “密碼學安全的”(“cryptographically strong” )1 哈希函數,它可以抵抗拒絕服務(Denial of Service, DoS)攻擊。然而這並不是可用的最快的算法,不過為了更高的安全性值得付出一些性能的代價。如果性能監測顯示此哈希函數非常慢,以致於你無法接受,你可以指定一個不同的 hasher 來切換為其它函數。hasher 是一個實現了 BuildHasher trait 的類型。第十章會討論 trait 和如何實現它們。你並不需要從頭開始實現你自己的 hasher;crates.io 有其他人分享的實現了許多常用哈希算法的 hasher 的庫。

總結

vector、字串和哈希 map 會在你的程序需要儲存、訪問和修改數據時幫助你。這裡有一些你應該能夠解決的練習問題:

  • 給定一系列數字,使用 vector 並返回這個列表的平均數(mean, average)、中位數(排列數組後位於中間的值)和眾數(mode,出現次數最多的值;這裡哈希函數會很有幫助)。
  • 將字串轉換為 Pig Latin,也就是每一個單詞的第一個子音字母被移動到單詞的結尾並增加 “ay”,所以 “first” 會變成 “irst-fay”。母音字母開頭的單詞則在結尾增加 “hay”(“apple” 會變成 “apple-hay”)。牢記 UTF-8 編碼!
  • 使用哈希 map 和 vector,創建一個文本介面來允許用戶向公司的部門中增加員工的名字。例如,“Add Sally to Engineering” 或 “Add Amir to Sales”。接著讓用戶獲取一個部門的所有員工的列表,或者公司每個部門的所有員工按照字典序排列的列表。

標準庫 API 文件中描述的這些類型的方法將有助於你進行這些練習!

我們已經開始接觸可能會有失敗操作的複雜程序了,這也意味著接下來是一個了解錯誤處理的絕佳時機!

錯誤處理

ch09-00-error-handling.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

Rust 對可靠性的執著也延伸到了錯誤處理。錯誤對於軟體來說是不可避免的,所以 Rust 有很多特性來處理出現錯誤的情況。在很多情況下,Rust 要求你承認出錯的可能性,並在編譯代碼之前就採取行動。這些要求使得程序更為健壯,它們確保了你會在將代碼部署到生產環境之前就發現錯誤並正確地處理它們!

Rust 將錯誤組合成兩個主要類別:可恢復錯誤recoverable)和 不可恢復錯誤unrecoverable)。可恢復錯誤通常代表向用戶報告錯誤和重試操作是合理的情況,比如未找到文件。不可恢復錯誤通常是 bug 的同義詞,比如嘗試訪問超過數組結尾的位置。

大部分語言並不區分這兩類錯誤,並採用類似異常這樣方式統一處理他們。Rust 並沒有異常,但是,有可恢復錯誤 Result<T, E> ,和不可恢復(遇到錯誤時停止程式執行)錯誤 panic!。這一章會首先介紹 panic! 調用,接著會講到如何返回 Result<T, E>。此外,我們將探討決定是嘗試從錯誤中恢復還是停止執行時的注意事項。

panic! 與不可恢復的錯誤

ch09-01-unrecoverable-errors-with-panic.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

突然有一天,代碼出問題了,而你對此束手無策。對於這種情況,Rust 有 panic!宏。當執行這個宏時,程序會列印出一個錯誤訊息,展開並清理棧數據,然後接著退出。出現這種情況的場景通常是檢測到一些類型的 bug,而且程式設計師並不清楚該如何處理它。

對應 panic 時的棧展開或終止

當出現 panic 時,程式預設會開始 展開unwinding),這意味著 Rust 會回溯棧並清理它遇到的每一個函數的數據,不過這個回溯並清理的過程有很多工作。另一種選擇是直接 終止abort),這會不清理數據就退出程序。那麼程序所使用的記憶體需要由操作系統來清理。如果你需要項目的最終二進位制文件越小越好,panic 時通過在 Cargo.toml[profile] 部分增加 panic = 'abort',可以由展開切換為終止。例如,如果你想要在release模式中 panic 時直接終止:

[profile.release]
panic = 'abort'

讓我們在一個簡單的程序中調用 panic!

檔案名: src/main.rs

fn main() {
    panic!("crash and burn");
}

運行程序將會出現類似這樣的輸出:

$ 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 'crash and burn', src/main.rs:2:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.

最後兩行包含 panic! 調用造成的錯誤訊息。第一行顯示了 panic 提供的訊息並指明了原始碼中 panic 出現的位置:src/main.rs:2:5 表明這是 src/main.rs 文件的第二行第五個字元。

在這個例子中,被指明的那一行是我們代碼的一部分,而且查看這一行的話就會發現 panic! 宏的調用。在其他情況下,panic! 可能會出現在我們的代碼所調用的代碼中。錯誤訊息報告的檔案名和行號可能指向別人代碼中的 panic! 宏調用,而不是我們代碼中最終導致 panic! 的那一行。我們可以使用 panic! 被調用的函數的 backtrace 來尋找代碼中出問題的地方。下面我們會詳細介紹 backtrace 是什麼。

使用 panic! 的 backtrace

讓我們來看看另一個因為我們代碼中的 bug 引起的別的庫中 panic! 的例子,而不是直接的宏調用。範例 9-1 有一些嘗試通過索引訪問 vector 中元素的例子:

檔案名: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

範例 9-1:嘗試訪問超越 vector 結尾的元素,這會造成 panic!

這裡嘗試訪問 vector 的第一百個元素(這裡的索引是 99 因為索引從 0 開始),不過它只有三個元素。這種情況下 Rust 會 panic。[] 應當返回一個元素,不過如果傳遞了一個無效索引,就沒有可供 Rust 返回的正確的元素。

這種情況下其他像 C 這樣語言會嘗試直接提供所要求的值,即便這可能不是你期望的:你會得到任何對應 vector 中這個元素的記憶體位置的值,甚至是這些記憶體並不屬於 vector 的情況。這被稱為 緩衝區溢出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', libcore/slice/mod.rs:2448:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

這指向了一個不是我們編寫的文件,libcore/slice/mod.rs。其為 Rust 原始碼中 slice 的實現。這是當對 vector v 使用 []libcore/slice/mod.rs 中會執行的代碼,也是真正出現 panic! 的地方。

接下來的幾行提醒我們可以設置 RUST_BACKTRACE 環境變數來得到一個 backtrace。backtrace 是一個執行到目前位置所有被調用的函數的列表。Rust 的 backtrace 跟其他語言中的一樣:閱讀 backtrace 的關鍵是從頭開始讀直到發現你編寫的文件。這就是問題的發源地。這一行往上是你的代碼所調用的代碼;往下則是調用你的代碼的代碼。這些行可能包含核心 Rust 代碼,標準庫代碼或用到的 crate 代碼。讓我們將 RUST_BACKTRACE 環境變數設置為任何不是 0 的值來獲取 backtrace 看看。範例 9-2 展示了與你看到類似的輸出:

$ RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', libcore/slice/mod.rs:2448:10
stack backtrace:
   0: std::sys::unix::backtrace::tracing::imp::unwind_backtrace
             at libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
   1: std::sys_common::backtrace::print
             at libstd/sys_common/backtrace.rs:71
             at libstd/sys_common/backtrace.rs:59
   2: std::panicking::default_hook::{{closure}}
             at libstd/panicking.rs:211
   3: std::panicking::default_hook
             at libstd/panicking.rs:227
   4: <std::panicking::begin_panic::PanicPayload<A> as core::panic::BoxMeUp>::get
             at libstd/panicking.rs:476
   5: std::panicking::continue_panic_fmt
             at libstd/panicking.rs:390
   6: std::panicking::try::do_call
             at libstd/panicking.rs:325
   7: core::ptr::drop_in_place
             at libcore/panicking.rs:77
   8: core::ptr::drop_in_place
             at libcore/panicking.rs:59
   9: <usize as core::slice::SliceIndex<[T]>>::index
             at libcore/slice/mod.rs:2448
  10: core::slice::<impl core::ops::index::Index<I> for [T]>::index
             at libcore/slice/mod.rs:2316
  11: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at liballoc/vec.rs:1653
  12: panic::main
             at src/main.rs:4
  13: std::rt::lang_start::{{closure}}
             at libstd/rt.rs:74
  14: std::panicking::try::do_call
             at libstd/rt.rs:59
             at libstd/panicking.rs:310
  15: macho_symbol_search
             at libpanic_unwind/lib.rs:102
  16: std::alloc::default_alloc_error_hook
             at libstd/panicking.rs:289
             at libstd/panic.rs:392
             at libstd/rt.rs:58
  17: std::rt::lang_start
             at libstd/rt.rs:74
  18: panic::main

範例 9-2:當設置 RUST_BACKTRACE 環境變數時 panic! 調用所生成的 backtrace 訊息

這裡有大量的輸出!你實際看到的輸出可能因不同的操作系統和 Rust 版本而有所不同。為了獲取帶有這些訊息的 backtrace,必須啟用 debug 標識。當不使用 --release 參數運行 cargo build 或 cargo run 時 debug 標識會預設啟用,就像這裡一樣。

範例 9-2 的輸出中,backtrace 的 12 行指向了我們項目中造成問題的行:src/main.rs 的第 4 行。如果你不希望程序 panic,第一個提到我們編寫的代碼行的位置是你應該開始調查的,以便查明是什麼值如何在這個地方引起了 panic。在範例 9-1 中,我們故意編寫會 panic 的代碼來示範如何使用 backtrace,修復這個 panic 的方法就是不要嘗試在一個只包含三個項的 vector 中請求索引是 100 的元素。當將來你的代碼出現了 panic,你需要搞清楚在這特定的場景下代碼中執行了什麼操作和什麼值導致了 panic,以及應當如何處理才能避免這個問題。

本章後面的小節 “panic! 還是不 panic!” 會再次回到 panic! 並講解何時應該、何時不應該使用 panic! 來處理錯誤情況。接下來,我們來看看如何使用 Result 來從錯誤中恢復。

Result 與可恢復的錯誤

ch09-02-recoverable-errors-with-result.md
commit aa339f78da31c330ede3f1b52b4bbfb62d7814cb

大部分錯誤並沒有嚴重到需要程序完全停止執行。有時,一個函數會因為一個容易理解並做出反應的原因失敗。例如,如果因為打開一個並不存在的文件而失敗,此時我們可能想要創建這個文件,而不是終止進程。

回憶一下第二章 “使用 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 語句改為如下:

let f: u32 = File::open("hello.txt");

現在嘗試編譯會給出如下輸出:

error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
  |
  = note: expected type `u32`
             found type `std::result::Result<std::fs::File, std::io::Error>`

這就告訴我們了 File::open 函數的返回值類型是 Result<T, E>。這裡泛型參數 T 放入了成功值的類型 std::fs::File,它是一個文件句柄。E 被用在失敗值上時 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!("Problem opening the file: {:?}", error)
        },
    };
}

範例 9-4:使用 match 表達式處理可能會返回的 Result 成員

注意與 Option 枚舉一樣,Result 枚舉和其成員也被導入到了 prelude 中,所以就不需要在 match 分支中的 OkErr 之前指定 Result::

這裡我們告訴 Rust 當結果是 Ok 時,返回 Ok 成員中的 file 值,然後將這個文件句柄賦值給變數 fmatch 之後,我們可以利用這個文件句柄來進行讀寫。

match 的另一個分支處理從 File::open 得到 Err 值的情況。在這種情況下,我們選擇調用 panic! 宏。如果當前目錄沒有一個叫做 hello.txt 的文件,當運行這段代碼時會看到如下來自 panic! 宏的輸出:

thread 'main' panicked at 'Problem opening the file: Error { repr:
Os { code: 2, message: "No such file or directory" } }', src/main.rs:9:12

一如既往,此輸出準確地告訴了我們到底出了什麼錯。

匹配不同的錯誤

範例 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!("Problem creating the file: {:?}", e),
            },
            other_error => panic!("Problem opening the file: {:?}", other_error),
        },
    };
}

範例 9-5:使用不同的方式處理不同類型的錯誤

File::open 返回的 Err 成員中的值類型 io::Error,它是一個標準庫中提供的結構體。這個結構體有一個返回 io::ErrorKind 值的 kind 方法可供調用。io::ErrorKind 是一個標準庫提供的枚舉,它的成員對應 io 操作可能導致的不同錯誤類型。我們感興趣的成員是 ErrorKind::NotFound,它代表嘗試打開的文件並不存在。這樣,match 就匹配完 f 了,不過對於 error.kind() 還有一個內層 match

我們希望在內層 match 中檢查的條件是 error.kind() 的返回值是否為 ErrorKindNotFound 成員。如果是,則嘗試通過 File::create 創建文件。然而因為 File::create 也可能會失敗,還需要增加一個內層 match 語句。當文件不能被打開,會列印出一個不同的錯誤訊息。外層 match 的最後一個分支保持不變,這樣對任何除了文件不存在的錯誤會使程序 panic。

這裡有好多 matchmatch 確實很強大,不過也非常的基礎。第十三章我們會介紹閉包(closure)。Result<T, E> 有很多接受閉包的方法,並採用 match 表達式實現。一個更老練的 Rustacean 可能會這麼寫:

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!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

雖然這段代碼有著如範例 9-5 一樣的行為,但並沒有包含任何 match 表達式且更容易閱讀。在閱讀完第十三章後再回到這個例子,並查看標準庫文件 unwrap_or_else 方法都做了什麼操作。在處理錯誤時,還有很多這類方法可以消除大量嵌套的 match 表達式。

失敗時 panic 的簡寫:unwrapexpect

match 能夠勝任它的工作,不過它可能有點冗長並且不總是能很好的表明其意圖。Result<T, E> 類型定義了很多輔助方法來處理各種情況。其中之一叫做 unwrap,它的實現就類似於範例 9-4 中的 match 語句。如果 Result 值是成員 Okunwrap 會返回 Ok 中的值。如果 Result 是成員 Errunwrap 會為我們調用 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

還有另一個類似於 unwrap 的方法它還允許我們選擇 panic! 的錯誤訊息:expect。使用 expect 而不是 unwrap 並提供一個好的錯誤訊息可以表明你的意圖並更易於追蹤 panic 的根源。expect 的語法看起來像這樣:

檔案名: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap 的使用方式一樣:返回文件句柄或調用 panic! 宏。expect 用來調用 panic! 的錯誤訊息將會作為參數傳遞給 expect ,而不像unwrap 那樣使用默認的 panic! 訊息。它看起來像這樣:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

因為這個錯誤訊息以我們指定的文本開始,Failed to open hello.txt,將會更容易找到代碼中的錯誤訊息來自何處。如果在多處使用 unwrap,則需要花更多的時間來分析到底是哪一個 unwrap 造成了 panic,因為所有的 unwrap 調用都列印相同的訊息。

傳播錯誤

當編寫一個其實現會調用一些可能會失敗的操作的函數時,除了在這個函數中處理錯誤外,還可以選擇讓調用者知道這個錯誤並決定該如何處理。這被稱為 傳播propagating)錯誤,這樣能更好的控制代碼調用,因為比起你代碼所擁有的上下文,調用者可能擁有更多訊息或邏輯來決定應該如何處理錯誤。

例如,範例 9-6 展示了一個從文件中讀取使用者名稱的函數。如果文件不存在或不能讀取,這個函數會將這些錯誤返回給調用它的代碼:

Filename: src/main.rs


#![allow(unused)]
fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

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 值 —— 函數從文件中讀取到的使用者名稱。如果函數遇到任何錯誤,函數的調用者會收到一個 Err 值,它儲存了一個包含更多這個問題相關訊息的 io::Error 實例。這裡選擇 io::Error 作為函數的返回值是因為它正好是函數體中那兩個可能會失敗的操作的錯誤返回值:File::open 函數和 read_to_string 方法。

函數體以 File::open 函數開頭。接著使用 match 處理返回值 Result,類似於範例 9-4 中的 match,唯一的區別是當 Err 時不再調用 panic!,而是提早返回並將 File::open 返回的錯誤值作為函數的錯誤返回值傳遞給調用者。如果 File::open 成功了,我們將文件句柄儲存在變數 f 中並繼續。

接著我們在變數 s 中創建了一個新 String 並調用文件句柄 fread_to_string 方法來將文件的內容讀取到 s 中。read_to_string 方法也返回一個 Result 因為它也可能會失敗:哪怕是 File::open 已經成功了。所以我們需要另一個 match 來處理這個 Result:如果 read_to_string 成功了,那麼這個函數就成功了,並返回文件中的使用者名稱,它現在位於被封裝進 Oks 中。如果read_to_string 失敗了,則像之前處理 File::open 的返回值的 match 那樣返回錯誤值。不過並不需要顯式的調用 return,因為這是函數的最後一個表達式。

調用這個函數的代碼最終會得到一個包含使用者名稱的 Ok 值,或者一個包含 io::ErrorErr 值。我們無從得知調用者會如何處理這些值。例如,如果他們得到了一個 Err 值,他們可能會選擇 panic! 並使程序崩潰、使用一個預設的使用者名稱或者從文件之外的地方尋找使用者名稱。我們沒有足夠的訊息知曉調用者具體會如何嘗試,所以將所有的成功或失敗訊息向上傳播,讓他們選擇合適的處理方法。

這種傳播錯誤的模式在 Rust 是如此的常見,以至於 Rust 提供了 ? 問號運算符來使其更易於處理。

傳播錯誤的簡寫:? 運算符

範例 9-7 展示了一個 read_username_from_file 的實現,它實現了與範例 9-6 中的代碼相同的功能,不過這個實現使用了 ? 運算符:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

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 中定義的處理 Result 值的 match 表達式有著完全相同的工作方式。如果 Result 的值是 Ok,這個表達式將會返回 Ok 中的值而程序將繼續執行。如果值是 ErrErr 中的值將作為整個函數的返回值,就好像使用了 return 關鍵字一樣,這樣錯誤值就被傳播給了調用者。

範例 9-6 中的 match 表達式與問號運算符所做的有一點不同:? 運算符所使用的錯誤值被傳遞給了 from 函數,它定義於標準庫的 From trait 中,其用來將錯誤從一種類型轉換為另一種類型。當 ? 運算符調用 from 函數時,收到的錯誤類型被轉換為由當前函數返回類型所指定的錯誤類型。這在當函數返回單個錯誤類型來代表所有可能失敗的方式時很有用,即使其可能會因很多種原因失敗。只要每一個錯誤類型都實現了 from 函數來定義如何將自身轉換為返回的錯誤類型,? 運算符會自動處理這些轉換。

在範例 9-7 的上下文中,File::open 調用結尾的 ? 將會把 Ok 中的值返回給變數 f。如果出現了錯誤,? 運算符會提早返回整個函數並將一些 Err 值傳播給調用者。同理也適用於 read_to_string 調用結尾的 ?

? 運算符消除了大量樣板代碼並使得函數的實現更簡單。我們甚至可以在 ? 之後直接使用鏈式方法調用來進一步縮短代碼,如範例 9-8 所示:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

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:問號運算符之後的鏈式方法調用

s 中創建新的 String 被放到了函數開頭;這一部分沒有變化。我們對 File::open("hello.txt")? 的結果直接鏈式調用了 read_to_string,而不再創建變數 f。仍然需要 read_to_string 調用結尾的 ?,而且當 File::openread_to_string 都成功沒有失敗時返回包含使用者名稱 sOk 值。其功能再一次與範例 9-6 和範例 9-7 保持一致,不過這是一個與眾不同且更符合工程學(ergonomic)的寫法。

說到編寫這個函數的不同方法,甚至還有一個更短的寫法:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io;
use std::fs;

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,接著返回它。當然,這樣做就沒有展示所有這些錯誤處理的機會了,所以我們最初就選擇了艱苦的道路。

? 運算符可被用於返回 Result 的函數

? 運算符可被用於返回值類型為 Result 的函數,因為他被定義為與範例 9-6 中的 match 表達式有著完全相同的工作方式。matchreturn Err(e) 部分要求返回值類型是 Result,所以函數的返回值必須是 Result 才能與這個 return 相相容。

讓我們看看在 main 函數中使用 ? 運算符會發生什麼事,如果你還記得的話其返回值類型是()

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

當編譯這些程式碼,會得到如下錯誤訊息:

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
  |
4 |     let f = File::open("hello.txt")?;
  |             ^^^^^^^^^^^^^^^^^^^^^^^^ cannot use the `?` operator in a
  function that returns `()`
  |
  = help: the trait `std::ops::Try` is not implemented for `()`
  = note: required by `std::ops::Try::from_error`

錯誤指出只能在返回 Result 或者其它實現了 std::ops::Try 的類型的函數中使用 ? 運算符。當你期望在不返回 Result 的函數中調用其他返回 Result 的函數時使用 ? 的話,有兩種方法修復這個問題。一種技巧是將函數返回值類型修改為 Result<T, E>,如果沒有其它限制阻止你這麼做的話。另一種技巧是透過合適的方法使用 matchResult 的方法之一來處理 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 對象”(“trait object”),第十七章 “為使用不同類型的值而設計的 trait 對象” 部分會做介紹。目前可以理解 Box<dyn Error> 為使用 ?main 允許返回的 “任何類型的錯誤”。

現在我們討論過了調用 panic! 或返回 Result 的細節,是時候回到他們各自適合哪些場景的話題了。

panic! 還是不 panic!

ch09-03-to-panic-or-not-to-panic.md
commit 76df60bccead5f3de96db23d97b69597cd8a2b82

那麼,該如何決定何時應該 panic! 以及何時應該返回 Result 呢?如果代碼 panic,就沒有恢復的可能。你可以選擇對任何錯誤場景都調用 panic!,不管是否有可能恢復,不過這樣就是你代替調用者決定了這是不可恢復的。選擇返回 Result 值的話,就將選擇權交給了調用者,而不是代替他們做出決定。調用者可能會選擇以符合他們場景的方式嘗試恢復,或者也可能乾脆就認為 Err 是不可恢復的,所以他們也可能會調用 panic! 並將可恢復的錯誤變成了不可恢復的錯誤。因此返回 Result 是定義可能會失敗的函數的一個好的預設選擇。

有一些情況 panic 比返回 Result 更為合適,不過他們並不常見。讓我們討論一下為何在範例、代碼原型和測試中,以及那些人們認為不會失敗而編譯器不這麼看的情況下, panic 是合適的。章節最後會總結一些在庫代碼中如何決定是否要 panic 的通用指導原則。

範例、代碼原型和測試都非常適合 panic

當你編寫一個範例來展示一些概念時,在擁有健壯的錯誤處理代碼的同時也會使得例子不那麼明確。例如,調用一個類似 unwrap 這樣可能 panic! 的方法可以被理解為一個你實際希望程序處理錯誤方式的占位符,它根據其餘代碼運行方式可能會各不相同。

類似地,在我們準備好決定如何處理錯誤之前,unwrapexpect方法在原型設計時非常方便。當我們準備好讓程序更加健壯時,它們會在代碼中留下清晰的標記。

如果方法調用在測試中失敗了,我們希望這個測試都失敗,即便這個方法並不是需要測試的功能。因為 panic! 會將測試標記為失敗,此時調用 unwrapexpect 是恰當的。

當我們比編譯器知道更多的情況

當你有一些其他的邏輯來確保 Result 會是 Ok 值時,調用 unwrap 也是合適的,雖然編譯器無法理解這種邏輯。你仍然需要處理一個 Result 值:即使在你的特定情況下邏輯上是不可能的,你所調用的任何操作仍然有可能失敗。如果通過人工檢查代碼來確保永遠也不會出現 Err 值,那麼調用 unwrap 也是完全可以接受的,這裡是一個例子:


#![allow(unused)]
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! 並警告使用你的庫的人他的代碼中有 bug 以便他能在開發時就修復它。類似的,panic! 通常適合調用不能夠控制的外部代碼時,這時無法修復其返回的無效狀態。

然而當錯誤預期會出現時,返回 Result 仍要比調用 panic! 更為合適。這樣的例子包括解析器接收到格式錯誤的數據,或者 HTTP 請求返回了一個表明觸發了限流的狀態。在這些例子中,應該通過返回 Result 來表明失敗預期是可能的,這樣將有害狀態向上傳播,調用者就可以決定該如何處理這個問題。使用 panic! 來處理這些情況就不是最好的選擇。

當代碼對值進行操作時,應該首先驗證值是有效的,並在其無效時 panic!。這主要是出於安全的原因:嘗試操作無效數據會暴露代碼漏洞,這就是標準庫在嘗試越界訪問數組時會 panic! 的主要原因:嘗試訪問不屬於當前數據結構的記憶體是一個常見的安全隱患。函數通常都遵循 契約contracts):他們的行為只有在輸入滿足特定條件時才能得到保證。當違反契約時 panic 是有道理的,因為這通常代表調用方的 bug,而且這也不是那種你希望所調用的代碼必須處理的錯誤。事實上所調用的代碼也沒有合理的方式來恢復,而是需要調用方的 程式設計師 修復其代碼。函數的契約,尤其是當違反它會造成 panic 的契約,應該在函數的 API 文件中得到解釋。

雖然在所有函數中都擁有許多錯誤檢查是冗長而煩人的。幸運的是,可以利用 Rust 的類型系統(以及編譯器的類型檢查)為你進行很多檢查。如果函數有一個特定類型的參數,可以在知曉編譯器已經確保其擁有一個有效值的前提下進行你的代碼邏輯。例如,如果你使用了一個並不是 Option 的類型,則程序期望它是 有值 的並且不是 空值。你的代碼無需處理 SomeNone 這兩種情況,它只會有一種情況就是絕對會有一個值。嘗試向函數傳遞空值的代碼甚至根本不能編譯,所以你的函數在運行時沒有必要判空。另外一個例子是使用像 u32 這樣的無符號整型,也會確保它永遠不為負。

創建自訂類型進行有效性驗證

讓我們使用 Rust 類型系統的思想來進一步確保值的有效性,並嘗試創建一個自訂類型以進行驗證。回憶一下第二章的猜猜看遊戲,我們的代碼要求用戶猜測一個 1 到 100 之間的數字,在將其與秘密數字做比較之前我們從未驗證用戶的猜測是位於這兩個數字之間的,我們只驗證它是否為正。在這種情況下,其影響並不是很嚴重:“Too high” 或 “Too low” 的輸出仍然是正確的。但是這是一個很好的引導用戶得出有效猜測的輔助,例如當用戶猜測一個超出範圍的數字或者輸入字母時採取不同的行為。

一種實現方式是將猜測解析成 i32 而不僅僅是 u32,來默許輸入負數,接著檢查數字是否在範圍內:

loop {
    // --snip--

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

    if guess < 1 || guess > 100 {
        println!("The secret number will be between 1 and 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
    // --snip--
}

if 表達式檢查了值是否超出範圍,告訴用戶出了什麼問題,並調用 continue 開始下一次循環,請求另一個猜測。if 表達式之後,就可以在知道 guess 在 1 到 100 之間的情況下與秘密數字作比較了。

然而,這並不是一個理想的解決方案:如果讓程序僅僅處理 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!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value
        }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

範例 9-10:一個 Guess 類型,它只在值位於 1 和 100 之間時才繼續

首先,我們定義了一個包含 i32 類型欄位 value 的結構體 Guess。這裡是儲存猜測值的地方。

接著在 Guess 上實現了一個叫做 new 的關聯函數來創建 Guess 的實例。new 定義為接收一個 i32 類型的參數 value 並返回一個 Guessnew 函數中代碼的測試確保了其值是在 1 到 100 之間的。如果 value 沒有通過測試則調用 panic!,這會警告調用這個函數的程式設計師有一個需要修改的 bug,因為創建一個 value 超出範圍的 Guess 將會違反 Guess::new 所遵循的契約。Guess::new 會出現 panic 的條件應該在其公有 API 文件中被提及;第十四章會涉及到在 API 文件中表明 panic! 可能性的相關規則。如果 value 通過了測試,我們新建一個 Guess,其欄位 value 將被設置為參數 value 的值,接著返回這個 Guess

接著,我們實現了一個借用了 self 的方法 value,它沒有任何其他參數並返回一個 i32。這類方法有時被稱為 getter,因為它的目的就是返回對應欄位的數據。這樣的公有方法是必要的,因為 Guess 結構體的 value 欄位是私有的。私有的欄位 value 是很重要的,這樣使用 Guess 結構體的代碼將不允許直接設置 value 的值:調用者 必須 使用 Guess::new 方法來創建一個 Guess 的實例,這就確保了不會存在一個 value 沒有通過 Guess::new 函數的條件檢查的 Guess

於是,一個接收(或返回) 1 到 100 之間數字的函數就可以聲明為接收(或返回) Guess的實例,而不是 i32,同時其函數體中也無需進行任何額外的檢查。

總結

Rust 的錯誤處理功能被設計為幫助你編寫更加健壯的代碼。panic! 宏代表一個程序無法處理的狀態,並停止執行而不是使用無效或不正確的值繼續處理。Rust 類型系統的 Result 枚舉代表操作可能會在一種可以恢復的情況下失敗。可以使用 Result 來告訴代碼調用者他需要處理潛在的成功或失敗。在適當的場景使用 panic!Result 將會使你的代碼在面對不可避免的錯誤時顯得更加可靠。

現在我們已經見識過了標準庫中 OptionResult 泛型枚舉的能力了,在下一章讓我們聊聊泛型是如何工作的,以及如何在你的代碼中使用他們。

泛型、trait 和生命週期

ch10-00-generics.md
commit 48b057106646758f6453f42b7887f34b8c24caf6

每一個程式語言都有高效處理重複概念的工具。在 Rust 中其工具之一就是 泛型generics)。泛型是具體類型或其他屬性的抽象替代。我們可以表達泛型的屬性,比如他們的行為或如何與其他泛型相關聯,而不需要在編寫和編譯代碼時知道他們在這裡實際上代表什麼。

同理為了編寫一份可以用於多種具體值的代碼,函數並不知道其參數為何值,這時就可以讓函數獲取泛型而不是像 i32String 這樣的具體值。我們已經使用過第六章的 Option<T>,第八章的 Vec<T>HashMap<K, V>,以及第九章的 Result<T, E> 這些泛型了。本章會探索如何使用泛型定義我們自己的類型、函數和方法!

首先,我們將回顧一下提取函數以減少代碼重複的機制。接下來,我們將使用相同的技術,從兩個僅參數類型不同的函數中創建一個泛型函數。我們也會講到結構體和枚舉定義中的泛型。

之後,我們討論 trait,這是一個定義泛型行為的方法。trait 可以與泛型結合來將泛型限制為擁有特定行為的類型,而不是任意類型。

最後介紹 生命週期lifetimes),它是一類允許我們向編譯器提供引用如何相互關聯的泛型。Rust 的生命週期功能允許在很多場景下借用值的同時仍然使編譯器能夠檢查這些引用的有效性。

提取函數來減少重複

在介紹泛型語法之前,首先來回顧一個不使用泛型的處理重複的技術:提取一個函數。當熟悉了這個技術以後,我們將使用相同的機制來提取一個泛型函數!如同你識別出可以提取到函數中重複代碼那樣,你也會開始識別出能夠使用泛型的重複代碼。

考慮一下這個尋找列表中最大值的小程序,如範例 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!("The largest number is {}", largest);
 assert_eq!(largest, 100);
}

範例 10-1:在一個數字列表中尋找最大值的函數

這段代碼獲取一個整型列表,存放在變數 number_list 中。它將列表的第一項放入了變數 largest 中。接著遍歷了列表中的所有數字,如果當前值大於 largest 中儲存的值,將 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!("The largest number is {}", 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!("The largest number is {}", 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!("The largest number is {}", result);
   assert_eq!(result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
   assert_eq!(result, 6000);
}

範例 10-3:抽象後的尋找兩個數字列表最大值的代碼

largest 函數有一個參數 list,它代表會傳遞給函數的任何具體的 i32值的 slice。函數定義中的 list 代表任何 &[i32]。當調用 largest 函數時,其代碼實際上運行於我們傳遞的特定值上。

總的來說,從範例 10-2 到範例 10-3 中涉及的機制經歷了如下幾步:

  1. 找出重複代碼。
  2. 將重複代碼提取到了一個函數中,並在函數簽名中指定了代碼中的輸入和返回值。
  3. 將重複代碼的兩個實例,改為調用函數。

在不同的場景使用不同的方式,我們也可以利用相同的步驟和泛型來減少重複代碼。與函數體可以在抽象list而不是特定值上操作的方式相同,泛型允許代碼對抽象類型進行操作。

如果我們有兩個函數,一個尋找一個 i32 值的 slice 中的最大項而另一個尋找 char 值的 slice 中的最大項該怎麼辦?該如何消除重複呢?讓我們拭目以待!

泛型數據類型

ch10-01-syntax.md
commit af34ac954a6bd7fc4a8bbcc5c9685e23c5af87da

我們可以使用泛型為像函數簽名或結構體這樣的項創建定義,這樣它們就可以用於多種不同的具體數據類型。讓我們看看如何使用泛型定義函數、結構體、枚舉和方法,然後我們將討論泛型如何影響代碼性能。

在函數定義中使用泛型

當使用泛型定義函數時,本來在函數簽名中指定參數和返回值的類型的地方,會改用泛型來表示。採用這種技術,使得代碼適應性更強,從而為函數的調用者提供更多的功能,同時也避免了代碼的重複。

回到 largest 函數,範例 10-4 中展示了兩個函數,它們的功能都是尋找 slice 中最大值。

檔案名: src/main.rs

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {}", result);
   assert_eq!(result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {}", result);
   assert_eq!(result, 'y');
}

範例 10-4:兩個函數,不同點只是名稱和簽名類型

largest_i32 函數是從範例 10-3 中摘出來的,它用來尋找 slice 中最大的 i32largest_char 函數尋找 slice 中最大的 char。因為兩者函數體的代碼是一樣的,我們可以定義一個函數,再引進泛型參數來消除這種重複。

為了參數化新函數中的這些類型,我們也需要為類型參數取個名字,道理和給函數的形參起名一樣。任何標識符都可以作為類型參數的名字。這裡選用 T,因為傳統上來說,Rust 的參數名字都比較短,通常就只有一個字母,同時,Rust 類型名的命名規範是駱駝命名法(CamelCase)。T 作為 “type” 的縮寫是大部分 Rust 程式設計師的首選。

如果要在函數體中使用參數,就必須在函數簽名中聲明它的名字,好讓編譯器知道這個名字指代的是什麼。同理,當在函數簽名中使用一個類型參數時,必須在使用它之前就聲明它。為了定義泛型版本的 largest 函數,類型參數聲明位於函數名稱與參數列表中間的角括號 <> 中,像這樣:

fn largest<T>(list: &[T]) -> T {

可以這樣理解這個定義:函數 largest 有泛型類型 T。它有個參數 list,其類型是元素為 T 的 slice。largest 函數的返回值類型也是 T

範例 10-5 中的 largest 函數在它的簽名中使用了泛型,統一了兩個實現。該範例也展示了如何調用 largest 函數,把 i32 值的 slice 或 char 值的 slice 傳給它。請注意這些程式碼還不能編譯,不過稍後在本章會解決這個問題。

檔案名: src/main.rs

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

範例 10-5:一個使用泛型參數的 largest 函數定義,尚不能編譯

如果現在就編譯這個代碼,會出現如下錯誤:

error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:12
  |
5 |         if item > largest {
  |            ^^^^^^^^^^^^^^
  |
  = note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

注釋中提到了 std::cmp::PartialOrd,這是一個 trait。下一部分會講到 trait。不過簡單來說,這個錯誤表明 largest 的函數體不能適用於 T 的所有可能的類型。因為在函數體需要比較 T 類型的值,不過它只能用於我們知道如何排序的類型。為了開啟比較功能,標準庫中定義的 std::cmp::PartialOrd trait 可以實現類型的比較功能(查看附錄 C 獲取該 trait 的更多訊息)。

標準庫中定義的 std::cmp::PartialOrd trait 可以實現類型的比較功能。在 “trait 作為參數” 部分會講解如何指定泛型實現特定的 trait,不過讓我們先探索其他使用泛型參數的方法。

結構體定義中的泛型

同樣也可以用 <> 語法來定義結構體,它包含一個或多個泛型參數類型欄位。範例 10-6 展示了如何定義和使用一個可以存放任何類型的 xy 坐標值的結構體 Point

檔案名: 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

其語法類似於函數定義中使用泛型。首先,必須在結構體名稱後面的角括號中聲明泛型參數的名稱。接著在結構體定義中可以指定具體數據類型的位置使用泛型類型。

注意 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 是整型的。接著指定 y 為 4.0,它被定義為與 x 相同類型,就會得到一個像這樣的類型不匹配錯誤:

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
  |
  = note: expected type `{integer}`
             found type `{float}`

如果想要定義一個 xy 可以有不同類型且仍然是泛型的 Point 結構體,我們可以使用多個泛型類型參數。在範例 10-8 中,我們修改 Point 的定義為擁有兩個泛型類型 TU。其中欄位 xT 類型的,而欄位 yU 類型的:

檔案名: 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,這樣 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 枚舉有兩個泛型類型,TEResult 有兩個成員: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,它返回 T 類型的欄位 x 的引用

這裡在 Point<T> 上定義了一個叫做 x 的方法來返回欄位 x 中數據的引用:

注意必須在 impl 後面聲明 T,這樣就可以在 Point<T> 上實現的方法中使用它了。在 impl 之後聲明泛型 T ,這樣 Rust 就知道 Point 的角括號中的類型是泛型而不是具體類型。

例如,可以選擇為 Point<f32> 實例實現方法,而不是為泛型 Point 實例。範例 10-10 展示了一個沒有在 impl 之後(的角括號)聲明泛型的例子,這裡使用了一個具體類型,f32


#![allow(unused)]
fn main() {
struct Point<T> {
    x: T,
    y: T,
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
}

範例 10-10:構建一個只用於擁有泛型參數 T 的結構體的具體類型的 impl

這段代碼意味著 Point<f32> 類型會有一個方法 distance_from_origin,而其他 T 不是 f32 類型的 Point<T> 實例則沒有定義此方法。這個方法計算點實例與坐標 (0.0, 0.0) 之間的距離,並使用了只能用於浮點型的數學運算符。

結構體定義中的泛型類型參數並不總是與結構體方法簽名中使用的泛型是同一類型。範例 10-11 中在範例 10-8 中的結構體 Point<T, U> 上定義了一個方法 mixup。這個方法獲取另一個 Point 作為參數,而它可能與調用 mixupself 是不同的 Point 類型。這個方法用 selfPoint 類型的 x 值(類型 T)和參數的 Point 類型的 y 值(類型 W)來創建一個新 Point 類型的實例:

檔案名: 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 函數中,定義了一個有 i32 類型的 x(其值為 5)和 f64y(其值為 10.4)的 Pointp2 則是一個有著字串 slice 類型的 x(其值為 "Hello")和 char 類型的 y(其值為c)的 Point。在 p1 上以 p2 作為參數調用 mixup 會返回一個 p3,它會有一個 i32 類型的 x,因為 x 來自 p1,並擁有一個 char 類型的 y,因為 y 來自 p2println! 會列印出 p3.x = 5, p3.y = c

這個例子的目的是展示一些泛型通過 impl 聲明而另一些透過方法定義聲明的情況。這裡泛型參數 TU 聲明於 impl 之後,因為他們與結構體定義相對應。而泛型參數 VW 聲明於 fn mixup 之後,因為他們只是相對於方法本身的。

泛型代碼的性能

在閱讀本部分內容的同時,你可能會好奇使用泛型類型參數是否會有運行時消耗。好消息是:Rust 實現了泛型,使得使用泛型類型參數的代碼相比使用具體類型並沒有任何速度上的損失。

Rust 通過在編譯時進行泛型代碼的 單態化monomorphization)來保證效率。單態化是一個透過填充編譯時使用的具體類型,將通用代碼轉換為特定代碼的過程。

編譯器所做的工作正好與範例 10-5 中我們創建泛型函數的步驟相反。編譯器尋找所有泛型代碼被調用的位置並使用泛型代碼針對具體類型生成代碼。

讓我們看看一個使用標準庫中 Option 枚舉的例子:


#![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:定義共享的行為

ch10-02-traits.md
commit 34b403864ad9c5e27b00b7cc4a6893804ef5b989

trait 告訴 Rust 編譯器某個特定類型擁有可能與其他類型共享的功能。可以通過 trait 以一種抽象的方式定義共享的行為。可以使用 trait bounds 指定泛型是任何擁有特定行為的類型。

注意:trait 類似於其他語言中的常被稱為 介面interfaces)的功能,雖然有一些不同。

定義 trait

一個類型的行為由其可供調用的方法構成。如果可以對不同類型調用相同的方法的話,這些類型就可以共享相同的行為了。trait 定義是一種將方法簽名組合起來的方法,目的是定義一個實現某些目的所必需的行為的集合。

例如,這裡有多個存放了不同類型和屬性文本的結構體:結構體 NewsArticle 用於存放發生於世界各地的新聞故事,而結構體 Tweet 最多只能存放 280 個字元的內容,以及像是否轉推或是否是對推友的回覆這樣的元數據。

我們想要創建一個多媒體聚合庫用來顯示可能儲存在 NewsArticleTweet 實例中的數據的總結。每一個結構體都需要的行為是他們是能夠被總結的,這樣的話就可以調用實例的 summarize 方法來請求總結。範例 10-12 中展示了一個表現這個概念的 Summary trait 的定義:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String;
}
}

範例 10-12:Summary trait 定義,它包含由 summarize 方法提供的行為

這裡使用 trait 關鍵字來聲明一個 trait,後面是 trait 的名字,在這個例子中是 Summary。在大括號中聲明描述實現這個 trait 的類型所需要的行為的方法簽名,在這個例子中是 fn summarize(&self) -> String

在方法簽名後跟分號,而不是在大括號中提供其實現。接著每一個實現這個 trait 的類型都需要提供其自訂行為的方法體,編譯器也會確保任何實現 Summary trait 的類型都擁有與這個簽名的定義完全一致的 summarize 方法。

trait 體中可以有多個方法:一行一個方法簽名且都以分號結尾。

為類型實現 trait

現在我們定義了 Summary trait,接著就可以在多媒體聚合庫中需要擁有這個行為的類型上實現它了。範例 10-13 中展示了 NewsArticle 結構體上 Summary trait 的一個實現,它使用標題、作者和創建的位置作為 summarize 的返回值。對於 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!("{}, by {} ({})", 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 trait

在類型上實現 trait 類似於實現與 trait 無關的方法。區別在於 impl 關鍵字之後,我們提供需要實現 trait 的名稱,接著是 for 和需要實現 trait 的類型的名稱。在 impl 塊中,使用 trait 定義中的方法簽名,不過不再後跟分號,而是需要在大括號中編寫函數體來為特定類型實現 trait 方法所擁有的行為。

一旦實現了 trait,我們就可以用與 NewsArticleTweet 實例的非 trait 方法一樣的方式調用 trait 方法了:

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 new tweet: {}", tweet.summarize());

這會列印出 1 new tweet: horse_ebooks: of course, as you probably already know, people

注意因為範例 10-13 中我們在相同的 lib.rs 裡定義了 Summary trait 和 NewsArticleTweet 類型,所以他們是位於同一作用域的。如果這個 lib.rs 是對應 aggregator crate 的,而別人想要利用我們 crate 的功能為其自己的庫作用域中的結構體實現 Summary trait。首先他們需要將 trait 引入作用域。這可以通過指定 use aggregator::Summary; 實現,這樣就可以為其類型實現 Summary trait 了。Summary 還必須是公有 trait 使得其他 crate 可以實現它,這也是為什麼實例 10-12 中將 pub 置於 trait 之前。

實現 trait 時需要注意的一個限制是,只有當 trait 或者要實現 trait 的類型位於 crate 的本地作用域時,才能為該類型實現 trait。例如,可以為 aggregator crate 的自訂類型 Tweet 實現如標準庫中的 Display trait,這是因為 Tweet 類型位於 aggregator crate 本地的作用域中。類似地,也可以在 aggregator crate 中為 Vec<T> 實現 Summary,這是因為 Summary trait 位於 aggregator crate 本地作用域中。

但是不能為外部類型實現外部 trait。例如,不能在 aggregator crate 中為 Vec<T> 實現 Display trait。這是因為 DisplayVec<T> 都定義於標準庫中,它們並不位於 aggregator crate 本地作用域中。這個限制是被稱為 相干性coherence) 的程序屬性的一部分,或者更具體的說是 孤兒規則orphan rule),其得名於不存在父類型。這條規則確保了其他人編寫的代碼不會破壞你代碼,反之亦然。沒有這條規則的話,兩個 crate 可以分別對相同類型實現相同的 trait,而 Rust 將無從得知應該使用哪一個實現。

默認實現

有時為 trait 中的某些或全部方法提供預設的行為,而不是在每個類型的每個實現中都定義自己的行為是很有用的。這樣當為某個特定類型實現 trait 時,可以選擇保留或重載每個方法的默認行為。

範例 10-14 中展示了如何為 Summary trait 的 summarize 方法指定一個預設的字串值,而不是像範例 10-12 中那樣只是定義方法簽名:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}
}

範例 10-14:Summary trait 的定義,帶有一個 summarize 方法的默認實現

如果想要對 NewsArticle 實例使用這個默認實現,而不是定義一個自己的實現,則可以通過 impl Summary for NewsArticle {} 指定一個空的 impl 塊。

雖然我們不再直接為 NewsArticle 定義 summarize 方法了,但是我們提供了一個默認實現並且指定 NewsArticle 實現 Summary trait。因此,我們仍然可以對 NewsArticle 實例調用 summarize 方法,如下所示:

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!("New article available! {}", article.summarize());

這段代碼會列印 New article available! (Read more...)

summarize 創建默認實現並不要求對範例 10-13 中 Tweet 上的 Summary 實現做任何改變。其原因是重載一個默認實現的語法與實現沒有默認實現的 trait 方法的語法一樣。

默認實現允許調用相同 trait 中的其他方法,哪怕這些方法沒有默認實現。如此,trait 可以提供很多有用的功能而只需要實現指定一小部分內容。例如,我們可以定義 Summary trait,使其具有一個需要實現的 summarize_author 方法,然後定義一個 summarize 方法,此方法的默認實現調用 summarize_author 方法:


#![allow(unused)]
fn main() {
pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}
}

為了使用這個版本的 Summary,只需在實現 trait 時定義 summarize_author 即可:

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

一旦定義了 summarize_author,我們就可以對 Tweet 結構體的實例調用 summarize 了,而 summary 的默認實現會調用我們提供的 summarize_author 定義。因為實現了 summarize_authorSummary trait 就提供了 summarize 方法的功能,且無需編寫更多的代碼。

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 new tweet: {}", tweet.summarize());

這會列印出 1 new tweet: (Read more from @horse_ebooks...)

注意無法從相同方法的重載實現中調用預設方法。

trait 作為參數

知道了如何定義 trait 和在類型上實現這些 trait 之後,我們可以探索一下如何使用 trait 來接受多種不同類型的參數。

例如在範例 10-13 中為 NewsArticleTweet 類型實現了 Summary trait。我們可以定義一個函數 notify 來調用其參數 item 上的 summarize 方法,該參數是實現了 Summary trait 的某種類型。為此可以使用 impl Trait 語法,像這樣:

pub fn notify(item: impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

對於 item 參數,我們指定了 impl 關鍵字和 trait 名稱,而不是具體的類型。該參數支持任何實現了指定 trait 的類型。在 notify 函數體中,可以調用任何來自 Summary trait 的方法,比如 summarize。我們可以傳遞任何 NewsArticleTweet 的實例來調用 notify。任何用其它如 Stringi32 的類型調用該函數的代碼都不能編譯,因為它們沒有實現 Summary

Trait Bound 語法

impl Trait 語法適用於直觀的例子,它不過是一個較長形式的語法糖。這被稱為 trait bound,這看起來像:

pub fn notify<T: Summary>(item: T) {
    println!("Breaking news! {}", item.summarize());
}

這與之前的例子相同,不過稍微冗長了一些。trait bound 與泛型參數聲明在一起,位於角括號中的冒號後面。

impl Trait 很方便,適用於短小的例子。trait bound 則適用於更複雜的場景。例如,可以獲取兩個實現了 Summary 的參數。使用 impl Trait 的語法看起來像這樣:

pub fn notify(item1: impl Summary, item2: impl Summary) {

這適用於 item1item2 允許是不同類型的情況(只要它們都實現了 Summary)。不過如果你希望強制它們都是相同類型呢?這只有在使用 trait bound 時才有可能:

pub fn notify<T: Summary>(item1: T, item2: T) {

泛型 T 被指定為 item1item2 的參數限制,如此傳遞給參數 item1item2 值的具體類型必須一致。

通過 + 指定多個 trait bound

如果 notify 需要顯示 item 的格式化形式,同時也要使用 summarize 方法,那麼 item 就需要同時實現兩個不同的 trait:DisplaySummary。這可以通過 + 語法實現:

pub fn notify(item: impl Summary + Display) {

+ 語法也適用於泛型的 trait bound:

pub fn notify<T: Summary + Display>(item: T) {

通過指定這兩個 trait bound,notify 的函數體可以調用 summarize 並使用 {} 來格式化 item

通過 where 簡化 trait bound

然而,使用過多的 trait bound 也有缺點。每個泛型有其自己的 trait bound,所以有多個泛型參數的函數在名稱和參數列表之間會有很長的 trait bound 訊息,這使得函數簽名難以閱讀。為此,Rust 有另一個在函數簽名之後的 where 從句中指定 trait bound 的語法。所以除了這麼寫:

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
{

這個函數簽名就顯得不那麼雜亂,函數名、參數列表和返回值類型都離得很近,看起來類似沒有很多 trait bounds 的函數。

返回實現了 trait 的類型

也可以在返回值中使用 impl Trait 語法,來返回實現了某個 trait 的類型:

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 trait 的類型,但是不確定其具體的類型。在這個例子中 returns_summarizable 返回了一個 Tweet,不過調用方並不知情。

返回一個只是指定了需要實現的 trait 的類型的能力在閉包和疊代器場景十分的有用,第十三章會介紹它們。閉包和疊代器創建只有編譯器知道的類型,或者是非常非常長的類型。impl Trait 允許你簡單的指定函數返回一個 Iterator 而無需寫出實際的冗長的類型。

不過這只適用於返回單一類型的情況。例如,這段代碼的返回值類型指定為返回 impl Summary,但是返回了 NewsArticleTweet 就行不通:

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 工作方式的限制。第十七章的 “為使用不同類型的值而設計的 trait 對象” 部分會介紹如何編寫這樣一個函數。

使用 trait bounds 來修復 largest 函數

現在你知道了如何使用泛型參數 trait bound 來指定所需的行為。讓我們回到實例 10-5 修復使用泛型類型參數的 largest 函數定義!回顧一下,最後嘗試編譯代碼時出現的錯誤是:

error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:12
  |
5 |         if item > largest {
  |            ^^^^^^^^^^^^^^
  |
  = note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

largest 函數體中我們想要使用大於運算符(>)比較兩個 T 類型的值。這個運算符被定義為標準庫中 trait std::cmp::PartialOrd 的一個預設方法。所以需要在 T 的 trait bound 中指定 PartialOrd,這樣 largest 函數可以用於任何可以比較大小的類型的 slice。因為 PartialOrd 位於 prelude 中所以並不需要手動將其引入作用域。將 largest 的簽名修改為如下:

fn largest<T: PartialOrd>(list: &[T]) -> T {

但是如果編譯代碼的話,會出現一些不同的錯誤:

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
  |                       help: consider using a reference instead: `&list[0]`

error[E0507]: cannot move out of borrowed content
 --> src/main.rs:4:9
  |
4 |     for &item in list.iter() {
  |         ^----
  |         ||
  |         |hint: to prevent move, use `ref item` or `ref mut item`
  |         cannot move out of borrowed content

錯誤的核心是 cannot move out of type [T], a non-copy slice,對於非泛型版本的 largest 函數,我們只嘗試了尋找最大的 i32char。正如第四章 “只在棧上的數據:拷貝” 部分討論過的,像 i32char 這樣的類型是已知大小的並可以儲存在棧上,所以他們實現了 Copy trait。當我們將 largest 函數改成使用泛型後,現在 list 參數的類型就有可能是沒有實現 Copy trait 的。這意味著我們可能不能將 list[0] 的值移動到 largest 變數中,這導致了上面的錯誤。

為了只對實現了 Copy 的類型調用這些程式碼,可以在 T 的 trait bounds 中增加 Copy!範例 10-15 中展示了一個可以編譯的泛型版本的 largest 函數的完整代碼,只要傳遞給 largest 的 slice 值的類型實現了 PartialOrd Copy 這兩個 trait,例如 i32char

檔案名: src/main.rs

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

範例 10-15:一個可以用於任何實現了 PartialOrdCopy trait 的泛型的 largest 函數

如果並不希望限制 largest 函數只能用於實現了 Copy trait 的類型,我們可以在 T 的 trait bounds 中指定 Clone 而不是 Copy。並複製 slice 的每一個值使得 largest 函數擁有其所有權。使用 clone 函數意味著對於類似 String 這樣擁有堆上數據的類型,會潛在的分配更多堆上空間,而堆分配在涉及大量數據時可能會相當緩慢。

另一種 largest 的實現方式是返回在 slice 中 T 值的引用。如果我們將函數返回值從 T 改為 &T 並改變函數體使其能夠返回一個引用,我們將不需要任何 CloneCopy 的 trait bounds 而且也不會有任何的堆分配。嘗試自己實現這種替代解決方式吧!

使用 trait bound 有條件地實現方法

透過使用帶有 trait bound 的泛型參數的 impl 塊,可以有條件地只為那些實現了特定 trait 的類型實現方法。例如,範例 10-16 中的類型 Pair<T> 總是實現了 new 方法,不過只有那些為 T 類型實現了 PartialOrd trait (來允許比較) Display trait (來啟用列印)的 Pair<T> 才會實現 cmp_display 方法:


#![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:根據 trait bound 在泛型上有條件的實現方法

也可以對任何實現了特定 trait 的類型有條件地實現 trait。對任何滿足特定 trait bound 的類型實現 trait 被稱為 blanket implementations,他們被廣泛的用於 Rust 標準庫中。例如,標準庫為任何實現了 Display trait 的類型實現了 ToString trait。這個 impl 塊看起來像這樣:

impl<T: Display> ToString for T {
    // --snip--
}

因為標準庫有了這些 blanket implementation,我們可以對任何實現了 Display trait 的類型調用由 ToString 定義的 to_string 方法。例如,可以將整型轉換為對應的 String 值,因為整型實現了 Display


#![allow(unused)]
fn main() {
let s = 3.to_string();
}

blanket implementation 會出現在 trait 文件的 “Implementers” 部分。

trait 和 trait bound 讓我們使用泛型類型參數來減少重複,並仍然能夠向編譯器明確指定泛型類型需要擁有哪些行為。因為我們向編譯器提供了 trait bound 訊息,它就可以檢查代碼中所用到的具體類型是否提供了正確的行為。在動態類型語言中,如果我們嘗試調用一個類型並沒有實現的方法,會在運行時出現錯誤。Rust 將這些錯誤移動到了編譯時,甚至在代碼能夠運行之前就強迫我們修復錯誤。另外,我們也無需編寫運行時檢查行為的代碼,因為在編譯時就已經檢查過了,這樣相比其他那些不願放棄泛型靈活性的語言有更好的性能。

這裡還有一種泛型,我們一直在使用它甚至都沒有察覺它的存在,這就是 生命週期lifetimes)。不同於其他泛型幫助我們確保類型擁有期望的行為,生命週期則有助於確保引用在我們需要他們的時候一直有效。讓我們學習生命週期是如何做到這些的。

生命週期與引用有效性

ch10-03-lifetime-syntax.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

當在第四章討論 “引用和借用” 部分時,我們遺漏了一個重要的細節:Rust 中的每一個引用都有其 生命週期lifetime),也就是引用保持有效的作用域。大部分時候生命週期是隱含並可以推斷的,正如大部分時候類型也是可以推斷的一樣。類似於當因為有多種可能類型的時候必須註明類型,也會出現引用的生命週期以一些不同方式相關聯的情況,所以 Rust 需要我們使用泛型生命週期參數來註明他們的關係,這樣就能確保運行時實際使用的引用絕對是有效的。

生命週期的概念從某種程度上說不同於其他語言中類似的工具,毫無疑問這是 Rust 最與眾不同的功能。雖然本章不可能涉及到它全部的內容,我們會講到一些通常你可能會遇到的生命週期語法以便你熟悉這個概念。

生命週期避免了懸垂引用

生命週期的主要目標是避免懸垂引用,它會導致程序引用了非預期引用的數據。考慮一下範例 10-17 中的程序,它有一個外部作用域和一個內部作用域.

{
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}

範例 10-17:嘗試使用離開作用域的值的引用

注意:範例 10-17、10-18 和 10-24 中聲明了沒有初始值的變數,所以這些變數存在於外部作用域。這乍看之下好像和 Rust 不允許存在空值相衝突。然而如果嘗試在給它一個值之前使用這個變數,會出現一個編譯時錯誤,這就說明了 Rust 確實不允許空值。

外部作用域聲明了一個沒有初值的變數 r,而內部作用域聲明了一個初值為 5 的變數x。在內部作用域中,我們嘗試將 r 的值設置為一個 x 的引用。接著在內部作用域結束後,嘗試列印出 r 的值。這段代碼不能編譯因為 r 引用的值在嘗試使用之前就離開了作用域。如下是錯誤訊息:

error[E0597]: `x` does not live long enough
  --> src/main.rs:7:5
   |
6  |         r = &x;
   |              - borrow occurs here
7  |     }
   |     ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

變數 x 並沒有 “存在的足夠久”。其原因是 x 在到達第 7 行內部作用域結束時就離開了作用域。不過 r 在外部作用域仍是有效的;作用域越大我們就說它 “存在的越久”。如果 Rust 允許這段代碼工作,r 將會引用在 x 離開作用域時被釋放的記憶體,這時嘗試對 r 做任何操作都不能正常工作。那麼 Rust 是如何決定這段代碼是不被允許的呢?這得益於借用檢查器。

借用檢查器

Rust 編譯器有一個 借用檢查器borrow checker),它比較作用域來確保所有的借用都是有效的。範例 10-18 展示了與範例 10-17 相同的例子不過帶有變數生命週期的注釋:

{
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

範例 10-18:rx 的生命週期註解,分別叫做 'a'b

這裡將 r 的生命週期標記為 'a 並將 x 的生命週期標記為 'b。如你所見,內部的 'b 塊要比外部的生命週期 'a 小得多。在編譯時,Rust 比較這兩個生命週期的大小,並發現 r 擁有生命週期 'a,不過它引用了一個擁有生命週期 'b 的對象。程序被拒絕編譯,因為生命週期 'b 比生命週期 'a 要小:被引用的對象比它的引用者存在的時間更短。

讓我們看看範例 10-19 中這個並沒有產生懸垂引用且可以正確編譯的例子:


#![allow(unused)]
fn main() {
{
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // --+       |
}                         // ----------+
}

範例 10-19:一個有效的引用,因為數據比引用有著更長的生命週期

這裡 x 擁有生命週期 'b,比 'a 要大。這就意味著 r 可以引用 x:Rust 知道 r 中的引用在 x 有效的時候也總是有效的。

現在我們已經在一個具體的例子中展示了引用的生命週期位於何處,並討論了 Rust 如何分析生命週期來保證引用總是有效的,接下來讓我們聊聊在函數的上下文中參數和返回值的泛型生命週期。

函數中的泛型生命週期

讓我們來編寫一個返回兩個字串 slice 中較長者的函數。這個函數獲取兩個字串 slice 並返回一個字串 slice。一旦我們實現了 longest 函數,範例 10-20 中的代碼應該會列印出 The longest string is abcd

檔案名: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

範例 10-20:main 函數調用 longest 函數來尋找兩個字串 slice 中較長的一個

注意這個函數獲取作為引用的字串 slice,因為我們不希望 longest 函數獲取參數的所有權。我們期望該函數接受 String 的 slice(參數 string1 的類型)和字串字面值(包含於參數 string2

參考之前第四章中的 “字串 slice 作為參數” 部分中更多關於為什麼範例 10-20 的參數正符合我們期望的討論。

如果嘗試像範例 10-21 中那樣實現 longest 函數,它並不能編譯:

檔案名: src/main.rs

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

範例 10-21:一個 longest 函數的實現,它返回兩個字串 slice 中較長者,現在還不能編譯

相應地會出現如下有關生命週期的錯誤:

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | 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`

提示文本揭示了返回值需要一個泛型生命週期參數,因為 Rust 並不知道將要返回的引用是指向 xy。事實上我們也不知道,因為函數體中 if 塊返回一個 x 的引用而 else 塊返回一個 y 的引用!

當我們定義這個函數的時候,並不知道傳遞給函數的具體值,所以也不知道到底是 if 還是 else 會被執行。我們也不知道傳入的引用的具體生命週期,所以也就不能像範例 10-18 和 10-19 那樣透過觀察作用域來確定返回的引用是否總是有效。借用檢查器自身同樣也無法確定,因為它不知道 xy 的生命週期是如何與返回值的生命週期相關聯的。為了修復這個錯誤,我們將增加泛型生命週期參數來定義引用間的關係以便借用檢查器可以進行分析。

生命週期註解語法

生命週期註解並不改變任何引用的生命週期的長短。與當函數簽名中指定了泛型類型參數後就可以接受任何類型一樣,當指定了泛型生命週期後函數也能接受任何生命週期的引用。生命週期註解描述了多個引用生命週期相互的關係,而不影響其生命週期。

生命週期註解有著一個不太常見的語法:生命週期參數名稱必須以撇號(')開頭,其名稱通常全是小寫,類似於泛型其名稱非常短。'a 是大多數人預設使用的名稱。生命週期參數註解位於引用的 & 之後,並有一個空格來將引用類型與生命週期註解分隔開。

這裡有一些例子:我們有一個沒有生命週期參數的 i32 的引用,一個有叫做 'a 的生命週期參數的 i32 的引用,和一個生命週期也是 'ai32 的可變引用:

&i32        // 引用
&'a i32     // 帶有顯式生命週期的引用
&'a mut i32 // 帶有顯式生命週期的可變引用

單個的生命週期註解本身沒有多少意義,因為生命週期註解告訴 Rust 多個引用的泛型生命週期參數如何相互聯繫的。例如如果函數有一個生命週期 'ai32 的引用的參數 first。還有另一個同樣是生命週期 'ai32 的引用的參數 second。這兩個生命週期註解意味著引用 firstsecond 必須與這泛型生命週期存在得一樣久。

函數簽名中的生命週期註解

現在來看看 longest 函數的上下文中的生命週期。就像泛型類型參數,泛型生命週期參數需要聲明在函數名和參數列表間的角括號中。這裡我們想要告訴 Rust 關於參數中的引用和返回值之間的限制是他們都必須擁有相同的生命週期,就像範例 10-22 中在每個引用中都加上了 'a 那樣:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
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 函數的結果。

現在函數簽名表明對於某些生命週期 'a,函數會獲取兩個參數,他們都是與生命週期 'a 存在的一樣長的字串 slice。函數會返回一個同樣也與生命週期 'a 存在的一樣長的字串 slice。它的實際含義是 longest 函數返回的引用的生命週期與傳入該函數的引用的生命週期的較小者一致。這就是我們告訴 Rust 需要其保證的約束條件。記住通過在函數簽名中指定生命週期參數時,我們並沒有改變任何傳入值或返回值的生命週期,而是指出任何不滿足這個約束條件的值都將被借用檢查器拒絕。注意 longest 函數並不需要知道 xy 具體會存在多久,而只需要知道有某個可以被 'a 替代的作用域將會滿足這個簽名。

當在函數中使用生命週期註解時,這些註解出現在函數簽名中,而不存在於函數體中的任何代碼中。這是因為 Rust 能夠分析函數中代碼而不需要任何協助,不過當函數引用或被函數之外的代碼引用時,讓 Rust 自身分析出參數或返回值的生命週期幾乎是不可能的。這些生命週期在每次函數被調用時都可能不同。這也就是為什麼我們需要手動標記生命週期。

當具體的引用被傳遞給 longest 時,被 'a 所替代的具體生命週期是 x 的作用域與 y 的作用域相重疊的那一部分。換一種說法就是泛型生命週期 'a 的具體生命週期等同於 xy 的生命週期中較小的那一個。因為我們用相同的生命週期參數 'a 標註了返回的引用值,所以返回的引用值就能保證在 xy 中較短的那個生命週期結束之前保持有效。

讓我們看看如何透過傳遞擁有不同具體生命週期的引用來限制 longest 函數的使用。範例 10-23 是一個很直觀的例子。

檔案名: src/main.rs

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

範例 10-23:通過擁有不同的具體生命週期的 String 值調用 longest 函數

在這個例子中,string1 直到外部作用域結束都是有效的,string2 則在內部作用域中是有效的,而 result 則引用了一些直到內部作用域結束都是有效的值。借用檢查器認可這些程式碼;它能夠編譯和運行,並列印出 The longest string is long string is long

接下來,讓我們嘗試另外一個例子,該例子揭示了 result 的引用的生命週期必須是兩個參數中較短的那個。以下代碼將 result 變數的聲明移動出內部作用域,但是將 resultstring2 變數的賦值語句一同留在內部作用域中。接著,使用了變數 resultprintln! 也被移動到內部作用域之外。注意範例 10-24 中的代碼不能通過編譯:

檔案名: src/main.rs

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

範例 10-24:嘗試在 string2 離開作用域之後使用 result

如果嘗試編譯會出現如下錯誤:

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!("The longest string is {}", result);
  |                                          ------ borrow later used here

錯誤表明為了保證 println! 中的 result 是有效的,string2 需要直到外部作用域結束都是有效的。Rust 知道這些是因為(longest)函數的參數和返回值都使用了相同的生命週期參數 'a

如果從人的角度讀上述代碼,我們可能會覺得這個代碼是正確的。 string1 更長,因此 result 會包含指向 string1 的引用。因為 string1 尚未離開作用域,對於 println! 來說 string1 的引用仍然是有效的。然而,我們通過生命週期參數告訴 Rust 的是: longest 函數返回的引用的生命週期應該與傳入參數的生命週期中較短那個保持一致。因此,借用檢查器不允許範例 10-24 中的代碼,因為它可能會存在無效的引用。

請嘗試更多採用不同的值和不同生命週期的引用作為 longest 函數的參數和返回值的實驗。並在開始編譯前猜想你的實驗能否通過借用檢查器,接著編譯一下看看你的理解是否正確!

深入理解生命週期

指定生命週期參數的正確方式依賴函數實現的具體功能。例如,如果將 longest 函數的實現修改為總是返回第一個參數而不是最長的字串 slice,就不需要為參數 y 指定一個生命週期。如下代碼將能夠編譯:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}
}

在這個例子中,我們為參數 x 和返回值指定了生命週期參數 'a,不過沒有為參數 y 指定,因為 y 的生命週期與參數 x 和返回值的生命週期沒有任何關係。

當從函數返回一個引用,返回值的生命週期參數需要與一個參數的生命週期參數相匹配。如果返回的引用 沒有 指向任何一個參數,那麼唯一的可能就是它指向一個函數內部創建的值,它將會是一個懸垂引用,因為它將會在函數結束時離開作用域。嘗試考慮這個並不能編譯的 longest 函數實現:

檔案名: src/main.rs

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

即便我們為返回值指定了生命週期參數 'a,這個實現卻編譯失敗了,因為返回值的生命週期與參數完全沒有關聯。這裡是會出現的錯誤訊息:

error[E0597]: `result` does not live long enough
 --> src/main.rs:3:5
  |
3 |     result.as_str()
  |     ^^^^^^ does not live long enough
4 | }
  | - borrowed value only lives until here
  |
note: borrowed value must be valid for the lifetime 'a as defined on the
function body at 1:1...
 --> src/main.rs:1:1
  |
1 | / fn longest<'a>(x: &str, y: &str) -> &'a str {
2 | |     let result = String::from("really long string");
3 | |     result.as_str()
4 | | }
  | |_^

出現的問題是 resultlongest 函數的結尾將離開作用域並被清理,而我們嘗試從函數返回一個 result 的引用。無法指定生命週期參數來改變懸垂引用,而且 Rust 也不允許我們創建一個懸垂引用。在這種情況,最好的解決方案是返回一個有所有權的數據類型而不是一個引用,這樣函數調用者就需要負責清理這個值了。

綜上,生命週期語法是用於將函數的多個參數與其返回值的生命週期進行關聯的。一旦他們形成了某種關聯,Rust 就有了足夠的訊息來允許記憶體安全的操作並阻止會產生懸垂指針亦或是違反記憶體安全的行為。

結構體定義中的生命週期註解

目前為止,我們只定義過有所有權類型的結構體。接下來,我們將定義包含引用的結構體,不過這需要為結構體定義中的每一個引用添加生命週期註解。範例 10-25 中有一個存放了一個字串 slice 的結構體 ImportantExcerpt

檔案名: src/main.rs

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.')
        .next()
        .expect("Could not find a '.'");
    let i = ImportantExcerpt { part: first_sentence };
}

範例 10-25:一個存放引用的結構體,所以其定義需要生命週期註解

這個結構體有一個欄位,part,它存放了一個字串 slice,這是一個引用。類似於泛型參數類型,必須在結構體名稱後面的角括號中聲明泛型生命週期參數,以便在結構體定義中使用生命週期參數。這個註解意味著 ImportantExcerpt 的實例不能比其 part 欄位中的引用存在的更久。

這裡的 main 函數創建了一個 ImportantExcerpt 的實例,它存放了變數 novel 所擁有的 String 的第一個句子的引用。novel 的數據在 ImportantExcerpt 實例創建之前就存在。另外,直到 ImportantExcerpt 離開作用域之後 novel 都不會離開作用域,所以 ImportantExcerpt 實例中的引用是有效的。

生命週期省略(Lifetime Elision)

現在我們已經知道了每一個引用都有一個生命週期,而且我們需要為那些使用了引用的函數或結構體指定生命週期。然而,第四章的範例 4-9 中有一個函數,如範例 10-26 所示,它沒有生命週期註解卻能編譯成功:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
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[..]
}
}

範例 10-26:範例 4-9 定義了一個沒有使用生命週期註解的函數,即便其參數和返回值都是引用

這個函數沒有生命週期註解卻能編譯是由於一些歷史原因:在早期版本(pre-1.0)的 Rust 中,這的確是不能編譯的。每一個引用都必須有明確的生命週期。那時的函數簽名將會寫成這樣:

fn first_word<'a>(s: &'a str) -> &'a str {

在編寫了很多 Rust 代碼後,Rust 團隊發現在特定情況下 Rust 程式設計師們總是重複地編寫一模一樣的生命週期註解。這些場景是可預測的並且遵循幾個明確的模式。接著 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,說明是個對象的方法(method)(譯者註: 這裡涉及rust的面向對象參見17章), 那麼所有輸出生命週期參數被賦予 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 {

再來應用第二條規則,因為函數存在多個輸入生命週期,它並不適用於這種情況。再來看第三條規則,它同樣也不適用,這是因為沒有 self 參數。應用了三個規則之後編譯器還沒有計算出返回值類型的生命週期。這就是為什麼在編譯範例 10-21 的代碼時會出現錯誤的原因:編譯器使用所有已知的生命週期省略規則,仍不能計算出簽名中所有引用的生命週期。

因為第三條規則真正能夠適用的就只有方法簽名,現在就讓我們看看那種情況中的生命週期,並看看為什麼這條規則意味著我們經常不需要在方法簽名中標註生命週期。

方法定義中的生命週期註解

當為帶有生命週期的結構體實現方法時,其語法依然類似範例 10-11 中展示的泛型類型參數的語法。聲明和使用生命週期參數的位置依賴於生命週期參數是否同結構體欄位或方法參數和返回值相關。

(實現方法時)結構體欄位的生命週期必須總是在 impl 關鍵字之後聲明並在結構體名稱之後被使用,因為這些生命週期是結構體類型的一部分。

impl 塊裡的方法簽名中,引用可能與結構體欄位中的引用相關聯,也可能是獨立的。另外,生命週期省略規則也經常讓我們無需在方法簽名中使用生命週期註解。讓我們看看一些使用範例 10-25 中定義的結構體 ImportantExcerpt 的例子。

首先,這裡有一個方法 level。其唯一的參數是 self 的引用,而且返回值只是一個 i32,並不引用任何值:


#![allow(unused)]
fn main() {
struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}
}

impl 之後和類型名稱之後的生命週期參數是必要的,不過因為第一條生命週期規則我們並不必須標註 self 引用的生命週期。

這裡是一個適用於第三條生命週期省略規則的例子:


#![allow(unused)]
fn main() {
struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}
}

這裡有兩個輸入生命週期,所以 Rust 應用第一條生命週期省略規則並給予 &selfannouncement 他們各自的生命週期。接著,因為其中一個參數是 &self,返回值類型被賦予了 &self 的生命週期,這樣所有的生命週期都被計算出來了。

靜態生命週期

這裡有一種特殊的生命週期值得討論:'static,其生命週期能夠存活於整個程序期間。所有的字串字面值都擁有 'static 生命週期,我們也可以選擇像下面這樣標註出來:


#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

這個字串的文本被直接儲存在程序的二進位制文件中而這個文件總是可用的。因此所有的字串字面值都是 'static 的。

你可能在錯誤訊息的幫助文本中見過使用 'static 生命週期的建議,不過將引用指定為 'static 之前,思考一下這個引用是否真的在整個程序的生命週期裡都有效。你也許要考慮是否希望它存在得這麼久,即使這是可能的。大部分情況,代碼中的問題是嘗試創建一個懸垂引用或者可用的生命週期不匹配,請解決這些問題而不是指定一個 'static 的生命週期。

結合泛型類型參數、trait bounds 和生命週期

讓我們簡要的看一下在同一函數中指定泛型類型參數、trait bounds 和生命週期的語法!


#![allow(unused)]
fn main() {
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!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
}

這個是範例 10-22 中那個返回兩個字串 slice 中較長者的 longest 函數,不過帶有一個額外的參數 annann 的類型是泛型 T,它可以被放入任何實現了 where 從句中指定的 Display trait 的類型。這個額外的參數會在函數比較字串 slice 的長度之前被列印出來,這也就是為什麼 Display trait bound 是必須的。因為生命週期也是泛型,所以生命週期參數 'a 和泛型類型參數 T 都位於函數名後的同一角括號列表中。

總結

這一章介紹了很多的內容!現在你知道了泛型類型參數、trait 和 trait bounds 以及泛型生命週期類型,你已經準備好編寫既不重複又能適用於多種場景的代碼了。泛型類型參數意味著代碼可以適用於不同的類型。trait 和 trait bounds 保證了即使類型是泛型的,這些類型也會擁有所需要的行為。由生命週期註解所指定的引用生命週期之間的關係保證了這些靈活多變的代碼不會出現懸垂引用。而所有的這一切發生在編譯時所以不會影響運行時效率!

你可能不會相信,這個話題還有更多需要學習的內容:第十七章會討論 trait 對象,這是另一種使用 trait 的方式。第十九章會涉及到生命週期註解更複雜的場景,並講解一些高級的類型系統功能。不過接下來,讓我們聊聊如何在 Rust 中編寫測試,來確保代碼的所有功能能像我們希望的那樣工作!

編寫自動化測試

ch11-00-testing.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

Edsger W. Dijkstra 在其 1972 年的文章【謙卑的程式設計師】(“The Humble Programmer”)中說到 “軟體測試是證明 bug 存在的有效方法,而證明其不存在時則顯得令人絕望的不足。”(“Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.”)這並不意味著我們不該儘可能地測試軟體!

程序的正確性意味著代碼如我們期望的那樣運行。Rust 是一個相當注重正確性的程式語言,不過正確性是一個難以證明的複雜主題。Rust 的類型系統在此問題上下了很大的功夫,不過它不可能捕獲所有種類的錯誤。為此,Rust 也在語言本身包含了編寫軟體測試的支持。

例如,我們可以編寫一個叫做 add_two 的將傳遞給它的值加二的函數。它的簽名有一個整型參數並返回一個整型值。當實現和編譯這個函數時,Rust 會進行所有目前我們已經見過的類型檢查和借用檢查,例如,這些檢查會確保我們不會傳遞 String 或無效的引用給這個函數。Rust 所 不能 檢查的是這個函數是否會準確的完成我們期望的工作:返回參數加二後的值,而不是比如說參數加 10 或減 50 的值!這也就是測試出場的地方。

我們可以編寫測試斷言,比如說,當傳遞 3add_two 函數時,返回值是 5。無論何時對代碼進行修改,都可以運行測試來確保任何現存的正確行為沒有被改變。

測試是一項複雜的技能:雖然不能在一個章節的篇幅中介紹如何編寫好的測試的每個細節,但我們還是會討論 Rust 測試功能的機制。我們會講到編寫測試時會用到的註解和宏,運行測試的默認行為和選項,以及如何將測試組織成單元測試和集成測試。

如何編寫測試

ch11-01-writing-tests.md
commit cc6a1ef2614aa94003566027b285b249ccf961fa

Rust 中的測試函數是用來驗證非測試代碼是否按照期望的方式運行的。測試函數體通常執行如下三種操作:

  1. 設置任何所需的數據或狀態
  2. 運行需要測試的代碼
  3. 斷言其結果是我們所期望的

讓我們看看 Rust 提供的專門用來編寫測試的功能:test 屬性、一些宏和 should_panic 屬性。

測試函數剖析

作為最簡單例子,Rust 中的測試就是一個帶有 test 屬性註解的函數。屬性(attribute)是關於 Rust 代碼片段的元數據;第五章中結構體中用到的 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

fn main() {}
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

範例 11-1:由 cargo new 自動生成的測試模組和函數

現在讓我們暫時忽略 tests 模組和 #[cfg(test)] 註解,並只關注函數來了解其如何工作。注意 fn 行之前的 #[test]:這個屬性表明這是一個測試函數,這樣測試執行者就知道將其作為測試處理。因為也可以在 tests 模組中擁有非測試的函數來幫助我們建立通用場景或進行常見操作,所以需要使用 #[test] 屬性標明哪些函數是測試。

函數體透過使用 assert_eq! 宏來斷言 2 加 2 等於 4。一個典型的測試的格式,就是像這個例子中的斷言一樣。接下來運行就可以看到測試通過。

cargo test 命令會運行項目中所有的測試,如範例 11-2 所示:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.22 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

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)在編寫本書時,仍只能用於 Rust 開發版(nightly Rust)。請查看 性能測試的文件 了解更多。

測試輸出中的以 Doc-tests adder 開頭的這一部分是所有文件測試的結果。我們現在並沒有任何文件測試,不過 Rust 會編譯任何在 API 文件中的代碼範例。這個功能幫助我們使文件和代碼保持同步!在第十四章的 “文件注釋作為測試” 部分會講到如何編寫文件測試。現在我們將忽略 Doc-tests 部分的輸出。

讓我們改變測試的名稱並看看這如何改變測試的輸出。給 it_works 函數取個不同的名字,比如 exploration,像這樣:

檔案名: src/lib.rs

fn main() {}
#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

並再次運行 cargo test。現在輸出中將出現 exploration 而不是 it_works

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

讓我們增加另一個測試,不過這一次是一個會失敗的測試!當測試函數中出現 panic 時測試就失敗了。每一個測試都在一個新執行緒中運行,當主執行緒發現測試執行緒異常了,就將對應測試標記為失敗。第九章講到了最簡單的造成 panic 的方法:調用 panic! 宏。寫入新測試 another 後, src/lib.rs 現在看起來如範例 11-3 所示:

檔案名: src/lib.rs

fn main() {}
#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

範例 11-3:增加第二個因調用了 panic! 而失敗的測試

再次 cargo test 運行測試。輸出應該看起來像範例 11-4,它表明 exploration 測試通過了而 another 失敗了:

running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED

failures:

---- tests::another stdout ----
thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed

範例 11-4:一個測試通過和一個測試失敗的測試結果

test tests::another 這一行是 FAILED 而不是 ok 了。在單獨測試結果和摘要之間多了兩個新的部分:第一個部分顯示了測試失敗的詳細原因。在這個例子中,another 因為在src/lib.rs 的第 10 行 panicked at 'Make this test fail' 而失敗。下一部分列出了所有失敗的測試,這在有很多測試和很多失敗測試的詳細輸出時很有幫助。我們可以透過使用失敗測試的名稱來只運行這個測試,以便除錯;下一部分 “控制測試如何運行” 會講到更多運行測試的方法。

最後是摘要行:總體上講,測試結果是 FAILED。有一個測試通過和一個測試失敗。

現在我們見過不同場景中測試結果是什麼樣子的了,再來看看除 panic! 之外的一些在測試中有幫助的宏吧。

使用 assert! 宏來檢查結果

assert! 宏由標準庫提供,在希望確保測試中一些條件為 true 時非常有用。需要向 assert! 宏提供一個求值為布爾值的參數。如果值是 trueassert! 什麼也不做,同時測試會通過。如果值為 falseassert! 調用 panic! 宏,這會導致測試失敗。assert! 宏幫助我們檢查代碼是否以期望的方式運行。

回憶一下第五章中,範例 5-15 中有一個 Rectangle 結構體和一個 can_hold 方法,在範例 11-5 中再次使用他們。將他們放進 src/lib.rs 並使用 assert! 宏編寫一些測試。

檔案名: src/lib.rs

fn main() {}
#[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
    }
}

範例 11-5:第五章中 Rectangle 結構體和其 can_hold 方法

can_hold 方法返回一個布爾值,這意味著它完美符合 assert! 宏的使用場景。在範例 11-6 中,讓我們編寫一個 can_hold 方法的測試來作為練習,這裡創建一個長為 8 寬為 7 的 Rectangle 實例,並假設它可以放得下另一個長為 5 寬為 1 的 Rectangle 實例:

檔案名: src/lib.rs

fn main() {}
#[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));
    }
}

範例 11-6:一個 can_hold 的測試,檢查一個較大的矩形確實能放得下一個較小的矩形

注意在 tests 模組中新增加了一行:use super::*;tests 是一個普通的模組,它遵循第七章 “路徑用於引用模組樹中的項” 部分介紹的可見性規則。因為這是一個內部模組,要測試外部模組中的代碼,需要將其引入到內部模組的作用域中。這裡選擇使用 glob 全局導入,以便在 tests 模組中使用所有在外部模組定義的內容。

我們將測試命名為 larger_can_hold_smaller,並創建所需的兩個 Rectangle 實例。接著調用 assert! 宏並傳遞 larger.can_hold(&smaller) 調用的結果作為參數。這個表達式預期會返回 true,所以測試應該通過。讓我們拭目以待!

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

它確實通過了!再來增加另一個測試,這一回斷言一個更小的矩形不能放下一個更大的矩形:

檔案名: src/lib.rs

fn main() {}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
    }

    #[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));
    }
}

因為這裡 can_hold 函數的正確結果是 false ,我們需要將這個結果取反後傳遞給 assert! 宏。因此 can_hold 返回 false 時測試就會通過:

running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

兩個通過的測試!現在讓我們看看如果引入一個 bug 的話測試結果會發生什麼事。將 can_hold 方法中比較長度時本應使用大於號的地方改成小於號:

fn main() {}
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}
// --snip--

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

現在運行測試會產生:

running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... FAILED

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at 'assertion failed:
larger.can_hold(&smaller)', src/lib.rs:22:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

我們的測試捕獲了 bug!因為 larger.length 是 8 而 smaller.length 是 5,can_hold 中的長度比較現在因為 8 不小於 5 而返回 false

使用 assert_eq!assert_ne! 宏來測試相等

測試功能的一個常用方法是將需要測試代碼的值與期望值做比較,並檢查是否相等。可以通過向 assert! 宏傳遞一個使用 == 運算符的表達式來做到。不過這個操作實在是太常見了,以至於標準庫提供了一對宏來更方便的處理這些操作 —— assert_eq!assert_ne!。這兩個宏分別比較兩個值是相等還是不相等。當斷言失敗時他們也會列印出這兩個值具體是什麼,以便於觀察測試 為什麼 失敗,而 assert! 只會列印出它從 == 表達式中得到了 false 值,而不是導致 false 的兩個值。

範例 11-7 中,讓我們編寫一個對其參數加二並返回結果的函數 add_two。接著使用 assert_eq! 宏測試這個函數。

檔案名: src/lib.rs

fn main() {}
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));
    }
}

範例 11-7:使用 assert_eq! 宏測試 add_two 函數

測試通過了!

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

傳遞給 assert_eq! 宏的第一個參數 4 ,等於調用 add_two(2) 的結果。測試中的這一行 test tests::it_adds_two ... okok 表明測試通過!

在代碼中引入一個 bug 來看看使用 assert_eq! 的測試失敗是什麼樣的。修改 add_two 函數的實現使其加 3:

fn main() {}
pub fn add_two(a: i32) -> i32 {
    a + 3
}

再次運行測試:

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

測試捕獲到了 bug!it_adds_two 測試失敗,顯示訊息 assertion failed: `(left == right)` 並表明 left4right5。這個訊息有助於我們開始除錯:它說 assert_eq!left 參數是 4,而 right 參數,也就是 add_two(2) 的結果,是 5

需要注意的是,在一些語言和測試框架中,斷言兩個值相等的函數的參數叫做 expectedactual,而且指定參數的順序是很關鍵的。然而在 Rust 中,他們則叫做 leftright,同時指定期望的值和被測試代碼產生的值的順序並不重要。這個測試中的斷言也可以寫成 assert_eq!(add_two(2), 4),這時失敗訊息會變成 assertion failed: `(left == right)` 其中 left5right4

assert_ne! 宏在傳遞給它的兩個值不相等時通過,而在相等時失敗。在代碼按預期運行,我們不確定值 是什麼,不過能確定值絕對 不會 是什麼的時候,這個宏最有用處。例如,如果一個函數保證會以某種方式改變其輸出,不過這種改變方式是由運行測試時是星期幾來決定的,這時最好的斷言可能就是函數的輸出不等於其輸入。

assert_eq!assert_ne! 宏在底層分別使用了 ==!=。當斷言失敗時,這些宏會使用除錯格式列印出其參數,這意味著被比較的值必需實現了 PartialEqDebug trait。所有的基本類型和大部分標準庫類型都實現了這些 trait。對於自訂的結構體和枚舉,需要實現 PartialEq 才能斷言他們的值是否相等。需要實現 Debug 才能在斷言失敗時列印他們的值。因為這兩個 trait 都是派生 trait,如第五章範例 5-12 所提到的,通常可以直接在結構體或枚舉上添加 #[derive(PartialEq, Debug)] 註解。附錄 C “可派生 trait” 中有更多關於這些和其他派生 trait 的詳細訊息。

自訂失敗訊息

你也可以向 assert!assert_eq!assert_ne! 宏傳遞一個可選的失敗訊息參數,可以在測試失敗時將自訂失敗訊息一同列印出來。任何在 assert! 的一個必需參數和 assert_eq!assert_ne! 的兩個必需參數之後指定的參數都會傳遞給 format! 宏(在第八章的 “使用 + 運算符或 format! 宏拼接字串” 部分討論過),所以可以傳遞一個包含 {} 占位符的格式字串和需要放入占位符的值。自訂訊息有助於記錄斷言的意義;當測試失敗時就能更好的理解代碼出了什麼問題。

例如,比如說有一個根據人名進行問候的函數,而我們希望測試將傳遞給函數的人名顯示在輸出中:

檔案名: src/lib.rs

fn main() {}
pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

這個程序的需求還沒有被確定,因此問候文本開頭的 Hello 文本很可能會改變。然而我們並不想在需求改變時不得不更新測試,所以相比檢查 greeting 函數返回的確切值,我們將僅僅斷言輸出的文本中包含輸入參數。

讓我們透過將 greeting 改為不包含 name 來在代碼中引入一個 bug 來測試失敗時是怎樣的:

fn main() {}
pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

運行測試會產生:

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'assertion failed:
result.contains("Carol")', src/lib.rs:12:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::greeting_contains_name

結果僅僅告訴了我們斷言失敗了和失敗的行號。一個更有用的失敗訊息應該列印出 greeting 函數的值。讓我們為測試函數增加一個自訂失敗訊息參數:帶占位符的格式字串,以及 greeting 函數的值:

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "Greeting did not contain name, value was `{}`", result
    );
}

現在如果再次運行測試,將會看到更有價值的訊息:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'Greeting did not
contain name, value was `Hello!`', src/lib.rs:12:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

可以在測試輸出中看到所取得的確切的值,這會幫助我們理解真正發生了什麼事,而不是期望發生什麼事。

使用 should_panic 檢查 panic

除了檢查代碼是否返回期望的正確的值之外,檢查代碼是否按照期望處理錯誤也是很重要的。例如,考慮第九章範例 9-10 創建的 Guess 類型。其他使用 Guess 的代碼都是基於 Guess 實例僅有的值範圍在 1 到 100 的前提。可以編寫一個測試來確保創建一個超出範圍的值的 Guess 實例會 panic。

可以通過對函數增加另一個屬性 should_panic 來實現這些。這個屬性在函數中的代碼 panic 時會通過,而在其中的代碼沒有 panic 時失敗。

範例 11-8 展示了一個檢查 Guess::new 是否按照我們的期望出錯的測試:

檔案名: src/lib.rs

fn main() {}
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value
        }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

範例 11-8:測試會造成 panic! 的條件

#[should_panic] 屬性位於 #[test] 之後,對應的測試函數之前。讓我們看看測試通過時它是什麼樣子:

running 1 test
test tests::greater_than_100 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

看起來不錯!現在在代碼中引入 bug,移除 new 函數在值大於 100 時會 panic 的條件:

fn main() {}
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1  {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value
        }
    }
}

如果運行範例 11-8 的測試,它會失敗:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

這回並沒有得到非常有用的訊息,不過一旦我們觀察測試函數,會發現它標註了 #[should_panic]。這個錯誤意味著代碼中測試函數 Guess::new(200) 並沒有產生 panic。

然而 should_panic 測試結果可能會非常含糊不清,因為它只是告訴我們代碼並沒有產生 panic。should_panic 甚至在一些不是我們期望的原因而導致 panic 時也會通過。為了使 should_panic 測試結果更精確,我們可以給 should_panic 屬性增加一個可選的 expected 參數。測試工具會確保錯誤訊息中包含其提供的文本。例如,考慮範例 11-9 中修改過的 Guess,這裡 new 函數根據其值是過大還或者過小而提供不同的 panic 訊息:

檔案名: src/lib.rs

fn main() {}
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be greater than or equal to 1, got {}.",
                   value);
        } else if value > 100 {
            panic!("Guess value must be less than or equal to 100, got {}.",
                   value);
        }

        Guess {
            value
        }
    }
}

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

    #[test]
    #[should_panic(expected = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

範例 11-9:一個會帶有特定錯誤訊息的 panic! 條件的測試

這個測試會通過,因為 should_panic 屬性中 expected 參數提供的值是 Guess::new 函數 panic 訊息的子串。我們可以指定期望的整個 panic 訊息,在這個例子中是 Guess value must be less than or equal to 100, got 200.expected 訊息的選擇取決於 panic 訊息有多獨特或動態,和你希望測試有多準確。在這個例子中,錯誤訊息的子字串足以確保函數在 else if value > 100 的情況下運行。

為了觀察帶有 expected 訊息的 should_panic 測試失敗時會發生什麼事,讓我們再次引入一個 bug,將 if value < 1else if value > 100 的代碼塊對換:

if value < 1 {
    panic!("Guess value must be less than or equal to 100, got {}.", value);
} else if value > 100 {
    panic!("Guess value must be greater than or equal to 1, got {}.", value);
}

這一次執行 should_panic 測試,它會失敗:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at 'Guess value must be
greater than or equal to 1, got 200.', src/lib.rs:11:13
note: Run with `RUST_BACKTRACE=1` for a backtrace.
note: Panic did not include expected string 'Guess value must be less than or
equal to 100'

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

失敗訊息表明測試確實如期望 panic 了,不過 panic 訊息中並沒有包含 expected 訊息 'Guess value must be less than or equal to 100'。而我們得到的 panic 訊息是 'Guess value must be greater than or equal to 1, got 200.'。這樣就可以開始尋找 bug 在哪了!

Result<T, E> 用於測試

目前為止,我們編寫的測試在失敗時就會 panic。也可以使用 Result<T, E> 編寫測試!這裡是第一個例子採用了 Result:


#![allow(unused)]
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 成員的測試。

不能對這些使用 Result<T, E> 的測試使用 #[should_panic] 註解。相反應該在測試失敗時直接返回 Err 值。

現在你知道了幾種編寫測試的方法,讓我們看看運行測試時會發生什麼事,和可以用於 cargo test 的不同選項。

控制測試如何運行

ch11-02-running-tests.md
commit 42b802f26197f9a066e4a671d2b062af25972c13

就像 cargo run 會編譯代碼並運行生成的二進位制文件一樣,cargo test 在測試模式下編譯代碼並運行生成的測試二進位制文件。可以指定命令行參數來改變 cargo test 的默認行為。例如,cargo test 生成的二進位制文件的默認行為是並行的運行所有測試,並截獲測試運行過程中產生的輸出,阻止他們被顯示出來,使得閱讀測試結果相關的內容變得更容易。

可以將一部分命令行參數傳遞給 cargo test,而將另外一部分傳遞給生成的測試二進位制文件。為了分隔這兩種參數,需要先列出傳遞給 cargo test 的參數,接著是分隔符 --,再之後是傳遞給測試二進位制文件的參數。運行 cargo test --help 會提示 cargo test 的有關參數,而運行 cargo test -- --help 可以提示在分隔符 -- 之後使用的有關參數。

並行或連續的運行測試

當運行多個測試時, Rust 預設使用執行緒來並行運行。這意味著測試會更快地運行完畢,所以你可以更快的得到代碼能否工作的回饋。因為測試是在同時執行的,你應該確保測試不能相互依賴,或依賴任何共享的狀態,包括依賴共享的環境,比如當前工作目錄或者環境變數。

舉個例子,每一個測試都運行一些程式碼,假設這些程式碼都在硬碟上創建一個 test-output.txt 文件並寫入一些數據。接著每一個測試都讀取文件中的數據並斷言這個文件包含特定的值,而這個值在每個測試中都是不同的。因為所有測試都是同時執行的,一個測試可能會在另一個測試讀寫文件過程中修改了文件。那麼第二個測試就會失敗,並不是因為代碼不正確,而是因為測試並行運行時相互干擾。一個解決方案是使每一個測試讀寫不同的文件;另一個解決方案是一次執行一個測試。

如果你不希望測試並行運行,或者想要更加精確的控制執行緒的數量,可以傳遞 --test-threads 參數和希望使用執行緒的數量給測試二進位制文件。例如:

$ cargo test -- --test-threads=1

這裡將測試執行緒設置為 1,告訴程序不要使用任何並行機制。這也會比並行運行花費更多時間,不過在有共享的狀態時,測試就不會潛在的相互干擾了。

顯示函數輸出

默認情況下,當測試通過時,Rust 的測試庫會截獲列印到標準輸出的所有內容。比如在測試中調用了 println! 而測試通過了,我們將不會在終端看到 println! 的輸出:只會看到說明測試通過的提示行。如果測試失敗了,則會看到所有標準輸出和其他錯誤訊息。

例如,範例 11-10 有一個無意義的函數,它列印出其參數的值並接著返回 10。接著還有一個會通過的測試和一個會失敗的測試:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {}", 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 將會看到這些測試的輸出:

running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

注意輸出中不會出現測試通過時列印的內容,即 I got the value 4。因為當測試通過時,這些輸出會被截獲。失敗測試的輸出 I got the value 8 ,則出現在輸出的測試摘要部分,同時也顯示了測試失敗的原因。

如果你希望也能看到通過的測試中列印的值,截獲輸出的行為可以通過 --nocapture 參數來禁用:

$ cargo test -- --nocapture

使用 --nocapture 參數再次運行範例 11-10 中的測試會顯示如下輸出:

running 2 tests
I got the value 4
I got the value 8
test tests::this_test_will_pass ... ok
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
test tests::this_test_will_fail ... FAILED

failures:

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

注意測試的輸出和測試結果的輸出是相互交叉的,這是由於測試是並行運行的(見上一部分)。嘗試一同使用 --test-threads=1--nocapture 功能來看看輸出是什麼樣子!

透過指定名字來運行部分測試

有時運行整個測試集會耗費很長時間。如果你負責特定位置的代碼,你可能會希望只運行與這些程式碼相關的測試。你可以向 cargo test 傳遞所希望運行的測試名稱的參數來選擇運行哪些測試。

為了展示如何運行部分測試,範例 11-11 為 add_two 函數創建了三個測試,我們可以選擇具體運行哪一個:

檔案名: 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:不同名稱的三個測試

如果沒有傳遞任何參數就運行測試,如你所見,所有測試都會並行運行:

running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

運行單個測試

可以向 cargo test 傳遞任意測試的名稱來只運行這個測試:

$ cargo test one_hundred
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

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
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 2 tests
test tests::add_two_and_two ... ok
test tests::add_three_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


#![allow(unused)]
fn main() {
#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
    // 需要運行一個小時的代碼
}
}

對於想要排除的測試,我們在 #[test] 之後增加了 #[ignore] 行。現在如果運行測試,就會發現 it_works 運行了,而 expensive_test 沒有運行:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

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

expensive_test 被列為 ignored,如果我們只希望運行被忽略的測試,可以使用 cargo test -- --ignored

$ cargo test -- --ignored
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out

通過控制運行哪些測試,你可以確保能夠快速地運行 cargo test 。當你需要運行 ignored 的測試時,可以執行 cargo test -- --ignored

測試的組織結構

ch11-03-test-organization.md
commit 4badf9a8574c12794795b05954baf5adc579fa90

本章一開始就提到,測試是一個複雜的概念,而且不同的開發者也採用不同的技術和組織。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


#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
}

上述代碼就是自動生成的測試模組。cfg 屬性代表 configuration ,它告訴 Rust 其之後的項只應該被包含進特定配置選項中。在這個例子中,配置選項是 test,即 Rust 所提供的用於編譯和運行測試的配置選項。透過使用 cfg 屬性,Cargo 只會在我們主動使用 cargo test 運行測試時才編譯測試代碼。需要編譯的不僅僅有標註為 #[test] 的函數之外,還包括測試模組中可能存在的幫助函數。

測試私有函數

測試社區中一直存在關於是否應該對私有函數直接進行測試的論戰,而在其他語言中想要測試私有函數是一件困難的,甚至是不可能的事。不過無論你堅持哪種測試意識形態,Rust 的私有性規則確實允許你測試私有函數。考慮範例 11-12 中帶有私有函數 internal_adder 的代碼:

檔案名: src/lib.rs

fn main() {}

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));
    }
}

範例 11-12:測試私有函數

注意 internal_adder 函數並沒有標記為 pub,不過因為測試也不過是 Rust 代碼同時 tests 也僅僅是另一個模組,我們完全可以在測試中導入和調用 internal_adder。如果你並不認為應該測試私有函數,Rust 也不會強迫你這麼做。

集成測試

在 Rust 中,集成測試對於你需要測試的庫來說完全是外部的。同其他使用庫的代碼一樣使用庫文件,也就是說它們只能調用一部分庫中的公有 API 。集成測試的目的是測試庫的多個部分能否一起正常工作。一些單獨能正確運行的代碼單元集成在一起也可能會出現問題,所以集成測試的覆蓋率也是很重要的。為了創建集成測試,你需要先創建一個 tests 目錄。

tests 目錄

為了編寫集成測試,需要在項目根目錄創建一個 tests 目錄,與 src 同級。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,所以需要在每一個文件中導入庫。

並不需要將 tests/integration_test.rs 中的任何代碼標註為 #[cfg(test)]tests 文件夾在 Cargo 中是一個特殊的文件夾, Cargo 只會在運行 cargo test 時編譯這個目錄中的文件。現在就運行 cargo test 試試:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running target/debug/deps/adder-abcabcabc

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-ce99bcc2479f4607

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(在輸出最後的哈希值可能不同)開頭。接下來每一行是一個集成測試中的測試函數,以及一個位於 Doc-tests adder 部分之前的集成測試的摘要行。

我們已經知道,單元測試函數越多,單元測試部分的結果行就會越多。同樣的,在集成文件中增加的測試函數越多,也會在對應的測試結果部分增加越多的結果行。每一個集成測試文件有對應的測試結果部分,所以如果在 tests 目錄中增加更多文件,測試結果中就會有更多集成測試結果部分。

我們仍然可以通過指定測試函數的名稱作為 cargo test 的參數來運行特定集成測試。也可以使用 cargo test--test 後跟文件的名稱來運行某個特定集成測試文件中的所有測試:

$ cargo test --test integration_test
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/integration_test-952a27e0126bb565

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 的函數,我們希望這個函數能被多個測試文件的測試函數調用:

檔案名: tests/common.rs


#![allow(unused)]
fn main() {
pub fn setup() {
    // 編寫特定庫測試所需的代碼
}
}

如果再次運行測試,將會在測試結果中看到一個新的對應 common.rs 文件的測試結果部分,即便這個文件並沒有包含任何測試函數,也沒有任何地方調用了 setup 函數:

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-b8b07b6f1be2db70

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/integration_test-d993c68b431d39df

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/mod.rs ,而不是創建 tests/common.rs 。這是一種 Rust 的命名規範,這樣命名告訴 Rust 不要將 common 看作一個集成測試文件。將 setup 函數代碼移動到 tests/common/mod.rs 並刪除 tests/common.rs 文件之後,測試輸出中將不會出現這一部分。tests 目錄中的子目錄不會被作為單獨的 crate 編譯或作為一個測試結果部分出現在測試輸出中。

一旦擁有了 tests/common/mod.rs,就可以將其作為模組以便在任何集成測試文件中使用。這裡是一個 tests/integration_test.rs 中調用 setup 函數的 it_adds_two 測試的例子:

檔案名: 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-25 中展示的模組聲明相同。接著在測試函數中就可以調用 common::setup() 了。

二進位制 crate 的集成測試

如果項目是二進位制 crate 並且只包含 src/main.rs 而沒有 src/lib.rs,這樣就不可能在 tests 目錄創建集成測試並使用 extern crate 導入 src/main.rs 中定義的函數。只有庫 crate 才會向其他 crate 暴露了可供調用和使用的函數;二進位制 crate 只意在單獨運行。

為什麼 Rust 二進位制項目的結構明確採用 src/main.rs 調用 src/lib.rs 中的邏輯的方式?因為通過這種結構,集成測試 就可以 通過 extern crate 測試庫 crate 中的主要功能了,而如果這些重要的功能沒有問題的話,src/main.rs 中的少量代碼也就會正常工作且不需要測試。

總結

Rust 的測試功能提供了一個確保即使你改變了函數的實現方式,也能繼續以期望的方式運行的途徑。單元測試獨立地驗證庫的不同部分,也能夠測試私有函數實現細節。集成測試則檢查多個部分是否能結合起來正確地工作,並像其他外部代碼那樣測試庫的公有 API。即使 Rust 的類型系統和所有權規則可以幫助避免一些 bug,不過測試對於減少代碼中不符合期望行為的邏輯 bug 仍然是很重要的。

讓我們將本章和其他之前章節所學的知識組合起來,在下一章一起編寫一個項目!

一個 I/O 項目:構建一個命令行程序

ch12-00-an-io-project.md
commit db919bc6bb9071566e9c4f05053672133eaac33e

本章既是一個目前所學的很多技能的概括,也是一個更多標準庫功能的探索。我們將構建一個與文件和命令行輸入/輸出交互的命令行工具來練習現在一些你已經掌握的 Rust 技能。

Rust 的運行速度、安全性、單二進位制文件輸出和跨平台支持使其成為創建命令行程序的絕佳選擇,所以我們的項目將創建一個我們自己版本的經典命令行工具:grep。grep 是 “Globally search a Regular Expression and Print.” 的首字母縮寫。grep 最簡單的使用場景是在特定文件中搜尋指定字串。為此,grep 獲取一個檔案名和一個字串作為參數,接著讀取文件並找到其中包含字串參數的行,然後列印出這些行。

在這個過程中,我們會展示如何讓我們的命令行工具利用很多命令行工具中用到的終端功能。讀取環境變數來使得用戶可以配置工具的行為。列印到標準錯誤控制流(stderr) 而不是標準輸出(stdout),例如這樣用戶可以選擇將成功輸出重定向到文件中的同時仍然在螢幕上顯示錯誤訊息。

一位 Rust 社區的成員,Andrew Gallant,已經創建了一個功能完整且非常快速的 grep 版本,叫做 ripgrep。相比之下,我們的 grep 版本將非常簡單,本章將教會你一些幫助理解像 ripgrep 這樣真實項目的背景知識。

我們的 grep 項目將會結合之前所學的一些內容:

另外還會簡要的講到閉包、疊代器和 trait 對象,他們分別會在 第十三章第十七章 中詳細介紹。

接受命令行參數

ch12-01-accepting-command-line-arguments.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

一如既往使用 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。這個函數返回一個傳遞給程序的命令行參數的 疊代器iterator)。我們會在 第十三章 全面的介紹它們。但是現在只需理解疊代器的兩個細節:疊代器生成一系列的值,可以在疊代器上調用 collect 方法將其轉換為一個集合,比如包含所有疊代器產生元素的 vector。

使用範例 12-1 中的代碼來讀取任何傳遞給 minigrep 的命令行參數並將其收集到一個 vector 中。

檔案名: src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

範例 12-1:將命令行參數收集到一個 vector 中並列印出來

首先使用 use 語句來將 std::env 模組引入作用域以便可以使用它的 args 函數。注意 std::env::args 函數被嵌套進了兩層模組中。正如 第七章 講到的,當所需函數嵌套了多於一層模組時,通常將父模組引入作用域,而不是其自身。這便於我們利用 std::env 中的其他函數。這比增加了 use std::env::args; 後僅僅使用 args 調用函數要更明確一些,因為 args 容易被錯認成一個定義於當前模組的函數。

args 函數和無效的 Unicode

注意 std::env::args 在其任何參數包含無效 Unicode 字元時會 panic。如果你需要接受包含無效 Unicode 字元的參數,使用 std::env::args_os 代替。這個函數返回 OsString 值而不是 String 值。這裡出於簡單考慮使用了 std::env::args,因為 OsString 值每個平台都不一樣而且比 String 值處理起來更為複雜。

main 函數的第一行,我們調用了 env::args,並立即使用 collect 來創建了一個包含疊代器所有值的 vector。collect 可以被用來創建很多類型的集合,所以這裡顯式註明 args 的類型來指定我們需要一個字串 vector。雖然在 Rust 中我們很少會需要註明類型,然而 collect 是一個經常需要註明類型的函數,因為 Rust 不能推斷出你想要什麼類型的集合。

最後,我們使用除錯格式 :? 列印出 vector。讓我們嘗試分別用兩種方式(不包含參數和包含參數)運行程式碼:

$ cargo run
--snip--
["target/debug/minigrep"]

$ cargo run needle haystack
--snip--
["target/debug/minigrep", "needle", "haystack"]

注意 vector 的第一個值是 "target/debug/minigrep",它是我們二進位制文件的名稱。這與 C 中的參數列表的行為相匹配,讓程序使用在執行時調用它們的名稱。如果要在消息中列印它或者根據用於調用程序的命令行別名更改程序的行為,通常可以方便地訪問程序名稱,不過考慮到本章的目的,我們將忽略它並只保存所需的兩個參數。

將參數值保存進變數

列印出參數 vector 中的值展示了程序可以訪問指定為命令行參數的值。現在需要將這兩個參數的值保存進變數這樣就可以在程序的餘下部分使用這些值了。讓我們如範例 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!("Searching for {}", query);
    println!("In file {}", filename);
}

範例 12-2:創建變數來存放查詢參數和檔案名參數

正如之前列印出 vector 時所所看到的,程序的名稱占據了 vector 的第一個值 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.0 secs
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

好的,它可以工作!我們將所需的參數值保存進了對應的變數中。之後會增加一些錯誤處理來應對類似用戶沒有提供參數的情況,不過現在我們將忽略他們並開始增加讀取文件功能。

讀取文件

ch12-02-reading-a-file.md
commit 76df60bccead5f3de96db23d97b69597cd8a2b82

現在我們要增加讀取由 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:艾米莉·狄金森的詩 “I’m nobody! Who are you?”,一個好的測試用例

創建完這個文件之後,修改 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!("Searching for {}", query);
    // --snip--
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");

    println!("With text:\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.0 secs
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
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 函數有著多個職能,通常函數隻負責一個功能的話會更簡潔並易於維護。另一個問題是沒有儘可能的處理錯誤。雖然我們的程序還很小,這些瑕疵並不是什麼大問題,不過隨著程式功能的豐富,將會越來越難以用簡單的方法修復他們。在開發程序時,及早開始重構是一個最佳實踐,因為重構少量代碼時要容易的多,所以讓我們現在就開始吧。

重構改進模組性和錯誤處理

ch12-03-improving-error-handling-and-modularity.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

為了改善我們的程序這裡有四個問題需要修復,而且他們都與程序的組織方式和如何處理潛在錯誤有關。

第一,main 現在進行了兩個任務:它解析了參數並打開了文件。對於一個這樣的小函數,這並不是一個大問題。然而如果 main 中的功能持續增加,main 函數處理的獨立任務也會增加。當函數承擔了更多責任,它就更難以推導,更難以測試,並且更難以在不破壞其他部分的情況下做出修改。最好能分離出功能以便每個函數就負責一個任務。

這同時也關係到第二個問題:queryfilename 是程序中的配置變數,而像 contents 則用來執行程序邏輯。隨著 main 函數的增長,就需要引入更多的變數到作用域中,而當作用域中有更多的變數時,將更難以追蹤每個變數的目的。最好能將配置變數組織進一個結構,這樣就能使他們的目的更明確了。

第三個問題是如果打開文件失敗我們使用 expect 來列印出錯誤訊息,不過這個錯誤訊息只是說 Something went wrong reading the file。讀取文件失敗的原因有多種:例如文件不存在,或者沒有打開此文件的權限。目前,無論處於何種情況,我們只是列印出“文件讀取出現錯誤”的訊息,這並沒有給予使用者具體的訊息!

第四,我們不停地使用 expect 來處理不同的錯誤,如果用戶沒有指定足夠的參數來運行程序,他們會從 Rust 得到 index out of bounds 錯誤,而這並不能明確地解釋問題。如果所有的錯誤處理都位於一處,這樣將來的維護者在需要修改錯誤處理邏輯時就只需要考慮這一處代碼。將所有的錯誤處理都放在一處也有助於確保我們列印的錯誤訊息對終端用戶來說是有意義的。

讓我們透過重構項目來解決這些問題。

二進位制項目的關注分離

main 函數負責多個任務的組織問題在許多二進位制項目中很常見。所以 Rust 社區開發出一類在 main 函數開始變得龐大時進行二進位制程序的關注分離的指導性過程。這些過程有如下步驟:

  • 將程序拆分成 main.rslib.rs 並將程序的邏輯放入 lib.rs 中。
  • 當命令行解析邏輯比較小時,可以保留在 main.rs 中。
  • 當命令行解析開始變得複雜時,也同樣將其從 main.rs 提取到 lib.rs 中。

經過這些過程之後保留在 main 函數中的責任應該被限制為:

  • 使用參數值調用命令行解析邏輯
  • 設置任何其他的配置
  • 調用 lib.rs 中的 run 函數
  • 如果 run 返回錯誤,則處理這個錯誤

這個模式的一切就是為了關注分離:main.rs 處理程序運行,而 lib.rs 處理所有的真正的任務邏輯。因為不能直接測試 main 函數,這個結構透過將所有的程序邏輯移動到 lib.rs 的函數中使得我們可以測試他們。僅僅保留在 main.rs 中的代碼將足夠小以便閱讀就可以驗證其正確性。讓我們遵循這些步驟來重構程序。

提取參數解析器

首先,我們將解析參數的功能提取到一個 main 將會調用的函數中,為將命令行解析邏輯移動到 src/lib.rs 中做準備。範例 12-5 中展示了新 main 函數的開頭,它調用了新函數 parse_config。目前它仍將定義在 src/main.rs 中:

檔案名: src/main.rs

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

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

    // --snip--
}

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

    (query, filename)
}

範例 12-5:從 main 中提取出 parse_config 函數

我們仍然將命令行參數收集進一個 vector,不過不同於在 main 函數中將索引 1 的參數值賦值給變數 query 和將索引 2 的值賦值給變數 filename,我們將整個 vector 傳遞給 parse_config 函數。接著 parse_config 函數將包含決定哪個參數該放入哪個變數的邏輯,並將這些值返回到 main。仍然在 main 中創建變數 queryfilename,不過 main 不再負責處理命令行參數與變數如何對應。

這對重構我們這小程序可能有點大材小用,不過我們將採用小的、增量的步驟進行重構。在做出這些改變之後,再次運行程序並驗證參數解析是否仍然正常。經常驗證你的進展是一個好習慣,這樣在遇到問題時能幫助你定位問題的成因。

組合配置值

我們可以採取另一個小的步驟來進一步改善這個函數。現在函數返回一個元組,不過立刻又將元組拆成了獨立的部分。這是一個我們可能沒有進行正確抽象的信號。

另一個表明還有改進空間的跡象是 parse_config 名稱的 config 部分,它暗示了我們返回的兩個值是相關的並都是一個配置值的一部分。目前除了將這兩個值組合進元組之外並沒有表達這個數據結構的意義:我們可以將這兩個值放入一個結構體並給每個欄位一個有意義的名字。這會讓未來的維護者更容易理解不同的值如何相互關聯以及他們的目的。

注意:一些同學將這種在複雜類型更為合適的場景下使用基本類型的反模式稱為 基本類型偏執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!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    // --snip--
}

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 值的字串 slice,現在我們定義 Config 來包含擁有所有權的 String 值。main 中的 args 變數是參數值的所有者並只允許 parse_config 函數借用他們,這意味著如果 Config 嘗試獲取 args 中值的所有權將違反 Rust 的借用規則。

還有許多不同的方式可以處理 String 的數據,而最簡單但有些不太高效的方式是調用這些值的 clone 方法。這會生成 Config 實例可以擁有的數據的完整拷貝,不過會比儲存字串數據的引用消耗更多的時間和記憶體。不過拷貝數據使得代碼顯得更加直接因為無需管理引用的生命週期,所以在這種情況下犧牲一小部分性能來換取簡潔性的取捨是值得的。

使用 clone 的權衡取捨

由於其運行時消耗,許多 Rustacean 之間有一個趨勢是傾向於避免使用 clone 來解決所有權問題。在關於疊代器的第十三章中,我們將會學習如何更有效率的處理這種情況,不過現在,複製一些字串來取得進展是沒有問題的,因為只會進行一次這樣的拷貝,而且檔案名和要搜索的字串都比較短。在第一輪編寫時擁有一個可以工作但有點低效的程序要比嘗試過度最佳化程式碼更好一些。隨著你對 Rust 更加熟練,將能更輕鬆的直奔合適的方法,不過現在調用 clone 是完全可以接受的。

我們更新 mainparse_config 返回的 Config 實例放入變數 config 中,並將之前分別使用 queryfilename 變數的代碼更新為現在的使用 Config 結構體的欄位的代碼。

現在代碼更明確的表現了我們的意圖,queryfilename 是相關聯的並且他們的目的是配置程序如何工作。任何使用這些值的代碼就知道在 config 實例中對應目的的欄位名中尋找他們。

創建一個 Config 的構造函數

目前為止,我們將負責解析命令行參數的邏輯從 main 提取到了 parse_config 函數中,這有助於我們看清值 queryfilename 是相互關聯的並應該在代碼中表現這種關係。接著我們增加了 Config 結構體來描述 queryfilename 的相關性,並能夠從 parse_config 函數中將這些值的名稱作為結構體欄位名稱返回。

所以現在 parse_config 函數的目的是創建一個 Config 實例,我們可以將 parse_config 從一個普通函數變為一個叫做 new 的與結構體關聯的函數。做出這個改變使得代碼更符合習慣:可以像標準庫中的 String 調用 String::new 來創建一個該類型的實例那樣,將 parse_config 變為一個與 Config 關聯的 new 函數。範例 12-7 展示了需要做出的修改:

檔案名: src/main.rs

use std::env;

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

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

    // --snip--
}

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

// --snip--

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

        Config { query, filename }
    }
}

範例 12-7:將 parse_config 變為 Config::new

這裡將 main 中調用 parse_config 的地方更新為調用 Config::new。我們將 parse_config 的名字改為 new 並將其移動到 impl 塊中,這使得 new 函數與 Config 相關聯。再次嘗試編譯並確保它可以工作。

修復錯誤處理

現在我們開始修復錯誤處理。回憶一下之前提到過如果 args vector 包含少於 3 個項並嘗試訪問 vector 中索引 1 或索引 2 的值會造成程序 panic。嘗試不帶任何參數運行程序;這將看起來像這樣:

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

index out of bounds: the len is 1 but the index is 1 是一個針對程式設計師的錯誤訊息,然而這並不能真正幫助終端用戶理解發生了什麼和他們應該做什麼。現在就讓我們修復它吧。

改善錯誤訊息

在範例 12-8 中,在 new 函數中增加了一個檢查在訪問索引 12 之前檢查 slice 是否足夠長。如果 slice 不夠長,我們使用一個更好的錯誤訊息 panic 而不是 index out of bounds 訊息:

檔案名: src/main.rs

// --snip--
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    // --snip--

範例 12-8:增加一個參數數量檢查

這類似於 範例 9-10 中的 Guess::new 函數,那裡如果 value 參數超出了有效值的範圍就調用 panic!。不同於檢查值的範圍,這裡檢查 args 的長度至少是 3,而函數的剩餘部分則可以在假設這個條件成立的基礎上運行。如果 args 少於 3 個項,則這個條件將為真,並調用 panic! 立即終止程式。

有了 new 中這幾行額外的代碼,再次不帶任何參數運行程序並看看現在錯誤看起來像什麼:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
note: Run with `RUST_BACKTRACE=1` for a backtrace.

這個輸出就好多了,現在有了一個合理的錯誤訊息。然而,還是有一堆額外的訊息我們不希望提供給用戶。所以在這裡使用範例 9-9 中的技術可能不是最好的;正如 第九章 所講到的一樣,panic! 的調用更趨向於程序上的問題而不是使用上的問題。相反我們可以使用第九章學習的另一個技術 —— 返回一個可以表明成功或錯誤的 Result

new 中返回 Result 而不是調用 panic!

我們可以選擇返回一個 Result 值,它在成功時會包含一個 Config 的實例,而在錯誤時會描述問題。當 Config::newmain 交流時,可以使用 Result 類型來表明這裡存在問題。接著修改 mainErr 成員轉換為對用戶更友好的錯誤,而不是 panic! 調用產生的關於 thread 'main'RUST_BACKTRACE 的文本。

範例 12-9 展示了為了返回 ResultConfig::new 的返回值和函數體中所需的改變。注意這還不能編譯,直到下一個範例同時也更新了 main 之後。

檔案名: src/main.rs

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

        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 調用並處理錯誤

為了處理錯誤情況並列印一個對用戶友好的訊息,我們需要像範例 12-10 那樣更新 main 函數來處理現在 Config::new 返回的 Result。另外還需要手動實現原先由 panic!負責的工作,即以非零錯誤碼退出命令行工具的工作。非零的退出狀態是一個慣例信號,用來告訴調用程序的進程:該程序以錯誤狀態退出了。

檔案名: src/main.rs

use std::process;

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

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

    // --snip--

範例 12-10:如果新建 Config 失敗則使用錯誤碼退出

在上面的範例中,使用了一個之前沒有涉及到的方法:unwrap_or_else,它定義於標準庫的 Result<T, E> 上。使用 unwrap_or_else 可以進行一些自訂的非 panic! 的錯誤處理。當 ResultOk 時,這個方法的行為類似於 unwrap:它返回 Ok 內部封裝的值。然而,當其值是 Err 時,該方法會調用一個 閉包closure),也就是一個我們定義的作為參數傳遞給 unwrap_or_else 的匿名函數。第十三章 會更詳細的介紹閉包。現在你需要理解的是 unwrap_or_else 會將 Err 的內部值,也就是範例 12-9 中增加的 not enough arguments 靜態字串的情況,傳遞給閉包中位於兩道豎線間的參數 err。閉包中的代碼在其運行時可以使用這個 err 值。

我們新增了一個 use 行來從標準庫中導入 process。在錯誤的情況閉包中將被運行的代碼只有兩行:我們列印出了 err 值,接著調用了 std::process::exitprocess::exit 會立即停止程式並將傳遞給它的數字作為退出狀態碼。這類似於範例 12-8 中使用的基於 panic! 的錯誤處理,除了不會再得到所有的額外輸出了。讓我們試試:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

非常好!現在輸出對於用戶來說就友好多了。

main 提取邏輯

現在我們完成了配置解析的重構:讓我們轉向程序的邏輯。正如 “二進位制項目的關注分離” 部分所展開的討論,我們將提取一個叫做 run 的函數來存放目前 main 函數中不屬於設置配置或處理錯誤的所有邏輯。一旦完成這些,main 函數將簡明得足以通過觀察來驗證,而我們將能夠為所有其他邏輯編寫測試。

範例 12-11 展示了提取出來的 run 函數。目前我們只進行小的增量式的提取函數的改進。我們仍將在 src/main.rs 中定義這個函數:

檔案名: src/main.rs

fn main() {
    // --snip--

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

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

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

// --snip--

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

現在 run 函數包含了 main 中從讀取文件開始的剩餘的所有邏輯。run 函數獲取一個 Config 實例作為參數。

run 函數中返回錯誤

透過將剩餘的邏輯分離進 run 函數而不是留在 main 中,就可以像範例 12-9 中的 Config::new 那樣改進錯誤處理。不再通過 expect 允許程序 panic,run 函數將會在出錯時返回一個 Result<T, E>。這讓我們進一步以一種對用戶友好的方式統一 main 中的錯誤處理。範例 12-12 展示了 run 簽名和函數體中的改變:

檔案名: src/main.rs

use std::error::Error;

// --snip--

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

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

    Ok(())
}

範例 12-12:修改 run 函數返回 Result

這裡我們做出了三個明顯的修改。首先,將 run 函數的返回類型變為 Result<(), Box<dyn Error>>。之前這個函數返回 unit 類型 (),現在它仍然保持作為 Ok 時的返回值。

對於錯誤類型,使用了 trait 對象 Box<dyn Error>(在開頭使用了 use 語句將 std::error::Error 引入作用域)。第十七章 會涉及 trait 對象。目前只需知道 Box<dyn Error> 意味著函數會返回實現了 Error trait 的類型,不過無需指定具體將會返回的值的類型。這提供了在不同的錯誤場景可能有不同類型的錯誤返回值的靈活性。這也就是 dyn,它是 “動態的”(“dynamic”)的縮寫。

第二個改變是去掉了 expect 調用並替換為 第九章 講到的 ?。不同於遇到錯誤就 panic!? 會從函數中返回錯誤值並讓調用者來處理它。

第三個修改是現在成功時這個函數會返回一個 Ok 值。因為 run 函數簽名中聲明成功類型返回值是 (),這意味著需要將 unit 類型值包裝進 Ok 值中。Ok(()) 一開始看起來有點奇怪,不過這樣使用 () 是慣用的做法,表明調用 run 函數只是為了它的副作用;函數並沒有返回什麼有意義的值。

上述代碼能夠編譯,不過會有一個警告:

warning: unused `std::result::Result` that must be used
  --> src/main.rs:17:5
   |
17 |     run(config);
   |     ^^^^^^^^^^^^
   |
   = note: #[warn(unused_must_use)] on by default
   = note: this `Result` may be an `Err` variant, which should be handled

Rust 提示我們的代碼忽略了 Result 值,它可能表明這裡存在一個錯誤。但我們卻沒有檢查這裡是否有一個錯誤,而編譯器提醒我們這裡應該有一些錯誤處理代碼!現在就讓我們修正這個問題。

處理 mainrun 返回的錯誤

我們將檢查錯誤並使用類似範例 12-10 中 Config::new 處理錯誤的技術來處理他們,不過有一些細微的不同:

檔案名: src/main.rs

fn main() {
    // --snip--

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

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

        process::exit(1);
    }
}

我們使用 if let 來檢查 run 是否返回一個 Err 值,不同於 unwrap_or_else,並在出錯時調用 process::exit(1)run 並不返回像 Config::new 返回的 Config 實例那樣需要 unwrap 的值。因為 run 在成功時返回 (),而我們只關心檢測錯誤,所以並不需要 unwrap_or_else 來返回未封裝的值,因為它只會是 ()

不過兩個例子中 if letunwrap_or_else 的函數體都一樣:列印出錯誤並退出。

將代碼拆分到庫 crate

現在我們的 minigrep 項目看起來好多了!現在我們將要拆分 src/main.rs 並將一些程式碼放入 src/lib.rs,這樣就能測試他們並擁有一個含有更少功能的 main 函數。

讓我們將所有不是 main 函數的代碼從 src/main.rs 移動到新文件 src/lib.rs 中:

  • run 函數定義
  • 相關的 use 語句
  • Config 的定義
  • Config::new 函數定義

現在 src/lib.rs 的內容應該看起來像範例 12-13(為了簡潔省略了函數體)。注意直到下一個範例修改完 src/main.rs 之後,代碼還不能編譯:

檔案名: 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> {
        // --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
}

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

這裡使用了公有的 pub 關鍵字:在 Config、其欄位和其 new 方法,以及 run 函數上。現在我們有了一個擁有可以測試的公有 API 的庫 crate 了。

現在需要在 src/main.rs 中將移動到 src/lib.rs 的代碼引入二進位制 crate 的作用域中,如範例 12-14 所示:

Filename: src/main.rs

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

use minigrep::Config;

fn main() {
    // --snip--
    if let Err(e) = minigrep::run(config) {
        // --snip--
    }
}

範例 12-14:將 minigrep crate 引入 src/main.rs 的作用域中

我們添加了一行 use minigrep::Config,它將 Config 類型引入作用域,並使用 crate 名稱作為 run 函數的前綴。通過這些重構,所有功能應該能夠聯繫在一起並運行了。運行 cargo run 來確保一切都正確的銜接在一起。

哇哦!我們做了大量的工作,不過我們為將來的成功打下了基礎。現在處理錯誤將更容易,同時代碼也更加模組化。從現在開始幾乎所有的工作都將在 src/lib.rs 中進行。

讓我們利用這些新創建的模組的優勢來進行一些在舊代碼中難以展開的工作,這些工作在新代碼中非常容易實現,那就是:編寫測試!

採用測試驅動開發完善庫的功能

ch12-04-testing-the-librarys-functionality.md
commit 0ca4b88f75f8579de87adc2ad36d340709f5ccad

現在我們將邏輯提取到了 src/lib.rs 並將所有的參數解析和錯誤處理留在了 src/main.rs 中,為代碼的核心功能編寫測試將更加容易。我們可以直接使用多種參數調用函數並檢查返回值而無需從命令行運行二進位制文件了。如果你願意的話,請自行為 Config::newrun 函數的功能編寫一些測試。

在這一部分,我們將遵循測試驅動開發(Test Driven Development, TDD)的模式來逐步增加 minigrep 的搜索邏輯。這是一個軟體開發技術,它遵循如下步驟:

  1. 編寫一個失敗的測試,並運行它以確保它失敗的原因是你所期望的。
  2. 編寫或修改足夠的代碼來使新的測試通過。
  3. 重構剛剛增加或修改的代碼,並確保測試仍然能通過。
  4. 從步驟 1 開始重複!

這只是眾多編寫軟體的方法之一,不過 TDD 有助於驅動代碼的設計。在編寫能使測試通過的代碼之前編寫測試有助於在開發過程中保持高測試覆蓋率。

我們將測試驅動實現實際在文件內容中搜尋查詢字串並返回匹配的行範例的功能。我們將在一個叫做 search 的函數中增加這些功能。

編寫失敗測試

去掉 src/lib.rssrc/main.rs 中用於檢查程序行為的 println! 語句,因為不再真正需要他們了。接著我們會像 第十一章 那樣增加一個 test 模組和一個測試函數。測試函數指定了 search 函數期望擁有的行為:它會獲取一個需要查詢的字串和用來查詢的文本,並只會返回包含請求的文本行。範例 12-15 展示了這個測試,它還不能編譯:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
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)
        );
    }
}
}

範例 12-15:創建一個我們期望的 search 函數的失敗測試

這裡選擇使用 "duct" 作為這個測試中需要搜索的字串。用來搜尋的文本有三行,其中只有一行包含 "duct"。我們斷言 search 函數的返回值只包含期望的那一行。

我們還不能運行這個測試並看到它失敗,因為它甚至都還不能編譯:search 函數還不存在呢!我們將增加足夠的代碼來使其能夠編譯:一個總是會返回空 vector 的 search 函數定義,如範例 12-16 所示。然後這個測試應該能夠編譯並因為空 vector 並不匹配一個包含一行 "safe, fast, productive." 的 vector 而失敗。

檔案名: src/lib.rs


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

範例 12-16:剛好足夠使測試通過編譯的 search 函數定義

注意需要在 search 的簽名中定義一個顯式生命週期 'a 並用於 contents 參數和返回值。回憶一下 第十章 中講到生命週期參數指定哪個參數的生命週期與返回值的生命週期相關聯。在這個例子中,我們表明返回的 vector 中應該包含引用參數 contents(而不是參數query) slice 的字串 slice。

換句話說,我們告訴 Rust 函數 search 返回的數據將與 search 函數中的參數 contents 的數據存在的一樣久。這是非常重要的!為了使這個引用有效那麼 slice 引用的數據也需要保持有效;如果編譯器認為我們是在創建 query 而不是 contents 的字串 slice,那麼安全檢查將是不正確的。

如果嘗試不用生命週期編譯的話,我們將得到如下錯誤:

error[E0106]: missing lifetime specifier
 --> src/lib.rs:5:51
  |
5 | 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`

Rust 不可能知道我們需要的是哪一個參數,所以需要告訴它。因為參數 contents 包含了所有的文本而且我們希望返回匹配的那部分文本,所以我們知道 contents 是應該要使用生命週期語法來與返回值相關聯的參數。

其他語言中並不需要你在函數簽名中將參數與返回值相關聯。所以這麼做可能仍然感覺有些陌生,隨著時間的推移這將會變得越來越容易。你可能想要將這個例子與第十章中 “生命週期與引用有效性” 部分做對比。

現在運行測試:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
--warnings--
    Finished dev [unoptimized + debuginfo] target(s) in 0.43 secs
     Running target/debug/deps/minigrep-abcabcabc

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
        thread 'tests::one_result' panicked at 'assertion failed: `(left ==
right)`
left: `["safe, fast, productive."]`,
right: `[]`)', src/lib.rs:48:8
note: Run with `RUST_BACKTRACE=1` for 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'

好的,測試失敗了,這正是我們所期望的。修改代碼來讓測試通過吧!

編寫使測試通過的代碼

目前測試之所以會失敗是因為我們總是返回一個空的 vector。為了修復並實現 search,我們的程序需要遵循如下步驟:

  • 遍歷內容的每一行文本。
  • 查看這一行是否包含要搜索的字串。
  • 如果有,將這一行加入列表返回值中。
  • 如果沒有,什麼也不做。
  • 返回匹配到的結果列表

讓我們一步一步的來,從遍歷每行開始。

使用 lines 方法遍歷每一行

Rust 有一個有助於一行一行遍歷字串的方法,出於方便它被命名為 lines,它如範例 12-17 這樣工作。注意這還不能編譯:

檔案名: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

範例 12-17:遍歷 contents 的每一行

lines 方法返回一個疊代器。第十三章 會深入了解疊代器,不過我們已經在 範例 3-5 中見過使用疊代器的方法了,在那裡使用了一個 for 循環和疊代器在一個集合的每一項上運行了一些程式碼。

用查詢字串搜索每一行

接下來將會增加檢查當前行是否包含查詢字串的功能。幸運的是,字串類型為此也有一個叫做 contains 的實用方法!如範例 12-18 所示在 search 函數中加入 contains 方法調用。注意這仍然不能編譯:

檔案名: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

範例 12-18:增加檢查文本行是否包含 query 中字串的功能

存儲匹配的行

我們還需要一個方法來存儲包含查詢字串的行。為此可以在 for 循環之前創建一個可變的 vector 並調用 push 方法在 vector 中存放一個 line。在 for 循環之後,返回這個 vector,如範例 12-19 所示:

檔案名: src/lib.rs

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
}

範例 12-19:儲存匹配的行以便可以返回他們

現在 search 函數應該返回只包含 query 的那些行,而測試應該會通過。讓我們運行測試:

$ cargo test
--snip--
running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

測試通過了,它可以工作了!

現在正是可以考慮重構的時機,在保證測試通過,保持功能不變的前提下重構 search 函數。search 函數中的代碼並不壞,不過並沒有利用疊代器的一些實用功能。第十三章將回到這個例子並深入探索疊代器並看看如何改進代碼。

run 函數中使用 search 函數

現在 search 函數是可以工作並測試通過了的,我們需要實際在 run 函數中調用 search。需要將 config.query 值和 run 從文件中讀取的 contents 傳遞給 search 函數。接著 run 會列印出 search 返回的每一行:

檔案名: src/lib.rs

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(())
}

這裡仍然使用了 for 循環獲取了 search 返回的每一行並列印出來。

現在整個程序應該可以工作了!讓我們試一試,首先使用一個只會在艾米莉·狄金森的詩中返回一行的單詞 “frog”:

$ cargo run frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38 secs
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

好的!現在試試一個會匹配多行的單詞,比如 “body”:

$ cargo run body poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     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
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep monomorphization poem.txt`

非常好!我們創建了一個屬於自己的迷你版經典工具,並學習了很多如何組織程序的知識。我們還學習了一些文件輸入輸出、生命週期、測試和命令行解析的內容。

為了使這個項目更豐滿,我們將簡要的展示如何處理環境變數和列印到標準錯誤,這兩者在編寫命令行程序時都很有用。

處理環境變數

ch12-05-working-with-environment-variables.md
commit f617d58c1a88dd2912739a041fd4725d127bf9fb

我們將增加一個額外的功能來改進 minigrep:用戶可以透過設置環境變數來設置搜索是否是大小寫敏感的 。當然,我們也可以將其設計為一個命令行參數並要求用戶每次需要時都加上它,不過在這裡我們將使用環境變數。這允許用戶設置環境變數一次之後在整個終端會話中所有的搜索都將是大小寫不敏感的。

編寫一個大小寫不敏感 search 函數的失敗測試

我們希望增加一個新函數 search_case_insensitive,並將會在設置了環境變數時調用它。這裡將繼續遵循 TDD 過程,其第一步是再次編寫一個失敗測試。我們將為新的大小寫不敏感搜索函數新增一個測試函數,並將老的測試函數從 one_result 改名為 case_sensitive 來更清楚的表明這兩個測試的區別,如範例 12-20 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
#[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)
        );
    }
}
}

範例 12-20:為準備添加的大小寫不敏感函數新增失敗測試

注意我們也改變了老測試中 contents 的值。還新增了一個含有文本 "Duct tape." 的行,它有一個大寫的 D,這在大小寫敏感搜索時不應該匹配 "duct"。我們修改這個測試以確保不會意外破壞已經實現的大小寫敏感搜索功能;這個測試現在應該能通過並在處理大小寫不敏感搜索時應該能一直通過。

大小寫 不敏感 搜索的新測試使用 "rUsT" 作為其查詢字串。在我們將要增加的 search_case_insensitive 函數中,"rUsT" 查詢應該包含帶有一個大寫 R 的 "Rust:" 還有 "Trust me." 這兩行,即便他們與查詢的大小寫都不同。這個測試現在不能編譯,因為還沒有定義 search_case_insensitive 函數。請隨意增加一個總是返回空 vector 的骨架實現,正如範例 12-16 中 search 函數為了使測試通過編譯並失敗時所做的那樣。

實現 search_case_insensitive 函數

search_case_insensitive 函數,如範例 12-21 所示,將與 search 函數基本相同。唯一的區別是它會將 query 變數和每一 line 都變為小寫,這樣不管輸入參數是大寫還是小寫,在檢查該行是否包含查詢字串時都會是小寫。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
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
}
}

範例 12-21:定義 search_case_insensitive 函數,它在比較查詢和每一行之前將他們都轉換為小寫

首先我們將 query 字串轉換為小寫,並將其覆蓋到同名的變數中。對查詢字串調用 to_lowercase 是必需的,這樣不管用戶的查詢是 "rust""RUST""Rust" 或者 "rUsT",我們都將其當作 "rust" 處理並對大小寫不敏感。

注意 query 現在是一個 String 而不是字串 slice,因為調用 to_lowercase 是在創建新數據,而不是引用現有數據。如果查詢字串是 "rUsT",這個字串 slice 並不包含可供我們使用的小寫的 ut,所以必需分配一個包含 "rust" 的新 String。現在當我們將 query 作為一個參數傳遞給 contains 方法時,需要增加一個 & 因為 contains 的簽名被定義為獲取一個字串 slice。

接下來在檢查每個 line 是否包含 search 之前增加了一個 to_lowercase 調用將他們都變為小寫。現在我們將 linequery 都轉換成了小寫,這樣就可以不管查詢的大小寫進行匹配了。

讓我們看看這個實現能否通過測試:

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

好的!現在,讓我們在 run 函數中實際調用新 search_case_insensitive 函數。首先,我們將在 Config 結構體中增加一個配置項來切換大小寫敏感和大小寫不敏感搜索。增加這些欄位會導致編譯錯誤,因為我們還沒有在任何地方初始化這些欄位:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}
}

這裡增加了 case_sensitive 字元來存放一個布爾值。接著我們需要 run 函數檢查 case_sensitive 欄位的值並使用它來決定是否調用 search 函數或 search_case_insensitive 函數,如範例 12-22 所示。注意這還不能編譯:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::error::Error;
use std::fs::{self, File};
use std::io::prelude::*;

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

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

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

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(())
}
}

範例 12-22:根據 config.case_sensitive 的值調用 searchsearch_case_insensitive

最後需要實際檢查環境變數。處理環境變數的函數位於標準庫的 env 模組中,所以我們需要在 src/lib.rs 的開頭增加一個 use std::env; 行將這個模組引入作用域中。接著在 Config::new 中使用 env 模組的 var 方法來檢查一個叫做 CASE_INSENSITIVE 的環境變數,如範例 12-23 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::env;
struct Config {
    query: String,
    filename: String,
    case_sensitive: bool,
}

// --snip--

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

        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 })
    }
}
}

範例 12-23:檢查叫做 CASE_INSENSITIVE 的環境變數

這裡創建了一個新變數 case_sensitive。為了設置它的值,需要調用 env::var 函數並傳遞我們需要尋找的環境變數名稱,CASE_INSENSITIVEenv::var 返回一個 Result,它在環境變數被設置時返回包含其值的 Ok 成員,並在環境變數未被設置時返回 Err 成員。

我們使用 Resultis_err 方法來檢查其是否是一個 error(也就是環境變數未被設置的情況),這也就意味著我們 需要 進行一個大小寫敏感搜索。如果CASE_INSENSITIVE 環境變數被設置為任何值,is_err 會返回 false 並將進行大小寫不敏感搜索。我們並不關心環境變數所設置的 ,只關心它是否被設置了,所以檢查 is_err 而不是 unwrapexpect 或任何我們已經見過的 Result 的方法。

我們將變數 case_sensitive 的值傳遞給 Config 實例,這樣 run 函數可以讀取其值並決定是否調用 search 或者範例 12-22 中實現的 search_case_insensitive

讓我們試一試吧!首先不設置環境變數並使用查詢 to 運行程序,這應該會匹配任何全小寫的單詞 “to” 的行:

$ cargo run to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

看起來程序仍然能夠工作!現在將 CASE_INSENSITIVE 設置為 1 並仍使用相同的查詢 to

如果你使用 PowerShell,則需要用兩個命令來設置環境變數並運行程序:

$ $env:CASE_INSENSITIVE=1
$ cargo run to poem.txt

這回應該得到包含可能有大寫字母的 “to” 的行:

$ CASE_INSENSITIVE=1 cargo run to poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     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 模組還包含了更多處理環境變數的實用功能;請查看官方文件來了解其可用的功能。

將錯誤訊息輸出到標準錯誤而不是標準輸出

ch12-06-writing-to-stderr-instead-of-stdout.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

目前為止,我們將所有的輸出都 println! 到了終端。大部分終端都提供了兩種輸出:標準輸出standard outputstdout)對應一般訊息,標準錯誤standard errorstderr)則用於錯誤訊息。這種區別允許用戶選擇將程序正常輸出定向到一個文件中並仍將錯誤訊息列印到螢幕上。

但是 println! 函數只能夠列印到標準輸出,所以我們必需使用其他方法來列印到標準錯誤。

檢查錯誤應該寫入何處

首先,讓我們觀察一下目前 minigrep 列印的所有內容是如何被寫入標準輸出的,包括那些應該被寫入標準錯誤的錯誤訊息。可以透過將標準輸出流重定向到一個文件同時有意產生一個錯誤來做到這一點。我們沒有重定向標準錯誤流,所以任何發送到標準錯誤的內容將會繼續顯示在螢幕上。

命令行程序被期望將錯誤訊息發送到標準錯誤流,這樣即便選擇將標準輸出流重定向到文件中時仍然能看到錯誤訊息。目前我們的程序並不符合期望;相反我們將看到它將錯誤訊息輸出保存到了文件中。

我們通過 > 和檔案名 output.txt 來運行程序,我們期望重定向標準輸出流到該文件中。在這裡,我們沒有傳遞任何參數,所以會產生一個錯誤:

$ cargo run > output.txt

> 語法告訴 shell 將標準輸出的內容寫入到 output.txt 文件中而不是螢幕上。我們並沒有看到期望的錯誤訊息列印到螢幕上,所以這意味著它一定被寫入了文件中。如下是 output.txt 所包含的:

Problem parsing arguments: not enough arguments

是的,錯誤訊息被列印到了標準輸出中。像這樣的錯誤訊息被列印到標準錯誤中將會有用得多,將使得只有成功運行所產生的輸出才會寫入檔案。我們接下來就修改。

將錯誤列印到標準錯誤

讓我們如範例 12-24 所示的代碼改變錯誤訊息是如何被列印的。得益於本章早些時候的重構,所有列印錯誤訊息的代碼都位於 main 一個函數中。標準庫提供了 eprintln! 宏來列印到標準錯誤流,所以將兩個調用 println! 列印錯誤訊息的位置替換為 eprintln!

檔案名: src/main.rs

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

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

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

        process::exit(1);
    }
}

範例 12-24:使用 eprintln! 將錯誤訊息寫入標準錯誤而不是標準輸出

println! 改為 eprintln! 之後,讓我們再次嘗試用同樣的方式運行程序,不使用任何參數並通過 > 重定向標準輸出:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

現在我們看到了螢幕上的錯誤訊息,同時 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 中的函數式語言功能:疊代器與閉包

ch13-00-functional-features.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

Rust 的設計靈感來源於很多現存的語言和技術。其中一個顯著的影響就是 函數式編程functional programming)。函數式編程風格通常包含將函數作為參數值或其他函數的返回值、將函數賦值給變數以供之後執行等等。

本章我們不會討論函數式編程是或不是什麼的問題,而是展示 Rust 的一些在功能上與其他被認為是函數式語言類似的特性。

更具體的,我們將要涉及:

  • 閉包Closures),一個可以儲存在變數裡的類似函數的結構
  • 疊代器Iterators),一種處理元素序列的方式
  • 如何使用這些功能來改進第十二章的 I/O 項目。
  • 這兩個功能的性能。(劇透警告: 他們的速度超乎你的想像!)

還有其它受函數式風格影響的 Rust 功能,比如模式匹配和枚舉,這些已經在其他章節中講到過了。掌握閉包和疊代器則是編寫符合語言風格的高性能 Rust 代碼的重要一環,所以我們將專門用一整章來講解他們。

閉包:可以捕獲環境的匿名函數

ch13-01-closures.md
commit 26565efc3f62d9dacb7c2c6d0f5974360e459493

Rust 的 閉包closures)是可以保存進變數或作為參數傳遞給其他函數的匿名函數。可以在一個地方創建閉包,然後在不同的上下文中執行閉包運算。不同於函數,閉包允許捕獲調用者作用域中的值。我們將展示閉包的這些功能如何復用代碼和自訂行為。

使用閉包創建行為的抽象

讓我們來看一個存儲稍後要執行的閉包的範例。其間我們會討論閉包的語法、類型推斷和 trait。

考慮一下這個假定的場景:我們在一個通過 app 生成自訂健身計劃的初創企業工作。其後端使用 Rust 編寫,而生成健身計劃的算法需要考慮很多不同的因素,比如用戶的年齡、身體質量指數(Body Mass Index)、用戶喜好、最近的健身活動和用戶指定的強度係數。本例中實際的算法並不重要,重要的是這個計算只花費幾秒鐘。我們只希望在需要時調用算法,並且只希望調用一次,這樣就不會讓用戶等得太久。

這裡將透過調用 simulated_expensive_calculation 函數來模擬調用假定的算法,如範例 13-1 所示,它會列印出 calculating slowly...,等待兩秒,並接著返回傳遞給它的數字:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}
}

範例 13-1:一個用來代替假定計算的函數,它大約會執行兩秒鐘

接下來,main 函數中將會包含本例的健身 app 中的重要部分。這代表當用戶請求健身計劃時 app 會調用的代碼。因為與 app 前端的交互與閉包的使用並不相關,所以我們將寫死代表程序輸入的值並列印輸出。

所需的輸入有這些:

  • 一個來自用戶的 intensity 數字,請求健身計劃時指定,它代表用戶喜好低強度還是高強度健身。
  • 一個隨機數,其會在健身計劃中生成變化。

程序的輸出將會是建議的鍛鍊計劃。範例 13-2 展示了我們將要使用的 main 函數:

檔案名: src/main.rs

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}
fn generate_workout(intensity: u32, random_number: u32) {}

範例 13-2:main 函數包含了用於 generate_workout 函數的模擬用戶輸入和模擬隨機數輸入

出於簡單考慮這裡寫死了 simulated_user_specified_value 變數的值為 10 和 simulated_random_number 變數的值為 7;一個實際的程序會從 app 前端獲取強度係數並使用 rand crate 來生成隨機數,正如第二章的猜猜看遊戲所做的那樣。main 函數使用模擬的輸入值調用 generate_workout 函數:

現在有了執行上下文,讓我們編寫算法。範例 13-3 中的 generate_workout 函數包含本例中我們最關心的 app 業務邏輯。本例中餘下的代碼修改都將在這個函數中進行:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(num: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
}

fn generate_workout(intensity: u32, random_number: u32) {
    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            simulated_expensive_calculation(intensity)
        );
        println!(
            "Next, do {} situps!",
            simulated_expensive_calculation(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                simulated_expensive_calculation(intensity)
            );
        }
    }
}
}

範例 13-3:程序的業務邏輯,它根據輸入並調用 simulated_expensive_calculation 函數來列印出健身計劃

範例 13-3 中的代碼有多處調用了慢計算函數 simulated_expensive_calculation 。第一個 if 塊調用了 simulated_expensive_calculation 兩次, else 中的 if 沒有調用它,而第二個 else 中的代碼調用了它一次。

generate_workout 函數的期望行為是首先檢查用戶需要低強度(由小於 25 的係數表示)鍛鍊還是高強度(25 或以上)鍛鍊。

低強度鍛鍊計劃會根據由 simulated_expensive_calculation 函數所模擬的複雜算法建議一定數量的伏地挺身和仰臥起坐。

如果用戶需要高強度鍛鍊,這裡有一些額外的邏輯:如果 app 生成的隨機數剛好是 3,app 相反會建議用戶稍做休息並補充水分。如果不是,則用戶會從複雜算法中得到數分鐘跑步的高強度鍛鍊計劃。

現在這份代碼能夠應對我們的需求了,但數據科學部門的同學告知我們將來會對調用 simulated_expensive_calculation 的方式做出一些改變。為了在要做這些改動的時候簡化更新步驟,我們將重構代碼來讓它只調用 simulated_expensive_calculation 一次。同時還希望去掉目前多餘的連續兩次函數調用,並不希望在計算過程中增加任何其他此函數的調用。也就是說,我們不希望在完全無需其結果的情況調用函數,不過仍然希望只調用函數一次。

使用函數重構

有多種方法可以重構此程序。我們首先嘗試的是將重複的 simulated_expensive_calculation 函數調用提取到一個變數中,如範例 13-4 所示:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(num: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
}

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_result =
        simulated_expensive_calculation(intensity);

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_result
        );
        println!(
            "Next, do {} situps!",
            expensive_result
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_result
            );
        }
    }
}
}

範例 13-4:將 simulated_expensive_calculation 調用提取到一個位置,並將結果儲存在變數 expensive_result

這個修改統一了 simulated_expensive_calculation 調用並解決了第一個 if 塊中不必要的兩次調用函數的問題。不幸的是,現在所有的情況下都需要調用函數並等待結果,包括那個完全不需要這一結果的內部 if 塊。

我們希望能夠在程序的一個位置指定某些程式碼,並只在程序的某處實際需要結果的時候 執行 這些程式碼。這正是閉包的用武之地!

重構使用閉包儲存代碼

不同於總是在 if 塊之前調用 simulated_expensive_calculation 函數並儲存其結果,我們可以定義一個閉包並將其儲存在變數中,如範例 13-5 所示。實際上可以選擇將整個 simulated_expensive_calculation 函數體移動到這裡引入的閉包中:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

let expensive_closure = |num| {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};
expensive_closure(5);
}

範例 13-5:定義一個閉包並儲存到變數 expensive_closure

閉包定義是 expensive_closure 賦值的 = 之後的部分。閉包的定義以一對豎線(|)開始,在豎線中指定閉包的參數;之所以選擇這個語法是因為它與 Smalltalk 和 Ruby 的閉包定義類似。這個閉包有一個參數 num;如果有多於一個參數,可以使用逗號分隔,比如 |param1, param2|

參數之後是存放閉包體的大括號 —— 如果閉包體只有一行則大括號是可以省略的。大括號之後閉包的結尾,需要用於 let 語句的分號。因為閉包體的最後一行沒有分號(正如函數體一樣),所以閉包體(num)最後一行的返回值作為調用閉包時的返回值 。

注意這個 let 語句意味著 expensive_closure 包含一個匿名函數的 定義,不是調用匿名函數的 返回值。回憶一下使用閉包的原因是我們需要在一個位置定義代碼,儲存代碼,並在之後的位置實際調用它;期望調用的代碼現在儲存在 expensive_closure 中。

定義了閉包之後,可以改變 if 塊中的代碼來調用閉包以執行程式碼並獲取結果值。調用閉包類似於調用函數;指定存放閉包定義的變數名並後跟包含期望使用的參數的括號,如範例 13-6 所示:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_closure(intensity)
        );
        println!(
            "Next, do {} situps!",
            expensive_closure(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}
}

範例 13-6:調用定義的 expensive_closure

現在耗時的計算只在一個地方被調用,並只會在需要結果的時候執行改代碼。

然而,我們又重新引入了範例 13-3 中的問題:仍然在第一個 if 塊中調用了閉包兩次,這調用了慢計算代碼兩次而使得用戶需要多等待一倍的時間。可以通過在 if 塊中創建一個本地變數存放閉包調用的結果來解決這個問題,不過閉包可以提供另外一種解決方案。我們稍後會討論這個方案,不過目前讓我們首先討論一下為何閉包定義中和所涉及的 trait 中沒有類型註解。

閉包類型推斷和註解

閉包不要求像 fn 函數那樣在參數和返回值上註明類型。函數中需要類型註解是因為他們是暴露給用戶的顯式介面的一部分。嚴格的定義這些介面對於保證所有人都認同函數使用和返回值的類型來說是很重要的。但是閉包並不用於這樣暴露在外的介面:他們儲存在變數中並被使用,不用命名他們或暴露給庫的用戶調用。

閉包通常很短,並只關聯於小範圍的上下文而非任意情境。在這些有限制的上下文中,編譯器能可靠的推斷參數和返回值的類型,類似於它是如何能夠推斷大部分變數的類型一樣。

強制在這些小的匿名函數中註明類型是很惱人的,並且與編譯器已知的訊息存在大量的重複。

類似於變數,如果相比嚴格的必要性你更希望增加明確性並變得更囉嗦,可以選擇增加類型註解;為範例 13-5 中定義的閉包標註類型將看起來像範例 13-7 中的定義:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;

let expensive_closure = |num: u32| -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};
}

範例 13-7:為閉包的參數和返回值增加可選的類型註解

有了類型註解閉包的語法就更類似函數了。如下是一個對其參數加一的函數的定義與擁有相同行為閉包語法的縱向對比。這裡增加了一些空格來對齊相應部分。這展示了閉包語法如何類似於函數語法,除了使用豎線而不是括號以及幾個可選的語法之外:

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  ;

第一行展示了一個函數定義,而第二行展示了一個完整標註的閉包定義。第三行閉包定義中省略了類型註解,而第四行去掉了可選的大括號,因為閉包體只有一行。這些都是有效的閉包定義,並在調用時產生相同的行為。

閉包定義會為每個參數和返回值推斷一個具體類型。例如,範例 13-8 中展示了僅僅將參數作為返回值的簡短的閉包定義。除了作為範例的目的這個閉包並不是很實用。注意其定義並沒有增加任何類型註解:如果嘗試調用閉包兩次,第一次使用 String 類型作為參數而第二次使用 u32,則會得到一個錯誤:

檔案名: src/main.rs

let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

範例 13-8:嘗試調用一個被推斷為兩個不同類型的閉包

編譯器給出如下錯誤:

error[E0308]: mismatched types
 --> src/main.rs
  |
  | let n = example_closure(5);
  |                         ^ expected struct `std::string::String`, found
  integer
  |
  = note: expected type `std::string::String`
             found type `{integer}`

第一次使用 String 值調用 example_closure 時,編譯器推斷 x 和此閉包返回值的類型為 String。接著這些類型被鎖定進閉包 example_closure 中,如果嘗試對同一閉包使用不同類型則會得到類型錯誤。

使用帶有泛型和 Fn trait 的閉包

回到我們的健身計劃生成 app ,在範例 13-6 中的代碼仍然把慢計算閉包調用了比所需更多的次數。解決這個問題的一個方法是在全部代碼中的每一個需要多個慢計算閉包結果的地方,可以將結果保存進變數以供復用,這樣就可以使用變數而不是再次調用閉包。但是這樣就會有很多重複的保存結果變數的地方。

幸運的是,還有另一個可用的方案。可以創建一個存放閉包和調用閉包結果的結構體。該結構體只會在需要結果時執行閉包,並會快取結果值,這樣餘下的代碼就不必再負責保存結果並可以復用該值。你可能見過這種模式被稱 memoizationlazy evaluation (惰性求值)

為了讓結構體存放閉包,我們需要指定閉包的類型,因為結構體定義需要知道其每一個欄位的類型。每一個閉包實例有其自己獨有的匿名類型:也就是說,即便兩個閉包有著相同的簽名,他們的類型仍然可以被認為是不同。為了定義使用閉包的結構體、枚舉或函數參數,需要像第十章討論的那樣使用泛型和 trait bound。

Fn 系列 trait 由標準庫提供。所有的閉包都實現了 trait FnFnMutFnOnce 中的一個。在 “閉包會捕獲其環境” 部分我們會討論這些 trait 的區別;在這個例子中可以使用 Fn trait。

為了滿足 Fn trait bound 我們增加了代表閉包所必須的參數和返回值類型的類型。在這個例子中,閉包有一個 u32 的參數並返回一個 u32,這樣所指定的 trait bound 就是 Fn(u32) -> u32

範例 13-9 展示了存放了閉包和一個 Option 結果值的 Cacher 結構體的定義:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
struct Cacher<T>
    where T: Fn(u32) -> u32
{
    calculation: T,
    value: Option<u32>,
}
}

範例 13-9:定義一個 Cacher 結構體來在 calculation 中存放閉包並在 value 中存放 Option 值

結構體 Cacher 有一個泛型 T 的欄位 calculationT 的 trait bound 指定了 T 是一個使用 Fn 的閉包。任何我們希望儲存到 Cacher 實例的 calculation 欄位的閉包必須有一個 u32 參數(由 Fn 之後的括號的內容指定)並必須返回一個 u32(由 -> 之後的內容)。

注意:函數也都實現了這三個 Fn trait。如果不需要捕獲環境中的值,則可以使用實現了 Fn trait 的函數而不是閉包。

欄位 valueOption<u32> 類型的。在執行閉包之前,value 將是 None。如果使用 Cacher 的代碼請求閉包的結果,這時會執行閉包並將結果儲存在 value 欄位的 Some 成員中。接著如果代碼再次請求閉包的結果,這時不再執行閉包,而是會返回存放在 Some 成員中的結果。

剛才討論的有關 value 欄位邏輯定義於範例 13-10:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
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
            },
        }
    }
}
}

範例 13-10:Cacher 的快取邏輯

Cacher 結構體的欄位是私有的,因為我們希望 Cacher 管理這些值而不是任由調用代碼潛在的直接改變他們。

Cacher::new 函數獲取一個泛型參數 T,它定義於 impl 塊上下文中並與 Cacher 結構體有著相同的 trait bound。Cacher::new 返回一個在 calculation 欄位中存放了指定閉包和在 value 欄位中存放了 None 值的 Cacher 實例,因為我們還未執行閉包。

當調用代碼需要閉包的執行結果時,不同於直接調用閉包,它會調用 value 方法。這個方法會檢查 self.value 是否已經有了一個 Some 的結果值;如果有,它返回 Some 中的值並不會再次執行閉包。

如果 self.valueNone,則會調用 self.calculation 中儲存的閉包,將結果保存到 self.value 以便將來使用,並同時返回結果值。

範例 13-11 展示了如何在範例 13-6 的 generate_workout 函數中利用 Cacher 結構體:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
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!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    });

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_result.value(intensity)
        );
        println!(
            "Next, do {} situps!",
            expensive_result.value(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_result.value(intensity)
            );
        }
    }
}
}

範例 13-11:在 generate_workout 函數中利用 Cacher 結構體來抽象出快取邏輯

不同於直接將閉包保存進一個變數,我們保存一個新的 Cacher 實例來存放閉包。接著,在每一個需要結果的地方,調用 Cacher 實例的 value 方法。可以調用 value 方法任意多次,或者一次也不調用,而慢計算最多只會運行一次。

嘗試使用範例 13-2 中的 main 函數來運行這段程序,並改變 simulated_user_specified_valuesimulated_random_number 變數中的值來驗證在所有情況下在多個 ifelse 塊中,閉包列印的 calculating slowly... 只會在需要時出現並只會出現一次。Cacher 負責確保不會調用超過所需的慢計算所需的邏輯,這樣 generate_workout 就可以專注業務邏輯了。

Cacher 實現的限制

值快取是一種更加廣泛的實用行為,我們可能希望在代碼中的其他閉包中也使用他們。然而,目前 Cacher 的實現存在兩個小問題,這使得在不同上下文中復用變得很困難。

第一個問題是 Cacher 實例假設對於 value 方法的任何 arg 參數值總是會返回相同的值。也就是說,這個 Cacher 的測試會失敗:

#[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 實例。使用為 1 的 arg 和為 2 的 arg 調用 Cacher 實例的 value 方法,同時我們期望使用為 2 的 arg 調用 value 會返回 2。

使用範例 13-9 和範例 13-10 的 Cacher 實現運行測試,它會在 assert_eq! 失敗並顯示如下訊息:

thread 'call_with_different_values' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/main.rs

這裡的問題是第一次使用 1 調用 c.valueCacher 實例將 Some(1) 保存進 self.value。在這之後,無論傳遞什麼值調用 value,它總是會返回 1。

嘗試修改 Cacher 存放一個哈希 map 而不是單獨一個值。哈希 map 的 key 將是傳遞進來的 arg 值,而 value 則是對應 key 調用閉包的結果值。相比之前檢查 self.value 直接是 Some 還是 None 值,現在 value 函數會在哈希 map 中尋找 arg,如果找到的話就返回其對應的值。如果不存在,Cacher 會調用閉包並將結果值保存在哈希 map 對應 arg 值的位置。

當前 Cacher 實現的第二個問題是它的應用被限制為只接受獲取一個 u32 值並返回一個 u32 值的閉包。比如說,我們可能需要能夠快取一個獲取字串 slice 並返回 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));
}

這會得到一個錯誤:

error[E0434]: can't capture dynamic environment in a fn item; use the || { ...
} closure form instead
 --> src/main.rs
  |
4 |     fn equal_to_x(z: i32) -> bool { z == x }
  |                                          ^

編譯器甚至會提示我們這只能用於閉包!

當閉包從環境中捕獲一個值,閉包會在閉包體中儲存這個值以供使用。這會使用記憶體並產生額外的開銷,在更一般的場景中,當我們不需要閉包來捕獲環境時,我們不希望產生這些開銷。因為函數從未允許捕獲環境,定義和使用函數也就從不會有這些額外開銷。

閉包可以透過三種方式捕獲其環境,他們直接對應函數的三種獲取參數的方式:獲取所有權,可變借用和不可變借用。這三種捕獲值的方式被編碼為如下三個 Fn trait:

  • FnOnce 消費從周圍作用域捕獲的變數,閉包周圍的作用域被稱為其 環境environment。為了消費捕獲到的變數,閉包必須獲取其所有權並在定義閉包時將其移動進閉包。其名稱的 Once 部分代表了閉包不能多次獲取相同變數的所有權的事實,所以它只能被調用一次。
  • FnMut 獲取可變的借用值所以可以改變其環境
  • Fn 從其環境獲取不可變的借用值

當創建一個閉包時,Rust 根據其如何使用環境中變數來推斷我們希望如何引用環境。由於所有閉包都可以被調用至少一次,所以所有閉包都實現了 FnOnce 。那些並沒有移動被捕獲變數的所有權到閉包內的閉包也實現了 FnMut ,而不需要對被捕獲的變數進行可變訪問的閉包則也實現了 Fn 。 在範例 13-12 中,equal_to_x 閉包不可變的借用了 x(所以 equal_to_x 具有 Fn trait),因為閉包體只需要讀取 x 的值。

如果你希望強制閉包獲取其使用的環境值的所有權,可以在參數列表前使用 move 關鍵字。這個技巧在將閉包傳遞給新執行緒以便將數據移動到新執行緒中時最為實用。

第十六章討論並發時會展示更多 move 閉包的例子,不過現在這裡修改了範例 13-12 中的代碼(作為示範),在閉包定義中增加 move 關鍵字並使用 vector 代替整型,因為整型可以被拷貝而不是移動;注意這些程式碼還不能編譯:

檔案名: src/main.rs

fn main() {
    let x = vec![1, 2, 3];

    let equal_to_x = move |z| z == x;

    println!("can't use x here: {:?}", x);

    let y = vec![1, 2, 3];

    assert!(equal_to_x(y));
}

這個例子並不能編譯,會產生以下錯誤:

error[E0382]: use of moved value: `x`
 --> src/main.rs:6:40
  |
4 |     let equal_to_x = move |z| z == x;
  |                      -------- value moved (into closure) here
5 |
6 |     println!("can't use x here: {:?}", x);
  |                                        ^ value used here after move
  |
  = note: move occurs because `x` has type `std::vec::Vec<i32>`, which does not
  implement the `Copy` trait

x 被移動進了閉包,因為閉包使用 move 關鍵字定義。接著閉包獲取了 x 的所有權,同時 main 就不再允許在 println! 語句中使用 x 了。去掉 println! 即可修復問題。

大部分需要指定一個 Fn 系列 trait bound 的時候,可以從 Fn 開始,而編譯器會根據閉包體中的情況告訴你是否需要 FnMutFnOnce

為了展示閉包作為函數參數時捕獲其環境的作用,讓我們繼續下一個主題:疊代器。

使用疊代器處理元素序列

ch13-02-iterators.md
commit 8edf0457ab571b375b87357e1353ae0dd2127abe

疊代器模式允許你對一個序列的項進行某些處理。疊代器iterator)負責遍歷序列中的每一項和決定序列何時結束的邏輯。當使用疊代器時,我們無需重新實現這些邏輯。

在 Rust 中,疊代器是 惰性的lazy),這意味著在調用方法使用疊代器之前它都不會有效果。例如,範例 13-13 中的代碼透過調用定義於 Vec 上的 iter 方法在一個 vector v1 上創建了一個疊代器。這段代碼本身沒有任何用處:


#![allow(unused)]
fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();
}

範例 13-13:創建一個疊代器

一旦創建疊代器之後,可以選擇用多種方式利用它。在第三章的範例 3-5 中,我們使用疊代器和 for 循環在每一個項上執行了一些程式碼,雖然直到現在為止我們一直沒有具體討論調用 iter 到底具體做了什麼。

範例 13-14 中的例子將疊代器的創建和 for 循環中的使用分開。疊代器被儲存在 v1_iter 變數中,而這時沒有進行疊代。一旦 for 循環開始使用 v1_iter,接著疊代器中的每一個元素被用於循環的一次疊代,這會列印出其每一個值:


#![allow(unused)]
fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

for val in v1_iter {
    println!("Got: {}", val);
}
}

範例 13-14:在一個 for 循環中使用疊代器

在標準庫中沒有提供疊代器的語言中,我們可能會使用一個從 0 開始的索引變數,使用這個變數索引 vector 中的值,並循環增加其值直到達到 vector 的元素數量。

疊代器為我們處理了所有這些邏輯,這減少了重複代碼並消除了潛在的混亂。另外,疊代器的實現方式提供了對多種不同的序列使用相同邏輯的靈活性,而不僅僅是像 vector 這樣可索引的數據結構.讓我們看看疊代器是如何做到這些的。

Iterator trait 和 next 方法

疊代器都實現了一個叫做 Iterator 的定義於標準庫的 trait。這個 trait 的定義看起來像這樣:


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // 此處省略了方法的默認實現
}
}

注意這裡有一下我們還未講到的新語法:type ItemSelf::Item,他們定義了 trait 的 關聯類型associated type)。第十九章會深入講解關聯類型,不過現在只需知道這段代碼表明實現 Iterator trait 要求同時定義一個 Item 類型,這個 Item 類型被用作 next 方法的返回值類型。換句話說,Item 類型將是疊代器返回元素的類型。

nextIterator 實現者被要求定義的唯一方法。next 一次返回疊代器中的一個項,封裝在 Some 中,當疊代器結束時,它返回 None

可以直接調用疊代器的 next 方法;範例 13-15 有一個測試展示了重複調用由 vector 創建的疊代器的 next 方法所得到的值:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
#[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);
}
}

範例 13-15:在疊代器上(直接)調用 next 方法

注意 v1_iter 需要是可變的:在疊代器上調用 next 方法改變了疊代器中用來記錄序列位置的狀態。換句話說,代碼 消費(consume)了,或使用了疊代器。每一個 next 調用都會從疊代器中消費一個項。使用 for 循環時無需使 v1_iter 可變因為 for 循環會獲取 v1_iter 的所有權並在後台使 v1_iter 可變。

另外需要注意到從 next 調用中得到的值是 vector 的不可變引用。iter 方法生成一個不可變引用的疊代器。如果我們需要一個獲取 v1 所有權並返回擁有所有權的疊代器,則可以調用 into_iter 而不是 iter。類似的,如果我們希望疊代可變引用,則可以調用 iter_mut 而不是 iter

消費疊代器的方法

Iterator trait 有一系列不同的由標準庫提供默認實現的方法;你可以在 Iterator trait 的標準庫 API 文件中找到所有這些方法。一些方法在其定義中調用了 next 方法,這也就是為什麼在實現 Iterator trait 時要求實現 next 方法的原因。

這些調用 next 方法的方法被稱為 消費適配器consuming adaptors),因為調用他們會消耗疊代器。一個消費適配器的例子是 sum 方法。這個方法獲取疊代器的所有權並反覆調用 next 來遍歷疊代器,因而會消費疊代器。當其遍歷每一個項時,它將每一個項加總到一個總和並在疊代完成時返回總和。範例 13-16 有一個展示 sum 方法使用的測試:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
#[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);
}
}

範例 13-16:調用 sum 方法獲取疊代器所有項的總和

調用 sum 之後不再允許使用 v1_iter 因為調用 sum 時它會獲取疊代器的所有權。

產生其他疊代器的方法

Iterator trait 中定義了另一類方法,被稱為 疊代器適配器iterator adaptors),他們允許我們將當前疊代器變為不同類型的疊代器。可以鏈式調用多個疊代器適配器。不過因為所有的疊代器都是惰性的,必須調用一個消費適配器方法以便獲取疊代器適配器調用的結果。

範例 13-17 展示了一個調用疊代器適配器方法 map 的例子,該 map 方法使用閉包來調用每個元素以生成新的疊代器。 這裡的閉包創建了一個新的疊代器,對其中 vector 中的每個元素都被加 1。不過這些程式碼會產生一個警告:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

v1.iter().map(|x| x + 1);
}

範例 13-17:調用疊代器適配器 map 來創建一個新疊代器

得到的警告是:

warning: unused `std::iter::Map` which must be used: iterator adaptors are lazy
and do nothing unless consumed
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: #[warn(unused_must_use)] on by default

範例 13-17 中的代碼實際上並沒有做任何事;所指定的閉包從未被調用過。警告提醒了我們為什麼:疊代器適配器是惰性的,而這裡我們需要消費疊代器。

為了修復這個警告並消費疊代器獲取有用的結果,我們將使用第十二章範例 12-1 結合 env::args 使用的 collect 方法。這個方法消費疊代器並將結果收集到一個數據結構中。

在範例 13-18 中,我們將遍歷由 map 調用生成的疊代器的結果收集到一個 vector 中,它將會含有原始 vector 中每個元素加 1 的結果:

檔案名: src/main.rs


#![allow(unused)]
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 方法消費新疊代器並創建一個 vector

因為 map 獲取一個閉包,可以指定任何希望在遍歷的每個元素上執行的操作。這是一個展示如何使用閉包來自訂行為同時又復用 Iterator trait 提供的疊代行為的絕佳例子。

使用閉包獲取環境

現在我們介紹了疊代器,讓我們展示一個透過使用 filter 疊代器適配器和捕獲環境的閉包的常規用例。疊代器的 filter 方法獲取一個使用疊代器的每一個項並返回布爾值的閉包。如果閉包返回 true,其值將會包含在 filter 提供的新疊代器中。如果閉包返回 false,其值不會包含在結果疊代器中。

範例 13-19 展示了使用 filter 和一個捕獲環境中變數 shoe_size 的閉包,這樣閉包就可以遍歷一個 Shoe 結構體集合以便只返回指定大小的鞋子:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
#[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()
}

#[test]
fn filters_by_size() {
    let shoes = vec![
        Shoe { size: 10, style: String::from("sneaker") },
        Shoe { size: 13, style: String::from("sandal") },
        Shoe { size: 10, style: String::from("boot") },
    ];

    let in_my_size = shoes_in_my_size(shoes, 10);

    assert_eq!(
        in_my_size,
        vec![
            Shoe { size: 10, style: String::from("sneaker") },
            Shoe { size: 10, style: String::from("boot") },
        ]
    );
}
}

範例 13-19:使用 filter 方法和一個捕獲 shoe_size 的閉包

shoes_in_my_size 函數獲取一個鞋子 vector 的所有權和一個鞋子大小作為參數。它返回一個只包含指定大小鞋子的 vector。

shoes_in_my_size 函數體中調用了 into_iter 來創建一個獲取 vector 所有權的疊代器。接著調用 filter 將這個疊代器適配成一個只含有那些閉包返回 true 的元素的新疊代器。

閉包從環境中捕獲了 shoe_size 變數並使用其值與每一隻鞋的大小作比較,只保留指定大小的鞋子。最終,調用 collect 將疊代器適配器返回的值收集進一個 vector 並返回。

這個測試展示當調用 shoes_in_my_size 時,我們只會得到與指定值相同大小的鞋子。

實現 Iterator trait 來創建自訂疊代器

我們已經展示了可以通過在 vector 上調用 iterinto_iteriter_mut 來創建一個疊代器。也可以用標準庫中其他的集合類型創建疊代器,比如哈希 map。另外,可以實現 Iterator trait 來創建任何我們希望的疊代器。正如之前提到的,定義中唯一要求提供的方法就是 next 方法。一旦定義了它,就可以使用所有其他由 Iterator trait 提供的擁有默認實現的方法來創建自訂疊代器了!

作為展示,讓我們創建一個只會從 1 數到 5 的疊代器。首先,創建一個結構體來存放一些值,接著實現 Iterator trait 將這個結構體放入疊代器中並在此實現中使用其值。

範例 13-20 有一個 Counter 結構體定義和一個創建 Counter 實例的關聯函數 new

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}
}

範例 13-20:定義 Counter 結構體和一個創建 count 初值為 0 的 Counter 實例的 new 函數

Counter 結構體有一個欄位 count。這個欄位存放一個 u32 值,它會記錄處理 1 到 5 的疊代過程中的位置。count 是私有的因為我們希望 Counter 的實現來管理這個值。new 函數通過總是從為 0 的 count 欄位開始新實例來確保我們需要的行為。

接下來將為 Counter 類型實現 Iterator trait,通過定義 next 方法來指定使用疊代器時的行為,如範例 13-21 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
struct Counter {
    count: u32,
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;

        if self.count < 6 {
            Some(self.count)
        } else {
            None
        }
    }
}
}

範例 13-21:在 Counter 結構體上實現 Iterator trait

這裡將疊代器的關聯類型 Item 設置為 u32,意味著疊代器會返回 u32 值集合。再一次,這裡仍無需擔心關聯類型,第十九章會講到。

我們希望疊代器對其內部狀態加一,這也就是為何將 count 初始化為 0:我們希望疊代器首先返回 1。如果 count 值小於 6,next 會返回封裝在 Some 中的當前值,不過如果 count 大於或等於 6,疊代器會返回 None

使用 Counter 疊代器的 next 方法

一旦實現了 Iterator trait,我們就有了一個疊代器!範例 13-22 展示了一個測試用來示範使用 Counter 結構體的疊代器功能,透過直接調用 next 方法,正如範例 13-15 中從 vector 創建的疊代器那樣:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
struct Counter {
    count: u32,
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;

        if self.count < 6 {
            Some(self.count)
        } else {
            None
        }
    }
}

#[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);
}
}

範例 13-22:測試 next 方法實現的功能

這個測試在 counter 變數中新建了一個 Counter 實例並接著反覆調用 next 方法,來驗證我們實現的行為符合這個疊代器返回從 1 到 5 的值的預期。

使用自訂疊代器中其他 Iterator trait 方法

通過定義 next 方法實現 Iterator trait,我們現在就可以使用任何標準庫定義的擁有默認實現的 Iterator trait 方法了,因為他們都使用了 next 方法的功能。

例如,出於某種原因我們希望獲取 Counter 實例產生的值,將這些值與另一個 Counter 實例在省略了第一個值之後產生的值配對,將每一對值相乘,只保留那些可以被三整除的結果,然後將所有保留的結果相加,這可以如範例 13-23 中的測試這樣做:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    // 疊代器會產生 u32s
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // count 自增 1。也就是為什麼從 0 開始。
        self.count += 1;

        // 檢測是否結束結束計數。
        if self.count < 6 {
            Some(self.count)
        } else {
            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);
}
}

範例 13-23:使用自訂的 Counter 疊代器的多種方法

注意 zip 只產生四對值;理論上第五對值 (5, None) 從未被產生,因為 zip 在任一輸入疊代器返回 None 時也返回 None

所有這些方法調用都是可能的,因為我們指定了 next 方法如何工作,而標準庫則提供了其它調用 next 的方法的默認實現。

改進 I/O 項目

ch13-03-improving-our-io-project.md
commit 6555fb6c805fbfe7d0961980991f8bca6918928f

有了這些關於疊代器的新知識,我們可以使用疊代器來改進第十二章中 I/O 項目的實現來使得代碼更簡潔明瞭。讓我們看看疊代器如何能夠改進 Config::new 函數和 search 函數的實現。

使用疊代器並去掉 clone

在範例 12-6 中,我們增加了一些程式碼獲取一個 String slice 並創建一個 Config 結構體的實例,他們索引 slice 中的值並複製這些值以便 Config 結構體可以擁有這些值。在範例 13-24 中重現了第十二章結尾範例 12-23 中 Config::new 函數的實現:

檔案名: src/lib.rs

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

        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 })
    }
}

範例 13-24:重現第十二章結尾的 Config::new 函數

那時我們說過不必擔心低效的 clone 調用了,因為將來可以對他們進行改進。好吧,就是現在!

起初這裡需要 clone 的原因是參數 args 中有一個 String 元素的 slice,而 new 函數並不擁有 args。為了能夠返回 Config 實例的所有權,我們需要複製 Config 中欄位 queryfilename 的值,這樣 Config 實例就能擁有這些值。

在學習了疊代器之後,我們可以將 new 函數改為獲取一個有所有權的疊代器作為參數而不是借用 slice。我們將使用疊代器功能之前檢查 slice 長度和索引特定位置的代碼。這會明確 Config::new 的工作因為疊代器會負責訪問這些值。

一旦 Config::new 獲取了疊代器的所有權並不再使用借用的索引操作,就可以將疊代器中的 String 值移動到 Config 中,而不是調用 clone 分配新的空間。

直接使用 env::args 返回的疊代器

打開 I/O 項目的 src/main.rs 文件,它看起來應該像這樣:

檔案名: src/main.rs

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

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

    // --snip--
}

修改第十二章結尾範例 12-24 中的 main 函數的開頭為範例 13-25 中的代碼。在更新 Config::new 之前這些程式碼還不能編譯:

檔案名: src/main.rs

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

    // --snip--
}

範例 13-25:將 env::args 的返回值傳遞給 Config::new

env::args 函數返回一個疊代器!不同於將疊代器的值收集到一個 vector 中接著傳遞一個 slice 給 Config::new,現在我們直接將 env::args 返回的疊代器的所有權傳遞給 Config::new

接下來需要更新 Config::new 的定義。在 I/O 項目的 src/lib.rs 中,將 Config::new 的簽名改為如範例 13-26 所示。這仍然不能編譯因為我們還需更新函數體:

檔案名: src/lib.rs

impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        // --snip--

範例 13-26:以疊代器作為參數更新 Config::new 的簽名

env::args 函數的標準庫文件顯示,它返回的疊代器的類型為 std::env::Args。我們已經更新了 Config :: new 函數的簽名,因此參數 args 的類型為 std::env::Args 而不是 &[String]。因為我們擁有 args 的所有權,並且將透過對其進行疊代來改變 args ,所以我們可以將 mut 關鍵字添加到 args 參數的規範中以使其可變。

使用 Iterator trait 代替索引

接下來,我們將修改 Config::new 的內容。標準庫文件還提到 std::env::Args 實現了 Iterator trait,因此我們知道可以對其調用 next 方法!範例 13-27 更新了範例 12-23 中的代碼,以使用 next 方法:

檔案名: src/lib.rs

fn main() {}
use std::env;

struct Config {
    query: String,
    filename: String,
    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("Didn't get a query string"),
        };

        let filename = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file name"),
        };

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

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

範例 13-27:修改 Config::new 的函數體來使用疊代器方法

請記住 env::args 返回值的第一個值是程序的名稱。我們希望忽略它並獲取下一個值,所以首先調用 next 並不對返回值做任何操作。之後對希望放入 Config 中欄位 query 調用 next。如果 next 返回 Some,使用 match 來提取其值。如果它返回 None,則意味著沒有提供足夠的參數並通過 Err 值提早返回。對 filename 值進行同樣的操作。

使用疊代器適配器來使代碼更簡明

I/O 項目中其他可以利用疊代器的地方是 search 函數,範例 13-28 中重現了第十二章結尾範例 12-19 中此函數的定義:

檔案名: src/lib.rs

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
}

範例 13-28:範例 12-19 中 search 函數的定義

可以透過使用疊代器適配器方法來編寫更簡明的代碼。這也避免了一個可變的中間 results vector 的使用。函數式編程風格傾向於最小化可變狀態的數量來使代碼更簡潔。去掉可變狀態可能會使得將來進行並行搜索的增強變得更容易,因為我們不必管理 results vector 的並發訪問。範例 13-29 展示了該變化:

檔案名: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents.lines()
        .filter(|line| line.contains(query))
        .collect()
}

範例 13-29:在 search 函數實現中使用疊代器適配器

回憶 search 函數的目的是返回所有 contents 中包含 query 的行。類似於範例 13-19 中的 filter 例子,可以使用 filter 適配器只保留 line.contains(query) 返回 true 的那些行。接著使用 collect 將匹配行收集到另一個 vector 中。這樣就容易多了!嘗試對 search_case_insensitive 函數做出同樣的使用疊代器方法的修改吧。

接下來的邏輯問題就是在代碼中應該選擇哪種風格:是使用範例 13-28 中的原始實現還是使用範例 13-29 中使用疊代器的版本?大部分 Rust 程式設計師傾向於使用疊代器風格。開始這有點難以理解,不過一旦你對不同疊代器的工作方式有了感覺之後,疊代器可能會更容易理解。相比擺弄不同的循環並創建新 vector,(疊代器)代碼則更關注循環的目的。這抽象掉那些老生常談的代碼,這樣就更容易看清代碼所特有的概念,比如疊代器中每個元素必須面對的過濾條件。

不過這兩種實現真的完全等同嗎?直覺上的假設是更底層的循環會更快一些。讓我們聊聊性能吧。

性能對比:循環 VS 疊代器

ch13-04-performance.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

為了決定使用哪個實現,我們需要知道哪個版本的 search 函數更快一些:是直接使用 for 循環的版本還是使用疊代器的版本。

我們運行了一個性能測試,透過將阿瑟·柯南·道爾的“福爾摩斯探案集”的全部內容載入進 String 並尋找其中的單詞 “the”。如下是 for 循環版本和疊代器版本的 search 函數的性能測試結果:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

結果疊代器版本還要稍微快一點!這裡我們將不會查看性能測試的代碼,我們的目的並不是為了證明他們是完全等同的,而是得出一個怎樣比較這兩種實現方式性能的基本思路。

對於一個更全面的性能測試,將會檢查不同長度的文本、不同的搜索單詞、不同長度的單詞和所有其他的可變情況。這裡所要表達的是:疊代器,作為一個高級的抽象,被編譯成了與手寫的底層代碼大體一致性能代碼。疊代器是 Rust 的 零成本抽象zero-cost abstractions)之一,它意味著抽象並不會引入運行時開銷,它與本賈尼·史特勞斯特盧普(C++ 的設計和實現者)在 “Foundations of C++”(2012) 中所定義的 零開銷zero-overhead)如出一轍:

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

  • Bjarne Stroustrup "Foundations of C++"

從整體來說,C++ 的實現遵循了零開銷原則:你不需要的,無需為他們買單。更有甚者的是:你需要的時候,也不可能找到其他更好的代碼了。

  • 本賈尼·史特勞斯特盧普 "Foundations of C++"

作為另一個例子,這裡有一些取自於音訊解碼器的代碼。解碼算法使用線性預測數學運算(linear prediction mathematical operation)來根據之前樣本的線性函數預測將來的值。這些程式碼使用疊代器鏈來對作用域中的三個變數進行了某種數學計算:一個叫 buffer 的數據 slice、一個有 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

ch14-00-more-about-cargo.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

目前為止我們只使用過 Cargo 構建、運行和測試代碼這些最基本的功能,不過它還可以做到更多。本章會討論 Cargo 其他一些更為高級的功能,我們將展示如何:

  • 使用發布配置來自訂構建
  • 將庫發布到 crates.io
  • 使用工作空間來組織更大的項目
  • crates.io 安裝二進位制文件
  • 使用自訂的命令來擴展 Cargo

Cargo 的功能不止本章所介紹的,關於其全部功能的詳盡解釋,請查看 文件

採用發布配置自訂構建

ch14-01-release-profiles.md
commit 0f10093ac5fbd57feb2352e08ee6d3efd66f887c

在 Rust 中 發布配置release profiles)是預定義的、可訂製的帶有不同選項的配置,他們允許程式設計師更靈活地控制代碼編譯的多種選項。每一個配置都彼此相互獨立。

Cargo 有兩個主要的配置:運行 cargo build 時採用的 dev 配置和運行 cargo build --releaserelease 配置。dev 配置被定義為開發時的好的默認配置,release 配置則有著良好的發布構建的默認配置。

這些配置名稱可能很眼熟,因為它們出現在構建的輸出中:

$ cargo build
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
$ cargo build --release
    Finished release [optimized] target(s) in 0.0 secs

構建輸出中的 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。當你準備發布時,花費更多時間在編譯上則更好。只需要在發布模式編譯一次,而編譯出來的程序則會運行很多次,所以發布模式用更長的編譯時間換取運行更快的代碼。這正是為什麼 release 配置的 opt-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

ch14-02-publishing-to-crates-io.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

我們曾經在項目中使用 crates.io 上的包作為依賴,不過你也可以透過發布自己的包來向它人分享代碼。crates.io 用來分發包的原始碼,所以它主要託管開原始碼。

Rust 和 Cargo 有一些幫助它人更方便找到和使用你發布的包的功能。我們將介紹一些這樣的功能,接著講到如何發布一個包。

編寫有用的文件注釋

準確的包文件有助於其他用戶理解如何以及何時使用他們,所以花一些時間編寫文件是值得的。第三章中我們討論了如何使用兩斜槓 // 注釋 Rust 代碼。Rust 也有特定的用於文件的注釋類型,通常被稱為 文件注釋documentation comments),他們會生成 HTML 文件。這些 HTML 展示公有 API 文件注釋的內容,他們意在讓對庫感興趣的程式設計師理解如何 使用 這個 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 並將生成的 HTML 文件放入 target/doc 目錄。

為了方便起見,運行 cargo doc --open 會構建當前 crate 文件(同時還有所有 crate 依賴的文件)的 HTML 並在瀏覽器中打開。導航到 add_one 函數將會發現文件注釋的文本是如何渲染的,如圖 14-1 所示:

`my_crate` 的 `add_one` 函數所渲染的文件注釋 HTML

圖 14-1:add_one 函數的文件注釋 HTML

常用(文件注釋)部分

範例 14-1 中使用了 # Examples Markdown 標題在 HTML 中創建了一個以 “Examples” 為標題的部分。其他一些 crate 作者經常在文件注釋中使用的部分有:

  • Panics:這個函數可能會 panic! 的場景。並不希望程序崩潰的函數調用者應該確保他們不會在這些情況下調用此函數。
  • Errors:如果這個函數返回 Result,此部分描述可能會出現何種錯誤以及什麼情況會造成這些錯誤,這有助於調用者編寫程式碼來採用不同的方式處理不同的錯誤。
  • Safety:如果這個函數使用 unsafe 代碼(這會在第十九章討論),這一部分應該會涉及到期望函數調用者支持的確保 unsafe 塊中代碼正常工作的不變條件(invariants)。

大部分文件注釋不需要所有這些部分,不過這是一個提醒你檢查調用你代碼的人有興趣了解的內容的列表。

文件注釋作為測試

在文件注釋中增加範例代碼塊是一個清楚的表明如何使用庫的方法,這麼做還有一個額外的好處:cargo test 也會像測試那樣運行文件中的範例代碼!沒有什麼比有例子的文件更好的了,但最糟糕的莫過於寫完文件後改動了代碼,而導致例子不能正常工作。嘗試 cargo test 運行像範例 14-1 中 add_one 函數的文件;應該在測試結果中看到像這樣的部分:

   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! 產生 panic。再次運行 cargo test,你將會看到文件測試捕獲到了例子與代碼不再同步!

注釋包含項的結構

還有另一種風格的文件注釋,//!,這為包含注釋的項,而不是位於注釋之後的項增加文件。這通常用於 crate 根文件(通常是 src/lib.rs)或模組的根文件為 crate 或模組整體提供文件。

作為一個例子,如果我們希望增加描述包含 add_one 函數的 my_crate 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.
// --snip--

範例 14-2:my_crate crate 整體的文件

注意 //! 的最後一行之後沒有任何代碼。因為他們以 //! 開頭而不是 ///,這是屬於包含此注釋的項而不是注釋之後項的文件。在這個情況中,包含這個注釋的項是 src/lib.rs 文件,也就是 crate 根文件。這些注釋描述了整個 crate。

如果運行 cargo doc --open,將會發現這些注釋顯示在 my_crate 文件的首頁,位於 crate 中公有項列表之上,如圖 14-2 所示:

crate 整體注釋所渲染的 HTML 文件

圖 14-2:包含 my_crate 整體描述的注釋所渲染的文件

位於項之中的文件注釋對於描述 crate 和模組特別有用。使用他們描述其容器整體的目的來幫助 crate 用戶理解你的代碼組織。

使用 pub use 導出合適的公有 API

第七章介紹了如何使用 mod 關鍵字來將代碼組織進模組中,如何使用 pub 關鍵字將項變為公有,和如何使用 use 關鍵字將項引入作用域。然而你開發時候使用的文件架構可能並不方便用戶。你的結構可能是一個包含多個層級的分層結構,不過這對於用戶來說並不方便。這是因為想要使用被定義在很深層級中的類型的人可能很難發現這些類型的存在。他們也可能會厭煩使用 use my_crate::some_module::another_module::UsefulType; 而不是 use my_crate::UsefulType; 來使用類型。

公有 API 的結構是你發布 crate 時主要需要考慮的。crate 用戶沒有你那麼熟悉其結構,並且如果模組層級過大他們可能會難以找到所需的部分。

好消息是,即使文件結構對於用戶來說 不是 很方便,你也無需重新安排內部組織:你可以選擇使用 pub use 重導出(re-export)項來使公有結構不同於私有結構。重導出獲取位於一個位置的公有項並將其公開到另一個位置,好像它就定義在這個新位置一樣。

例如,假設我們創建了一個描述美術訊息的庫 art。這個庫中包含了一個有兩個枚舉 PrimaryColorSecondaryColor 的模組 kinds,以及一個包含函數 mix 的模組 utils,如範例 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 {
        // --snip--
        SecondaryColor::Orange
    }
}
fn main() {}

範例 14-3:一個庫 art 其組織包含 kindsutils 模組

cargo doc 所生成的 crate 文件首頁如圖 14-3 所示:

包含 `kinds` 和 `utils` 模組的 `art`

圖 14-3:包含 kindsutils 模組的庫 art 的文件首頁

注意 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 中項的 crate

範例 14-4 中使用 art crate 代碼的作者不得不搞清楚 PrimaryColor 位於 kinds 模組而 mix 位於 utils 模組。art crate 的模組結構相比使用它的開發者來說對編寫它的開發者更有意義。其內部的 kinds 模組和 utils 模組的組織結構並沒有對嘗試理解如何使用它的人提供任何有價值的訊息。art crate 的模組結構因不得不搞清楚所需的內容在何處和必須在 use 語句中指定模組名稱而顯得混亂和不便。

為了從公有 API 中去掉 crate 的內部組織,我們可以採用範例 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 {
    // --snip--
}

pub mod utils {
    // --snip--
}

範例 14-5:增加 pub use 語句重導出項

現在此 crate 由 cargo doc 生成的 API 文件會在首頁列出重導出的項以及其連結,如圖 14-4 所示,這使得 PrimaryColorSecondaryColor 類型和 mix 函數更易於查找。

Rendered documentation for the `art` crate with the re-exports on the front page

圖 14-10:art 文件的首頁,這裡列出了重導出的項

art crate 的用戶仍然可以看見和選擇使用範例 14-4 中的內部結構,或者可以使用範例 14-5 中更為方便的結構,如範例 14-6 所示:

檔案名: src/main.rs

use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
}

範例 14-6:一個使用 art crate 中重導出項的程序

對於有很多嵌套模組的情況,使用 pub use 將類型重導出到頂級結構對於使用 crate 的人來說將會是大為不同的體驗。

創建一個有用的公有 API 結構更像是一門藝術而非科學,你可以反覆檢視他們來找出最適合用戶的 API。pub use 提供了解耦組織 crate 內部結構和與終端用戶體現的靈活性。觀察一些你所安裝的 crate 的代碼來看看其內部結構是否不同於公有 API。

創建 Crates.io 帳號

在你可以發布任何 crate 之前,需要在 crates.io 上註冊帳號並獲取一個 API token。為此,訪問位於 crates.io 的首頁並使用 GitHub 帳號登陸。(目前 GitHub 帳號是必須的,不過將來該網站可能會支持其他創建帳號的方法)一旦登陸之後,查看位於 https://crates.io/me/ 的帳戶設置頁面並獲取 API token。接著使用該 API token 運行 cargo login 命令,像這樣:

$ cargo login abcdefghijklmnopqrstuvwxyz012345

這個命令會通知 Cargo 你的 API token 並將其儲存在本地的 ~/.cargo/credentials 文件中。注意這個 token 是一個 秘密secret)且不應該與其他人共享。如果因為任何原因與他人共享了這個訊息,應該立即到 crates.io 重新生成這個 token。

發布新 crate 之前

有了帳號之後,比如說你已經有一個希望發布的 crate。在發布之前,你需要在 crate 的 Cargo.toml 文件的 [package] 部分增加一些本 crate 的元訊息(metadata)。

首先 crate 需要一個唯一的名稱。雖然在本地開發 crate 時,可以使用任何你喜歡的名稱。不過 crates.io 上的 crate 名稱遵守先到先得的分配原則。一旦某個 crate 名稱被使用,其他人就不能再發布這個名稱的 crate 了。請在網站上搜索你希望使用的名稱來找出它是否已被使用。如果沒有,修改 Cargo.toml[package] 裡的名稱為你希望用於發布的名稱,像這樣:

檔案名: Cargo.toml

[package]
name = "guessing_game"

即使你選擇了一個唯一的名稱,如果此時嘗試運行 cargo publish 發布該 crate 的話,會得到一個警告接著是一個錯誤:

$ cargo publish
    Updating registry `https://github.com/rust-lang/crates.io-index`
warning: manifest has no description, license, license-file, documentation,
homepage or repository.
--snip--
error: api errors: missing or empty metadata fields: description, license.

這是因為我們缺少一些關鍵訊息:關於該 crate 用途的描述和用戶可能在何種條款下使用該 crate 的 license。為了修正這個錯誤,需要在 Cargo.toml 中引入這些訊息。

描述通常是一兩句話,因為它會出現在 crate 的搜索結果中和 crate 頁面裡。對於 license 欄位,你需要一個 license 標識符值license identifier value)。Linux 基金會的 Software Package Data Exchange (SPDX) 列出了可以使用的標識符。例如,為了指定 crate 使用 MIT License,增加 MIT 標識符:

檔案名: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

如果你希望使用不存在於 SPDX 的 license,則需要將 license 文本放入一個文件,將該文件包含進項目中,接著使用 license-file 來指定檔案名而不是使用 license 欄位。

關於項目所適用的 license 指導超出了本書的範疇。很多 Rust 社區成員選擇與 Rust 自身相同的 license,這是一個雙許可的 MIT OR Apache-2.0。這個實踐展示了也可以通過 OR 分隔為項目指定多個 license 標識符。

那麼,有了唯一的名稱、版本號、由 cargo new 新建項目時增加的作者訊息、描述和所選擇的 license,已經準備好發布的項目的 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 會上傳特定版本的 crate 到 crates.io 以供他人使用。

發布 crate 時請多加小心,因為發布是 永久性的permanent)。對應版本不可能被覆蓋,其代碼也不可能被刪除。crates.io 的一個主要目標是作為一個存儲代碼的永久文件伺服器,這樣所有依賴 crates.io 中的 crate 的項目都能一直正常工作。而允許刪除版本沒辦法達成這個目標。然而,可以被發布的版本號卻沒有限制。

再次運行 cargo publish 命令。這次它應該會成功:

$ cargo publish
 Updating registry `https://github.com/rust-lang/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.19 secs
Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

恭喜!你現在向 Rust 社區分享了代碼,而且任何人都可以輕鬆的將你的 crate 加入他們項目的依賴。

發布現存 crate 的新版本

當你修改了 crate 並準備好發布新版本時,改變 Cargo.tomlversion 所指定的值。請使用 語義化版本規則 來根據修改的類型決定下一個版本號。接著運行 cargo publish 來上傳新版本。

使用 cargo yank 從 Crates.io 撤回版本

雖然你不能刪除之前版本的 crate,但是可以阻止任何將來的項目將他們加入到依賴中。這在某個版本因為這樣或那樣的原因被破壞的情況很有用。對於這種情況,Cargo 支持 撤回yanking)某個版本。

撤回某個版本會阻止新項目開始依賴此版本,不過所有現存此依賴的項目仍然能夠下載和依賴這個版本。從本質上說,撤回意味著所有帶有 Cargo.lock 的項目的依賴不會被破壞,同時任何新生成的 Cargo.lock 將不能使用被撤回的版本。

為了撤回一個 crate,運行 cargo yank 並指定希望撤回的版本:

$ cargo yank --vers 1.0.1

也可以撤銷撤回操作,並允許項目可以再次開始依賴某個版本,通過在命令上增加 --undo

$ cargo yank --vers 1.0.1 --undo

撤回 並沒有 刪除任何代碼。舉例來說,撤回功能並不意在刪除不小心上傳的秘密訊息。如果出現了這種情況,請立即重新設置這些秘密訊息。

Cargo 工作空間

ch14-03-cargo-workspaces.md
commit 6d3e76820418f2d2bb203233c61d90390b5690f1

第十二章中,我們構建一個包含二進位制 crate 和庫 crate 的包。你可能會發現,隨著項目開發的深入,庫 crate 持續增大,而你希望將其進一步拆分成多個庫 crate。對於這種情況,Cargo 提供了一個叫 工作空間workspaces)的功能,它可以幫助我們管理多個相關的協同開發的包。

創建工作空間

工作空間 是一系列共享同樣的 Cargo.lock 和輸出目錄的包。讓我們使用工作空間創建一個項目 —— 這裡採用常見的代碼以便可以關注工作空間的結構。有多種組織工作空間的方式;我們將展示一個常用方法。我們的工作空間有一個二進位制項目和兩個庫。二進位制項目會提供主要功能,並會依賴另兩個庫。一個庫會提供 add_one 方法而第二個會提供 add_two 方法。這三個 crate 將會是相同工作空間的一部分。讓我們以新建工作空間目錄開始:

$ mkdir add
$ cd add

接著在 add 目錄中,創建 Cargo.toml 文件。這個 Cargo.toml 文件配置了整個工作空間。它不會包含 [package] 或其他我們在 Cargo.toml 中見過的元訊息。相反,它以 [workspace] 部分作為開始,並通過指定 adder 的路徑來為工作空間增加成員,如下會加入二進位制 crate:

檔案名: Cargo.toml

[workspace]

members = [
    "adder",
]

接下來,在 add 目錄運行 cargo new 新建 adder 二進位制 crate:

$ cargo new adder
     Created binary (application) `adder` project

到此為止,可以運行 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。工作空間中的 crate 之間相互依賴。如果每個 crate 有其自己的 target 目錄,為了在自己的 target 目錄中生成構建結果,工作空間中的每一個 crate 都不得不相互重新編譯其他 crate。通過共享一個 target 目錄,工作空間可以避免其他 crate 多餘的重複構建。

在工作空間中創建第二個 crate

接下來,讓我們在工作空間中指定另一個成員 crate。這個 crate 位於 add-one 目錄中,所以修改頂級 Cargo.toml 為也包含 add-one 路徑:

檔案名: Cargo.toml

[workspace]

members = [
    "adder",
    "add-one",
]

接著新生成一個叫做 add-one 的庫:

$ cargo new add-one --lib
     Created library `add-one` project

現在 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
}
}

現在工作空間中有了一個庫 crate,讓 adder 依賴庫 crate add-one。首先需要在 adder/Cargo.toml 文件中增加 add-one 作為路徑依賴:

檔案名: adder/Cargo.toml

[dependencies]

add-one = { path = "../add-one" }

cargo並不假定工作空間中的Crates會相互依賴,所以需要明確表明工作空間中 crate 的依賴關係。

接下來,在 adder crate 中使用 add-one crate 的函數 add_one。打開 adder/src/main.rs 在頂部增加一行 use 將新 add-one 庫 crate 引入作用域。接著修改 main 函數來調用 add_one 函數,如範例 14-7 所示。

檔案名: adder/src/main.rs

use add_one;

fn main() {
    let num = 10;
    println!("Hello, world! {} plus one is {}!", 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.68 secs

為了在頂層 add 目錄運行二進位制 crate,需要通過 -p 參數和包名稱來運行 cargo run 指定工作空間中我們希望使用的包:

$ cargo run -p adder
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

這會運行 adder/src/main.rs 中的代碼,其依賴 add-one crate

在工作空間中依賴外部 crate

還需注意的是工作空間只在根目錄有一個 Cargo.lock,而不是在每一個 crate 目錄都有 Cargo.lock。這確保了所有的 crate 都使用完全相同版本的依賴。如果在 Cargo.tomladd-one/Cargo.toml 中都增加 rand crate,則 Cargo 會將其都解析為同一版本並記錄到唯一的 Cargo.lock 中。使得工作空間中的所有 crate 都使用相同的依賴意味著其中的 crate 都是相互相容的。讓我們在 add-one/Cargo.toml 中的 [dependencies] 部分增加 rand crate 以便能夠在 add-one crate 中使用 rand crate:

檔案名: add-one/Cargo.toml

[dependencies]
rand = "0.5.5"

現在就可以在 add-one/src/lib.rs 中增加 use rand; 了,接著在 add 目錄運行 cargo build 構建整個工作空間就會引入並編譯 rand crate:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.5.5
   --snip--
   Compiling rand v0.5.5
   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.18 secs

現在頂級的 Cargo.lock 包含了 add-onerand 依賴的訊息。然而,即使 rand 被用於工作空間的某處,也不能在其他 crate 中使用它,除非也在他們的 Cargo.toml 中加入 rand。例如,如果在頂級的 adder crate 的 adder/src/main.rs 中增加 use rand;,會得到一個錯誤:

$ cargo build
   Compiling adder v0.1.0 (file:///projects/add/adder)
error: use of unstable library feature 'rand': use `rand` from crates.io (see
issue #27703)
 --> adder/src/main.rs:1:1
  |
1 | use rand;

為了修復這個錯誤,修改頂級 adder crate 的 Cargo.toml 來表明 rand 也是這個 crate 的依賴。構建 adder crate 會將 rand 加入到 Cargo.lockadder 的依賴列表中,但是這並不會下載 rand 的額外拷貝。Cargo 確保了工作空間中任何使用 rand 的 crate 都採用相同的版本。在整個工作空間中使用相同版本的 rand 節省了空間,因為這樣就無需多個拷貝並確保了工作空間中的 crate 將是相互相容的。

為工作空間增加測試

作為另一個提升,讓我們為 add_one crate 中的 add_one::add_one 函數增加一個測試:

檔案名: 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 dev [unoptimized + debuginfo] target(s) in 0.27 secs
     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-f88af9d2cc175a5e

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 中找到了 0 個測試,最後一部分顯示 add-one crate 中有 0 個文件測試。在像這樣的工作空間結構中運行 cargo test 會運行工作空間中所有 crate 的測試。

也可以選擇運行工作空間中特定 crate 的測試,透過在根目錄使用 -p 參數並指定希望測試的 crate 名稱:

$ cargo test -p add-one
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     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 的測試。

如果你選擇向 crates.io發布工作空間中的 crate,每一個工作空間中的 crate 需要單獨發布。cargo publish 命令並沒有 --all 或者 -p 參數,所以必須進入每一個 crate 的目錄並運行 cargo publish 來發布工作空間中的每一個 crate。

現在嘗試以類似 add-one crate 的方式向工作空間增加 add-two crate 來作為更多的練習!

隨著項目增長,考慮使用工作空間:每一個更小的組件比一大塊代碼要容易理解。如果它們經常需要同時被修改的話,將 crate 保持在工作空間中更易於協調他們的改變。

使用 cargo install 從 Crates.io 安裝二進位制文件

ch14-04-installing-binaries.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

cargo install 命令用於在本地安裝和使用二進位制 crate。它並不打算替換系統中的包;它意在作為一個方便 Rust 開發者們安裝其他人已經在 crates.io 上共享的工具的手段。只有擁有二進位制目標文件的包能夠被安裝。二進位制目標 文件是在 crate 有 src/main.rs 或者其他指定為二進位制文件時所創建的可執行程序,這不同於自身不能執行但適合包含在其他程序中的庫目標文件。通常 crate 的 README 文件中有該 crate 是庫、二進位制目標還是兩者都是的訊息。

所有來自 cargo install 的二進位制文件都安裝到 Rust 安裝根目錄的 bin 文件夾中。如果你使用 rustup.rs 安裝的 Rust 且沒有自訂任何配置,這將是 $HOME/.cargo/bin。確保將這個目錄添加到 $PATH 環境變數中就能夠運行通過 cargo install 安裝的程序了。

例如,第十二章提到的叫做 ripgrep 的用於搜尋文件的 grep 的 Rust 實現。如果想要安裝 ripgrep,可以運行如下:

$ cargo install ripgrep
Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading ripgrep v0.3.2
 --snip--
   Compiling ripgrep v0.3.2
    Finished release [optimized + debuginfo] target(s) in 97.91 secs
  Installing ~/.cargo/bin/rg

最後一行輸出展示了安裝的二進位制文件的位置和名稱,在這裡 ripgrep 被命名為 rg。只要你像上面提到的那樣將安裝目錄加入 $PATH,就可以運行 rg --help 並開始使用一個更快更 Rust 的工具來搜尋文件了!

Cargo 自訂擴展命令

ch14-05-extending-cargo.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

Cargo 的設計使得開發者可以透過新的子命令來對 Cargo 進行擴展,而無需修改 Cargo 本身。如果 $PATH 中有類似 cargo-something 的二進位制文件,就可以通過 cargo something 來像 Cargo 子命令一樣運行它。像這樣的自訂命令也可以運行 cargo --list 來展示出來。能夠通過 cargo install 向 Cargo 安裝擴展並可以如內建 Cargo 工具那樣運行他們是 Cargo 設計上的一個非常方便的優點!

總結

通過 Cargo 和 crates.io 來分享代碼是使得 Rust 生態環境可以用於許多不同的任務的重要組成部分。Rust 的標準庫是小而穩定的,不過 crate 易於分享和使用,並採用一個不同語言自身的時間線來提供改進。不要羞於在 crates.io 上共享對你有用的代碼;因為它很有可能對別人也很有用!

智慧指針

ch15-00-smart-pointers.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

指針pointer)是一個包含記憶體地址的變數的通用概念。這個地址引用,或 “指向”(points at)一些其他數據。Rust 中最常見的指針是第四章介紹的 引用reference)。引用以 & 符號為標誌並借用了他們所指向的值。除了引用數據沒有任何其他特殊功能。它們也沒有任何額外開銷,所以應用得最多。

另一方面,智慧指針smart pointers)是一類數據結構,他們的表現類似指針,但是也擁有額外的元數據和功能。智慧指針的概念並不為 Rust 所獨有;其起源於 C++ 並存在於其他語言中。Rust 標準庫中不同的智慧指針提供了多於引用的額外功能。本章將會探索的一個例子便是 引用計數reference counting)智慧指針類型,其允許數據有多個所有者。引用計數智慧指針記錄總共有多少個所有者,並當沒有任何所有者時負責清理數據。

在 Rust 中,普通引用和智慧指針的一個額外的區別是引用是一類只借用數據的指針;相反,在大部分情況下,智慧指針 擁有 他們指向的數據。

實際上本書中已經出現過一些智慧指針,比如第八章的 StringVec<T>,雖然當時我們並不這麼稱呼它們。這些類型都屬於智慧指針因為它們擁有一些數據並允許你修改它們。它們也帶有元數據(比如他們的容量)和額外的功能或保證(String 的數據總是有效的 UTF-8 編碼)。

智慧指針通常使用結構體實現。智慧指針區別於常規結構體的顯著特性在於其實現了 DerefDrop trait。Deref trait 允許智慧指針結構體實例表現的像引用一樣,這樣就可以編寫既用於引用、又用於智慧指針的代碼。Drop trait 允許我們自訂當智慧指針離開作用域時運行的代碼。本章會討論這些 trait 以及為什麼對於智慧指針來說他們很重要。

考慮到智慧指針是一個在 Rust 經常被使用的通用設計模式,本章並不會覆蓋所有現存的智慧指針。很多庫都有自己的智慧指針而你也可以編寫屬於你自己的智慧指針。這裡將會講到的是來自標準庫中最常用的一些:

  • Box<T>,用於在堆上分配值
  • Rc<T>,一個引用計數類型,其數據可以有多個所有者
  • Ref<T>RefMut<T>,通過 RefCell<T> 訪問。( RefCell<T> 是一個在運行時而不是在編譯時執行借用規則的類型)。

另外我們會涉及 內部可變性interior mutability)模式,這是不可變類型暴露出改變其內部值的 API。我們也會討論 引用循環reference cycles)會如何洩漏記憶體,以及如何避免。

讓我們開始吧!

使用Box <T>指向堆上的數據

ch15-01-box.md
commit a203290c640a378453261948b3fee4c4c6eb3d0f

最簡單直接的智慧指針是 box,其類型是 Box<T>。 box 允許你將一個值放在堆上而不是棧上。留在棧上的則是指向堆數據的指針。如果你想回顧一下棧與堆的區別請參考第四章。

除了數據被儲存在堆上而不是棧上之外,box 沒有性能損失。不過也沒有很多額外的功能。它們多用於如下場景:

  • 當有一個在編譯時未知大小的類型,而又想要在需要確切大小的上下文中使用這個類型值的時候
  • 當有大量數據並希望在確保數據不被拷貝的情況下轉移所有權的時候
  • 當希望擁有一個值並只關心它的類型是否實現了特定 trait 而不是其具體類型的時候

我們會在 “box 允許創建遞迴類型” 部分展示第一種場景。在第二種情況中,轉移大量數據的所有權可能會花費很長的時間,因為數據在棧上進行了拷貝。為了改善這種情況下的性能,可以通過 box 將這些數據儲存在堆上。接著,只有少量的指針數據在棧上被拷貝。第三種情況被稱為 trait 對象trait object),第十七章剛好有一整個部分 “為使用不同類型的值而設計的 trait 對象” 專門講解這個主題。所以這裡所學的內容會在第十七章再次用上!

使用 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,其值是一個指向被分配在堆上的值 5Box。這個程序會列印出 b = 5;在這個例子中,我們可以像數據是儲存在棧上的那樣訪問 box 中的數據。正如任何擁有數據所有權的值那樣,當像 b 這樣的 box 在 main 的末尾離開作用域時,它將被釋放。這個釋放過程作用於 box 本身(位於棧上)和它所指向的數據(位於堆上)。

將一個單獨的值存放在堆上並不是很有意義,所以像範例 15-1 這樣單獨使用 box 並不常見。將像單個 i32 這樣的值儲存在棧上,也就是其默認存放的地方在大部分使用場景中更為合適。讓我們看看一個不使用 box 時無法定義的類型的例子。

Box 允許創建遞迴類型

Rust 需要在編譯時知道類型占用多少空間。一種無法在編譯時知道大小的類型是 遞迴類型recursive type),其值的一部分可以是相同類型的另一個值。這種值的嵌套理論上可以無限的進行下去,所以 Rust 不知道遞迴類型需要多少空間。不過 box 有一個已知的大小,所以通過在循環類型定義中插入 box,就可以創建遞迴類型了。

讓我們探索一下 cons list,一個函數式程式語言中的常見類型,來展示這個(遞迴類型)概念。除了遞迴之外,我們將要定義的 cons list 類型是很直接的,所以這個例子中的概念,在任何遇到更為複雜的涉及到遞迴類型的場景時都很實用。

cons list 的更多內容

cons list 是一個來源於 Lisp 程式語言及其方言的數據結構。在 Lisp 中,cons 函數(“construct function" 的縮寫)利用兩個參數來構造一個新的列表,他們通常是一個單獨的值和另一個列表。

cons 函數的概念涉及到更常見的函數式編程術語;“將 xy 連接” 通常意味著構建一個新的容器而將 x 的元素放在新容器的開頭,其後則是容器 y 的元素。

cons list 的每一項都包含兩個元素:當前項的值和下一項。其最後一項值包含一個叫做 Nil 的值且沒有下一項。cons list 透過遞迴調用 cons 函數產生。代表遞迴的終止條件(base case)的規範名稱是 Nil,它宣布列表的終止。注意這不同於第六章中的 “null” 或 “nil” 的概念,他們代表無效或缺失的值。

注意雖然函數式程式語言經常使用 cons list,但是它並不是一個 Rust 中常見的類型。大部分在 Rust 中需要列表的時候,Vec<T> 是一個更好的選擇。其他更為複雜的遞迴數據類型 確實 在 Rust 的很多場景中很有用,不過通過以 cons list 作為開始,我們可以探索如何使用 box 毫不費力的定義一個遞迴數據類型。

範例 15-2 包含一個 cons list 的枚舉定義。注意這還不能編譯因為這個類型沒有已知的大小,之後我們會展示:

檔案名: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

範例 15-2:第一次嘗試定義一個代表 i32 值的 cons list 數據結構的枚舉

注意:出於範例的需要我們選擇實現一個只存放 i32 值的 cons list。也可以用泛型,正如第十章講到的,來定義一個可以存放任何類型值的 cons list 類型。

使用這個 cons list 來儲存列表 1, 2, 3 將看起來如範例 15-3 所示:

檔案名: src/main.rs

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 是另一個包含 2Cons 值和下一個 List 值。接著又有另一個存放了 3Cons 值和最後一個值為 NilList,非遞迴成員代表了列表的結尾。

如果嘗試編譯範例 15-3 的代碼,會得到如範例 15-4 所示的錯誤:

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

範例 15-4:嘗試定義一個遞迴枚舉時得到的錯誤

這個錯誤表明這個類型 “有無限的大小”。其原因是 List 的一個成員被定義為是遞迴的:它直接存放了另一個相同類型的值。這意味著 Rust 無法計算為了存放 List 值到底需要多少空間。讓我們一點一點來看:首先了解一下 Rust 如何決定需要多少空間來存放一個非遞迴類型。

計算非遞迴類型的大小

回憶一下第六章討論枚舉定義時範例 6-2 中定義的 Message 枚舉:


#![allow(unused)]
fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
}

當 Rust 需要知道要為 Message 值分配多少空間時,它可以檢查每一個成員並發現 Message::Quit 並不需要任何空間,Message::Move 需要足夠儲存兩個 i32 值的空間,依此類推。因此,Message 值所需的空間等於儲存其最大成員的空間大小。

與此相對當 Rust 編譯器檢查像範例 15-2 中的 List 這樣的遞迴類型時會發生什麼事呢。編譯器嘗試計算出儲存一個 List 枚舉需要多少記憶體,並開始檢查 Cons 成員,那麼 Cons 需要的空間等於 i32 的大小加上 List 的大小。為了計算 List 需要多少記憶體,它檢查其成員,從 Cons 成員開始。Cons成員儲存了一個 i32 值和一個List值,這樣的計算將無限進行下去,如圖 15-1 所示:

An infinite Cons list

圖 15-1:一個包含無限個 Cons 成員的無限 List

使用 Box<T> 給遞迴類型一個已知的大小

Rust 無法計算出要為定義為遞迴的類型分配多少空間,所以編譯器給出了範例 15-4 中的錯誤。這個錯誤也包括了有用的建議:

  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

在建議中,“indirection” 意味著不同於直接儲存一個值,我們將間接的儲存一個指向值的指針。

因為 Box<T> 是一個指針,我們總是知道它需要多少空間:指針的大小並不會根據其指向的數據量而改變。這意味著可以將 Box 放入 Cons 成員中而不是直接存放另一個 List 值。Box 會指向另一個位於堆上的 List 值,而不是存放在 Cons 成員中。從概念上講,我們仍然有一個通過在其中 “存放” 其他列表創建的列表,不過現在實現這個概念的方式更像是一個項挨著另一項,而不是一項包含另一項。

我們可以修改範例 15-2 中 List 枚舉的定義和範例 15-3 中對 List 的應用,如範例 15-65 所示,這是可以編譯的:

檔案名: 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:因為 Cons 存放一個 Box 所以 List 不是無限大小的了

box 只提供了間接存儲和堆分配;他們並沒有任何其他特殊的功能,比如我們將會見到的其他智慧指針。它們也沒有這些特殊功能帶來的性能損失,所以他們可以用於像 cons list 這樣間接存儲是唯一所需功能的場景。我們還將在第十七章看到 box 的更多應用場景。

Box<T> 類型是一個智慧指針,因為它實現了 Deref trait,它允許 Box<T> 值被當作引用對待。當 Box<T> 值離開作用域時,由於 Box<T> 類型 Drop trait 的實現,box 所指向的堆數據也會被清除。讓我們更詳細的探索一下這兩個 trait。這兩個 trait 對於在本章餘下討論的其他智慧指針所提供的功能中,將會更為重要。

通過 Deref trait 將智慧指針當作常規引用處理

ch15-02-deref.md
commit 44f1b71c117b0dcec7805eced0b95405167092f6

實現 Deref trait 允許我們重載 解引用運算符dereference operator*(與乘法運算符或通配符相區別)。透過這種方式實現 Deref trait 的智慧指針可以被當作常規引用來對待,可以編寫操作引用的代碼並用於智慧指針。

讓我們首先看看解引用運算符如何處理常規引用,接著嘗試定義我們自己的類似 Box<T> 的類型並看看為何解引用運算符不能像引用一樣工作。我們會探索如何實現 Deref trait 使得智慧指針以類似引用的方式工作變為可能。最後,我們會討論 Rust 的 解引用強制多態deref coercions)功能以及它是如何處理引用或智慧指針的。

我們將要構建的 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 存放了一個 i325y 等於 x 的一個引用。可以斷言 x 等於 5。然而,如果希望對 y 的值做出斷言,必須使用 *y 來追蹤引用所指向的值(也就是 解引用)。一旦解引用了 y,就可以訪問 y 所指向的整型值並可以與 5 做比較。

相反如果嘗試編寫 assert_eq!(5, y);,則會得到如下編譯錯誤:

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}`

不允許比較數字的引用與數字,因為它們是不同的類型。必須使用解引用運算符追蹤引用所指向的值。

像引用一樣使用 Box<T>

可以使用 Box<T> 代替引用來重寫範例 15-6 中的代碼,解引用運算符也一樣能工作,如範例 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 值的引用。在最後的斷言中,可以使用解引用運算符以 y 為引用時相同的方式追蹤 box 的指針。接下來讓我們通過實現自己的 box 類型來探索 Box<T> 能這麼做有何特殊之處。

自訂智慧指針

為了體會默認情況下智慧指針與引用的不同,讓我們創建一個類似於標準庫提供的 Box<T> 類型的智慧指針。接著學習如何增加使用解引用運算符的功能。

從根本上說,Box<T> 被定義為包含一個元素的元組結構體,所以範例 15-8 以相同的方式定義了 MyBox<T> 類型。我們還定義了 new 函數來對應定義於 Box<T>new 函數:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}
}

範例 15-8:定義 MyBox<T> 類型

這裡定義了一個結構體 MyBox 並聲明了一個泛型參數 T,因為我們希望其可以存放任何類型的值。MyBox 是一個包含 T 類型元素的元組結構體。MyBox::new 函數獲取一個 T 類型的參數並返回一個存放傳入值的 MyBox 實例。

嘗試將範例 15-7 中的代碼加入範例 15-8 中並修改 main 使用我們定義的 MyBox<T> 類型代替 Box<T>。範例 15-9 中的代碼不能編譯,因為 Rust 不知道如何解引用 MyBox

檔案名: src/main.rs

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

範例 15-9:嘗試以使用引用和 Box<T> 相同的方式使用 MyBox<T>

得到的編譯錯誤是:

error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

MyBox<T> 類型不能解引用,因為我們尚未在該類型實現這個功能。為了啟用 * 運算符的解引用功能,需要實現 Deref trait。

通過實現 Deref trait 將某類型像引用一樣處理

如第十章所討論的,為了實現 trait,需要提供 trait 所需的方法實現。Deref trait,由標準庫提供,要求實現名為 deref 的方法,其借用 self 並返回一個內部數據的引用。範例 15-10 包含定義於 MyBox 之上的 Deref 實現:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::ops::Deref;

struct MyBox<T>(T);

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}
}

範例 15-10:MyBox<T> 上的 Deref 實現

type Target = T; 語法定義了用於此 trait 的關聯類型。關聯類型是一個稍有不同的定義泛型參數的方式,現在還無需過多的擔心它;第十九章會詳細介紹。

deref 方法體中寫入了 &self.0,這樣 deref 返回了我希望通過 * 運算符訪問的值的引用。範例 15-9 中的 main 函數中對 MyBox<T> 值的 * 調用現在可以編譯並能通過斷言了!

沒有 Deref trait 的話,編譯器只會解引用 & 引用類型。deref 方法向編譯器提供了獲取任何實現了 Deref trait 的類型的值,並且調用這個類型的 deref 方法來獲取一個它知道如何解引用的 & 引用的能力。

當我們在範例 15-9 中輸入 *y 時,Rust 事實上在底層運行了如下代碼:

*(y.deref())

Rust 將 * 運算符替換為先調用 deref 方法再進行普通解引用的操作,如此我們便不用擔心是否還需手動調用 deref 方法了。Rust 的這個特性可以讓我們寫出行為一致的代碼,無論是面對的是常規引用還是實現了 Deref 的類型。

deref 方法返回值的引用,以及 *(y.deref()) 括號外面的普通解引用仍為必須的原因在於所有權。如果 deref 方法直接返回值而不是值的引用,其值(的所有權)將被移出 self。在這裡以及大部分使用解引用運算符的情況下我們並不希望獲取 MyBox<T> 內部值的所有權。

注意,每次當我們在代碼中使用 * 時, * 運算符都被替換成了先調用 deref 方法再接著使用 * 解引用的操作,且只會發生一次,不會對 * 操作符無限遞迴替換,解引用出上面 i32 類型的值就停止了,這個值與範例 15-9 中 assert_eq!5 相匹配。

函數和方法的隱式解引用強制多態

解引用強制多態deref coercions)是 Rust 在函數或方法傳參上的一種便利。其將實現了 Deref 的類型的引用轉換為原始類型通過 Deref 所能夠轉換的類型的引用。當這種特定類型的引用作為實參傳遞給和形參類型不同的函數或方法時,解引用強制多態將自動發生。這時會有一系列的 deref 方法被調用,把我們提供的類型轉換成了參數所需的類型。

解引用強制多態的加入使得 Rust 程式設計師編寫函數和方法調用時無需增加過多顯式使用 &* 的引用和解引用。這個功能也使得我們可以編寫更多同時作用於引用或智慧指針的代碼。

作為展示解引用強制多態的實例,讓我們使用範例 15-8 中定義的 MyBox<T>,以及範例 15-10 中增加的 Deref 實現。範例 15-11 展示了一個有著字串 slice 參數的函數定義:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
fn hello(name: &str) {
    println!("Hello, {}!", name);
}
}

範例 15-11:hello 函數有著 &str 類型的參數 name

可以使用字串 slice 作為參數調用 hello 函數,比如 hello("Rust");。解引用強制多態使得用 MyBox<String> 類型值的引用調用 hello 成為可能,如範例 15-12 所示:

檔案名: src/main.rs

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

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 trait,Rust 可以通過 deref 調用將 &MyBox<String> 變為 &String。標準庫中提供了 String 上的 Deref 實現,其會返回字串 slice,這可以在 Deref 的 API 文件中看到。Rust 再次調用 deref&String 變為 &str,這就符合 hello 函數的定義了。

如果 Rust 沒有實現解引用強制多態,為了使用 &MyBox<String> 類型的值調用 hello,則不得不編寫範例 15-13 中的代碼來代替範例 15-12:

檔案名: src/main.rs

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

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 的字串 slice 來匹配 hello 的簽名。沒有解引用強制多態所有這些符號混在一起將更難以讀寫和理解。解引用強制多態使得 Rust 自動的幫我們處理這些轉換。

當所涉及到的類型定義了 Deref trait,Rust 會分析這些類型並使用任意多次 Deref::deref 調用以獲得匹配參數的類型。這些解析都發生在編譯時,所以利用解引用強制多態並沒有運行時懲罰!

解引用強制多態如何與可變性交互

類似於如何使用 Deref trait 重載不可變引用的 * 運算符,Rust 提供了 DerefMut trait 用於重載可變引用的 * 運算符。

Rust 在發現類型和 trait 實現滿足三種情況時會進行解引用強制多態:

  • T: Deref<Target=U> 時從 &T&U
  • T: DerefMut<Target=U> 時從 &mut T&mut U
  • T: Deref<Target=U> 時從 &mut T&U

頭兩個情況除了可變性之外是相同的:第一種情況表明如果有一個 &T,而 T 實現了返回 U 類型的 Deref,則可以直接得到 &U。第二種情況表明對於可變引用也有著相同的行為。

第三個情況有些微妙:Rust 也會將可變引用強轉為不可變引用。但是反之是 不可能 的:不可變引用永遠也不能強轉為可變引用。因為根據借用規則,如果有一個可變引用,其必須是這些數據的唯一引用(否則程序將無法編譯)。將一個可變引用轉換為不可變引用永遠也不會打破借用規則。將不可變引用轉換為可變引用則需要數據只能有一個不可變引用,而借用規則無法保證這一點。因此,Rust 無法假設將不可變引用轉換為可變引用是可能的。

使用 Drop Trait 運行清理代碼

ch15-03-drop.md
commit 57adb83f69a69e20862d9e107b2a8bab95169b4c

對於智慧指針模式來說第二個重要的 trait 是 Drop,其允許我們在值要離開作用域時執行一些程式碼。可以為任何類型提供 Drop trait 的實現,同時所指定的代碼被用於釋放類似於文件或網路連接的資源。我們在智慧指針上下文中討論 Drop 是因為其功能幾乎總是用於實現智慧指針。例如,Box<T> 自訂了 Drop 用來釋放 box 所指向的堆空間。

在其他一些語言中,我們不得不記住在每次使用完智慧指針實例後調用清理記憶體或資源的代碼。如果忘記的話,運行程式碼的系統可能會因為負荷過重而崩潰。在 Rust 中,可以指定每當值離開作用域時被執行的代碼,編譯器會自動插入這些程式碼。於是我們就不需要在程序中到處編寫在實例結束時清理這些變數的代碼 —— 而且還不會洩漏資源。

指定在值離開作用域時應該執行的代碼的方式是實現 Drop trait。Drop trait 要求實現一個叫做 drop 的方法,它獲取一個 self 的可變引用。為了能夠看出 Rust 何時調用 drop,讓我們暫時使用 println! 語句實現 drop

範例 15-14 展示了唯一定製功能就是當其實例離開作用域時,列印出 Dropping CustomSmartPointer! 的結構體 CustomSmartPointer。這會示範 Rust 何時運行 drop 函數:

檔案名: src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer { data: String::from("my stuff") };
    let d = CustomSmartPointer { data: String::from("other stuff") };
    println!("CustomSmartPointers created.");
}

範例 15-14:結構體 CustomSmartPointer,其實現了放置清理代碼的 Drop trait

Drop trait 包含在 prelude 中,所以無需導入它。我們在 CustomSmartPointer 上實現了 Drop trait,並提供了一個調用 println!drop 方法實現。drop 函數體是放置任何當類型實例離開作用域時期望運行的邏輯的地方。這裡選擇列印一些文本以展示 Rust 何時調用 drop

main 中,我們新建了兩個 CustomSmartPointer 實例並列印出了 CustomSmartPointer created.。在 main 的結尾,CustomSmartPointer 的實例會離開作用域,而 Rust 會調用放置於 drop 方法中的代碼,列印出最後的訊息。注意無需顯示調用 drop 方法:

當運行這個程序,會出現如下輸出:

CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

當實例離開作用域 Rust 會自動調用 drop,並調用我們指定的代碼。變數以被創建時相反的順序被丟棄,所以 dc 之前被丟棄。這個例子剛好給了我們一個 drop 方法如何工作的可視化指導,不過通常需要指定類型所需執行的清理代碼而不是列印訊息。

通過 std::mem::drop 提早丟棄值

不幸的是,我們並不能直截了當的禁用 drop 這個功能。通常也不需要禁用 drop ;整個 Drop trait 存在的意義在於其是自動處理的。然而,有時你可能需要提早清理某個值。一個例子是當使用智慧指針管理鎖時;你可能希望強制運行 drop 方法來釋放鎖以便作用域中的其他代碼可以獲取鎖。Rust 並不允許我們主動調用 Drop trait 的 drop 方法;當我們希望在作用域結束之前就強制釋放變數的話,我們應該使用的是由標準庫提供的 std::mem::drop

如果我們像是範例 15-14 那樣嘗試調用 Drop trait 的 drop 方法,就會得到像範例 15-15 那樣的編譯錯誤:

檔案名: src/main.rs

fn main() {
    let c = CustomSmartPointer { data: String::from("some data") };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}

範例 15-15:嘗試手動調用 Drop trait 的 drop 方法提早清理

如果嘗試編譯代碼會得到如下錯誤:

error[E0040]: explicit use of destructor method
  --> src/main.rs:14:7
   |
14 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed

錯誤訊息表明不允許顯式調用 drop。錯誤訊息使用了術語 析構函數destructor),這是一個清理實例的函數的通用編程概念。析構函數 對應創建實例的 構造函數。Rust 中的 drop 函數就是這麼一個析構函數。

Rust 不允許我們顯式調用 drop 因為 Rust 仍然會在 main 的結尾對值自動調用 drop,這會導致一個 double free 錯誤,因為 Rust 會嘗試清理相同的值兩次。

因為不能禁用當值離開作用域時自動插入的 drop,並且不能顯式調用 drop,如果我們需要強制提早清理值,可以使用 std::mem::drop 函數。

std::mem::drop 函數不同於 Drop trait 中的 drop 方法。可以通過傳遞希望提早強制丟棄的值作為參數。std::mem::drop 位於 prelude,所以我們可以修改範例 15-15 中的 main 來調用 drop 函數。如範例 15-16 所示:

檔案名: src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer { data: String::from("some data") };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

範例 15-16: 在值離開作用域之前調用 std::mem::drop 顯式清理

運行這段代碼會列印出如下:

CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

Dropping CustomSmartPointer with data `some data`! 出現在 CustomSmartPointer created.CustomSmartPointer dropped before the end of main. 之間,表明了 drop 方法被調用了並在此丟棄了 c

Drop trait 實現中指定的代碼可以用於許多方面,來使得清理變得方便和安全:比如可以用其創建我們自己的記憶體分配器!通過 Drop trait 和 Rust 所有權系統,你無需擔心之後的代碼清理,Rust 會自動考慮這些問題。

我們也無需擔心意外的清理掉仍在使用的值,這會造成編譯器錯誤:所有權系統確保引用總是有效的,也會確保 drop 只會在值不再被使用時被調用一次。

現在我們學習了 Box<T> 和一些智慧指針的特性,讓我們聊聊標準庫中定義的其他幾種智慧指針。

Rc<T> 引用計數智慧指針

ch15-04-rc.md
commit 6f292c8439927b4c5b870dd4afd2bfc52cc4eccc

大部分情況下所有權是非常明確的:可以準確地知道哪個變數擁有某個值。然而,有些情況單個值可能會有多個所有者。例如,在圖數據結構中,多個邊可能指向相同的節點,而這個節點從概念上講為所有指向它的邊所擁有。節點直到沒有任何邊指向它之前都不應該被清理。

為了啟用多所有權,Rust 有一個叫做 Rc<T> 的類型。其名稱為 引用計數reference counting)的縮寫。引用計數意味著記錄一個值引用的數量來知曉這個值是否仍在被使用。如果某個值有零個引用,就代表沒有任何有效引用並可以被清理。

可以將其想像為客廳中的電視。當一個人進來看電視時,他打開電視。其他人也可以進來看電視。當最後一個人離開房間時,他關掉電視因為它不再被使用了。如果某人在其他人還在看的時候就關掉了電視,正在看電視的人肯定會抓狂的!

Rc<T> 用於當我們希望在堆上分配一些記憶體供程序的多個部分讀取,而且無法在編譯時確定程序的哪一部分會最後結束使用它的時候。如果確實知道哪部分是最後一個結束使用的話,就可以令其成為數據的所有者,正常的所有權規則就可以在編譯時生效。

注意 Rc<T> 只能用於單執行緒場景;第十六章並發會涉及到如何在多執行緒程序中進行引用計數。

使用 Rc<T> 共享數據

讓我們回到範例 15-5 中使用 Box<T> 定義 cons list 的例子。這一次,我們希望創建兩個共享第三個列表所有權的列表,其概念將會看起來如圖 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> 的列表嘗試共享第三個列表的所有權

編譯會得出如下錯誤:

error[E0382]: use of moved value: `a`
  --> src/main.rs:13:30
   |
12 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
13 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move
   |
   = note: move occurs because `a` has type `List`, which does not implement
   the `Copy` trait

Cons 成員擁有其儲存的數據,所以當創建 b 列表時,a 被移動進了 b 這樣 b 就擁有了 a。接著當再次嘗試使用 a 創建 c 時,這不被允許,因為 a 的所有權已經被移動。

可以改變 Cons 的定義來存放一個引用,不過接著必須指定生命週期參數。通過指定生命週期參數,表明列表中的每一個元素都至少與列表本身存在的一樣久。例如,借用檢查器不會允許 let a = Cons(10, &Nil); 編譯,因為臨時值 Nil 會在 a 獲取其引用之前就被丟棄了。

相反,我們修改 List 的定義為使用 Rc<T> 代替 Box<T>,如列表 15-18 所示。現在每一個 Cons 變數都包含一個值和一個指向 ListRc<T>。當創建 b 時,不同於獲取 a 的所有權,這裡會複製 a 所包含的 Rc<List>,這會將引用計數從 1 增加到 2 並允許 ab 共享 Rc<List> 中數據的所有權。創建 c 時也會複製 a,這會將引用計數從 2 增加為 3。每次調用 Rc::cloneRc<List> 中數據的引用計數都會增加,直到有零個引用之前其數據都不會被清理。

檔案名: src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

範例 15-18: 使用 Rc<T> 定義的 List

需要使用 use 語句將 Rc<T> 引入作用域,因為它不在 prelude 中。在 main 中創建了存放 5 和 10 的列表並將其存放在 a 的新的 Rc<List> 中。接著當創建 bc 時,調用 Rc::clone 函數並傳遞 aRc<List> 的引用作為參數。

也可以調用 a.clone() 而不是 Rc::clone(&a),不過在這裡 Rust 的習慣是使用 Rc::cloneRc::clone 的實現並不像大部分類型的 clone 實現那樣對所有數據進行深拷貝。Rc::clone 只會增加引用計數,這並不會花費多少時間。深拷貝可能會花費很長時間。透過使用 Rc::clone 進行引用計數,可以明顯的區別深拷貝類的複製和增加引用計數類的複製體。當查找代碼中的性能問題時,只需考慮深拷貝類的複製而無需考慮 Rc::clone 調用。

複製 Rc<T> 會增加引用計數

讓我們修改範例 15-18 的代碼以便觀察創建和丟棄 aRc<List> 的引用時引用計數的變化。

在範例 15-19 中,修改了 main 以便將列表 c 置於內部作用域中,這樣就可以觀察當 c 離開作用域時引用計數如何變化。

檔案名: src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

範例 15-19:列印出引用計數

在程序中每個引用計數變化的點,會列印出引用計數,其值可以透過調用 Rc::strong_count 函數獲得。這個函數叫做 strong_count 而不是 count 是因為 Rc<T> 也有 weak_count;在 “避免引用循環:將 Rc<T> 變為 Weak<T> 部分會講解 weak_count 的用途。

這段代碼會列印出:

count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

我們能夠看到 aRc<List> 的初始引用計數為1,接著每次調用 clone,計數會增加1。當 c 離開作用域時,計數減1。不必像調用 Rc::clone 增加引用計數那樣調用一個函數來減少計數;Drop trait 的實現當 Rc<T> 值離開作用域時自動減少引用計數。

從這個例子我們所不能看到的是,在 main 的結尾當 b 然後是 a 離開作用域時,此處計數會是 0,同時 Rc<List> 被完全清理。使用 Rc<T> 允許一個值有多個所有者,引用計數則確保只要任何所有者依然存在其值也保持有效。

透過不可變引用, Rc<T> 允許在程序的多個部分之間只讀地共享數據。如果 Rc<T> 也允許多個可變引用,則會違反第四章討論的借用規則之一:相同位置的多個可變借用可能造成數據競爭和不一致。不過可以修改數據是非常有用的!在下一部分,我們將討論內部可變性模式和 RefCell<T> 類型,它可以與 Rc<T> 結合使用來處理不可變性的限制。

RefCell<T> 和內部可變性模式

ch15-05-interior-mutability.md
commit 26565efc3f62d9dacb7c2c6d0f5974360e459493

內部可變性Interior mutability)是 Rust 中的一個設計模式,它允許你即使在有不可變引用時也可以改變數據,這通常是借用規則所不允許的。為了改變數據,該模式在數據結構中使用 unsafe 代碼來模糊 Rust 通常的可變性和借用規則。我們還未講到不安全代碼;第十九章會學習它們。當可以確保代碼在運行時會遵守借用規則,即使編譯器不能保證的情況,可以選擇使用那些運用內部可變性模式的類型。所涉及的 unsafe 代碼將被封裝進安全的 API 中,而外部類型仍然是不可變的。

讓我們通過遵循內部可變性模式的 RefCell<T> 類型來開始探索。

通過 RefCell<T> 在運行時檢查借用規則

不同於 Rc<T>RefCell<T> 代表其數據的唯一的所有權。那麼是什麼讓 RefCell<T> 不同於像 Box<T> 這樣的類型呢?回憶一下第四章所學的借用規則:

  1. 在任意給定時刻,只能擁有一個可變引用或任意數量的不可變引用 之一(而不是兩者)。
  2. 引用必須總是有效的。

對於引用和 Box<T>,借用規則的不可變性作用於編譯時。對於 RefCell<T>,這些不可變性作用於 運行時。對於引用,如果違反這些規則,會得到一個編譯錯誤。而對於 RefCell<T>,如果違反這些規則程序會 panic 並退出。

在編譯時檢查借用規則的優勢是這些錯誤將在開發過程的早期被捕獲,同時對運行時沒有性能影響,因為所有的分析都提前完成了。為此,在編譯時檢查借用規則是大部分情況的最佳選擇,這也正是其為何是 Rust 的默認行為。

相反在運行時檢查借用規則的好處則是允許出現特定記憶體安全的場景,而它們在編譯時檢查中是不允許的。靜態分析,正如 Rust 編譯器,是天生保守的。但代碼的一些屬性不可能通過分析代碼發現:其中最著名的就是 停機問題(Halting Problem),這超出了本書的範疇,不過如果你感興趣的話這是一個值得研究的有趣主題。

因為一些分析是不可能的,如果 Rust 編譯器不能通過所有權規則編譯,它可能會拒絕一個正確的程序;從這種角度考慮它是保守的。如果 Rust 接受不正確的程序,那麼用戶也就不會相信 Rust 所做的保證了。然而,如果 Rust 拒絕正確的程序,雖然會給程式設計師帶來不便,但不會帶來災難。RefCell<T> 正是用於當你確信代碼遵守借用規則,而編譯器不能理解和確定的時候。

類似於 Rc<T>RefCell<T> 只能用於單執行緒場景。如果嘗試在多執行緒上下文中使用RefCell<T>,會得到一個編譯錯誤。第十六章會介紹如何在多執行緒程序中使用 RefCell<T> 的功能。

如下為選擇 Box<T>Rc<T>RefCell<T> 的理由:

  • Rc<T> 允許相同數據有多個所有者;Box<T>RefCell<T> 有單一所有者。
  • Box<T> 允許在編譯時執行不可變或可變借用檢查;Rc<T>僅允許在編譯時執行不可變借用檢查;RefCell<T> 允許在運行時執行不可變或可變借用檢查。
  • 因為 RefCell<T> 允許在運行時執行可變借用檢查,所以我們可以在即便 RefCell<T> 自身是不可變的情況下修改其內部的值。

在不可變值內部改變值就是 內部可變性 模式。讓我們看看何時內部可變性是有用的,並討論這是如何成為可能的。

內部可變性:不可變值的可變借用

借用規則的一個推論是當有一個不可變值時,不能可變地借用它。例如,如下代碼不能編譯:

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

如果嘗試編譯,會得到如下錯誤:

error[E0596]: cannot borrow immutable local variable `x` as mutable
 --> src/main.rs:3:18
  |
2 |     let x = 5;
  |         - consider changing this to `mut x`
3 |     let y = &mut x;
  |                  ^ cannot borrow mutably

然而,特定情況下,令一個值在其方法內部能夠修改自身,而在其他代碼中仍視為不可變,是很有用的。值方法外部的代碼就不能修改其值了。RefCell<T> 是一個獲得內部可變性的方法。RefCell<T> 並沒有完全繞開借用規則,編譯器中的借用檢查器允許內部可變性並相應地在運行時檢查借用規則。如果違反了這些規則,會出現 panic 而不是編譯錯誤。

讓我們透過一個實際的例子來探索何處可以使用 RefCell<T> 來修改不可變值並看看為何這麼做是有意義的。

內部可變性的用例:mock 對象

測試替身test double)是一個通用編程概念,它代表一個在測試中替代某個類型的類型。mock 對象 是特定類型的測試替身,它們記錄測試過程中發生了什麼以便可以斷言操作是正確的。

雖然 Rust 中的對象與其他語言中的對象並不是一回事,Rust 也沒有像其他語言那樣在標準庫中內建 mock 對象功能,不過我們確實可以創建一個與 mock 對象有著相同功能的結構體。

如下是一個我們想要測試的場景:我們在編寫一個紀錄某個值與最大值的差距的庫,並根據當前值與最大值的差距來發送消息。例如,這個庫可以用於記錄用戶所允許的 API 調用數量限額。

該庫只提供記錄與最大值的差距,以及何種情況發送什麼消息的功能。使用此庫的程序則期望提供實際發送消息的機制:程序可以選擇記錄一條消息、發送 email、發送簡訊等等。庫本身無需知道這些細節;只需實現其提供的 Messenger trait 即可。範例 15-20 展示了庫代碼:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
    where T: Messenger {
    pub fn new(messenger: &T, max: usize) -> LimitTracker<T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
             self.messenger.send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger.send("Warning: You've used up over 75% of your quota!");
        }
    }
}
}

範例 15-20:一個紀錄某個值與最大值差距的庫,並根據此值的特定級別發出警告

這些程式碼中一個重要部分是擁有一個方法 sendMessenger trait,其獲取一個 self 的不可變引用和文本訊息。這是我們的 mock 對象所需要擁有的介面。另一個重要的部分是我們需要測試 LimitTrackerset_value 方法的行為。可以改變傳遞的 value 參數的值,不過 set_value 並沒有返回任何可供斷言的值。也就是說,如果使用某個實現了 Messenger trait 的值和特定的 max 創建 LimitTracker,當傳遞不同 value 值時,消息發送者應被告知發送合適的消息。

我們所需的 mock 對象是,調用 send 並不實際發送 email 或消息,而是只記錄訊息被通知要發送了。可以新建一個 mock 對象範例,用其創建 LimitTracker,調用 LimitTrackerset_value 方法,然後檢查 mock 對象是否有我們期望的消息。範例 15-21 展示了一個如此嘗試的 mock 對象實現,不過借用檢查器並不允許:

檔案名: src/lib.rs

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

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger { sent_messages: vec![] }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}

範例 15-21:嘗試實現 MockMessenger,借用檢查器不允許這麼做

測試代碼定義了一個 MockMessenger 結構體,其 sent_messages 欄位為一個 String 值的 Vec 用來記錄被告知發送的消息。我們還定義了一個關聯函數 new 以便於新建從空消息列表開始的 MockMessenger 值。接著為 MockMessenger 實現 Messenger trait 這樣就可以為 LimitTracker 提供一個 MockMessenger。在 send 方法的定義中,獲取傳入的消息作為參數並儲存在 MockMessengersent_messages 列表中。

在測試中,我們測試了當 LimitTracker 被告知將 value 設置為超過 max 值 75% 的某個值。首先新建一個 MockMessenger,其從空消息列表開始。接著新建一個 LimitTracker 並傳遞新建 MockMessenger 的引用和 max 值 100。我們使用值 80 調用 LimitTrackerset_value 方法,這超過了 100 的 75%。接著斷言 MockMessenger 中記錄的消息列表應該有一條消息。

然而,這個測試是有問題的:

error[E0596]: cannot borrow immutable field `self.sent_messages` as mutable
  --> src/lib.rs:52:13
   |
51 |         fn send(&self, message: &str) {
   |                 ----- use `&mut self` here to make mutable
52 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ cannot mutably borrow immutable field

不能修改 MockMessenger 來記錄消息,因為 send 方法獲取了 self 的不可變引用。我們也不能參考錯誤文本的建議使用 &mut self 替代,因為這樣 send 的簽名就不符合 Messenger trait 定義中的簽名了(可以試著這麼改,看看會出現什麼錯誤訊息)。

這正是內部可變性的用武之地!我們將通過 RefCell 來儲存 sent_messages,然後 send 將能夠修改 sent_messages 並儲存消息。範例 15-22 展示了代碼:

檔案名: src/lib.rs

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
    where T: Messenger {
    pub fn new(messenger: &T, max: usize) -> LimitTracker<T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
             self.messenger.send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger.send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger { sent_messages: RefCell::new(vec![]) }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
        limit_tracker.set_value(75);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
fn main() {}

範例 15-22:使用 RefCell<T> 能夠在外部值被認為是不可變的情況下修改內部值

現在 sent_messages 欄位的類型是 RefCell<Vec<String>> 而不是 Vec<String>。在 new 函數中新建了一個 RefCell 範例替代空 vector。

對於 send 方法的實現,第一個參數仍為 self 的不可變借用,這是符合方法定義的。我們調用 self.sent_messagesRefCellborrow_mut 方法來獲取 RefCell 中值的可變引用,這是一個 vector。接著可以對 vector 的可變引用調用 push 以便記錄測試過程中看到的消息。

最後必須做出的修改位於斷言中:為了看到其內部 vector 中有多少個項,需要調用 RefCellborrow 以獲取 vector 的不可變引用。

現在我們見識了如何使用 RefCell<T>,讓我們研究一下它怎樣工作的!

RefCell<T> 在運行時記錄借用

當創建不可變和可變引用時,我們分別使用 &&mut 語法。對於 RefCell<T> 來說,則是 borrowborrow_mut 方法,這屬於 RefCell<T> 安全 API 的一部分。borrow 方法返回 Ref<T> 類型的智慧指針,borrow_mut 方法返回 RefMut 類型的智慧指針。這兩個類型都實現了 Deref,所以可以當作常規引用對待。

RefCell<T> 記錄當前有多少個活動的 Ref<T>RefMut<T> 智慧指針。每次調用 borrowRefCell<T> 將活動的不可變借用計數加一。當 Ref<T> 值離開作用域時,不可變借用計數減一。就像編譯時借用規則一樣,RefCell<T> 在任何時候只允許有多個不可變借用或一個可變借用。

如果我們嘗試違反這些規則,相比引用時的編譯時錯誤,RefCell<T> 的實現會在運行時出現 panic。範例 15-23 展示了對範例 15-22 中 send 實現的修改,這裡我們故意嘗試在相同作用域創建兩個可變借用以便示範 RefCell<T> 不允許我們在運行時這麼做:

檔案名: src/lib.rs

impl Messenger for MockMessenger {
    fn send(&self, message: &str) {
        let mut one_borrow = self.sent_messages.borrow_mut();
        let mut two_borrow = self.sent_messages.borrow_mut();

        one_borrow.push(String::from(message));
        two_borrow.push(String::from(message));
    }
}

範例 15-23:在同一作用域中創建兩個可變引用並觀察 RefCell<T> panic

這裡為 borrow_mut 返回的 RefMut 智慧指針創建了 one_borrow 變數。接著用相同的方式在變數 two_borrow 創建了另一個可變借用。這會在相同作用域中創建兩個可變引用,這是不允許的。當運行庫的測試時,範例 15-23 編譯時不會有任何錯誤,不過測試會失敗:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----
    thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at
'already borrowed: BorrowMutError', src/libcore/result.rs:906:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.

注意代碼 panic 和訊息 already borrowed: BorrowMutError。這也就是 RefCell<T> 如何在運行時處理違反借用規則的情況。

在運行時捕獲借用錯誤而不是編譯時意味著將會在開發過程的後期才會發現錯誤,甚至有可能發布到生產環境才發現;還會因為在運行時而不是編譯時記錄借用而導致少量的運行時性能懲罰。然而,使用 RefCell 使得在只允許不可變值的上下文中編寫修改自身以記錄消息的 mock 對象成為可能。雖然有取捨,但是我們可以選擇使用 RefCell<T> 來獲得比常規引用所能提供的更多的功能。

結合 Rc<T>RefCell<T> 來擁有多個可變數據所有者

RefCell<T> 的一個常見用法是與 Rc<T> 結合。回憶一下 Rc<T> 允許對相同數據有多個所有者,不過只能提供數據的不可變訪問。如果有一個儲存了 RefCell<T>Rc<T> 的話,就可以得到有多個所有者 並且 可以修改的值了!

例如,回憶範例 15-18 的 cons list 的例子中使用 Rc<T> 使得多個列表共享另一個列表的所有權。因為 Rc<T> 只存放不可變值,所以一旦創建了這些列表值後就不能修改。讓我們加入 RefCell<T> 來獲得修改列表中值的能力。範例 15-24 展示了通過在 Cons 定義中使用 RefCell<T>,我們就允許修改所有列表中的值了:

檔案名: src/main.rs

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(6)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(10)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {:?}", a);
    println!("b after = {:?}", b);
    println!("c after = {:?}", c);
}

範例 15-24:使用 Rc<RefCell<i32>> 創建可以修改的 List

這裡創建了一個 Rc<RefCell<i32>> 實例並儲存在變數 value 中以便之後直接訪問。接著在 a 中用包含 valueCons 成員創建了一個 List。需要複製 value 以便 avalue 都能擁有其內部值 5 的所有權,而不是將所有權從 value 移動到 a 或者讓 a 借用 value

我們將列表 a 封裝進了 Rc<T> 這樣當創建列表 bc 時,他們都可以引用 a,正如範例 15-18 一樣。

一旦創建了列表 abc,我們將 value 的值加 10。為此對 value 調用了 borrow_mut,這裡使用了第五章討論的自動解引用功能(-> 運算符到哪去了?” 部分)來解引用 Rc<T> 以獲取其內部的 RefCell<T> 值。borrow_mut 方法返回 RefMut<T> 智慧指針,可以對其使用解引用運算符並修改其內部值。

當我們列印出 abc 時,可以看到他們都擁有修改後的值 15 而不是 5:

a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 6 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 10 }, Cons(RefCell { value: 15 }, Nil))

這是非常巧妙的!透過使用 RefCell<T>,我們可以擁有一個表面上不可變的 List,不過可以使用 RefCell<T> 中提供內部可變性的方法來在需要時修改數據。RefCell<T> 的運行時借用規則檢查也確實保護我們免於出現數據競爭——有時為了數據結構的靈活性而付出一些性能是值得的。

標準庫中也有其他提供內部可變性的類型,比如 Cell<T>,它類似 RefCell<T> 但有一點除外:它並非提供內部值的引用,而是把值拷貝進和拷貝出 Cell<T>。還有 Mutex<T>,其提供執行緒間安全的內部可變性,我們將在第 16 章中討論其用法。請查看標準庫來獲取更多細節關於這些不同類型之間的區別。

引用循環與記憶體洩漏

ch15-06-reference-cycles.md
commit f617d58c1a88dd2912739a041fd4725d127bf9fb

Rust 的記憶體安全性保證使其難以意外地製造永遠也不會被清理的記憶體(被稱為 記憶體洩漏memory leak)),但並不是不可能。與在編譯時拒絕數據競爭不同, Rust 並不保證完全地避免記憶體洩漏,這意味著記憶體洩漏在 Rust 被認為是記憶體安全的。這一點可以通過 Rc<T>RefCell<T> 看出:創建引用循環的可能性是存在的。這會造成記憶體洩漏,因為每一項的引用計數永遠也到不了 0,其值也永遠不會被丟棄。

製造引用循環

讓我們看看引用循環是如何發生的以及如何避免它。以範例 15-25 中的 List 枚舉和 tail 方法的定義開始:

檔案名: src/main.rs

fn main() {}
use std::rc::Rc;
use std::cell::RefCell;
use crate::List::{Cons, Nil};

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

範例 15-25: 一個存放 RefCell 的 cons list 定義,這樣可以修改 Cons 成員所引用的數據

這裡採用了範例 15-25 中 List 定義的另一種變體。現在 Cons 成員的第二個元素是 RefCell<Rc<List>>,這意味著不同於像範例 15-24 那樣能夠修改 i32 的值,我們希望能夠修改 Cons 成員所指向的 List。這裡還增加了一個 tail 方法來方便我們在有 Cons 成員的時候訪問其第二項。

在範例 15-26 中增加了一個 main 函數,其使用了範例 15-25 中的定義。這些程式碼在 a 中創建了一個列表,一個指向 a 中列表的 b 列表,接著修改 a 中的列表指向 b 中的列表,這會創建一個引用循環。在這個過程的多個位置有 println! 語句展示引用計數。

Filename: src/main.rs

use crate::List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack
    // println!("a next item = {:?}", a.tail());
}

範例 15-26:創建一個引用循環:兩個 List 值互相指向彼此

這裡在變數 a 中創建了一個 Rc<List> 實例來存放初值為 5, NilList 值。接著在變數 b 中創建了存放包含值 10 和指向列表 aList 的另一個 Rc<List> 實例。

最後,修改 a 使其指向 b 而不是 Nil,這就創建了一個循環。為此需要使用 tail 方法獲取 aRefCell<Rc<List>> 的引用,並放入變數 link 中。接著使用 RefCell<Rc<List>>borrow_mut 方法將其值從存放 NilRc<List> 修改為 b 中的 Rc<List>

如果保持最後的 println! 行注釋並運行程式碼,會得到如下輸出:

a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

可以看到將 a 修改為指向 b 之後,ab 中都有的 Rc<List> 實例的引用計數為 2。在 main 的結尾,Rust 會嘗試首先丟棄 b,這會使 abRc<List> 實例的引用計數減 1。

然而,因為 a 仍然引用 b 中的 Rc<List>Rc<List> 的引用計數是 1 而不是 0,所以 Rc<List> 在堆上的記憶體不會被丟棄。其記憶體會因為引用計數為 1 而永遠停留。為了更形象的展示,我們創建了一個如圖 15-4 所示的引用循環:

Reference cycle of lists

圖 15-4: 列表 ab 彼此互相指向形成引用循環

如果取消最後 println! 的注釋並運行程序,Rust 會嘗試列印出 a 指向 b 指向 a 這樣的循環直到棧溢出。

這個特定的例子中,創建了引用循環之後程序立刻就結束了。這個循環的結果並不可怕。如果在更為複雜的程序中並在循環裡分配了很多記憶體並佔有很長時間,這個程序會使用多於它所需要的記憶體,並有可能壓垮系統並造成沒有記憶體可供使用。

創建引用循環並不容易,但也不是不可能。如果你有包含 Rc<T>RefCell<T> 值或類似的嵌套結合了內部可變性和引用計數的類型,請務必小心確保你沒有形成一個引用循環;你無法指望 Rust 幫你捕獲它們。創建引用循環是一個程序上的邏輯 bug,你應該使用自動化測試、代碼評審和其他軟體開發最佳實踐來使其最小化。

另一個解決方案是重新組織數據結構,使得一部分引用擁有所有權而另一部分沒有。換句話說,循環將由一些擁有所有權的關係和一些無所有權的關係組成,只有所有權關係才能影響值是否可以被丟棄。在範例 15-25 中,我們總是希望 Cons 成員擁有其列表,所以重新組織數據結構是不可能的。讓我們看看一個由父節點和子節點構成的圖的例子,觀察何時是使用無所有權的關係來避免引用循環的合適時機。

避免引用循環:將 Rc<T> 變為 Weak<T>

到目前為止,我們已經展示了調用 Rc::clone 會增加 Rc<T> 實例的 strong_count,和只在其 strong_count 為 0 時才會被清理的 Rc<T> 實例。你也可以透過調用 Rc::downgrade 並傳遞 Rc<T> 實例的引用來創建其值的 弱引用weak reference)。調用 Rc::downgrade 時會得到 Weak<T> 類型的智慧指針。不同於將 Rc<T> 實例的 strong_count 加1,調用 Rc::downgrade 會將 weak_count 加1。Rc<T> 類型使用 weak_count 來記錄其存在多少個 Weak<T> 引用,類似於 strong_count。其區別在於 weak_count 無需計數為 0 就能使 Rc<T> 實例被清理。

強引用代表如何共享 Rc<T> 實例的所有權,但弱引用並不屬於所有權關係。他們不會造成引用循環,因為任何弱引用的循環會在其相關的強引用計數為 0 時被打斷。

因為 Weak<T> 引用的值可能已經被丟棄了,為了使用 Weak<T> 所指向的值,我們必須確保其值仍然有效。為此可以調用 Weak<T> 實例的 upgrade 方法,這會返回 Option<Rc<T>>。如果 Rc<T> 值還未被丟棄,則結果是 Some;如果 Rc<T> 已被丟棄,則結果是 None。因為 upgrade 返回一個 Option<T>,我們確信 Rust 會處理 SomeNone 的情況,所以它不會返回非法指針。

我們會創建一個某項知道其子項父項的樹形結構的例子,而不是只知道其下一項的列表。

創建樹形數據結構:帶有子節點的 Node

在最開始,我們將會構建一個帶有子節點的樹。讓我們創建一個用於存放其擁有所有權的 i32 值和其子節點引用的 Node

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}
}

我們希望能夠 Node 擁有其子節點,同時也希望透過變數來共享所有權,以便可以直接訪問樹中的每一個 Node,為此 Vec<T> 的項的類型被定義為 Rc<Node>。我們還希望能修改其他節點的子節點,所以 childrenVec<Rc<Node>> 被放進了 RefCell<T>

接下來,使用此結構體定義來創建一個叫做 leaf 的帶有值 3 且沒有子節點的 Node 實例,和另一個帶有值 5 並以 leaf 作為子節點的實例 branch,如範例 15-27 所示:

檔案名: src/main.rs

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
   children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

範例 15-27:創建沒有子節點的 leaf 節點和以 leaf 作為子節點的 branch 節點

這裡複製了 leaf 中的 Rc<Node> 並儲存在了 branch 中,這意味著 leaf 中的 Node 現在有兩個所有者:leafbranch。可以通過 branch.childrenbranch 中獲得 leaf,不過無法從 leafbranchleaf 沒有到 branch 的引用且並不知道他們相互關聯。我們希望 leaf 知道 branch 是其父節點。稍後我們會這麼做。

增加從子到父的引用

為了使子節點知道其父節點,需要在 Node 結構體定義中增加一個 parent 欄位。問題是 parent 的類型應該是什麼。我們知道其不能包含 Rc<T>,因為這樣 leaf.parent 將會指向 branchbranch.children 會包含 leaf 的指針,這會形成引用循環,會造成其 strong_count 永遠也不會為 0.

現在換一種方式思考這個關係,父節點應該擁有其子節點:如果父節點被丟棄了,其子節點也應該被丟棄。然而子節點不應該擁有其父節點:如果丟棄子節點,其父節點應該依然存在。這正是弱引用的例子!

所以 parent 使用 Weak<T> 類型而不是 Rc<T>,具體來說是 RefCell<Weak<Node>>。現在 Node 結構體定義看起來像這樣:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}
}

這樣,一個節點就能夠引用其父節點,但不擁有其父節點。在範例 15-28 中,我們更新 main 來使用新定義以便 leaf 節點可以通過 branch 引用其父節點:

檔案名: src/main.rs

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

範例 15-28:一個 leaf 節點,其擁有指向其父節點 branchWeak 引用

創建 leaf 節點類似於範例 15-27 中如何創建 leaf 節點的,除了 parent 欄位有所不同:leaf 開始時沒有父節點,所以我們新建了一個空的 Weak 引用實例。

此時,當嘗試使用 upgrade 方法獲取 leaf 的父節點引用時,會得到一個 None 值。如第一個 println! 輸出所示:

leaf parent = None

當創建 branch 節點時,其也會新建一個 Weak<Node> 引用,因為 branch 並沒有父節點。leaf 仍然作為 branch 的一個子節點。一旦在 branch 中有了 Node 實例,就可以修改 leaf 使其擁有指向父節點的 Weak<Node> 引用。這裡使用了 leafparent 欄位裡的 RefCell<Weak<Node>>borrow_mut 方法,接著使用了 Rc::downgrade 函數來從 branch 中的 Rc<Node> 值創建了一個指向 branchWeak<Node> 引用。

當再次列印出 leaf 的父節點時,這一次將會得到存放了 branchSome 值:現在 leaf 可以訪問其父節點了!當列印出 leaf 時,我們也避免了如範例 15-26 中最終會導致棧溢出的循環:Weak<Node> 引用被列印為 (Weak)

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

沒有無限的輸出表明這段代碼並沒有造成引用循環。這一點也可以從觀察 Rc::strong_countRc::weak_count 調用的結果看出。

可視化 strong_countweak_count 的改變

讓我們透過創建了一個新的內部作用域並將 branch 的創建放入其中,來觀察 Rc<Node> 實例的 strong_countweak_count 值的變化。這會展示當 branch 創建和離開作用域被丟棄時會發生什麼事。這些修改如範例 15-29 所示:

檔案名: src/main.rs

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

範例 15-29:在內部作用域創建 branch 並檢查其強弱引用計數

一旦創建了 leaf,其 Rc<Node> 的強引用計數為 1,弱引用計數為 0。在內部作用域中創建了 branch 並與 leaf 相關聯,此時 branchRc<Node> 的強引用計數為 1,弱引用計數為 1(因為 leaf.parent 通過 Weak<Node> 指向 branch)。這裡 leaf 的強引用計數為 2,因為現在 branchbranch.children 中儲存了 leafRc<Node> 的拷貝,不過弱引用計數仍然為 0。

當內部作用域結束時,branch 離開作用域,Rc<Node> 的強引用計數減少為 0,所以其 Node 被丟棄。來自 leaf.parent 的弱引用計數 1 與 Node 是否被丟棄無關,所以並沒有產生任何記憶體洩漏!

如果在內部作用域結束後嘗試訪問 leaf 的父節點,會再次得到 None。在程序的結尾,leafRc<Node> 的強引用計數為 1,弱引用計數為 0,因為現在 leaf 又是 Rc<Node> 唯一的引用了。

所有這些管理計數和值的邏輯都內建於 Rc<T>Weak<T> 以及它們的 Drop trait 實現中。通過在 Node 定義中指定從子節點到父節點的關係為一個Weak<T>引用,就能夠擁有父節點和子節點之間的雙向引用而不會造成引用循環和記憶體洩漏。

總結

這一章涵蓋了如何使用智慧指針來做出不同於 Rust 常規引用默認所提供的保證與取捨。Box<T> 有一個已知的大小並指向分配在堆上的數據。Rc<T> 記錄了堆上數據的引用數量以便可以擁有多個所有者。RefCell<T> 和其內部可變性提供了一個可以用於當需要不可變類型但是需要改變其內部值能力的類型,並在運行時而不是編譯時檢查借用規則。

我們還介紹了提供了很多智慧指針功能的 trait DerefDrop。同時探索了會造成記憶體洩漏的引用循環,以及如何使用 Weak<T> 來避免它們。

如果本章內容引起了你的興趣並希望現在就實現你自己的智慧指針的話,請閱讀 “The Rustonomicon” 來獲取更多有用的訊息。

接下來,讓我們談談 Rust 的並發。屆時甚至還會學習到一些新的對並發有幫助的智慧指針。

無畏並發

ch16-00-concurrency.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

安全且高效的處理並發編程是 Rust 的另一個主要目標。並發編程Concurrent programming),代表程序的不同部分相互獨立的執行,而 並行編程parallel programming)代表程序不同部分於同時執行,這兩個概念隨著計算機越來越多的利用多處理器的優勢時顯得越來越重要。由於歷史原因,在此類上下文中編程一直是困難且容易出錯的:Rust 希望能改變這一點。

起初,Rust 團隊認為確保記憶體安全和防止並發問題是兩個分別需要不同方法應對的挑戰。隨著時間的推移,團隊發現所有權和類型系統是一系列解決記憶體安全 並發問題的強有力的工具!透過利用所有權和類型檢查,在 Rust 中很多並發錯誤都是 編譯時 錯誤,而非運行時錯誤。因此,相比花費大量時間嘗試重現運行時並發 bug 出現的特定情況,Rust 會拒絕編譯不正確的代碼並提供解釋問題的錯誤訊息。因此,你可以在開發時修復代碼,而不是在部署到生產環境後修復代碼。我們給 Rust 的這一部分取了一個綽號 無畏並發fearless concurrency)。無畏並發令你的代碼免於出現詭異的 bug 並可以輕鬆重構且無需擔心會引入新的 bug。

注意:出於簡潔的考慮,我們將很多問題歸類為 並發,而不是更準確的區分 並發和(或)並行。如果這是一本專注於並發和/或並行的書,我們肯定會更加精確的。對於本章,當我們談到 並發 時,請自行腦內替換為 並發和(或)並行

很多語言所提供的處理並發問題的解決方法都非常有特色。例如,Erlang 有著優雅的消息傳遞並發功能,但只有模糊不清的在執行緒間共享狀態的方法。對於高級語言來說,只實現可能解決方案的子集是一個合理的策略,因為高級語言所許諾的價值來源於犧牲一些控制來換取抽象。然而對於底層語言則期望提供在任何給定的情況下有著最高的性能且對硬體有更少的抽象。因此,Rust 提供了多種工具,以符合實際情況和需求的方式來為問題建模。

如下是本章將要涉及到的內容:

  • 如何創建執行緒來同時執行多段代碼。
  • 消息傳遞Message passing)並發,其中通道(channel)被用來在執行緒間傳遞消息。
  • 共享狀態Shared state)並發,其中多個執行緒可以訪問同一片數據。
  • SyncSend trait,將 Rust 的並發保證擴展到用戶定義的以及標準庫提供的類型中。

使用執行緒同時執行程式碼

ch16-01-threads.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

在大部分現代操作系統中,已執行程序的代碼在一個 進程process)中運行,操作系統則負責管理多個進程。在程序內部,也可以擁有多個同時執行的獨立部分。運行這些獨立部分的功能被稱為 執行緒threads)。

將程序中的計算拆分進多個執行緒可以改善性能,因為程序可以同時進行多個任務,不過這也會增加複雜性。因為執行緒是同時執行的,所以無法預先保證不同執行緒中的代碼的執行順序。這會導致諸如此類的問題:

  • 競爭狀態(Race conditions),多個執行緒以不一致的順序訪問數據或資源
  • 死鎖(Deadlocks),兩個執行緒相互等待對方停止使用其所擁有的資源,這會阻止它們繼續運行
  • 只會發生在特定情況且難以穩定重現和修復的 bug

Rust 嘗試減輕使用執行緒的負面影響。不過在多執行緒上下文中編程仍需格外小心,同時其所要求的代碼結構也不同於運行於單執行緒的程序。

程式語言有一些不同的方法來實現執行緒。很多操作系統提供了創建新執行緒的 API。這種由程式語言調用操作系統 API 創建執行緒的模型有時被稱為 1:1,一個 OS 執行緒對應一個語言執行緒。

很多程式語言提供了自己特殊的執行緒實現。程式語言提供的執行緒被稱為 綠色green)執行緒,使用綠色執行緒的語言會在不同數量的 OS 執行緒的上下文中執行它們。為此,綠色執行緒模式被稱為 M:N 模型:M 個綠色執行緒對應 N 個 OS 執行緒,這裡 MN 不必相同。

每一個模型都有其優勢和取捨。對於 Rust 來說最重要的取捨是運行時支持。運行時Runtime)是一個令人迷惑的概念,其在不同上下文中可能有不同的含義。

在當前上下文中,運行時 代表二進位制文件中包含的由語言自身提供的代碼。這些程式碼根據語言的不同可大可小,不過任何非匯編語言都會有一定數量的運行時代碼。為此,通常人們說一個語言 “沒有運行時”,一般意味著 “小運行時”。更小的運行時擁有更少的功能不過其優勢在於更小的二進位制輸出,這使其易於在更多上下文中與其他語言相結合。雖然很多語言覺得增加運行時來換取更多功能沒有什麼問題,但是 Rust 需要做到幾乎沒有運行時,同時為了保持高性能必須能夠調用 C 語言,這點也是不能妥協的。

綠色執行緒的 M:N 模型需要更大的語言運行時來管理這些執行緒。因此,Rust 標準庫只提供了 1:1 執行緒模型實現。由於 Rust 是較為底層的語言,如果你願意犧牲性能來換取抽象,以獲得對執行緒運行更精細的控制及更低的上下文切換成本,你可以使用實現了 M:N 執行緒模型的 crate。

現在我們明白了 Rust 中的執行緒是如何定義的,讓我們開始探索如何使用標準庫提供的執行緒相關的 API 吧。

使用 spawn 創建新執行緒

為了創建一個新執行緒,需要調用 thread::spawn 函數並傳遞一個閉包(第十三章學習了閉包),並在其中包含希望在新執行緒運行的代碼。範例 16-1 中的例子在主執行緒列印了一些文本而另一些文本則由新執行緒列印:

檔案名: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

範例 16-1: 創建一個列印某些內容的新執行緒,但是主執行緒列印其它內容

注意這個函數編寫的方式,當主執行緒結束時,新執行緒也會結束,而不管其是否執行完畢。這個程序的輸出可能每次都略有不同,不過它大體上看起來像這樣:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

thread::sleep 調用強制執行緒停止執行一小段時間,這會允許其他不同的執行緒運行。這些執行緒可能會輪流運行,不過並不保證如此:這依賴操作系統如何調度執行緒。在這裡,主執行緒首先列印,即便新創建執行緒的列印語句位於程序的開頭,甚至即便我們告訴新建的執行緒列印直到 i 等於 9 ,它在主執行緒結束之前也只列印到了 5。

如果運行程式碼只看到了主執行緒的輸出,或沒有出現重疊列印的現象,嘗試增大區間 (變數 i 的範圍) 來增加操作系統切換執行緒的機會。

使用 join 等待所有執行緒結束

由於主執行緒結束,範例 16-1 中的代碼大部分時候不光會提早結束新建執行緒,甚至不能實際保證新建執行緒會被執行。其原因在於無法保證執行緒運行的順序!

可以透過將 thread::spawn 的返回值儲存在變數中來修復新建執行緒部分沒有執行或者完全沒有執行的問題。thread::spawn 的返回值類型是 JoinHandleJoinHandle 是一個擁有所有權的值,當對其調用 join 方法時,它會等待其執行緒結束。範例 16-2 展示了如何使用範例 16-1 中創建的執行緒的 JoinHandle 並調用 join 來確保新建執行緒在 main 退出前結束運行:

檔案名: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

範例 16-2: 從 thread::spawn 保存一個 JoinHandle 以確保該執行緒能夠運行至結束

透過調用 handle 的 join 會阻塞當前執行緒直到 handle 所代表的執行緒結束。阻塞Blocking) 執行緒意味著阻止該執行緒執行工作或退出。因為我們將 join 調用放在了主執行緒的 for 循環之後,運行範例 16-2 應該會產生類似這樣的輸出:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

這兩個執行緒仍然會交替執行,不過主執行緒會由於 handle.join() 調用會等待直到新建執行緒執行完畢。

不過讓我們看看將 handle.join() 移動到 mainfor 循環之前會發生什麼事,如下:

檔案名: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

主執行緒會等待直到新建執行緒執行完畢之後才開始執行 for 循環,所以輸出將不會交替出現,如下所示:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

諸如將 join 放置於何處這樣的小細節,會影響執行緒是否同時執行。

執行緒與 move 閉包

move 閉包,我們曾在第十三章簡要的提到過,其經常與 thread::spawn 一起使用,因為它允許我們在一個執行緒中使用另一個執行緒的數據。

在第十三章中,我們講到可以在參數列表前使用 move 關鍵字強制閉包獲取其使用的環境值的所有權。這個技巧在創建新執行緒將值的所有權從一個執行緒移動到另一個執行緒時最為實用。

注意範例 16-1 中傳遞給 thread::spawn 的閉包並沒有任何參數:並沒有在新建執行緒代碼中使用任何主執行緒的數據。為了在新建執行緒中使用來自於主執行緒的數據,需要新建執行緒的閉包獲取它需要的值。範例 16-3 展示了一個嘗試在主執行緒中創建一個 vector 並用於新建執行緒的例子,不過這麼寫還不能工作,如下所示:

檔案名: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

範例 16-3: 嘗試在另一個執行緒使用主執行緒創建的 vector

閉包使用了 v,所以閉包會捕獲 v 並使其成為閉包環境的一部分。因為 thread::spawn 在一個新執行緒中運行這個閉包,所以可以在新執行緒中訪問 v。然而當編譯這個例子時,會得到如下錯誤:

error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ^^^^^^^

Rust 會 推斷 如何捕獲 v,因為 println! 只需要 v 的引用,閉包嘗試借用 v。然而這有一個問題:Rust 不知道這個新建執行緒會執行多久,所以無法知曉 v 的引用是否一直有效。

範例 16-4 展示了一個 v 的引用很有可能不再有效的場景:

檔案名: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

範例 16-4: 一個具有閉包的執行緒,嘗試使用一個在主執行緒中被回收的引用 v

假如這段代碼能正常運行的話,則新建執行緒則可能會立刻被轉移到後台並完全沒有機會運行。新建執行緒內部有一個 v 的引用,不過主執行緒立刻就使用第十五章討論的 drop 丟棄了 v。接著當新建執行緒開始執行,v 已不再有效,所以其引用也是無效的。噢,這太糟了!

為了修復範例 16-3 的編譯錯誤,我們可以聽取錯誤訊息的建議:

help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ^^^^^^^

通過在閉包之前增加 move 關鍵字,我們強制閉包獲取其使用的值的所有權,而不是任由 Rust 推斷它應該借用值。範例 16-5 中展示的對範例 16-3 代碼的修改,可以按照我們的預期編譯並運行:

檔案名: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

範例 16-5: 使用 move 關鍵字強制獲取它使用的值的所有權

那麼如果使用了 move 閉包,範例 16-4 中主執行緒調用了 drop 的代碼會發生什麼事呢?加了 move 就搞定了嗎?不幸的是,我們會得到一個不同的錯誤,因為範例 16-4 所嘗試的操作由於一個不同的原因而不被允許。如果為閉包增加 move,將會把 v 移動進閉包的環境中,如此將不能在主執行緒中對其調用 drop 了。我們會得到如下不同的編譯錯誤:

error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
   = note: move occurs because `v` has type `std::vec::Vec<i32>`, which does
   not implement the `Copy` trait

Rust 的所有權規則又一次幫助了我們!範例 16-3 中的錯誤是因為 Rust 是保守的並只會為執行緒借用 v,這意味著主執行緒理論上可能使新建執行緒的引用無效。通過告訴 Rust 將 v 的所有權移動到新建執行緒,我們向 Rust 保證主執行緒不會再使用 v。如果對範例 16-4 也做出如此修改,那麼當在主執行緒中使用 v 時就會違反所有權規則。 move 關鍵字覆蓋了 Rust 默認保守的借用,但它不允許我們違反所有權規則。

現在我們對執行緒和執行緒 API 有了基本的了解,讓我們討論一下使用執行緒實際可以 什麼吧。

使用消息傳遞在執行緒間傳送數據

ch16-02-message-passing.md
commit 26565efc3f62d9dacb7c2c6d0f5974360e459493

一個日益流行的確保安全並發的方式是 消息傳遞message passing),這裡執行緒或 actor 透過發送包含數據的消息來相互溝通。這個思想來源於 Go 程式語言文件中 的口號:“不要透過共享記憶體來通訊;而是透過通訊來共享記憶體。”(“Do not communicate by sharing memory; instead, share memory by communicating.”)

Rust 中一個實現消息傳遞並發的主要工具是 通道channel),Rust 標準庫提供了其實現的程式概念。你可以將其想像為一個水流的通道,比如河流或小溪。如果你將諸如橡皮鴨或小船之類的東西放入其中,它們會順流而下到達下游。

編程中的通道有兩部分組成,一個發送者(transmitter)和一個接收者(receiver)。發送者位於上游位置,在這裡可以將橡皮鴨放入河中,接收者則位於下游,橡皮鴨最終會漂流至此。代碼中的一部分調用發送者的方法以及希望發送的數據,另一部分則檢查接收端收到的消息。當發送者或接收者任一被丟棄時可以認為通道被 關閉closed)了。

這裡,我們將開發一個程序,它會在一個執行緒生成值向通道發送,而在另一個執行緒會接收值並列印出來。這裡會透過通道在執行緒間發送簡單值來示範這個功能。一旦你熟悉了這項技術,就能使用通道來實現聊天系統,或利用很多執行緒進行分布式計算並將部分計算結果發送給一個執行緒進行聚合。

首先,在範例 16-6 中,創建了一個通道但沒有做任何事。注意這還不能編譯,因為 Rust 不知道我們想要在通道中發送什麼類型:

檔案名: src/main.rs

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

範例 16-6: 創建一個通道,並將其兩端賦值給 txrx

這裡使用 mpsc::channel 函數創建一個新的通道;mpsc多個生產者,單個消費者multiple producer, single consumer)的縮寫。簡而言之,Rust 標準庫實現通道的方式意味著一個通道可以有多個產生值的 發送sending)端,但只能有一個消費這些值的 接收receiving)端。想像一下多條小河小溪最終匯聚成大河:所有通過這些小河發出的東西最後都會來到下游的大河。目前我們以單個生產者開始,但是當範例可以工作後會增加多個生產者。

mpsc::channel 函數返回一個元組:第一個元素是發送端,而第二個元素是接收端。由於歷史原因,txrx 通常作為 發送者transmitter)和 接收者receiver)的縮寫,所以這就是我們將用來綁定這兩端變數的名字。這裡使用了一個 let 語句和模式來解構了此元組;第十八章會討論 let 語句中的模式和解構。如此使用 let 語句是一個方便提取 mpsc::channel 返回的元組中一部分的手段。

讓我們將發送端移動到一個新建執行緒中並發送一個字串,這樣新建執行緒就可以和主執行緒通訊了,如範例 16-7 所示。這類似於在河的上游扔下一隻橡皮鴨或從一個執行緒向另一個執行緒發送聊天訊息:

檔案名: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

範例 16-7: 將 tx 移動到一個新建的執行緒中並發送 “hi”

這裡再次使用 thread::spawn 來創建一個新執行緒並使用 movetx 移動到閉包中這樣新建執行緒就擁有 tx 了。新建執行緒需要擁有通道的發送端以便能向通道發送消息。

通道的發送端有一個 send 方法用來獲取需要放入通道的值。send 方法返回一個 Result<T, E> 類型,所以如果接收端已經被丟棄了,將沒有發送值的目標,所以發送操作會返回錯誤。在這個例子中,出錯的時候調用 unwrap 產生 panic。不過對於一個真實程序,需要合理地處理它:回到第九章複習正確處理錯誤的策略。

在範例 16-8 中,我們在主執行緒中從通道的接收端獲取值。這類似於在河的下游撈起橡皮鴨或接收聊天訊息:

檔案名: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

範例 16-8: 在主執行緒中接收並列印內容 “hi”

通道的接收端有兩個有用的方法:recvtry_recv。這裡,我們使用了 recv,它是 receive 的縮寫。這個方法會阻塞主執行緒執行直到從通道中接收一個值。一旦發送了一個值,recv 會在一個 Result<T, E> 中返回它。當通道發送端關閉,recv 會返回一個錯誤表明不會再有新的值到來了。

try_recv 不會阻塞,相反它立刻返回一個 Result<T, E>Ok 值包含可用的訊息,而 Err 值代表此時沒有任何消息。如果執行緒在等待消息過程中還有其他工作時使用 try_recv 很有用:可以編寫一個循環來頻繁調用 try_recv,在有可用消息時進行處理,其餘時候則處理一會其他工作直到再次檢查。

出於簡單的考慮,這個例子使用了 recv;主執行緒中除了等待消息之外沒有任何其他工作,所以阻塞主執行緒是合適的。

如果運行範例 16-8 中的代碼,我們將會看到主執行緒列印出這個值:

Got: hi

完美!

通道與所有權轉移

所有權規則在消息傳遞中扮演了重要角色,其有助於我們編寫安全的並發代碼。防止並發編程中的錯誤是在 Rust 程序中考慮所有權的一大優勢。現在讓我們做一個試驗來看看通道與所有權如何一同協作以避免產生問題:我們將嘗試在新建執行緒中的通道中發送完 val之後 再使用它。嘗試編譯範例 16-9 中的代碼並看看為何這是不允許的:

檔案名: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val);
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

範例 16-9: 在我們已經發送到通道中後,嘗試使用 val 引用

這裡嘗試在通過 tx.send 發送 val 到通道中之後將其列印出來。允許這麼做是一個壞主意:一旦將值發送到另一個執行緒後,那個執行緒可能會在我們再次使用它之前就將其修改或者丟棄。其他執行緒對值可能的修改會由於不一致或不存在的數據而導致錯誤或意外的結果。然而,嘗試編譯範例 16-9 的代碼時,Rust 會給出一個錯誤:

error[E0382]: use of moved value: `val`
  --> src/main.rs:10:31
   |
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {}", val);
   |                               ^^^ value used here after move
   |
   = note: move occurs because `val` has type `std::string::String`, which does
not implement the `Copy` trait

我們的並發錯誤會造成一個編譯時錯誤。send 函數獲取其參數的所有權並移動這個值歸接收者所有。這可以防止在發送後再次意外地使用這個值;所有權系統檢查一切是否合乎規則。

發送多個值並觀察接收者的等待

範例 16-8 中的代碼可以編譯和運行,不過它並沒有明確的告訴我們兩個獨立的執行緒通過通道相互通訊。範例 16-10 則有一些改進會證明範例 16-8 中的代碼是並發執行的:新建執行緒現在會發送多個消息並在每個消息之間暫停一秒鐘。

檔案名: src/main.rs

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

範例 16-10: 發送多個消息,並在每次發送後暫停一段時間

這一次,在新建執行緒中有一個字串 vector 希望發送到主執行緒。我們遍歷他們,單獨的發送每一個字串並通過一個 Duration 值調用 thread::sleep 函數來暫停一秒。

在主執行緒中,不再顯式調用 recv 函數:而是將 rx 當作一個疊代器。對於每一個接收到的值,我們將其列印出來。當通道被關閉時,疊代器也將結束。

當運行範例 16-10 中的代碼時,將看到如下輸出,每一行都會暫停一秒:

Got: hi
Got: from
Got: the
Got: thread

因為主執行緒中的 for 循環裡並沒有任何暫停或等待的代碼,所以可以說主執行緒是在等待從新建執行緒中接收值。

透過複製發送者來創建多個生產者

之前我們提到了mpscmultiple producer, single consumer 的縮寫。可以運用 mpsc 來擴展範例 16-10 中的代碼來創建向同一接收者發送值的多個執行緒。這可以透過複製通道的發送端來做到,如範例 16-11 所示:

檔案名: src/main.rs

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
// --snip--

let (tx, rx) = mpsc::channel();

let tx1 = mpsc::Sender::clone(&tx);
thread::spawn(move || {
    let vals = vec![
        String::from("hi"),
        String::from("from"),
        String::from("the"),
        String::from("thread"),
    ];

    for val in vals {
        tx1.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
});

thread::spawn(move || {
    let vals = vec![
        String::from("more"),
        String::from("messages"),
        String::from("for"),
        String::from("you"),
    ];

    for val in vals {
        tx.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
});

for received in rx {
    println!("Got: {}", received);
}

// --snip--
}

範例 16-11: 從多個生產者發送多個消息

這一次,在創建新執行緒之前,我們對通道的發送端調用了 clone 方法。這會給我們一個可以傳遞給第一個新建執行緒的發送端句柄。我們會將原始的通道發送端傳遞給第二個新建執行緒。這樣就會有兩個執行緒,每個執行緒將向通道的接收端發送不同的消息。

如果運行這些程式碼,你 可能 會看到這樣的輸出:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

雖然你可能會看到這些值以不同的順序出現;這依賴於你的系統。這也就是並發既有趣又困難的原因。如果通過 thread::sleep 做實驗,在不同的執行緒中提供不同的值,就會發現他們的運行更加不確定,且每次都會產生不同的輸出。

現在我們見識過了通道如何工作,再看看另一種不同的並發方式吧。

共享狀態並發

ch16-03-shared-state.md
commit ef072458f903775e91ea9e21356154bc57ee31da

雖然消息傳遞是一個很好的處理並發的方式,但並不是唯一一個。再一次思考一下 Go 程式語言文件中口號的這一部分:“不要透過共享記憶體來通訊”(“do not communicate by sharing memory.”):

What would communicating by sharing memory look like? In addition, why would message passing enthusiasts not use it and do the opposite instead?

透過共享記憶體通訊看起來如何?除此之外,為何消息傳遞的擁護者並不使用它並反其道而行之呢?

在某種程度上,任何程式語言中的通道都類似於單所有權,因為一旦將一個值傳送到通道中,將無法再使用這個值。共享記憶體類似於多所有權:多個執行緒可以同時訪問相同的記憶體位置。第十五章介紹了智慧指針如何使得多所有權成為可能,然而這會增加額外的複雜性,因為需要以某種方式管理這些不同的所有者。Rust 的類型系統和所有權規則極大的協助了正確地管理這些所有權。作為一個例子,讓我們看看互斥器,一個更為常見的共享記憶體並發原語。

互斥器一次只允許一個執行緒訪問數據

互斥器mutex)是 mutual exclusion 的縮寫,也就是說,任意時刻,其只允許一個執行緒訪問某些數據。為了訪問互斥器中的數據,執行緒首先需要通過獲取互斥器的 lock)來表明其希望訪問數據。鎖是一個作為互斥器一部分的數據結構,它記錄誰有數據的排他訪問權。因此,我們描述互斥器為通過鎖系統 保護guarding)其數據。

互斥器以難以使用著稱,因為你不得不記住:

  1. 在使用數據之前嘗試獲取鎖。
  2. 處理完被互斥器所保護的數據之後,必須解鎖數據,這樣其他執行緒才能夠獲取鎖。

作為一個現實中互斥器的例子,想像一下在某個會議的一次小組座談會中,只有一個麥克風。如果一位成員要發言,他必須請求或表示希望使用麥克風。一旦得到了麥克風,他可以暢所欲言,然後將麥克風交給下一位希望講話的成員。如果一位成員結束發言後忘記將麥克風交還,其他人將無法發言。如果對共享麥克風的管理出現了問題,座談會將無法如期進行!

正確的管理互斥器異常複雜,這也是許多人之所以熱衷於通道的原因。然而,在 Rust 中,得益於類型系統和所有權,我們不會在鎖和解鎖上出錯。

Mutex<T>的 API

作為展示如何使用互斥器的例子,讓我們從在單執行緒上下文使用互斥器開始,如範例 16-12 所示:

檔案名: src/main.rs

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {:?}", m);
}

範例 16-12: 出於簡單的考慮,在一個單執行緒上下文中探索 Mutex<T> 的 API

像很多類型一樣,我們使用關聯函數 new 來創建一個 Mutex<T>。使用 lock 方法獲取鎖,以訪問互斥器中的數據。這個調用會阻塞當前執行緒,直到我們擁有鎖為止。

如果另一個執行緒擁有鎖,並且那個執行緒 panic 了,則 lock 調用會失敗。在這種情況下,沒人能夠再獲取鎖,所以這裡選擇 unwrap 並在遇到這種情況時使執行緒 panic。

一旦獲取了鎖,就可以將返回值(在這裡是num)視為一個其內部數據的可變引用了。類型系統確保了我們在使用 m 中的值之前獲取鎖:Mutex<i32> 並不是一個 i32,所以 必須 獲取鎖才能使用這個 i32 值。我們是不會忘記這麼做的,因為反之類型系統不允許訪問內部的 i32 值。

正如你所懷疑的,Mutex<T> 是一個智慧指針。更準確的說,lock 調用 返回 一個叫做 MutexGuard 的智慧指針。這個智慧指針實現了 Deref 來指向其內部數據;其也提供了一個 Drop 實現當 MutexGuard 離開作用域時自動釋放鎖,這正發生於範例 16-12 內部作用域的結尾。為此,我們不會冒忘記釋放鎖並阻塞互斥器為其它執行緒所用的風險,因為鎖的釋放是自動發生的。

丟棄了鎖之後,可以列印出互斥器的值,並發現能夠將其內部的 i32 改為 6。

在執行緒間共享 Mutex<T>

現在讓我們嘗試使用 Mutex<T> 在多個執行緒間共享值。我們將啟動十個執行緒,並在各個執行緒中對同一個計數器值加一,這樣計數器將從 0 變為 10。範例 16-13 中的例子會出現編譯錯誤,而我們將透過這些錯誤來學習如何使用 Mutex<T>,以及 Rust 又是如何幫助我們正確使用的。

檔案名: src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

範例 16-13: 程序啟動了 10 個執行緒,每個執行緒都通過 Mutex<T> 來增加計數器的值

這裡創建了一個 counter 變數來存放內含 i32Mutex<T>,類似範例 16-12 那樣。接下來遍歷 range 創建了 10 個執行緒。使用了 thread::spawn 並對所有執行緒使用了相同的閉包:他們每一個都將調用 lock 方法來獲取 Mutex<T> 上的鎖,接著將互斥器中的值加一。當一個執行緒結束執行,num 會離開閉包作用域並釋放鎖,這樣另一個執行緒就可以獲取它了。

在主執行緒中,我們像範例 16-2 那樣收集了所有的 join 句柄,調用它們的 join 方法來確保所有執行緒都會結束。這時,主執行緒會獲取鎖並列印出程序的結果。

之前提示過這個例子不能編譯,讓我們看看為什麼!

error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here,
in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
which does not implement the `Copy` trait

錯誤訊息表明 counter 值在上一次循環中被移動了。所以 Rust 告訴我們不能將 counter 鎖的所有權移動到多個執行緒中。讓我們透過一個第十五章討論過的多所有權手段來修復這個編譯錯誤。

多執行緒和多所有權

在第十五章中,透過使用智慧指針 Rc<T> 來創建引用計數的值,以便擁有多所有者。讓我們在這也這麼做看看會發生什麼事。將範例 16-14 中的 Mutex<T> 封裝進 Rc<T> 中並在將所有權移入執行緒之前複製了 Rc<T>。現在我們理解了所發生的錯誤,同時也將代碼改回使用 for 循環,並保留閉包的 move 關鍵字:

檔案名: src/main.rs

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

範例 16-14: 嘗試使用 Rc<T> 來允許多個執行緒擁有 Mutex<T>

再一次編譯並...出現了不同的錯誤!編譯器真是教會了我們很多!

error[E0277]: `std::rc::Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:22
   |
11 |         let handle = thread::spawn(move || {
   |                      ^^^^^^^^^^^^^ `std::rc::Rc<std::sync::Mutex<i32>>`
cannot be sent between threads safely
   |
   = help: within `[closure@src/main.rs:11:36: 14:10
counter:std::rc::Rc<std::sync::Mutex<i32>>]`, the trait `std::marker::Send`
is not implemented for `std::rc::Rc<std::sync::Mutex<i32>>`
   = note: required because it appears within the type
`[closure@src/main.rs:11:36: 14:10 counter:std::rc::Rc<std::sync::Mutex<i32>>]`
   = note: required by `std::thread::spawn`

哇哦,錯誤訊息太長不看!這裡是一些需要注意的重要部分:第一行錯誤表明 `std::rc::Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely。編譯器也告訴了我們原因 the trait bound `Send` is not satisfied。下一部分會講到 Send:這是確保所使用的類型可以用於並發環境的 trait 之一。

不幸的是,Rc<T> 並不能安全的在執行緒間共享。當 Rc<T> 管理引用計數時,它必須在每一個 clone 調用時增加計數,並在每一個複製被丟棄時減少計數。Rc<T> 並沒有使用任何並發原語,來確保改變計數的操作不會被其他執行緒打斷。在計數出錯時可能會導致詭異的 bug,比如可能會造成記憶體洩漏,或在使用結束之前就丟棄一個值。我們所需要的是一個完全類似 Rc<T>,又以一種執行緒安全的方式改變引用計數的類型。

原子引用計數 Arc<T>

所幸 Arc<T> 正是 這麼一個類似 Rc<T> 並可以安全的用於並發環境的類型。字母 “a” 代表 原子性atomic),所以這是一個原子引用計數atomically reference counted)類型。原子性是另一類這裡還未涉及到的並發原語:請查看標準庫中 std::sync::atomic 的文件來獲取更多細節。其中的要點就是:原子性類型工作起來類似原始類型,不過可以安全的在執行緒間共享。

你可能會好奇為什麼不是所有的原始類型都是原子性的?為什麼不是所有標準庫中的類型都預設使用 Arc<T> 實現?原因在於執行緒安全帶有性能懲罰,我們希望只在必要時才為此買單。如果只是在單執行緒中對值進行操作,原子性提供的保證並無必要,代碼可以因此運行的更快。

回到之前的例子:Arc<T>Rc<T> 有著相同的 API,所以修改程序中的 use 行和 new 調用。範例 16-15 中的代碼最終可以編譯和運行:

檔案名: src/main.rs

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

範例 16-15: 使用 Arc<T> 包裝一個 Mutex<T> 能夠實現在多執行緒之間共享所有權

這會列印出:

Result: 10

成功了!我們從 0 數到了 10,這可能並不是很顯眼,不過一路上我們確實學習了很多關於 Mutex<T> 和執行緒安全的內容!這個例子中構建的結構可以用於比增加計數更為複雜的操作。使用這個策略,可將計算分成獨立的部分,分散到多個執行緒中,接著使用 Mutex<T> 使用各自的結算結果更新最終的結果。

RefCell<T>/Rc<T>Mutex<T>/Arc<T> 的相似性

你可能注意到了,因為 counter 是不可變的,不過可以獲取其內部值的可變引用;這意味著 Mutex<T> 提供了內部可變性,就像 Cell 系列類型那樣。正如第十五章中使用 RefCell<T> 可以改變 Rc<T> 中的內容那樣,同樣的可以使用 Mutex<T> 來改變 Arc<T> 中的內容。

另一個值得注意的細節是 Rust 不能避免使用 Mutex<T> 的全部邏輯錯誤。回憶一下第十五章使用 Rc<T> 就有造成引用循環的風險,這時兩個 Rc<T> 值相互引用,造成記憶體洩漏。同理,Mutex<T> 也有造成 死鎖deadlock) 的風險。這發生於當一個操作需要鎖住兩個資源而兩個執行緒各持一個鎖,這會造成它們永遠相互等待。如果你對這個主題感興趣,嘗試編寫一個帶有死鎖的 Rust 程序,接著研究任何其他語言中使用互斥器的死鎖規避策略並嘗試在 Rust 中實現他們。標準庫中 Mutex<T>MutexGuard 的 API 文件會提供有用的訊息。

接下來,為了豐富本章的內容,讓我們討論一下 SendSync trait 以及如何對自訂類型使用他們。

使用 SyncSend trait 的可擴展並發

ch16-04-extensible-concurrency-sync-and-send.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

Rust 的並發模型中一個有趣的方面是:語言本身對並發知之 甚少。我們之前討論的幾乎所有內容,都屬於標準庫,而不是語言本身的內容。由於不需要語言提供並發相關的基礎設施,並發方案不受標準庫或語言所限:我們可以編寫自己的或使用別人編寫的並發功能。

然而有兩個並發概念是內嵌於語言中的:std::marker 中的 SyncSend trait。

通過 Send 允許在執行緒間轉移所有權

Send 標記 trait 表明類型的所有權可以在執行緒間傳遞。幾乎所有的 Rust 類型都是Send 的,不過有一些例外,包括 Rc<T>:這是不能 Send 的,因為如果複製了 Rc<T> 的值並嘗試將複製的所有權轉移到另一個執行緒,這兩個執行緒都可能同時更新引用計數。為此,Rc<T> 被實現為用於單執行緒場景,這時不需要為擁有執行緒安全的引用計數而付出性能代價。

因此,Rust 類型系統和 trait bound 確保永遠也不會意外的將不安全的 Rc<T> 在執行緒間發送。當嘗試在範例 16-14 中這麼做的時候,會得到錯誤 the trait Send is not implemented for Rc<Mutex<i32>>。而使用標記為 SendArc<T> 時,就沒有問題了。

任何完全由 Send 的類型組成的類型也會自動被標記為 Send。幾乎所有基本類型都是 Send 的,除了第十九章將會討論的裸指針(raw pointer)。

Sync 允許多執行緒訪問

Sync 標記 trait 表明一個實現了 Sync 的類型可以安全的在多個執行緒中擁有其值的引用。換一種方式來說,對於任意類型 T,如果 &TT 的引用)是 Send 的話 T 就是 Sync 的,這意味著其引用就可以安全的發送到另一個執行緒。類似於 Send 的情況,基本類型是 Sync 的,完全由 Sync 的類型組成的類型也是 Sync 的。

智慧指針 Rc<T> 也不是 Sync 的,出於其不是 Send 相同的原因。RefCell<T>(第十五章討論過)和 Cell<T> 系列類型不是 Sync 的。RefCell<T> 在運行時所進行的借用檢查也不是執行緒安全的。Mutex<T>Sync 的,正如 “在執行緒間共享 Mutex<T> 部分所講的它可以被用來在多執行緒中共享訪問。

手動實現 SendSync 是不安全的

通常並不需要手動實現 SendSync trait,因為由 SendSync 的類型組成的類型,自動就是 SendSync 的。因為他們是標記 trait,甚至都不需要實現任何方法。他們只是用來加強並發相關的不可變性的。

手動實現這些標記 trait 涉及到編寫不安全的 Rust 代碼,第十九章將會講述具體的方法;當前重要的是,在創建新的由不是 SendSync 的部分構成的並發類型時需要多加小心,以確保維持其安全保證。The Rustonomicon 中有更多關於這些保證以及如何維持他們的訊息。

總結

這不會是本書最後一個出現並發的章節:第二十章的項目會在更現實的場景中使用這些概念,而不像本章中討論的這些小例子。

正如之前提到的,因為 Rust 本身很少有處理並發的部分內容,有很多的並發方案都由 crate 實現。他們比標準庫要發展的更快;請在網路上搜索當前最新的用於多執行緒場景的 crate。

Rust 提供了用於消息傳遞的通道,和像 Mutex<T>Arc<T> 這樣可以安全的用於並發上下文的智慧指針。類型系統和借用檢查器會確保這些場景中的代碼,不會出現數據競爭和無效的引用。一旦代碼可以編譯了,我們就可以堅信這些程式碼可以正確的運行於多執行緒環境,而不會出現其他語言中經常出現的那些難以追蹤的 bug。並發編程不再是什麼可怕的概念:無所畏懼地並發吧!

接下來,讓我們討論一下當 Rust 程序變得更大時,有哪些符合語言習慣的問題建模方法和結構化解決方案,以及 Rust 的風格是如何與面向對象編程(Object Oriented Programming)中那些你所熟悉的概念相聯繫的。

Rust 的面向對象特性

ch17-00-oop.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

面向對象編程(Object-Oriented Programming,OOP)是一種模式化編程方式。對象(Object)來源於 20 世紀 60 年代的 Simula 程式語言。這些對象影響了 Alan Kay 的程式架構中對象之間的消息傳遞。他在 1967 年創造了 面向對象編程 這個術語來描述這種架構。關於 OOP 是什麼有很多相互矛盾的定義;在一些定義下,Rust 是面向對象的;在其他定義下,Rust 不是。在本章節中,我們會探索一些被普遍認為是面向對象的特性和這些特性是如何體現在 Rust 語言習慣中的。接著會展示如何在 Rust 中實現面向對象設計模式,並討論這麼做與利用 Rust 自身的一些優勢實現的方案相比有什麼取捨。

面向對象語言的特徵

ch17-01-what-is-oo.md
commit 34caca254c3e08ff9fe3ad985007f45e92577c03

關於一個語言被稱為面向對象所需的功能,在編程社區內並未達成一致意見。Rust 被很多不同的程式範式影響,包括面向對象編程;比如第十三章提到了來自函數式編程的特性。面向對象程式語言所共享的一些特性往往是對象、封裝和繼承。讓我們看一下這每一個概念的含義以及 Rust 是否支持他們。

對象包含數據和行為

由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional, 1994)編寫的書 Design Patterns: Elements of Reusable Object-Oriented Software 被俗稱為 The Gang of Four (字面意思為“四人幫”),它是面向對象編程模式的目錄。它這樣定義面向對象編程:

Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.

面向對象的程序是由對象組成的。一個 對象 包含數據和操作這些數據的過程。這些過程通常被稱為 方法操作

在這個定義下,Rust 是面向對象的:結構體和枚舉包含數據而 impl 塊提供了在結構體和枚舉之上的方法。雖然帶有方法的結構體和枚舉並不被 稱為 對象,但是他們提供了與對象相同的功能,參考 The Gang of Four 中對象的定義。

封裝隱藏了實現細節

另一個通常與面向對象編程相關的方面是 封裝encapsulation)的思想:對象的實現細節不能被使用對象的代碼獲取到。所以唯一與對象交互的方式是通過對象提供的公有 API;使用對象的代碼無法深入到對象內部並直接改變數據或者行為。封裝使得改變和重構對象的內部時無需改變使用對象的代碼。

就像我們在第七章討論的那樣:可以使用 pub 關鍵字來決定模組、類型、函數和方法是公有的,而默認情況下其他一切都是私有的。比如,我們可以定義一個包含一個 i32 類型 vector 的結構體 AveragedCollection 。結構體也可以有一個欄位,該欄位保存了 vector 中所有值的平均值。這樣,希望知道結構體中的 vector 的平均值的人可以隨時獲取它,而無需自己計算。換句話說,AveragedCollection 會為我們快取平均值結果。範例 17-1 有 AveragedCollection 結構體的定義:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
}

範例 17-1: AveragedCollection 結構體維護了一個整型列表和集合中所有元素的平均值。

注意,結構體自身被標記為 pub,這樣其他代碼就可以使用這個結構體,但是在結構體內部的欄位仍然是私有的。這是非常重要的,因為我們希望保證變數被增加到列表或者被從列表刪除時,也會同時更新平均值。可以通過在結構體上實現 addremoveaverage 方法來做到這一點,如範例 17-2 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            },
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
}

範例 17-2: 在AveragedCollection 結構體上實現了addremoveaverage 公有方法

公有方法 addremoveaverage 是修改 AveragedCollection 實例的唯一方式。當使用 add 方法把一個元素加入到 list 或者使用 remove 方法來刪除時,這些方法的實現同時會調用私有的 update_average 方法來更新 average 欄位。

listaverage 是私有的,所以沒有其他方式來使得外部的代碼直接向 list 增加或者刪除元素,否則 list 改變時可能會導致 average 欄位不同步。average 方法返回 average 欄位的值,這使得外部的代碼只能讀取 average 而不能修改它。

因為我們已經封裝好了 AveragedCollection 的實現細節,將來可以輕鬆改變類似數據結構這些方面的內容。例如,可以使用 HashSet<i32> 代替 Vec<i32> 作為 list 欄位的類型。只要 addremoveaverage 公有函數的簽名保持不變,使用 AveragedCollection 的代碼就無需改變。相反如果使得 list 為公有,就未必都會如此了: HashSet<i32>Vec<i32> 使用不同的方法增加或移除項,所以如果要想直接修改 list 的話,外部的代碼可能不得不做出修改。

如果封裝是一個語言被認為是面向對象語言所必要的方面的話,那麼 Rust 滿足這個要求。在代碼中不同的部分使用 pub 與否可以封裝其實現細節。

繼承,作為類型系統與代碼共享

繼承Inheritance)是一個很多程式語言都提供的機制,一個對象可以定義為繼承另一個對象的定義,這使其可以獲得父對象的數據和行為,而無需重新定義。

如果一個語言必須有繼承才能被稱為面向對象語言的話,那麼 Rust 就不是面向對象的。無法定義一個結構體繼承父結構體的成員和方法。然而,如果你過去常常在你的程式工具箱使用繼承,根據你最初考慮繼承的原因,Rust 也提供了其他的解決方案。

選擇繼承有兩個主要的原因。第一個是為了重用代碼:一旦為一個類型實現了特定行為,繼承可以對一個不同的類型重用這個實現。相反 Rust 代碼可以使用默認 trait 方法實現來進行共享,在範例 10-14 中我們見過在 Summary trait 上增加的 summarize 方法的默認實現。任何實現了 Summary trait 的類型都可以使用 summarize 方法而無須進一步實現。這類似於父類有一個方法的實現,而通過繼承子類也擁有這個方法的實現。當實現 Summary trait 時也可以選擇覆蓋 summarize 的默認實現,這類似於子類覆蓋從父類繼承的方法實現。

第二個使用繼承的原因與類型系統有關:表現為子類型可以用於父類型被使用的地方。這也被稱為 多態polymorphism),這意味著如果多種對象共享特定的屬性,則可以相互替代使用。

多態(Polymorphism)

很多人將多態描述為繼承的同義詞。不過它是一個有關可以用於多種類型的代碼的更廣泛的概念。對於繼承來說,這些類型通常是子類。 Rust 則透過泛型來對不同的可能類型進行抽象,並通過 trait bounds 對這些類型所必須提供的內容施加約束。這有時被稱為 bounded parametric polymorphism

近來繼承作為一種語言設計的解決方案在很多語言中失寵了,因為其時常帶有共享多於所需的代碼的風險。子類不應總是共享其父類的所有特徵,但是繼承卻始終如此。如此會使程式設計更為不靈活,並引入無意義的子類方法調用,或由於方法實際並不適用於子類而造成錯誤的可能性。某些語言還只允許子類繼承一個父類,進一步限制了程式設計的靈活性。

因為這些原因,Rust 選擇了一個不同的途徑,使用 trait 對象而不是繼承。讓我們看一下 Rust 中的 trait 對象是如何實現多態的。

為使用不同類型的值而設計的 trait 對象

ch17-02-trait-objects.md
commit 7b23a000fc511d985069601eb5b09c6017e609eb

在第八章中,我們談到了 vector 只能存儲同種類型元素的局限。範例 8-10 中提供了一個定義 SpreadsheetCell 枚舉來儲存整型,浮點型和文本成員的替代方案。這意味著可以在每個單元中儲存不同類型的數據,並仍能擁有一個代表一排單元的 vector。這在當編譯代碼時就知道希望可以交替使用的類型為固定集合的情況下是完全可行的。

然而有時我們希望庫用戶在特定情況下能夠擴展有效的類型集合。為了展示如何實現這一點,這裡將創建一個圖形用戶介面(Graphical User Interface, GUI)工具的例子,它透過遍歷列表並調用每一個項目的 draw 方法來將其繪製到螢幕上 —— 此乃一個 GUI 工具的常見技術。我們將要創建一個叫做 gui 的庫 crate,它含一個 GUI 庫的結構。這個 GUI 庫包含一些可供開發者使用的類型,比如 ButtonTextField。在此之上,gui 的用戶希望創建自訂的可以繪製於螢幕上的類型:比如,一個程式設計師可能會增加 Image,另一個可能會增加 SelectBox

這個例子中並不會實現一個功能完善的 GUI 庫,不過會展示其中各個部分是如何結合在一起的。編寫庫的時候,我們不可能知曉並定義所有其他程式設計師希望創建的類型。我們所知曉的是 gui 需要記錄一系列不同類型的值,並需要能夠對其中每一個值調用 draw 方法。這裡無需知道調用 draw 方法時具體會發生什麼事,只要該值會有那個方法可供我們調用。

在擁有繼承的語言中,可以定義一個名為 Component 的類,該類上有一個 draw 方法。其他的類比如 ButtonImageSelectBox 會從 Component 派生並因此繼承 draw 方法。它們各自都可以覆蓋 draw 方法來定義自己的行為,但是框架會把所有這些類型當作是 Component 的實例,並在其上調用 draw。不過 Rust 並沒有繼承,我們得另尋出路。

定義通用行為的 trait

為了實現 gui 所期望的行為,讓我們定義一個 Draw trait,其中包含名為 draw 的方法。接著可以定義一個存放 trait 對象trait object) 的 vector。trait 對象指向一個實現了我們指定 trait 的類型的實例,以及一個用於在運行時查找該類型的trait方法的表。我們透過指定某種指針來創建 trait 對象,例如 & 引用或 Box<T> 智慧指針,還有 dyn keyword, 以及指定相關的 trait(第十九章 ““動態大小類型和 Sized trait” 部分會介紹 trait 對象必須使用指針的原因)。我們可以使用 trait 對象代替泛型或具體類型。任何使用 trait 對象的位置,Rust 的類型系統會在編譯時確保任何在此上下文中使用的值會實現其 trait 對象的 trait。如此便無需在編譯時就知曉所有可能的類型。

之前提到過,Rust 刻意不將結構體與枚舉稱為 “對象”,以便與其他語言中的對象相區別。在結構體或枚舉中,結構體欄位中的數據和 impl 塊中的行為是分開的,不同於其他語言中將數據和行為組合進一個稱為對象的概念中。trait 對象將數據和行為兩者相結合,從這種意義上說 其更類似其他語言中的對象。不過 trait 對象不同於傳統的對象,因為不能向 trait 對象增加數據。trait 對象並不像其他語言中的對象那麼通用:其(trait 對象)具體的作用是允許對通用行為進行抽象。

範例 17-3 展示了如何定義一個帶有 draw 方法的 trait Draw

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}
}

範例 17-3:Draw trait 的定義

因為第十章已經討論過如何定義 trait,其語法看起來應該比較眼熟。接下來就是新內容了:範例 17-4 定義了一個存放了名叫 components 的 vector 的結構體 Screen。這個 vector 的類型是 Box<dyn Draw>,此為一個 trait 對象:它是 Box 中任何實現了 Draw trait 的類型的替身。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
}

範例 17-4: 一個 Screen 結構體的定義,它帶有一個欄位 components,其包含實現了 Draw trait 的 trait 對象的 vector

Screen 結構體上,我們將定義一個 run 方法,該方法會對其 components 上的每一個組件調用 draw 方法,如範例 17-5 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
}

範例 17-5:在 Screen 上實現一個 run 方法,該方法在每個 component 上調用 draw 方法

這與定義使用了帶有 trait bound 的泛型類型參數的結構體不同。泛型類型參數一次只能替代一個具體類型,而 trait 對象則允許在運行時替代多種具體類型。例如,可以定義 Screen 結構體來使用泛型和 trait bound,如範例 17-6 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
    where T: Draw {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
}

範例 17-6: 一種 Screen 結構體的替代實現,其 run 方法使用泛型和 trait bound

這限制了 Screen 實例必須擁有一個全是 Button 類型或者全是 TextField 類型的組件列表。如果只需要同質(相同類型)集合,則傾向於使用泛型和 trait bound,因為其定義會在編譯時採用具體類型進行單態化。

另一方面,透過使用 trait 對象的方法,一個 Screen 實例可以存放一個既能包含 Box<Button>,也能包含 Box<TextField>Vec<T>。讓我們看看它是如何工作的,接著會講到其運行時性能影響。

實現 trait

現在來增加一些實現了 Draw trait 的類型。我們將提供 Button 類型。再一次重申,真正實現 GUI 庫超出了本書的範疇,所以 draw 方法體中不會有任何有意義的實現。為了想像一下這個實現看起來像什麼,一個 Button 結構體可能會擁有 widthheightlabel 欄位,如範例 17-7 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait Draw {
    fn draw(&self);
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 實際繪製按鈕的代碼
    }
}
}

範例 17-7: 一個實現了 Draw trait 的 Button 結構體

Button 上的 widthheightlabel 欄位會和其他組件不同,比如 TextField 可能有 widthheightlabel 以及 placeholder 欄位。每一個我們希望能在螢幕上繪製的類型都會使用不同的代碼來實現 Draw trait 的 draw 方法來定義如何繪製特定的類型,像這裡的 Button 類型(並不包含任何實際的 GUI 代碼,這超出了本章的範疇)。除了實現 Draw trait 之外,比如 Button 還可能有另一個包含按鈕點擊如何響應的方法的 impl 塊。這類方法並不適用於像 TextField 這樣的類型。

如果一些庫的使用者決定實現一個包含 widthheightoptions 欄位的結構體 SelectBox,並且也為其實現了 Draw trait,如範例 17-8 所示:

檔案名: src/main.rs

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

範例 17-8: 另一個使用 gui 的 crate 中,在 SelectBox 結構體上實現 Draw trait

庫使用者現在可以在他們的 main 函數中創建一個 Screen 實例。至此可以透過將 SelectBoxButton 放入 Box<T> 轉變為 trait 對象來增加組件。接著可以調用 Screenrun 方法,它會調用每個組件的 draw 方法。範例 17-9 展示了這個實現:

檔案名: src/main.rs

use gui::{Screen, Button};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No")
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

範例 17-9: 使用 trait 對象來存儲實現了相同 trait 的不同類型的值

當編寫庫的時候,我們不知道何人會在何時增加 SelectBox 類型,不過 Screen 的實現能夠操作並繪製這個新類型,因為 SelectBox 實現了 Draw trait,這意味著它實現了 draw 方法。

這個概念 —— 只關心值所反映的訊息而不是其具體類型 —— 類似於動態類型語言中稱為 鴨子類型duck typing)的概念:如果它走起來像一隻鴨子,叫起來像一隻鴨子,那麼它就是一隻鴨子!在範例 17-5 中 Screen 上的 run 實現中,run 並不需要知道各個組件的具體類型是什麼。它並不檢查組件是 Button 或者 SelectBox 的實例。通過指定 Box<dyn Draw> 作為 components vector 中值的類型,我們就定義了 Screen 為需要可以在其上調用 draw 方法的值。

使用 trait 對象和 Rust 類型系統來進行類似鴨子類型操作的優勢是無需在運行時檢查一個值是否實現了特定方法或者擔心在調用時因為值沒有實現方法而產生錯誤。如果值沒有實現 trait 對象所需的 trait 則 Rust 不會編譯這些程式碼。

例如,範例 17-10 展示了當創建一個使用 String 做為其組件的 Screen 時發生的情況:

檔案名: src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(String::from("Hi")),
        ],
    };

    screen.run();
}

範例 17-10: 嘗試使用一種沒有實現 trait 對象的 trait 的類型

我們會遇到這個錯誤,因為 String 沒有實現 rust_gui::Draw trait:

error[E0277]: the trait bound `std::string::String: gui::Draw` is not satisfied
  --> src/main.rs:7:13
   |
 7 |             Box::new(String::from("Hi")),
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait gui::Draw is not
   implemented for `std::string::String`
   |
   = note: required for the cast to the object type `gui::Draw`

這告訴了我們,要嘛是我們傳遞了並不希望傳遞給 Screen 的類型並應該提供其他類型,要嘛應該在 String 上實現 Draw 以便 Screen 可以調用其上的 draw

trait 對象執行動態分發

回憶一下第十章 “泛型代碼的性能” 部分討論過的,當對泛型使用 trait bound 時編譯器所進行單態化處理:編譯器為每一個被泛型類型參數代替的具體類型生成了非泛型的函數和方法實現。單態化所產生的代碼進行 靜態分發static dispatch)。靜態分發發生於編譯器在編譯時就知曉調用了什麼方法的時候。這與 動態分發dynamic dispatch)相對,這時編譯器在編譯時無法知曉調用了什麼方法。在動態分發的情況下,編譯器會生成在運行時確定調用了什麼方法的代碼。

當使用 trait 對象時,Rust 必須使用動態分發。編譯器無法知曉所有可能用於 trait 對象代碼的類型,所以它也不知道應該調用哪個類型的哪個方法實現。為此,Rust 在運行時使用 trait 對象中的指針來知曉需要調用哪個方法。動態分發也阻止編譯器有選擇的內聯方法代碼,這會相應的禁用一些最佳化。儘管在編寫範例 17-5 和可以支持範例 17-9 中的代碼的過程中確實獲得了額外的靈活性,但仍然需要權衡取捨。

Trait 對象要求對象安全

只有 對象安全object safe)的 trait 才可以組成 trait 對象。圍繞所有使得 trait 對象安全的屬性存在一些複雜的規則,不過在實踐中,只涉及到兩條規則。如果一個 trait 中所有的方法有如下屬性時,則該 trait 是對象安全的:

  • 返回值類型不為 Self
  • 方法沒有任何泛型類型參數

Self 關鍵字是我們要實現 trait 或方法的類型的別名。對象安全對於 trait 對象是必須的,因為一旦有了 trait 對象,就不再知曉實現該 trait 的具體類型是什麼了。如果 trait 方法返回具體的 Self 類型,但是 trait 對象忘記了其真正的類型,那麼方法不可能使用已經忘卻的原始具體類型。同理對於泛型類型參數來說,當使用 trait 時其會放入具體的類型參數:此具體類型變成了實現該 trait 的類型的一部分。當使用 trait 對象時其具體類型被抹去了,故無從得知放入泛型參數類型的類型是什麼。

一個 trait 的方法不是對象安全的例子是標準庫中的 Clone trait。Clone trait 的 clone 方法的參數簽名看起來像這樣:


#![allow(unused)]
fn main() {
pub trait Clone {
    fn clone(&self) -> Self;
}
}

String 實現了 Clone trait,當在 String 實例上調用 clone 方法時會得到一個 String 實例。類似的,當調用 Vec<T> 實例的 clone 方法會得到一個 Vec<T> 實例。clone 的簽名需要知道什麼類型會代替 Self,因為這是它的返回值。

如果嘗試做一些違反有關 trait 對象的對象安全規則的事情,編譯器會提示你。例如,如果嘗試實現範例 17-4 中的 Screen 結構體來存放實現了 Clone trait 而不是 Draw trait 的類型,像這樣:

pub struct Screen {
    pub components: Vec<Box<dyn Clone>>,
}

將會得到如下錯誤:

error[E0038]: the trait `std::clone::Clone` cannot be made into an object
 --> src/lib.rs:2:5
  |
2 |     pub components: Vec<Box<dyn Clone>>,
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
  cannot be made into an object
  |
  = note: the trait cannot require that `Self : Sized`

這意味著不能以這種方式使用此 trait 作為 trait 對象。如果你對對象安全的更多細節感興趣,請查看 Rust RFC 255

面向對象設計模式的實現

ch17-03-oo-design-patterns.md
commit 7e219336581c41a80fd41f4fbe615fecb6ed0a7d

狀態模式state pattern)是一個面向對象設計模式。該模式的關鍵在於一個值有某些內部狀態,體現為一系列的 狀態對象,同時值的行為隨著其內部狀態而改變。狀態對象共享功能:當然,在 Rust 中使用結構體和 trait 而不是對象和繼承。每一個狀態對象負責其自身的行為,以及該狀態何時應當轉移至另一個狀態。持有一個狀態對象的值對於不同狀態的行為以及何時狀態轉移毫不知情。

使用狀態模式意味著當程序的業務需求改變時,無需改變值持有狀態或者使用值的代碼。我們只需更新某個狀態對象中的代碼來改變其規則,或者是增加更多的狀態對象。讓我們看看一個有關狀態模式和如何在 Rust 中使用它的例子。

為了探索這個概念,我們將實現一個增量式的發布博文的工作流。這個部落格的最終功能看起來像這樣:

  1. 博文從空白的草案開始。
  2. 一旦草案完成,請求審核博文。
  3. 一旦博文過審,它將被發表。
  4. 只有被發表的博文的內容會被列印,這樣就不會意外列印出沒有被審核的博文的文本。

任何其他對博文的修改嘗試都是沒有作用的。例如,如果嘗試在請求審核之前通過一個草案博文,博文應該保持未發布的狀態。

範例 17-11 展示這個工作流的代碼形式:這是一個我們將要在一個叫做 blog 的庫 crate 中實現的 API 的範例。這段代碼還不能編譯,因為還未實現 blog

檔案名: src/main.rs

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

範例 17-11: 展示了 blog crate 期望行為的代碼

我們希望允許用戶使用 Post::new 創建一個新的博文草案。接著希望能在草案階段為博文編寫一些文本。如果嘗試在審核之前立即列印出博文的內容,什麼也不會發生因為博文仍然是草案。這裡增加的 assert_eq! 出於示範目的。一個好的單元測試將是斷言草案博文的 content 方法返回空字串,不過我們並不準備為這個例子編寫單元測試。

接下來,我們希望能夠請求審核博文,而在等待審核的階段 content 應該仍然返回空字串。最後當博文審核通過,它應該被發表,這意味著當調用 content 時博文的文本將被返回。

注意我們與 crate 交互的唯一的類型是 Post。這個類型會使用狀態模式並會存放處於三種博文所可能的狀態之一的值 —— 草案,等待審核和發布。狀態上的改變由 Post 類型內部進行管理。狀態依庫用戶對 Post 實例調用的方法而改變,但是不能直接管理狀態變化。這也意味著用戶不會在狀態上犯錯,比如在過審前發布博文。

定義 Post 並新建一個草案狀態的實例

讓我們開始實現這個庫吧!我們知道需要一個公有 Post 結構體來存放一些文本,所以讓我們從結構體的定義和一個創建 Post 實例的公有關聯函數 new 開始,如範例 17-12 所示。還需定義一個私有 trait StatePost 將在私有欄位 state 中存放一個 Option<T> 類型的 trait 對象 Box<dyn State>。稍後將會看到為何 Option<T> 是必須的。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
}

範例 17-12: Post 結構體的定義和新建 Post 實例的 new 函數,State trait 和結構體 Draft

State trait 定義了所有不同狀態的博文所共享的行為,同時 DraftPendingReviewPublished 狀態都會實現 State 狀態。現在這個 trait 並沒有任何方法,同時開始將只定義 Draft 狀態因為這是我們希望博文的初始狀態。

當創建新的 Post 時,我們將其 state 欄位設置為一個存放了 BoxSome 值。這個 Box 指向一個 Draft 結構體新實例。這確保了無論何時新建一個 Post 實例,它都會從草案開始。因為 Poststate 欄位是私有的,也就無法創建任何其他狀態的 Post 了!。Post::new 函數中將 content 設置為新建的空 String

存放博文內容的文本

在範例 17-11 中,展示了我們希望能夠調用一個叫做 add_text 的方法並向其傳遞一個 &str 來將文本增加到博文的內容中。選擇實現為一個方法而不是將 content 欄位暴露為 pub 。這意味著之後可以實現一個方法來控制 content 欄位如何被讀取。add_text 方法是非常直觀的,讓我們在範例 17-13 的 impl Post 塊中增加一個實現:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    content: String,
}

impl Post {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
}

範例 17-13: 實現方法 add_text 來向博文的 content 增加文本

add_text 獲取一個 self 的可變引用,因為需要改變調用 add_textPost 實例。接著調用 content 中的 Stringpush_str 並傳遞 text 參數來保存到 content 中。這不是狀態模式的一部分,因為它的行為並不依賴博文所處的狀態。add_text 方法完全不與 state 狀態交互,不過這是我們希望支持的行為的一部分。

確保博文草案的內容是空的

即使調用 add_text 並向博文增加一些內容之後,我們仍然希望 content 方法返回一個空字串 slice,因為博文仍然處於草案狀態,如範例 17-11 的第 8 行所示。現在讓我們使用能滿足要求的最簡單的方式來實現 content 方法:總是返回一個空字串 slice。當實現了將博文狀態改為發布的能力之後將改變這一做法。但是目前博文只能是草案狀態,這意味著其內容應該總是空的。範例 17-14 展示了這個占位符實現:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    content: String,
}

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        ""
    }
}
}

列表 17-14: 增加一個 Postcontent 方法的占位實現,它總是返回一個空字串 slice

透過增加這個 content 方法,範例 17-11 中直到第 8 行的代碼能如期運行。

請求審核博文來改變其狀態

接下來需要增加請求審核博文的功能,這應當將其狀態由 Draft 改為 PendingReview。範例 17-15 展示了這個代碼:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
}

範例 17-15: 實現 PostState trait 的 request_review 方法

這裡為 Post 增加一個獲取 self 可變引用的公有方法 request_review。接著在 Post 的當前狀態下調用內部的 request_review 方法,並且第二個 request_review 方法會消費當前的狀態並返回一個新狀態。

這裡給 State trait 增加了 request_review 方法;所有實現了這個 trait 的類型現在都需要實現 request_review 方法。注意不同於使用 self&self 或者 &mut self 作為方法的第一個參數,這裡使用了 self: Box<Self>。這個語法意味著這個方法調用只對這個類型的 Box 有效。這個語法獲取了 Box<Self> 的所有權,使老狀態無效化以便 Post 的狀態值可以將自身轉換為新狀態。

為了消費老狀態,request_review 方法需要獲取狀態值的所有權。這也就是 Poststate 欄位中 Option 的來歷:調用 take 方法將 state 欄位中的 Some 值取出並留下一個 None,因為 Rust 不允許在結構體中存在空的欄位。這使得我們將 state 值移動出 Post 而不是借用它。接著將博文的 state 值設置為這個操作的結果。

這裡需要將 state 臨時設置為 None,不同於像 self.state = self.state.request_review(); 這樣的代碼直接設置 state 欄位,來獲取 state 值的所有權。這確保了當 Post 被轉換為新狀態後其不再能使用老的 state 值。

Draft 的方法 request_review 的實現返回一個新的,裝箱的 PendingReview 結構體的實例,其用來代表博文處於等待審核狀態。結構體 PendingReview 同樣也實現了 request_review 方法,不過它不進行任何狀態轉換。相反它返回自身,因為請求審核已經處於 PendingReview 狀態的博文應該保持 PendingReview 狀態。

現在開始能夠看出狀態模式的優勢了:Postrequest_review 方法無論 state 是何值都是一樣的。每個狀態只負責它自己的規則。

我們將繼續保持 Postcontent 方法不變,返回一個空字串 slice。現在可以擁有 PendingReview 狀態而不僅僅是 Draft 狀態的 Post 了,不過我們希望在 PendingReview 狀態下其也有相同的行為。現在範例 17-11 中直到 10 行的代碼是可以執行的!

增加改變 content 行為的 approve 方法

approve 方法將與 request_review 方法類似:它會將 state 設置為審核通過時應處於的狀態,如範例 17-16 所示。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    // --snip--
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    // --snip--
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
}

範例 17-16: 為 PostState trait 實現 approve 方法

這裡為 State trait 增加了 approve 方法,並新增了一個實現了 State 的結構體,Published 狀態。

類似於 request_review,如果對 Draft 調用 approve 方法,並沒有任何效果,因為它會返回 self。當對 PendingReview 調用 approve 時,它返回一個新的、裝箱的 Published 結構體的實例。Published 結構體實現了 State trait,同時對於 request_reviewapprove 兩方法來說,它返回自身,因為在這兩種情況博文應該保持 Published 狀態。

現在更新 Postcontent 方法:如果狀態為 Published 希望返回博文 content 欄位的值;否則希望返回空字串 slice,如範例 17-17 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
trait State {
    fn content<'a>(&self, post: &'a Post) -> &'a str;
}
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--
}
}

範例 17-17: 更新 Postcontent 方法來委託調用 Statecontent 方法

因為目標是將所有像這樣的規則保持在實現了 State 的結構體中,我們將調用 state 中的值的 content 方法並傳遞博文實例(也就是 self)作為參數。接著返回 state 值的 content 方法的返回值。

這裡調用 Optionas_ref 方法是因為需要 Option 中值的引用而不是獲取其所有權。因為 state 是一個 Option<Box<State>>,調用 as_ref 會返回一個 Option<&Box<State>>。如果不調用 as_ref,將會得到一個錯誤,因為不能將 state 移動出借用的 &self 函數參數。

接著調用 unwrap 方法,這裡我們知道它永遠也不會 panic,因為 Post 的所有方法都確保在他們返回時 state 會有一個 Some 值。這就是一個第十二章 “當我們比編譯器知道更多的情況” 部分討論過的我們知道 None 是不可能的而編譯器卻不能理解的情況。

接著我們就有了一個 &Box<State>,當調用其 content 時,解引用強制多態會作用於 &Box ,這樣最終會調用實現了 State trait 的類型的 content 方法。這意味著需要為 State trait 定義增加 content,這也是放置根據所處狀態返回什麼內容的邏輯的地方,如範例 17-18 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    content: String
}
trait State {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--
struct Published {}

impl State for Published {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
}

範例 17-18: 為 State trait 增加 content 方法

這裡增加了一個 content 方法的默認實現來返回一個空字串 slice。這意味著無需為 DraftPendingReview 結構體實現 content 了。Published 結構體會覆蓋 content 方法並會返回 post.content 的值。

注意這個方法需要生命週期註解,如第十章所討論的。這裡獲取 post 的引用作為參數,並返回 post 一部分的引用,所以返回的引用的生命週期與 post 參數相關。

現在範例完成了 —— 現在範例 17-11 中所有的代碼都能工作!我們通過發布博文工作流的規則實現了狀態模式。圍繞這些規則的邏輯都存在於狀態對象中而不是分散在 Post 之中。

狀態模式的權衡取捨

我們展示了 Rust 是能夠實現面向對象的狀態模式的,以便能根據博文所處的狀態來封裝不同類型的行為。Post 的方法並不知道這些不同類型的行為。透過這種組織代碼的方式,要找到所有已發布博文的不同行為只需查看一處代碼:PublishedState trait 的實現。

如果要創建一個不使用狀態模式的替代實現,則可能會在 Post 的方法中,或者甚至於在 main 代碼中用到 match 語句,來檢查博文狀態並在這裡改變其行為。這意味著需要查看很多位置來理解處於發布狀態的博文的所有邏輯!這在增加更多狀態時會變得更糟:每一個 match 語句都會需要另一個分支。

對於狀態模式來說,Post 的方法和使用 Post 的位置無需 match 語句,同時增加新狀態只涉及到增加一個新 struct 和為其實現 trait 的方法。

這個實現易於擴展增加更多功能。為了體會使用此模式維護代碼的簡潔性,請嘗試如下一些建議:

  • 增加 reject 方法將博文的狀態從 PendingReview 變回 Draft
  • 在將狀態變為 Published 之前需要兩次 approve 調用
  • 只允許博文處於 Draft 狀態時增加文本內容。提示:讓狀態對象負責內容可能發生什麼改變,但不負責修改 Post

狀態模式的一個缺點是因為狀態實現了狀態之間的轉換,一些狀態會相互聯繫。如果在 PendingReviewPublished 之間增加另一個狀態,比如 Scheduled,則不得不修改 PendingReview 中的代碼來轉移到 Scheduled。如果 PendingReview 無需因為新增的狀態而改變就更好了,不過這意味著切換到另一種設計模式。

另一個缺點是我們會發現一些重複的邏輯。為了消除他們,可以嘗試為 State trait 中返回 selfrequest_reviewapprove 方法增加默認實現,不過這會違反對象安全性,因為 trait 不知道 self 具體是什麼。我們希望能夠將 State 作為一個 trait 對象,所以需要其方法是對象安全的。

另一個重複是 Postrequest_reviewapprove 這兩個類似的實現。他們都委託調用了 state 欄位中 Option 值的同一方法,並在結果中為 state 欄位設置了新值。如果 Post 中的很多方法都遵循這個模式,我們可能會考慮定義一個宏來消除重複(查看第十九章的 “宏” 部分)。

完全按照面向對象語言的定義實現這個模式並沒有儘可能地利用 Rust 的優勢。讓我們看看一些程式碼中可以做出的修改,來將無效的狀態和狀態轉移變為編譯時錯誤。

將狀態和行為編碼為類型

我們將展示如何稍微反思狀態模式來進行一系列不同的權衡取捨。不同於完全封裝狀態和狀態轉移使得外部代碼對其毫不知情,我們將狀態編碼進不同的類型。如此,Rust 的類型檢查就會將任何在只能使用發布博文的地方使用草案博文的嘗試變為編譯時錯誤。

讓我們考慮一下範例 17-11 中 main 的第一部分:

檔案名: src/main.rs

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
}

我們仍然希望能夠使用 Post::new 創建一個新的草案博文,並能夠增加博文的內容。不過不同於存在一個草案博文時返回空字串的 content 方法,我們將使草案博文完全沒有 content 方法。這樣如果嘗試獲取草案博文的內容,將會得到一個方法不存在的編譯錯誤。這使得我們不可能在生產環境意外顯示出草案博文的內容,因為這樣的代碼甚至就不能編譯。範例 17-19 展示了 Post 結構體、DraftPost 結構體以及各自的方法的定義:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
}

範例 17-19: 帶有 content 方法的 Post 和沒有 content 方法的 DraftPost

PostDraftPost 結構體都有一個私有的 content 欄位來儲存博文的文本。這些結構體不再有 state 欄位因為我們將狀態編碼改為結構體類型。Post 將代表發布的博文,它有一個返回 contentcontent 方法。

仍然有一個 Post::new 函數,不過不同於返回 Post 實例,它返回 DraftPost 的實例。現在不可能創建一個 Post 實例,因為 content 是私有的同時沒有任何函數返回 Post

DraftPost 上定義了一個 add_text 方法,這樣就可以像之前那樣向 content 增加文本,不過注意 DraftPost 並沒有定義 content 方法!如此現在程序確保了所有博文都從草案開始,同時草案博文沒有任何可供展示的內容。任何繞過這些限制的嘗試都會產生編譯錯誤。

實現狀態轉移為不同類型的轉換

那麼如何得到發布的博文呢?我們希望強制執行的規則是草案博文在可以發布之前必須被審核通過。等待審核狀態的博文應該仍然不會顯示任何內容。讓我們透過增加另一個結構體 PendingReviewPost 來實現這個限制,在 DraftPost 上定義 request_review 方法來返回 PendingReviewPost,並在 PendingReviewPost 上定義 approve 方法來返回 Post,如範例 17-20 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl DraftPost {
    // --snip--

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
}

列表 17-20: PendingReviewPost 透過調用 DraftPostrequest_review 創建,approve 方法將 PendingReviewPost 變為發布的 Post

request_reviewapprove 方法獲取 self 的所有權,因此會消費 DraftPostPendingReviewPost 實例,並分別轉換為 PendingReviewPost 和發布的 Post。這樣在調用 request_review 之後就不會遺留任何 DraftPost 實例,後者同理。PendingReviewPost 並沒有定義 content 方法,所以嘗試讀取其內容會導致編譯錯誤,DraftPost 同理。因為唯一得到定義了 content 方法的 Post 實例的途徑是調用 PendingReviewPostapprove 方法,而得到 PendingReviewPost 的唯一辦法是調用 DraftPostrequest_review 方法,現在我們就將發博文的工作流編碼進了類型系統。

這也意味著不得不對 main 做出一些小的修改。因為 request_reviewapprove 返回新實例而不是修改被調用的結構體,所以我們需要增加更多的 let post = 覆蓋賦值來保存返回的實例。也不再能斷言草案和等待審核的博文的內容為空字串了,我們也不再需要他們:不能編譯嘗試使用這些狀態下博文內容的代碼。更新後的 main 的代碼如範例 17-21 所示:

檔案名: src/main.rs

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

範例 17-21: main 中使用新的博文工作流實現的修改

不得不修改 main 來重新賦值 post 使得這個實現不再完全遵守面向對象的狀態模式:狀態間的轉換不再完全封裝在 Post 實現中。然而,得益於類型系統和編譯時類型檢查,我們得到了的是無效狀態是不可能的!這確保了某些特定的 bug,比如顯示未發布博文的內容,將在部署到生產環境之前被發現。

嘗試為範例 17-20 之後的 blog crate 實現這一部分開始所建議的增加額外需求的任務來體會使用這個版本的代碼是何感覺。注意在這個設計中一些需求可能已經完成了。

即便 Rust 能夠實現面向對象設計模式,也有其他像將狀態編碼進類型這樣的模式存在。這些模式有著不同的權衡取捨。雖然你可能非常熟悉面向對象模式,重新思考這些問題來利用 Rust 提供的像在編譯時避免一些 bug 這樣有益功能。在 Rust 中面向對象模式並不總是最好的解決方案,因為 Rust 擁有像所有權這樣的面向對象語言所沒有的功能。

總結

閱讀本章後,不管你是否認為 Rust 是一個面向對象語言,現在你都見識了 trait 對象是一個 Rust 中獲取部分面向對象功能的方法。動態分發可以透過犧牲少量運行時性能來為你的代碼提供一些靈活性。這些靈活性可以用來實現有助於代碼可維護性的面向對象模式。Rust 也有像所有權這樣不同於面向對象語言的功能。面向對象模式並不總是利用 Rust 優勢的最好方式,但也是可用的選項。

接下來,讓我們看看另一個提供了多樣靈活性的 Rust 功能:模式。貫穿全書的模式, 我們已經和它們打過照面了,但並沒有見識過它們的全部本領。讓我們開始探索吧!

模式用來匹配值的結構

ch18-00-patterns.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

模式是 Rust 中特殊的語法,它用來匹配類型中的結構,無論類型是簡單還是複雜。結合使用模式和 match 表達式以及其他結構可以提供更多對程序控制流的支配權。模式由如下一些內容組合而成:

  • 字面值
  • 解構的數組、枚舉、結構體或者元組
  • 變數
  • 通配符
  • 占位符

這些部分描述了我們要處理的數據的形狀,接著可以用其匹配值來決定程序是否擁有正確的數據來運行特定部分的代碼。

我們透過將一些值與模式相比較來使用它。如果模式匹配這些值,我們對值部分進行相應處理。回憶一下第六章討論 match 表達式時像硬幣分類器那樣使用模式。如果數據符合這個形狀,就可以使用這些命名的片段。如果不符合,與該模式相關的代碼則不會運行。

本章是所有模式相關內容的參考。我們將涉及到使用模式的有效位置,refutableirrefutable 模式的區別,和你可能會見到的不同類型的模式語法。在最後,你將會看到如何使用模式創建強大而簡潔的代碼。

所有可能會用到模式的位置

ch18-01-all-the-places-for-patterns.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

模式出現在 Rust 的很多地方。你已經在不經意間使用了很多模式!本部分是一個所有有效模式位置的參考。

match 分支

如第六章所討論的,一個模式常用的位置是 match 表達式的分支。在形式上 match 表達式由 match 關鍵字、用於匹配的值和一個或多個分支構成,這些分支包含一個模式和在值匹配分支的模式時運行的表達式:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

match 表達式必須是 窮盡exhaustive)的,意為 match 表達式所有可能的值都必須被考慮到。一個確保覆蓋每個可能值的方法是在最後一個分支使用捕獲所有的模式:比如,一個匹配任何值的名稱永遠也不會失敗,因此可以覆蓋所有匹配剩下的情況。

有一個特定的模式 _ 可以匹配所有情況,不過它從不綁定任何變數。這在例如希望忽略任何未指定值的情況很有用。本章之後的 “忽略模式中的值” 部分會詳細介紹 _ 模式的更多細節。

if let 條件表達式

第六章討論過了 if let 表達式,以及它是如何主要用於編寫等同於只關心一個情況的 match 語句簡寫的。if let 可以對應一個可選的帶有代碼的 elseif let 中的模式不匹配時運行。

範例 18-1 展示了也可以組合併匹配 if letelse ifelse if let 表達式。這相比 match 表達式一次只能將一個值與模式比較提供了更多靈活性;一系列 if letelse ifelse if let 分支並不要求其條件相互關聯。

範例 18-1 中的代碼展示了一系列針對不同條件的檢查來決定背景顏色應該是什麼。為了達到這個例子的目的,我們創建了寫死值的變數,在真實程序中則可能由詢問用戶獲得。

檔案名: src/main.rs

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {}, as the background", color);
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}

範例 18-1: 結合 if letelse ifelse if let 以及 else

如果用戶指定了中意的顏色,將使用其作為背景顏色。如果今天是星期二,背景顏色將是綠色。如果用戶指定了他們的年齡字串並能夠成功將其解析為數字的話,我們將根據這個數字使用紫色或者橙色。最後,如果沒有一個條件符合,背景顏色將是藍色:

這個條件結構允許我們支持複雜的需求。使用這裡寫死的值,例子會列印出 Using purple as the background color

注意 if let 也可以像 match 分支那樣引入覆蓋變數:if let Ok(age) = age 引入了一個新的覆蓋變數 age,它包含 Ok 成員中的值。這意味著 if age > 30 條件需要位於這個代碼塊內部;不能將兩個條件組合為 if let Ok(age) = age && age > 30,因為我們希望與 30 進行比較的被覆蓋的 age 直到大括號開始的新作用域才是有效的。

if let 表達式的缺點在於其窮盡性沒有為編譯器所檢查,而 match 表達式則檢查了。如果去掉最後的 else 塊而遺漏處理一些情況,編譯器也不會警告這類可能的邏輯錯誤。

while let 條件循環

一個與 if let 結構類似的是 while let 條件循環,它允許只要模式匹配就一直進行 while 循環。範例 18-2 展示了一個使用 while let 的例子,它使用 vector 作為棧並以先進後出的方式列印出 vector 中的值:


#![allow(unused)]
fn main() {
let mut stack = Vec::new();

stack.push(1);
stack.push(2);
stack.push(3);

while let Some(top) = stack.pop() {
    println!("{}", top);
}
}

列表 18-2: 使用 while let 循環只要 stack.pop() 返回 Some 就列印出其值

這個例子會列印出 3、2 接著是 1。pop 方法取出 vector 的最後一個元素並返回 Some(value)。如果 vector 是空的,它返回 Nonewhile 循環只要 pop 返回 Some 就會一直運行其塊中的代碼。一旦其返回 Nonewhile 循環停止。我們可以使用 while let 來彈出棧中的每一個元素。

for 循環

如同第三章所講的,for 循環是 Rust 中最常見的循環結構,不過還沒有講到的是 for 可以獲取一個模式。在 for 循環中,模式是 for 關鍵字直接跟隨的值,正如 for x in y 中的 x

範例 18-3 中展示了如何使用 for 循環來解構,或拆開一個元組作為 for 循環的一部分:


#![allow(unused)]
fn main() {
let v = vec!['a', 'b', 'c'];

for (index, value) in v.iter().enumerate() {
    println!("{} is at index {}", value, index);
}
}

列表 18-3: 在 for 循環中使用模式來解構元組

範例 18-3 的代碼會列印出:

a is at index 0
b is at index 1
c is at index 2

這裡使用 enumerate 方法適配一個疊代器來產生一個值和其在疊代器中的索引,他們位於一個元組中。第一個 enumerate 調用會產生元組 (0, 'a')。當這個值匹配模式 (index, value)index 將會是 0 而 value 將會是 'a',並列印出第一行輸出。

let 語句

在本章之前,我們只明確的討論過通過 matchif let 使用模式,不過事實上也在別地地方使用過模式,包括 let 語句。例如,考慮一下這個直接的 let 變數賦值:


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

本書進行了不下百次這樣的操作,不過你可能沒有發覺,這正是在使用模式!let 語句更為正式的樣子如下:

let PATTERN = EXPRESSION;

let x = 5; 這樣的語句中變數名位於 PATTERN 位置,變數名不過是形式特別樸素的模式。我們將表達式與模式比較,並為任何找到的名稱賦值。所以例如 let x = 5; 的情況,x 是一個模式代表 “將匹配到的值綁定到變數 x”。同時因為名稱 x 是整個模式,這個模式實際上等於 “將任何值綁定到變數 x,不管值是什麼”。

為了更清楚的理解 let 的模式匹配方面的內容,考慮範例 18-4 中使用 let 和模式解構一個元組:


#![allow(unused)]
fn main() {
let (x, y, z) = (1, 2, 3);
}

範例 18-4: 使用模式解構元組並一次創建三個變數

這裡將一個元組與模式匹配。Rust 會比較值 (1, 2, 3) 與模式 (x, y, z) 並發現此值匹配這個模式。在這個例子中,將會把 1 綁定到 x2 綁定到 y 並將 3 綁定到 z。你可以將這個元組模式看作是將三個獨立的變數模式結合在一起。

如果模式中元素的數量不匹配元組中元素的數量,則整個類型不匹配,並會得到一個編譯時錯誤。例如,範例 18-5 展示了嘗試用兩個變數解構三個元素的元組,這是不行的:

let (x, y) = (1, 2, 3);

範例 18-5: 一個錯誤的模式結構,其中變數的數量不符合元組中元素的數量

嘗試編譯這段代碼會給出如下類型錯誤:

error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^ expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected type `({integer}, {integer}, {integer})`
             found type `(_, _)`

如果希望忽略元組中一個或多個值,也可以使用 _..,如 “忽略模式中的值” 部分所示。如果問題是模式中有太多的變數,則解決方法是通過去掉變數使得變數數與元組中元素數相等。

函數參數

函數參數也可以是模式。列表 18-6 中的代碼聲明了一個叫做 foo 的函數,它獲取一個 i32 類型的參數 x,現在這看起來應該很熟悉:


#![allow(unused)]
fn main() {
fn foo(x: i32) {
    // 代碼
}
}

列表 18-6: 在參數中使用模式的函數簽名

x 部分就是一個模式!類似於之前對 let 所做的,可以在函數參數中匹配元組。列表 18-7 將傳遞給函數的元組拆分為值:

檔案名: src/main.rs

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({}, {})", x, y);
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}

列表 18-7: 一個在參數中解構元組的函數

這會列印出 Current location: (3, 5)。值 &(3, 5) 會匹配模式 &(x, y),如此 x 得到了值 3,而 y得到了值 5

因為如第十三章所講閉包類似於函數,也可以在閉包參數列表中使用模式。

現在我們見過了很多使用模式的方式了,不過模式在每個使用它的地方並不以相同的方式工作;在一些地方,模式必須是 irrefutable 的,意味著他們必須匹配所提供的任何值。在另一些情況,他們則可以是 refutable 的。接下來讓我們討論這兩個概念。

Refutability(可反駁性): 模式是否會匹配失效

ch18-02-refutability.md
commit 30fe5484f3923617410032d28e86a5afdf4076fb

模式有兩種形式:refutable(可反駁的)和 irrefutable(不可反駁的)。能匹配任何傳遞的可能值的模式被稱為是 不可反駁的irrefutable)。一個例子就是 let x = 5; 語句中的 x,因為 x 可以匹配任何值所以不可能會失敗。對某些可能的值進行匹配會失敗的模式被稱為是 可反駁的refutable)。一個這樣的例子便是 if let Some(x) = a_value 表達式中的 Some(x);如果變數 a_value 中的值是 None 而不是 Some,那麼 Some(x) 模式不能匹配。

函數參數、 let 語句和 for 循環只能接受不可反駁的模式,因為通過不匹配的值程序無法進行有意義的工作。if letwhile let 表達式被限制為只能接受可反駁的模式,因為根據定義他們意在處理可能的失敗:條件表達式的功能就是根據成功或失敗執行不同的操作。

通常我們無需擔心可反駁和不可反駁模式的區別,不過確實需要熟悉可反駁性的概念,這樣當在錯誤訊息中看到時就知道如何應對。遇到這些情況,根據代碼行為的意圖,需要修改模式或者使用模式的結構。

讓我們看看一個嘗試在 Rust 要求不可反駁模式的地方使用可反駁模式以及相反情況的例子。在範例 18-8 中,有一個 let 語句,不過模式被指定為可反駁模式 Some(x)。如你所見,這不能編譯:

let Some(x) = some_option_value;

範例 18-8: 嘗試在 let 中使用可反駁模式

如果 some_option_value 的值是 None,其不會成功匹配模式 Some(x),表明這個模式是可反駁的。然而 let 語句只能接受不可反駁模式因為代碼不能通過 None 值進行有效的操作。Rust 會在編譯時抱怨我們嘗試在要求不可反駁模式的地方使用可反駁模式:

error[E0005]: refutable pattern in local binding: `None` not covered
 -->
  |
3 | let Some(x) = some_option_value;
  |     ^^^^^^^ pattern `None` not covered

因為我們沒有覆蓋(也不可能覆蓋!)到模式 Some(x) 的每一個可能的值, 所以 Rust 會合理地抗議。

為了修復在需要不可反駁模式的地方使用可反駁模式的情況,可以修改使用模式的代碼:不同於使用 let,可以使用 if let。如此,如果模式不匹配,大括號中的代碼將被忽略,其餘代碼保持有效。範例 18-9 展示了如何修復範例 18-8 中的代碼。


#![allow(unused)]
fn main() {
let some_option_value: Option<i32> = None;
if let Some(x) = some_option_value {
    println!("{}", x);
}
}

範例 18-9: 使用 if let 和一個帶有可反駁模式的代碼塊來代替 let

我們給了代碼一個得以繼續的出路!這段代碼可以完美運行,儘管這意味著我們不能再使用不可反駁模式並免於收到錯誤。如果為 if let 提供了一個總是會匹配的模式,比如範例 18-10 中的 x,編譯器會給出一個警告:

if let x = 5 {
    println!("{}", x);
};

範例 18-10: 嘗試把不可反駁模式用到 if let

Rust 會抱怨將不可反駁模式用於 if let 是沒有意義的:

warning: irrefutable if-let pattern
 --> <anon>:2:5
  |
2 | /     if let x = 5 {
3 | |     println!("{}", x);
4 | | };
  | |_^
  |
  = note: #[warn(irrefutable_let_patterns)] on by default

基於此,match匹配分支必須使用可反駁模式,除了最後一個分支需要使用能匹配任何剩餘值的不可反駁模式。Rust允許我們在只有一個匹配分支的match中使用不可反駁模式,不過這麼做不是特別有用,並可以被更簡單的 let 語句替代。

目前我們已經討論了所有可以使用模式的地方, 以及可反駁模式與不可反駁模式的區別,下面讓我們一起去把可以用來創建模式的語法過目一遍吧。

所有的模式語法

ch18-03-pattern-syntax.md
commit 86f0ae4831f24b3c429fa4845b900b4cad903a8b

通過本書我們已領略過許多不同類型模式的例子。在本節中,我們收集了模式中所有有效的語法,並討論了為什麼可能要使用每個語法。

匹配字面值

如第六章所示,可以直接匹配字面值模式。如下代碼給出了一些例子:


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

match x {
    1 => println!("one"),
    2 => println!("two"),
    3 => println!("three"),
    _ => println!("anything"),
}
}

這段代碼會列印 one 因為 x 的值是 1。如果希望代碼獲得特定的具體值,則該語法很有用。

匹配命名變數

命名變數是匹配任何值的不可反駁模式,這在之前已經使用過數次。然而當其用於 match 表達式時情況會有些複雜。因為 match 會開始一個新作用域,match 表達式中作為模式的一部分聲明的變數會覆蓋 match 結構之外的同名變數,與所有變數一樣。在範例 18-11 中,聲明了一個值為 Some(5) 的變數 x 和一個值為 10 的變數 y。接著在值 x 上創建了一個 match 表達式。觀察匹配分支中的模式和結尾的 println!,並在運行此代碼或進一步閱讀之前推斷這段代碼會列印什麼。

檔案名: src/main.rs

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

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {:?}", y),
        _ => println!("Default case, x = {:?}", x),
    }

    println!("at the end: x = {:?}, y = {:?}", x, y);
}

範例 18-11: 一個 match 語句其中一個分支引入了覆蓋變數 y

讓我們看看當 match 語句運行的時候發生了什麼事。第一個匹配分支的模式並不匹配 x 中定義的值,所以代碼繼續執行。

第二個匹配分支中的模式引入了一個新變數 y,它會匹配任何 Some 中的值。因為我們在 match 表達式的新作用域中,這是一個新變數,而不是開頭聲明為值 10 的那個 y。這個新的 y 綁定會匹配任何 Some 中的值,在這裡是 x 中的值。因此這個 y 綁定了 xSome 內部的值。這個值是 5,所以這個分支的表達式將會執行並列印出 Matched, y = 5

如果 x 的值是 None 而不是 Some(5),頭兩個分支的模式不會匹配,所以會匹配下劃線。這個分支的模式中沒有引入變數 x,所以此時表達式中的 x 會是外部沒有被覆蓋的 x。在這個假想的例子中,match 將會列印 Default case, x = None

一旦 match 表達式執行完畢,其作用域也就結束了,同理內部 y 的作用域也結束了。最後的 println! 會列印 at the end: x = Some(5), y = 10

為了創建能夠比較外部 xy 的值,而不引入覆蓋變數的 match 表達式,我們需要相應地使用帶有條件的匹配守衛(match guard)。我們稍後將在 “匹配守衛提供的額外條件” 這一小節討論匹配守衛。

多個模式

match 表達式中,可以使用 | 語法匹配多個模式,它代表 or)的意思。例如,如下代碼將 x 的值與匹配分支相比較,第一個分支有 選項,意味著如果 x 的值匹配此分支的任一個值,它就會運行:


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

match x {
    1 | 2 => println!("one or two"),
    3 => println!("three"),
    _ => println!("anything"),
}
}

上面的代碼會列印 one or two

通過 ..= 匹配值的範圍

..= 語法允許你匹配一個閉區間範圍內的值。在如下代碼中,當模式匹配任何在此範圍內的值時,該分支會執行:


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

match x {
    1..=5 => println!("one through five"),
    _ => println!("something else"),
}
}

如果 x 是 1、2、3、4 或 5,第一個分支就會匹配。這相比使用 | 運算符表達相同的意思更為方便;相比 1..=5,使用 | 則不得不指定 1 | 2 | 3 | 4 | 5。相反指定範圍就簡短的多,特別是在希望匹配比如從 1 到 1000 的數字的時候!

範圍只允許用於數字或 char 值,因為編譯器會在編譯時檢查範圍不為空。char 和 數字值是 Rust 僅有的可以判斷範圍是否為空的類型。

如下是一個使用 char 類型值範圍的例子:


#![allow(unused)]
fn main() {
let x = 'c';

match x {
    'a'..='j' => println!("early ASCII letter"),
    'k'..='z' => println!("late ASCII letter"),
    _ => println!("something else"),
}
}

Rust 知道 c 位於第一個模式的範圍內,並會列印出 early ASCII letter

解構並分解值

也可以使用模式來解構結構體、枚舉、元組和引用,以便使用這些值的不同部分。讓我們來分別看一看。

解構結構體

範例 18-12 展示帶有兩個欄位 xy 的結構體 Point,可以通過帶有模式的 let 語句將其分解:

檔案名: src/main.rs

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}

範例 18-12: 解構一個結構體的欄位為單獨的變數

這段代碼創建了變數 ab 來匹配結構體 p 中的 xy 欄位。這個例子展示了模式中的變數名不必與結構體中的欄位名一致。不過通常希望變數名與欄位名一致以便於理解變數來自於哪些欄位。

因為變數名匹配欄位名是常見的,同時因為 let Point { x: x, y: y } = p; 包含了很多重複,所以對於匹配結構體欄位的模式存在簡寫:只需列出結構體欄位的名稱,則模式創建的變數會有相同的名稱。範例 18-13 展示了與範例 18-12 有著相同行為的代碼,不過 let 模式創建的變數為 xy 而不是 ab

檔案名: src/main.rs

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}

範例 18-13: 使用結構體欄位簡寫來解構結構體欄位

這段代碼創建了變數 xy,與變數 p 中的 xy 相匹配。其結果是變數 xy 包含結構體 p 中的值。

也可以使用字面值作為結構體模式的一部分進行進行解構,而不是為所有的欄位創建變數。這允許我們測試一些欄位為特定值的同時創建其他欄位的變數。

範例 18-14 展示了一個 match 語句將 Point 值分成了三種情況:直接位於 x 軸上(此時 y = 0 為真)、位於 y 軸上(x = 0)或不在任何軸上的點。

檔案名: src/main.rs

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {}", x),
        Point { x: 0, y } => println!("On the y axis at {}", y),
        Point { x, y } => println!("On neither axis: ({}, {})", x, y),
    }
}

範例 18-14: 解構和匹配模式中的字面值

第一個分支通過指定欄位 y 匹配字面值 0 來匹配任何位於 x 軸上的點。此模式仍然創建了變數 x 以便在分支的代碼中使用。

類似的,第二個分支通過指定欄位 x 匹配字面值 0 來匹配任何位於 y 軸上的點,並為欄位 y 創建了變數 y。第三個分支沒有指定任何字面值,所以其會匹配任何其他的 Point 並為 xy 兩個欄位創建變數。

在這個例子中,值 p 因為其 x 包含 0 而匹配第二個分支,因此會列印出 On the y axis at 7

解構枚舉

本書之前的部分曾經解構過枚舉,比如第六章中範例 6-5 中解構了一個 Option<i32>。一個當時沒有明確提到的細節是解構枚舉的模式需要對應枚舉所定義的儲存數據的方式。讓我們以範例 6-2 中的 Message 枚舉為例,編寫一個 match 使用模式解構每一個內部值,如範例 18-15 所示:

檔案名: src/main.rs

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.")
        }
        Message::Move { x, y } => {
            println!(
                "Move in the x direction {} and in the y direction {}",
                x,
                y
            );
        }
        Message::Write(text) => println!("Text message: {}", text),
        Message::ChangeColor(r, g, b) => {
            println!(
                "Change the color to red {}, green {}, and blue {}",
                r,
                g,
                b
            )
        }
    }
}

範例 18-15: 解構包含不同類型值成員的枚舉

這段代碼會列印出 Change the color to red 0, green 160, and blue 255。嘗試改變 msg 的值來觀察其他分支代碼的運行。

對於像 Message::Quit 這樣沒有任何數據的枚舉成員,不能進一步解構其值。只能匹配其字面值 Message::Quit,因此模式中沒有任何變數。

對於像 Message::Move 這樣的類結構體枚舉成員,可以採用類似於匹配結構體的模式。在成員名稱後,使用大括號並列出欄位變數以便將其分解以供此分支的代碼使用。這裡使用了範例 18-13 所展示的簡寫。

對於像 Message::Write 這樣的包含一個元素,以及像 Message::ChangeColor 這樣包含三個元素的類元組枚舉成員,其模式則類似於用於解構元組的模式。模式中變數的數量必須與成員中元素的數量一致。

解構嵌套的結構體和枚舉

目前為止,所有的例子都只匹配了深度為一級的結構體或枚舉。當然也可以匹配嵌套的項!

例如,我們可以重構列表 18-15 的代碼來同時支持 RGB 和 HSV 色彩模式:

enum Color {
   Rgb(i32, i32, i32),
   Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!(
                "Change the color to red {}, green {}, and blue {}",
                r,
                g,
                b
            )
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!(
                "Change the color to hue {}, saturation {}, and value {}",
                h,
                s,
                v
            )
        }
        _ => ()
    }
}

範例 18-16: 匹配嵌套的枚舉

match 表達式第一個分支的模式匹配一個包含 Color::Rgb 枚舉成員的 Message::ChangeColor 枚舉成員,然後模式綁定了 3 個內部的 i32 值。第二個分支的模式也匹配一個 Message::ChangeColor 枚舉成員, 但是其內部的枚舉會匹配 Color::Hsv 枚舉成員。我們可以在一個 match 表達式中指定這些複雜條件,即使會涉及到兩個枚舉。

解構結構體和元組

甚至可以用複雜的方式來混合、匹配和嵌套解構模式。如下是一個複雜結構體的例子,其中結構體和元組嵌套在元組中,並將所有的原始類型解構出來:


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}

let ((feet, inches), Point {x, y}) = ((3, 10), Point { x: 3, y: -10 });
}

這將複雜的類型分解成部分組件以便可以單獨使用我們感興趣的值。

透過模式解構是一個方便利用部分值片段的手段,比如結構體中每個單獨欄位的值。

忽略模式中的值

有時忽略模式中的一些值是有用的,比如 match 中最後捕獲全部情況的分支實際上沒有做任何事,但是它確實對所有剩餘情況負責。有一些簡單的方法可以忽略模式中全部或部分值:使用 _ 模式(我們已經見過了),在另一個模式中使用 _ 模式,使用一個以下劃線開始的名稱,或者使用 .. 忽略所剩部分的值。讓我們來分別探索如何以及為什麼要這麼做。

使用 _ 忽略整個值

我們已經使用過下劃線(_)作為匹配但不綁定任何值的通配符模式了。雖然 _ 模式作為 match 表達式最後的分支特別有用,也可以將其用於任意模式,包括函數參數中,如範例 18-17 所示:

檔案名: src/main.rs

fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {}", y);
}

fn main() {
    foo(3, 4);
}

範例 18-17: 在函數簽名中使用 _

這段代碼會完全忽略作為第一個參數傳遞的值 3,並會列印出 This code only uses the y parameter: 4

大部分情況當你不再需要特定函數參數時,最好修改簽名不再包含無用的參數。在一些情況下忽略函數參數會變得特別有用,比如實現 trait 時,當你需要特定類型簽名但是函數實現並不需要某個參數時。此時編譯器就不會警告說存在未使用的函數參數,就跟使用命名參數一樣。

使用嵌套的 _ 忽略部分值

也可以在一個模式內部使用_ 忽略部分值,例如,當只需要測試部分值但在期望運行的代碼中沒有用到其他部分時。範例 18-18 展示了負責管理設置值的代碼。業務需求是用戶不允許覆蓋現有的自訂設置,但是可以取消設置,也可以在當前未設置時為其提供設置。


#![allow(unused)]
fn main() {
let mut setting_value = Some(5);
let new_setting_value = Some(10);

match (setting_value, new_setting_value) {
    (Some(_), Some(_)) => {
        println!("Can't overwrite an existing customized value");
    }
    _ => {
        setting_value = new_setting_value;
    }
}

println!("setting is {:?}", setting_value);
}

範例 18-18: 當不需要 Some 中的值時在模式內使用下劃線來匹配 Some 成員

這段代碼會列印出 Can't overwrite an existing customized value 接著是 setting is Some(5)。在第一個匹配分支,我們不需要匹配或使用任一個 Some 成員中的值;重要的部分是需要測試 setting_valuenew_setting_value 都為 Some 成員的情況。在這種情況,我們列印出為何不改變 setting_value,並且不會改變它。

對於所有其他情況(setting_valuenew_setting_value 任一為 None),這由第二個分支的 _ 模式體現,這時確實希望允許 new_setting_value 變為 setting_value

也可以在一個模式中的多處使用下劃線來忽略特定值,如範例 18-19 所示,這裡忽略了一個五元元組中的第二和第四個值:


#![allow(unused)]
fn main() {
let numbers = (2, 4, 8, 16, 32);

match numbers {
    (first, _, third, _, fifth) => {
        println!("Some numbers: {}, {}, {}", first, third, fifth)
    },
}
}

範例 18-19: 忽略元組的多個部分

這會列印出 Some numbers: 2, 8, 32, 值 4 和 16 會被忽略。

透過在名字前以一個下劃線開頭來忽略未使用的變數

如果你創建了一個變數卻不在任何地方使用它, Rust 通常會給你一個警告,因為這可能會是個 bug。但是有時創建一個還未使用的變數是有用的,比如你正在設計原型或剛剛開始一個項目。這時你希望告訴 Rust 不要警告未使用的變數,為此可以用下劃線作為變數名的開頭。範例 18-20 中創建了兩個未使用變數,不過當運行程式碼時只會得到其中一個的警告:

檔案名: src/main.rs

fn main() {
    let _x = 5;
    let y = 10;
}

範例 18-20: 以下劃線開始變數名以便去掉未使用變數警告

這裡得到了警告說未使用變數 y,不過沒有警告說未使用下劃線開頭的變數。

注意, 只使用 _ 和使用以下劃線開頭的名稱有些微妙的不同:比如 _x 仍會將值綁定到變數,而 _ 則完全不會綁定。為了展示這個區別的意義,範例 18-21 會產生一個錯誤。

let s = Some(String::from("Hello!"));

if let Some(_s) = s {
    println!("found a string");
}

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

範例 18-21: 以下劃線開頭的未使用變數仍然會綁定值,它可能會獲取值的所有權

我們會得到一個錯誤,因為 s 的值仍然會移動進 _s,並阻止我們再次使用 s。然而只使用下劃線本身,並不會綁定值。範例 18-22 能夠無錯編譯,因為 s 沒有被移動進 _


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

if let Some(_) = s {
    println!("found a string");
}

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

範例 18-22: 單獨使用下劃線不會綁定值

上面的代碼能很好的運行;因為沒有把 s 綁定到任何變數;它沒有被移動。

.. 忽略剩餘值

對於有多個部分的值,可以使用 .. 語法來只使用部分並忽略其它值,同時避免不得不每一個忽略值列出下劃線。.. 模式會忽略模式中剩餘的任何沒有顯式匹配的值部分。在範例 18-23 中,有一個 Point 結構體存放了三維空間中的坐標。在 match 表達式中,我們希望只操作 x 坐標並忽略 yz 欄位的值:


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
    z: i32,
}

let origin = Point { x: 0, y: 0, z: 0 };

match origin {
    Point { x, .. } => println!("x is {}", x),
}
}

範例 18-23: 透過使用 .. 來忽略 Point 中除 x 以外的欄位

這裡列出了 x 值,接著僅僅包含了 .. 模式。這比不得不列出 y: _z: _ 要來得簡單,特別是在處理有很多欄位的結構體,但只涉及一到兩個欄位時的情形。

.. 會擴展為所需要的值的數量。範例 18-24 展示了元組中 .. 的應用:

檔案名: src/main.rs

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {}, {}", first, last);
        },
    }
}

範例 18-24: 只匹配元組中的第一個和最後一個值並忽略掉所有其它值

這裡用 firstlast 來匹配第一個和最後一個值。.. 將匹配並忽略中間的所有值。

然而使用 .. 必須是無歧義的。如果期望匹配和忽略的值是不明確的,Rust 會報錯。範例 18-25 展示了一個帶有歧義的 .. 例子,因此其不能編譯:

檔案名: src/main.rs

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {}", second)
        },
    }
}

範例 18-25: 嘗試以有歧義的方式運用 ..

如果編譯上面的例子,會得到下面的錯誤:

error: `..` can only be used once per tuple or tuple struct pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |                      ^^

Rust 不可能決定在元組中匹配 second 值之前應該忽略多少個值,以及在之後忽略多少個值。這段代碼可能表明我們意在忽略 2,綁定 second4,接著忽略 81632;抑或是意在忽略 24,綁定 second8,接著忽略 1632,以此類推。變數名 second 對於 Rust 來說並沒有任何特殊意義,所以會得到編譯錯誤,因為在這兩個地方使用 .. 是有歧義的。

匹配守衛提供的額外條件

匹配守衛match guard)是一個指定於 match 分支模式之後的額外 if 條件,它也必須被滿足才能選擇此分支。匹配守衛用於表達比單獨的模式所能允許的更為複雜的情況。

這個條件可以使用模式中創建的變數。範例 18-26 展示了一個 match,其中第一個分支有模式 Some(x) 還有匹配守衛 if x < 5


#![allow(unused)]
fn main() {
let num = Some(4);

match num {
    Some(x) if x < 5 => println!("less than five: {}", x),
    Some(x) => println!("{}", x),
    None => (),
}
}

範例 18-26: 在模式中加入匹配守衛

上例會列印出 less than five: 4。當 num 與模式中第一個分支比較時,因為 Some(4) 匹配 Some(x) 所以可以匹配。接著匹配守衛檢查 x 值是否小於 5,因為 4 小於 5,所以第一個分支被選擇。

相反如果 numSome(10),因為 10 不小於 5 所以第一個分支的匹配守衛為假。接著 Rust 會前往第二個分支,這會匹配因為它沒有匹配守衛所以會匹配任何 Some 成員。

無法在模式中表達 if x < 5 的條件,所以匹配守衛提供了表現此邏輯的能力。

在範例 18-11 中,我們提到可以使用匹配守衛來解決模式中變數覆蓋的問題,那裡 match 表達式的模式中新建了一個變數而不是使用 match 之外的同名變數。新變數意味著不能夠測試外部變數的值。範例 18-27 展示了如何使用匹配守衛修復這個問題。

檔案名: src/main.rs

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

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {}", n),
        _ => println!("Default case, x = {:?}", x),
    }

    println!("at the end: x = {:?}, y = {}", x, y);
}

範例 18-27: 使用匹配守衛來測試與外部變數的相等性

現在這會列印出 Default case, x = Some(5)。現在第二個匹配分支中的模式不會引入一個覆蓋外部 y 的新變數 y,這意味著可以在匹配守衛中使用外部的 y。相比指定會覆蓋外部 y 的模式 Some(y),這裡指定為 Some(n)。此新建的變數 n 並沒有覆蓋任何值,因為 match 外部沒有變數 n

匹配守衛 if n == y 並不是一個模式所以沒有引入新變數。這個 y 正是 外部的 y 而不是新的覆蓋變數 y,這樣就可以通過比較 ny 來表達尋找一個與外部 y 相同的值的概念了。

也可以在匹配守衛中使用 運算符 | 來指定多個模式,同時匹配守衛的條件會作用於所有的模式。範例 18-28 展示了結合匹配守衛與使用了 | 的模式的優先度。這個例子中重要的部分是匹配守衛 if y 作用於 45 6,即使這看起來好像 if y 只作用於 6


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

match x {
    4 | 5 | 6 if y => println!("yes"),
    _ => println!("no"),
}
}

範例 18-28: 結合多個模式與匹配守衛

這個匹配條件表明此分支值匹配 x 值為 456 同時 ytrue 的情況。運行這段代碼時會發生的是第一個分支的模式因 x4 而匹配,不過匹配守衛 if y 為假,所以第一個分支不會被選擇。代碼移動到第二個分支,這會匹配,此程序會列印出 no。這是因為 if 條件作用於整個 4 | 5 | 6 模式,而不僅是最後的值 6。換句話說,匹配守衛與模式的優先度關係看起來像這樣:

(4 | 5 | 6) if y => ...

而不是:

4 | 5 | (6 if y) => ...

可以通過運行程式碼時的情況看出這一點:如果匹配守衛只作用於由 | 運算符指定的值列表的最後一個值,這個分支就會匹配且程序會列印出 yes

@ 綁定

at 運算符(@)允許我們在創建一個存放值的變數的同時測試其值是否匹配模式。範例 18-29 展示了一個例子,這裡我們希望測試 Message::Helloid 欄位是否位於 3..=7 範圍內,同時也希望能將其值綁定到 id_variable 變數中以便此分支相關聯的代碼可以使用它。可以將 id_variable 命名為 id,與欄位同名,不過出於範例的目的這裡選擇了不同的名稱。


#![allow(unused)]
fn main() {
enum Message {
    Hello { id: i32 },
}

let msg = Message::Hello { id: 5 };

match msg {
    Message::Hello { id: id_variable @ 3..=7 } => {
        println!("Found an id in range: {}", id_variable)
    },
    Message::Hello { id: 10..=12 } => {
        println!("Found an id in another range")
    },
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}
}

範例 18-29: 使用 @ 在模式中綁定值的同時測試它

上例會列印出 Found an id in range: 5。通過在 3..=7 之前指定 id_variable @,我們捕獲了任何匹配此範圍的值並同時測試其值匹配這個範圍模式。

第二個分支只在模式中指定了一個範圍,分支相關代碼代碼沒有一個包含 id 欄位實際值的變數。id 欄位的值可以是 10、11 或 12,不過這個模式的代碼並不知情也不能使用 id 欄位中的值,因為沒有將 id 值保存進一個變數。

最後一個分支指定了一個沒有範圍的變數,此時確實擁有可以用於分支代碼的變數 id,因為這裡使用了結構體欄位簡寫語法。不過此分支中沒有像頭兩個分支那樣對 id 欄位的值進行測試:任何值都會匹配此分支。

使用 @ 可以在一個模式中同時測試和保存變數值。

總結

模式是 Rust 中一個很有用的功能,它幫助我們區分不同類型的數據。當用於 match 語句時,Rust 確保模式會包含每一個可能的值,否則程序將不能編譯。let 語句和函數參數的模式使得這些結構更強大,可以在將值解構為更小部分的同時為變數賦值。可以創建簡單或複雜的模式來滿足我們的要求。

接下來,在本書倒數第二章中,我們將介紹一些 Rust 眾多功能中較為高級的部分。

高級特徵

ch19-00-advanced-features.md
commit 10f89936b02dc366a2d0b34083b97cadda9e0ce4

現在我們已經學習了 Rust 程式語言中最常用的部分。在第二十章開始另一個新項目之前,讓我們聊聊一些總有一天你會遇上的部分內容。你可以將本章作為不經意間遇到未知的內容時的參考。本章將要學習的功能在一些非常特定的場景下很有用處。雖然很少會碰到它們,我們希望確保你了解 Rust 提供的所有功能。

本章將涉及如下內容:

  • 不安全 Rust:用於當需要捨棄 Rust 的某些保證並負責手動維持這些保證
  • 高級 trait:與 trait 相關的關聯類型,默認類型參數,完全限定語法(fully qualified syntax),超(父)trait(supertraits)和 newtype 模式
  • 高級類型:關於 newtype 模式的更多內容,類型別名,never 類型和動態大小類型
  • 高級函數和閉包:函數指針和返回閉包
  • 宏:定義在編譯時定義更多代碼的方式

對所有人而言,這都是一個介紹 Rust 迷人特性的寶典!讓我們翻開它吧!

不安全 Rust

ch19-01-unsafe-rust.md
commit 28fa3d15b0bc67ea5e79eeff2198e4277fc61baf

目前為止討論過的代碼都有 Rust 在編譯時會強制執行的記憶體安全保證。然而,Rust 還隱藏有第二種語言,它不會強制執行這類記憶體安全保證:這被稱為 不安全 Rustunsafe Rust)。它與常規 Rust 代碼無異,但是會提供額外的超級力量。

不安全 Rust 之所以存在,是因為靜態分析本質上是保守的。當編譯器嘗試確定一段代碼是否支持某個保證時,拒絕一些有效的程序比接受無效程序要好一些。這必然意味著有時代碼可能是合法的,但是 Rust 不這麼認為!在這種情況下,可以使用不安全代碼告訴編譯器,“相信我,我知道我在幹什麼。”這麼做的缺點就是你只能靠自己了:如果不安全代碼出錯了,比如解引用空指針,可能會導致不安全的記憶體使用。

另一個 Rust 存在不安全一面的原因是:底層計算機硬體固有的不安全性。如果 Rust 不允許進行不安全操作,那麼有些任務則根本完成不了。Rust 需要能夠進行像直接與操作系統交互,甚至於編寫你自己的操作系統這樣的底層系統編程!這也是 Rust 語言的目標之一。讓我們看看不安全 Rust 能做什麼,和怎麼做。

不安全的超級力量

可以通過 unsafe 關鍵字來切換到不安全 Rust,接著可以開啟一個新的存放不安全代碼的塊。這裡有五類可以在不安全 Rust 中進行而不能用於安全 Rust 的操作,它們稱之為 “不安全的超級力量。” 這些超級力量是:

  • 解引用裸指針
  • 調用不安全的函數或方法
  • 訪問或修改可變靜態變數
  • 實現不安全 trait
  • 訪問 union 的欄位

有一點很重要,unsafe 並不會關閉借用檢查器或禁用任何其他 Rust 安全檢查:如果在不安全代碼中使用引用,它仍會被檢查。unsafe 關鍵字只是提供了那五個不會被編譯器檢查記憶體安全的功能。你仍然能在不安全塊中獲得某種程度的安全。

再者,unsafe 不意味著塊中的代碼就一定是危險的或者必然導致記憶體安全問題:其意圖在於作為程式設計師你將會確保 unsafe 塊中的代碼以有效的方式訪問記憶體。

人是會犯錯誤的,錯誤總會發生,不過通過要求這五類操作必須位於標記為 unsafe 的塊中,就能夠知道任何與記憶體安全相關的錯誤必定位於 unsafe 塊內。保持 unsafe 塊儘可能小,如此當之後調查記憶體 bug 時就會感謝你自己了。

為了儘可能隔離不安全代碼,將不安全代碼封裝進一個安全的抽象並提供安全 API 是一個好主意,當我們學習不安全函數和方法時會討論到。標準庫的一部分被實現為在被評審過的不安全代碼之上的安全抽象。這個技術防止了 unsafe 洩露到所有你或者用戶希望使用由 unsafe 代碼實現的功能的地方,因為使用其安全抽象是安全的。

讓我們按順序依次介紹上述五個超級力量,同時我們會看到一些提供不安全代碼的安全介面的抽象。

解引用裸指針

回到第四章的 “懸垂引用” 部分,那裡提到了編譯器會確保引用總是有效的。不安全 Rust 有兩個被稱為 裸指針raw pointers)的類似於引用的新類型。和引用一樣,裸指針是不可變或可變的,分別寫作 *const T*mut T。這裡的星號不是解引用運算符;它是類型名稱的一部分。在裸指針的上下文中,不可變 意味著指針解引用之後不能直接賦值。

與引用和智慧指針的區別在於,記住裸指針

  • 允許忽略借用規則,可以同時擁有不可變和可變的指針,或多個指向相同位置的可變指針
  • 不保證指向有效的記憶體
  • 允許為空
  • 不能實現任何自動清理功能

通過去掉 Rust 強加的保證,你可以放棄安全保證以換取性能或使用另一個語言或硬體介面的能力,此時 Rust 的保證並不適用。

範例 19-1 展示了如何從引用同時創建不可變和可變裸指針。


#![allow(unused)]
fn main() {
let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
}

範例 19-1: 透過引用創建裸指針

注意這裡沒有引入 unsafe 關鍵字。可以在安全代碼中 創建 裸指針,只是不能在不安全塊之外 解引用 裸指針,稍後便會看到。

這裡使用 as 將不可變和可變引用強轉為對應的裸指針類型。因為直接從保證安全的引用來創建他們,可以知道這些特定的裸指針是有效,但是不能對任何裸指針做出如此假設。

接下來會創建一個不能確定其有效性的裸指針,範例 19-2 展示了如何創建一個指向任意記憶體地址的裸指針。嘗試使用任意記憶體是未定義行為:此地址可能有數據也可能沒有,編譯器可能會最佳化掉這個記憶體訪問,或者程序可能會出現段錯誤(segmentation fault)。通常沒有好的理由編寫這樣的代碼,不過卻是可行的:


#![allow(unused)]
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}

範例 19-2: 創建指向任意記憶體地址的裸指針

記得我們說過可以在安全代碼中創建裸指針,不過不能 解引用 裸指針和讀取其指向的數據。現在我們要做的就是對裸指針使用解引用運算符 *,這需要一個 unsafe 塊,如範例 19-3 所示:


#![allow(unused)]
fn main() {
let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
}
}

範例 19-3: 在 unsafe 塊中解引用裸指針

創建一個指針不會造成任何危險;只有當訪問其指向的值時才有可能遇到無效的值。

還需注意範例 19-1 和 19-3 中創建了同時指向相同記憶體位置 num 的裸指針 *const i32*mut i32。相反如果嘗試創建 num 的不可變和可變引用,這將無法編譯因為 Rust 的所有權規則不允許擁有可變引用的同時擁有不可變引用。通過裸指針,就能夠同時創建同一地址的可變指針和不可變指針,若通過可變指針修改數據,則可能潛在造成數據競爭。請多加小心!

既然存在這麼多的危險,為何還要使用裸指針呢?一個主要的應用場景便是調用 C 代碼介面,這在下一部分 “調用不安全函數或方法” 中會講到。另一個場景是構建借用檢查器無法理解的安全抽象。讓我們先介紹不安全函數,接著看一看使用不安全代碼的安全抽象的例子。

調用不安全函數或方法

第二類要求使用不安全塊的操作是調用不安全函數。不安全函數和方法與常規函數方法十分類似,除了其開頭有一個額外的 unsafe。在此上下文中,關鍵字unsafe表示該函數具有調用時需要滿足的要求,而 Rust 不會保證滿足這些要求。通過在 unsafe 塊中調用不安全函數,表明我們已經閱讀過此函數的文件並對其是否滿足函數自身的契約負責。

如下是一個沒有做任何操作的不安全函數 dangerous 的例子:


#![allow(unused)]
fn main() {
unsafe fn dangerous() {}

unsafe {
    dangerous();
}
}

必須在一個單獨的 unsafe 塊中調用 dangerous 函數。如果嘗試不使用 unsafe 塊調用 dangerous,則會得到一個錯誤:

error[E0133]: call to unsafe function requires unsafe function or block
 -->
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function

透過將 dangerous 調用插入 unsafe 塊中,我們就向 Rust 保證了我們已經閱讀過函數的文件,理解如何正確使用,並驗證過其滿足函數的契約。

不安全函數體也是有效的 unsafe 塊,所以在不安全函數中進行另一個不安全操作時無需新增額外的 unsafe 塊。

創建不安全代碼的安全抽象

僅僅因為函數包含不安全代碼並不意味著整個函數都需要標記為不安全的。事實上,將不安全代碼封裝進安全函數是一個常見的抽象。作為一個例子,標準庫中的函數,split_at_mut,它需要一些不安全代碼,讓我們探索如何可以實現它。這個安全函數定義於可變 slice 之上:它獲取一個 slice 並從給定的索引參數開始將其分為兩個 slice。split_at_mut 的用法如範例 19-4 所示:


#![allow(unused)]
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];

let r = &mut v[..];

let (a, b) = r.split_at_mut(3);

assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}

範例 19-4: 使用安全的 split_at_mut 函數

這個函數無法只通過安全 Rust 實現。一個嘗試可能看起來像範例 19-5,它不能編譯。出於簡單考慮,我們將 split_at_mut 實現為函數而不是方法,並只處理 i32 值而非泛型 T 的 slice。

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();

    assert!(mid <= len);

    (&mut slice[..mid],
     &mut slice[mid..])
}

範例 19-5: 嘗試只使用安全 Rust 來實現 split_at_mut

此函數首先獲取 slice 的長度,然後通過檢查參數是否小於或等於這個長度來斷言參數所給定的索引位於 slice 當中。該斷言意味著如果傳入的索引比要分割的 slice 的索引更大,此函數在嘗試使用這個索引前 panic。

之後我們在一個元組中返回兩個可變的 slice:一個從原始 slice 的開頭直到 mid 索引,另一個從 mid 直到原 slice 的結尾。

如果嘗試編譯範例 19-5 的代碼,會得到一個錯誤:

error[E0499]: cannot borrow `*slice` as mutable more than once at a time
 -->
  |
6 |     (&mut slice[..mid],
  |           ----- first mutable borrow occurs here
7 |      &mut slice[mid..])
  |           ^^^^^ second mutable borrow occurs here
8 | }
  | - first borrow ends here

Rust 的借用檢查器不能理解我們要借用這個 slice 的兩個不同部分:它只知道我們借用了同一個 slice 兩次。本質上借用 slice 的不同部分是可以的,因為結果兩個 slice 不會重疊,不過 Rust 還沒有智慧到能夠理解這些。當我們知道某些事是可以的而 Rust 不知道的時候,就是觸及不安全代碼的時候了

範例 19-6 展示了如何使用 unsafe 塊,裸指針和一些不安全函數調用來實現 split_at_mut


#![allow(unused)]
fn main() {
use std::slice;

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (slice::from_raw_parts_mut(ptr, mid),
         slice::from_raw_parts_mut(ptr.add(mid), len - mid))
    }
}
}

範例 19-6: 在 split_at_mut 函數的實現中使用不安全代碼

回憶第四章的 “Slice 類型” 部分,slice 是一個指向一些數據的指針,並帶有該 slice 的長度。可以使用 len 方法獲取 slice 的長度,使用 as_mut_ptr 方法訪問 slice 的裸指針。在這個例子中,因為有一個 i32 值的可變 slice,as_mut_ptr 返回一個 *mut i32 類型的裸指針,儲存在 ptr 變數中。

我們保持索引 mid 位於 slice 中的斷言。接著是不安全代碼:slice::from_raw_parts_mut 函數獲取一個裸指針和一個長度來創建一個 slice。這裡使用此函數從 ptr 中創建了一個有 mid 個項的 slice。之後在 ptr 上調用 add 方法並使用 mid 作為參數來獲取一個從 mid 開始的裸指針,使用這個裸指針並以 mid 之後項的數量為長度創建一個 slice。

slice::from_raw_parts_mut 函數是不安全的因為它獲取一個裸指針,並必須確信這個指針是有效的。裸指針上的 add 方法也是不安全的,因為其必須確信此地址偏移量也是有效的指針。因此必須將 slice::from_raw_parts_mutadd 放入 unsafe 塊中以便能調用它們。通過觀察代碼,和增加 mid 必然小於等於 len 的斷言,我們可以說 unsafe 塊中所有的裸指針將是有效的 slice 中數據的指針。這是一個可以接受的 unsafe 的恰當用法。

注意無需將 split_at_mut 函數的結果標記為 unsafe,並可以在安全 Rust 中調用此函數。我們創建了一個不安全代碼的安全抽象,其代碼以一種安全的方式使用了 unsafe 代碼,因為其只從這個函數訪問的數據中創建了有效的指針。

與此相對,範例 19-7 中的 slice::from_raw_parts_mut 在使用 slice 時很有可能會崩潰。這段代碼獲取任意記憶體地址並創建了一個長為一萬的 slice:


#![allow(unused)]
fn main() {
use std::slice;

let address = 0x01234usize;
let r = address as *mut i32;

let slice: &[i32] = unsafe {
    slice::from_raw_parts_mut(r, 10000)
};
}

範例 19-7: 透過任意記憶體地址創建 slice

我們並不擁有這個任意地址的記憶體,也不能保證這段代碼創建的 slice 包含有效的 i32 值。試圖使用臆測為有效的 slice 會導致未定義的行為。

使用 extern 函數調用外部代碼

有時你的 Rust 代碼可能需要與其他語言編寫的代碼交互。為此 Rust 有一個關鍵字,extern,有助於創建和使用 外部函數介面Foreign Function Interface, FFI)。外部函數介面是一個程式語言用以定義函數的方式,其允許不同(外部)程式語言調用這些函數。

範例 19-8 展示了如何集成 C 標準庫中的 abs 函數。extern 塊中聲明的函數在 Rust 代碼中總是不安全的。因為其他語言不會強制執行 Rust 的規則且 Rust 無法檢查它們,所以確保其安全是程式設計師的責任:

檔案名: src/main.rs

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

範例 19-8: 聲明並調用另一個語言中定義的 extern 函數

extern "C" 塊中,列出了我們希望能夠調用的另一個語言中的外部函數的簽名和名稱。"C" 部分定義了外部函數所使用的 應用二進位制介面application binary interface,ABI) —— ABI 定義了如何在匯編語言層面調用此函數。"C" ABI 是最常見的,並遵循 C 程式語言的 ABI。

從其它語言調用 Rust 函數

也可以使用 extern 來創建一個允許其他語言調用 Rust 函數的介面。不同於 extern 塊,就在 fn 關鍵字之前增加 extern 關鍵字並指定所用到的 ABI。還需增加 #[no_mangle] 註解來告訴 Rust 編譯器不要 mangle 此函數的名稱。Mangling 發生於當編譯器將我們指定的函數名修改為不同的名稱時,這會增加用於其他編譯過程的額外訊息,不過會使其名稱更難以閱讀。每一個程式語言的編譯器都會以稍微不同的方式 mangle 函數名,所以為了使 Rust 函數能在其他語言中指定,必須禁用 Rust 編譯器的 name mangling。

在如下的例子中,一旦其編譯為動態庫並從 C 語言中連結,call_from_c 函數就能夠在 C 代碼中訪問:


#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

extern 的使用無需 unsafe

訪問或修改可變靜態變數

目前為止全書都儘量避免討論 全局變數global variables),Rust 確實支持他們,不過這對於 Rust 的所有權規則來說是有問題的。如果有兩個執行緒訪問相同的可變全局變數,則可能會造成數據競爭。

全局變數在 Rust 中被稱為 靜態static)變數。範例 19-9 展示了一個擁有字串 slice 值的靜態變數的聲明和應用:

檔案名: src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {}", HELLO_WORLD);
}

範例 19-9: 定義和使用一個不可變靜態變數

static 變數類似於第三章 “變數和常量的區別” 部分討論的常量。通常靜態變數的名稱採用 SCREAMING_SNAKE_CASE 寫法,並 必須 標註變數的類型,在這個例子中是 &'static str。靜態變數只能儲存擁有 'static 生命週期的引用,這意味著 Rust 編譯器可以自己計算出其生命週期而無需顯式標註。訪問不可變靜態變數是安全的。

常量與不可變靜態變數可能看起來很類似,不過一個微妙的區別是靜態變數中的值有一個固定的記憶體地址。使用這個值總是會訪問相同的地址。另一方面,常量則允許在任何被用到的時候複製其數據。

常量與靜態變數的另一個區別在於靜態變數可以是可變的。訪問和修改可變靜態變數都是 不安全 的。範例 19-10 展示了如何聲明、訪問和修改名為 COUNTER 的可變靜態變數:

檔案名: src/main.rs

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {}", COUNTER);
    }
}

範例 19-10: 讀取或修改一個可變靜態變數是不安全的

就像常規變數一樣,我們使用 mut 關鍵來指定可變性。任何讀寫 COUNTER 的代碼都必須位於 unsafe 塊中。這段代碼可以編譯並如期列印出 COUNTER: 3,因為這是單執行緒的。擁有多個執行緒訪問 COUNTER 則可能導致數據競爭。

擁有可以全局訪問的可變數據,難以保證不存在數據競爭,這就是為何 Rust 認為可變靜態變數是不安全的。任何可能的情況,請優先使用第十六章討論的並發技術和執行緒安全智慧指針,這樣編譯器就能檢測不同執行緒間的數據訪問是否是安全的。

實現不安全 trait

最後一個只能用在 unsafe 中的操作是實現不安全 trait。當至少有一個方法中包含編譯器不能驗證的不變數時 trait 是不安全的。可以在 trait 之前增加 unsafe 關鍵字將 trait 聲明為 unsafe,同時 trait 的實現也必須標記為 unsafe,如範例 19-11 所示:


#![allow(unused)]
fn main() {
unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}
}

範例 19-11: 定義並實現不安全 trait

通過 unsafe impl,我們承諾將保證編譯器所不能驗證的不變數。

作為一個例子,回憶第十六章 “使用 SyncSend trait 的可擴展並發” 部分中的 SyncSend 標記 trait,編譯器會自動為完全由 SendSync 類型組成的類型自動實現他們。如果實現了一個包含一些不是 SendSync 的類型,比如裸指針,並希望將此類型標記為 SendSync,則必須使用 unsafe。Rust 不能驗證我們的類型保證可以安全的跨執行緒發送或在多執行緒間訪問,所以需要我們自己進行檢查並通過 unsafe 表明。

訪問聯合體中的欄位

unionstruct 類似,但是在一個實例中同時只能使用一個聲明的欄位。聯合體主要用於和 C 代碼中的聯合體交互。訪問聯合體的欄位是不安全的,因為 Rust 無法保證當前存儲在聯合體實例中數據的類型。可以查看參考文件了解有關聯合體的更多訊息。

何時使用不安全代碼

使用 unsafe 來進行這五個操作(超級力量)之一是沒有問題的,甚至是不需要深思熟慮的,不過使得 unsafe 代碼正確也實屬不易,因為編譯器不能幫助保證記憶體安全。當有理由使用 unsafe 代碼時,是可以這麼做的,透過使用顯式的 unsafe 標註使得在出現錯誤時易於追蹤問題的源頭。

高級 trait

ch19-03-advanced-traits.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

第十章 “trait:定義共享的行為” 部分,我們第一次涉及到了 trait,不過就像生命週期一樣,我們並沒有覆蓋一些較為高級的細節。現在我們更加了解 Rust 了,可以深入理解其本質了。

關聯類型在 trait 定義中指定占位符類型

關聯類型associated types)是一個將類型占位符與 trait 相關聯的方式,這樣 trait 的方法簽名中就可以使用這些占位符類型。trait 的實現者會針對特定的實現在這個類型的位置指定相應的具體類型。如此可以定義一個使用多種類型的 trait,直到實現此 trait 時都無需知道這些類型具體是什麼。

本章所描述的大部分內容都非常少見。關聯類型則比較適中;它們比本書其他的內容要少見,不過比本章中的很多內容要更常見。

一個帶有關聯類型的 trait 的例子是標準庫提供的 Iterator trait。它有一個叫做 Item 的關聯類型來替代遍歷的值的類型。第十三章的 Iterator trait 和 next 方法” 部分曾提到過 Iterator trait 的定義如範例 19-12 所示:


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
}

範例 19-12: Iterator trait 的定義中帶有關聯類型 Item

Item 是一個占位類型,同時 next 方法定義表明它返回 Option<Self::Item> 類型的值。這個 trait 的實現者會指定 Item 的具體類型,然而不管實現者指定何種類型, next 方法都會返回一個包含了此具體類型值的 Option

關聯類型看起來像一個類似泛型的概念,因為它允許定義一個函數而不指定其可以處理的類型。那麼為什麼要使用關聯類型呢?

讓我們通過一個在第十三章中出現的 Counter 結構體上實現 Iterator trait 的例子來檢視其中的區別。在範例 13-21 中,指定了 Item 的類型為 u32

檔案名: src/lib.rs

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--

這類似於泛型。那麼為什麼 Iterator trait 不像範例 19-13 那樣定義呢?


#![allow(unused)]
fn main() {
pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
}

範例 19-21: 一個使用泛型的 Iterator trait 假想定義

區別在於當如範例 19-13 那樣使用泛型時,則不得不在每一個實現中標註類型。這是因為我們也可以實現為 Iterator<String> for Counter,或任何其他類型,這樣就可以有多個 CounterIterator 的實現。換句話說,當 trait 有泛型參數時,可以多次實現這個 trait,每次需改變泛型參數的具體類型。接著當使用 Counternext 方法時,必須提供類型註解來表明希望使用 Iterator 的哪一個實現。

通過關聯類型,則無需標註類型因為不能多次實現這個 trait。對於範例 19-12 使用關聯類型的定義,我們只能選擇一次 Item 會是什麼類型,因為只能有一個 impl Iterator for Counter。當調用 Counternext 時不必每次指定我們需要 u32 值的疊代器。

默認泛型類型參數和運算符重載

當使用泛型類型參數時,可以為泛型指定一個預設的具體類型。如果默認類型就足夠的話,這消除了為具體類型實現 trait 的需要。為泛型類型指定默認類型的語法是在聲明泛型類型時使用 <PlaceholderType=ConcreteType>

這種情況的一個非常好的例子是用於運算符重載。運算符重載Operator overloading)是指在特定情況下自訂運算符(比如 +)行為的操作。

Rust 並不允許創建自訂運算符或重載任意運算符,不過 std::ops 中所列出的運算符和相應的 trait 可以通過實現運算符相關 trait 來重載。例如,範例 19-14 中展示了如何在 Point 結構體上實現 Add trait 來重載 + 運算符,這樣就可以將兩個 Point 實例相加了:

檔案名: src/main.rs

use std::ops::Add;

#[derive(Debug, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
               Point { x: 3, y: 3 });
}

範例 19-14: 實現 Add trait 重載 Point 實例的 + 運算符

add 方法將兩個 Point 實例的 x 值和 y 值分別相加來創建一個新的 PointAdd trait 有一個叫做 Output 的關聯類型,它用來決定 add 方法的返回值類型。

這裡默認泛型類型位於 Add trait 中。這裡是其定義:


#![allow(unused)]
fn main() {
trait Add<RHS=Self> {
    type Output;

    fn add(self, rhs: RHS) -> Self::Output;
}
}

這看來應該很熟悉,這是一個帶有一個方法和一個關聯類型的 trait。比較陌生的部分是角括號中的 RHS=Self:這個語法叫做 默認類型參數default type parameters)。RHS 是一個泛型類型參數(“right hand side” 的縮寫),它用於定義 add 方法中的 rhs 參數。如果實現 Add trait 時不指定 RHS 的具體類型,RHS 的類型將是默認的 Self 類型,也就是在其上實現 Add 的類型。

當為 Point 實現 Add 時,使用了默認的 RHS,因為我們希望將兩個 Point 實例相加。讓我們看看一個實現 Add trait 時希望自訂 RHS 類型而不是使用默認類型的例子。

這裡有兩個存放不同單元值的結構體,MillimetersMeters。我們希望能夠將毫米值與米值相加,並讓 Add 的實現正確處理轉換。可以為 Millimeters 實現 Add 並以 Meters 作為 RHS,如範例 19-15 所示。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
}

範例 19-15: 在 Millimeters 上實現 Add,以便能夠將 MillimetersMeters 相加

為了使 MillimetersMeters 能夠相加,我們指定 impl Add<Meters> 來設定 RHS 類型參數的值而不是使用默認的 Self

默認參數類型主要用於如下兩個方面:

  • 擴展類型而不破壞現有代碼。
  • 在大部分用戶都不需要的特定情況進行自訂。

標準庫的 Add trait 就是一個第二個目的例子:大部分時候你會將兩個相似的類型相加,不過它提供了自訂額外行為的能力。在 Add trait 定義中使用默認類型參數意味著大部分時候無需指定額外的參數。換句話說,一小部分實現的樣板代碼是不必要的,這樣使用 trait 就更容易了。

第一個目的是相似的,但過程是反過來的:如果需要為現有 trait 增加類型參數,為其提供一個默認類型將允許我們在不破壞現有實現代碼的基礎上擴展 trait 的功能。

完全限定語法與消歧義:調用相同名稱的方法

Rust 既不能避免一個 trait 與另一個 trait 擁有相同名稱的方法,也不能阻止為同一類型同時實現這兩個 trait。甚至直接在類型上實現開始已經有的同名方法也是可能的!

不過,當調用這些同名方法時,需要告訴 Rust 我們希望使用哪一個。考慮一下範例 19-16 中的代碼,這裡定義了 trait PilotWizard 都擁有方法 fly。接著在一個本身已經實現了名為 fly 方法的類型 Human 上實現這兩個 trait。每一個 fly 方法都進行了不同的操作:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}
}

範例 19-16: 兩個 trait 定義為擁有 fly 方法,並在直接定義有 fly 方法的 Human 類型上實現這兩個 trait

當調用 Human 實例的 fly 時,編譯器默認調用直接實現在類型上的方法,如範例 19-17 所示。

檔案名: src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

範例 19-17: 調用 Human 實例的 fly

運行這段代碼會列印出 *waving arms furiously*,這表明 Rust 調用了直接實現在 Human 上的 fly 方法。

為了能夠調用 Pilot trait 或 Wizard trait 的 fly 方法,我們需要使用更明顯的語法以便能指定我們指的是哪個 fly 方法。這個語法展示在範例 19-18 中:

檔案名: src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

範例 19-18: 指定我們希望調用哪一個 trait 的 fly 方法

在方法名前指定 trait 名向 Rust 澄清了我們希望調用哪個 fly 實現。也可以選擇寫成 Human::fly(&person),這等同於範例 19-18 中的 person.fly(),不過如果無需消歧義的話這麼寫就有點長了。

運行這段代碼會列印出:

This is your captain speaking.
Up!
*waving arms furiously*

因為 fly 方法獲取一個 self 參數,如果有兩個 類型 都實現了同一 trait,Rust 可以根據 self 的類型計算出應該使用哪一個 trait 實現。

然而,關聯函數是 trait 的一部分,但沒有 self 參數。當同一作用域的兩個類型實現了同一 trait,Rust 就不能計算出我們期望的是哪一個類型,除非使用 完全限定語法fully qualified syntax)。例如,拿範例 19-19 中的 Animal trait 來說,它有關聯函數 baby_name,結構體 Dog 實現了 Animal,同時有關聯函數 baby_name 直接定義於 Dog 之上:

檔案名: src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

範例 19-19: 一個帶有關聯函數的 trait 和一個帶有同名關聯函數並實現了此 trait 的類型

這段代碼用於一個動物收容所,他們將所有的小狗起名為 Spot,這實現為定義於 Dog 之上的關聯函數 baby_nameDog 類型還實現了 Animal trait,它描述了所有動物的共有的特徵。小狗被稱為 puppy,這表現為 DogAnimal trait 實現中與 Animal trait 相關聯的函數 baby_name

main 調用了 Dog::baby_name 函數,它直接調用了定義於 Dog 之上的關聯函數。這段代碼會列印出:

A baby dog is called a Spot

這並不是我們需要的。我們希望調用的是 DogAnimal trait 實現那部分的 baby_name 函數,這樣能夠列印出 A baby dog is called a puppy。範例 19-18 中用到的技術在這並不管用;如果將 main 改為範例 19-20 中的代碼,則會得到一個編譯錯誤:

檔案名: src/main.rs

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

範例 19-20: 嘗試調用 Animal trait 的 baby_name 函數,不過 Rust 並不知道該使用哪一個實現

因為 Animal::baby_name 是關聯函數而不是方法,因此它沒有 self 參數,Rust 無法計算出所需的是哪一個 Animal::baby_name 實現。我們會得到這個編譯錯誤:

error[E0283]: type annotations required: cannot resolve `_: Animal`
  --> src/main.rs:20:43
   |
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^
   |
   = note: required by `Animal::baby_name`

為了消歧義並告訴 Rust 我們希望使用的是 DogAnimal 實現,需要使用 完全限定語法,這是調用函數時最為明確的方式。範例 19-21 展示了如何使用完全限定語法:

檔案名: src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

範例 19-21: 使用完全限定語法來指定我們希望調用的是 DogAnimal trait 實現中的 baby_name 函數

我們在角括號中向 Rust 提供了類型註解,並透過在此函數調用中將 Dog 類型當作 Animal 對待,來指定希望調用的是 DogAnimal trait 實現中的 baby_name 函數。現在這段代碼會列印出我們期望的數據:

A baby dog is called a puppy

通常,完全限定語法定義為:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

對於關聯函數,其沒有一個 receiver,故只會有其他參數的列表。可以選擇在任何函數或方法調用處使用完全限定語法。然而,允許省略任何 Rust 能夠從程序中的其他訊息中計算出的部分。只有當存在多個同名實現而 Rust 需要幫助以便知道我們希望調用哪個實現時,才需要使用這個較為冗長的語法。

父 trait 用於在另一個 trait 中使用某 trait 的功能

有時我們可能會需要某個 trait 使用另一個 trait 的功能。在這種情況下,需要能夠依賴相關的 trait 也被實現。這個所需的 trait 是我們實現的 trait 的 父(超) traitsupertrait)。

例如我們希望創建一個帶有 outline_print 方法的 trait OutlinePrint,它會列印出帶有星號框的值。也就是說,如果 Point 實現了 Display 並返回 (x, y),調用以 1 作為 x3 作為 yPoint 實例的 outline_print 會顯示如下:

**********
*        *
* (1, 3) *
*        *
**********

outline_print 的實現中,因為希望能夠使用 Display trait 的功能,則需要說明 OutlinePrint 只能用於同時也實現了 Display 並提供了 OutlinePrint 需要的功能的類型。可以通過在 trait 定義中指定 OutlinePrint: Display 來做到這一點。這類似於為 trait 增加 trait bound。範例 19-22 展示了一個 OutlinePrint trait 的實現:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}
}

範例 19-22: 實現 OutlinePrint trait,它要求來自 Display 的功能

因為指定了 OutlinePrint 需要 Display trait,則可以在 outline_print 中使用 to_string, 其會為任何實現 Display 的類型自動實現。如果不在 trait 名後增加 : Display 並嘗試在 outline_print 中使用 to_string,則會得到一個錯誤說在當前作用域中沒有找到用於 &Self 類型的方法 to_string

讓我們看看如果嘗試在一個沒有實現 Display 的類型上實現 OutlinePrint 會發生什麼事,比如 Point 結構體:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
trait OutlinePrint {}
struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}
}

這樣會得到一個錯誤說 Display 是必須的而未被實現:

error[E0277]: the trait bound `Point: std::fmt::Display` is not satisfied
  --> src/main.rs:20:6
   |
20 | impl OutlinePrint for Point {}
   |      ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter;
try using `:?` instead if you are using a format string
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`

一旦在 Point 上實現 Display 並滿足 OutlinePrint 要求的限制,比如這樣:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}
}

那麼在 Point 上實現 OutlinePrint trait 將能成功編譯,並可以在 Point 實例上調用 outline_print 來顯示位於星號框中的點的值。

newtype 模式用以在外部類型上實現外部 trait

在第十章的 “為類型實現 trait” 部分,我們提到了孤兒規則(orphan rule),它說明只要 trait 或類型對於當前 crate 是本地的話就可以在此類型上實現該 trait。一個繞開這個限制的方法是使用 newtype 模式newtype pattern),它涉及到在一個元組結構體(第五章 “用沒有命名欄位的元組結構體來創建不同的類型” 部分介紹了元組結構體)中創建一個新類型。這個元組結構體帶有一個欄位作為希望實現 trait 的類型的簡單封裝。接著這個封裝類型對於 crate 是本地的,這樣就可以在這個封裝上實現 trait。Newtype 是一個源自 (U.C.0079,逃) Haskell 程式語言的概念。使用這個模式沒有運行時性能懲罰,這個封裝類型在編譯時就被省略了。

例如,如果想要在 Vec<T> 上實現 Display,而孤兒規則阻止我們直接這麼做,因為 Display trait 和 Vec<T> 都定義於我們的 crate 之外。可以創建一個包含 Vec<T> 實例的 Wrapper 結構體,接著可以如列表 19-31 那樣在 Wrapper 上實現 Display 並使用 Vec<T> 的值:

檔案名: src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {}", w);
}

範例 19-31: 創建 Wrapper 類型封裝 Vec<String> 以便能夠實現 Display

Display 的實現使用 self.0 來訪問其內部的 Vec<T>,因為 Wrapper 是元組結構體而 Vec<T> 是結構體總位於索引 0 的項。接著就可以使用 WrapperDisplay 的功能了。

此方法的缺點是,因為 Wrapper 是一個新類型,它沒有定義於其值之上的方法;必須直接在 Wrapper 上實現 Vec<T> 的所有方法,這樣就可以代理到self.0 上 —— 這就允許我們完全像 Vec<T> 那樣對待 Wrapper。如果希望新類型擁有其內部類型的每一個方法,為封裝類型實現 Deref trait(第十五章 “通過 Deref trait 將智慧指針當作常規引用處理” 部分討論過)並返回其內部類型是一種解決方案。如果不希望封裝類型擁有所有內部類型的方法 —— 比如為了限制封裝類型的行為 —— 則必須只自行實現所需的方法。

上面便是 newtype 模式如何與 trait 結合使用的;還有一個不涉及 trait 的實用模式。現在讓我們將話題的焦點轉移到一些與 Rust 類型系統交互的高級方法上來吧。

高級類型

ch19-04-advanced-types.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

Rust 的類型系統有一些我們曾經提到但沒有討論過的功能。首先我們從一個關於為什麼 newtype 與類型一樣有用的更寬泛的討論開始。接著會轉向類型別名(type aliases),一個類似於 newtype 但有著稍微不同的語義的功能。我們還會討論 ! 類型和動態大小類型。

這一部分假設你已經閱讀了之前的 “newtype 模式用於在外部類型上實現外部 trait” 部分。

為了類型安全和抽象而使用 newtype 模式

newtype 模式可以用於一些其他我們還未討論的功能,包括靜態的確保某值不被混淆,和用來表示一個值的單元。實際上範例 19-23 中已經有一個這樣的例子:MillimetersMeters 結構體都在 newtype 中封裝了 u32 值。如果編寫了一個有 Millimeters 類型參數的函數,不小心使用 Meters 或普通的 u32 值來調用該函數的程序是不能編譯的。

另一個 newtype 模式的應用在於抽象掉一些類型的實現細節:例如,封裝類型可以暴露出與直接使用其內部私有類型時所不同的公有 API,以便限制其功能。

newtype 也可以隱藏其內部的泛型類型。例如,可以提供一個封裝了 HashMap<i32, String>People 類型,用來儲存人名以及相應的 ID。使用 People 的代碼只需與提供的公有 API 交互即可,比如向 People 集合增加名字字串的方法,這樣這些程式碼就無需知道在內部我們將一個 i32 ID 賦予了這個名字了。newtype 模式是一種實現第十七章 “封裝隱藏了實現細節” 部分所討論的隱藏實現細節的封裝的輕量級方法。

類型別名用來創建類型同義詞

連同 newtype 模式,Rust 還提供了聲明 類型別名type alias)的能力,使用 type 關鍵字來給予現有類型另一個名字。例如,可以像這樣創建 i32 的別名 Kilometers


#![allow(unused)]
fn main() {
type Kilometers = i32;
}

這意味著 Kilometersi32同義詞synonym);不同於範例 19-23 中創建的 MillimetersMeters 類型。Kilometers 不是一個新的、單獨的類型。Kilometers 類型的值將被完全當作 i32 類型值來對待:


#![allow(unused)]
fn main() {
type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

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

因為 Kilometersi32 的別名,他們是同一類型,可以將 i32Kilometers 相加,也可以將 Kilometers 傳遞給獲取 i32 參數的函數。但透過這種手段無法獲得上一部分討論的 newtype 模式所提供的類型檢查的好處。

類型別名的主要用途是減少重複。例如,可能會有這樣很長的類型:

Box<dyn Fn() + Send + 'static>

在函數簽名或類型註解中每次都書寫這個類型將是枯燥且易於出錯的。想像一下如範例 19-24 這樣全是如此代碼的項目:


#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
    // --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
    // --snip--
    Box::new(|| ())
}
}

範例 19-24: 在很多地方使用名稱很長的類型

類型別名透過減少項目中重複代碼的數量來使其更加易於控制。這裡我們為這個冗長的類型引入了一個叫做 Thunk 的別名,這樣就可以如範例 19-25 所示將所有使用這個類型的地方替換為更短的 Thunk


#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
    // --snip--
}

fn returns_long_type() -> Thunk {
    // --snip--
    Box::new(|| ())
}
}

範例 19-25: 引入類型別名 Thunk 來減少重複

這樣就讀寫起來就容易多了!為類型別名選擇一個好名字也可以幫助你表達意圖(單詞 thunk 表示會在之後被計算的代碼,所以這是一個存放閉包的合適的名字)。

類型別名也經常與 Result<T, E> 結合使用來減少重複。考慮一下標準庫中的 std::io 模組。I/O 操作通常會返回一個 Result<T, E>,因為這些操作可能會失敗。標準庫中的 std::io::Error 結構體代表了所有可能的 I/O 錯誤。std::io 中大部分函數會返回 Result<T, E>,其中 Estd::io::Error,比如 Write trait 中的這些函數:


#![allow(unused)]
fn main() {
use std::io::Error;
use std::fmt;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
}

這裡出現了很多的 Result<..., Error>。為此,std::io 有這個類型別名聲明:


#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, std::io::Error>;
}

因為這位於 std::io 中,可用的完全限定的別名是 std::io::Result<T> —— 也就是說,Result<T, E>E 放入了 std::io::ErrorWrite trait 中的函數最終看起來像這樣:

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: Arguments) -> Result<()>;
}

類型別名在兩個方面有幫助:易於編寫 在整個 std::io 中提供了一致的介面。因為這是一個別名,它只是另一個 Result<T, E>,這意味著可以在其上使用 Result<T, E> 的任何方法,以及像 ? 這樣的特殊語法。

從不返回的 never type

Rust 有一個叫做 ! 的特殊類型。在類型理論術語中,它被稱為 empty type,因為它沒有值。我們更傾向於稱之為 never type。這個名字描述了它的作用:在函數從不返回的時候充當返回值。例如:

fn bar() -> ! {
    // --snip--
}

這讀 “函數 bar 從不返回”,而從不返回的函數被稱為 發散函數diverging functions)。不能創建 ! 類型的值,所以 bar 也不可能返回值。

不過一個不能創建值的類型有什麼用呢?如果你回想一下範例 2-5 中的代碼,曾經有一些看起來像這樣的代碼,如範例 19-26 所重現的:


#![allow(unused)]
fn main() {
let guess = "3";
loop {
let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};
break;
}
}

範例 19-26: match 語句和一個以 continue 結束的分支

當時我們忽略了代碼中的一些細節。在第六章 match 控制流運算符” 部分,我們學習了 match 的分支必須返回相同的類型。如下代碼不能工作:

let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
}

這裡的 guess 必須既是整型 也是 字串,而 Rust 要求 guess 只能是一個類型。那麼 continue 返回了什麼呢?為什麼範例 19-26 中會允許一個分支返回 u32 而另一個分支卻以 continue 結束呢?

正如你可能猜到的,continue 的值是 !。也就是說,當 Rust 要計算 guess 的類型時,它查看這兩個分支。前者是 u32 值,而後者是 ! 值。因為 ! 並沒有一個值,Rust 決定 guess 的類型是 u32

描述 ! 的行為的正式方式是 never type 可以強轉為任何其他類型。允許 match 的分支以 continue 結束是因為 continue 並不真正返回一個值;相反它把控制權交回上層循環,所以在 Err 的情況,事實上並未對 guess 賦值。

never type 的另一個用途是 panic!。還記得 Option<T> 上的 unwrap 函數嗎?它產生一個值或 panic。這裡是它的定義:

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

這裡與範例 19-34 中的 match 發生了相同的情況:Rust 知道 valT 類型,panic!! 類型,所以整個 match 表達式的結果是 T 類型。這能工作是因為 panic! 並不產生一個值;它會終止程式。對於 None 的情況,unwrap 並不返回一個值,所以這些程式碼是有效。

最後一個有著 ! 類型的表達式是 loop

print!("forever ");

loop {
    print!("and ever ");
}

這裡,循環永遠也不結束,所以此表達式的值是 !。但是如果引入 break 這就不為真了,因為循環在執行到 break 後就會終止。

動態大小類型和 Sized trait

因為 Rust 需要知道例如應該為特定類型的值分配多少空間這樣的訊息其類型系統的一個特定的角落可能令人迷惑:這就是 動態大小類型dynamically sized types)的概念。這有時被稱為 “DST” 或 “unsized types”,這些類型允許我們處理只有在運行時才知道大小的類型。

讓我們深入研究一個貫穿本書都在使用的動態大小類型的細節:str。沒錯,不是 &str,而是 str 本身。str 是一個 DST;直到運行時我們都不知道字串有多長。因為直到運行時都不能知道大其小,也就意味著不能創建 str 類型的變數,也不能獲取 str 類型的參數。考慮一下這些程式碼,他們不能工作:

let s1: str = "Hello there!";
let s2: str = "How's it going?";

Rust 需要知道應該為特定類型的值分配多少記憶體,同時所有同一類型的值必須使用相同數量的記憶體。如果允許編寫這樣的代碼,也就意味著這兩個 str 需要占用完全相同大小的空間,不過它們有著不同的長度。這也就是為什麼不可能創建一個存放動態大小類型的變數的原因。

那麼該怎麼辦呢?你已經知道了這種問題的答案:s1s2 的類型是 &str 而不是 str。如果你回想第四章 “字串 slice” 部分,slice 數據結儲存了開始位置和 slice 的長度。

所以雖然 &T 是一個儲存了 T 所在的記憶體位置的單個值,&str 則是 兩個 值:str 的地址和其長度。這樣,&str 就有了一個在編譯時可以知道的大小:它是 usize 長度的兩倍。也就是說,我們總是知道 &str 的大小,而無論其引用的字串是多長。這裡是 Rust 中動態大小類型的常規用法:他們有一些額外的元訊息來儲存動態訊息的大小。這引出了動態大小類型的黃金規則:必須將動態大小類型的值置於某種指針之後。

可以將 str 與所有類型的指針結合:比如 Box<str>Rc<str>。事實上,之前我們已經見過了,不過是另一個動態大小類型:trait。每一個 trait 都是一個可以通過 trait 名稱來引用的動態大小類型。在第十七章 “為使用不同類型的值而設計的 trait 對象” 部分,我們提到了為了將 trait 用於 trait 對象,必須將他們放入指針之後,比如 &dyn TraitBox<dyn Trait>Rc<dyn Trait> 也可以)。

為了處理 DST,Rust 有一個特定的 trait 來決定一個類型的大小是否在編譯時可知:這就是 Sized trait。這個 trait 自動為編譯器在編譯時就知道大小的類型實現。另外,Rust 隱式的為每一個泛型函數增加了 Sized bound。也就是說,對於如下泛型函數定義:

fn generic<T>(t: T) {
    // --snip--
}

實際上被當作如下處理:

fn generic<T: Sized>(t: T) {
    // --snip--
}

泛型函數默認只能用於在編譯時已知大小的類型。然而可以使用如下特殊語法來放寬這個限制:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized trait bound 與 Sized 相對;也就是說,它可以讀作 “T 可能是也可能不是 Sized 的”。這個語法只能用於 Sized ,而不能用於其他 trait。

另外注意我們將 t 參數的類型從 T 變為了 &T:因為其類型可能不是 Sized 的,所以需要將其置於某種指針之後。在這個例子中選擇了引用。

接下來,讓我們討論一下函數和閉包!

高級函數與閉包

ch19-05-advanced-functions-and-closures.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

接下來我們將探索一些有關函數和閉包的進階功能:函數指針以及返回值閉包。

函數指針

我們討論過了如何向函數傳遞閉包;也可以向函數傳遞常規函數!這在我們希望傳遞已經定義的函數而不是重新定義閉包作為參數時很有用。透過函數指針允許我們使用函數作為另一個函數的參數。函數的類型是 fn (使用小寫的 “f” )以免與 Fn 閉包 trait 相混淆。fn 被稱為 函數指針function pointer)。指定參數為函數指針的語法類似於閉包,如範例 19-27 所示:

檔案名: src/main.rs

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

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {}", answer);
}

範例 19-27: 使用 fn 類型接受函數指針作為參數

這會列印出 The answer is: 12do_twice 中的 f 被指定為一個接受一個 i32 參數並返回 i32fn。接著就可以在 do_twice 函數體中調用 f。在 main 中,可以將函數名 add_one 作為第一個參數傳遞給 do_twice

不同於閉包,fn 是一個類型而不是一個 trait,所以直接指定 fn 作為參數而不是聲明一個帶有 Fn 作為 trait bound 的泛型參數。

函數指針實現了所有三個閉包 trait(FnFnMutFnOnce),所以總是可以在調用期望閉包的函數時傳遞函數指針作為參數。傾向於編寫使用泛型和閉包 trait 的函數,這樣它就能接受函數或閉包作為參數。

一個只期望接受 fn 而不接受閉包的情況的例子是與不存在閉包的外部代碼交互時:C 語言的函數可以接受函數作為參數,但 C 語言沒有閉包。

作為一個既可以使用內聯定義的閉包又可以使用命名函數的例子,讓我們看看一個 map 的應用。使用 map 函數將一個數字 vector 轉換為一個字串 vector,就可以使用閉包,比如這樣:


#![allow(unused)]
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> = list_of_numbers
    .iter()
    .map(|i| i.to_string())
    .collect();
}

或者可以將函數作為 map 的參數來代替閉包,像是這樣:


#![allow(unused)]
fn main() {
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> = list_of_numbers
    .iter()
    .map(ToString::to_string)
    .collect();
}

注意這裡必須使用 “高級 trait” 部分講到的完全限定語法,因為存在多個叫做 to_string 的函數;這裡使用了定義於 ToString trait 的 to_string 函數,標準庫為所有實現了 Display 的類型實現了這個 trait。

另一個實用的模式暴露了元組結構體和元組結構體枚舉成員的實現細節。這些項使用 () 作為初始化語法,這看起來就像函數調用,同時它們確實被實現為返回由參數構造的實例的函數。它們也被稱為實現了閉包 trait 的函數指針,並可以採用類似如下的方式調用:


#![allow(unused)]
fn main() {
enum Status {
    Value(u32),
    Stop,
}

let list_of_statuses: Vec<Status> =
    (0u32..20)
    .map(Status::Value)
    .collect();
}

這裡創建了 Status::Value 實例,它通過 map 用範圍的每一個 u32 值調用 Status::Value 的初始化函數。一些人傾向於函數風格,一些人喜歡閉包。這兩種形式最終都會產生同樣的代碼,所以請使用對你來說更明白的形式吧。

返回閉包

閉包表現為 trait,這意味著不能直接返回閉包。對於大部分需要返回 trait 的情況,可以使用實現了期望返回的 trait 的具體類型來替代函數的返回值。但是這不能用於閉包,因為他們沒有一個可返回的具體類型;例如不允許使用函數指針 fn 作為返回值類型。

這段代碼嘗試直接返回閉包,它並不能編譯:

fn returns_closure() -> Fn(i32) -> i32 {
    |x| x + 1
}

編譯器給出的錯誤是:

error[E0277]: the trait bound `std::ops::Fn(i32) -> i32 + 'static:
std::marker::Sized` is not satisfied
 -->
  |
1 | fn returns_closure() -> Fn(i32) -> i32 {
  |                         ^^^^^^^^^^^^^^ `std::ops::Fn(i32) -> i32 + 'static`
  does not have a constant size known at compile-time
  |
  = help: the trait `std::marker::Sized` is not implemented for
  `std::ops::Fn(i32) -> i32 + 'static`
  = note: the return type of a function must have a statically known size

錯誤又一次指向了 Sized trait!Rust 並不知道需要多少空間來儲存閉包。不過我們在上一部分見過這種情況的解決辦法:可以使用 trait 對象:


#![allow(unused)]
fn main() {
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}
}

這段代碼正好可以編譯。關於 trait 對象的更多內容,請回顧第十七章的 “為使用不同類型的值而設計的 trait 對象” 部分。

接下來讓我們學習宏!

ch19-06-macros.md
commit 7ddc46460f09a5cd9bd2a620565bdc20b3315ea9

我們已經在本書中使用過像 println! 這樣的宏了,不過還沒完全探索什麼是宏以及它是如何工作的。Macro)指的是 Rust 中一系列的功能:聲明Declarative)宏,使用 macro_rules!,和三種 過程Procedural)宏:

  • 自訂 #[derive] 宏在結構體和枚舉上指定通過 derive 屬性添加的代碼
  • 類屬性(Attribute-like)宏定義可用於任意項的自訂屬性
  • 類函數宏看起來像函數不過作用於作為參數傳遞的 token。

我們會依次討論每一種宏,不過首要的是,為什麼已經有了函數還需要宏呢?

宏和函數的區別

從根本上來說,宏是一種為寫其他代碼而寫程式碼的方式,即所謂的 元編程metaprogramming)。在附錄 C 中會探討 derive 屬性,其生成各種 trait 的實現。我們也在本書中使用過 println! 宏和 vec! 宏。所有的這些宏以 展開 的方式來生成比你所手寫出的更多的代碼。

元編程對於減少大量編寫和維護的代碼是非常有用的,它也扮演了函數扮演的角色。但宏有一些函數所沒有的附加能力。

一個函數標籤必須聲明函數參數個數和類型。相比之下,宏能夠接受不同數量的參數:用一個參數調用 println!("hello") 或用兩個參數調用 println!("hello {}", name) 。而且,宏可以在編譯器翻譯代碼前展開,例如,宏可以在一個給定類型上實現 trait 。而函數則不行,因為函數是在運行時被調用,同時 trait 需要在編譯時實現。

實現一個宏而不是函數的消極面是宏定義要比函數定義更複雜,因為你正在編寫生成 Rust 代碼的 Rust 代碼。由於這樣的間接性,宏定義通常要比函數定義更難閱讀、理解以及維護。

宏和函數的最後一個重要的區別是:在一個文件裡調用宏 之前 必須定義它,或將其引入作用域,而函數則可以在任何地方定義和調用。

使用 macro_rules! 的聲明宏用於通用元編程

Rust 最常用的宏形式是 聲明宏declarative macros)。它們有時也被稱為 “macros by example”、“macro_rules! 宏” 或者就是 “macros”。其核心概念是,聲明宏允許我們編寫一些類似 Rust match 表達式的代碼。正如在第六章討論的那樣,match 表達式是控制結構,其接收一個表達式,與表達式的結果進行模式匹配,然後根據模式匹配執行相關代碼。宏也將一個值和包含相關代碼的模式進行比較;此種情況下,該值是傳遞給宏的 Rust 原始碼字面值,模式用於和傳遞給宏的原始碼進行比較,同時每個模式的相關代碼則用於替換傳遞給宏的代碼。所有這一切都發生於編譯時。

可以使用 macro_rules! 來定義宏。讓我們通過查看 vec! 宏定義來探索如何使用 macro_rules! 結構。第八章講述了如何使用 vec! 宏來生成一個給定值的 vector。例如,下面的宏用三個整數創建一個 vector:


#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

也可以使用 vec! 宏來構造兩個整數的 vector 或五個字串 slice 的 vector 。但卻無法使用函數做相同的事情,因為我們無法預先知道參數值的數量和類型。

在範例 19-28 中展示了一個 vec! 稍微簡化的定義。

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
}

範例 19-28: 一個 vec! 宏定義的簡化版本

注意:標準庫中實際定義的 vec! 包括預分配適當量的記憶體的代碼。這部分為代碼最佳化,為了讓範例簡化,此處並沒有包含在內。

無論何時導入定義了宏的包,#[macro_export] 註解說明宏應該是可用的。 如果沒有該註解,這個宏不能被引入作用域。

接著使用 macro_rules! 和宏名稱開始宏定義,且所定義的宏並 不帶 驚嘆號。名字後跟大括號表示宏定義體,在該例中宏名稱是 vec

vec! 宏的結構和 match 表達式的結構類似。此處有一個單邊模式 ( $( $x:expr ),* ) ,後跟 => 以及和模式相關的代碼塊。如果模式匹配,該相關代碼塊將被執行。假設這是這個宏中唯一的模式,則只有這一種有效匹配,其他任何匹配都是錯誤的。更複雜的宏會有多個單邊模式。

宏定義中有效模式語法和在第十八章提及的模式語法是不同的,因為宏模式所匹配的是 Rust 代碼結構而不是值。回過頭來檢查一下範例 19-28 中模式片段什麼意思。對於全部的宏模式語法,請查閱參考

首先,一對括號包含了整個模式。接下來是美元符號( $ ),後跟一對括號,捕獲了符合括號內模式的值以用於替換後的代碼。$() 內則是 $x:expr ,其匹配 Rust 的任意表達式,並將該表達式記作 $x

$() 之後的逗號說明一個可有可無的逗號分隔符可以出現在 $() 所匹配的代碼之後。緊隨逗號之後的 * 說明該模式匹配零個或更多個 * 之前的任何模式。

當以 vec![1, 2, 3]; 調用宏時,$x 模式與三個表達式 123 進行了三次匹配。

現在讓我們來看看與此單邊模式相關聯的代碼塊中的模式:對於每個(在 => 前面)匹配模式中的 $() 的部分,生成零個或更多個(在 => 後面)位於 $()* 內的 temp_vec.push() ,生成的個數取決於該模式被匹配的次數。$x 由每個與之相匹配的表達式所替換。當以 vec![1, 2, 3]; 調用該宏時,替換該宏調用所生成的代碼會是下面這樣:

let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec

我們已經定義了一個宏,其可以接收任意數量和類型的參數,同時可以生成能夠創建包含指定元素的 vector 的代碼。

macro_rules! 中有一些奇怪的地方。在將來,會有第二種採用 macro 關鍵字的聲明宏,其工作方式類似但修復了這些極端情況。在此之後,macro_rules! 實際上就過時(deprecated)了。在此基礎之上,同時鑑於大多數 Rust 程式設計師 使用 宏而非 編寫 宏的事實,此處不再深入探討 macro_rules!。請查閱線上文件或其他資源,如 “The Little Book of Rust Macros” 來更多地了解如何寫宏。

用於從屬性生成代碼的過程宏

第二種形式的宏被稱為 過程宏procedural macros),因為它們更像函數(一種過程類型)。過程宏接收 Rust 代碼作為輸入,在這些程式碼上進行操作,然後產生另一些程式碼作為輸出,而非像聲明式宏那樣匹配對應模式然後以另一部分代碼替換當前代碼。

有三種類型的過程宏(自訂派生(derive),類屬性和類函數),不過它們的工作方式都類似。

當創建過程宏時,其定義必須位於一種特殊類型的屬於它們自己的 crate 中。這麼做出於複雜的技術原因,將來我們希望能夠消除這些限制。使用這些宏需採用類似範例 19-29 所示的代碼形式,其中 some_attribute 是一個使用特定宏的占位符。

檔案名: src/lib.rs

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

範例 19-29: 一個使用過程宏的例子

過程宏包含一個函數,這也是其得名的原因:“過程” 是 “函數” 的同義詞。那麼為何不叫 “函數宏” 呢?好吧,有一個過程宏是 “類函數” 的,叫成函數會產生混亂。無論如何,定義過程宏的函數接受一個 TokenStream 作為輸入並產生一個 TokenStream 作為輸出。這也就是宏的核心:宏所處理的原始碼組成了輸入 TokenStream,同時宏生成的代碼是輸出 TokenStream。最後,函數上有一個屬性;這個屬性表明過程宏的類型。在同一 crate 中可以有多種的過程宏。

考慮到這些宏是如此類似,我們會從自訂派生宏開始。接著會解釋與其他形式宏的微小區別。

如何編寫自訂 derive

讓我們創建一個 hello_macro crate,其包含名為 HelloMacro 的 trait 和關聯函數 hello_macro。不同於讓 crate 的用戶為其每一個類型實現 HelloMacro trait,我們將會提供一個過程式宏以便用戶可以使用 #[derive(HelloMacro)] 註解他們的類型來得到 hello_macro 函數的默認實現。該默認實現會列印 Hello, Macro! My name is TypeName!,其中 TypeName 為定義了 trait 的類型名。換言之,我們會創建一個 crate,使程式設計師能夠寫類似範例 19-30 中的代碼。

檔案名: src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

範例 19-30: crate 用戶所寫的能夠使用過程式宏的代碼

運行該代碼將會列印 Hello, Macro! My name is Pancakes! 第一步是像下面這樣新建一個庫 crate:

$ cargo new hello_macro --lib

接下來,會定義 HelloMacro trait 以及其關聯函數:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub trait HelloMacro {
    fn hello_macro();
}
}

現在有了一個包含函數的 trait 。此時,crate 用戶可以實現該 trait 以達到其期望的功能,像這樣:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

然而,他們需要為每一個他們想使用 hello_macro 的類型編寫實現的代碼塊。我們希望為其節約這些工作。

另外,我們也無法為 hello_macro 函數提供一個能夠列印實現了該 trait 的類型的名字的默認實現:Rust 沒有反射的能力,因此其無法在運行時獲取類型名。我們需要一個在編譯時生成代碼的宏。

下一步是定義過程式宏。在編寫本部分時,過程式宏必須在其自己的 crate 內。該限制最終可能被取消。構造 crate 和其中宏的慣例如下:對於一個 foo 的包來說,一個自訂的派生過程宏的包被稱為 foo_derive 。在 hello_macro 項目中新建名為 hello_macro_derive 的包。

$ cargo new hello_macro_derive --lib

由於兩個 crate 緊密相關,因此在 hello_macro 包的目錄下創建過程式宏的 crate。如果改變在 hello_macro 中定義的 trait ,同時也必須改變在 hello_macro_derive 中實現的過程式宏。這兩個包需要分別發布,編程人員如果使用這些包,則需要同時添加這兩個依賴並將其引入作用域。我們也可以只用 hello_macro 包而將 hello_macro_derive 作為一個依賴,並重新導出過程式宏的代碼。但現在我們組織項目的方式使編程人員在無需 derive 功能時也能夠單獨使用 hello_macro

需要將 hello_macro_derive 聲明為一個過程宏的 crate。同時也需要 synquote crate 中的功能,正如注釋中所說,需要將其加到依賴中。為 hello_macro_derive 將下面的代碼加入到 Cargo.toml 文件中。

檔案名: hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = "0.14.4"
quote = "0.6.3"

為定義一個過程式宏,請將範例 19-31 中的代碼放在 hello_macro_derive crate 的 src/lib.rs 文件裡面。注意這段代碼在我們添加 impl_hello_macro 函數的定義之前是無法編譯的。

檔案名: hello_macro_derive/src/lib.rs

在 Rust 1.31.0 時,extern crate 仍是必須的,請查看
https://github.com/rust-lang/rust/issues/54418
https://github.com/rust-lang/rust/pull/54658
https://github.com/rust-lang/rust/issues/55599

extern crate proc_macro;

use crate::proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // 構建 Rust 代碼所代表的語法樹
    // 以便可以進行操作
    let ast = syn::parse(input).unwrap();

    // 構建 trait 實現
    impl_hello_macro(&ast)
}

範例 19-31: 大多數過程式宏處理 Rust 代碼時所需的代碼

注意 hello_macro_derive 函數中代碼分割的方式,它負責解析 TokenStream,而 impl_hello_macro 函數則負責轉換語法樹:這讓編寫一個過程式宏更加方便。外部函數中的代碼(在這裡是 hello_macro_derive)幾乎在所有你能看到或創建的過程宏 crate 中都一樣。內部函數(在這裡是 impl_hello_macro)的函數體中所指定的代碼則依過程宏的目的而各有不同。

現在,我們已經引入了三個新的 crate:proc_macrosynquote 。Rust 自帶 proc_macro crate,因此無需將其加到 Cargo.toml 文件的依賴中。proc_macro crate 是編譯器用來讀取和操作我們 Rust 代碼的 API。

syn crate 將字串中的 Rust 代碼解析成為一個可以操作的數據結構。quote 則將 syn 解析的數據結構轉換回 Rust 代碼。這些 crate 讓解析任何我們所要處理的 Rust 代碼變得更簡單:為 Rust 編寫整個的解析器並不是一件簡單的工作。

當用戶在一個類型上指定 #[derive(HelloMacro)] 時,hello_macro_derive 函數將會被調用。原因在於我們已經使用 proc_macro_derive 及其指定名稱對 hello_macro_derive 函數進行了註解:HelloMacro ,其匹配到 trait 名,這是大多數過程宏遵循的習慣。

該函數首先將來自 TokenStreaminput 轉換為一個我們可以解釋和操作的數據結構。這正是 syn 派上用場的地方。syn 中的 parse_derive_input 函數獲取一個 TokenStream 並返回一個表示解析出 Rust 代碼的 DeriveInput 結構體。範例 19-32 展示了從字串 struct Pancakes; 中解析出來的 DeriveInput 結構體的相關部分:

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

範例 19-32: 解析範例 19-30 中帶有宏屬性的代碼時得到的 DeriveInput 實例

該結構體的欄位展示了我們解析的 Rust 代碼是一個類單元結構體,其 ident( identifier,表示名字)為 Pancakes。該結構體裡面有更多欄位描述了所有類型的 Rust 代碼,查閱 synDeriveInput 的文件 以獲取更多訊息。

此時,尚未定義 impl_hello_macro 函數,其用於構建所要包含在內的 Rust 新代碼。但在此之前,注意其輸出也是 TokenStream。所返回的 TokenStream 會被加到我們的 crate 用戶所寫的代碼中,因此,當用戶編譯他們的 crate 時,他們會獲取到我們所提供的額外功能。

你可能也注意到了,當調用 syn::parse 函數失敗時,我們用 unwrap 來使 hello_macro_derive 函數 panic。在錯誤時 panic 對過程宏來說是必須的,因為 proc_macro_derive 函數必須返回 TokenStream 而不是 Result,以此來符合過程宏的 API。這裡選擇用 unwrap 來簡化了這個例子;在生產代碼中,則應該通過 panic!expect 來提供關於發生何種錯誤的更加明確的錯誤訊息。

現在我們有了將註解的 Rust 代碼從 TokenStream 轉換為 DeriveInput 實例的代碼,讓我們來創建在註解類型上實現 HelloMacro trait 的代碼,如範例 19-33 所示。

檔案名: hello_macro_derive/src/lib.rs

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}", stringify!(#name));
            }
        }
    };
    gen.into()
}

範例 19-33: 使用解析過的 Rust 代碼實現 HelloMacro trait

我們得到一個包含以 ast.ident 作為註解類型名字(標識符)的 Ident 結構體實例。範例 19-32 中的結構體表明當 impl_hello_macro 函數運行於範例 19-30 中的代碼上時 ident 欄位的值是 "Pancakes"。因此,範例 19-33 中 name 變數會包含一個 Ident 結構體的實例,當列印時,會是字串 "Pancakes",也就是範例 19-30 中結構體的名稱。

quote! 宏讓我們可以編寫希望返回的 Rust 代碼。quote! 宏執行的直接結果並不是編譯器所期望的並需要轉換為 TokenStream。為此需要調用 into 方法,它會消費這個中間表示(intermediate representation,IR)並返回所需的 TokenStream 類型值。

這個宏也提供了一些非常酷的模板機制;我們可以寫 #name ,然後 quote! 會以名為 name 的變數值來替換它。你甚至可以做一些類似常用宏那樣的重複代碼的工作。查閱 quote crate 的文件 來獲取詳盡的介紹。

我們期望我們的過程式宏能夠為通過 #name 獲取到的用戶註解類型生成 HelloMacro trait 的實現。該 trait 的實現有一個函數 hello_macro ,其函數體包括了我們期望提供的功能:列印 Hello, Macro! My name is 和註解的類型名。

此處所使用的 stringify! 為 Rust 內建宏。其接收一個 Rust 表達式,如 1 + 2 , 然後在編譯時將表達式轉換為一個字串常量,如 "1 + 2" 。這與 format!println! 是不同的,它計算表達式並將結果轉換為 String 。有一種可能的情況是,所輸入的 #name 可能是一個需要列印的表達式,因此我們用 stringify!stringify! 編譯時也保留了一份將 #name 轉換為字串之後的記憶體分配。

此時,cargo build 應該都能成功編譯 hello_macrohello_macro_derive 。我們將這些 crate 連接到範例 19-38 的代碼中來看看過程宏的行為!在 projects 目錄下用 cargo new pancakes 命令新建一個二進位制項目。需要將 hello_macrohello_macro_derive 作為依賴加到 pancakes 包的 Cargo.toml 文件中去。如果你正將 hello_macrohello_macro_derive 的版本發布到 crates.io 上,其應為常規依賴;如果不是,則可以像下面這樣將其指定為 path 依賴:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

把範例 19-38 中的代碼放在 src/main.rs ,然後執行 cargo run:其應該列印 Hello, Macro! My name is Pancakes!。其包含了該過程宏中 HelloMacro trait 的實現,而無需 pancakes crate 實現它;#[derive(HelloMacro)] 增加了該 trait 實現。

接下來,讓我們探索一下其他類型的過程宏與自訂派生宏有何區別。

類屬性宏

類屬性宏與自訂派生宏相似,不同於為 derive 屬性生成代碼,它們允許你創建新的屬性。它們也更為靈活;derive 只能用於結構體和枚舉;屬性還可以用於其它的項,比如函數。作為一個使用類屬性宏的例子,可以創建一個名為 route 的屬性用於註解 web 應用程式框架(web application framework)的函數:

#[route(GET, "/")]
fn index() {

#[route] 屬性將由框架本身定義為一個過程宏。其宏定義的函數簽名看起來像這樣:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

這裡有兩個 TokenStream 類型的參數;第一個用於屬性內容本身,也就是 GET, "/" 部分。第二個是屬性所標記的項:在本例中,是 fn index() {} 和剩下的函數體。

除此之外,類屬性宏與自訂派生宏工作方式一致:創建 proc-macro crate 類型的 crate 並實現希望生成代碼的函數!

類函數宏

類函數宏定義看起來像函數調用的宏。類似於 macro_rules!,它們比函數更靈活;例如,可以接受未知數量的參數。然而 macro_rules! 宏只能使用之前 “使用 macro_rules! 的聲明宏用於通用元編程” 介紹的類匹配的語法定義。類函數宏獲取 TokenStream 參數,其定義使用 Rust 代碼操縱 TokenStream,就像另兩種過程宏一樣。一個類函數宏例子是可以像這樣被調用的 sql! 宏:

let sql = sql!(SELECT * FROM posts WHERE id=1);

這個宏會解析其中的 SQL 語句並檢查其是否是句法正確的,這是比 macro_rules! 可以做到的更為複雜的處理。sql! 宏應該被定義為如此:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

這類似於自訂派生宏的簽名:獲取括號中的 token,並返回希望生成的代碼。

總結

好的!現在我們學習了 Rust 並不常用但在特定情況下你可能用得著的功能。我們介紹了很多複雜的主題,這樣若你在錯誤訊息提示或閱讀他人代碼時遇到他們,至少可以說之前已經見過這些概念和語法了。你可以使用本章作為一個解決方案的參考。

接下來,我們將再開始一個項目,將本書所學的所有內容付與實踐!

最後的項目: 構建多執行緒 web server

ch20-00-final-project-a-web-server.md
commit c084bdd9ee328e7e774df19882ccc139532e53d8

這是一次漫長的旅途,不過我們到達了本書的結束。在本章中,我們將一同構建另一個項目,來展示最後幾章所學,同時複習更早的章節。

作為最後的項目,我們將要實現一個返回 “hello” 的 web server,它在瀏覽器中看起來就如圖例 20-1 所示:

hello from rust

圖例 20-1: 我們最後將一起分享的項目

如下是我們將怎樣構建此 web server 的計劃:

  1. 學習一些 TCP 與 HTTP 知識
  2. 在套接字(socket)上監聽 TCP 請求
  3. 解析少量的 HTTP 請求
  4. 創建一個合適的 HTTP 響應
  5. 通過執行緒池改善 server 的吞吐量

不過在開始之前,需要提到一點細節:這裡使用的方法並不是使用 Rust 構建 web server 最好的方法。crates.io 上有很多可用於生產環境的 crate,它們提供了比我們所要編寫的更為完整的 web server 和執行緒池實現。

然而,本章的目的在於學習,而不是走捷徑。因為 Rust 是一個系統程式語言,我們能夠選擇處理什麼層次的抽象,並能夠選擇比其他語言可能或可用的層次更低的層次。因此我們將自己編寫一個基礎的 HTTP server 和執行緒池,以便學習將來可能用到的 crate 背後的通用理念和技術。

構建單執行緒 web server

ch20-01-single-threaded.md
commit f617d58c1a88dd2912739a041fd4725d127bf9fb

首先讓我們創建一個可運行的單執行緒 web server,不過在開始之前,我們將快速了解一下構建 web server 所涉及到的協議。這些協議的細節超出了本書的範疇,不過一個簡單的概括會提供我們所需的訊息。

web server 中涉及到的兩個主要協議是 超文本傳輸協定Hypertext Transfer ProtocolHTTP)和 傳輸控制協議Transmission Control ProtocolTCP)。這兩者都是 請求-響應request-response)協議,也就是說,有 用戶端client)來初始化請求,並有 服務端server)監聽請求並向用戶端提供響應。請求與響應的內容由協議本身定義。

TCP 是一個底層協議,它描述了訊息如何從一個 server 到另一個的細節,不過其並不指定訊息是什麼。HTTP 構建於 TCP 之上,它定義了請求和響應的內容。為此,技術上講可將 HTTP 用於其他協議之上,不過對於絕大部分情況,HTTP 通過 TCP 傳輸。我們將要做的就是處理 TCP 和 HTTP 請求與響應的原始位元組數據。

監聽 TCP 連接

所以我們的 web server 所需做的第一件事便是能夠監聽 TCP 連接。標準庫提供了 std::net 模組處理這些功能。讓我們一如既往新建一個項目:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

並在 src/main.rs 輸入範例 20-1 中的代碼作為開始。這段代碼會在地址 127.0.0.1:7878 上監聽傳入的 TCP 流。當獲取到傳入的流,它會列印出 Connection established!

檔案名: src/main.rs

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}

範例 20-1: 監聽傳入的流並在接收到流時列印訊息

TcpListener 用於監聽 TCP 連接。我們選擇監聽地址 127.0.0.1:7878。將這個地址拆開,冒號之前的部分是一個代表本機的 IP 地址(這個地址在每台計算機上都相同,並不特指作者的計算機),而 7878 是埠。選擇這個埠出於兩個原因:通常 HTTP 接受這個埠而且 7878 在電話上打出來就是 "rust"(譯者註:九宮格鍵盤上的英文)。

在這個場景中 bind 函數類似於 new 函數,在這裡它返回一個新的 TcpListener 實例。這個函數叫做 bind 是因為,在網路領域,連接到監聽埠被稱為 “綁定到一個埠”(“binding to a port”)

bind 函數返回 Result<T, E>,這表明綁定可能會失敗,例如,連接 80 埠需要管理員權限(非管理員用戶只能監聽大於 1024 的埠),所以如果不是管理員嘗試連接 80 埠,則會綁定失敗。另一個例子是如果運行兩個此程序的實例這樣會有兩個程序監聽相同的埠,綁定會失敗。因為我們是出於學習目的來編寫一個基礎的 server,將不用關心處理這類錯誤,使用 unwrap 在出現這些情況時直接停止程式。

TcpListenerincoming 方法返回一個疊代器,它提供了一系列的流(更準確的說是 TcpStream 類型的流)。stream)代表一個用戶端和服務端之間打開的連接。連接connection)代表用戶端連接服務端、服務端生成響應以及服務端關閉連接的全部請求 / 響應過程。為此,TcpStream 允許我們讀取它來查看用戶端發送了什麼,並可以編寫響應。總體來說,這個 for 循環會依次處理每個連接並產生一系列的流供我們處理。

目前為止,處理流的過程包含 unwrap 調用,如果出現任何錯誤會終止程式,如果沒有任何錯誤,則列印出訊息。下一個範例我們將為成功的情況增加更多功能。當用戶端連接到服務端時 incoming 方法返回錯誤是可能的,因為我們實際上沒有遍歷連接,而是遍歷 連接嘗試connection attempts)。連接可能會因為很多原因不能成功,大部分是操作系統相關的。例如,很多系統限制同時打開的連接數;新連接嘗試產生錯誤,直到一些打開的連接關閉為止。

讓我們試試這段代碼!首先在終端執行 cargo run,接著在瀏覽器中載入 127.0.0.1:7878。瀏覽器會顯示出看起來類似於“連接重設”(“Connection reset”)的錯誤訊息,因為 server 目前並沒響應任何數據。但是如果我們觀察終端,會發現當瀏覽器連接 server 時會列印出一系列的訊息!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

有時會看到對於一次瀏覽器請求會列印出多條訊息;這可能是因為瀏覽器在請求頁面的同時還請求了其他資源,比如出現在瀏覽器 tab 標籤中的 favicon.ico

這也可能是因為瀏覽器嘗試多次連接 server,因為 server 沒有響應任何數據。當 stream 在循環的結尾離開作用域並被丟棄,其連接將被關閉,作為 drop 實現的一部分。瀏覽器有時透過重連來處理關閉的連接,因為這些問題可能是暫時的。現在重要的是我們成功的處理了 TCP 連接!

記得當運行完特定版本的代碼後使用 ctrl-C 來停止程式。並在做出最新的代碼修改之後執行 cargo run 重啟服務。

讀取請求

讓我們實現讀取來自瀏覽器請求的功能!為了分離獲取連接和接下來對連接的操作的相關內容,我們將開始一個新函數來處理連接。在這個新的 handle_connection 函數中,我們從 TCP 流中讀取數據並列印出來以便觀察瀏覽器發送過來的數據。將代碼修改為如範例 20-2 所示:

檔案名: src/main.rs

use std::io::prelude::*;
use std::net::TcpStream;
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];

    stream.read(&mut buffer).unwrap();

    println!("Request: {}", String::from_utf8_lossy(&buffer[..]));
}

範例 20-2: 讀取 TcpStream 並列印數據

這裡將 std::io::prelude 引入作用域來獲取讀寫流所需的特定 trait。在 main 函數的 for 循環中,相比獲取到連接時列印訊息,現在調用新的 handle_connection 函數並向其傳遞 stream

handle_connection 中,stream 參數是可變的。這是因為 TcpStream 實例在內部記錄了所返回的數據。它可能讀取了多於我們請求的數據並保存它們以備下一次請求數據。因此它需要是 mut 的因為其內部狀態可能會改變;通常我們認為 “讀取” 不需要可變性,不過在這個例子中則需要 mut 關鍵字。

接下來,需要實際讀取流。這裡分兩步進行:首先,在棧上聲明一個 buffer 來存放讀取到的數據。這裡創建了一個 1024 位元組的緩衝區,它足以存放基本請求的數據並滿足本章的目的需要。如果希望處理任意大小的請求,緩衝區管理將更為複雜,不過現在一切從簡。接著將緩衝區傳遞給 stream.read ,它會從 TcpStream 中讀取位元組並放入緩衝區中。

接下來將緩衝區中的位元組轉換為字串並列印出來。String::from_utf8_lossy 函數獲取一個 &[u8] 並產生一個 String。函數名的 “lossy” 部分來源於當其遇到無效的 UTF-8 序列時的行為:它使用 U+FFFD REPLACEMENT CHARACTER,來代替無效序列。你可能會在緩衝區的剩餘部分看到這些替代字元,因為他們沒有被請求數據填滿。

讓我們試一試!啟動程序並再次在瀏覽器中發起請求。注意瀏覽器中仍然會出現錯誤頁面,不過終端中程序的輸出現在看起來像這樣:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42 secs
     Running `target/debug/hello`
Request: GET / HTTP/1.1
Host: 127.0.0.1:7878
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
������������������������������������

根據使用的瀏覽器不同可能會出現稍微不同的數據。現在我們列印出了請求數據,可以通過觀察 Request: GET 之後的路徑來解釋為何會從瀏覽器得到多個連接。如果重複的連接都是請求 /,就知道了瀏覽器嘗試重複獲取 / 因為它沒有從程序得到響應。

拆開請求數據來理解瀏覽器向程序請求了什麼。

仔細觀察 HTTP 請求

HTTP 是一個基於文本的協議,同時一個請求有如下格式:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

第一行叫做 請求行request line),它存放了用戶端請求了什麼的訊息。請求行的第一部分是所使用的 method,比如 GETPOST,這描述了用戶端如何進行請求。這裡用戶端使用了 GET 請求。

請求行接下來的部分是 /,它代表用戶端請求的 統一資源標識符Uniform Resource IdentifierURI) —— URI 大體上類似,但也不完全類似於 URL(統一資源定位符Uniform Resource Locators)。URI 和 URL 之間的區別對於本章的目的來說並不重要,不過 HTTP 規範使用術語 URI,所以這裡可以簡單的將 URL 理解為 URI。

最後一部分是用戶端使用的HTTP版本,然後請求行以 CRLF序列 (CRLF代表回車和換行,carriage return line feed,這是打字機時代的術語!)結束。CRLF序列也可以寫成\r\n,其中\r是回車符,\n是換行符。 CRLF序列將請求行與其餘請求數據分開。 請注意,列印CRLF時,我們會看到一個新行,而不是\r\n

觀察目前運行程序所接收到的數據的請求行,可以看到 GET 是 method,/ 是請求 URI,而 HTTP/1.1 是版本。

Host: 開始的其餘的行是 headers;GET 請求沒有 body。

如果你希望的話,嘗試用不同的瀏覽器發送請求,或請求不同的地址,比如 127.0.0.1:7878/test,來觀察請求數據如何變化。

現在我們知道了瀏覽器請求了什麼。讓我們返回一些數據!

編寫響應

我們將實現在用戶端請求的響應中發送數據的功能。響應有如下格式:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

第一行叫做 狀態行status line),它包含響應的 HTTP 版本、一個數字狀態碼用以總結請求的結果和一個描述之前狀態碼的文本原因短語。CRLF 序列之後是任意 header,另一個 CRLF 序列,和響應的 body。

這裡是一個使用 HTTP 1.1 版本的響應例子,其狀態碼為 200,原因短語為 OK,沒有 header,也沒有 body:

HTTP/1.1 200 OK\r\n\r\n

狀態碼 200 是一個標準的成功響應。這些文本是一個微型的成功 HTTP 響應。讓我們將這些文本寫入流作為成功請求的響應!在 handle_connection 函數中,我們需要去掉列印請求數據的 println!,並替換為範例 20-3 中的代碼:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io::prelude::*;
use std::net::TcpStream;
fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];

    stream.read(&mut buffer).unwrap();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
}

範例 20-3: 將一個微型成功 HTTP 響應寫入流

新代碼中的第一行定義了變數 response 來存放將要返回的成功響應的數據。接著,在 response 上調用 as_bytes,因為 streamwrite 方法獲取一個 &[u8] 並直接將這些位元組發送給連接。

因為 write 操作可能會失敗,所以像之前那樣對任何錯誤結果使用 unwrap。同理,在真實世界的應用中這裡需要添加錯誤處理。最後,flush 會等待並阻塞程序執行直到所有位元組都被寫入連接中;TcpStream 包含一個內部緩衝區來最小化對底層操作系統的調用。

有了這些修改,運行我們的代碼並進行請求!我們不再向終端列印任何數據,所以不會再看到除了 Cargo 以外的任何輸出。不過當在瀏覽器中載入 127.0.0.1:7878 時,會得到一個空頁面而不是錯誤。太棒了!我們剛剛手寫了一個 HTTP 請求與響應。

返回真正的 HTML

讓我們實現不只是返回空頁面的功能。在項目根目錄創建一個新文件,hello.html —— 也就是說,不是在 src 目錄。在此可以放入任何你期望的 HTML;列表 20-4 展示了一個可能的文本:

檔案名: hello.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

範例 20-4: 一個簡單的 HTML 文件用來作為響應

這是一個極小化的 HTML5 文件,它有一個標題和一小段文本。為了在 server 接受請求時返回它,需要如範例 20-5 所示修改 handle_connection 來讀取 HTML 文件,將其加入到響應的 body 中,並發送:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io::prelude::*;
use std::net::TcpStream;
use std::fs;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let contents = fs::read_to_string("hello.html").unwrap();

    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
        contents.len(),
        contents
    );

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
}

範例 20-5: 將 hello.html 的內容作為響應 body 發送

在開頭增加了一行來將標準庫中的 File 引入作用域。打開和讀取文件的代碼應該看起來很熟悉,因為第十二章 I/O 項目的範例 12-4 中讀取文件內容時出現過類似的代碼。

接下來,使用 format! 將文件內容加入到將要寫入流的成功響應的 body 中。

使用 cargo run 運行程序,在瀏覽器載入 127.0.0.1:7878,你應該會看到渲染出來的 HTML 文件!

目前忽略了 buffer 中的請求數據並無條件的發送了 HTML 文件的內容。這意味著如果嘗試在瀏覽器中請求 127.0.0.1:7878/something-else 也會得到同樣的 HTML 響應。如此其作用是非常有限的,也不是大部分 server 所做的;讓我們檢查請求並只對格式良好(well-formed)的請求 / 發送 HTML 文件。

驗證請求並有選擇的進行響應

目前我們的 web server 不管用戶端請求什麼都會返回相同的 HTML 文件。讓我們增加在返回 HTML 文件前檢查瀏覽器是否請求 /,並在其請求任何其他內容時返回錯誤的功能。為此需要如範例 20-6 那樣修改 handle_connection。新代碼接收到的請求的內容與已知的 / 請求的一部分做比較,並增加了 ifelse 塊來區別處理請求:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io::prelude::*;
use std::net::TcpStream;
use std::fs;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";

    if buffer.starts_with(get) {
        let contents = fs::read_to_string("hello.html").unwrap();

        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        );

        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    } else {
        // 其他請求
    }
}
}

範例 20-6: 匹配請求並區別處理 / 請求與其他請求

首先,將與 / 請求相關的數據寫死進變數 get。因為我們將原始位元組讀取進了緩衝區,所以在 get 的數據開頭增加 b"" 位元組字串語法將其轉換為位元組字串。接著檢查 buffer 是否以 get 中的位元組開頭。如果是,這就是一個格式良好的 / 請求,也就是 if 塊中期望處理的成功情況,並會返回 HTML 文件內容的代碼。

如果 buffer get 中的位元組開頭,就說明接收的是其他請求。之後會在 else 塊中增加代碼來響應所有其他請求。

現在如果運行程式碼並請求 127.0.0.1:7878,就會得到 hello.html 中的 HTML。如果進行任何其他請求,比如 127.0.0.1:7878/something-else,則會得到像運行範例 20-1 和 20-2 中代碼那樣的連接錯誤。

現在向範例 20-7 的 else 塊增加代碼來返回一個帶有 404 狀態碼的響應,這代表了所請求的內容沒有找到。接著也會返回一個 HTML 向瀏覽器終端用戶表明此意:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io::prelude::*;
use std::net::TcpStream;
use std::fs;
fn handle_connection(mut stream: TcpStream) {
if true {
// --snip--

} else {
    let status_line = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
    let contents = fs::read_to_string("404.html").unwrap();

    let response = format!("{}{}", status_line, contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
}
}

範例 20-7: 對於任何不是 / 的請求返回 404 狀態碼的響應和錯誤頁面

這裡,響應的狀態行有狀態碼 404 和原因短語 NOT FOUND。仍然沒有返回任何 header,而其 body 將是 404.html 文件中的 HTML。需要在 hello.html 同級目錄創建 404.html 文件作為錯誤頁面;這一次也可以隨意使用任何 HTML 或使用範例 20-8 中的範例 HTML:

檔案名: 404.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

範例 20-8: 任何 404 響應所返回錯誤頁面內容樣例

有了這些修改,再次運行 server。請求 127.0.0.1:7878 應該會返回 hello.html 的內容,而對於任何其他請求,比如 127.0.0.1:7878/foo,應該會返回 404.html 中的錯誤 HTML!

少量代碼重構

目前 ifelse 塊中的代碼有很多的重複:他們都讀取文件並將其內容寫入流。唯一的區別是狀態行和檔案名。為了使代碼更為簡明,將這些區別分別提取到一行 ifelse 中,對狀態行和檔案名變數賦值;然後在讀取文件和寫入響應的代碼中無條件的使用這些變數。重構後取代了大段 ifelse 塊代碼後的結果如範例 20-9 所示:

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::io::prelude::*;
use std::net::TcpStream;
use std::fs;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";
    // --snip--

    let (status_line, filename) = if buffer.starts_with(get) {
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();

    let response = format!("{}{}", status_line, contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
}

範例 20-9: 重構使得 ifelse 塊中只包含兩個情況所不同的代碼

現在 ifelse 塊所做的唯一的事就是在一個元組中返回合適的狀態行和檔案名的值;接著使用第十八章講到的使用模式的 let 語句通過解構元組的兩部分為 filenameheader 賦值。

之前讀取文件和寫入響應的冗餘代碼現在位於 ifelse 塊之外,並會使用變數 status_linefilename。這樣更易於觀察這兩種情況真正有何不同,還意味著如果需要改變如何讀取文件或寫入響應時只需要更新一處的代碼。範例 20-9 中代碼的行為與範例 20-8 完全一樣。

好極了!我們有了一個 40 行左右 Rust 代碼的小而簡單的 server,它對一個請求返回頁面內容而對所有其他請求返回 404 響應。

目前 server 運行於單執行緒中,它一次只能處理一個請求。讓我們模擬一些慢請求來看看這如何會成為一個問題,並進行修復以便 server 可以一次處理多個請求。

將單執行緒 server 變為多執行緒 server

ch20-02-multithreaded.md
commit 120e76a0cc77c9cde52643f847ed777f8f441817

目前 server 會依次處理每一個請求,意味著它在完成第一個連接的處理之前不會處理第二個連接。如果 server 正接收越來越多的請求,這類串列操作會使性能越來越差。如果一個請求花費很長時間來處理,隨後而來的請求則不得不等待這個長請求結束,即便這些新請求可以很快就處理完。我們需要修復這種情況,不過首先讓我們實際嘗試一下這個問題。

在當前 server 實現中模擬慢請求

讓我們看看一個慢請求如何影響當前 server 實現中的其他請求。範例 20-10 通過模擬慢響應實現了 /sleep 請求處理,它會使 server 在響應之前休眠五秒。

檔案名: src/main.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::time::Duration;
use std::io::prelude::*;
use std::net::TcpStream;
use std::fs::File;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 512];
    stream.read(&mut buffer).unwrap();
    // --snip--

    let get = b"GET / HTTP/1.1\r\n";
    let sleep = b"GET /sleep HTTP/1.1\r\n";

    let (status_line, filename) = if buffer.starts_with(get) {
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else if buffer.starts_with(sleep) {
        thread::sleep(Duration::from_secs(5));
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html")
    };

    // --snip--
}
}

範例 20-10: 通過識別 /sleep 並休眠五秒來模擬慢請求

這段代碼有些凌亂,不過對於模擬的目的來說已經足夠。這裡創建了第二個請求 sleep,我們會識別其數據。在 if 塊之後增加了一個 else if 來檢查 /sleep 請求,當接收到這個請求時,在渲染成功 HTML 頁面之前會先休眠五秒。

現在就可以真切的看出我們的 server 有多麼的原始:真實的庫將會以更簡潔的方式處理多請求識別問題!

使用 cargo run 啟動 server,並接著打開兩個瀏覽器窗口:一個請求 http://127.0.0.1:7878/ 而另一個請求 http://127.0.0.1:7878/sleep 。如果像之前一樣多次請求 /,會發現響應的比較快速。不過如果請求 /sleep 之後在請求 /,就會看到 / 會等待直到 sleep 休眠完五秒之後才出現。

這裡有多種辦法來改變我們的 web server 使其避免所有請求都排在慢請求之後;我們將要實現的一個便是執行緒池。

使用執行緒池改善吞吐量

執行緒池thread pool)是一組預先分配的等待或準備處理任務的執行緒。當程序收到一個新任務,執行緒池中的一個執行緒會被分配任務,這個執行緒會離開並處理任務。其餘的執行緒則可用於處理在第一個執行緒處理任務的同時處理其他接收到的任務。當第一個執行緒處理完任務時,它會返回空閒執行緒池中等待處理新任務。執行緒池允許我們並發處理連接,增加 server 的吞吐量。

我們會將池中執行緒限制為較少的數量,以防拒絕服務(Denial of Service, DoS)攻擊;如果程序為每一個接收的請求都新建一個執行緒,某人向 server 發起千萬級的請求時會耗盡伺服器的資源並導致所有請求的處理都被終止。

不同於分配無限的執行緒,執行緒池中將有固定數量的等待執行緒。當新進請求時,將請求發送到執行緒池中做處理。執行緒池會維護一個接收請求的隊列。每一個執行緒會從隊列中取出一個請求,處理請求,接著向對隊列索取另一個請求。通過這種設計,則可以並發處理 N 個請求,其中 N 為執行緒數。如果每一個執行緒都在響應慢請求,之後的請求仍然會阻塞隊列,不過相比之前增加了能處理的慢請求的數量。

這個設計僅僅是多種改善 web server 吞吐量的方法之一。其他可供探索的方法有 fork/join 模型和單執行緒非同步 I/O 模型。如果你對這個主題感興趣,則可以閱讀更多關於其他解決方案的內容並嘗試用 Rust 實現他們;對於一個像 Rust 這樣的底層語言,所有這些方法都是可能的。

在開始之前,讓我們討論一下執行緒池應用看起來怎樣。當嘗試設計代碼時,首先編寫用戶端介面確實有助於指導代碼設計。以期望的調用方式來構建 API 代碼的結構,接著在這個結構之內實現功能,而不是先實現功能再設計公有 API。

類似於第十二章項目中使用的測試驅動開發。這裡將要使用編譯器驅動開發(compiler-driven development)。我們將編寫調用所期望的函數的代碼,接著觀察編譯器錯誤告訴我們接下來需要修改什麼使得代碼可以工作。

為每一個請求分配執行緒的代碼結構

首先,讓我們探索一下為每一個連接都創建一個執行緒的代碼看起來如何。這並不是最終方案,因為正如之前講到的它會潛在的分配無限的執行緒,不過這是一個開始。範例 20-11 展示了 main 的改變,它在 for 循環中為每一個流分配了一個新執行緒進行處理:

檔案名: src/main.rs

use std::thread;
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::TcpStream;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}
fn handle_connection(mut stream: TcpStream) {}

範例 20-11: 為每一個流新建一個執行緒

正如第十六章講到的,thread::spawn 會創建一個新執行緒並在其中運行閉包中的代碼。如果運行這段代碼並在在瀏覽器中載入 /sleep,接著在另兩個瀏覽器標籤頁中載入 /,確實會發現 / 請求不必等待 /sleep 結束。不過正如之前提到的,這最終會使系統崩潰因為我們無限制的創建新執行緒。

為有限數量的執行緒創建一個類似的介面

我們期望執行緒池以類似且熟悉的方式工作,以便從執行緒切換到執行緒池並不會對使用該 API 的代碼做出較大的修改。範例 20-12 展示我們希望用來替換 thread::spawnThreadPool 結構體的假想介面:

檔案名: src/main.rs

use std::thread;
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::TcpStream;
struct ThreadPool;
impl ThreadPool {
   fn new(size: u32) -> ThreadPool { ThreadPool }
   fn execute<F>(&self, f: F)
       where F: FnOnce() + Send + 'static {}
}

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}
fn handle_connection(mut stream: TcpStream) {}

範例 20-12: 假想的 ThreadPool 介面

這裡使用 ThreadPool::new 來創建一個新的執行緒池,它有一個可配置的執行緒數的參數,在這裡是四。這樣在 for 循環中,pool.execute 有著類似 thread::spawn 的介面,它獲取一個執行緒池運行於每一個流的閉包。pool.execute 需要實現為獲取閉包並傳遞給池中的執行緒運行。這段代碼還不能編譯,不過通過嘗試編譯器會指導我們如何修復它。

採用編譯器驅動構建 ThreadPool 結構體

繼續並對範例 20-12 中的 src/main.rs 做出修改,並利用來自 cargo check 的編譯器錯誤來驅動開發。下面是我們得到的第一個錯誤:

$ cargo check
   Compiling hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve. Use of undeclared type or module `ThreadPool`
  --> src\main.rs:10:16
   |
10 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^^^^^^ Use of undeclared type or module
   `ThreadPool`

error: aborting due to previous error

好的,這告訴我們需要一個 ThreadPool 類型或模組,所以我們將構建一個。ThreadPool 的實現會與 web server 的特定工作相獨立,所以讓我們從 hello crate 切換到存放 ThreadPool 實現的新庫 crate。這也意味著可以在任何工作中使用這個單獨的執行緒池庫,而不僅僅是處理網路請求。

創建 src/lib.rs 文件,它包含了目前可用的最簡單的 ThreadPool 定義:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct ThreadPool;
}

接著創建一個新目錄,src/bin,並將二進位制 crate 根文件從 src/main.rs 移動到 src/bin/main.rs。這使得庫 crate 成為 hello 目錄的主要 crate;不過仍然可以使用 cargo run 運行 src/bin/main.rs 二進位制文件。移動了 main.rs 文件之後,修改 src/bin/main.rs 文件開頭加入如下代碼來引入庫 crate 並將 ThreadPool 引入作用域:

檔案名: src/bin/main.rs

use hello::ThreadPool;

這仍然不能工作,再次嘗試運行來得到下一個需要解決的錯誤:

$ cargo check
   Compiling hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for type
`hello::ThreadPool` in the current scope
 --> src/bin/main.rs:13:16
   |
13 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^^^^^^ function or associated item not found in
   `hello::ThreadPool`

這告訴我們下一步是為 ThreadPool 創建一個叫做 new 的關聯函數。我們還知道 new 需要有一個參數可以接受 4,而且 new 應該返回 ThreadPool 實例。讓我們實現擁有此特徵的最小化 new 函數:

文件夾: src/lib.rs


#![allow(unused)]
fn main() {
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}
}

這裡選擇 usize 作為 size 參數的類型,因為我們知道為負的執行緒數沒有意義。我們還知道將使用 4 作為執行緒集合的元素數量,這也就是使用 usize 類型的原因,如第三章 “整數類型” 部分所講。

再次編譯檢查這段代碼:

$ cargo check
   Compiling hello v0.1.0 (file:///projects/hello)
warning: unused variable: `size`
 --> src/lib.rs:4:16
  |
4 |     pub fn new(size: usize) -> ThreadPool {
  |                ^^^^
  |
  = note: #[warn(unused_variables)] on by default
  = note: to avoid this warning, consider using `_size` instead

error[E0599]: no method named `execute` found for type `hello::ThreadPool` in the current scope
  --> src/bin/main.rs:18:14
   |
18 |         pool.execute(|| {
   |              ^^^^^^^

現在有了一個警告和一個錯誤。暫時先忽略警告,發生錯誤是因為並沒有 ThreadPool 上的 execute 方法。回憶 “為有限數量的執行緒創建一個類似的介面” 部分我們決定執行緒池應該有與 thread::spawn 類似的介面,同時我們將實現 execute 函數來獲取傳遞的閉包並將其傳遞給池中的空閒執行緒執行。

我們會在 ThreadPool 上定義 execute 函數來獲取一個閉包參數。回憶第十三章的 “使用帶有泛型和 Fn trait 的閉包” 部分,閉包作為參數時可以使用三個不同的 trait:FnFnMutFnOnce。我們需要決定這裡應該使用哪種閉包。最終需要實現的類似於標準庫的 thread::spawn,所以我們可以觀察 thread::spawn 的簽名在其參數中使用了何種 bound。查看文件會發現:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T + Send + 'static,
        T: Send + 'static

F 是這裡我們關心的參數;T 與返回值有關所以我們並不關心。考慮到 spawn 使用 FnOnce 作為 F 的 trait bound,這可能也是我們需要的,因為最終會將傳遞給 execute 的參數傳給 spawn。因為處理請求的執行緒只會執行閉包一次,這也進一步確認了 FnOnce 是我們需要的 trait,這裡符合 FnOnceOnce 的意思。

F 還有 trait bound Send 和生命週期綁定 'static,這對我們的情況也是有意義的:需要 Send 來將閉包從一個執行緒轉移到另一個執行緒,而 'static 是因為並不知道執行緒會執行多久。讓我們編寫一個使用帶有這些 bound 的泛型參數 FThreadPoolexecute 方法:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct ThreadPool;
impl ThreadPool {
    // --snip--

    pub fn execute<F>(&self, f: F)
        where
            F: FnOnce() + Send + 'static
    {

    }
}
}

FnOnce trait 仍然需要之後的 (),因為這裡的 FnOnce 代表一個沒有參數也沒有返回值的閉包。正如函數的定義,返回值類型可以從簽名中省略,不過即便沒有參數也需要括號。

這裡再一次增加了 execute 方法的最小化實現:它沒有做任何工作,只是嘗試讓代碼能夠編譯。再次進行檢查:

$ cargo check
   Compiling hello v0.1.0 (file:///projects/hello)
warning: unused variable: `size`
 --> src/lib.rs:4:16
  |
4 |     pub fn new(size: usize) -> ThreadPool {
  |                ^^^^
  |
  = note: #[warn(unused_variables)] on by default
  = note: to avoid this warning, consider using `_size` instead

warning: unused variable: `f`
 --> src/lib.rs:8:30
  |
8 |     pub fn execute<F>(&self, f: F)
  |                              ^
  |
  = note: to avoid this warning, consider using `_f` instead

現在就只有警告了!這意味著能夠編譯了!注意如果嘗試 cargo run 運行程序並在瀏覽器中發起請求,仍會在瀏覽器中出現在本章開始時那樣的錯誤。這個庫實際上還沒有調用傳遞給 execute 的閉包!

一個你可能聽說過的關於像 Haskell 和 Rust 這樣有嚴格編譯器的語言的說法是 “如果代碼能夠編譯,它就能工作”。這是一個提醒大家的好時機,實際上這並不是普適的。我們的項目可以編譯,不過它完全沒有做任何工作!如果構建一個真實且功能完整的項目,則需花費大量的時間來開始編寫單元測試來檢查代碼能否編譯 並且 擁有期望的行為。

new 中驗證池中執行緒數量

這裡仍然存在警告是因為其並沒有對 newexecute 的參數做任何操作。讓我們用期望的行為來實現這些函數。以考慮 new 作為開始。之前選擇使用無符號類型作為 size 參數的類型,因為執行緒數為負的執行緒池沒有意義。然而,執行緒數為零的執行緒池同樣沒有意義,不過零是一個完全有效的 u32 值。讓我們增加在返回 ThreadPool 實例之前檢查 size 是否大於零的代碼,並使用 assert! 宏在得到零時 panic,如範例 20-13 所示:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
pub struct ThreadPool;
impl ThreadPool {
    /// 創建執行緒池。
    ///
    /// 執行緒池中執行緒的數量。
    ///
    /// # Panics
    ///
    /// `new` 函數在 size 為 0 時會 panic。
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
}
}

範例 20-13: 實現 ThreadPool::newsize 為零時 panic

這裡用文件注釋為 ThreadPool 增加了一些文件。注意這裡遵循了良好的文件實踐並增加了一個部分來提示函數會 panic 的情況,正如第十四章所討論的。嘗試運行 cargo doc --open 並點擊 ThreadPool 結構體來查看生成的 new 的文件看起來如何!

相比像這裡使用 assert! 宏,也可以讓 new 像之前 I/O 項目中範例 12-9 中 Config::new 那樣返回一個 Result,不過在這裡我們選擇創建一個沒有任何執行緒的執行緒池應該是不可恢復的錯誤。如果你想做的更好,嘗試編寫一個採用如下簽名的 new 版本來感受一下兩者的區別:

pub fn new(size: usize) -> Result<ThreadPool, PoolCreationError> {

分配空間以儲存執行緒

現在有了一個有效的執行緒池執行緒數,就可以實際創建這些執行緒並在返回之前將他們儲存在 ThreadPool 結構體中。不過如何 “儲存” 一個執行緒?讓我們再看看 thread::spawn 的簽名:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T + Send + 'static,
        T: Send + 'static

spawn 返回 JoinHandle<T>,其中 T 是閉包返回的類型。嘗試使用 JoinHandle 來看看會發生什麼事。在我們的情況中,傳遞給執行緒池的閉包會處理連接並不返回任何值,所以 T 將會是單元類型 ()

範例 20-14 中的代碼可以編譯,不過實際上還並沒有創建任何執行緒。我們改變了 ThreadPool 的定義來存放一個 thread::JoinHandle<()> 的 vector 實例,使用 size 容量來初始化,並設置一個 for 循環了來運行創建執行緒的代碼,並返回包含這些執行緒的 ThreadPool 實例:

檔案名: src/lib.rs

use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool {
            threads
        }
    }

    // --snip--
}

範例 20-14: 為 ThreadPool 創建一個 vector 來存放執行緒

這裡將 std::thread 引入庫 crate 的作用域,因為使用了 thread::JoinHandle 作為 ThreadPool 中 vector 元素的類型。

在得到了有效的數量之後,ThreadPool 新建一個存放 size 個元素的 vector。本書還未使用過 with_capacity,它與 Vec::new 做了同樣的工作,不過有一個重要的區別:它為 vector 預先分配空間。因為已經知道了 vector 中需要 size 個元素,預先進行分配比僅僅 Vec::new 要稍微有效率一些,因為 Vec::new 隨著插入元素而重新改變大小。

如果再次運行 cargo check,會看到一些警告,不過應該可以編譯成功。

Worker 結構體負責從 ThreadPool 中將代碼傳遞給執行緒

範例 20-14 的 for 循環中留下了一個關於創建執行緒的注釋。如何實際創建執行緒呢?這是一個難題。標準庫提供的創建執行緒的方法,thread::spawn,它期望獲取一些一旦創建執行緒就應該執行的代碼。然而,我們希望開始執行緒並使其等待稍後傳遞的代碼。標準庫的執行緒實現並沒有包含這麼做的方法;我們必須自己實現。

我們將要實現的行為是創建執行緒並稍後發送代碼,這會在 ThreadPool 和執行緒間引入一個新數據類型來管理這種新行為。這個數據結構稱為 Worker:這是一個池實現中的常見概念。想像一下在餐館廚房工作的員工:員工等待來自客戶的訂單,他們負責接受這些訂單並完成它們。

不同於在執行緒池中儲存一個 JoinHandle<()> 實例的 vector,我們會儲存 Worker 結構體的實例。每一個 Worker 會儲存一個單獨的 JoinHandle<()> 實例。接著會在 Worker 上實現一個方法,它會獲取需要允許代碼的閉包並將其發送給已經運行的執行緒執行。我們還會賦予每一個 worker id,這樣就可以在日誌和除錯中區別執行緒池中的不同 worker。

首先,讓我們做出如此創建 ThreadPool 時所需的修改。在透過如下方式設置完 Worker 之後,我們會實現向執行緒發送閉包的代碼:

  1. 定義 Worker 結構體存放 idJoinHandle<()>
  2. 修改 ThreadPool 存放一個 Worker 實例的 vector
  3. 定義 Worker::new 函數,它獲取一個 id 數字並返回一個帶有 id 和用空閉包分配的執行緒的 Worker 實例
  4. ThreadPool::new 中,使用 for 循環計數生成 id,使用這個 id 新建 Worker,並儲存進 vector 中

如果你渴望挑戰,在查範例 20-15 中的代碼之前嘗試自己實現這些修改。

準備好了嗎?範例 20-15 就是一個做出了這些修改的例子:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool {
            workers
        }
    }
    // --snip--
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker {
            id,
            thread,
        }
    }
}
}

範例 20-15: 修改 ThreadPool 存放 Worker 實例而不是直接存放執行緒

這裡將 ThreadPool 中欄位名從 threads 改為 workers,因為它現在儲存 Worker 而不是 JoinHandle<()>。使用 for 循環中的計數作為 Worker::new 的參數,並將每一個新建的 Worker 儲存在叫做 workers 的 vector 中。

Worker 結構體和其 new 函數是私有的,因為外部代碼(比如 src/bin/main.rs 中的 server)並不需要知道關於 ThreadPool 中使用 Worker 結構體的實現細節。Worker::new 函數使用 id 參數並儲存了使用一個空閉包創建的 JoinHandle<()>

這段代碼能夠編譯並用指定給 ThreadPool::new 的參數創建儲存了一系列的 Worker 實例,不過 仍然 沒有處理 execute 中得到的閉包。讓我們聊聊接下來怎麼做。

使用通道向執行緒發送請求

下一個需要解決的問題是傳遞給 thread::spawn 的閉包完全沒有做任何工作。目前,我們在 execute 方法中獲得期望執行的閉包,不過在創建 ThreadPool 的過程中創建每一個 Worker 時需要向 thread::spawn 傳遞一個閉包。

我們希望剛創建的 Worker 結構體能夠從 ThreadPool 的隊列中獲取需要執行的代碼,並發送到執行緒中執行他們。

在第十六章,我們學習了 通道 —— 一個溝通兩個執行緒的簡單手段 —— 對於這個例子來說則是絕佳的。這裡通道將充當任務隊列的作用,execute 將通過 ThreadPool 向其中執行緒正在尋找工作的 Worker 實例發送任務。如下是這個計劃:

  1. ThreadPool 會創建一個通道並充當發送端。
  2. 每個 Worker 將會充當通道的接收端。
  3. 新建一個 Job 結構體來存放用於向通道中發送的閉包。
  4. execute 方法會在通道發送端發出期望執行的任務。
  5. 在執行緒中,Worker 會遍歷通道的接收端並執行任何接收到的任務。

讓我們以在 ThreadPool::new 中創建通道並讓 ThreadPool 實例充當發送端開始,如範例 20-16 所示。Job 是將在通道中發出的類型,目前它是一個沒有任何內容的結構體:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::thread;
// --snip--
use std::sync::mpsc;

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool {
            workers,
            sender,
        }
    }
    // --snip--
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker {
            id,
            thread,
        }
    }
}
}

範例 20-16: 修改 ThreadPool 來儲存一個發送 Job 實例的通道發送端

ThreadPool::new 中,新建了一個通道,並接著讓執行緒池在接收端等待。這段代碼能夠編譯,不過仍有警告。

讓我們嘗試在執行緒池創建每個 worker 時將通道的接收端傳遞給他們。須知我們希望在 worker 所分配的執行緒中使用通道的接收端,所以將在閉包中引用 receiver 參數。範例 20-17 中展示的代碼還不能編譯:

檔案名: src/lib.rs

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool {
            workers,
            sender,
        }
    }
    // --snip--
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker {
            id,
            thread,
        }
    }
}

範例 20-17: 將通道的接收端傳遞給 worker

這是一些小而直觀的修改:將通道的接收端傳遞進了 Worker::new,並接著在閉包中使用它。

如果嘗試 check 代碼,會得到這個錯誤:

$ cargo check
   Compiling hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:27:42
   |
27 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here in
   previous iteration of loop
   |
   = note: move occurs because `receiver` has type
   `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait

這段代碼嘗試將 receiver 傳遞給多個 Worker 實例。這是不行的,回憶第十六章:Rust 所提供的通道實現是多 生產者,單 消費者 的。這意味著不能簡單的複製通道的消費端來解決問題。即便可以,那也不是我們希望使用的技術;我們希望通過在所有的 worker 中共享單一 receiver,在執行緒間分發任務。

另外,從通道隊列中取出任務涉及到修改 receiver,所以這些執行緒需要一個能安全的共享和修改 receiver 的方式,否則可能導致競爭狀態(參考第十六章)。

回憶一下第十六章討論的執行緒安全智慧指針,為了在多個執行緒間共享所有權並允許執行緒修改其值,需要使用 Arc<Mutex<T>>Arc 使得多個 worker 擁有接收端,而 Mutex 則確保一次只有一個 worker 能從接收端得到任務。範例 20-18 展示了所需的修改:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}
struct Job;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender,
        }
    }

    // --snip--
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
           receiver;
        });

        Worker {
            id,
            thread,
        }
    }
}
}

範例 20-18: 使用 ArcMutex 在 worker 間共享通道的接收端

ThreadPool::new 中,將通道的接收端放入一個 Arc 和一個 Mutex 中。對於每一個新 worker,複製 Arc 來增加引用計數,如此這些 worker 就可以共享接收端的所有權了。

通過這些修改,代碼可以編譯了!我們做到了!

實現 execute 方法

最後讓我們實現 ThreadPool 上的 execute 方法。同時也要修改 Job 結構體:它將不再是結構體,Job 將是一個有著 execute 接收到的閉包類型的 trait 對象的類型別名。第十九章 “類型別名用來創建類型同義詞” 部分提到過,類型別名允許將長的類型變短。觀察範例 20-19:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
// --snip--
pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}
use std::sync::mpsc;
struct Worker {}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--

    pub fn execute<F>(&self, f: F)
        where
            F: FnOnce() + Send + 'static
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--
}

範例 20-19: 為存放每一個閉包的 Box 創建一個 Job 類型別名,接著在通道中發出任務

在使用 execute 得到的閉包新建 Job 實例之後,將這些任務從通道的發送端發出。這裡調用 send 上的 unwrap,因為發送可能會失敗,這可能發生於例如停止了所有執行緒執行的情況,這意味著接收端停止接收新消息了。不過目前我們無法停止執行緒執行;只要執行緒池存在他們就會一直執行。使用 unwrap 是因為我們知道失敗不可能發生,即便編譯器不這麼認為。

不過到此事情還沒有結束!在 worker 中,傳遞給 thread::spawn 的閉包仍然還只是 引用 了通道的接收端。相反我們需要閉包一直循環,向通道的接收端請求任務,並在得到任務時執行他們。如範例 20-20 對 Worker::new 做出修改:

檔案名: src/lib.rs

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {} got a job; executing.", id);

                job();
            }
        });

        Worker {
            id,
            thread,
        }
    }
}

範例 20-20: 在 worker 執行緒中接收並執行任務

這裡,首先在 receiver 上調用了 lock 來獲取互斥器,接著 unwrap 在出現任何錯誤時 panic。如果互斥器處於一種叫做 被汙染poisoned)的狀態時獲取鎖可能會失敗,這可能發生於其他執行緒在持有鎖時 panic 了且沒有釋放鎖。在這種情況下,調用 unwrap 使其 panic 是正確的行為。請隨意將 unwrap 改為包含有意義錯誤訊息的 expect

如果鎖定了互斥器,接著調用 recv 從通道中接收 Job。最後的 unwrap 也繞過了一些錯誤,這可能發生於持有通道發送端的執行緒停止的情況,類似於如果接收端關閉時 send 方法如何返回 Err 一樣。

調用 recv 會阻塞當前執行緒,所以如果還沒有任務,其會等待直到有可用的任務。Mutex<T> 確保一次只有一個 Worker 執行緒嘗試請求任務。

透過這個技巧,執行緒池處於可以運行的狀態了!執行 cargo run 並發起一些請求:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field is never used: `workers`
 --> src/lib.rs:7:5
  |
7 |     workers: Vec<Worker>,
  |     ^^^^^^^^^^^^^^^^^^^^
  |
  = note: #[warn(dead_code)] on by default

warning: field is never used: `id`
  --> src/lib.rs:61:5
   |
61 |     id: usize,
   |     ^^^^^^^^^
   |
   = note: #[warn(dead_code)] on by default

warning: field is never used: `thread`
  --> src/lib.rs:62:5
   |
62 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: #[warn(dead_code)] on by default

    Finished dev [unoptimized + debuginfo] target(s) in 0.99 secs
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

成功了!現在我們有了一個可以非同步執行連接的執行緒池!它絕不會創建超過四個執行緒,所以當 server 收到大量請求時系統也不會負擔過重。如果請求 /sleep,server 也能夠通過另外一個執行緒處理其他請求。

注意如果同時在多個瀏覽器窗口打開 /sleep,它們可能會彼此間隔地載入 5 秒,因為一些瀏覽器處於快取的原因會順序執行相同請求的多個實例。這些限制並不是由於我們的 web server 造成的。

在學習了第十八章的 while let 循環之後,你可能會好奇為何不能如此編寫 worker 執行緒,如範例 20-21 所示:

檔案名: src/lib.rs

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {} got a job; executing.", id);

                job();
            }
        });

        Worker {
            id,
            thread,
        }
    }
}

範例 20-21: 一個使用 while letWorker::new 替代實現

這段代碼可以編譯和運行,但是並不會產生所期望的執行緒行為:一個慢請求仍然會導致其他請求等待執行。其原因有些微妙:Mutex 結構體沒有公有 unlock 方法,因為鎖的所有權依賴 lock 方法返回的 LockResult<MutexGuard<T>>MutexGuard<T> 的生命週期。這允許借用檢查器在編譯時確保絕不會在沒有持有鎖的情況下訪問由 Mutex 守護的資源,不過如果沒有認真的思考 MutexGuard<T> 的生命週期的話,也可能會導致比預期更久的持有鎖。因為 while 表達式中的值在整個塊一直處於作用域中,job() 調用的過程中其仍然持有鎖,這意味著其他 worker 不能接收任務。

相反透過使用 loop 並在循環塊之內而不是之外獲取鎖和任務,lock 方法返回的 MutexGuardlet job 語句結束之後立刻就被丟棄了。這確保了 recv 調用過程中持有鎖,而在 job() 調用前鎖就被釋放了,這就允許並發處理多個請求了。

優雅停機與清理

ch20-03-graceful-shutdown-and-cleanup.md
commit 9a5a1bfaec3b7763e1bcfd31a2fb19fe95183746

範例 20-21 中的代碼如期透過使用執行緒池非同步的響應請求。這裡有一些警告說 workersidthread 欄位沒有直接被使用,這提醒了我們並沒有清理所有的內容。當使用不那麼優雅的 ctrl-c 終止主執行緒時,所有其他執行緒也會立刻停止,即便它們正處於處理請求的過程中。

現在我們要為 ThreadPool 實現 Drop trait 對執行緒池中的每一個執行緒調用 join,這樣這些執行緒將會執行完他們的請求。接著會為 ThreadPool 實現一個告訴執行緒他們應該停止接收新請求並結束的方式。為了實踐這些程式碼,修改 server 在優雅停機(graceful shutdown)之前只接受兩個請求。

ThreadPool 實現 Drop Trait

現在開始為執行緒池實現 Drop。當執行緒池被丟棄時,應該 join 所有執行緒以確保他們完成其操作。範例 20-23 展示了 Drop 實現的第一次嘗試;這些程式碼還不能夠編譯:

檔案名: src/lib.rs

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

範例 20-23: 當執行緒池離開作用域時 join 每個執行緒

這裡首先遍歷執行緒池中的每個 workers。這裡使用了 &mut 因為 self 本身是一個可變引用而且也需要能夠修改 worker。對於每一個執行緒,會列印出說明訊息表明此特定 worker 正在關閉,接著在 worker 執行緒上調用 join。如果 join 調用失敗,通過 unwrap 使得 panic 並進行不優雅的關閉。

如下是嘗試編譯代碼時得到的錯誤:

error[E0507]: cannot move out of borrowed content
  --> src/lib.rs:65:13
   |
65 |             worker.thread.join().unwrap();
   |             ^^^^^^ cannot move out of borrowed content

這告訴我們並不能調用 join,因為只有每一個 worker 的可變借用,而 join 獲取其參數的所有權。為了解決這個問題,需要一個方法將 thread 移動出擁有其所有權的 Worker 實例以便 join 可以消費這個執行緒。範例 17-15 中我們曾見過這麼做的方法:如果 Worker 存放的是 Option<thread::JoinHandle<()>,就可以在 Option 上調用 take 方法將值從 Some 成員中移動出來而對 None 成員不做處理。換句話說,正在運行的 Workerthread 將是 Some 成員值,而當需要清理 worker 時,將 Some 替換為 None,這樣 worker 就沒有可以運行的執行緒了。

為此需要更新 Worker 的定義為如下:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::thread;
struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}
}

現在依靠編譯器來找出其他需要修改的地方。check 代碼會得到兩個錯誤:

error[E0599]: no method named `join` found for type
`std::option::Option<std::thread::JoinHandle<()>>` in the current scope
  --> src/lib.rs:65:27
   |
65 |             worker.thread.join().unwrap();
   |                           ^^^^

error[E0308]: mismatched types
  --> src/lib.rs:89:13
   |
89 |             thread,
   |             ^^^^^^
   |             |
   |             expected enum `std::option::Option`, found struct
   `std::thread::JoinHandle`
   |             help: try using a variant of the expected type: `Some(thread)`
   |
   = note: expected type `std::option::Option<std::thread::JoinHandle<()>>`
              found type `std::thread::JoinHandle<_>`

讓我們修復第二個錯誤,它指向 Worker::new 結尾的代碼;當新建 Worker 時需要將 thread 值封裝進 Some。做出如下改變以修復問題:

檔案名: src/lib.rs

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

第一個錯誤位於 Drop 實現中。之前提到過要調用 Option 上的 takethread 移動出 worker。如下改變會修復問題:

檔案名: src/lib.rs

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

如第十七章我們見過的,Option 上的 take 方法會取出 Some 而留下 None。使用 if let 解構 Some 並得到執行緒,接著在執行緒上調用 join。如果 worker 的執行緒已然是 None,就知道此時這個 worker 已經清理了其執行緒所以無需做任何操作。

向執行緒發送信號使其停止接收任務

有了所有這些修改,代碼就能編譯且沒有任何警告。不過也有壞消息,這些程式碼還不能以我們期望的方式運行。問題的關鍵在於 Worker 中分配的執行緒所運行的閉包中的邏輯:調用 join 並不會關閉執行緒,因為他們一直 loop 來尋找任務。如果採用這個實現來嘗試丟棄 ThreadPool ,則主執行緒會永遠阻塞在等待第一個執行緒結束上。

為了修復這個問題,修改執行緒既監聽是否有 Job 運行也要監聽一個應該停止監聽並退出無限循環的信號。所以通道將發送這個枚舉的兩個成員之一而不是 Job 實例:

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
struct Job;
enum Message {
    NewJob(Job),
    Terminate,
}
}

Message 枚舉要嘛是存放了執行緒需要運行的 JobNewJob 成員,要嘛是會導致執行緒退出循環並終止的 Terminate 成員。

同時需要修改通道來使用 Message 類型值而不是 Job,如範例 20-24 所示:

檔案名: src/lib.rs

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Message>,
}

// --snip--

impl ThreadPool {
    // --snip--

    pub fn execute<F>(&self, f: F)
        where
            F: FnOnce() + Send + 'static
    {
        let job = Box::new(f);

        self.sender.send(Message::NewJob(job)).unwrap();
    }
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Message>>>) ->
        Worker {

        let thread = thread::spawn(move ||{
            loop {
                let message = receiver.lock().unwrap().recv().unwrap();

                match message {
                    Message::NewJob(job) => {
                        println!("Worker {} got a job; executing.", id);

                        job();
                    },
                    Message::Terminate => {
                        println!("Worker {} was told to terminate.", id);

                        break;
                    },
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

範例 20-24: 收發 Message 值並在 Worker 收到 Message::Terminate 時退出循環

為了適用 Message 枚舉需要將兩個地方的 Job 修改為 MessageThreadPool 的定義和 Worker::new 的簽名。ThreadPoolexecute 方法需要發送封裝進 Message::NewJob 成員的任務。然後,在 Worker::new 中當從通道接收 Message 時,當獲取到 NewJob成員會處理任務而收到 Terminate 成員則會退出循環。

通過這些修改,代碼再次能夠編譯並繼續按照範例 20-21 之後相同的行為運行。不過還是會得到一個警告,因為並沒有創建任何 Terminate 成員的消息。如範例 20-25 所示修改 Drop 實現來修復此問題:

檔案名: src/lib.rs

impl Drop for ThreadPool {
    fn drop(&mut self) {
        println!("Sending terminate message to all workers.");

        for _ in &mut self.workers {
            self.sender.send(Message::Terminate).unwrap();
        }

        println!("Shutting down all workers.");

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

範例 20-25:在對每個 worker 執行緒調用 join 之前向 worker 發送 Message::Terminate

現在遍歷了 worker 兩次,一次向每個 worker 發送一個 Terminate 消息,一個調用每個 worker 執行緒上的 join。如果嘗試在同一循環中發送消息並立即 join 執行緒,則無法保證當前疊代的 worker 是從通道收到終止消息的 worker。

為了更好的理解為什麼需要兩個分開的循環,想像一下只有兩個 worker 的場景。如果在一個單獨的循環中遍歷每個 worker,在第一次疊代中向通道發出終止消息並對第一個 worker 執行緒調用 join。如果此時第一個 worker 正忙於處理請求,那麼第二個 worker 會收到終止消息並停止。我們會一直等待第一個 worker 結束,不過它永遠也不會結束因為第二個執行緒接收了終止消息。死鎖!

為了避免此情況,首先在一個循環中向通道發出所有的 Terminate 消息,接著在另一個循環中 join 所有的執行緒。每個 worker 一旦收到終止消息即會停止從通道接收消息,意味著可以確保如果發送同 worker 數相同的終止消息,在 join 之前每個執行緒都會收到一個終止消息。

為了實踐這些程式碼,如範例 20-26 所示修改 main 在優雅停機 server 之前只接受兩個請求:

檔案名: src/bin/main.rs

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

範例 20-26: 在處理兩個請求之後透過退出循環來停止 server

你不會希望真實世界的 web server 只處理兩次請求就停機了,這只是為了展示優雅停機和清理處於正常工作狀態。

take 方法定義於 Iterator trait,這裡限制循環最多頭 2 次。ThreadPool 會在 main 的結尾離開作用域,而且還會看到 drop 實現的運行。

使用 cargo run 啟動 server,並發起三個請求。第三個請求應該會失敗,而終端的輸出應該看起來像這樣:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 1.0 secs
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 3 got a job; executing.
Shutting down.
Sending terminate message to all workers.
Shutting down all workers.
Shutting down worker 0
Worker 1 was told to terminate.
Worker 2 was told to terminate.
Worker 0 was told to terminate.
Worker 3 was told to terminate.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

可能會出現不同順序的 worker 和訊息輸出。可以從訊息中看到服務是如何運行的: worker 0 和 worker 3 獲取了頭兩個請求,接著在第三個請求時,我們停止接收連接。當 ThreadPoolmain 的結尾離開作用域時,其 Drop 實現開始工作,執行緒池通知所有執行緒終止。每個 worker 在收到終止消息時會列印出一個訊息,接著執行緒池調用 join 來終止每一個 worker 執行緒。

這個特定的運行過程中一個有趣的地方在於:注意我們向通道中發出終止消息,而在任何執行緒收到消息之前,就嘗試 join worker 0 了。worker 0 還沒有收到終止消息,所以主執行緒阻塞直到 worker 0 結束。與此同時,每一個執行緒都收到了終止消息。一旦 worker 0 結束,主執行緒就等待其他 worker 結束,此時他們都已經收到終止消息並能夠停止了。

恭喜!現在我們完成了這個項目,也有了一個使用執行緒池非同步響應請求的基礎 web server。我們能對 server 執行優雅停機,它會清理執行緒池中的所有執行緒。

如下是完整的代碼參考:

檔案名: src/bin/main.rs

use hello::ThreadPool;

use std::io::prelude::*;
use std::net::TcpListener;
use std::net::TcpStream;
use std::fs;
use std::thread;
use std::time::Duration;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";
    let sleep = b"GET /sleep HTTP/1.1\r\n";

    let (status_line, filename) = if buffer.starts_with(get) {
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else if buffer.starts_with(sleep) {
        thread::sleep(Duration::from_secs(5));
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();

    let response = format!("{}{}", status_line, contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

檔案名: src/lib.rs


#![allow(unused)]
fn main() {
use std::thread;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;

enum Message {
    NewJob(Job),
    Terminate,
}

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Message>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// 創建執行緒池。
    ///
    /// 執行緒池中執行緒的數量。
    ///
    /// # Panics
    ///
    /// `new` 函數在 size 為 0 時會 panic。
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender,
        }
    }

    pub fn execute<F>(&self, f: F)
        where
            F: FnOnce() + Send + 'static
    {
        let job = Box::new(f);

        self.sender.send(Message::NewJob(job)).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        println!("Sending terminate message to all workers.");

        for _ in &mut self.workers {
            self.sender.send(Message::Terminate).unwrap();
        }

        println!("Shutting down all workers.");

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Message>>>) ->
        Worker {

        let thread = thread::spawn(move ||{
            loop {
                let message = receiver.lock().unwrap().recv().unwrap();

                match message {
                    Message::NewJob(job) => {
                        println!("Worker {} got a job; executing.", id);

                        job();
                    },
                    Message::Terminate => {
                        println!("Worker {} was told to terminate.", id);

                        break;
                    },
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}
}

這裡還有很多可以做的事!如果你希望繼續增強這個項目,如下是一些點子:

  • ThreadPool 和其公有方法增加更多文件
  • 為庫的功能增加測試
  • unwrap 調用改為更健壯的錯誤處理
  • 使用 ThreadPool 進行其他不同於處理網路請求的任務
  • crates.io 上尋找一個執行緒池 crate 並使用它實現一個類似的 web server,將其 API 和強健性與我們的實現做對比

總結

好極了!你結束了本書的學習!由衷感謝你同我們一道加入這次 Rust 之旅。現在你已經準備好出發並實現自己的 Rust 項目並幫助他人了。請不要忘記我們的社區,這裡有其他 Rustaceans 正樂於幫助你迎接 Rust 之路上的任何挑戰。

附錄

appendix-00.md
commit 1fedfc4b96c2017f64ecfcf41a0a07e2e815f24f

附錄部分包含一些在你的 Rust 之旅中可能用到的參考資料。

附錄 A:關鍵字

appendix-01-keywords.md
commit 27dd97a785794709aa87c51ab697cded41e8163a

下面的列表包含 Rust 中正在使用或者以後會用到的關鍵字。因此,這些關鍵字不能被用作標識符(除了 “原始標識符” 部分介紹的原始標識符),這包括函數、變數、參數、結構體欄位、模組、crate、常量、宏、靜態值、屬性、類型、trait 或生命週期 的名字。

目前正在使用的關鍵字

如下關鍵字目前有對應其描述的功能。

  • as - 強制類型轉換,消除特定包含項的 trait 的歧義,或者對 useextern crate 語句中的項重命名
  • break - 立刻退出循環
  • const - 定義常量或不變裸指針(constant raw pointer)
  • continue - 繼續進入下一次循環疊代
  • crate - 連結(link)一個外部 crate 或一個代表宏定義的 crate 的宏變數
  • dyn - 動態分發 trait 對象
  • else - 作為 ifif let 控制流結構的 fallback
  • enum - 定義一個枚舉
  • extern - 連結一個外部 crate 、函數或變數
  • false - 布爾字面值 false
  • fn - 定義一個函數或 函數指針類型 (function pointer type)
  • for - 遍歷一個疊代器或實現一個 trait 或者指定一個更高級的生命週期
  • if - 基於條件表達式的結果分支
  • impl - 實現自有或 trait 功能
  • in - for 循環語法的一部分
  • let - 綁定一個變數
  • loop - 無條件循環
  • match - 模式匹配
  • mod - 定義一個模組
  • move - 使閉包獲取其所捕獲項的所有權
  • mut - 表示引用、裸指針或模式綁定的可變性
  • pub - 表示結構體欄位、impl 塊或模組的公有可見性
  • ref - 透過引用綁定
  • return - 從函數中返回
  • Self - 實現 trait 的類型的類型別名
  • self - 表示方法本身或當前模組
  • static - 表示全局變數或在整個程序執行期間保持其生命週期
  • struct - 定義一個結構體
  • super - 表示當前模組的父模組
  • trait - 定義一個 trait
  • true - 布爾字面值 true
  • type - 定義一個類型別名或關聯類型
  • unsafe - 表示不安全的代碼、函數、trait 或實現
  • use - 引入外部空間的符號
  • where - 表示一個約束類型的從句
  • while - 基於一個表達式的結果判斷是否進行循環

保留做將來使用的關鍵字

如下關鍵字沒有任何功能,不過由 Rust 保留以備將來的應用。

  • abstract
  • async
  • await
  • become
  • box
  • do
  • final
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

原始標識符

原始標識符(Raw identifiers)允許你使用通常不能使用的關鍵字,其帶有 r# 前綴。

例如,match 是關鍵字。如果嘗試編譯如下使用 match 作為名字的函數:

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

會得到這個錯誤:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

該錯誤表示你不能將關鍵字 match 用作函數標識符。你可以使用原始標識符將 match 作為函數名稱使用:

檔案名: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

此代碼編譯沒有任何錯誤。注意 r# 前綴需同時用於函數名定義和 main 函數中的調用。

原始標識符允許使用你選擇的任何單詞作為標識符,即使該單詞恰好是保留關鍵字。 此外,原始標識符允許你使用以不同於你的 crate 使用的 Rust 版本編寫的庫。比如,try 在 2015 edition 中不是關鍵字,而在 2018 edition 則是。所以如果如果用 2015 edition 編寫的庫中帶有 try 函數,在 2018 edition 中調用時就需要使用原始標識符語法,在這裡是 r#try。有關版本的更多訊息,請參見附錄 E.

附录 B:运算符与符号

appendix-02-operators.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

该附录包含了 Rust 语法的词汇表,包括运算符以及其他的符号,这些符号单独出现或出现在路径、泛型、trait bounds、宏、属性、注释、元组以及大括号上下文中。

运算符

表 B-1 包含了 Rust 中的运算符、运算符如何出现在上下文中的示例、简短解释以及该运算符是否可重载。如果一个运算符是可重载的,则该运算符上用于重载的相关 trait 也会列出。

表 B-1: 运算符

运算符示例解释是否可重载
!ident!(...), ident!{...}, ident![...]宏展开
!!expr按位非或逻辑非Not
!=var != expr不等比较PartialEq
%expr % expr算术取模Rem
%=var %= expr算术取模与赋值RemAssign
&&expr, &mut expr借用
&&type, &mut type, &'a type, &'a mut type借用指针类型
&expr & expr按位与BitAnd
&=var &= expr按位与及赋值BitAndAssign
&&expr && expr逻辑与
*expr * expr算术乘法Mul
*=var *= expr算术乘法与赋值MulAssign
**expr解引用
**const type, *mut type裸指针
+trait + trait, 'a + trait复合类型限制
+expr + expr算术加法Add
+=var += expr算术加法与赋值AddAssign
,expr, expr参数以及元素分隔符
-- expr算术取负Neg
-expr - expr算术减法Sub
-=var -= expr算术减法与赋值SubAssign
->fn(...) -> type, |...| -> type函数与闭包,返回类型
.expr.ident成员访问
...., expr.., ..expr, expr..expr右排除范围
....expr结构体更新语法
..variant(x, ..), struct_type { x, .. }“与剩余部分”的模式绑定
...expr...expr模式: 范围包含模式
/expr / expr算术除法Div
/=var /= expr算术除法与赋值DivAssign
:pat: type, ident: type约束
:ident: expr结构体字段初始化
:'a: loop {...}循环标志
;expr;语句和语句结束符
;[...; len]固定大小数组语法的部分
<<expr << expr左移Shl
<<=var <<= expr左移与赋值ShlAssign
<expr < expr小于比较PartialOrd
<=expr <= expr小于等于比较PartialOrd
=var = expr, ident = type赋值/等值
==expr == expr等于比较PartialEq
=>pat => expr匹配准备语法的部分
>expr > expr大于比较PartialOrd
>=expr >= expr大于等于比较PartialOrd
>>expr >> expr右移Shr
>>=var >>= expr右移与赋值ShrAssign
@ident @ pat模式绑定
^expr ^ expr按位异或BitXor
^=var ^= expr按位异或与赋值BitXorAssign
|pat | pat模式选择
|expr | expr按位或BitOr
|=var |= expr按位或与赋值BitOrAssign
||expr || expr逻辑或
?expr?错误传播

非运算符符号

下面的列表中包含了所有和运算符不一样功能的非字符符号;也就是说,他们并不像函数调用或方法调用一样表现。

表 B-2 展示了以其自身出现以及出现在合法其他各个地方的符号。

表 B-2:独立语法

符号解释
'ident命名生命周期或循环标签
...u8, ...i32, ...f64, ...usize, 等指定类型的数值常量
"..."字符串常量
r"...", r#"..."#, r##"..."##, etc.原始字符串字面值, 未处理的转义字符
b"..."字节字符串字面值; 构造一个 [u8] 类型而非字符串
br"...", br#"..."#, br##"..."##, 等原始字节字符串字面值,原始和字节字符串字面值的结合
'...'字符字面值
b'...'ASCII 码字节字面值
|...| expr闭包
!离散函数的总是为空的类型
_“忽略” 模式绑定;也用于增强整型字面值的可读性

表 B-3 展示了出现在从模块结构到项的路径上下文中的符号

表 B-3:路径相关语法

符号解释
ident::ident命名空间路径
::path与 crate 根相对的路径(如一个显式绝对路径)
self::path与当前模块相对的路径(如一个显式相对路径)
super::path与父模块相对的路径
type::ident, <type as trait>::ident关联常量、函数以及类型
<type>::...不可以被直接命名的关联项类型(如 <&T>::...<[T]>::..., 等)
trait::method(...)通过命名定义的 trait 来消除方法调用的二义性
type::method(...)通过命名定义的类型来消除方法调用的二义性
<type as trait>::method(...)通过命名 trait 和类型来消除方法调用的二义性

表 B-4 展示了出现在泛型类型参数上下文中的符号。

表 B-4:泛型

符号解释
path<...>为一个类型中的泛型指定具体参数(如 Vec<u8>
path::<...>, method::<...>为一个泛型、函数或表达式中的方法指定具体参数,通常指 turbofish(如 "42".parse::<i32>()
fn ident<...> ...泛型函数定义
struct ident<...> ...泛型结构体定义
enum ident<...> ...泛型枚举定义
impl<...> ...定义泛型实现
for<...> type高级生命周期限制
type<ident=type>泛型,其一个或多个相关类型必须被指定为特定类型(如 Iterator<Item=T>

表 B-5 展示了出现在使用 trait bounds 约束泛型参数上下文中的符号。

表 B-5: Trait Bound 约束

符号解释
T: U泛型参数 T 约束于实现了 U 的类型
T: 'a泛型 T 的生命周期必须长于 'a(意味着该类型不能传递包含生命周期短于 'a 的任何引用)
T : 'static泛型 T 不包含除 'static 之外的借用引用
'b: 'a泛型 'b 生命周期必须长于泛型 'a
T: ?Sized使用一个不定大小的泛型类型
'a + trait, trait + trait复合类型限制

表 B-6 展示了在调用或定义宏以及在其上指定属性时的上下文中出现的符号。

表 B-6: 宏与属性

符号解释
#[meta]外部属性
#![meta]内部属性
$ident宏替换
$ident:kind宏捕获
$(…)…宏重复

表 B-7 展示了写注释的符号。

表 B-7: 注释

符号注释
//行注释
//!内部行文档注释
///外部行文档注释
/*...*/块注释
/*!...*/内部块文档注释
/**...*/外部块文档注释

表 B-8 展示了出现在使用元组时上下文中的符号。

表 B-8: 元组

符号解释
()空元组(亦称单元),即是字面值也是类型
(expr)括号表达式
(expr,)单一元素元组表达式
(type,)单一元素元组类型
(expr, ...)元组表达式
(type, ...)元组类型
expr(expr, ...)函数调用表达式;也用于初始化元组结构体 struct 以及元组枚举 enum 变体
ident!(...), ident!{...}, ident![...]宏调用
expr.0, expr.1, etc.元组索引

表 B-9 展示了使用大括号的上下文。

表 B-9: 大括号

符号解释
{...}块表达式
Type {...}struct 字面值

表 B-10 展示了使用方括号的上下文。

表 B-10: 方括号

符号解释
[...]数组
[expr; len]复制了 lenexpr的数组
[type; len]包含 lentype 类型的数组
expr[expr]集合索引。 重载(Index, IndexMut
expr[..], expr[a..], expr[..b], expr[a..b]集合索引,使用 RangeRangeFromRangeToRangeFull 作为索引来代替集合 slice

附錄 C:可派生的 trait

appendix-03-derivable-traits.md
commit 426f3e4ec17e539ae9905ba559411169d303a031

在本書的各個部分中,我們討論了可應用於結構體和枚舉定義的 derive 屬性。derive 屬性會在使用 derive 語法標記的類型上生成對應 trait 的默認實現的代碼。

在本附錄中提供了標準庫中所有可以使用 derive 的 trait 的參考。這些部分涉及到:

  • 該 trait 將會派生什麼樣的操作符和方法
  • derive 提供什麼樣的 trait 實現
  • 由什麼來實現類型的 trait
  • 是否允許實現該 trait 的條件
  • 需要 trait 操作的例子

如果你希望不同於 derive 屬性所提供的行為,請查閱 標準庫文件 中每個 trait 的細節以了解如何手動實現它們。

標準庫中定義的其它 trait 不能通過 derive 在類型上實現。這些 trait 不存在有意義的默認行為,所以由你負責以合理的方式實現它們。

一個無法被派生的 trait 的例子是為終端用戶處理格式化的 Display 。你應該時常考慮使用合適的方法來為終端用戶顯示一個類型。終端用戶應該看到類型的什麼部分?他們會找出相關部分嗎?對他們來說最相關的數據格式是什麼樣的?Rust 編譯器沒有這樣的洞察力,因此無法為你提供合適的默認行為。

本附錄所提供的可派生 trait 列表並不全面:庫可以為其自己的 trait 實現 derive,可以使用 derive 的 trait 列表事實上是無限的。實現 derive 涉及到過程宏的應用,這在第十九章的 “宏” 有介紹。

用於程式設計師輸出的 Debug

Debug trait 用於開啟格式化字串中的除錯格式,其通過在 {} 占位符中增加 :? 表明。

Debug trait 允許以除錯目的來列印一個類型的實例,所以使用該類型的程式設計師可以在程序執行的特定時間點觀察其實例。

例如,在使用 assert_eq! 宏時,Debug trait 是必須的。如果等式斷言失敗,這個宏就把給定實例的值作為參數列印出來,如此程式設計師可以看到兩個實例為什麼不相等。

等值比較的 PartialEqEq

PartialEq trait 可以比較一個類型的實例以檢查是否相等,並開啟了 ==!= 運算符的功能。

派生的 PartialEq 實現了 eq 方法。當 PartialEq 在結構體上派生時,只有所有 的欄位都相等時兩個實例才相等,同時只要有任何欄位不相等則兩個實例就不相等。當在枚舉上派生時,每一個成員都和其自身相等,且和其他成員都不相等。

例如,當使用 assert_eq! 宏時,需要比較比較一個類型的兩個實例是否相等,則 PartialEq trait 是必須的。

Eq trait 沒有方法。其作用是表明每一個被標記類型的值等於其自身。Eq trait 只能應用於那些實現了 PartialEq 的類型,但並非所有實現了 PartialEq 的類型都可以實現 Eq。浮點類型就是一個例子:浮點數的實現表明兩個非數位(NaN,not-a-number)值是互不相等的。

例如,對於一個 HashMap<K, V> 中的 key 來說, Eq 是必須的,這樣 HashMap<K, V> 就可以知道兩個 key 是否一樣了。

次序比較的 PartialOrdOrd

PartialOrd trait 可以基於排序的目的而比較一個類型的實例。實現了 PartialOrd 的類型可以使用 <><=>= 操作符。但只能在同時實現了 PartialEq 的類型上使用 PartialOrd

派生 PartialOrd 實現了 partial_cmp 方法,其返回一個 Option<Ordering> ,但當給定值無法產生順序時將返回 None。儘管大多數類型的值都可以比較,但一個無法產生順序的例子是:浮點類型的非數字值。當在浮點數上調用 partial_cmp 時,NaN 的浮點數將返回 None

當在結構體上派生時,PartialOrd 以在結構體定義中欄位出現的順序比較每個欄位的值來比較兩個實例。當在枚舉上派生時,認為在枚舉定義中聲明較早的枚舉變體小於其後的變體。

例如,對於來自於 rand crate 中的 gen_range 方法來說,當在一個大值和小值指定的範圍內生成一個隨機值時,PartialOrd trait 是必須的。

Ord trait 也讓你明白在一個帶註解類型上的任意兩個值存在有效順序。Ord trait 實現了 cmp 方法,它返回一個 Ordering 而不是 Option<Ordering>,因為總存在一個合法的順序。只可以在實現了 PartialOrdEqEq 依賴 PartialEq)的類型上使用 Ord trait 。當在結構體或枚舉上派生時, cmp 和以 PartialOrd 派生實現的 partial_cmp 表現一致。

例如,當在 BTreeSet<T>(一種基於有序值存儲數據的數據結構)上存值時,Ord 是必須的。

複製值的 CloneCopy

Clone trait 可以明確地創建一個值的深拷貝(deep copy),複製過程可能包含任意代碼的執行以及堆上數據的複製。查閱第四章 “變數和數據的交互方式:移動” 以獲取有關 Clone 的更多訊息。

派生 Clone 實現了 clone 方法,其為整個的類型實現時,在類型的每一部分上調用了 clone 方法。這意味著類型中所有欄位或值也必須實現了 Clone,這樣才能夠派生 Clone

例如,當在一個切片(slice)上調用 to_vec 方法時,Clone 是必須的。切片並不擁有其所包含實例的類型,但是從 to_vec 中返回的 vector 需要擁有其實例,因此,to_vec 在每個元素上調用 clone。因此,存儲在切片中的類型必須實現 Clone

Copy trait 允許你透過只拷貝存儲在棧上的位來複製值而不需要額外的代碼。查閱第四章 “只在棧上的數據:拷貝” 的部分來獲取有關 Copy 的更多訊息。

Copy trait 並未定義任何方法來阻止編程人員重寫這些方法或違反不需要執行額外代碼的假設。儘管如此,所有的程式人員可以假設複製(copy)一個值非常快。

可以在類型內部全部實現 Copy trait 的任意類型上派生 Copy。 但只可以在那些同時實現了 Clone 的類型上使用 Copy trait ,因為一個實現了 Copy 的類型也簡單地實現了 Clone,其執行和 Copy 相同的任務。

Copy trait 很少使用;實現 Copy 的類型是可以最佳化的,這意味著你無需調用 clone,這讓代碼更簡潔。

任何使用 Copy 的代碼都可以通過 Clone 實現,但代碼可能會稍慢,或者不得不在代碼中的許多位置上使用 clone

固定大小的值到值映射的 Hash

Hash trait 可以實例化一個任意大小的類型,並且能夠用哈希(hash)函數將該實例映射到一個固定大小的值上。派生 Hash 實現了 hash 方法。hash 方法的派生實現結合了在類型的每部分調用 hash 的結果,這意味著所有的欄位或值也必須實現了 Hash,這樣才能夠派生 Hash

例如,在 HashMap<K, V> 上存儲數據,存放 key 的時候,Hash 是必須的。

預設值的 Default

Default trait 使你創建一個類型的預設值。 派生 Default 實現了 default 函數。default 函數的派生實現調用了類型每部分的 default 函數,這意味著類型中所有的欄位或值也必須實現了 Default,這樣才能夠派生 Default

Default::default 函數通常結合結構體更新語法一起使用,這在第五章的 “使用結構體更新語法從其他實例中創建實例” 部分有討論。可以自訂一個結構體的一小部分欄位而剩餘欄位則使用 ..Default::default() 設置為預設值。

例如,當你在 Option<T> 實例上使用 unwrap_or_default 方法時,Default trait是必須的。如果 Option<T>None的話, unwrap_or_default 方法將返回存儲在 Option<T>T 類型的 Default::default 的結果。

附錄 D:實用開發工具

appendix-04-useful-development-tools.md
commit 70a82519e48b8a61f98cabb8ff443d1b21962fea

本附錄,我們將討論 Rust 項目提供的用於開發 Rust 代碼的工具。

通過 rustfmt 自動格式化

rustfmt 工具根據社區代碼風格格式化程式碼。很多項目使用 rustfmt 來避免編寫 Rust 風格的爭論:所有人都用這個工具格式化程式碼!

安裝 rustfmt

$ rustup component add rustfmt

這會提供 rustfmtcargo-fmt,類似於 Rust 同時安裝 rustccargo。為了格式化整個 Cargo 項目:

$ cargo fmt

運行此命令會格式化當前 crate 中所有的 Rust 代碼。這應該只會改變代碼風格,而不是代碼語義。請查看 該文件 了解 rustfmt 的更多訊息。

通過 rustfix 修復代碼

如果你編寫過 Rust 代碼,那麼你可能見過編譯器警告。例如,考慮如下代碼:

檔案名: src/main.rs

fn do_something() {}

fn main() {
    for i in 0..100 {
        do_something();
    }
}

這裡調用了 do_something 函數 100 次,不過從未在 for 循環體中使用變數 i。Rust 會警告說:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: unused variable: `i`
 --> src/main.rs:4:9
  |
4 |     for i in 1..100 {
  |         ^ help: consider using `_i` instead
  |
  = note: #[warn(unused_variables)] on by default

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

警告中建議使用 _i 名稱:下劃線表明該變數有意不使用。我們可以通過 cargo fix 命令使用 rustfix 工具來自動採用該建議:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

如果再次查看 src/main.rs,會發現 cargo fix 修改了代碼:

檔案名: src/main.rs

fn do_something() {}

fn main() {
    for _i in 0..100 {
        do_something();
    }
}

現在 for 循環變數變為 _i,警告也不再出現。

cargo fix 命令可以用於在不同 Rust 版本間遷移代碼。版本在附錄 E 中介紹。

通過 clippy 提供更多 lint 功能

clippy 工具是一系列 lint 的集合,用於捕捉常見錯誤和改進 Rust 代碼。

安裝 clippy

$ rustup component add clippy

對任何 Cargo 項目運行 clippy 的 lint:

$ cargo clippy

例如,如果程序使用了如 pi 這樣數學常數的近似值,如下:

檔案名: src/main.rs

fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

在此項目上運行 cargo clippy 會導致這個錯誤:

error: approximate value of `f{32, 64}::consts::PI` found. Consider using it directly
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: #[deny(clippy::approx_constant)] on by default
  = help: for further information visit https://rust-lang-nursery.github.io/rust-clippy/master/index.html#approx_constant

這告訴我們 Rust 定義了更為精確的常量,而如果使用了這些常量程序將更加準確。如下代碼就不會導致 clippy 產生任何錯誤或警告:

檔案名: src/main.rs

fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

請查看 其文件 來了解 clippy 的更多訊息。

使用 Rust Language Server 的 IDE 集成

為了幫助 IDE 集成,Rust 項目分發了 rls,其為 Rust Language Server 的縮寫。這個工具採用 Language Server Protocol,這是一個 IDE 與程式語言溝通的規格說明。rls 可以用於不同的用戶端,比如 Visual Studio: Code 的 Rust 插件

rls 工具的質量還未達到發布 1.0 版本的水準,不過目前有一個可用的預覽版。請嘗試使用並告訴我們它如何!

安裝 rls

$ rustup component add rls

接著為特定的 IDE 安裝 language server 支持,如此便會獲得如自動補全、跳轉到定義和 inline error 之類的功能。

請查看 其文件 來了解 rls 的更多訊息。

附錄 E:版本

appendix-05-editions.md
commit 70a82519e48b8a61f98cabb8ff443d1b21962fea

早在第一章,我們見過 cargo newCargo.toml 中增加了一些有關 edition 的元數據。本附錄將解釋其意義!

Rust 語言和編譯器有一個為期 6 周的發布循環。這意味著用戶會穩定得到新功能的更新。其他程式語言發布大更新但不甚頻繁;Rust 選擇更為頻繁的發布小更新。一段時間之後,所有這些小更新會日積月累。不過隨著小更新逐次的發布,或許很難回過頭來感嘆:“哇,從 Rust 1.10 到 Rust 1.31,Rust 的變化真大!”

每兩到三年,Rust 團隊會生成一個新的 Rust 版本edition)。每一個版本會結合已經落地的功能,並提供一個清晰的帶有完整更新文件和工具的功能包。新版本會作為常規的 6 周發布過程的一部分發布。

這為不同的人群提供了不同的功能:

  • 對於活躍的 Rust 用戶,其將增量的修改與易於理解的功能包相結合。
  • 對於非用戶,它表明發布了一些重大進展,這意味著 Rust 可能變得值得一試。
  • 對於 Rust 自身開發者,其提供了項目整體的集合點。

在本文件編寫時,Rust 有兩個版本:Rust 2015 和 Rust 2018。本書基於 Rust 2018 edition 編寫。

Cargo.toml 中的 edition 欄位表明代碼應該使用哪個版本編譯。如果該欄位不存在,其預設為 2015 以提供後向相容性。

每個項目都可以選擇不同於默認的 2015 edition 的版本。這樣,版本可能會包含不相容的修改,比如新增關鍵字可能會與代碼中的標識符衝突並導致錯誤。不過除非選擇相容這些修改,(舊)代碼仍將能夠編譯,即便升級了 Rust 編譯器的版本。

所有 Rust 編譯器都支持任何之前存在的編譯器版本,並可以連結任何支持版本的 crate。編譯器修改只影響最初的解析代碼的過程。因此,如果你使用 Rust 2015 而某個依賴使用 Rust 2018,你的項目仍舊能夠編譯並使用該依賴。反之,若項目使用 Rust 2018 而依賴使用 Rust 2015 亦可工作。

有一點需要明確:大部分功能在所有版本中都能使用。開發者使用任何 Rust 版本將能繼續接收最新穩定版的改進。然而在一些情況,主要是增加了新關鍵字的時候,則可能出現了只能用於新版本的功能。只需切換版本即可利用新版本的功能。

請查看 Edition Guide 了解更多細節,這是一個完全介紹版本的書籍,包括如何通過 cargo fix 自動將代碼遷移到新版本。

附錄 F:本書譯本

appendix-06-translation.md
commit 72900e05f04ae60e06c2665567771bdd8befa89c

一些非英語語言的資源。多數仍在翻譯中;查閱 翻譯標籤 來幫助我們或使我們知道新的翻譯!

附錄 G:Rust 是如何開發的與 “Nightly Rust”

appendix-07-nightly-rust.md
commit 70a82519e48b8a61f98cabb8ff443d1b21962fea

本附錄介紹 Rust 是如何開發的以及這如何影響作為 Rust 開發者的你。

無停滯穩定

作為一個語言,Rust 十分 注重代碼的穩定性。我們希望 Rust 成為你代碼堅實的基礎,假如持續地有東西在變,這個希望就實現不了。但與此同時,如果不能實驗新功能的話,在發布之前我們又無法發現其中重大的缺陷,而一旦發布便再也沒有修改的機會了。

對於這個問題我們的解決方案被稱為 “無停滯穩定”(“stability without stagnation”),其指導性原則是:無需擔心升級到最新的穩定版 Rust。每次升級應該是無痛的,並應帶來新功能,更少的 bug 和更快的編譯速度。

Choo, Choo! (開車啦,逃) 發布通道和發布時刻表(Riding the Trains)

Rust 開發運行於一個 車次表 發布時刻表train schedule)之上。也就是說,所有的開發工作都位於 Rust 倉庫的 master 分支。發布採用 software release train 模型,其被用於思科 IOS 等其它軟體項目。Rust 有三個 發布通道release channel):

  • Nightly
  • Beta
  • Stable(穩定版)

大部分 Rust 開發者主要採用穩定版通道,不過希望實驗新功能的開發者可能會使用 nightly 或 beta 版。

如下是一個開發和發布過程如何運轉的例子:假設 Rust 團隊正在進行 Rust 1.5 的發布工作。該版本發布於 2015 年 12 月,不過這裡只是為了提供一個真實的版本。Rust 新增了一項功能:一個 master 分支的新提交。每天晚上,會產生一個新的 nightly 版本。每天都是發布版本的日子,而這些發布由發布基礎設施自動完成。所以隨著時間推移,發布軌跡看起來像這樣,版本一天一發:

nightly: * - - * - - *

每 6 周時間,是準備發布新版本的時候了!Rust 倉庫的 beta 分支會從用於 nightly 的 master 分支產生。現在,有了兩個發布版本:

nightly: * - - * - - *
                     |
beta:                *

大部分 Rust 用戶不會主要使用 beta 版本,不過在 CI 系統中對 beta 版本進行測試能夠幫助 Rust 發現可能的回歸缺陷(regression)。同時,每天仍產生 nightly 發布:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

比如我們發現了一個回歸缺陷。好消息是在這些缺陷流入穩定發布之前還有一些時間來測試 beta 版本!fix 被合併到 master,為此 nightly 版本得到了修復,接著這些 fix 將 backport 到 beta 分支,一個新的 beta 發布就產生了:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

第一個 beta 版的 6 周後,是發布穩定版的時候了!stable 分支從 beta 分支生成:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

好的!Rust 1.5 發布了!然而,我們忘了些東西:因為又過了 6 周,我們還需發布 新版 Rust 的 beta 版,Rust 1.6。所以從 beta 生成 stable 分支後,新版的 beta 分支也再次從 nightly 生成:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

這被稱為 “train model”,因為每 6 周,一個版本 “離開車站”(“leaves the station”),不過從 beta 通道到達穩定通道還有一段旅程。

Rust 每 6 周發布一個版本,如時鐘般準確。如果你知道了某個 Rust 版本的發布時間,就可以知道下個版本的時間:6 周後。每 6 周發布版本的一個好的方面是下一班車會來得更快。如果特定版本碰巧缺失某個功能也無需擔心:另一個版本很快就會到來!這有助於減少因臨近發版時間而偷偷釋出未經完善的功能的壓力。

多虧了這個過程,你總是可以切換到下一版本的 Rust 並驗證是否可以輕易的升級:如果 beta 版不能如期工作,你可以向 Rust 團隊報告並在發布穩定版之前得到修復!beta 版造成的破壞是非常少見的,不過 rustc 也不過是一個軟體,可能會存在 bug。

不穩定功能

這個發布模型中另一個值得注意的地方:不穩定功能(unstable features)。Rust 使用一個被稱為 “功能標記”(“feature flags”)的技術來確定給定版本的某個功能是否啟用。如果新功能正在積極地開發中,其提交到了 master,因此會出現在 nightly 版中,不過會位於一個 功能標記 之後。作為用戶,如果你希望嘗試這個正在開發的功能,則可以在原始碼中使用合適的標記來開啟,不過必須使用 nightly 版。

如果使用的是 beta 或穩定版 Rust,則不能使用任何功能標記。這是在新功能被宣布為永久穩定之前獲得實用價值的關鍵。這既滿足了希望使用最尖端技術的同學,那些堅持穩定版的同學也知道其代碼不會被破壞。這就是無停滯穩定。

本書只包含穩定的功能,因為還在開發中的功能仍可能改變,當其進入穩定版時肯定會與編寫本書的時候有所不同。你可以在網路上獲取 nightly 版的文件。

Rustup 和 Rust Nightly 的職責

Rustup 使得改變不同發布通道的 Rust 更為簡單,其在全局或分項目的層次工作。其預設會安裝穩定版 Rust。例如為了安裝 nightly:

$ rustup install nightly

你會發現 rustup 也安裝了所有的 工具鏈toolchains, Rust 和其相關組件)。如下是一位作者的 Windows 計算機上的例子:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

如你所見,預設是穩定版。大部分 Rust 用戶在大部分時間使用穩定版。你可能也會這麼做,不過如果你關心最新的功能,可以為特定項目使用 nightly 版。為此,可以在項目目錄使用 rustup override 來設置當前目錄 rustup 使用 nightly 工具鏈:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

現在,每次在 ~/projects/needs-nightly 調用 rustccargorustup 會確保使用 nightly 版 Rust。在你有很多 Rust 項目時大有裨益!

RFC 過程和團隊

那麼你如何了解這些新功能呢?Rust 開發模式遵循一個 Request For Comments (RFC) 過程。如果你希望改進 Rust,可以編寫一個提議,也就是 RFC。

任何人都可以編寫 RFC 來改進 Rust,同時這些 RFC 會被 Rust 團隊評審和討論,他們由很多不同分工的子團隊組成。這裡是 Rust 官網上 所有團隊的總列表,其包含了項目中每個領域的團隊:語言設計、編譯器實現、基礎設施、文件等。各個團隊會閱讀相應的提議和評論,編寫回復,並最終達成接受或回絕功能的一致。

如果功能被接受了,在 Rust 倉庫會打開一個 issue,人們就可以實現它。實現功能的人當然可能不是最初提議功能的人!當實現完成後,其會合併到 master 分支並位於一個功能開關(feature gate)之後,正如 “不穩定功能” 部分所討論的。

在稍後的某個時間,一旦使用 nightly 版的 Rust 團隊能夠嘗試這個功能了,團隊成員會討論這個功能,它如何在 nightly 中工作,並決定是否應該進入穩定版。如果決定繼續推進,功能開關會移除,然後這個功能就被認為是穩定的了!乘著“發布的列車”,最終在新的穩定版 Rust 中出現。