iT邦幫忙

2023 iThome 鐵人賽

DAY 8
0

歹戲拖棚,不囉嗦,希望這系列不會變成歹戲 XDD,所以我們快點來趕進度,直接開始:

先在run.ps1run.sh裡加上以下這段,方便我們接下來的開發,記得加完後直接跑起來:

cargo watch -q -c -w ./service -w ./core -x 'test -p service'

服務層測試結果-空

先實作 CRUD的 C

我們先來做 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方法

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未實作{:?}顯示方式
建議把Error加上Debug derive
物件不能使用 == 比較

第一個錯誤指出我們的Error物件沒有實現Debug trait,所以不能用{:?}的格式去顯示輸出,並且也直接告訴我們怎麼解決,就只要加上#[derive(Debug)]就好,我們直接複製貼上。

而第二的錯誤說gameGame::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);
}

不過為什麼參數&x會多一個&,這個是所有權的借用,當遇到需要引用但又不想把所有權交出去的時候,可以加&的符號來表示我只是借用一下,並沒有要取得所有權,詳見補充資料。秉著要有好奇的精神才學的到東西,我們試著把&拿掉看看會怎樣:

把&amp;拿掉試試

這邊說&Option<Symbol>不能和Option<_>互相比較,一個是加&號的借用類別,一個是原生Option類別,如果我不想用&可以嗎,這裡有另一種使用方式,就是用*解引用,取得該資料的而不是參照,(&借用其實就是參照的概念),更改如下:

let is_empty = game.cells.iter().all(|x| *x == None);

完成Create的測試結果

喔耶,完成了四分之一了。

來實作 CRUD 的 R

副駕很厲害的幫忙寫好了下面代碼,我們來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一樣有&,我們拿掉&看看會怎樣:

拿掉get(&amp;id)參數的&amp;後提示的訊息

編譯器請我們考慮(明明是強迫)在這裡使用借用(Borrowing),叫我們加上&

這邊我們先停下來看一下get的語法,可以把滑鼠停在get上面,或按住ctrl再用滑鼠點擊跳至原始碼的文件說明,如下圖所示,fn指定的參數類型是借用&Q,所以我們要傳入一個借用的東西給它。

rust中HaspMap的 get說明

上面的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的鐵道開發法快去看,這裡的OptionResult就是鐵道的相接)。

但其實這裡還可以再短一些些,我們先偷偷用cargo clippy跑一下看看:

執行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物件

這裡我們造的Error不能使用==比較,不過這情況我們遇過了,照它提供的解法了,直接補PartialEq在derive就可以

+#[derive(Debug, PartialEq)]
-#[derive(Debug)]
 pub enum Error {

順利通過測試

順利通過測試,可以邁進下一關了。

再接著 CRUD 的 U

接下來是遊戲關鍵的部分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

IDE針對game類別的提示

ok_or是剛剛學的大家印象還在,就是回傳Ok(結果)Err(錯誤),可是為什麼這一行不是回傳值也可以運行?下面還有其他代碼吔,到這裡如果還不熟的同學可以上去比較一下,在前一節R裡面用的ok_or是使用在最後一行的Expression,所以視為該方法的return回傳值,沒有問題;而這裡的ok_or一樣會回傳Result<R,E>,只不過在這邊的 Result不是傳出去給fn呼叫者,而是指派給game,我把最後的?拿掉,讓大家看一下型別:

觀測game的類別:ok_or回傳值

的確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()),
-})?;

提示未實作From trait無法轉換Error

編譯器告訴我們不能把這個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有 frominto,而我們實作From,Rust會自動幫我們補上into,反之不然,所以我們通常會from來進行實作。

也就是說 impl From <Source> for Target 實作後即可依情境使用frominto來實現轉換。

let target = Target::from(source)        // 目標 from 來源
let target : Target = source.into();     // 來源 into 目標

似乎編譯成功沒有報錯了,我們等等再寫測試來驗證一下,這裡說明一下,Trait的實作,必需至少有一邊是我們自己寫的,比如上面impl .. for ErrorError是我們寫的,所以才可以實作,我們也可以自己寫一個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的部分,喝口咖啡再繼續。

最後是 CRUD 的 D

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:

  • max
  • max_by
  • max_by_key

這三個分別差在哪呢,我們看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

每完成一個段落,最好記得Clippy一下,看看自己哪裡寫髒了,免得越後面越不好改:

執行 clippy結果

第1個new沒用到,可是我們剛剛測試不是有用嗎?還記得之前有講過一般build不會建置 #[cfg(test)] 裡的區塊,只有跑測試時才會嗎,我們之後在web專案會呼叫,所以這裡可以先忽略。

第2個和第3個警告似乎是同一件事:我們寫的是service,而new會讓大家以為是建構式,所以它才說new通常回傳的是自己的類別,我們趕緊調整一下,記得用IDE的重構工具,不要用搜尋/取代,(別笑,我看有很多人都是全部搜尋後一個個人工改,這個人工可是一點都不智慧 XDD)。

VS Code按右鍵的 Rename Symbol

VS Code的重命名方式

Jetbrain按右鍵RefactorRename

Jetbrain的重命名方式

補充資料

所有權借用 Borrow &

直接用例子說明,我們昨天有提到所有權移交(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的值,那如果借用一個已經死掉的變數會怎樣:

借用被move值的錯誤訊息

這裡編譯器會幫忙檢查每個變數的作用域(生命周期),這裡就是rust所強調它安全的其中一個方式。如果可以避免出現懸垂指針(dangling pointer),想像剛剛的收貨物的例子,有人幫你把貨領走了,但放信箱的牌子沒有拿掉,你就拿著這個牌子找啊找啊找,(挖啊挖啊挖),就會造成非預期的錯誤。

可是我們從剛開始到現在寫過很多次 println!,也沒有用借用的方式啊,怎麼所有權沒有被移轉呢?大家還有印象之前講到!結尾的是巨集嗎,巨集充其量只是rust幫我們寫出重用的code,所以可以理解為我們寫了s,但rust透過巨集翻譯後的最終代碼是&s,所以所有權並沒有因此被移轉。

原始碼

程式原始碼同步放置於 https://github.com/kenstt/demo-app


上一篇
07 熟悉的 rest api 最對味,feat. rust
下一篇
09 我的rust環境我決定 Example, Logger, Env
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言