是說,要選什麼範例來demo想了快一週 XDDD,太簡單怕大家沒感覺,太難我也寫不出來,所以想一想還是用這個簡單的小遊戲當作試金石。一來大家都玩過(?),二來它有一些簡單的邏輯,也需要處理一些畫面的呈現,現在只要google打「井字遊戲」搜尋,就可以玩了。
透過實作這個小遊戲,帶大家認識一下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
我們來看一下效果,看起來還不錯:
但還有一個問題,怎麼問題那麼多,難怪說開發流程是bug製造過程,就是指令很長記不住怎麼辦?很簡單,多打幾次就可以了,順便練練英打(誤)。我們來寫個批次檔,自動幫我們省略打字的時間吧。
使用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
#!/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
終於要開始寫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
檔案,不熟的多用幾次就習慣了。
建立好檔案後,依照 直覺胡亂寫一通 秉持著先求有再求好的精神,使用小步向前的方式開發,先設定兩個玩家,在一場遊戲中有9個格子,每個格子可能是空白、X
或O
,依照這個邏輯,我們快速寫出以下代碼:
// 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大螢幕會對開發非常非常有幫助。
這時候應該跑起來長相是這樣:
雖然執行的結果仍然是上一節demo的結果,但我們可以發現多了一些warning的訊息,而且是關於我們剛剛新增的代碼,這邊rust compiler給的訊息,請記得以後要仔細看rust的訊息,非常有幫助,反正之後會強迫你不得不看它,從現在開始請把它當作我們的好朋友。
這裡的enum 跟各語言的enum長的差不多(但其實是開掛的威力加強版),目前先不過多著墨,struct
是放一包資料結構的結構體,pub
是公開給非當前mod
裡的程式使用,我們的Game
結構體裡有一個欄位cells
,存放著一個陣列,大小為9個元素,元素類別為enum Cell
。也就是說 實際的資料可能長這樣: [Empty, X, O, ...]
或 [Empty, Empty, ...]
學習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]
};
}
let
關鍵字Enum::Variant
如:上面的 Cell::Empty
上面的 use
是為了參照其他mod
裡的代碼,當要使用其他mod
裡struct
, enum
, fn
等代碼(前提是他們定義成公開pub
),則在最上面寫use可以簡化我們的代碼,如果不用use會等價下面代碼,但你可能要注意一下自己的人身安全,喔不是,我們身為一個專業的程式設計師,請儘量保持我們的代碼乾淨易讀,下面示例只是幫助大家了解 use
作用為何。
fn main() {
let game = core::tic_tac_toe::Game {
cells: [core::tic_tac_toe::Cell::Empty; 9]
};
}
這邊的物件初始化方式跟JavaScript看起來好像唷,但編譯器開始抱怨了:
如同剛剛說的,我們的mod是private,所以無法被外部引用,我們直接修改:
@@ core/src/lib.rs @@
+pub mod tic_tac_toe;
-mod tic_tac_toe;
改好後又有另外一個錯誤訊息:
其實編譯器給的說明很完整了,只是大家還不熟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自動完成
/// 略
}
還是有錯,不過這一次編譯器好像突然變聰明了,直接告訴我們要怎麼解了,我們此時只要使用複製貼上大法就好了:
+ #[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);
這邊說需要實作 std::fmt::Display
這個 trait,我們等等再實作,先說明一下其中note第1點,有提到可以使用{:?}
或{:#?}
,先試試看這是什麼
+println!("{:?}", game);
-println!("{}", game);
改加一個:?
後,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的長相:
使用{:#?}
的結果如下,就只是上面的結果加以縮排格式化:
有了Debug就可以開始Debug了(?),我們把main改回println!("{}", game);
,再回tic_tac_toe.rs
裡加上Game
及Cell
的Display
功能,這時候要手動製作了:
@@ 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
是可變借用,我們在這裡先當成是固定寫法就好, impl <trait> for <struct>
:替結構體實作某traitimpl <traint> for <enum>
:替enum實作某trait是的,有發現enum的強大了嗎,enum竟然也可以實作方法,所以enum不像其他語言只是單純的枚舉而已。
trait用法有點像C#的
class MyClass : IMyInterface
或是Java的class MyClass implements SomeInterface
,但威力大很多,請慢慢感受
這樣一來我們的終端介面好看多了,等等也比較好測試(O)
再來是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()
的感覺
::
可以當成是 C#裡的static method或ruby裡的class method的呼叫,對應instance method的呼叫方式是.
。再來我們把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,就表示我們輸入的不是表達示:
為了先使修改後的main能動,我們補上Game
的is_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時self參數設定為可變,所以呼叫者也必需宣告成可變的,rust在這方面比較嚴格,我們調整一下:
@@ core/src/play.rs @@
+let mut game = Game::default();
-let game = Game::default();
看起來運行正常,我們下一篇再來補上正確的邏輯實作。
巨集不同於function,rust的巨集就是幫你把 code 展開,簡單說巨集就是幫你把code展開,不用一直重覆寫code,比如前面的例子中,在IDE裡按住Ctrl
再用滑鼠點println!
,會跑出下圖:
所以你寫 println!()
沒放參數rust編譯器就是幫你把程式碼翻譯成
$crate::print!("\n")
寫println!("Hello")
就會幫你把碼式碼翻譯成
$crate::io::_print($crate::format_args_nl!("Hello"));
這裡的
format_args_nl!
又是另一個巨集,就再展開
!
結尾。所以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