上一篇完成了基本也完整的功能,我們繼續往下之前,先停一下腳步,回顧一下我們代碼的品質,rust提供clippy這個工具,可以有效的幫我們檢查代碼寫的不夠好的部分,並給予相關建議。
Rust裡有個工具叫Clippy,理論上應該已經在安裝rust的時候就裝好了,如果沒有的話,快看這裡安裝,好了我們來掃掃看吧:
~/demo-app$ rustup component add clippy # 如果沒安裝到的話才需要跑這行
~/demo-app$ cargo clippy
跑完說我們看一下提示的訊息,中間help後面說可以造訪他提供的網址,我們點開來看說明及建議如下:
建議說我們用range表達會有更好的可讀性,range在上一篇的進階寫法有短暫現身過:
let mut indices: [usize; 9] = (1..=9).collect::<Vec<_>>().try_into().unwrap();
先講一下range語法,使用2個「.
」符號接起來的數字就是範圍,並且是計頭不計尾,如果要計尾就加等號變..=
,如下示例:
1..9 // => 1到8
1..=9 // => 1到9
let a = String::from("1234567");
let b = &a[3..5]; // 借用a,取第3至第5(不含)字元的概念
println!("{:?}", b); // 45
除了告訴我們這個問題怎麼修比較好,一些簡單的問題還可以自動修正(好像在用eslint一樣),我們依提示執行下列命令即可:
~/demo-app$ cargo clippy --fix --bin "play"
rust的單元測試工具內建,不用另外安裝,而且可以寫在cod旁邊,很容易參照,作法如下:
// 你的程式碼
#[cfg(test)] // 告訴編譯器這是測試模組,所以在編譯代碼會忽略,只有跑測試才會編譯
mod test { // 你要別的名字也可以只是一般我們寫test比較清晰
// 你的測試代碼
}
#[cfg(test)]
的內容不會編譯到production code裡寫完測試整體結構就像這樣:
這邊的測試代碼9成以上是副駕駛幫忙完成的,我只列部分讓大家參考一下測試的代碼:
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_check_winner() {
let mut game = Game::default();
game.cells = [
Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
Some(Symbol::X), Some(Symbol::X), None,
None, None, None,
];
assert_eq!(game.check_winner(), Some(Symbol::O));
}
#[test]
fn test_check_over_having_winner() {
let mut game = Game::default();
game.cells = [
Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
Some(Symbol::X), Some(Symbol::X), None,
None, None, None,
];
game.check_over();
assert_eq!(game.is_over, true);
assert_eq!(game.winner, Some(Symbol::O));
}
#[test]
fn test_current_step() {
let mut game = Game::default();
game.cells = [
Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
Some(Symbol::O), None, None,
];
assert_eq!(game.current_step(), 7);
}
}
單元測試的重要性和介紹文章很多,就算你不想要當單元測試高手,你再怎麼討厭測試,你還是要會,相信未來的你會感激過去有寫測試的你。什麼?我講沒說服力,那只好有請大神來說「你就是不寫測試才會沒時間」了。
快速帶過單元測試的3A原則:「Arrange, Act, Assert」,「準備、執行、檢查」,上面的範例不外乎先準備前置資料(Arrange),再執行我們的受測代碼(Act),最後驗證是否符合預期的結果(Assert)。
接著對照上面的rust代碼,複習一下之前說的mod
參照同目錄底下同名rs文件,或是同名資料夾底下的mod.rs
文件,這只是跨檔案引入模組的慣例,而在檔案內直接宣告一個模組mod test
也可以,mod block 包起來的部分就是獨立一個子模組範例如下:
mod test {
use crate::tic_tac_toe::{Game, Symbol}; // 使用絕對路徑,或者是:
use super::{Game, Symbol}; // 使用相對路徑,super 代表上一層
}
這個測試模組有加 #[cfg(test)]
所以實際編譯的成品(production code)不會包含測試的部分,平常也不會編譯這裡面的內容,只有我們在跑test的時候才會跑,所以不用擔心都放在同一個檔案,會不會增加最後執行檔的大小,或讓編譯的時間變慢。
至於最後的assert_eq!
,相信大家看到!
,就知道它是一個巨集(Macro),語義也很直白,就是驗證我們放的兩個東西是不是一致(eq: equal),那怎麼跑呢,一樣下命令列即可:
~/demo-app$ cargo test
~/demo-app$ cargo test -p core # 執行指定專案名稱為 core 的測試
如果有跟著做完前一篇實作的同學應該會發現cargo watch很好用,約莫就是下圖的心情:
快把測試的批次指令加到我們的run批次檔裡面吧:
#!/bin/bash
echo Run Options:
echo 1: [core] demo play in console
echo 2: [core] unit test # 加這行
read VAR
if [[ $VAR -eq 1 ]]
then
cargo watch -q -c -w ./core -x 'run -p core --bin play'
elif [[ $VAR -eq 2 ]]
then
cargo watch -q -c -w ./core -x 'test -p core' # 和加這行
fi
Write-Host "輸入以下選項:"
Write-Host "1) [core]: 在命令列試玩井字遊戲"
Write-Host "2) [core]: 跑單元測試" # 加這行
$opt = Read-Host ":"
if ($opt -eq 1) {
cargo watch -c -q -w ./core/src -x 'run -p core --bin play'
} elseif ($opt -eq 2) {
cargo watch -q -c -w ./core -x 'test -p core' # 和這行
}
實際運行起來結果如下:
耶~都綠燈好開心,不過為什麼出現三排test result,下面還有 Doc-tests又是指什麼,稍後我們會提到。
剛剛的單元測試是隨附在每個檔案裡的,就是小單元,只是測單個function或method對不對,如果要整個串起來,跨度大一點,就是屬於整合測試,rust裡的整合測試,會獨立放在另一個資料夾,通常叫會取名tests
,雖然目前沒什麼好整合的,但我還是為了演示講一下,畢竟開發人員沒看到code就是沒fu(?,副駕駛該你上場了
檔案資料夾結構供參考:
~/demo-app$ tree core/
core/
├── Cargo.toml
├── src
│ ├── lib.rs
│ ├── play.rs
│ └── tic_tac_toe.rs
└── tests
└── tic_tac_toe_integration_test.rs
整合測試這邊用兩個案例,就是依序跑完整個遊戲,其間驗證每一步驟是不是都有該有應該要有的狀態,在先前單元測試裡,所參照的遊戲模組,可以訪問私有的屬性,但在整合測試就不行,所以整合測試可以模擬別的模組在訪問(公開的介面)時,是不是可以順利完成相對應的功能:
// core/tests/tic_tac_toe_integration_test.rs
use core::tic_tac_toe::{Game, Symbol};
#[test]
fn test_game1_for_x_win() { // 測試X贏的情境
let mut game = Game::default();
game.play(3).unwrap(); // O
game.play(1).unwrap(); // X
game.play(2).unwrap(); // O
let err = game.play(1); // 下到非空的格子
assert_eq!(err.is_err(), true); // 應該會回傳錯誤
game.play(4).unwrap(); // X
game.play(5).unwrap(); // O
assert_eq!(game.is_over, false); // 確定分出勝負前的狀態
assert_eq!(game.winner, None); // 棋局尚未結束,且尚無贏家
game.play(7).unwrap(); // X -> 連成一線 1, 4, 7
assert_eq!(game.is_over, true); // 棋局狀態為結束
assert_eq!(game.winner, Some(Symbol::X)); // 棋局贏家為 X
let err = game.play(6);
assert_eq!(err.is_err(), true);
}
#[test]
fn test_game2_for_draw() { // 模擬平手情境
let mut game = Game::default();
game.play(1).unwrap(); // O
game.play(4).unwrap(); // X
game.play(2).unwrap(); // O
game.play(5).unwrap(); // X
game.play(6).unwrap(); // O
game.play(3).unwrap(); // X
game.play(7).unwrap(); // O
game.play(8).unwrap(); // X
assert_eq!(game.is_over, false);
game.play(9).unwrap(); // O
assert_eq!(game.is_over, true); // 棋局已結束
assert_eq!(game.winner, None); // 但無玩家勝出
}
完成上面的整合測試檔,這時如果終端還開著,會自動刷新,並且會出現整合測試檔的測試成功訊息:
我們來試一下剛剛講到訪問私有屬性的部分,如果這時候我們回去把Game的cells的pub拿掉:
pub struct Game {
+ cells: [Option<Symbol>; 9],
- pub cells: [Option<Symbol>; 9],
可以發現單元測試依然成功
但如果把下面這段單元測試代碼,放到整合測試的檔案去跑的話:
// core/tests/tic_tac_toe_integration_tests
#[test]
fn test_access_game_cell() {
let mut game = Game::default();
game.cells = [
Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
Some(Symbol::X), Some(Symbol::X), None,
None, None, None,
];
}
這時候就會報錯:
所以證實了剛剛所講的,整合測試檔會把我們實際程式當作另一個模組來訪問,私有部分就訪問不到。
這其實是來自rust的可見與私有特性的設定,私有的欄位或方法,只有在該模組或該模組底下的模組才可見,所以剛剛我們單元測試建立的mod是在該檔案內,等於子模組的概念,所以自然可見該檔的內容,這也方便了單元測試的調試。
還沒講剛剛看到的Doc-tests,那是什麼,其實就是他所講的doc tests(欸,我還是講中文吧),就是文件加測試(有講跟沒講一樣欸),測試剛剛帶大家跑過了,那文件呢,我們先講一下rust的文件怎麼寫,身為一個程式設計師,會寫Markdown也是很自然的一件事,所以rust讓我們直接在程式碼內嵌入markdown文件,對的,用法如下:
/// 與電腦對戰,`num`為玩家下的格號,1~9分別代表
/// [九宮格](https://zh.wikipedia.org/zh-tw/%E4%B9%9D%E5%AE%AE%E6%A0%BC)的位置,
/// 玩家下完後,電腦會隨機選擇一個空格劃記,直到遊戲結束,
/// 若玩家下的格號已被劃記,會回傳錯誤[`Error::AlreadyOccupied`],
/// 若遊戲已結束,會回傳錯誤[`Error::GameOver`],
/// 若一切正常,回傳Ok(())。
pub fn play_with_counter(&mut self, num: usize) -> Result<(), Error> {
只要在該struct
, enum
, field
, variant
, fn
上面加///
編寫的文件,就可以利用rust內建的rustdoc幫我們產生文件,
寫好相關註釋之後(有點多,在此我不逐項列出,好奇的人可以參考專案原始碼),接著我們把 rust 的文件跑起來看看長啥模樣:
~/demo-app$ cargo doc -p core --open # -p 指定要產文件的專案, --open 產完自動開啟
產完會長這樣,這也是我們在查詢別的crates
套件文件,經常會看到類似的畫面,對比一下剛剛的範例,看一下產生出文件中的play_with_counter
,可以看到的九宮格變成跟這邊一樣可以點擊的連結,就像一般MarkDown一樣;而後面兩個連結連到不同的Error
物件,在rust裡用[`__`]
的寫法可以連結到該指定物件的文檔。值得注意的一點是,如果參照到別的crate庫,在引用範圍內也可以連結過去,如下面的None:
另外眼尖的朋友應該有發現到右上角的source
,是不是可以直接開啟對應的原始碼呢:
的確是這樣的,有時候看文件的說明還是不太明白就可以直接看code,聽起來還不錯,但其實rust的文檔還有一項功能,就是把文件和測試結合在一起的doc test(活文件?)。
直接講用法,剛剛提到文件格式既然是markdown,那麼在裡面加code block代碼區塊是很正常的,所以:
/// 取得目前步數,用於判斷現在是輪到哪一方符號。
/// 以下範例,預設第一手`O`,所以第一步後,步數為1,後續依下棋次數遞增:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// assert_eq!(game.current_step(), 0);
/// game.play(1).unwrap();
/// assert_eq!(game.current_step(), 1);
/// game.play(2).unwrap();
/// assert_eq!(game.current_step(), 2);
/// ```
pub fn current_step(&self) -> usize {
重新執行cargo doc
以產生文件,再去刷新文件的頁面,可以看到異動後的結果:
看起來還不錯耶,加了一個代碼區塊範例,讓閱讀的人比較容易理解,畢竟有時候文件說明太抽象,看code最直接。這時回到剛剛一直開在一旁的test console,可以看到:
剛剛測試裡面的Doc-tests 多了一條測試出來,就是我們剛剛寫在文件裡的範例代碼,(強迫你圖文合一,文件要跟代碼一致的概念? 才不會被噴圖文不符),我們再替fn play
加上說明及測試看看,這次有不同情境,所以我們需要分2個程式碼區塊來分別表達:
/// 下棋,`num`為指定劃記的格號,1~9分別代表九宮格的位置,
/// 若指定的格號已被劃記,會回傳錯誤[`Error::AlreadyOccupied`],
/// 若遊戲已結束,會回傳錯誤[`Error::GameOver`],
/// 若無報錯,會將格號劃記為當前劃記符號,並檢查遊戲是否結束,
/// 若一切正常,回傳`Ok(())`。
/// 範例,以下在第1格下棋後,預設第一手`O`,所以第1格(陣列索引第0項)會畫上`O`:
/// ```
/// use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// ```
/// 如果我們再下第二手,預設第二手`X`,所以下例第8格(陣列索引第7項)會畫上`X`:
/// ```
/// use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// game.play(8).unwrap();
/// assert_eq!(game.cells[7], Some(Symbol::X));
/// ```
pub fn play(&mut self, num: usize) -> Result<(), Error> {
測試沒有意外地通過了:
文檔也順利的產出:
因為每個代碼區塊都是獨立運行的測試,所以我們要一直重覆寫初始化的部分,如use
, let .. = ..
等等,這樣對於整個文件的閱讀來說有點小干擾,那麼怎辬呢,解法是在code block裡加#
,使其在markdown文檔裡失效,但測試程式依然會跑:
/// 範例,以下在第1格下棋後,預設第一手`O`,所以第1格(陣列索引第0項)會畫上`O`:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// let mut game = Game::default();
/// game.play(1).unwrap();
/// assert_eq!(game.cells[0], Some(Symbol::O));
/// ```
/// 如果我們再下第二手,預設第二手`X`,所以下例第8格(陣列索引第7項)會畫上`X`:
/// ```
/// # use core::tic_tac_toe::{Game, Symbol};
/// # let mut game = Game::default();
/// # game.play(1).unwrap();
/// # assert_eq!(game.cells[0], Some(Symbol::O));
/// game.play(8).unwrap();
/// assert_eq!(game.cells[7], Some(Symbol::X));
/// ```
如此我們的文檔會更清晰,同時測試代碼也可以順利運行。附帶一提,如果有人覺得測試跑起來太長太多的話,可以叫它安靜(quiet)一點:
~/demo-app$ cargo test -p core -q # 加上 q(uiet) 參數
結果如下:
就只會依檔案列出每個檔案執行的結果,成功的案例就不會再逐條細項列出,只有測試失敗才會提示,如下:
一些有OO物件導向經驗的的朋友想必從上例發現一個問題,就是我們的資料結構都是 pub
,似乎並沒有封裝啊。好吧,如果有人堅持要封裝的話,rust的作法其實很簡單,就是在該結構體下實作同名fn
,在把結構體裡的pub拿掉即可,這樣變成getter(或get property)了。
impl Game {
pub fn cells(&self) -> [Option<Symbol>; 9] { self.cells }
pub fn is_over(&self) -> bool { self.is_over }
pub fn winner(&self) -> Option<Symbol> { self.winner }
pub fn symbols(&self) -> [Symbol; 2] { self.symbols }
}
使用上也很簡單,原本呼叫欄位,加一個小括號()就可以呼叫了,範例如下:
let game = Game::default();
let cells = game.cells; // field 欄位
let cells = game.cells(); // fn (方法)
相信大家已經開始對rust的語法越來越熟悉了,也大略知道rust文件生態,可以曉得怎麼去看別人的文件,應該可以自己開始實作一些簡單的功能(?)了,下一篇我們來實作後端的接口。另外這邊就先省略不實作對戰時,怎麼樣使電腦聰明下棋的演算法了,有興趣的朋友可以試著自己挑戰一下。