iT邦幫忙

2023 iThome 鐵人賽

DAY 4
0
Software Development

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

04 今晚,我想來點... rust 入門語法

  • 分享至 

  • xImage
  •  

井字遊戲

是說,要選什麼範例來demo想了快一週 XDDD,太簡單怕大家沒感覺,太難我也寫不出來,所以想一想還是用這個簡單的小遊戲當作試金石。一來大家都玩過(?),二來它有一些簡單的邏輯,也需要處理一些畫面的呈現,現在只要google打「井字遊戲」搜尋,就可以玩了。

google tic tac toe

透過實作這個小遊戲,帶大家認識一下rust的基本的語法的後端,以及svelte基本的畫面的前端。

開發前準備工作

為了開發效率,我們先 輸入一些作弊碼 做好相關設定,依下列步驟:

  • core/Cargo.toml裡加上這段
    [[bin]]
    name = "play"
    path = "src/play.rs"
    
  • 新增檔案core/src/play.rs
    fn main() {
        let sum = core::add(1, 2);
        println!("sum: {}", sum);
    }
    

說明:因為當初我們核心層core設定專案類別為lib,無法單獨執行,所以只好 偷吃步地 加一個bin的設定檔在Cargo.toml裡,指向一個有寫fn main的檔案即可把它當執行檔來運行,加完後我們可以透過cargo run -p core --bin play來執行,這樣在沒有UI的情境下,我們可以使用console畫面快速調試。(如果你偏好TDD或想練習TDD也可以自己試著用TDD進行)

cargo run -p [專案名稱] --bin [可執行檔名稱]

直接run有一個小問題,就是每次我們改完程式要再去console把程式中斷,就要再手動執行一次了,雖然在console直接按Ctrl+C(中斷)、(上一個指令)、enter(執行)也滿快的,但 我就是懶 這樣要一直切換沒有效率,這時候就是 cargo watch上場的時機了,還沒安裝的同學快回上一章安裝。兄弟們面對疾風吧!

使用 cargo watch 取代 cargo run

~/demo-app$ cargo watch -c -q -w ./core/src -x 'run -p core --bin play'

要監看多個目錄只要重覆下-w即可,如:-w ./core -w ./web,其他參數可自行查閱

上面的指令等於自動監看./core/src資料夾,有watch到檔案異動,就重新執行下面指令:

~/demo-app$  cargo run -p core --bin play

我們來看一下效果,看起來還不錯:

cargo watch example

但還有一個問題,怎麼問題那麼多,難怪說開發流程是bug製造過程,就是指令很長記不住怎麼辦?很簡單,多打幾次就可以了,順便練練英打(誤)。我們來寫個批次檔,自動幫我們省略打字的時間吧。

windows 腳本檔案設定

使用PowerShell Script 建立一個run.ps1檔案:

Write-Host "輸入以下選項:"
Write-Host "1) [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) {
    # 語法先寫著保留未來擴充使用
}

第一次執行先允許電腦可以在windows上執行

Set-ExecutionPolicy AllSigned # 或 Bypass 如果你要相信所有檔案

之後要執行只要打下面指令就可以(按r, tab也可以(?))

PS D:\demo-app> ./run.ps1

mac / linux 批次腳本設定

#!/bin/bash
# Ask the user for their name
echo Run Options:
echo 1: [core] demo play
read VAR

if [[ $VAR -eq 1 ]]
then
  cargo watch -q -c -w ./core  -x 'run -p core --bin play'
  elif [[ $VAR -eq 2 ]]
  then
  # todo: add later
  echo "todo"
fi

要先把檔案設定成可執行檔:

chmod +x ./run.sh

之後一樣執行該檔案即可,(., /, r, tab)

~/demo-app$ ./run.sh

開始寫 rust 吧

終於要開始寫code了,我們先新增一個rust檔案吧:

直接在 core/src/lib.rs 內容加上 pub mod tic_tac_toe;,就可以再依IDE提示加入tic_tac_toe.rs檔案。

這裡的mod在rust裡代表模組的概念,有點像C#裡的namespace但又不只,慣例是 mod a 會去找同層目錄的 a.rs 檔案 或 同層目錄底下 a 資料夾裡的mod.rs檔案,不熟的多用幾次就習慣了。

rust mod

建立好檔案後,依照 直覺胡亂寫一通 秉持著先求有再求好的精神,使用小步向前的方式開發,先設定兩個玩家,在一場遊戲中有9個格子,每個格子可能是空白、XO,依照這個邏輯,我們快速寫出以下代碼:

// core/src/tic_tac_toe.rs
pub enum Player {
    A,
    B,
}

pub enum Cell {
    Empty,
    O,
    X,
}

pub struct Game {
    pub cells: [Cell; 9], // 元素為Cell,大小為9的陣列
}

注意:開發的時候,會另外開一個視窗一直跑著,就是我們剛剛示範的cargo watch,我們修改代碼後要隨時看編譯器提供的訊息進行調試,所以雙螢幕或4K大螢幕會對開發非常非常有幫助。

這時候應該跑起來長相是這樣:
cargo run play

雖然執行的結果仍然是上一節demo的結果,但我們可以發現多了一些warning的訊息,而且是關於我們剛剛新增的代碼,這邊rust compiler給的訊息,請記得以後要仔細看rust的訊息,非常有幫助,反正之後會強迫你不得不看它,從現在開始請把它當作我們的好朋友。

這裡的enum 跟各語言的enum長的差不多(但其實是開掛的威力加強版),目前先不過多著墨,struct是放一包資料結構的結構體,pub是公開給非當前mod裡的程式使用,我們的Game結構體裡有一個欄位cells,存放著一個陣列,大小為9個元素,元素類別為enum Cell。也就是說 實際的資料可能長這樣: [Empty, X, O, ...][Empty, Empty, ...]

參考資料:Rust 資料型別Rust 結構體

學習rust的時候,建議直接拋棄掉之前學的物件導向,雖然也可以用new的方式來建構新物件,不過還是先試著用比較rust的方式(?)來進行實作,我們先把 play.rs 改成下面這樣:

// core/src/play.rs
use core::tic_tac_toe::{Game, Cell};

fn main() {
    let game = Game {
        cells: [Cell::Empty; 9]
    };
}
  • rust的變數指派是用 let 關鍵字
  • Rust裡要指定enum的特定變體用法是 Enum::Variant 如:上面的 Cell::Empty

上面的 use 是為了參照其他mod裡的代碼,當要使用其他modstruct, enum, fn等代碼(前提是他們定義成公開pub),則在最上面寫use可以簡化我們的代碼,如果不用use會等價下面代碼,但你可能要注意一下自己的人身安全,喔不是,我們身為一個專業的程式設計師,請儘量保持我們的代碼乾淨易讀,下面示例只是幫助大家了解 use 作用為何。

fn main() {
    let game = core::tic_tac_toe::Game {
        cells: [core::tic_tac_toe::Cell::Empty; 9]
    };
}

這邊的物件初始化方式跟JavaScript看起來好像唷,但編譯器開始抱怨了:

private module reference

如同剛剛說的,我們的mod是private,所以無法被外部引用,我們直接修改:

@@ core/src/lib.rs @@
+pub mod tic_tac_toe;
-mod tic_tac_toe;

改好後又有另外一個錯誤訊息:

rust error message

其實編譯器給的說明很完整了,只是大家還不熟rust的trait是什麼,它是一個類似interface介面的東西,先這樣理解,它說我們的Cell沒有實現Copy這個trait,所以無法複製這個元素到陣列的每個空間裡,這時候一個解法是:

let game = Game {
    // cells: [Cell::Empty; 9]
    cells: [
        Cell::Empty, Cell::Empty, Cell::Empty,
        Cell::Empty, Cell::Empty, Cell::Empty,
        Cell::Empty, Cell::Empty, Cell::Empty,
    ]
};

Rust也可以像 JavaScript 或 TypeScript 或 C# 一樣使用 trailing comma

顯然地,上面的解法好像很瞎,萬一我陣列要改成100個元素不就搞笑了,我們先就字面上去理解,原本的寫法,Rust要把Cell::Empty這個東西複製9次到陣列裡,所以我們要賦予Cell::Empty複製(Copy)的方法。好,那要怎麼加呢,可以透過Derive這個方式,來替rust裡的物件裝備上武器:

#[derive(Copy)]     // derive是擴充的意思,這邊撩充Copy這個trait
pub enum Cell {     // 其實是背後有人幫我們寫好code,套用Copy自動完成
    /// 略
}

還是有錯,不過這一次編譯器好像突然變聰明了,直接告訴我們要怎麼解了,我們此時只要使用複製貼上大法就好了:

need clone trait

+ #[derive(Copy, Clone)]
- #[derive(Copy)]
pub enum Cell {

在derive裡補上Clone就好了,有關Copy 和 Clone 的差異可以看Stackflow官方說明,新手的話先都當成複製的概念,有點程度的可以先簡單把Copy當成 Call By Value,Clone想像成 Call By Ref,至於原理可以看Copy trait的說明。

總之,目前編譯正常,但要怎麼知道物件有沒有正確建立,這裡有沒有console.log可以用呢?有唷,我們在rust裡滿常用println!()這個巨集(macro),用法如下:

println!("hello");         // hello
let x = 5;
println!("hello {}", x);   // hello 5
// or
println!("hello {x}");     // hello 5

巨集的概念會佔用一些篇幅,我放在後面的補充資料再行說明

我們把在main加上

println!("{}", game);

game cannot be formatted

這邊說需要實作 std::fmt::Display這個 trait,我們等等再實作,先說明一下其中note第1點,有提到可以使用{:?}{:#?},先試試看這是什麼

+println!("{:?}", game);
-println!("{}", game);

game cannot be formatted for Debug

改加一個:?後,rust提示要印出的內容是 Debug 這個 trait 的實作,我們沒有實作,繼續在derive裡擴充rust內建巨集:

+#[derive(Copy, Clone, Debug)]
-#[derive(Copy, Clone)]
 pub enum Cell {
 ...略
+#[derive(Debug)]
 pub struct Game {

記得我們自己定的物件(struct或Cell)都要在擴充(derive)掛上Debug,就可以看到結果,印出來的資料很像JSON的長相:

print out Game struct

使用{:#?}的結果如下,就只是上面的結果加以縮排格式化:

print out game struct formatted

有了Debug就可以開始Debug了(?),我們把main改回println!("{}", game);,再回tic_tac_toe.rs裡加上GameCellDisplay功能,這時候要手動製作了:

@@ core/src/play.rs @@
+println!("{}", game);
-println!("{:?}", game);
// core/src/tic_tac_toe.rs
use std::fmt::{Display, Formatter};   // 引用下面使用的 Display 和 Formatter

impl Display for Game {    // 替 Game 實作 Display 這個 trait
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "
        {} | {} | {}
        ---------
        {} | {} | {}
        ---------
        {} | {} | {}
        ",
            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],
        )
    }
}

impl Display for Cell {    // 替 Cell 實現 Display 這個 trait
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Cell::Empty => write!(f, " "),
            Cell::O => write!(f, "O"),
            Cell::X => write!(f, "X"),
        }
    }
}
  • self代表自己(?),就像 java/C#裡的this,或ruby/python裡的self
  • &self前面的&符號代表借用,而mut代表可修改,所以&mut是可變借用,我們在這裡先當成是固定寫法就好, 反正它寫在trait的合約中,我們用IDE的功能,只要點implement members就會跑出來了,不會寫也沒關係 ,因為這個涉及所有權生命週期這兩個rust裡非常重要的概念,需要特別說明的篇幅,這裡暫時不影響上下文理解,我們先跳過留待未來再行說明。
  • impl <trait> for <struct>:替結構體實作某trait
  • impl <traint> for <enum>:替enum實作某trait

是的,有發現enum的強大了嗎,enum竟然也可以實作方法,所以enum不像其他語言只是單純的枚舉而已。

trait用法有點像C#的 class MyClass : IMyInterface或是Java的 class MyClass implements SomeInterface,但威力大很多,請慢慢感受

這樣一來我們的終端介面好看多了,等等也比較好測試(O)

tic tac toe console output

再來是Game的初始化方式,用JS物件初始化的寫法有點累,因為我們並非總是需要逐項填入,有時候用預設的(建構方式)就好了,我們利用剛學到的trait用法,幫Game加上rust裡建構預設值的Default trait,使用後如下:

// core/src/tic_tac_toe.rs
impl Default for Game {        // 替Game實作預設(無參數建構式)
    fn default() -> Self {     // 這裡的大寫Self指的是Self的Type,就是指Game
        Self {                 // 所以這邊的兩個Self換成Game也是一樣的結果
            cells: [Cell::Empty; 9],
        }
    }
}
@@ core/src/play.rs @@
+    let gmae = Game::default();
-    let game = Game {
-        cells: [Cell::Empty; 9]
-    };

有點像是物件導向語言裡的預設建構子 new()的感覺

再來我們把main改一下,我們需要取得用戶(User)的輸入,在調試先使用終端,因為我們的功能都會使用參數,所以到時候前端也可以輸入一樣的參數給後端:

fn main() {
    let mut game = Game::default();
    println!("{}", game);
    loop {                               // 開始迴圈
        println!("請輸入數字 1 and 9");   // 提示用戶輸入 
        let num = 1;                     // 取得用戶輸入
        game.play(num);                  // 處理遊戲邏輯
        if game.is_over { break; }       // 若遊戲結束,離開迴圈
    }
    println!("{}", game);                // 印出結果
}
  • loop是 rust裡的迴圈,內部可使用continue跳入下一輪迴圈,或使用break離開迴圈。
  • let mut 是rust裡的宣告可變的變數,單單let指派(宣告)是預設不可變的,(不可變是FP裡面很重要的概念)。
  • if expression { expression } 是rust的if用法,其中Expression表達式(?)可以先初步當成可以計算出結果當成回傳值的句子,比如let i = 2;只是指令派值給i,不能算出結果回傳什麼,所以不是expression,再比如2+3可以算出結果5,所以是expression。

所以在js裡我們常常會看到回傳undefined,就表示我們輸入的不是表達示:
console.log

為了先使修改後的main能動,我們補上Gameis_over欄位和play方法:

@@ core/src/tic_tac_toe.rs @@
 pub struct Game {
     pub cells: [Cell; 9],
+    pub is_over: bool,
 }
 
 impl Default for Game {
    fn default() -> Self {
        Self {
            cells: [Cell::Empty; 9],
+           is_over: false,
        }
// core/src/tic_tac_toe.rs
impl Game {                                // 替Game加上方法
    pub fn play(&mut self, num: usize) {   // 這裡的方法參數為self與無號整數
        self.cells[num] = Cell::O;         
        self.is_over = true;
    }
}
  • impl <StructName>impl <EnumName>可替該東西加上function或方法
  • &mut 是可變引用,因為我們需要更改結構體的值,在此引用self只能是全部可變或全部不可變,不允許僅部分欄位可變的情況。
  • 可以對比先前看到實體呼叫play方法,並未傳遞第一個參數self,所以我們在fn第一個參數放self代表實例方法(Instance method),就像平常寫OO語言一樣。

跑起來結果如下:

play game trial

剛剛我們實作play時self參數設定為可變,所以呼叫者也必需宣告成可變的,rust在這方面比較嚴格,我們調整一下:

@@ core/src/play.rs @@
+let mut game = Game::default();
-let game = Game::default();

game demo

看起來運行正常,我們下一篇再來補上正確的邏輯實作。

補充資料:巨集

巨集不同於function,rust的巨集就是幫你把 code 展開,簡單說巨集就是幫你把code展開,不用一直重覆寫code,比如前面的例子中,在IDE裡按住Ctrl再用滑鼠點println!,會跑出下圖:

rust println macro

所以你寫 println!() 沒放參數rust編譯器就是幫你把程式碼翻譯成

$crate::print!("\n")

println!("Hello") 就會幫你把碼式碼翻譯成

$crate::io::_print($crate::format_args_nl!("Hello"));

這裡的 format_args_nl! 又是另一個巨集,就再展開

  • rust的巨集使用!結尾。

所以println!() 這個函數在最後的執行檔裡是不存在的 (There is no spoon?)。在rust裡可能偶爾會聽到零成本抽象(zero cost abstraction),意思是我寫的程式碼是抽象的,但沒有成本(?)

因為翻成更底層的代碼,所以抽象出來的代碼在執行的時候,不會需要額外的資源開銷。什麼意思,好像不是很好懂,沒關係,我們對照一下C#的Console.WriteLine,就可以感受一下抽象的成本開銷:

以C#來說 Console.WriteLine("Hello"); 往底層看是放System裡的static方法,該方法會一直往下呼叫各個方法,:

public static class Console
{
    [MethodImplAttribute(MethodImplOptions.NoInlining)]
    public static void WriteLine(string? value)
    {
        Out.WriteLine(value); // 往裡面呼叫
    }
public virtual void WriteLine(string? value)
{
    if (value != null)      // runtime判斷
    {
        Write(value);       // 往裡面呼叫
    }
    Write(CoreNewLineStr);  // 往裡面呼叫
}
public virtual void Write(string? value)
{
    if (value != null)                // runtime判斷
    {
        Write(value.ToCharArray());   // 往裡面呼叫
    }
}

public virtual void Write(char[]? buffer)
{
    if (buffer != null)                    // runtime判斷
    {
        Write(buffer, 0, buffer.Length);   // 往裡面呼叫
    }
}
public virtual void Write(char[] buffer, int index, int count)
{
    ArgumentNullException.ThrowIfNull(buffer);

    if (index < 0)    // runtime判斷
    {
        throw new ArgumentOutOfRangeException(nameof(index), SR.ArgumentOutOfRange_NeedNonNegNum);
    }
    if (count < 0)    // runtime判斷
    {
        throw new ArgumentOutOfRangeException(nameof(count), SR.ArgumentOutOfRange_NeedNonNegNum);
    }
    if (buffer.Length - index < count)   // runtime判斷
    {
        throw new ArgumentException(SR.Argument_InvalidOffLen);
    }

    for (int i = 0; i < count; i++) Write(buffer[index + i]); // 往裡面呼叫 i 次
}

光一個輸出字串到終端,C#在執行時就要呼叫這麼多function,function編譯完本身也會需要佔記憶體空間放置,所以C#之類的OO語言因為抽象而產生了系統執行時額外的成本。

參考資料

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


上一篇
03 rust 跑起來!,建立第一支 tauri 程式
下一篇
05 利用 rust 完成井字遊戲... 啊不就只是個小遊戲?
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言