我們先完成「可以動」的主線,再來打副本,先寫好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)。為避免中斷我們看程式的整體脈絡,我把相關語法教學請拉到後方的說明,現在我們就可以開始手動驗證一下:
沒錯,流程大致正確,接下來我們開始一步步實作核心邏輯吧。
我們要讓 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實作Display:
impl Display for Option<Symbol>{}
就會報錯:
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隨意下,看起來有模有樣了:
不過測的時候發現如果重覆key同樣的數字會覆蓋掉,這樣好像 不用key作弊碼就可以作弊了 是很明顯的bug,快點修一下:
我們這時候要在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;
}
看起來好像不是很友好(?),我們利用thiserror這個crate來美化一下錯誤的處理。
cargo官方套件管理registry在這裡,就像npm或NuGet或RubyGems。一般要加引用,只要:
~/my-add$ cargo add {套件名稱} # 套件可以在crates.io尋找合適的
然後cargo就會幫我們記錄在Cargo.toml
的檔案裡,就像package.json
或myproj.csproj
或Gemfile
。不過我們現在是個「好大大」(?)的專案,我希望我們用的外部套件版本儘量一致,後續我會統一把專案的設定放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 }
然後修改一下我們的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寫讓不熟匹配模式的朋友了解,寫好編譯器抱怨:
rust提示Symbol沒有實作PartialEq這個trait,無法進行==
的比較,解法也告訴你了,就是在enum Symbol上的derive
補上PartialEq
即可,:
+#[derive(Copy, Clone, Debug, PartialEq)]
-#[derive(Copy, Clone, Debug)]
pub enum Symbol {
判斷結束的兩個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(())
}
?
是下面的縮寫:
match self.play(num) {
Ok(data) => data,
Err(e) => return Err(e),
};
因為我們常常要用到if 正確 { 繼續 } else { return錯誤 }
,有錯提早回傳可以避免浪費(好奇的人自行估狗一下early return
),在rust中經常可看到?
,我們後面也會常使用。iter().enumerate()
來遍歷每一個元素,每次取出來的index
是一個tuple(usize, 元素類別)
,tuple
第一個值是從0開始的計數索引號,第二個值才是取出來的元素,tuple要選擇元素的方式是 t.0
取指標0(第1個)元素,t.1
取指標1(第2個)元素。*num
多出一個*
,是因為rust在遍歷時,是借用(引用)元素的參照,所以要使用*
來解引用才能取到我們所想要的值,之後有機會再說明借用這個也很重要的概念。隨機打亂的進階版是google查到stack flow的解法,一併附上提供大家參考。
最後我們把試玩的play改成我們新寫的方法
@@ core/src/play.rs @@
+let round = game.play_with_counter(num);
-let round = game.play(num);
測試一下,看起來可以正確跟電腦對奕了,勝負的判斷和平手看起來都正確,噢耶終於完成了。
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),
enum Pet {
Cat,
Dog,
}
fn barking(pet: Pet) {
match pet {
Pet::Cat => println!("meow"), // 可單行表示一個表達式
Pet::Dog => { // 或使用 Block 進行多行的操作
println!("woof")
},
}
}
_
,如 _ => 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
}
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:"),
}
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),
}
}