Rust——猜謎遊戲【二】

。思索發表於2024-06-26

前言

讓我們一起動手完成一個專案,來快速上手 Rust!本章將介紹 Rust 中一些常用概念,並向您展示如何在實際專案中運用它們。您將會學到 let、match、方法、關聯函式、引用外部 crate 等知識!後續章節會深入探討這些概念的細節。

內容

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

建立專案

首先我們使用cargo在我們學習的專案下,建立一個新的專案guessing_game,命令如下:

$ cargo new guessing_game
$ cd guessing_game

目錄結構如下:

.
├── Cargo.toml
└── src
    └── main.rs

Cargo.toml的內容:

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

[dependencies]

main.rs的內容:

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

現在讓我們一起來使用cargo run來執行這個程式:

$ cargo run   
	Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.65s
     Running `target/debug/guessing_game`
Hello, world!

編寫猜謎遊戲

猜數字程式的第一部分請求使用者輸入,處理該輸入,並檢查輸入是否符合預期格式。首先,我們將允許玩家輸入猜測。

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);
}

現在讓我們一起來執行下這個程式,看看是否可以跑起來;

$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
45
You guessed: 45

我們已經成功了處理並執行了第一部分的工作,讓使用者輸入,並將使用者的輸入列印到終端,接下來,我們來逐步分析上面的程式碼。

引入io庫到當前作用域,標準庫在rust中被稱為std:

預設情況下,Rust會將少量標準庫中定義的程式項(item)引入到每個程式的作用域中。這些項稱作 prelude,可以在標準庫文件中瞭解到關於它的所有知識。

如果需要的型別不在 prelude 中,您必須使用 use 語句顯式地將其引入作用域。std::io 庫提供很多有用的功能,包括接收使用者輸入的功能。

use std::io;

main函式

main函式是程式的入口點,使用fn宣告瞭一個新的函式,這個函式沒有接收額外的引數;

fn main() {}

println!

println! 是一個在螢幕上列印字串的宏,列印相應的引導,讓使用者知道這是一個猜數字的遊戲並需要輸入自己猜的數字。

 println!("Guess the number!");
 println!("Please input your guess.");

變數

建立一個變數用來儲存使用者的輸入,變數預設是不可變的,想要讓變數可變,可以在變數名前新增 mut(mutability,可變性)

let mut guess = String::new();

現在我們知道了 let mut guess 會引入一個叫做 guess 的可變變數。等號(=)告訴 Rust 現在想將某個值繫結在變數上。等號的右邊是 guess 所繫結的值,它是 String::new 的結果,這個函式會返回一個 String 的新例項。String 是標準庫提供的字串型別,是一個 UTF-8 編碼的可增長文字。

::new 那一行的 :: 語法表明 newString 型別的一個關聯函式。關聯函式associated function)是實現一種特定型別的函式,在這個例子中型別是 String。這個 new 函式建立了一個新的空字串。您會在很多型別上找到一個 new 函式,因為它是建立型別例項的慣用函式名。

總的來說,let mut guess = String::new(); 這一行建立了一個可變變數,並繫結到一個新的 String 空例項上。

接收使用者輸入

我們在程式的第一行使用 use std::io; 從標準庫中引入了輸入/輸出功能。現在我們可以從 io 模組呼叫 stdin 函式,這將允許我們處理使用者輸入:

   io::stdin()
        .read_line(&mut guess)

如果程式的開頭沒有使用 use std::io 引入 io 庫,我們仍可以透過 std::io::stdin 來呼叫函式。stdin 函式返回一個 std::io::Stdin 的例項,這是一個型別,代表終端標準輸入的控制代碼。

接下來,.read_line(&mut guess) 這一行呼叫了read_line 方法,來從標準輸入控制代碼中獲取使用者輸入。我們還將 &mut guess 作為引數傳遞給 read_line(),以告訴它在哪個字串儲存使用者輸入。read_line 的全部工作是,將使用者在標準輸入中輸入的任何內容都追加到一個字串中(而不會覆蓋其內容),所以它需要字串作為引數。這個字串應是可變的,以便該方法可以更改其內容。

& 表示這個引數是一個引用reference),這為您提供了一種方法,讓程式碼的多個部分可以訪問同一處資料,而無需在記憶體中多次複製。引用是一個複雜的特性,Rust 的一個主要優勢就是安全而簡單的使用引用。完成當前程式並不需要了解太多細節。現在,我們只需知道就像變數一樣,引用預設是不可變的。因此,需要寫成 &mut guess 來使其可變,而不是 &guess

使用Result型別處理潛在的錯誤

我們仍在研究這行程式碼。我們現在正在討論第三行文字,但請注意,它仍然是單個邏輯程式碼行的一部分。下一部分是這個方法:

        .expect("Failed to read line");

我們可以將這段程式碼編寫為:

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

但是,一行過長的程式碼很難閱讀,所以最好拆開來寫。當您使用 .method_name() 語法呼叫方法時,用換行和空格來拆分長程式碼行通常是明智的。現在讓我們來看看這行程式碼幹了什麼。

如前所述, read_line 將使用者輸入的任何內容放入我們傳遞給它的字串中,但它也返回一個 Result 值。 Result 是一個列舉(enumeration),通常稱為列舉(enum),列舉型別持有固定集合的值,這些值被稱為列舉的成員(variant)。

這些 Result 型別的用途是對錯誤處理資訊進行編碼,Result 的成員是 OkErrOk 表示操作成功, Ok內部包含成功生成的值。 Err 表示操作失敗, Err包含有關操作失敗的方式或原因的資訊。

Result 型別的值,就像任何型別的值一樣,都有為其定義的方法。io::Result 的例項擁有 expect 方法。如果 io::Result 例項的值是 Errexpect 會導致程式崩潰,並顯示傳遞給 expect 的引數。如果 read_line 方法返回 Err,則可能是作業系統底層引起的錯誤結果。如果 io::Result 例項的值是 Okexpect 會獲取 Ok 中的值並原樣返回,以便您可以使用它。在本例中,這個值是使用者輸入的位元組數。

如果不呼叫 expect,程式也能編譯,但會出現警告提示:

Rust 警告您尚未使用 返回 read_lineResult 值,表明程式尚未處理可能的錯誤。

$ cargo run  
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
error: expected `;`, found `println`
  --> src/main.rs:9:38
   |
9  |     io::stdin().read_line(&mut guess)
   |                                      ^ help: add `;` here
10 |
11 |     println!("You guessed: {}", guess);
   |     ------- unexpected token

warning: unused import: `std::io`
 --> src/main.rs:1:5
  |
1 | use std::io;
  |     ^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `guessing_game` (bin "guessing_game") generated 1 warning
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error; 1 warning emitted

​ 消除警告的正確做法是實際編寫錯誤處理程式碼,但在這個例子中,我們只希望程式在出現問題時立即崩潰,因此我們可以直接使用 expect

列印值

很好,現在我們終於來到了最後一行程式碼,呼呼!

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

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

let x = 5;
let y = 10;

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

此程式碼將列印 x = 5 and y + 2 = 12

生成謎底數字

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

使用 crate 來增加更多功能

記住,crate 是一個 Rust 程式碼包。我們正在構建的專案是一個 二進位制 crate,它生成一個可執行檔案。 rand crate 是一個 庫 crate,庫 crate 可以包含任意能被其他程式使用的程式碼,但是不能獨自執行。

Cargo 對外部 crate 的運用是其真正的亮點所在。在我們使用 rand 編寫程式碼之前,需要修改 Cargo.toml 檔案,引入一個 rand 依賴。現在開啟這個檔案並將下面這一行新增到 [dependencies] 表塊標題之下。請確保按照我們這裡的方式指定 rand 及其這裡給出的版本號,否則本教程中的示例程式碼可能無法工作。

Cargo.toml:

[dependencies]
rand = "0.8.5"

Cargo.toml 檔案中,表頭以及之後的內容屬同一個表塊,直到遇到下一個表頭才開始新的表塊。在 [dependencies] 表塊中,您要告訴 Cargo 本專案依賴了哪些外部 crate 及其版本。本例中,我們使用語義化版本 0.8.5 來指定 rand crate。Cargo 理解語義化版本(Semantic Versioning,有時也稱為 SemVer),這是一種定義版本號的標準。0.8.5 實際上是 ^0.8.5 的簡寫,它表示任何至少包含 0.8.5 但低於 0.9.0 的版本。 Cargo 認為這些版本具有與 0.8.5 版本相容的公有 API, 此規範可確保您獲得最新的補丁版本,該版本仍將與本章中的程式碼一起編譯。任何版本 0.9.0 或更高版本都不能保證具有與以下示例使用的相同的 API。

現在,在不更改任何程式碼的情況下,讓我們構建專案:

$ cargo build              
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.17
   Compiling libc v0.2.155
   Compiling getrandom v0.2.15
   Compiling rand_core v0.6.4
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.82s

您可能會看到不同的版本號(但它們都與程式碼相容,這要歸功於語義化版本!)和不同的行(取決於作業系統),並且行的順序可能不同。

當我們引入了一個外部依賴後,Cargo 將從 registry 上獲取所有依賴所需的最新版本,這是一份來自 Crates.io 的資料複製。Crates.io 是 Rust 生態環境中開發者們向他人貢獻 Rust 開源專案的地方。

在更新完 registry 後,Cargo 檢查 [dependencies] 表塊並下載缺失的 crate 。本例中,雖然只宣告瞭 rand 一個依賴,然而 Cargo 還是額外獲取了 rand 所需的其他 crate,rand 依賴它們來正常工作。下載完成後,Rust 編譯依賴,然後使用這些依賴編譯專案。

如果不做任何修改,立刻再次執行 cargo build,則不會看到任何除了 Finished 行之外的輸出。Cargo 知道它已經下載並編譯了依賴,同時 Cargo.toml 檔案也沒有變動。Cargo 還知道程式碼也沒有任何修改,所以它也不會重新編譯。無事可做,它只是退出。

如果開啟 src/main.rs 檔案,進行簡單的更改,然後儲存並重新生成,則只會看到兩行輸出:

$ cargo build
   Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s

這些行表明 Cargo 僅透過對 src/main.rs 檔案的微小更改來更新構建。您的依賴項沒有更改,因此 Cargo 知道它可以重用已經下載和編譯的內容。

Cargo.lock 檔案確保構建是可重現的

Cargo 有一種機制,可以確保每次您或其他任何人構建程式碼時都可以重新生成相同的工件:Cargo 將僅使用您指定的依賴項的版本,直到您另行指示。例如,假設下週 rand crate 的 0.8.6 版本釋出,該版本包含一個重要的錯誤修復,但它也包含一個會破壞程式碼的迴歸。為了解決這個問題,Rust 會在您第一次執行 cargo build 時建立 Cargo.lock 檔案,因此我們現在將其放在 guessing_game 目錄中。

當您第一次構建專案時,Cargo 會找出符合條件的所有依賴項版本,然後將它們寫入 Cargo.lock 檔案。當您將來構建專案時,Cargo 將看到 Cargo.lock 檔案存在,並將使用其中指定的版本,而不是再次執行找出版本的所有工作。這使您可以自動獲得可重現的構建。換句話說,由於 Cargo.lock 檔案,您的專案將保持在 0.8.5 版本,直到您明確升級。由於 Cargo.lock 檔案對於可重現的構建非常重要,因此它通常與專案中的其餘程式碼一起簽入原始碼管理。

更新crate到一個新版本

當您確實想要更新carte時,Cargo 提供了命令,該命令 update 將忽略 Cargo.lock 檔案,並在 Cargo.toml 中找出符合您規格的所有最新版本。然後,Cargo 會將這些版本寫入 Cargo.lock 檔案。在這種情況下,Cargo 將僅查詢大於 0.8.5 且小於 0.9.0 的版本。如果 rand crate 釋出了兩個新版本 0.8.6 和 0.9.0,則在執行 cargo update

$ cargo update
    Updating crates.io index
    Updating rand v0.8.5 -> v0.8.6

Cargo 忽略 0.9.0 版本。此時,您還會注意到 Cargo.lock 檔案中的更改,指出您現在使用的 rand crate 版本是 0.8.6。要使用 rand 0.9.0 版或 0.9.x 系列中的任何版本,您必須將 Cargo.toml 檔案更新為如下所示:

[dependencies]
rand = "0.9.0"

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

生成隨機數

讓我們開始使用 rand 來生成一個要猜測的數字。

注意:您不僅知道要使用哪些特徵以及要從 crate 呼叫哪些方法和函式,因此每個 crate 都有包含使用說明的文件。Cargo 的另一個簡潔功能是,執行該 cargo doc --open 命令將在本地構建所有依賴項提供的文件,並在瀏覽器中開啟它。例如,如果您對 rand crate中的其他功能感興趣,請執行 cargo doc --open 並單擊左側邊欄中的按鈕 rand

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

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

    let secret_number = rand::thread_rng().gen_range(1..=100);
    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);
}

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

接下來,我們在中間新增兩行。在第一行中,我們呼叫了為我們提供將要使用的特定隨機數生成器的 rand::thread_rng 函式:該生成器是當前執行執行緒的本地變數,並由作業系統設定種子。然後我們在隨機數生成器上呼叫該 gen_range 方法。此方法由 Rng 我們在 use rand::Rng; 語句中引入範圍的特徵定義。該 gen_range 方法將範圍表示式作為引數,並在該範圍內生成一個隨機數。我們在這裡使用的範圍表示式採用的形式 start..=end 是包含下限和上限的,因此我們需要指定 1..=100 請求一個介於 1 和 100 之間的數字。

新新增的第二行程式碼列印出數字。這在開發程式時很有用,因為可以測試它,不過在最終版本中會刪掉它。如果遊戲一開始就列印出結果就沒什麼可玩的了!

嘗試執行程式幾次:

$  cargo run 
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 39
Please input your guess.
39
You guessed: 39

$  cargo run 
 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 2
Please input your guess.
2
You guessed: 2

您應該得到不同的隨機數,它們都應該是 1 到 100 之間的數字。

將猜測與秘密數字進行比較

現在我們有了使用者輸入和隨機數,我們可以比較它們。

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

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

    let secret_number = rand::thread_rng().gen_range(1..=100);
    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);
    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

首先我們增加了另一個 use 宣告,從標準庫引入了一個叫做 std::cmp::Ordering 的型別到作用域中。Ordering 也是一個列舉,不過它的成員是 LessGreaterEqual。這是比較兩個值時可能出現的三種結果。

接著,底部的五行新程式碼使用了 Ordering 型別,cmp 方法用來比較兩個值並可以在任何可比較的值上呼叫。它獲取一個被比較值的引用:這裡是把 guesssecret_number 做比較。 然後它會返回一個剛才透過 use 引入作用域的 Ordering 列舉的成員。使用一個 match 表示式,根據對 guesssecret_number 呼叫 cmp 返回的 Ordering 成員來決定接下來做什麼。該表示式根據呼叫 cmp 返回的變數 Ordering ,其中的值為 guesssecret_number

一個 match 表示式由分支(arm) 構成。一個分支包含一個用於匹配的模式(pattern),給到 match 的值與分支模式相匹配時,應該執行對應分支的程式碼。Rust 獲取提供給 match 的值並逐個檢查每個分支的模式。模式和 match 構造是 Rust 強大的功能:它們可以讓你表達你的程式碼可能遇到的各種情況,並確保你處理所有這些情況。

讓我們用我們在這裡使用的 match 表示式來演示一個示例。假設使用者猜到了 50,這次隨機生成的秘密數字是 38。

當程式碼將 50 與 38 進行比較時,該 cmp 方法將返回 Ordering::Greater ,因為 50 大於 38。 match 表示式獲取 Ordering::Greater 值並開始檢查每隻分支的模式。它檢視第一個分支的模式, Ordering::Less 並發現值 Ordering::Greater 不匹配 Ordering::Less ,因此它忽略該分支中的程式碼並移動到下一個分支。下一隻手分支的圖案是 Ordering::Greater ,它確實匹配 Ordering::Greater !該分支中的關聯程式碼將執行並列印 Too big! 到螢幕上。 match 表示式在第一次成功匹配後結束,因此在此方案中,它不會檢視最後一隻分支。

現在我們來執行這個程式碼:

$ cargo run
   Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
error[E0308]: mismatched types
   --> src/main.rs:21:21
    |
21  |     match guess.cmp(&secret_number) {
    |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
    |                 |
    |                 arguments to this method are incorrect
    |
    = note: expected reference `&String`
               found reference `&{integer}`
note: method defined here
   --> /Users/wangyang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/cmp.rs:840:8
    |
840 |     fn cmp(&self, other: &Self) -> Ordering;
    |        ^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

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

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

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

完整程式碼如下:

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

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

    let secret_number = rand::thread_rng().gen_range(1..=100);
    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");

    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!"),
    }
}

我們建立一個名為 guess 的變數。但是等等,程式不是已經有一個名為 guess ?確實如此,確實如此,不過 Rust 允許用一個新值來掩蓋 guess 之前的值。允許我們重用變數名稱, guess 而不是強制我們建立兩個唯一的變數,例如 guess_strguess

我們將這個新變數繫結到表示式 guess.trim().parse() 。表示式 guess 中的 引用包含字串形式的輸入的原始 guess 變數。 String 例項上 trim 的方法將消除開頭和結尾的任何空格,我們必須這樣做才能將字串與只能包含數值資料的 u32 進行比較。使用者必須按Enter鍵才能滿足 read_line 並輸入他們的猜測,這會向字串新增一個換行符。例如,如果使用者鍵入 5 並按 Enter 鍵, guess 則如下所示: 5\n 。表示 \n “換行符”。(在 Windows 上,按 Enter 鍵會導致回車符和換行符 \r\n .)該 trim 方法消除 \n\r\n ,結果僅 為5

字串 parse 上的方法將字串轉換為另一種型別。在這裡,我們用它來從字串轉換為數字。我們需要告訴 Rust 我們想要的 let guess: u32 確切數字型別。冒號 ( : ) 告訴 guess Rust 我們將註釋變數的型別。Rust 有一些內建的數字型別;這裡 u32 看到的是一個無符號的 32 位整數。對於小正數來說,這是一個很好的預設選擇。

此外,此示例程式中的 u32 註釋以及與 secret_number means 的比較 Rust 將推斷出 secret_number 它也應該是一個 u32 。所以現在比較的是同一型別的兩個值!

由於 parse 方法只能用於可以邏輯轉換為數字的字元,所以呼叫它很容易產生錯誤。例如,字串中包含 A👍%,就無法將其轉換為一個數字。因此,parse 方法返回一個 Result 型別。像前面 部分討論的 read_line 方法那樣,再次按部就班地用 expect 方法處理即可。如果 parse 不能從字串生成一個數字,返回一個 ResultErr 成員時,expect 會使遊戲崩潰並列印附帶的資訊。如果 parse 成功地將字串轉換為一個數字,它會返回 ResultOk 成員,然後 expect 會返回 Ok 值中的數字。

現在讓我們執行程式:

$ cargo run
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 49
Please input your guess.
  89
You guessed: 89
Too big!

好!儘管在猜測之前新增了空格,但程式仍然發現使用者猜到了 76。執行程式幾次以驗證不同型別輸入的不同行為:正確猜測數字,猜測太高的數字,以及猜測太低的數字。

現在遊戲可以執行了,但使用者只能猜測一個。讓我們透過新增一個迴圈來改變它!

藉助迴圈允許多次猜測

loop 關鍵字建立一個無限迴圈,讓使用者有更多機會猜出數字:

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

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

    let secret_number = rand::thread_rng().gen_range(1..=100);
    println!("The secret number is: {secret_number}");
    
    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 = 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!"),
        }
    }
}

正如你所看到的,我們已經將所有從猜測輸入提示開始的都移動到一個迴圈中。請務必將迴圈內的行縮排另外四個空格,然後再次執行程式。該程式現在將永遠要求另一個猜測,這實際上引入了一個新問題。使用者似乎不能退出!

使用者始終可以使用鍵盤快捷鍵 ctrl-c 中斷程式。但是還有另一種方法可以逃脫這個貪得無厭的怪物,正如“將猜測與秘密數字進行比較” parse 中的討論中提到的:如果使用者輸入非數字答案,程式將崩潰。我們可以利用這一點來允許使用者退出,如下所示:

$ cargo run     
   Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 75
Please input your guess.
20
You guessed: 20
Too small!
Please input your guess.
80
You guessed: 80
Too big!
Please input your guess.
75 
You guessed: 75
You win!
Please input your guess.
quit
thread 'main' panicked at src/main.rs:20:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

quit 字會退出遊戲,但你會注意到,輸入任何其他非數字輸入也會退出。至少可以說,這是次優的;我們希望遊戲在猜到正確的數字時也停止。

猜對後退出

讓我們透過新增一個 break 語句來對遊戲進行程式設計,使其在使用者獲勝時退出:

			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 的最後一部分。

處理無效輸入

為了進一步完善遊戲的行為,讓我們讓遊戲忽略一個非數字,這樣使用者就可以繼續猜測,而不是在使用者輸入非數字時使程式崩潰。我們可以透過改變從 a String 轉換為 a u32 的行 guess 來做到這一點。

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

我們從 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
Compiling guessing_game v0.1.0 (/Users/wangyang/Documents/project/rust-learn/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 44
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
 jjj
Please input your guess.
00
You guessed: 0
Too small!
Please input your guess.
 99
You guessed: 99
Too big!
Please input your guess.
44
You guessed: 44
You win!

太棒了!再有最後一個小的修改,就能完成猜數字遊戲了:還記得程式依然會列印出秘密數字。在測試時還好,但正式釋出時會毀了遊戲。刪掉列印秘密數字的 println!

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

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

    let secret_number = rand::thread_rng().gen_range(1..=100);
    // println!("The secret number is: {secret_number}");

    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;
            },
        }
    }
}

總結

今天我們學習到了許多新的 Rust 概念的實踐方式: letmatch 、函式、外部crate的使用等等。在接下來的幾章中,您將更詳細地瞭解這些概念。第 3 章介紹了大多數程式語言的概念,例如變數、資料型別和函式,並展示瞭如何在 Rust 中使用它們。第 4 章探討了所有權,這是 Rust 與其他語言不同的特性。第 5 章討論了結構和方法語法,第 6 章解釋了列舉的工作原理。

相關文章