[Rust 入門 04] 猜數字

猜數字遊戲

這篇學到的事:

  1. 學會接收使用者輸入值
  2. 引用外部 crate
  3. 寫註解
  4. 變數型別轉換
  5. 使用 match, shadow, enum, expect()

0. 遊戲規則

產生一個 1 到 100 間的亂數。 然後提示我們輸入數字猜猜看
當我們輸入之後,它會告訴我們太大還是太小。 當我們猜對了,它會恭喜我們

1. 建立專案

在終端機執行

cargo new guessing_game --bin

加入 --bin 的參數,因為我們要建立執行檔而不是函式庫
使用 cargo 產生新專案的時候,預設就在 src/main.rs 幫你產生 hello world,讓你可以直接測試
於是就直接使用 cargo build 來編譯一下吧!還記得之前說過 Rust 的編譯跟執行是分開的嗎?
所以在編譯好後需要另外執行 cargo run 來執行剛剛編譯好的檔案,這樣就可以看見初始化專案時候建立的 hello world 顯示在終端機視窗上了

2. 取得使用者輸入

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

use 引用

use std::io;

取得標準函式庫中的 io 函式庫

main()

fn main() {

因為 main 沒有回傳值,所以他的回傳值是一個空的 tuple 「()
接著印出了兩行說明

變數定義

let mut guess = String::new();

不一樣的東西來了,有一個 let 陳述式,它被用來建立「變數綁定」
它的形式是:let foo = bar;,很多程式語言會這麼做,但在 Rust 中事情有些不同

let 變數預設是不可變的(immutable),使用 mut 使綁定變成可變的(mutable),而不再是不可變的
範例:

let foo = 5; // immutable.
let mut bar = 5; // mutable

藉著範例你也知道了在 Rust 中註解是使用兩個斜線 // 跟你熟悉的程式語言類似嗎?
我們知道了等號左邊,那接著來了解右邊的 String::new()

String

String 是一個字串型別,由標準函式庫提供
::new() 語法使用 :: 是因為它是一個特定型別的「關聯函式」(associated function)。也就是說,它被關聯到 String 本身,而非特定的某個 String 的實體(instance)。
一些語言(例如 PHP)稱之為「靜態方法」(static method)
new() 會建立一個新的、空的 String

io

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

因為我們第一行有 use std::io; 所以這邊可以直接呼叫關聯函式,否則我們就要把這行寫成 std::io::stdin()。這個函式會回傳終端機標準輸入的控制代碼(handle),詳細說明可以參考std::io::Stdin

.read_line(&mut guess)

接著使用這個控制代碼(handle)呼叫(handle)的 read_line() 方法取得使用者的輸入
接著好玩的事情就發生了,在前面我們已經把 guess 設定成可變的(mutable)。然而,read_line 不接受把 String 當作參數:它只接受 &mut String
Rust 有一個叫做 參照(references)的功能,它允許你將多個參照指向同一塊資料,這樣可以降低複製的動作,畢竟 Rust 的賣點就是安全。這個和 PHP 中的參照(Reference),或是中國那邊翻作引用。使用「&」符號來賦值相似
因為 guess 是使用者輸入的字串,所以他必須是一個可變的變數
然而這行程式並還未結束,記得嗎?我們說過要看到分號才算表達式完成。這邊換行是為了人類閱讀上方便,當然也可以寫成一行

.expect("Failed to read line");

這段主要在做的事情是把錯誤處理訊息編碼,上面的 read_line() 會把取得的值放入 &mut String 參數並回傳一個值,在這邊是一個 io::Result
為的是把錯誤處理訊息編碼,此處的 io::Result 有 expect() 方法,若是不成功就會回傳 Rust 內建的 panic!

panic! 會讓你的程式當機,並顯示出傳給它的訊息。

印出變數

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

{} 是個 placeholder,因此我們傳遞 guess 作為參數。如果我們有多個 {},我們就需要傳遞多個參數,概念類似於 MySQL 中的 %s 用法
接著執行 cargo run 執行專案,就完成第一部分:從鍵盤取得輸入值,然後印出來。
print input

3. 產生秘密數字

接著要產生隨機數字,但是 Rust 本身的標準函式庫還沒有包含亂數功能。不過 Rust 團隊提供了 rand crate 來協助我們產生隨機亂數
於是照著文件,我們在 Cargo.toml 加入所需的外部 crates 及版本

[dependencies]
rand = "0.7.3"

Cargo 套件的版本號能使用 Semantic Versioning 的標準方式
上面的版號實際上可以寫成 ^0.3.0,代表「所有跟 0.3.0 相容的版本」。如果我們想要確實的使用 0.3.0,我們可以寫成 rand="=0.3.0"(請注意引號內還有一個等號),而當我們想要使用最新版,我們可以使用 *。
了解版本號的撰寫方式之後回頭來建置專案:cargo build
修改我們的程式碼

extern crate rand;
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);
}

可以發現在第一行,加入了 extern crate rand 讓 Rust 知道我們要使用 rand 這個 crate。等同於 use rand;,所以我們可以透過 rand:: 前綴詞來使用任何 rand crate 內的東西。
接著使用剛剛加入的 crate rand 來取得指定範圍內的隨機數字,並且印出來。這只是簡單測試功能正常,畢竟猜數字是不該一開始就印出解答的

比較猜測值

接著就要來比較輸入跟答案的值,這邊會用到標準函式庫中一個比較大小的函式庫型別:cmp::Ordering;

extern crate rand;
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);
 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!"),
 }
}

可以看到最後面多了五行程式碼,cmp() 方法可以被任何能用來比較的東西呼叫,且它會要求傳入你想比較的東西的參照(記得嗎?上面講的 &)
它回傳我們前面 use 的 Ordering 型別,而我們使用了 match 陳述去判定它實際上是哪種 Ordering

Ordering 是個 enum,是枚舉(enumeration)的簡寫
枚舉看起來會有點像這樣:

enum Foo {
 Bar,
 Baz,
}

這裡定義了一個 Foo 的命名空間(namespace),若使用裡面的變數,必須使用 :: ,例如 Foo::Bar 或是 Foo::Baz
解釋完 enum 就可以了解那五行在做什麼了
然後就給他 cargo run 下去!

error[E0308]: mismatched types
 --> src/main.rs:23:21
 |
23 |     match guess.cmp(&secret_number) {
 |                     ^^^^^^^^^^^^^^ expected struct `std::string::String`, found integer
 |
 = note: expected reference `&std::string::String`
 found reference `&{integer}`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game`.

出現問題了!

他說我們有一個 mismatched types 翻譯過來就是「無法匹配的型別」
原來是因為,我們的 guess 在定義的時候說他是個 String::new(); 是個字串,但是 secret_number 有多種可以存入 1 到 100 值的型別,有:32 位元整數 i32、無號數(unsigned)32 位元整數 u32、64 位元整數 i64 或其他等等。

Rust 預設為 i32
但這都不是這邊的問題,而是 Rust 不知道怎麼去比對 guess 與 secret_number。他們必須是同樣的型別,這在動態語言如 PHP javascript 中是會被語言本身解決的,工程師不需要在型別上糾結,相對的也得付出一些代價
想要把輸入的 String 轉換為一個真正的數字型別,我們可以用額外的兩行做到

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

等等!我們已經有了 guess 了吧?
沒錯,但 Rust 允許我們用新的 guess 去「遮蔽」(shadow)前一個。通過使用 let 重新聲明一個已經存在的變數,這時是可以繼續使用原本變數所賦予的值,而不需要重新想一個變數來區分例如:guess_str 和 guess
一開始 guess 是 String,但我們希望能轉換為 u32,在類似的情況中我們時常使用這個方法
接著就是把原本的 guest 綁定在表達式上,最終要回傳 u32 的型別回來

guess.trim().parse()

值得一提的是因為我們必須按下「Enter」按鍵去符合 read_line() 的輸入條件。意思就是當我們輸入「87」按下 Enter 送出,那 guess 就會是:87\n。 \n 代表「新的一行」(newline)、enter 鍵。所以必須使用 trim() 來清除這些多餘的東西
字串的 parse() 方法會把字串分析為數字。因為它可以被分析為很多種數字型別,我們必須給 Rust 我們確切想要的數字型別的提示。所以我們在等號左邊寫了 let guess: u32 告訴 Rust 我們需要的型別
read_line() 一樣,我們呼叫 parse() 時可能會發生錯誤,例如有人輸入了 A👍% 怎麼辦?沒錯就是跟 read_line() 一樣,使用 expect() 方法,讓他在出錯時停止
再編譯一次執行,果然這次就沒有問題了。但新的問題來了,我只能猜一次,這不符合遊戲規則。所以我們加入迴圈來改進他

迴圈

關鍵字 loop 會給我們一個無限迴圈,break 會幫助我們離開無窮迴圈
所以會在要求使用者輸入前開始無限迴圈,直到使用者猜的數字符合條件,那我們就在該條件下 break 指令讓程式跳出無窮迴圈
如果你實際的玩了幾次你的程式,並且耐不住好奇心驅使輸入了非數字的內容,你會發現 expect() 方法的確可以停止錯誤,但一般的遊戲不應該直接跳出,應該要告訴使用者該怎麼做
這是因為 expect() 是直接將程式「崩潰 (crash)」,於是我們把 parse() 修改為以下的樣子

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

改為使用 match 陳述的方式,把「錯誤時當機」改為「實際處理錯誤」的方法,parse() 回傳的 Result 是個跟 Ordering 類似的 enum。但裡面的變數是 Ok 或是 Err
當 match 到 Ok(num) 時,會把 Ok 內的值設給 num 這個名稱,然後在右邊直接回傳它;在 Err 的情況,我們不在意發生了什麼錯誤,所以使用一個下劃線 _ 來接收到 Err 收到的所有內容,不管其中包含什麼訊息。接著 continue 讓我們可以繼續 loop 的下一次疊代(iteration)要求使用者輸入數字。
看起來應該是沒問題了!執行最終的完整程式碼…
等等!先把印出答案那行刪掉

extern crate rand;
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);
 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;
 }
 }
 }
}

留言