在這個章節,我們將會直接使用Rust程式語言來建立出猜數字程式的專案,並逐步將它完成!並在撰寫程式的過程中,來練習Rust程式語言基礎的程式語法和引用外部套件的方式。這支小程式在執行之後,將會先從1到100的整數中,隨機抽取一個數字作為答案,並且允許使用者輸入要猜的數字,如果答錯了,程式會回答使用者輸入的數字究竟是大於答案,還是小於答案,並持續讓使用者繼續猜下去;如果答對了,程式會出現使用者贏了的訊息,並且結束程式。



建立新的Cargo專案

使用Cargo來建立出猜數字程式的專案,將其命名為「guessing_game」,指令如下:

cargo new --bin guessing_game

在這裡我們加了「--bin」參數,讓Cargo建立出可執行的應用程式專案。

接著就將工作移動到程式專案的根目錄吧!也就是剛才建立出來的「guessing_game」目錄。

如果您不放心程式專案可能沒有產生成功,那麼可以使用以下指令來直接執行剛才產生出來的「guessing_game」專案。

cargo run

就如同前一章所介紹的一樣,Cargo建立出來的可執行應用程式專案,就是Hello World程式。所以會看到程式在螢幕上印出了「Hello, world!」。

rust-guess-number

修改專案設定檔

成功產生出Cargo的專案後,用文字編輯器打開「Cargo.toml」,應該會看到如以下的內容:

在套件的設定區域中,「name」對應的就是專案名稱,Cargo的專案名稱習慣使用底線來將兩個單字區隔開來。我們在一開始下指令建立專案的時候,就已經將專案名稱取名為「guessing_game」了,所以這邊已經預先填好了「guessing_game」。如果想要改變專案名稱,直接改掉這個欄位的值即可。專案目錄的名稱可以跟專案名稱不相同。

再來,「version」對應的是程式的版本,Cargo支援「語意化版本」(Semantic Versioning),版本的表示方式如下:

主版號(major).次版號(minor).修訂號(patch)

預設的新專案的程式版本號碼是從「0.1.0」開始,之後每做一次程式專案的修改,在發佈新版本的程式時,就應該要去修改這邊的版本號碼,將其按照底下的規則遞增(通常是加一):

  • 主版號:做了不相容的API修改。
  • 次版號:做了向下相容的功能性新增。
  • 修訂號:做了向下相容的問題修正。

如果要更講究使用版本號碼的話,詳細的語意化版本控制規範(SemVer)可以到底下這個網頁來查看:

https://semver.org/lang/zh-TW

「authors」對應的是這個程式專案的作者,如果有多位作者的話,可以改寫成如以下的格式:

開始撰寫程式碼

設定好程式專案之後,就可以開始專心寫程式啦!

用文字編輯器開啟「main.rs」,將「println!("Hello, world!");」改成「println!("Guess the number!");」,目的是要讓程式在螢幕上印出「Guess the number!」這幾個字。

程式在螢幕上印出「Guess the number!」之後,應該要先出好猜數字的題目,也就是隨機從1到100的整數中挑出一個作為猜數字的答案。然而,Rust的標準函式庫,並沒有提供隨機數值的函數,因此在此我們需要引用外部的套件,來完成。至於要使用哪個套件呢?那就是官方提供的「rand」啦。

為了讓Cargo程式專案能夠使用外部的套件,我們必須編輯「Cargo.toml」專案設定檔,要在「[dependencies]」的區塊中加入「rand」套件,撰寫的格式如下:

注意「Cargo.toml」設定檔的第7行,我們加入了「rand = "0.3.14"」。等號左邊表示套件的名稱,等號右邊表示套件的版本。也就是說,在此我們會把「rand v0.3.14」這個套件加入至我們的專案中使用。根據語意化版本控制規範,如果將版本寫成「^0.3.14」,代表「任何API相容於0.3.14版本的版本」,Cargo會選擇當時最新且符合這個條件的套件來使用。舉例來說,如果之後rand套件有了三個新版本,分別是「0.3.22」、「0.4.2」與「1.0.0」,而在我們的「Cargo.toml」設定檔中,rand套件的版本是設定為「^0.3.14」的話,Cargo將會自動選擇「0.3.22」來使用,確保程式專案能用到比較新版本的套件,且也能夠在不用修改專案原本程式碼的情況下,依然可以成功編譯程式。可是,為什麼Cargo會是選擇「0.3.22」,而不是「0.4.2」呢?主版號一樣的話不是表示API都是相容的嗎?這其實是個例外狀況,當「主版號」為「0」的時候,表示這個套件還在開發階段,API會改來改去,但因為程式還在開發階段,不應該將「主版號」遞增到「1」以上,因此會退而求其次,只遞增「次版號」。這個例外規則稱作「Magic Zero」!

雖然剛才提到版本寫成「^0.3.14」的話就會有自動使用最新相容套件版本的功能,事實上,在「Cargo.toml」設定檔中,若將套件的版本直接寫成「0.3.14」,其實就代表「^0.3.22」了!

將rand套件加入「Cargo.toml」設定檔後,請試試看用以下指令編譯專案:

cargo build

rust-guess-number

Cargo就會自動從「Crates.io」將我們剛才加入至「Cargo.toml」設定檔的套件和其本身相依的套件都下載下來編譯了!Crates.io是Rust官方提供的一個能讓Rust程式設計者們分享開源套件的平台,rand套件就是Crates.io上的其中一個套件,網址如下:

https://crates.io/crates/rand

此時如果直接再執行一次「cargo build」,因為我們對專案沒有進行任何的修改,因此Cargo也不會花時間重新下載套件和編譯專案。

rust-guess-number

如果我們對「main.rs」進行一些改動,比如說,將輸出的「Guess the number!」,在最後多加一個「!」。

此時再執行一次「cargo build」,會發現Cargo只會重新編譯剛才修改的「main.rs」檔案,而並未重新下載rand套件,也沒有對它進行重新編譯的動作。因為Cargo只重新編譯有做修改的部份,可以大大增加編譯速度。

rust-guess-number

在編譯專案之後,程式專案的根目錄中會產生出「Cargo.lock」檔案。這個檔案被用來記錄此Cargo程式專案使用的相依套件版本,假使之後這些套件有的發佈了API相容的新版本,Cargo在編譯專案時,還是會根據「Cargo.lock」裡面記載的套件版本,也就是最一開始編譯專案時選擇使用的套件版本,來繼續沿用那個版本的套件,而不是去使用新版本,如此一來也不用每次編譯專案都要去尋找和下載比較新的套件。但若要讓Cargo去使用API相容的新版本套件,也可以使用以下指令來更新「Cargo.lock」檔案:

cargo update

這裡要注意的是,「cargo update」只會更新「Cargo.lock」檔案,並不會去更新「Cargo.toml」設定檔內的套件版本哦!

知道怎麼加套件至Cargo程式專案後,再來就是要在程式碼中使用它啦!在上一章節有稍微提到,Rust對於套件有定義了一個新的名稱──「crate」(板條箱),若要在程式碼中使用套件,需在程式碼的最上方使用「extern crate」關鍵字。由於我們要使用的套件為「rand」,因此在程式碼第一行插入「extern crate」敘述,插入後的程式碼如下:

接下來介紹產生一個1到100隨機整數的方式。

來看一下我們新增的第六行敘述。「let」這個關鍵字可以建立變數,例如:

這行程式可以宣告出一個變數「foo」,並且讓它存放「bar」這個值(value)。在Rust程式語言中,預設宣告出來的變數是不可以改變(immutable)的,舉例來說:

如果要讓宣告出來的變數可以被改變(mutable),則需要在「let」關鍵字加上「mut」。程式碼如下:

舉例來說:

以上程式會宣告出兩個變數「x」和「y」,並且讓它們都存放「5」這個數值。變數「x」由於在宣告時沒有使用「mut」關鍵字的關係,因此它只能夠被存取,無法進行修改,也就是說「x」的值永遠是「5」。而變數「y」在宣告時使用了「mut」關鍵字,因次在程式執行階段,變數「y」的值有可能從原本的「5」,被改成其它同型別的值。就像大多數的程式語言一樣,「//」也是Rust語言的單行註解開頭。加入註解只是為了方便理解程式碼,對於程式本身來說,註解並沒有任何用處,Rust編譯器會忽略所有程式碼中的註解,因此也不用擔心註解寫太多會造成編譯出來的程式體積變大。

回到「main.rs」程式碼的部份。

在這個猜數字程式中,用來儲存猜數字答案的變數「secret_number」並不需要被改變,因此在被宣告出來時不使用「mut」關鍵字。如此一來可以確保我們在撰寫程式的時候不會不小心去改到這個變數的值,而編譯器也可以有機會對這種值不會被改變的變數進行特別優化。

接下來我們來看一下「rand::thread_rng().gen_range(1, 101)」到底是什麼意思吧!Rust程式語言使用「::」這樣的語法來關聯指定套件的關聯函數或是模組,像是這裡使用的「rand::thread_rng()」,表示要從rand套件中呼叫「thread_rng」這個函數。「thread_rng」這個函數會回傳一個ThreadRng結構(struct)的實體(instance),可以根據目前執行這個函數是用哪個執行緒,來決定要用什麼亂數種子(seed)。

接著看到「thread_rng().gen_range(1, 101)」。Rust程式語言使用「.」這樣的語法來使用結構實體的方法(method),因此這個敘述將會再去呼叫由「thread_rng」函數回傳的ThreadRng結構實體的「gen_range」方法。然而,事實上,原始的ThreadRng結構,並沒有實作「gen_range」這樣如此高階的方法,因此目前的程式是無法成功編譯的。

為了要讓程式能夠成功編譯,我們需要在程式碼中定義函數的上方,再加入一行敘述,程式碼如下:

第3行程式,我們使用了「use」這個關鍵字來使用rand套件所提供的「Rng」。「Rng」是rand套件已經實作好的特性(trait),簡單來說「Rng」這個特性能夠擴充ThreadRng結構所擁有的方法,使其擁有一些高階產生隨機數值的方法,「gen_range」方法就是其中之一。

「gen_range」方法需要傳入兩個參數,來決定要從哪個範圍中隨機產生數值。第一個參數是數值範圍的最小值,第二個參數則是數值範圍的最大值,「gen_range」方法會在傳入的最小值和最大值所組成的數值範圍中隨機產生一個數值,這裡要注意的是,範圍最大值本身並不包含在隨機產生的可能內。因此,若要使用「gen_range」方法來隨機產生1到100中的一個的整數,程式碼需寫成「gen_range(1, 101)」,而不是「gen_range(1, 100)」。

您可能會注意到,我們將儲存猜數字答案的變數取名叫「secret_number」,而剛才用到的方法名稱則是「thread_rng」和「gen_range」,這是Rust程式語言對於專案、變數、函數、方法的命名習慣──使用底線將單字區分開來,英文字母為小寫。而像是「Rng」等結構、特性、列舉(enum)、列舉值的命名習慣,則是使用駝峰式大小寫的方式區隔單字,且第一個字母為大寫。

有關於Rust的結構、特性和列舉將會在之後的章節詳細介紹,現階段只要知道執行「gen_range」方法之後,我們會得到指定範圍內的一個隨機數值。

緊接著我們可以將這個隨機產生出來的數值再利用「println!」這個巨集輸出到螢幕上。程式如下:

就像是其它程式語言有的字串格式化(format)功能,Rust當然也有自己的一套。在練習Hello World程式時我們也稍微了解到,Rust用的「println!」不是一般的函數,而是巨集!

巨集「println!」第一個參數傳入字串的樣本,並允許在字串樣本中使用大括號「{}」來表示要預留的空位,格式化時就會把緊接著傳入進巨集「println!」的其它參數用來取代字串樣本預留的空位。大括號「{}」內還可以再加上其他訊息,用來控制參數代入的順序和方式,這個在之後的章節會介紹,在此我們先不添加任何東西。

接下來再加入一行「println!」的敘述,在螢幕上印出訊息,來提示使用者該要開始輸入要猜的數字了。程式如下:

再來,我們要宣告出一個變數,用來儲存使用者輸入的文字。程式如下:

程式第14行,由於我們不知道使用者究竟會輸入怎麼樣的文字,因此在宣告「guess」時加上「mut」關鍵字。「String」為Rust程式語言的標準函式庫所內建的一個結構體,使用UTF-8編碼來處理可變動長度的字串資料,其提供的「new」方法可以快速產生String結構的實體。

接著,為了讓使用者輸入文字了,我們需要使用「std::io」這個模組(mod),「std::io」提供了一些有關輸入與輸出的功能,在之後的章節會詳細介紹模組的用法。

若要在程式碼中方便使用「std::io」模組,我們可以利用「use」指令將其引用進來。程式如下:

如此一來,在程式碼中就可以直接使用「io」來表示「std::io」模組了!

在預設的情況下,Rust只會自動引用一些東西進入目前程式的使用範圍(scope),這些自動引入的東西均定義在「std::prelude」模組中。如果要使用沒有定義在「std::prelude」模組中的東西,就要靠「use」關鍵字來引用。

引用了「std::io」模組之後,即可加入新的程式敘述:

程式第17行,利用「std::io」提供的「stdin」函數,來建立出一個用來處理標準輸入的「Stdin」結構實體,並且呼叫其提供的「read_line」方法,來讓使用者能透過標準輸入串流輸入文字,並且將輸入的文字存進從參數傳入的String結構實體。前面我們使用「use」關鍵字,將「std::io」模組加入至我們的程式scope中,如果只是臨時要使用某套件的某個模組或函數,不想將其加入整個程式的scope中的話,也可以省略「use」關鍵字,之後在使用時改用完整的名稱來指定即可。舉例來說,不用「use」關鍵字來引用「std::io」模組的話,原本第17行的「io::stdin()」可以改寫成「std::io::stdin()」。

傳入「read_line」方法的參數型別為「&mut String」,「&」所代表的意思是取得這個變數的參考(reference),至於為什麼在此要用參考的方式傳遞參數,將留在之後的章節介紹。在「&」後面加上「mut」是用來表示這個參考所指到的值是可變的,因為我們需要把使用者輸入的文字存進傳進「read_line」方法的String結構中,所以使用「mut」。「read_line」方法還會回傳一個「Result」列舉的值,方便我們判斷這個方法有沒有執行成功。事實上,我們不一定要處理函數或是方法回傳的Result列舉的值,只不過若沒有去使用到函數或是方法的回傳值的話,編譯器是會出現警告訊息的!為了能夠讓增加程式的安全性,我們還是應該要去處理函數或是方法的回傳值。

「Result」列舉是Rust程式語言提供的型別,只有兩種值(準確來說是「變體」(variant)),分別是「Ok」和「Err」。顧名思義,「Ok」代表程式執行成功,「Err」則代表程式執行失敗,這兩種值分別包裹執行成功的值和執行錯誤的值。「Result」列舉經常被函數或是方法作為回傳值,來回傳函數或是方法有沒有執行成功。由於判斷「『Result』列舉的值是『Ok』還是『Err』」的這個動作會很頻繁的在程式碼中被使用到,因此Rust程式語言提供了一個簡化判斷「Result」列舉的語法糖──「expect」方法。

程式第18行,我們呼叫了「read_line」方法所回傳的Result列舉的「expect」方法,如果該Result列舉的值是「Err」的話,將會造成程式panic(驚慌),而panic訊息就是代入至expect方法之參數的「Failed to read line」文字。如果該Result列舉的值是「Ok」的話,則會回傳被「Ok」包裹的值。

接著使用「println!」巨集,輸出使用者剛才輸入的文字。程式如下:

我們剛才使用「read_line」方法讓使用者輸入進來的文字是String型別,為了讓它能夠與猜數字的答案「secret_number」進行數值大小比較,我們必須將String轉成「i32」。Rust程式語言對於數值型別,區分了有號整數(i)、無號整數(u)和浮點數(f),整數依照表示範圍區分為8位元、16位元、32位元、64位元和128位元,浮點數也依照精準度區分為32位元和64位元。由於我們在使用「gen_range」方法時所代入的參數為整數,在Rust程式語言中,直接寫在程式碼的整數預設使用32位元的整數型別,也就是「i32」,因此「secret_number」的型別也就是「i32」。為了能夠與型別為「i32」的「secret_number」變數進行比較,我們需要將「guess」所儲存的String結構體也轉成「i32」型別。程式如下:

程式第20行,我們使用「let」關鍵字宣告了一個「guess」變數,並且使用「:」語法將其的型別指定為「i32」。可是程式第15行不是已經有用「let」宣告過guess變數了嗎?而且還使用了「mut」關鍵字讓它是可變得,為什麼不直接使用這個「guess」變數,而在程式第20行的時候用「let」重新宣告呢?這是因為Rust程式語言所使用的變數一旦被定義好型別,就不能再進行改變了,程式第20行其實是使用「let」宣告一個新的且型別不同的「guess」變數,由於變數名稱相同的關係,這個新的「guess」變數在宣告之後會遮蔽(shadowing)掉原先的「guess」變數。程式「=」右邊的式子會先執行完畢之後再指派給左邊,因此程式第20行等號右邊的「guess」變數,其實還是第15行宣告的那個String結構體的「guess」變數。String結構實體提供了「trim」方法,可以消除字串頭尾所使用的空白與換行字元,並回傳另一種型別的字串──「str」,這個在之後的章節會詳細介紹。總而言之,透過「trim」方法,我們也可以過濾掉使用者確認輸入時按下Enter鍵而產生的「\n」字元,避免造成之後轉換成數值型別時發生問題。無論是「String」還是「str」型別的字串,都有實作「FromStr」這個特性,因此可以使用「parse」方法,來將字串轉成數值。在此,變數的型別不像程式第15行一樣,可以自動根據最一開始指派的值來定義型別,而是必須先定義好,否則會編譯錯誤。或者,也可以用「turbofish」語法來指定「parse」方法要轉成的數值型別,語法如下:

或許這種「::<>」的語法很像是一種叫作「Turbot」(大口鰜)的魚,所以才會稱作「turbofish」吧?之後的章節會詳細介紹「<>」語法所表示的泛型(generic)概念。

程式第21行,由於「parse」方法執行之後會回傳Result列舉,所以同樣也可以使用「expect」方法來處理。

接著就要來判斷使用者輸入的數值是否剛好猜中答案,如果是的話,要印出「You win!」;如果不是的話,要印出「Too small!」或是「Too big!」。程式如下:

程式第25行到第29行,使用了「match」關鍵字來控制程式流程。「match」關鍵字是Rust程式語言中提供的一種類似其它語言「switch」關鍵字控制流程的功能,它能夠判斷傳入的數值符合哪個它定義好的「arm」(手臂),來決定要執行什麼程式。型別為i32的guess變數,可以使用「cmp」這個方法來與傳入另一個「i32」的值進行比較,如果傳進來比較的值小於guess變數的值,「cmp」方法就會回傳「std::cmp::Ordering」列舉的「Less」值;如果大於,會回傳「Greater」;如果等於,會回傳「Equal」。程式第26行定義了一個match的arm,如果傳入match的值為「std::cmp::Ordering」列舉的「Less」值的話,程式就會用「println!」巨集印出「Too small!」。同理,程式第27、28行就是定義符合其它值時候的流程。之後的章節會詳細介紹「match」的用法,現在先理解到這樣的程度即可。

由於「std::cmp::Ordering::Less」這樣的寫法實在又臭又長,我們可以利用「use」關鍵字,將「std::cmp::Ordering」引用到我們程式的scope中。程式碼可以改寫成如下的樣子:

再來,我們要想辦法讓使用者在猜錯之後,還能夠依照提示繼續猜下去,而不是直接結束程式,因此我們會需要用到「loop」關鍵字來建立程式迴圈。將程式改寫如下:

程式第14行和第32行,我們利用「loop」關鍵字將讓使用者輸入文字並顯示猜測結果的程式包成了一個區塊,程式如果未在這個區塊執行到「break」或是「return」敘述,就會不斷的重複執行這個區塊內的敘述。為了能夠讓使用者在猜對數字之後結束程式,我們需要再將程式進行改寫,如下:

程式第30行到第33行,改寫了原先「Ordering::Equal」這個match arm的寫法,在印出「You win!」文字之後執行「break」敘述。和其他大多數的程式語言一樣,Rust程式語言「loop」區塊中的「break」敘述是用來跳出迴圈;「continue」敘述是用來跳過這次迴圈的執行,直接進入下一次。由於原先的arm只有一行敘述,所以不需要另外用大括號「{}」表示程式區塊,但現在需要執行超過一個敘述,因此要使用大括號「{}」將需要執行的敘述包起來。

至此,我們已經差不多完成了這個猜數字程式了。可以用「cargo run」指令玩個幾次試試。

rust-guess-number

我們預期使用者輸入的字串是數字,接著再由我們的程式將字串轉成數值型別,但如果使用者輸入的字串不是正確的數字,就會出現帶有「Please type a number!」訊息的panic,程式就這樣莫名其妙的停止結束執行了。

rust-guess-number

這樣的方式對於使用者來說會是一個很糟糕的體驗,為了解決這個問題,我們可以利用「match」關鍵字來處理parse方法回傳的Result列舉。將程式改寫如下:

程式第22行到第25行,我們把原先使用expect方法來處理Result列舉的方式改為使用match關鍵字。在match的arm中,我們判斷parse的Result是否符合「Ok(num)」或是「Err(_)」。「num」是一個表示「Ok」第一個包裹的參數的變數,透過這樣的語法,就可以在「=>」右方用「num」取得「Ok」包裹在第一個參數的值。若在此使用的不是「num」這樣的變數名稱,而是一個明確的值的話,就表示parse回傳的Result::Ok,第一個參數必須要正好包裹著這個明確的值。若我們將程式改寫成如下的樣子:

當使用者猜的數字為「-1」時,就會印出「Bye bye!」文字,然後跳出迴圈,結束程式。

rust-guess-number

請注意,若把「Ok(-1)」這條arm放在「Ok(num)」之後,將會永遠無法被執行到,因為「match」關鍵字判斷要執行哪條arm的方式是依照程式碼撰寫順序的。

至於「Err(_)」的「_」,則是表示要忽略這個參數,如果將其改寫成「Err(e)」的話,程式雖然也可以被編譯執行,但因「e」這個變數並沒有用到,所以編譯器會出現警告訊息。

程式寫到這裡,這個章節的猜數字程式已經完成了,最後使用「cargo build --release」指令將專案編譯出執行檔,然後把這個執行檔傳給親朋好友們玩玩看吧!

rust-guess-number

總結

在這一章中,我們成功地完成了猜數字程式,已經用過了「let」、「match」、「use」、「extern crate」等等的關鍵字,稍微接觸了Rust的基本資料型別、結構、特性和列舉,也用了一些標準函式庫提供的功能。在接下來的章節中,將會對變數、資料型別、函數、程式流程進行更深入的探討。

下一章:Rust程式語言的基礎概念