歹戲拖棚,不囉嗦,希望這系列不會變成歹戲 XDD,所以我們快點來趕進度,直接開始:
先在run.ps1
或run.sh
裡加上以下這段,方便我們接下來的開發,記得加完後直接跑起來:
cargo watch -q -c -w ./service -w ./core -x 'test -p service'
我們先來做 Create,試試看,依前篇講到的Mutex,直接參考用官方的範例,模仿著它寫出來如下:
fn new(&self) -> Result<(usize, Game), Error> {
let mut games = self.games
.lock() // LockResult<MutexGuard<HashMap<…>>>
.unwrap(); // MutexGuard<HashMap<…>>
let id = games.len() + 1; // 遞增序號
let game = Game::default();
games.insert(id, game); // HashMap新增 key/value方式
Ok((id, game))
}
其中lock()
方法會取得mutex的互斥鎖,等這裡拿到鎖的owner變數games
被丟棄時,佔用的鎖就會自動解開,所以我們在這不用手動解鎖;我們在fn
裡新建一個遊戲,並模擬存入資料庫後回傳,看到編譯器報錯如下:
還好我們昨天學過所有權了,現在可以很放心(?)地直接複製(clone)一份game,避免所有權被移交:
+games.insert(id, game.clone());
-games.insert(id, game));
改好後不出意外地又出了意外,編譯器說:
game沒有clone這個方法可以呼叫,那我們要自己定義嗎,其實可以自己寫,不過一般情況下我們使用內建的macro幫我們實作就好了,我們在第4篇有做過類似的事:
+#[derive(Debug, Clone)]
-#[derive(Debug)]
pub struct Game {
好了,目前的測試回報通過,只是筆數是0而已(X),想到一句台詞「 手術很成功,可是病人死掉了」。我們接著補上我們的測試吧,以下是副駕貼心幫忙寫的,但好像有一些問題,一起來看看吧:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let service = InMemoryTicTacToeService::new();
let (id, game) = service.new().unwrap();
assert_eq!(id, 1);
assert_eq!(game, Game::default());
}
}
測試視窗的結果:
第一個錯誤指出我們的Error
物件沒有實現Debug
trait,所以不能用{:?}
的格式去顯示輸出,並且也直接告訴我們怎麼解決,就只要加上#[derive(Debug)]
就好,我們直接複製貼上。
而第二的錯誤說game
和Game::default()
不能直接用==
相互比較,寫過物件語言的應該都曉得因為物件是參考型別的關係,我們直接改寫用別的測法,直接取用裡面的狀態,來驗證程式執行結果,修改後如下:
#[test]
fn test_new() {
let service = InMemoryTicTacToeService::new();
let (id, game) = service.new().unwrap(); // unwrap取得Result內容
assert_eq!(id, 1);
assert_eq!(game.is_over, false);
let is_empty = game.cells.iter().all(|&x| x == None); // 驗證每一格都是空的
assert_eq!(is_empty, true);
}
let (a, b) = tuple
是tuple的解構式,類似C# Tuple解構或JavaScript物件解構。iter()
是在rust裡取得迭代器,接上all()
裡面看起來像是匿名函式(lambda function)的東西,|x| { x + 1 }
這種型式在rust叫閉包(closure),可以先用js的arrow function (x) => x === None
理解。不過為什麼參數&x
會多一個&
,這個是所有權的借用,當遇到需要引用但又不想把所有權交出去的時候,可以加&
的符號來表示我只是借用一下,並沒有要取得所有權,詳見補充資料。秉著要有好奇的精神才學的到東西,我們試著把&
拿掉看看會怎樣:
這邊說&Option<Symbol>
不能和Option<_>
互相比較,一個是加&
號的借用類別,一個是原生Option類別,如果我不想用&
可以嗎,這裡有另一種使用方式,就是用*
解引用,取得該資料的值而不是參照,(&
借用其實就是參照的概念),更改如下:
let is_empty = game.cells.iter().all(|x| *x == None);
喔耶,完成了四分之一了。
副駕很厲害的幫忙寫好了下面代碼,我們來Review一下:
fn get(&self, id: usize) -> Result<Game, Error> {
let games = self.games.lock().unwrap();
match games.get(&id) {
Some(game) => Ok(game.clone()),
None => Err(Error::NotFound),
}
}
這裡get
裡的參數&id
一樣有&
,我們拿掉&
看看會怎樣:
編譯器請我們考慮(明明是強迫)在這裡使用借用(Borrowing),叫我們加上&
。
這邊我們先停下來看一下get
的語法,可以把滑鼠停在get
上面,或按住ctrl
再用滑鼠點擊跳至原始碼的文件說明,如下圖所示,fn指定的參數類型是借用的&Q
,所以我們要傳入一個借用的東西給它。
上面的match雖然很好用,好像有點長,這裡我們其實還有另一種寫法可以使用:
fn get(&self, id: usize) -> Result<Game, Error> {
let games = self.games.lock().unwrap();
games.get(&id).map(|game| game.clone()).ok_or(Error::NotFound)
}
什麼,用一行取代了match
,到底做了什麼事情,到map(|| ...)
還勉強看的懂就是把game複製一份丟出來,那後面的ok_or
是什麼?應該也可以從字面上推敲出,ok或丟出Error
,我們知道從HashMap
裡取資料可能是Some(game)
或 None
(不知道的利用剛剛的技巧去看一下文件),而get
回傳是 Result
,所以ok_or
就是幫我們簡化剛剛match
的寫法,把Option
的2個結果分別對應到Result
的兩個結果(再提醒一下還沒看Scott的鐵道開發法快去看,這裡的Option
接Result
就是鐵道的相接)。
但其實這裡還可以再短一些些,我們先偷偷用cargo clippy
跑一下看看:
再依提示修改如下,代碼簡潔了不少:
fn get(&self, id: usize) -> Result<Game, Error> {
let games = self.games.lock().unwrap();
games.get(&id).cloned().ok_or(Error::NotFound)
}
果然rust的compiler可以教我們很多東西,係金ㄟ!係金ㄟ!
補測試:
#[test]
fn test_get() { // 測試 Happy Path
let service = InMemoryTicTacToeService::new();
let _ = service.new(); // 新局的變數我們不需要,直接 _ 丟棄
let game = service.get(1).unwrap(); // 透過 get 取得 game
assert_eq!(game.is_over, false); // 簡單驗一下內容
let is_empty = game.cells.iter().all(|x| *x == None);
assert_eq!(is_empty, true);
}
#[test]
fn test_get_none() { // 測試 None 會回傳Error
let service = InMemoryTicTacToeService::new();
let game = service.get(10); // Result<Game, Error>
assert_eq!(game.is_err(), true); // 驗證回傳Error
assert_eq!(game.err(), Some(Error::NotFound)); // 驗證Error類別
}
這裡我們造的Error不能使用==
比較,不過這情況我們遇過了,照它提供的解法了,直接補PartialEq
在derive就可以
+#[derive(Debug, PartialEq)]
-#[derive(Debug)]
pub enum Error {
順利通過測試,可以邁進下一關了。
接下來是遊戲關鍵的部分play
,我們來看一下下面的code:
fn play(&self, id: usize, num: usize) -> Result<Game, Error> {
let mut games = self.games.lock().unwrap();
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
game.play_with_counter(num).map_err(|e| match e {
core::tic_tac_toe::Error::GameOver => Error::GameOver,
core::tic_tac_toe::Error::AlreadyOccupied
=> Error::GameRules("AlreadyOccupied".to_string()),
})?;
Ok(game.clone())
}
這裡出現了一個不同方法的get_mut
,對比之前只有get
不難猜出get_mut
是要取得可變的變數,看一下下圖IDE幫我們探出來的型別,的確是&mut
可變引用的Game
:
ok_or
是剛剛學的大家印象還在,就是回傳Ok(結果)
或Err(錯誤)
,可是為什麼這一行不是回傳值也可以運行?下面還有其他代碼吔,到這裡如果還不熟的同學可以上去比較一下,在前一節R裡面用的ok_or
是使用在最後一行的Expression,所以視為該方法的return
回傳值,沒有問題;而這裡的ok_or
一樣會回傳Result<R,E>
,只不過在這邊的 Result
不是傳出去給fn呼叫者,而是指派給game
,我把最後的?
拿掉,讓大家看一下型別:
的確ok_or
回傳的Type是Result<&mut Game, Error>
,那麼最後的?
怎麼這麼神奇幫我們提取Result
的結果,其實原理版是我們之前常寫的match
,只不過到處都到match
有的冗,所以這裡的?
算是語法糖,大意是 if game == Error { return Error } 否則 unwrap
,所以這裡的?
會幫我們直接回傳Error
,不過有一個小前提,就是這裡產出的Error
要跟fn定義回傳的Error
同類(代碼第3行的Error和第1行的Error必需為同一個類別)。(rust裡常常會看到一堆都叫Error的,要習慣它是菜市場名字 XDDD)
而這個方法裡面用的是我們在core裡定義的Error,而方法回傳的是在service裡定義的Error,此彼非彼。這時可以用map_err
將內部的Error
類轉換成外層要回傳的Error
類。如果在這裡也想用?
的話有沒有機會(我就懶),使用暴力法 直接把第4行改成?
寫法:
+game.play_with_counter(num)?;
-game.play_with_counter(num).map_err(|e| match e {
- core::tic_tac_toe::Error::GameOver => Error::GameOver,
- core::tic_tac_toe::Error::AlreadyOccupied
- => Error::GameRules("AlreadyOccupied".to_string()),
-})?;
編譯器告訴我們不能把這個core裡Error轉成我們同檔案裡定義的Error,因為沒有實作特定trait,那言下之意,是不是我們實作它就可以自動轉了?好像不錯欸,我們來試試看吧,在Error下面實作它提到的trait如下:
#[derive(Debug, PartialEq)]
pub enum Error { // 之前好的寫的,附上對比參考
GameRules(String),
GameOver,
NotFound,
Unknown,
}
impl From<core::tic_tac_toe::Error> for Error { // 實作訊息裡提到的 trait
fn from(e: core::tic_tac_toe::Error) -> Self {
match e { // 把 mapping邏輯寫這
core::tic_tac_toe::Error::GameOver => Error::GameOver,
_ => Error::GameRules(e.to_string()),
}
}
}
這裡值得注意的是,rust中資料轉換的trait有 from 和 into,而我們實作From,Rust會自動幫我們補上into,反之不然,所以我們通常會from來進行實作。
也就是說 impl From <Source> for Target
實作後即可依情境使用from
或into
來實現轉換。
let target = Target::from(source) // 目標 from 來源
let target : Target = source.into(); // 來源 into 目標
似乎編譯成功沒有報錯了,我們等等再寫測試來驗證一下,這裡說明一下,Trait的實作,必需至少有一邊是我們自己寫的,比如上面impl .. for Error
的Error
是我們寫的,所以才可以實作,我們也可以自己寫一個trait套在系統預設的struct或enum上。簡化後我們的代碼如下:
fn play(&self, id: usize, num: usize) -> Result<Game, Error> {
let mut games = self.games.lock().unwrap();
let game = games.get_mut(&id).ok_or(Error::NotFound)?;
game.play_with_counter(num)?;
Ok(game.clone())
}
Rust的代碼應該大家開始越看越適應了(?),接著寫2個測試案例:
#[test]
fn test_play() {
let service = InMemoryTicTacToeService::new();
let _ = service.new(); // 建立一筆id=1的遊戲局
let game = service.play(1, 1).unwrap(); //呼叫 play
assert_eq!(game.cells[0], Some(core::tic_tac_toe::Symbol::O));
let game = service.get(1).unwrap(); // 透過 get 確認修改是否回存
assert_eq!(game.cells[0], Some(core::tic_tac_toe::Symbol::O));
}
這邊雖然模擬資料庫,但是我們程式利用get_mut取得了記憶體裡的物件直接修改,所以沒有存取資料庫需要做序列化(serialize)及反序列化(deserialize)的動作,白話文就是修改直接生效,不用自己組INSERT/UPDATE
字串,我們再測一個例子,因為自動下棋無法預期電腦會下哪一格,所以我只能先選好特定兩個,看哪個空的再行填入:
#[test]
fn test_play_two_round() {
let service = InMemoryTicTacToeService::new();
let _ = service.new();
let game = service.play(1, 1).unwrap();
let game = if game.cells[3] == Some(core::tic_tac_toe::Symbol::X) {
service.play(1, 2).unwrap()
} else {
service.play(1, 3).unwrap()
};
let steps = game.cells.iter().filter(|x| x.is_some()).count();
assert_eq!(steps, 4); // 驗證執行完含電腦總步數是4
}
好了,終於好容易完成了U的部分,喝口咖啡再繼續。
D比似乎比較簡單,讓我們看下去:
fn delete(&self, id: usize) -> Result<(), Error> {
let mut games = self.games.lock().unwrap();
match games.remove(&id) { // remove 會回傳Some(Game)
Some(_) => Ok(()), // Reulst的Ok型別是()
None => Err(Error::NotFound),
} // 注意這裡沒;結尾,所以表示match表達式結果直接回傳
}
這裡Option的Some型別和Result的Ok型別不一樣,所以不能像前例用ok_or
(殘念)(Error的Type一樣,但Ok的Type不一樣)。測試如下:
#[test]
fn test_delete() {
let service = InMemoryTicTacToeService::new();
let _ = service.new(); // 先製造1個才有東西刪除
let result = service.delete(1); // 執行刪除
assert_eq!(result.is_ok(), true); // 驗證執行成功
let game = service.get(1); // 覆驗已無編號1資料
assert_eq!(game.is_err(), true);
assert_eq!(game.err(), Some(Error::NotFound));
}
#[test]
fn test_delete_none() {
let service = InMemoryTicTacToeService::new();
let result = service.delete(10); // 刪除不存在資料
assert_eq!(result.is_err(), true); // 如預期報錯
assert_eq!(result.err(), Some(Error::NotFound));
}
到這裡有經驗的人應該會想到,我們剛剛取號是用len()
,這裡刪除再取,不是有可能會重號,啊啊啊啊,竟然犯這麼笨的錯誤,快點修一下:
// let id = games.len() + 1; 原寫法有問題
let id = if games.iter().count() == 0 {
1 // 不加; 視為 if 的回傳值
} else {
games.iter().max_by_key(|(k, _)| *k).unwrap().0 + 1 // 不加; 視為 if 的回傳值
};
rust裡if是表達式,所以可以給回傳值,如:let x = if a > b { a } else { b }
。
max_by_key
是副駕寫的,裡面的東西乍看之下好像有點奇怪,我們逐一確認一下內容,先看一下 rust 有哪些max相關的function:
這三個分別差在哪呢,我們看rust的說明文件裡的範例,直接對比一下就一目了然了:
let a = [1, 2, 3];
assert_eq!(a.iter().max(), Some(&3));
let a = [-3_i32, 0, 1, 5, -10];
assert_eq!(*a.iter().max_by(|x, y| x.cmp(y)).unwrap(), 5);
let a = [-3_i32, 0, 1, 5, -10];
assert_eq!(*a.iter().max_by_key(|x| x.abs()).unwrap(), -10);
max
: 針對有實作Ord這個trait的東西進行比較,並取最大者。Ord指Order,整數預設有實作這個tarit,所以上例整數陣列可以直接使用。max_by
:透過自定義的fn或閉包來比較大小。max_by_key
:透過自定義的fn或閉包來回傳可以比大小的值。再回來看剛剛的代碼,我們分段看,應該就很好理解了:
games.iter() // 取得迭代器
.max_by_key(|(k, _)| *k) // games的資料是 key value tuple,我們只取key
.unwrap() // 取得Option內的值
.0 // 取得tuple第1個元素(指標0)
+ 1 // 結果再加1
這篇做完CRUD,追加了不少rust的語法以及概念,後面可以開始提提速了(應該吧?)
每完成一個段落,最好記得Clippy一下,看看自己哪裡寫髒了,免得越後面越不好改:
第1個new
沒用到,可是我們剛剛測試不是有用嗎?還記得之前有講過一般build不會建置 #[cfg(test)]
裡的區塊,只有跑測試時才會嗎,我們之後在web專案會呼叫,所以這裡可以先忽略。
第2個和第3個警告似乎是同一件事:我們寫的是service,而new會讓大家以為是建構式,所以它才說new通常回傳的是自己的類別,我們趕緊調整一下,記得用IDE的重構工具,不要用搜尋/取代,(別笑,我看有很多人都是全部搜尋後一個個人工改,這個人工可是一點都不智慧 XDD)。
VS Code按右鍵的 Rename Symbol
:
Jetbrain按右鍵Refactor
→Rename
:
&
直接用例子說明,我們昨天有提到所有權移交(move)時,可採用clone
複製一份的方式處理,而還有另一個方式是使用borrow借用處理,使用的方式就是要借用該變數時,在前面加&
fn main() {
let s: String = String::from("Hello, World!");
let n: usize = length(&s); // 把原本 s.clone() 改為 &s
println!("The length of '{}' is {}.", s, n);
}
fn length(s: &String) -> usize { // 這裡也要跟著改,宣告我們傳入的參數是借用的String
s.len()
}
一開始好像不是很好懂,有朋友會說我一律用clone不就好了(?),我們來看一下clone的說明:「while Clone is always explicit and may or may not be expensive.」,前面explicit是在講Clone一定要明文宣示(就是程式碼要寫出.clone()
不能省略);好,那提面提到可能會或不會比較貴又是啥:點進去看souce code,提到一句話「cloning is often expensive and is not expected to have side effects」,卻又說clone通常比較貴,那麼到底是在貴什麼,以下我分兩個版本說明:
通俗版:住過社區的朋友,一般收信都有自己的信箱,直接拿了就好,但是包裏或其他大件的,就會在信箱放一個牌子,我們還要在拿這個牌子去兌換(簽名)再去放貨品的小倉儲拿。所以像信這類的小東西](int,bool)很便宜直接拿了就走,像包裹之類的大東西(String, Object)要依放在信箱裡的牌子(指標),去尋找放在哪裡。因為信箱位置是固定的(Stack),所以你找都不用找就知道自己家信箱實體位置(不用去數第幾排第幾列);但放貨品的地區(Heap)是大家共用的空間,所以會依當時存放的貨物大小,哪裡有空間就塞哪裡,找起來就比較耗費時間了。
沒住過社區的朋友應該去便利商店取貨過吧,或買過煙,或看過別人買煙(?)。煙架就是stack,位置固定,客戶指定好就直接拿不用數第幾個,但取貨報末三碼,就要看當時店裡的空間(Heap)慢慢的人工找起。
原理版:因為String是放在heap,int之類的基礎型別放stack(或是比較精確一點說,在編譯時已知大小的變數,比如i32我早就知道它占用 4 byte)。stack的空間一啟動程式就會向作業系統OS要,所以每個變數放置位址是已知,而heap的空間,則是使用時才去指派一個新空間,並且會協調OS去尋找可用空間再行配置,所以會會耗損一些時間在找空間。
假設對效能比較要求的情境,那透過借用的方式來使用變數,可節省複製一份的成本,那有人可能又會問如果借了被倒帳怎麼辦(謎之音:那就不要借錢給別人啊),我們看以下例子:
fn main() {
let s: String = String::from("Hello, World!");
let borrow = &s;
println!("borrow is {}, s is {}.", borrow, s);
let newS = s ; // 這行執行後s就死了:或是說被移動了,或是說被drop了
println!("newS is {}.", newS); // 這行沒有問題
println!("s is {}.", s); // 這行會報錯
println!("borrow is {}.", borrow); // 這行也報錯:
}
之前看過好幾次rust警告我們使用到被move的值,那如果借用一個已經死掉的變數會怎樣:
這裡編譯器會幫忙檢查每個變數的作用域(生命周期),這裡就是rust所強調它安全的其中一個方式。如果可以避免出現懸垂指針(dangling pointer),想像剛剛的收貨物的例子,有人幫你把貨領走了,但放信箱的牌子沒有拿掉,你就拿著這個牌子找啊找啊找,(挖啊挖啊挖),就會造成非預期的錯誤。
可是我們從剛開始到現在寫過很多次 println!
,也沒有用借用的方式啊,怎麼所有權沒有被移轉呢?大家還有印象之前講到!
結尾的是巨集嗎,巨集充其量只是rust幫我們寫出重用的code,所以可以理解為我們寫了s
,但rust透過巨集翻譯後的最終代碼是&s
,所以所有權並沒有因此被移轉。
程式原始碼同步放置於 https://github.com/kenstt/demo-app