iT邦幫忙

2023 iThome 鐵人賽

DAY 5
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 5

05 利用 rust 完成井字遊戲... 啊不就只是個小遊戲?

  • 分享至 

  • xImage
  •  

我們先完成「可以動」的主線,再來打副本,先寫好play劇本,再編成執行檔使用手動測試,程式碼如下:

// core/src/play.rs
use core::tic_tac_toe::Game;

fn main() {
let mut game = Game::default();                     // 遊戲內容會變動,故給 mut
println!("{}", game);
loop {
    println!("請輸入數字 1 and 9");

    let mut input = String::new();                  // 存放 User 輸入的變數

    let _ = std::io::stdin()                        // 讀取輸入
        .read_line(&mut input);                     // Result<usize> 回傳Bytes

    match input.trim().parse::<usize>() {           // 嘗試轉換輸入內容為數字
        Ok(num) => {                                // 轉換成功
            if num < 1 || num > 9 {                 // 檢核輸入範圍不符
                println!("數字範圍錯誤,請輸入數字 1 ~ 9");
                continue;                           // 跳下一輪請User重新輸入
            }
            println!("你輸入的是: {}", num);          // Debug 用 (?)
            game.play(num);                          // 運行遊戲邏輯
        }
        Err(_) => {                                  // 解析錯誤
            println!("輸入內容錯誤:請輸入數字 1 ~ 9:");
            continue;                                // 跳下一輪請User重新輸入
        }
    };

    println!("{}", game);                            // 印出運行後結果

    if game.is_over {                                // 處理遊戲結束
        if game.winner.is_some() {                   // 顯示贏家或平手
            println!("遊戲結束:贏家是:{}", game.winner.unwrap());
        } else {
            println!("遊戲結束:平手");
        }
        break;                                       // 結束遊戲
    }
}
println!("{}", game);
}

調整對應的欄位修改:

@@ core/src/tic_tac_toe.rs @@
 pub struct Game {
     pub cells: [Cell; 9],
     pub is_over: bool,
+    pub winner: Option<Cell>,

 pub fn play(&mut self, num: usize) {
+    let index = num - 1;
+    self.cells[index] = Cell::O;
-    self.cells[num] = Cell::O;
-    self.is_over = true;

 fn default() -> Self {
     Self {
         cells: [Cell::Empty; 9],
         is_over: false,
+        winner: None,
  • Result<T, E>是預設的Enum之一,主要用來表達成功失敗,若成功回傳型別T,若失敗回傳型別E。
  • match用來匹配enum的所有情境,有點像大家常見的switch但又更威,詳見後方說明及舉例。
  • Option<T>也是預設的Enum之一,主要用來表達有資料無資料(類似Null但又不太一樣,請不要用Null去看待它,rust裡不會有昂貴的null reference exception)。

為避免中斷我們看程式的整體脈絡,我把相關語法教學請拉到後方的說明,現在我們就可以開始手動驗證一下:

game demo

沒錯,流程大致正確,接下來我們開始一步步實作核心邏輯吧。

構思

我們要讓 player 對應到 O 或 X,所以之後可能要實作這個:

fn play_move(player: Player) -> Cell {
    match player {
        Player::A => Cell::O,
        Player::B => Cell::X,
    }
}

但是這樣不如就把 玩家取名 A, B 改為直接取名 O, X 不是更直觀

match player {
    Player::O => Cell::O,
    Player::X => Cell::X,
}

好像不錯,但是:

pub enum Player {
    O,
    X,
}
pub enum Cell {
    Empty,
    O,
    X,
}

這時候突然謎之音響起:重複的程式碼是罪惡的(?),既然 O/X都一樣,那我們把它們塞在一起吧

pub enum Cell {
    Empty,
    Player,    // Cell::Player::O , Cell::Player::X
}

但是這樣好長啊,而且Player放這感覺語意怪怪的(?),有了把Player改Symbol好了,但還是有點長,呃,Empty就是空的意思,那Option不就可以自帶空了嗎,那我們把它們合併在一起好了:

+#[derive(Copy, Clone, Debug)]
+pub enum Symbol {
+    O,
+    X,
-pub enum Player {
-    A,
-    B,
}
...
-#[derive(Copy, Clone, Debug)]
-pub enum Cell {
-    Empty,
-    O,
-    X,
-}
...
 #[derive(Debug)]
 pub struct Game {
+    pub cells: [Option<Symbol>; 9],
-    pub cells: [Cell; 9],
     pub is_over: bool,
+    pub winner: Option<Symbol>,
-    pub winner: Option<Cell>,
...
 impl Game {
     pub fn play(&mut self, num: usize) {
         let index = num - 1;
+        self.cells[index] = Some(Symbol::O);
-        self.cells[index] = Cell::O;
...
     fn default() -> Self {
         Self {
+            cells: [None; 9],
-            cells: [Cell::Empty; 9],
...
+impl Display for Symbol {
-impl Display for Cell {
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         match self {
+             Symbol::O => write!(f, "O"),
+             Symbol::X => write!(f, "X"),
-             Cell::Empty => write!(f, " "),
-             Cell::O => write!(f, "O"),
-             Cell::X => write!(f, "X"),
         }

刪Code就是開心,有乾淨代碼的強迫症(?),不過,改了之後要打印整個遊戲出了點問題,compiler說

無法打印Option類別

如果這時候有人很天真的想要幫Option實作Display:

impl Display for Option<Symbol>{}

就會報錯:
不能實作非crate內struct

rust裡有規範不能隨意套trait,只能幫我們crate裡的type(可以是enum或struct)套trait,或是我們自己crate裡的tarit可以套任何struct,所以在這裡只好寫一個help method來處理:

// core/src/tic_tac_toe.rs
fn show(cell: Option<Symbol>) -> String {
    match cell {
        Some(symbol) => format!("{}", symbol),
        None => " ".to_string(),
    }
}
@@ core/src/tic_tac_toe.rs @@
+show(self.cells[0]), show(self.cells[1]), show(self.cells[2]),
+show(self.cells[3]), show(self.cells[4]), show(self.cells[5]),
+show(self.cells[6]), show(self.cells[7]), show(self.cells[8]),
-self.cells[0], self.cells[1], self.cells[2],
-self.cells[3], self.cells[4], self.cells[5],
-self.cells[6], self.cells[7], self.cells[8],

上面這段修改請使用multi cursor處理啊,別浪費IDE的功能了。

這時候可以看到watch的畫面,從剛剛一路各種報錯到現在,終於又恢復正常了,先喘口氣休息一下,喝口水我們再接再厲。

實現下棋邏輯

再來,每一步一定是O, X輪流著下,可以的話我們不要用if (?),我們用相對簡單的方式,把步數除以2求餘數,就會是0, 1, 0, 1,...剛好可以輪流2個符號

// core/src/tic_tac_toe.rs
pub struct Game {
    // ...
    pub symbols: [Symbol; 2],   // 加上我們要loop的元素陣列
}

impl Default for Game {
    fn default() -> Self {
        //...
            symbols: [Symbol::O, Symbol::X],  // 預設為 O / X 
        }        // 未來開心的話可以改順序,或加上奇怪的符號(?)△☆★ (?
// ...

impl Game {
    pub fn current_step(&self) -> usize {
        self.cells.iter().filter(|x| x.is_some()).count() // 算步數
    }

    pub fn play(&mut self, num: usize) {
        let symbol = self.symbols[self.current_step() % 2]; // 總步數除以2求餘
        self.cells[index] = Some(symbol);
    }
}

這時候再到console隨意下,看起來有模有樣了:

game play demo

不過測的時候發現如果重覆key同樣的數字會覆蓋掉,這樣好像 不用key作弊碼就可以作弊了 是很明顯的bug,快點修一下:

Error Handling

我們這時候要在fn play裡加檢核,檢核若為錯誤的邏輯就不能繼續,並且提示訊息,而rust裡的錯誤處理使用我們剛剛提過的Result,(可以先拉到最後面看Result介紹,或等等再看)。這邊我們先自定義Result enum裡回傳錯誤類別(E),記得錯誤是來幫助我們的,要麻幫助我們更好的除錯,要麻讓User知道他自己錯了(?):

// core/src/tic_tac_toe.rs
#[derive(Debug)]
pub enum Error {
    AlreadyOccupied,
    GameOver,
}
// ...
pub fn play(&mut self, num: usize) -> Result<(), Error>{
    let index = num - 1;
    if self.cells[index].is_some() {
        return Err(Error::AlreadyOccupied);
    }
    let symbol = self.symbols[self.current_step() % 2];
    self.cells[index] = Some(symbol);
    Ok(())
}
// core/src/play.rs
match input.trim().parse::<usize>() {
    Ok(num) => {
        // ...略
        println!("你輸入的是: {}", num); 
        let round = game.play(num);    // play現有有回傳值Result<T,E>
        if round.is_err() {            // 加上遇到錯誤便打印出來
            println!("錯誤:{:?}", round.err().unwrap());
            continue;
        }

error message

看起來好像不是很友好(?),我們利用thiserror這個crate來美化一下錯誤的處理。

套件管理 Crates

cargo官方套件管理registry在這裡,就像npmNuGetRubyGems。一般要加引用,只要:

~/my-add$ cargo add {套件名稱}  # 套件可以在crates.io尋找合適的

然後cargo就會幫我們記錄在Cargo.toml的檔案裡,就像package.jsonmyproj.csprojGemfile。不過我們現在是個「好大大」(?)的專案,我希望我們用的外部套件版本儘量一致,後續我會統一把專案的設定放workspace的toml檔裡,目前cargo指令還不支援workspace,所以我們要手動維護:

在workspace裡的Cargo.toml檔加上以下:

# Cargo.toml
[workspace.dependencies]
thiserror = { version = "1.0" }

core/Cargo.toml檔加上以下:

# core/Cargo.toml
[dependencies]
thiserror = { worksapce = true }

thiserror使用

然後修改一下我們的Error enum,並把play裡打印的訊息從Debug訊息改為Display

// core/src/tic_tac_toe.rs
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("已非空格,不能再次畫記!")]
    AlreadyOccupied,
    #[error("遊戲已結束,無法操作!")]
    GameOver,
}
@@ core/src/play.rs @@
 if round.is_err() {
+    println!("錯誤:{}", round.err().unwrap());
-    println!("錯誤:{:?}", round.err().unwrap());
     continue;

顯示中文提示

核心邏輯實作

好,接下來判斷勝利的邏輯

// core/src/tic_tac_toe.rs
pub fn play(&mut self, num: usize) -> Result<(), Error>{
    if self.is_over {                    // 一開始先判斷遊戲結束就報錯
        return Err(Error::GameOver);
    }
    // (略過先前寫好的部分,中間不動)
    self.check_over();                   // 玩家每一步結束後判斷是否結束
    Ok(())
}

pub fn check_over(&mut self) {
    let winner = self.check_winner();        // 檢查贏家,邏輯比較複雜,另外寫個fn
    if winner.is_some() {                    // 有玩家勝出
        self.is_over = true;                 // 設定遊戲狀態為結束
        self.winner = winner;                // 記錄勝利玩家
        return;
    }
    if self.cells.iter().all(|x| x.is_some()) { // 無贏家且所有格式都填滿
        self.is_over = true;                    // 表示和局,遊戲狀態設為結束
    }
}

pub fn check_winner(&mut self) -> Option<Symbol> {
    let win_patterns = [                 // 連線的index情境
        [0, 1, 2], [3, 4, 5], [6, 7, 8], // 橫
        [0, 3, 6], [1, 4, 7], [2, 5, 8], // 直
        [0, 4, 8], [2, 4, 6],            // 斜
    ];
    for idx in win_patterns.iter() {     // 用for 逐項檢查上面8條線
        let line = [                     // 把資料代入
            self.cells[idx[0]], 
            self.cells[idx[1]], 
            self.cells[idx[2]],
        ];
        if line == [Some(Symbol::O); 3] { // 整條線等於 [O,O,O] 表示O贏
            return Some(Symbol::O);
        }
        if line == [Some(Symbol::X); 3] { // 整條線等於 [X,X,X] 表示X贏
            return Some(Symbol::X);
        }
    }
    None                                  // 檢查完無符合條件回傳無
}

我們先用if寫讓不熟匹配模式的朋友了解,寫好編譯器抱怨:

must implement PartialEq

rust提示Symbol沒有實作PartialEq這個trait,無法進行==的比較,解法也告訴你了,就是在enum Symbol上的derive補上PartialEq即可,:

+#[derive(Copy, Clone, Debug, PartialEq)]
-#[derive(Copy, Clone, Debug)]
 pub enum Symbol {

用匹配模式置換if

判斷結束的兩個if,和判斷贏家裡面的兩個if判斷也可以使用匹配模式進行,如下:

pub fn check_over(&mut self) {
    let winner = self.check_winner();        // 檢查贏家,邏輯比較複雜,另寫fn
    match winner {                                // 匹配玩家所有可能
        Some(_) => {                              // 情境一:非None
            self.is_over = true;                  //   註記遊戲結束
            self.winner = winner;                 //   紀錄贏家
        }
        None => {                                 // 情境二:無贏家   
            if self.cells.iter().all(|x| x.is_some()) { // 若格子已填滿
                self.is_over = true;              // 遊戲結束 (平手)
            }
        }
    }
}
match line {
    [Some(Symbol::O), Some(Symbol::O), Some(Symbol::O)] => return Some(Symbol::O),
    [Some(Symbol::X), Some(Symbol::X), Some(Symbol::X)] => return Some(Symbol::X),
    _ => (),
}

可以的話多習慣用match來取代if,生活應該會比較好過一點(?),上面用match替換後應該還是可以正常跑。

加上對手

好了,測試一下遊戲都可以順利的運行,不過自己跟自己下好像怪怪的(? 我不是邊緣人),我們加入電腦當對手,但是要讓電腦隨機下的話,要先加入rust的隨機crate: rand,一樣到workspace跟core裡的toml檔設定:

@@ core\Cargo.toml @@
 [dependencies]
+rand = { worksapce = true }
 thiserror = { worksapce = true }

@@ in Cargo.toml @@
 [workspace.dependencies]
+rand = { version = "0.8" }
 thiserror = { version = "1.0" }

我們打算加一個play_with_counter的方法,玩家每下一步,電腦就會下一步,先寫偽代碼(pseudo code)如下:

fn 對奕()
    玩家下棋()
    if 結束 return
    電腦下棋()

fn 電腦下棋()
    for each 格子
        if 格子不為空,next
        if 格子為空,下棋 & 離開

想好後就可以推出以下code來:

use rand::prelude::SliceRandom;   // 記得最上面要加入rand的參照
// 略
pub fn play_with_counter(&mut self, num: usize) -> Result<(), Error> {
    self.play(num)?;         // unwrap or return Error
    if self.is_over {        // 結束就離開
        return Ok(());
    }

    let mut indices: [usize; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 9]; // 格子的範圍
    // let mut indices: [usize; 9] = (1..=9)            // 同上一行,進階寫法
    //     .collect::<Vec<_>>().try_into().unwrap(); 
    indices.shuffle(&mut rand::thread_rng());        // 打亂順序
    for index in indices.iter().enumerate() {        // 逐一檢查可否下
        let num = index.1;                           // 取出格號
        if self.cells[num - 1].is_some() {           // 檢查該格是否已劃記
            continue;                                // 若已劃記便跳至下一格
        }
        self.play(*num)?;                            // 格號為空直接劃記該格
        break;                                       // 中斷整個for(避免全填滿)
    }
    Ok(())
}
  • 其中第4行的?是下面的縮寫:
    match self.play(num) {
        Ok(data) => data,
        Err(e) => return Err(e), 
    };
    
    因為我們常常要用到if 正確 { 繼續 } else { return錯誤 },有錯提早回傳可以避免浪費(好奇的人自行估狗一下early return),在rust中經常可看到?,我們後面也會常使用。
  • 其中第12行的透過 iter().enumerate() 來遍歷每一個元素,每次取出來的index是一個tuple(usize, 元素類別)tuple第一個值是從0開始的計數索引號,第二個值才是取出來的元素,tuple要選擇元素的方式是 t.0 取指標0(第1個)元素,t.1取指標1(第2個)元素。
  • 其中第17行的*num多出一個*,是因為rust在遍歷時,是借用(引用)元素的參照,所以要使用*來解引用才能取到我們所想要的值,之後有機會再說明借用這個也很重要的概念。

隨機打亂的進階版是google查到stack flow的解法,一併附上提供大家參考。

最後我們把試玩的play改成我們新寫的方法

@@ core/src/play.rs @@
+let round = game.play_with_counter(num);
-let round = game.play(num);

測試一下,看起來可以正確跟電腦對奕了,勝負的判斷和平手看起來都正確,噢耶終於完成了。

遊戲O贏範例

遊戲X贏範例

遊戲平手範例

說明:Rust的Enum

Rust 的 Enum 可以附帶各種資料,簡直是開了掛,我們直接看例子:

enum PaymentMethod {
    Cash,                              // 沒附帶資料
    Check(u32),                        // 附帶1個u32的資料
    CreditCard(CardAssociation, u64),  // 附帶1個二元素Tuple(x,y)
    Credit {                           // 也可以是個物件 (刷人臉 XDD)
        name: String,
        phone: String,
    }
}
enum CardAssociation {
    Visa,
    MasterCard,
    AmericanExpress,
    JCB,
}

說明:Match 匹配

最常搭配使用的就是匹配模式(match)

enum Pet {
    Cat,
    Dog,
}

fn barking(pet: Pet) {
    match pet {
        Pet::Cat => println!("meow"), // 可單行表示一個表達式
        Pet::Dog => {                 // 或使用 Block 進行多行的操作
            println!("woof")
        },
    }
}
  • match必須窮舉所有可能
  • 有一個補足所有窮舉的方法是:「所有剩下的可能」_,如 _ => println!("others")

match也可以回傳值,因為 match 也是個表達式

enum Color {
    Yellow,
    Red,
}
let color = Color::Yellow;
let code = match color {
    Color::Yellow => "FFFF00",
    Color::Red => "FF0000",
};

匹配同時可以解構被匹配資料,如:

enum Demo {
    A(i32),
    B{ name: String, age: u8 }
}

fn demo(d: Demo) {
    match d {
        Demo::A(n) => println!("A: {}", n),
        Demo::B{ name, age } => println!("B: {} {}", name, age),
    }
}

fn main() {
    let d1 = Demo::B {
        name:"Hello".to_string(), 
        age: 32
    };
    demo(d1);                // B: Hello
    let d2 = Demo::A(32);
    demo(d2);                // A: 32
}
  • match回傳值必需一致的型別

重要 Enum 1: Option

Rust裡並沒有Null型別,那要怎麼處理沒資料呢,一個簡單的方式就是用我們剛學的enum,二選一,有資料或無資料,再把資料類別打上個泛型T,就打遍天下無敵手了 (?)

rust 定義Option 的 source code:

pub enum Option<T> {
    None,
    Some(T),
}

就這樣?沒錯就這樣,但這個會延申出很多很好用的用法,寫FP的朋友應該很熟悉這個模式。以下列舉一些常見的方法:

let data : Option<i32> = Some(5);    // Option Enum 賦值要用Some包起來
println!("data: {:?}", data);                        // => Some(5)
println!("data unwrap: {}", data.unwrap());          // => 5
println!("data unwrap_or: {}", data.unwrap_or(0));   // => 5
println!("data is_some: {}", data.is_some());        // true
println!("data is_none(): {}", data.is_none());      // false
let data : Option<i32> = None;                       // 無資料直接設定為None
println!("data: {:?}", data);                        // None
// println!("data unwrap: {}", data.unwrap());       // panicked 會中斷全程式
println!("data unwrap_or: {}", data.unwrap_or(0));   // 0
println!("data is_some: {}", data.is_some());        // flase
println!("data is_none(): {}", data.is_none());      // true

搭配match使用:

let data : Option<i32> = Some(5);
match data {
    Some(x) => println!("there is data: {}", x),
    _ => println!("no data:"),
}

重要 Enum 2: Result<T, E>

Result其實跟Option很像,只是泛型的類別是2個,所以相當於是加強版的Option,源代碼如下:

enum Result<T, E> {
   Ok(T),    // 成功,回傳類別 T
   Err(E),   // 錯誤,回傳類別 E
}

常見用法:

// 定義fn回傳的type為 T: usize, E: string
fn divide(p: usize, n: usize) -> Result<usize, String> {
    if n == 0 {
        return Err("不能除以0".to_string());
    };
    Ok(p / n)
}

fn main() {
    let r = divide(10, 0);
    println!("{:?}", r);                  // Err("不能除以0")
    println!("{}", r.is_err());           // true
    println!("{}", r.is_ok());            // flase
    // println!("{}", r.unwrap());        // paniced
    println!("{}", r.unwrap_or(9999999)); // 9999999
    let r = divide(10, 5);
    println!("{:?}", r);                  // Ok(2)
    println!("{}", r.is_err());           // false
    println!("{}", r.is_ok());            // true
    println!("{}", r.unwrap());           // 2
}

有時候我們Result只想確定有執行成功,並不一定要有回傳值,有點類似void,這時可以回傳 unit type,可以想像就是一個tuple但裡面放的元素個數是0。

fn handle_order(quantities: i32) -> Result<(),String> {
    if quantities < 0 {
        return Err("數量不能為負數".to_string());
    }
    if quantities > 100 {
        return Err("數量不能超過100".to_string());
    }

    // 處理訂單邏輯...

    Ok(())
}

fn main(){
    let result = handle_order(101);   // result可以使用前述各種方法如is_ok()
    match result {                    // 或是用匹配進行個別案例處理
        Ok(_) => println!("訂單處理成功"),   // _ 是忽略Ok所回傳的值
        Err(err) => println!("訂單處理失敗:{}", err),
    }
}

參考資料


上一篇
04 今晚,我想來點... rust 入門語法
下一篇
06 好還要更好,讓 rust 乾淨一點
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言