[為你自己學 Rust] 函數
在別的程式語言裡,我相信各位應該都寫過函數或是用過別人寫的函數,但在開始介紹語法之前,我想先問大家一個很簡單的問題:
什麼是函數(function)?
大家是否曾想過這個問題?還是因為學校老師或是書上說要寫函數就是 function 給它寫下去,還有什麼參數、回傳值什麼的...。我上課的時候很愛問同學們「為什麼」,例如「為什麼要寫函數?」這個問題,我大多會得到差不多是「因為可以重複使用」之類的答案。是沒錯啦,函數可以重複使用的確是使用函數的好處之一。那麼到底什麼是函數?
大家在國中還是高中上數學課的時候有沒有看過這種東西:
f(x) = 3x + 2
當時老師會說這個叫做「一元一次方程式」,那個 x
就是「代數」,如果代 2 進去會得到 8,帶 3 進去會得到 11,如果再代一次 2,還是會得到 8。事實上這就是函數,前面那個 f
就是函數 function 的意思,只是大家可能一時沒意識到罷了(還是不願想起來?)
所以如果要我給「函數」一個定義,我會說:
函數是「輸入值」與「輸出值」之間的對應關係
而函數的名稱就是這個對應關係的名字。
把這些名詞換成程式語言的話,輸入值就是「參數(parameter)」,而輸出值就是「回傳值(return value)」。一個好的函數,理想狀況是可以做到這個函數的輸出值只跟它的輸入值有關。只要是固定的輸入值,不管執行幾次,它的答案不會飄,就是固定的輸出值,不會因為其它像是亂數或環境變數之類的「副作用(Side Effect)」而造成輸出值不同。通常我們也會稱這樣的函數叫「純函數(Pure Function)」,不純免錢。
給函數一個好的名字很重要,一個好的名字最好做到一眼就看出來它想要做什麼事,之所以會撰寫函數,是因為我們可以透過函數把原本比較繁瑣的流程抽象出來,我們的腦細胞就可以把重點放在怎麼使用這個函數,而不需要關注函數本身實作的細節。不過,命名是電腦科學界的兩大難題之一,但有好的名字是很難的,光是命名就能寫一整本書了。
‟ There are only two hard things in Computer Science: cache invalidation and naming things. ”
-- Phil Karlton
想要再深入了解這方面的主題,可用關鍵字「函數式程式設計(Functional Programming)」再找其它資料研究。
有點離題了,拉回來 Rust。在 Rust 裡定義函數是使用關鍵字 fn
,後面接著函數的名稱:
fn say_hello() {
println!("Hello, Rust!");
}
函數名稱的命名建議跟變數一樣也是蛇式命名法。
參數與回傳值
函數可以不帶任何參數,也可以帶很多個參數,就看實際需要而決定。如果要帶參數的話,需要明確的指定代入的參數是什麼型態:
fn main() {
print_number(5566);
}
fn print_number(n: i32) {
println!("{}", n);
}
若帶入的資料的型別跟期望的不符,Rust 就會給你一個錯誤訊息。除了參數之外,函數的回傳值也要標註型別:
fn add(a: i32, b: i32) -> i32 {
return a + b;
}
如果該函數沒有要回傳任何東西可以不用特別標記型別,或是也可寫成 -> ()
,這個是在上個章節介紹到的那個沒有任何元素的 Tuple,又稱 Unit。
雖然 Rust 不支援直接一次回傳多個值,不過你可以使用 Tuple 來做到這件事,要注意的是,在函數的回傳值也需要根據實際會回傳的資料型別做標記:
fn get_data() -> (char, i32, bool) {
return ('a', 30, true);
}
特別提一下在 Rust 裡的 main
函數,它是一個特別的設計。main
函數是整個程式的進入點,這個函數不收任何參數也沒有回傳值。
函數簽名(function signature)
在程式語言裡,函數簽名指的是是函數的宣告或定義,它包括了函數的名稱、參數的列表、型別,以及函數的回傳值及型別,透過函數簽名的描述可以更清楚的知道每個函數的使用方式,例如引數(Argument)應該要給幾個、分別是什麼型別?執行完的回傳值又是什麼。
也許以前大家在寫 JavaScript 的時候不需要把函數描述這麼詳細,方便是方便(或說是隨便?),但一不小心也會因為傳入引數的個數或型別不正確而造成奇怪的錯誤。後來推出的 TypeScript 就是想解決這個問題,如果各位曾經寫過 TypeScript 應該就對 Rust 的函數簽名不會覺得太陌生。
要不要寫分號?
在 JavaScript 寫分號是個選擇,你想寫就寫,不想寫就不要寫。但就算沒寫,事實上 JavaScript 的 runtime 也會執行的時候幫你補上去,這是 JavaScript 本身就有的機制,專有名詞叫「ASI(Automatic Semicolon Insertion)」。
在撰寫 Rust 程式的時候你可能會注意到,每行程式碼的結尾都要有分號,但好像有時候不寫又會出錯?所以,到底是該不該寫?在討論要不要寫分號之前,我們得先來認識兩個專有名詞,一個叫「Expression(表達式)」,另一個叫「Statement(陳述句)」。
Expression vs Statement
我們一般人類用的語言,不管是英文、日文還是中文都差不多,都有很多的詞組或片語(Phrase),而一個完整的句子是由這些片語組合而成。舉例來說,「我喜歡吃火鍋」這句話其中的「喜歡」、「吃」跟「火鍋」雖然你都知道這些代表什麼意思,但都不能算是一個完整的句子,只能算是單字片語(Phrase)。如果對比到電腦程式語言來說,這些單字片語就是「表達式(Expression)」。雖然有些單字片語本身就能夠表達意思,但通常要把整個句字從頭到尾講完,才算的上是個完整句子;以電腦程式語言來說,完整的一句話就是「陳述句(Statement)」。
如果這樣還是有些抽象,來些例子吧:
18
age
age > 18
從最簡單的數字、字串,到四則運算,或是變數本身或函數呼叫,這些都是 Expression,例如上例中數字 18
本身、age
變數,以及 age > 18
的結果,都是一個 Expression,它們最後都會得到一個結果、一個值,也就是得到數字 18 本身、age
變數所代表的值,以及 age > 18
計算之後的結果。
我們再看看下面這個例子:
let cats = 5;
if cats > 0 {
println!("有好多貓 🐈");
}
第一行宣告了一個 cat
變數並且指定值等於數字 5
,這是一個 Statement;接著的 if
判斷句,也是一個 Statement。
再以人類的語言來比喻,「鮪魚壽司 🍣」就是一個 Expression,它就是代表「鮪魚壽司 🍣」這個東西,但並沒辦法表達你想要說的內容;相對的「我要吃鮪魚壽司 🍣」就是一個 Statement,它可以完整的表示你想表達的意思。
就像我們講一句完整的話,裡面也是由許多單字組合而成一樣,例如「我要吃鮪魚壽司 🍣」這句話裡也包含「鮪魚壽司 🍣」這個單字,一個 Statement 可能會包括一個或多個 Expression。
有些時候 Expression 跟 Statement 並沒有那麼明顯的不同,以一個程式新手來說,暫時可以不用太揪結這兩者在定義上有什麼不同,但比較明確的差別,在於 Expression 會有回傳值(Return Value),但 Statement 不會,所以:
let a = 1 + 2;
因為 1 + 2
這個 Expression 的回傳值是 3
,所以此時變數 a
的值就是 3
。但這整句「使用 let
宣告變數」是一個 Statement,這個行為本身是沒有回傳值的。
Expression 跟 Statement 這兩個名詞並不是 Rust 發明的,其實在很多程式語言裡面本來就都有這個概念,只是你可能不知道你寫的就是 Expression 跟 Statement 而已。
回到原本的問題,Rust 需不需要寫分號?如果你不想了解這麼多細節的話,簡單的答案是「要」。雖然在 Rust 的 Expression 可以不加分號,但 Statement 結尾需要加上分號,表示這個句子結束了,否則會影響之後的程式碼的編話。看看這段範例:
fn main() {
let age = 20;
18;
age > 18;
println!("{}", age);
}
數字 18
以及 age > 18
這兩行是 Expression 雖然可以不加分號,但不加分號就不會是一個完整的句字(Statement),會導致後續的程式碼編譯錯誤。執行上面這段程式碼不會出錯,但 Rust 編譯器還是很貼心的給了一段警告:
$ cargo run
warning: unused comparison that must be used
|
4 | age > 18;
| ^^^^^^^^ the comparison produces a value
如果程式碼就只有一行,或是剛好是最後一行,不寫分號也可以,Rust 編譯器會知道該結束了,像這樣:
fn main() {
println!("有好多貓 🐈")
}
在這個情況下,不寫分號也可以。
回傳值
在 JavaScript 一般的函數裡,return
不是一個選項,沒有寫 return
就是表示沒有明確的回傳值,也就是會回傳 undefined
。
不過在 Rust 裡,如果函數的最後一行是一個 Expression,可以適當的省略 return
,Rust 會自動回傳那個 Expression 的值。例如原本完整的程式碼是這樣寫:
fn add_extra(a: i32, b: i32) -> i32 {
let extra = 100;
return a + b + extra;
}
利用 Rust 會回傳最後一個 Expression 的設計,就能簡化成這樣:
fn add_extra(a: i32, b: i32) -> i32 {
let extra = 100;
a + b + extra
}
把 return
跟分號都拿掉。這時候如果寫上分號反而會出錯。
$ cargo run
error[E0308]: mismatched types
|
6 | fn add_extra(a: i32, b: i32) -> i32 {
| --------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
7 | let extra = 100;
8 | a + b + extra;
| - help: remove this semicolon to return this value
加上了分號,表示這是一個 Statement,它是沒有回傳值的。原本說好要回傳 i32
卻沒有回傳值,所以 Rust 就跳出來請你把最尾巴的分號移掉,讓它變成一個 Expression 就行了。
條件賦值
先看看以下這段範例:
let age = 20;
let message;
if age < 8 {
message = "小朋友";
} else if age >= 8 && age < 18 {
message = "年輕人";
} else {
message = "成年人";
}
println!("{}", message);
應該不算太難理解,大概就是依據變數 age
的值而決定 message
的值。在 Rust 同樣可以透過 Expression 來簡化它:
let age = 20;
let message = if age < 8 {
"小朋友"
} else if age >= 8 && age < 18 {
"年輕人"
} else {
"成年人"
};
println!("{}", message);
用這種寫法的話需要要注意兩點:
- 每個 Expression 結尾不加分號。
- 在最後
else
區塊後面的分號別忘了寫,因為這一整串就是一個 Statement,Statement 是需要分號的。
第 2 點其實還滿容易忘的,但沒關係,漏了寫 Rust 的編譯器會提醒你的。