第一章:基礎
第一章第 1 集:安裝 Rust
本集目標
把 Rust 裝到你的電腦上,確認它可以用。
正文
哈囉!歡迎來到 Rust 教學系列!
今天是第一集,我們什麼程式都還不寫,先把工具裝好就好。就像你要煮菜,總得先有鍋子對吧?
安裝 rustup
Rust 有一個官方的安裝工具叫 rustup,它會幫你把所有需要的東西一次裝好。
打開你的瀏覽器,去這個網址:
https://rustup.rs
- Windows 使用者:下載
rustup-init.exe,雙擊執行,然後一路按 Enter 選預設就好。 - Mac / Linux 使用者:打開終端機(Terminal),貼上這行指令:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
它會問你要不要用預設設定,直接按 Enter 就好。
確認安裝成功
裝完之後,重新打開一個終端機(這很重要,舊的終端機可能還抓不到),然後輸入:
rustc --version
如果你看到類似這樣的東西:
rustc 1.XX.X (xxxxxxx 20XX-XX-XX)
恭喜你!Rust 已經裝好了!
rustc 就是 Rust 的編譯器,它負責把你寫的程式碼變成電腦能執行的東西。至於什麼是編譯器,之後我們慢慢聊,現在你只要知道「裝好了、能用了」就夠了。
重點整理
- 用 rustup 安裝 Rust,它會幫你把所有需要的工具一次裝好
- 安裝完要重新開終端機,再用
rustc --version確認安裝成功 rustc是 Rust 的編譯器,負責把程式碼轉成電腦能執行的東西
第一章第 2 集:第一個程式
本集目標
用 Cargo 建立專案,跑出你人生中第一個 Rust 程式。
正文
上一集我們裝好了 Rust,今天就來寫第一個程式吧!
用 Cargo 建立專案
Rust 有一個超好用的工具叫 Cargo,它是 Rust 的專案管理工具。你可以把它想成一個管家,幫你整理程式碼、編譯、執行,全部包辦。
打開終端機,輸入:
cargo new hello
這會幫你建立一個叫 hello 的資料夾,裡面已經幫你準備好了基本的檔案結構。
用 VS Code 打開
接著用 VS Code(或你喜歡的編輯器)打開這個 hello 資料夾。你會看到兩個重要的東西:
-
Cargo.toml — 這是專案的設定檔,記錄你的專案叫什麼名字、用什麼版本之類的。現在不用管它,知道有這個檔案就好。
-
src/main.rs — 這就是你的程式碼!打開來看看:
fn main() {
println!("Hello, world!");
}
這就是 Rust 自動幫你生成的第一個程式。fn main() 是程式的入口點,所有程式都從這裡開始跑。println! 是印東西到螢幕上的指令。我們在第一章裡面暫時都只會在 fn main() 後面接的大括號裡面寫程式。
什麼是編譯?
在跑程式之前,先來了解一個重要概念。
我們寫的 .rs 檔案是給人看的程式碼,電腦其實看不懂。所以需要一個翻譯的過程,把我們寫的程式碼變成電腦能直接執行的檔案——這個翻譯的過程就叫做編譯(compile)。
負責做這件事的工具叫做編譯器(compiler),Rust 的編譯器就是上一集安裝的 rustc。
好消息是,你不需要自己去呼叫 rustc,等一下用的 cargo run 會自動幫你編譯再執行,一步搞定。
執行看看
回到終端機,先進入 hello 資料夾:
cd hello
然後輸入:
cargo run
你應該會看到螢幕上印出:
Hello, world!
太棒了!你的第一個 Rust 程式成功跑起來了!
改一下再跑
現在回到 VS Code,把 println! 裡面的文字改成:
fn main() {
println!("Hello, Rust!");
}
存檔,再回到終端機跑一次 cargo run:
Hello, Rust!
看到了嗎?你改了什麼,它就印什麼。程式設計就是這麼回事——你告訴電腦要做什麼,它就照做。
重點整理
- Cargo 是 Rust 的專案管理工具,用
cargo new建立新專案 - 專案裡
Cargo.toml是設定檔,src/main.rs是主程式碼 - 編譯是把人看得懂的程式碼翻譯成電腦能跑的檔案
- 用
cargo run一鍵完成編譯和執行 fn main()是程式的入口點,println!用來印東西到螢幕上
第一章第 3 集:變數與輸出
本集目標
學會用 let 建立變數,再用 println! 把它印出來。
正文
上一集我們成功跑出了 "Hello, Rust!",但那個文字是寫死在程式裡的。如果我們想要更靈活一點呢?這時候就需要變數了。
什麼是變數?
變數就像一個盒子,你可以把東西放進去,之後再拿出來用。
來看看怎麼用:
fn main() {
let x = 5;
println!("{}", x);
}
跑起來會印出:
5
這裡 let x = 5; 就是在說:「我要建立一個叫 x 的變數,然後把 5 放進去。」
然後 println!("{}", x); 裡面的 {} 就是一個佔位符,意思是「這個位置,請幫我填入 x 的值」。
文字變數
變數不只能放數字,也能放文字:
fn main() {
let name = "Rust";
println!("Hello, {}!", name);
}
跑起來會印出:
Hello, Rust!
看到了嗎?{} 的位置被 name 的值 "Rust" 取代了。
你也可以試著把 "Rust" 改成你自己的名字,看看會印出什麼!
let 不一定要馬上賦值
let 宣告變數的時候,不一定要馬上給值。你可以先宣告,之後再賦值:
fn main() {
let x;
x = 5;
println!("{}", x);
}
這樣完全合法,跑起來一樣印出 5,但一定要賦值剛好一次,沒有賦值就使用會發生編譯錯誤。
重點整理
let用來建立變數"雙引號"包起來的是文字println!("{}", 變數)可以把變數的值印出來{}是佔位符,會被後面的值取代let宣告不一定要馬上賦值,但一定要賦值一次
第一章第 4 集:註解
本集目標
學會在程式碼裡寫筆記(註解),讓自己和別人看得懂你在幹嘛。
正文
寫程式的時候,有時候你會想在旁邊做個筆記,提醒自己「這段在幹嘛」。這就是註解的用途。
註解不會被電腦執行,它純粹是寫給人看的。
單行註解
用 // 開頭,後面的內容整行都是註解:
fn main() {
// 這是一個註解,電腦會忽略這行
let x = 5; // 也可以寫在程式碼後面
println!("{}", x);
}
跑起來還是只會印出 5,那兩行註解完全不會影響程式。
多行註解
如果你要寫很長的筆記,可以用 /* */ 把它包起來:
fn main() {
/*
這是多行註解
可以寫好幾行
電腦通通會忽略
*/
let x = 10;
println!("{}", x);
}
什麼時候要寫註解?
- 當這段程式碼的邏輯不太明顯的時候
- 當你怕自己過幾天回來看會忘記的時候
- 當你想暫時讓某行程式碼不要執行(把它「註解掉」)
fn main() {
let x = 5;
// println!("{}", x); // 暫時不印,但不想刪掉
println!("程式結束");
}
這樣 println!("{}", x); 就不會被執行了,但你隨時可以把 // 拿掉讓它復活。
小提醒
不用每一行都寫註解喔!好的程式碼本身就應該夠清楚。註解是用在「不明顯」的地方,不是每行都要解釋。
重點整理
//是單行註解,/* */是多行註解- 註解是寫給人看的,電腦完全忽略
- 可以用註解暫時「關掉」某行程式碼,不用刪掉它
- 好的程式碼本身就該夠清楚,註解用在不明顯的地方就好
第一章第 5 集:算術運算子
本集目標
學會在 Rust 裡做加減乘除和取餘數。
正文
今天來學數學!別怕,就是加減乘除而已。
基本四則運算
先建立兩個變數:
fn main() {
let a = 10;
let b = 3;
println!("{} + {} = {}", a, b, a + b);
println!("{} - {} = {}", a, b, a - b);
println!("{} * {} = {}", a, b, a * b);
println!("{} / {} = {}", a, b, a / b);
println!("{} % {} = {}", a, b, a % b);
}
跑起來會印出:
10 + 3 = 13
10 - 3 = 7
10 * 3 = 30
10 / 3 = 3
10 % 3 = 1
等等,10 / 3 怎麼是 3?
好問題!因為 a 和 b 都是整數,所以 Rust 做的是整數除法,小數點後面直接砍掉。10 除以 3 等於 3.333...,砍掉小數就是 3。
% 是什麼?
% 叫做取餘數(模數運算)。10 除以 3 等於 3 餘 1,所以 10 % 3 就是 1。
你可以想成:「10 裡面有幾個 3?有 3 個,然後剩下 1。」那個剩下的就是餘數。
多個 {} 的用法
你有注意到嗎?我們在 println! 裡面放了三個 {}:
println!("{} + {} = {}", a, b, a + b);
Rust 會按照順序把值填進去:
- 第一個
{}→a的值(10) - 第二個
{}→b的值(3) - 第三個
{}→a + b的值(13)
幾個 {} 就對應後面幾個值,順序要對上。
重點整理
- 五個算術運算子:
+(加)、-(減)、*(乘)、/(除)、%(取餘數) - 整數除法會直接捨去小數部分(
10 / 3是3不是3.333) %取餘數:10 % 3就是 10 除以 3 剩下的1println!裡可以放多個{},按順序對應後面的值
第一章第 6 集:運算子優先順序
本集目標
了解 Rust 的運算順序——先乘除後加減,以及怎麼用括號改變順序。
正文
上一集我們學了加減乘除,但如果把它們混在一起呢?電腦會先算哪個?
先乘除,後加減
fn main() {
println!("{}", 2 + 3 * 4);
}
你覺得答案是多少?
如果你覺得是 20(先算 2 + 3 = 5,再乘 4),那就錯了!
答案是 14。因為 Rust 跟數學一樣,先乘除,後加減。所以它先算 3 * 4 = 12,再算 2 + 12 = 14。
用括號改變順序
如果你真的想先算加法呢?加括號就對了:
fn main() {
println!("{}", (2 + 3) * 4);
}
這次答案就是 20 了。括號裡面的會先算,2 + 3 = 5,然後 5 * 4 = 20。
小訣竅
不確定順序的時候,加括號就對了。括號不只是改順序,也讓程式碼更好讀。就算順序本來就對,加個括號讓意圖更明確也沒什麼不好。
fn main() {
// 這兩行結果一樣,但第二行更清楚
println!("{}", 2 + 3 * 4);
println!("{}", 2 + (3 * 4));
}
重點整理
- Rust 的運算優先順序跟數學一樣:先乘除後加減
- 用括號
()可以強制改變運算順序 - 不確定順序時加括號,既安全又讓程式碼更好讀
第一章第 7 集:比較運算子
本集目標
學會用比較運算子來比大小、判斷相不相等。
正文
到目前為止我們都在做數學運算,但程式設計裡還有另一種很重要的運算——比較。
比較的結果不是數字,而是 true(對)或 false(錯)。
== 等於
fn main() {
println!("{}", 5 == 5);
}
印出 true。5 等於 5 嗎?對,所以是 true。
注意喔,是兩個等號 ==,不是一個。一個等號 = 是拿來給變數賦值的(let x = 5),兩個等號 == 才是拿來比較的。
!= 不等於
fn main() {
println!("{}", 5 != 3);
}
印出 true。5 不等於 3 嗎?對。
< 小於
fn main() {
println!("{}", 3 < 5);
}
印出 true。3 小於 5。
> 大於
fn main() {
println!("{}", 10 > 7);
}
印出 true。10 大於 7。
<= 小於等於
fn main() {
println!("{}", 5 <= 5);
}
印出 true。5 小於或等於 5 嗎?等於的話也算,所以是 true。
>= 大於等於
fn main() {
println!("{}", 8 >= 10);
}
印出 false。8 大於或等於 10 嗎?不是。
一覽表
| 運算子 | 意思 | 範例 | 結果 |
|---|---|---|---|
== |
等於 | 5 == 5 |
true |
!= |
不等於 | 5 != 3 |
true |
< |
小於 | 3 < 5 |
true |
> |
大於 | 10 > 7 |
true |
<= |
小於等於 | 5 <= 5 |
true |
>= |
大於等於 | 8 >= 10 |
false |
重點整理
- 六個比較運算子:
==、!=、<、>、<=、>= - 比較的結果是
true(對)或false(錯) ==(兩個等號)是比較,=(一個等號)是賦值,別搞混
第一章第 8 集:if
本集目標
用 if 讓程式根據條件決定要不要做某件事。
正文
到目前為止,我們的程式都是從頭到尾一行一行執行的。但真正的程式需要會「判斷」——如果怎樣,就做什麼事。
這就是 if 的用途。
基本用法
fn main() {
let x = 7;
if x > 3 {
println!("大於 3");
}
}
跑起來會印出:
大於 3
邏輯很簡單:x 是 7,7 大於 3 嗎?對,所以就執行大括號 {} 裡面的程式碼。
條件不成立的話呢?
把 x 改成 1 試試看:
fn main() {
let x = 1;
if x > 3 {
println!("大於 3");
}
}
跑起來……什麼都沒有。因為 1 不大於 3,條件是 false,所以大括號裡的程式碼就被跳過了。
重點整理
if後面接條件,條件為true就執行大括號裡的程式碼- 條件為
false就整段跳過不執行 - Rust 的
if條件不需要加小括號
第一章第 9 集:作用域
本集目標
了解大括號 {} 創造的「範圍」,以及為什麼變數出了大括號就不能用了。
正文
今天來聊一個很重要的概念——作用域(scope)。
什麼是作用域?
你可以把大括號 {} 想成一個房間。在房間裡面建立的東西,出了房間就消失了。
來看這個例子:
fn main() {
{
let y = 10;
println!("{}", y);
}
}
跑起來會印出 10。到這裡都沒問題。
出了大括號會怎樣?
現在試著在大括號外面用 y:
fn main() {
{
let y = 10;
println!("{}", y);
}
println!("{}", y); // 這行會出錯!
}
你會得到一個編譯錯誤:
error[E0425]: cannot find value `y` in this scope
Rust 在跟你說:「我找不到 y 這個東西。」
為什麼?因為 y 是在那對大括號裡面建立的,一出了大括號,y 就「消失」了。就像你在一個房間裡放了一張椅子,關上門之後,走廊上是看不到那張椅子的。
為什麼要有作用域?
這其實是一件好事。它讓你的變數不會在不該出現的地方亂跑。想像一下如果每個變數在程式的任何地方都能用,那程式一大起來就會超級混亂。作用域幫你把東西整理得有條有理。
不只是獨立的大括號
上一集教的 if 也有大括號對吧?其實 if 的大括號也是一個作用域。之後我們會學到迴圈、函數等等,只要看到 {},裡面就是一個作用域。這是 Rust 裡面一個很統一的規則。
重點整理
- 每對大括號
{}都會建立一個作用域(scope) - 在作用域裡建立的變數,出了
{}就消失、不能再用 if和其他帶有{}的語法都會形成作用域
第一章第 10 集:else
本集目標
用 else 讓程式在條件不成立時,做另一件事。
正文
上次學 if 的時候,如果條件不成立,程式就什麼都不做。但很多時候我們想說:「如果這樣就做 A,否則就做 B。」這就是 else 的用途。
基本用法
fn main() {
let x = 2;
if x > 5 {
println!("大");
} else {
println!("小");
}
}
跑起來會印出:
小
x 是 2,2 大於 5 嗎?不是,所以跳過 if 的大括號,執行 else 的大括號,印出「小」。
換個值試試
把 x 改成 8:
fn main() {
let x = 8;
if x > 5 {
println!("大");
} else {
println!("小");
}
}
這次印出「大」,因為 8 大於 5,條件成立,走 if 那邊。
白話文
你可以把 if...else 想成:
如果條件成立,就做這個;否則,就做那個。
一定會走其中一邊,不會兩邊都走,也不會兩邊都不走。
重點整理
else接在if後面,處理條件不成立時要做的事if...else是二選一:一定會走其中一邊,不會兩邊都走或都不走
第一章第 11 集:else if
本集目標
用 else if 處理多個條件分支——不只二選一,還能三選一、四選一。
正文
上一集學的 if...else 只能處理「二選一」。但如果有更多情況呢?比如成績分等第:A、B、C、F……這時候就需要 else if。
範例:成績等第
fn main() {
let score = 85;
if score >= 90 {
println!("A");
} else if score >= 80 {
println!("B");
} else if score >= 70 {
println!("C");
} else {
println!("F");
}
}
跑起來會印出:
B
它是怎麼判斷的?
Rust 會從上到下,一個一個條件去看:
score >= 90?85 >= 90?不是,跳過。score >= 80?85 >= 80?是!印 "B",然後結束。- 後面的都不看了。
這很重要:一旦某個條件成立,後面的都會被跳過。
試試其他分數
score = 95→ 印 "A"score = 73→ 印 "C"score = 50→ 印 "F"(前面全部不成立,走到else)
結構
if 條件1 {
...
} else if 條件2 {
...
} else if 條件3 {
...
} else {
...(以上都不成立時)
}
你可以放任意多個 else if,最後的 else 是選擇性的(但通常建議加上去,以防漏掉什麼情況)。
重點整理
else if可以處理多個條件分支,不只二選一- Rust 從上到下檢查條件,第一個成立的就執行,後面全部跳過
- 最後的
else是選擇性的,用來處理「以上條件都不成立」的情況
第一章第 12 集:邏輯運算子
本集目標
學會用 &&(而且)、||(或者)、!(不是)來組合多個條件。
正文
上幾集我們學了 if,但條件都很簡單——只有一個。現實中常常需要同時考慮好幾個條件,比如「年滿 18 歲而且是學生」。這就需要邏輯運算子。
&& —— 而且(AND)
兩個條件都要成立,結果才是 true:
fn main() {
let age = 24;
let is_student = true;
if age >= 18 && is_student {
println!("是個成年學生");
}
}
印出 是個成年學生。因為 24 >= 18 是 true,is_student 也是 true,兩個都成立,所以整體是 true。
如果把 age 改成 15,15 >= 18 是 false,不管 is_student 是不是 true,整體就是 false,就不會印了。
|| —— 或者(OR)
只要其中一個條件成立,結果就是 true:
fn main() {
let is_weekend = false;
let is_holiday = true;
if is_weekend || is_holiday {
println!("今天放假!");
}
}
印出 今天放假!。雖然 is_weekend 是 false,但 is_holiday 是 true,只要有一個是 true 就夠了。
! —— 不是(NOT)
把 true 變成 false,把 false 變成 true:
fn main() {
let raining = false;
if !raining {
println!("出門走走吧!");
}
}
印出 出門走走吧!。raining 是 false,加上 ! 之後就變成 true,所以條件成立。
你可以讀成:「如果沒有在下雨,就出門走走。」
重點整理
&&(而且):兩邊都為true才是true||(或者):只要一邊為true就是true!(不是):把true變false,false變true- 用邏輯運算子組合多個條件,寫出更精確的判斷
第一章第 13 集:let mut
本集目標
了解 Rust 的變數預設不可變,要用 mut 才能改值。
正文
今天來聊 Rust 一個很有特色的設計——變數預設不可變。
先看看會怎樣
fn main() {
let x = 5;
x = 10;
println!("{}", x);
}
你覺得會印出 10 嗎?不會。你會得到一個編譯錯誤:
error[E0384]: cannot assign twice to immutable variable `x`
Rust 在跟你說:「x 是不可變的,你不能再給它新的值。」
等等,為什麼不行?
在很多程式語言裡,變數就是可以隨便改的。但 Rust 的態度是:如果你不打算改它,就別讓它可以被改。
為什麼?因為如果一個值你知道它不會變,你在讀程式的時候就不用擔心它被偷改了。這在大型程式裡超級重要。
要改的話,加 mut
如果你確實需要改變值,加上 mut(mutable 的縮寫,意思是「可變的」):
fn main() {
let mut x = 5;
println!("x 原本是 {}", x);
x = 10;
println!("x 現在是 {}", x);
}
跑起來會印出:
x 原本是 5
x 現在是 10
這次就沒問題了!因為你用 let mut 告訴 Rust:「這個變數我之後會改。」
小整理
let x = 5; // 不可變,之後不能改
let mut x = 5; // 可變,之後可以改
Rust 不是不讓你改變數,它只是要你明確說出來。這是 Rust 的一個設計哲學:有意識地做出選擇。
重點整理
- Rust 的變數預設是不可變的,不能重新賦值
- 要讓變數可以改值,宣告時加上
mut:let mut x = 5; - 修改可變變數的值:直接用
x = 新值;(不需要再寫let) - 這是 Rust 的設計哲學:要你明確選擇,而不是默默允許修改
第一章第 14 集:複合賦值運算子
本集目標
學會用 +=、-= 等簡寫方式來更新變數的值。
正文
上一集學了 let mut 讓變數可以改值。今天來學一個偷懶的寫法。
x = x + 5 是什麼意思?
先來看一個很重要的觀念。假設你有一個變數 x 是 10,你想讓它加 5:
fn main() {
let mut x = 10;
x = x + 5;
println!("{}", x); // 15
}
這裡 x 同時出現在 = 的左邊和右邊。這不是數學上的「x 等於 x + 5」(那在數學上根本不成立對吧?),而是程式語言的意思:先算右邊的 x + 5(也就是 10 + 5 = 15),然後把結果存回左邊的 x。所以 x 的值就從 10 變成了 15。
+= 簡寫
上面那行其實有個更簡短的寫法:
fn main() {
let mut x = 10;
x += 5;
println!("{}", x); // 15
}
x += 5 就等於 x = x + 5,只是比較簡潔。
其他複合賦值運算子
減法、乘法、除法、取餘數都有對應的簡寫:
fn main() {
let mut a = 20;
a -= 3;
println!("20 - 3 = {}", a); // 17
a *= 2;
println!("17 * 2 = {}", a); // 34
a /= 4;
println!("34 / 4 = {}", a); // 8(整數除法)
a %= 3;
println!("8 % 3 = {}", a); // 2
}
一覽表
| 簡寫 | 等同於 |
|---|---|
x += 5 |
x = x + 5 |
x -= 5 |
x = x - 5 |
x *= 5 |
x = x * 5 |
x /= 5 |
x = x / 5 |
x %= 5 |
x = x % 5 |
小提醒
要用這些運算子,變數一定要是 let mut 宣告的,因為你正在改變它的值。
重點整理
- 複合賦值運算子:
+=、-=、*=、/=、%= x += 5就是x = x + 5的簡寫——先算右邊,再存回左邊- 使用前提:變數必須用
let mut宣告
第一章第 15 集:stdin
本集目標
讓程式讀取使用者的鍵盤輸入——先照抄,不用完全理解每一行。
正文
到目前為止,我們程式裡的值都是寫死的。但如果想讓使用者自己輸入呢?比如讓使用者輸入名字,然後程式跟他打招呼?
先照抄這段程式碼
fn main() {
println!("請輸入你的名字:");
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
println!("你好,{}!", input.trim());
}
跑起來的效果:
請輸入你的名字:
Andy
你好,Andy!
這段在幹嘛?
我知道這段看起來有點嚇人,但別擔心,我們先把它當成一個黑盒子——你只要知道它能讀取使用者輸入就好。
大概的意思是:
let mut input = String::new();→ 建立一個空的文字變數,準備接收輸入std::io::stdin().read_line(&mut input).expect("讀取失敗");→ 從鍵盤讀一行文字,存到input裡input.trim()→ 把多餘的空白和換行符號去掉
至於 String::new()、&mut、.expect() 這些是什麼意思?以後會慢慢教,現在先照抄就好。
為什麼先不解釋?
因為要解釋這段程式碼,需要先理解好幾個還沒學的觀念。與其硬塞一堆看不懂的解釋,不如先學會用,之後自然就懂了。
就像小時候你學騎腳踏車,不用先學力學和陀螺效應——先騎就對了。
重要的事
每次要讀使用者輸入,就把這三行拿去用:
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
let name = input.trim(); // 去掉尾巴的換行
重點整理
- 讀取使用者輸入的三行固定寫法:
String::new()→stdin().read_line()→.trim() - 這段先當成黑盒子照抄即可,背後的觀念之後會慢慢學
.trim()用來去掉輸入尾巴的換行符號
第一章第 16 集:parse
本集目標
學會把使用者輸入的文字轉換成數字。
正文
和上一集一樣,這集的語法先照抄就好,不用完全理解每一行在做什麼。之後學到更多概念會回來解釋。
上一集我們學了怎麼讀取使用者的輸入,但讀進來的東西是文字。如果使用者輸入了 "42",對 Rust 來說那是一串文字,不是數字 42。
你不能拿文字去做加減乘除,所以我們需要把它「轉換」成數字。
文字轉數字
fn main() {
println!("請輸入一個數字:");
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
let num = input.trim().parse::<i32>().expect("請輸入數字");
println!("你輸入的數字是 {}", num);
}
跑起來:
請輸入一個數字:
42
你輸入的數字是 42
關鍵在這行
let num = input.trim().parse::<i32>().expect("請輸入數字");
拆開來看:
input.trim()→ 去掉頭尾的空白和換行.parse::<i32>()→ 把文字解析成整數(i32就是一種整數型別).expect("請輸入數字")→ 如果轉換失敗(比如使用者輸入了 "abc"),就印這個錯誤訊息然後結束程式
完整的互動範例
fn main() {
println!("請輸入一個數字:");
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
let num = input.trim().parse::<i32>().expect("請輸入數字");
println!("{} 乘以 2 等於 {}", num, num * 2);
}
請輸入一個數字:
7
7 乘以 2 等於 14
現在你可以讀取數字並拿來運算了!
重點整理
- 使用者輸入的東西是文字,要用
.parse::<i32>()轉成整數才能做運算 .expect("錯誤訊息")在轉換失敗時會印出訊息並結束程式- 完整流程:
input.trim().parse::<i32>().expect("請輸入數字")
第一章第 17 集:綜合練習
本集目標
把前面學的東西組合起來,做一個「輸入分數 → 判斷等第」的小程式。
正文
恭喜你撐到現在!今天我們要把前面學的東西全部串起來,做一個真正有用的小程式。
目標
讓使用者輸入分數,程式自動判斷等第並印出來。
完整程式碼
fn main() {
println!("請輸入你的分數:");
// 讀取使用者輸入
let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
// 把文字轉成數字
let score = input.trim().parse::<i32>().expect("請輸入數字");
// 判斷等第
if score >= 90 {
println!("你的成績是 A");
} else if score >= 80 {
println!("你的成績是 B");
} else if score >= 70 {
println!("你的成績是 C");
} else {
println!("你的成績是 F");
}
}
跑跑看
請輸入你的分數:
85
你的成績是 B
請輸入你的分數:
92
你的成績是 A
請輸入你的分數:
45
你的成績是 F
回顧一下用了哪些技巧
- println! → 印提示訊息(第一章第 2 集)
- let mut + String::new() → 準備接收輸入(第一章第 15 集)
- stdin().read_line() → 讀取鍵盤輸入(第一章第 15 集)
- .trim().parse::\<i32\>() → 文字轉數字(第一章第 16 集)
- if / else if / else → 條件判斷(第 8、10、11 集)
看到了嗎?你已經學會了這麼多東西,而且它們組合起來就能做出一個有互動性的小程式。這就是程式設計的魅力——把小塊的知識拼在一起,就能做出有用的東西。
挑戰
如果你想多練習,試試看:
- 加一個 D 等第(60~69 分)
- 如果分數超過 100 或小於 0,印出「分數不正確」
重點整理
- 結合前面學的
stdin、parse、if/else if,就能做出有互動性的程式 - 程式設計的魅力:把小塊知識拼在一起,就能做出有用的東西
- 除了學新語法,多練習組合應用也很重要
第一章第 18 集:loop + break
本集目標
用 loop 做出一個無限迴圈,再用 break 在適當的時機跳出來。
正文
到目前為止,我們的程式都是跑一次就結束。但如果有些事情需要重複做呢?比如倒數計時:5、4、3、2、1、發射!
這就需要迴圈。
loop —— 無限迴圈
loop 就是一直跑、一直跑,永遠不停:
fn main() {
loop {
println!("停不下來啊啊啊");
}
}
如果你真的跑了這段程式,它會一直印一直印……你得用 Ctrl + C 強制停止它。
所以我們需要一個「出口」。
break —— 跳出迴圈
fn main() {
let mut count = 5;
loop {
if count == 0 {
println!("發射!");
break;
}
println!("{}", count);
count -= 1;
}
}
跑起來:
5
4
3
2
1
發射!
它是怎麼運作的?
count從 5 開始- 進入
loop,先檢查count == 0嗎?不是,印出 5,然後count -= 1,count 變成 4 - 再回到
loop的開頭,檢查count == 0嗎?不是,印出 4,count 變成 3 - ……一直重複……
- 當
count變成 0,if count == 0成立,印出「發射!」,然後break跳出迴圈 - 程式結束
重點整理
loop會無限重複執行大括號裡的程式碼break用來跳出迴圈,讓程式繼續往下跑- 沒有
break的loop就是無限迴圈,記得一定要有出口
第一章第 19 集:while
本集目標
用 while 迴圈改寫倒數計時,對比 loop + break 的寫法。
正文
上一集我們用 loop + break 做了倒數計時。今天來學另一種迴圈——while,它讓同樣的邏輯寫起來更乾淨。
用 while 改寫倒數計時
fn main() {
let mut count = 5;
while count > 0 {
println!("{}", count);
count -= 1;
}
println!("發射!");
}
跑起來:
5
4
3
2
1
發射!
結果一模一樣!
跟 loop + break 比一下
上一集的寫法:
fn main() {
let mut count = 5;
loop {
if count == 0 {
println!("發射!");
break;
}
println!("{}", count);
count -= 1;
}
}
while 的寫法:
fn main() {
let mut count = 5;
while count > 0 {
println!("{}", count);
count -= 1;
}
println!("發射!");
}
有看出差別嗎?while 把「條件判斷」和「迴圈」合在一起了。不需要自己寫 if 和 break,只要告訴 while:「只要這個條件成立,就繼續跑。」
while 的白話文
當條件成立的時候,就一直做大括號裡的事。
while count > 0 → 只要 count 大於 0,就繼續跑。count 一旦變成 0,條件不成立了,就自動停下來。
什麼時候用 loop,什麼時候用 while?
- while:你在迴圈開始前就知道要不要繼續(先檢查條件,再決定要不要跑)
- loop + break:你需要在迴圈中間某個地方才能決定要不要停下來
兩種都可以達成目的,只是 while 在很多情況下寫起來更簡潔。
重點整理
while迴圈:只要條件成立就一直跑,條件不成立就自動停- 比
loop + break更簡潔,適合在迴圈開始前就知道要不要繼續的情況 loop + break適合在迴圈中間才能決定要不要停的情況
第一章第 20 集:for + range
本集目標
用 for 迴圈搭配範圍(range)來重複執行,不用自己處理計數器。
正文
前兩集我們學了 loop 和 while,都需要自己手動管理計數器(count -= 1 之類的)。今天來學一個更簡單的寫法——for 迴圈。
for + 範圍
fn main() {
for i in 0..5 {
println!("{}", i);
}
}
跑起來:
0
1
2
3
4
0..5 是什麼?
0..5 叫做範圍(range),意思是「從 0 開始,到 5 之前」。注意,不包含 5!
所以 0..5 就是 0、1、2、3、4 這五個數字。
你可以把 for i in 0..5 讀成:「讓 i 依序從 0 跑到 4,每次做大括號裡的事。」
注意這裡的 i 不需要寫 let——for 會自動幫你宣告它。而且 i 在 for 後面的大括號 {} 裡面都可以使用。
要包含結尾呢?用 0..=5
fn main() {
for i in 0..=5 {
println!("{}", i);
}
}
跑起來:
0
1
2
3
4
5
0..=5 多了一個 =,表示「包含 5」。
比較一下
| 語法 | 意思 | 產生的數字 |
|---|---|---|
0..5 |
0 到 4 | 0, 1, 2, 3, 4 |
0..=5 |
0 到 5 | 0, 1, 2, 3, 4, 5 |
1..4 |
1 到 3 | 1, 2, 3 |
1..=4 |
1 到 4 | 1, 2, 3, 4 |
while 和 for 也能用 break
之前在 loop 裡面學過 break,其實 while 和 for 也能用。要注意的是,break 只會跳出迴圈,不會跳出 if 之類的控制結構。所以下面這段程式碼裡,break 是跳出 for 迴圈,不是跳出 if:
fn main() {
for i in 0..10 {
if i == 5 {
println!("找到 5 了,不找了!");
break;
}
println!("{}", i);
}
}
跑起來只會印 0~4,遇到 5 就 break 跳出迴圈了。
重點整理
for i in 0..5讓i從 0 跑到 4(不含尾),0..=5則包含 5- 相比
while,for不用手動遞增計數器和檢查條件,更簡潔也更不容易出錯 break在loop、while、for裡面都可以使用
第一章第 21 集:巢狀迴圈
本集目標
把迴圈放進迴圈裡——用巢狀迴圈印出九九乘法表。
正文
上一集學了 for 迴圈,今天來玩個進階的——把一個迴圈放進另一個迴圈裡面。
什麼是巢狀迴圈?
「巢狀」就是「一層包一層」的意思,像俄羅斯套娃一樣。外面的迴圈跑一次,裡面的迴圈就會完整跑完一輪。
九九乘法表
來挑戰一下,用巢狀迴圈印出九九乘法表:
fn main() {
for i in 1..=9 {
for j in 1..=9 {
print!("{} x {} = {} ", i, j, i * j);
}
println!(); // 換行
}
}
跑起來:
1 x 1 = 1 1 x 2 = 2 1 x 3 = 3 ... 1 x 9 = 9
2 x 1 = 2 2 x 2 = 4 2 x 3 = 6 ... 2 x 9 = 18
...
9 x 1 = 9 9 x 2 = 18 9 x 3 = 27 ... 9 x 9 = 81
它是怎麼運作的?
- 外面的迴圈
i從 1 跑到 9 - 當
i = 1時,裡面的迴圈j從 1 跑到 9 → 印出 1×1, 1×2, ... 1×9 - 裡面的迴圈跑完後,
println!()換行 - 外面的迴圈走到
i = 2,裡面的迴圈又從 1 跑到 9 → 印出 2×1, 2×2, ... 2×9 - 以此類推……
print! vs println!
這裡用了一個新東西:print!。它跟 println! 很像,差別在於 print! 印完不會換行,而 println! 印完會換行。
視覺化
外迴圈跑一次 = 一行:
i=1 → [j=1, j=2, j=3, ... j=9] → 換行
i=2 → [j=1, j=2, j=3, ... j=9] → 換行
...
i=9 → [j=1, j=2, j=3, ... j=9] → 換行
break 只跳出最內層
在巢狀迴圈裡用 break,它只會跳出最裡面那一層迴圈,外面那層還會繼續跑:
fn main() {
for i in 1..=3 {
for j in 1..=3 {
if j == 2 {
break; // 只跳出內層迴圈
}
println!("i={}, j={}", i, j);
}
}
}
跑起來:
i=1, j=1
i=2, j=1
i=3, j=1
每次 j 到 2 就 break 了,但外層的 i 還是繼續跑 1、2、3。
loop label:跳出指定層
如果你想直接跳出外面那層呢?可以用 loop label:
fn main() {
'outer: for i in 1..=3 {
for j in 1..=3 {
if j == 2 {
break 'outer; // 跳出外層迴圈
}
println!("i={}, j={}", i, j);
}
}
println!("結束!");
}
跑起來:
i=1, j=1
結束!
'outer: 是一個標籤(label),放在迴圈前面。break 'outer 就是說「跳出標記為 'outer 的那個迴圈」。注意標籤名稱前面要加 '(單引號)。
重點整理
- 巢狀迴圈就是迴圈裡面再放迴圈,外層跑一次、內層就完整跑一輪
print!和println!的差別:print!印完不換行break在巢狀迴圈裡只會跳出最內層的迴圈- 用 loop label(
'outer:+break 'outer)可以跳出指定的外層迴圈
第一章第 22 集:continue
本集目標
用 continue 跳過迴圈中的某些輪次。
正文
之前學了 break 可以跳出迴圈。今天來學 continue——它不是跳出迴圈,而是跳過這一次,直接進入下一次迴圈。
只印奇數
fn main() {
for i in 0..10 {
if i % 2 == 0 {
continue;
}
println!("{}", i);
}
}
跑起來:
1
3
5
7
9
它是怎麼運作的?
迴圈 i 從 0 跑到 9:
i = 0→0 % 2 == 0嗎?是(偶數),continue!跳過,不印。i = 1→1 % 2 == 0嗎?不是(奇數),繼續往下跑,印出 1。i = 2→ 偶數,continue,跳過。i = 3→ 奇數,印出 3。- ……以此類推
break vs continue
break:整個迴圈結束,不再跑了continue:這一次跳過,但迴圈繼續跑下一次
和 break 一樣,continue 也是只作用在迴圈上,不會跳過 if 之類的控制結構。上面的程式碼裡,continue 是跳過 for 迴圈的這一次,不是跳過 if。
另一個例子
跳過 5 不印:
fn main() {
for i in 1..=10 {
if i == 5 {
continue;
}
println!("{}", i);
}
}
1
2
3
4
6
7
8
9
10
5 被跳過了,其他都正常印出來。
continue + loop label
上一集學過 break 'outer 可以跳出指定層迴圈,continue 也可以搭配 label:
fn main() {
'outer: for i in 1..=3 {
for j in 1..=3 {
if j == 2 {
continue 'outer; // 跳過外層迴圈的這一次
}
println!("i={}, j={}", i, j);
}
}
}
跑起來:
i=1, j=1
i=2, j=1
i=3, j=1
每次 j 到 2,就 continue 'outer 直接跳到外層的下一輪,所以 j=2 和 j=3 都不會印。
重點整理
continue跳過這一次,直接進入下一次迴圈break是「整個迴圈不跑了」,continue是「這次跳過,跑下一次」- 搭配
if可以有選擇性地跳過特定情況 continue 'outer可以搭配 loop label 跳過外層迴圈的一輪
第一章第 23 集:型別(基礎)
本集目標
認識 Rust 的基本型別。
正文
到現在為止,我們寫 let x = 5; 的時候都沒有特別說 x 是什麼「型別」。今天來正式認識一下型別是什麼。
什麼是型別?
型別就是在告訴 Rust:「這個變數裡面放的是什麼東西。」
是整數?小數?文字?還是 true/false?不同的型別代表不同的資料。
手動標註型別
你可以在變數名稱後面加上 : 型別 來指定:
fn main() {
let x: i32 = 5;
let negative: i32 = -10;
let y: f64 = 3.14;
let z: bool = true;
println!("x = {}", x);
println!("negative = {}", negative);
println!("y = {}", y);
println!("z = {}", z);
}
x = 5
negative = -10
y = 3.14
z = true
i32→ 整數(integer,32 位元)f64→ 浮點數(float,64 位元),就是帶小數點的數字bool→ 布林值,只有true和false
那之前為什麼不用標?
因為 Rust 很聰明!它會看你給的值,自動推斷型別:
let x = 5; // Rust 自動判斷:這是 i32
let y = 3.14; // Rust 自動判斷:這是 f64
let z = true; // Rust 自動判斷:這是 bool
這叫做型別推斷(type inference)。大部分的時候 Rust 都能自己搞定,你不用特別標。
重點整理
- 三個基本型別:
i32(整數)、f64(浮點數)、bool(布林值) - Rust 有型別推斷,大部分時候不用手動標註型別
- 需要時可以用
let x: i32 = 5;手動指定型別
第一章第 24 集:型別(數字詳解)
本集目標
認識 Rust 所有的數字型別,以及數字後綴的用法。
正文
上一集我們簡單認識了 i32 和 f64。今天來把 Rust 所有的數字型別都看一遍。
整數型別
Rust 的整數型別分成有號(可以是負數)和無號(只能是正數和零):
| 有號 | 無號 | 位元數 | 範圍(有號) |
|---|---|---|---|
i8 |
u8 |
8 | -128 ~ 127 |
i16 |
u16 |
16 | -32,768 ~ 32,767 |
i32 |
u32 |
32 | 約 ±21 億 |
i64 |
u64 |
64 | 超級大 |
i128 |
u128 |
128 | 天文數字 |
isize |
usize |
看電腦 | 64 位元電腦 = 64 位元 |
i= integer(整數),u= unsigned(無號)- 數字代表用幾個位元來存——位元越多,能存的數字越大
isize和usize的大小取決於你的電腦是 32 位元還是 64 位元(現在幾乎都是 64 位元)
日常用 i32 就夠了。 不確定的時候,用 i32。
浮點數型別
浮點數就是帶小數點的數字,只有兩種:
| 型別 | 精確度 |
|---|---|
f32 |
單精度(精確到約 7 位小數) |
f64 |
雙精度(精確到約 15 位小數) |
日常用 f64 就夠了。 Rust 預設的浮點數就是 f64。
浮點數運算
第 5 集教算術運算的時候,我們都用整數。浮點數一樣可以用 + - * / %,但有一個重要的差別——浮點數除法會保留小數:
fn main() {
let a = 10.0;
let b = 3.0;
println!("{}", a / b); // 3.3333333333333335
println!("{}", a % b); // 1.0
}
還記得第 5 集 10 / 3 的結果是 3(整數除法直接截斷)嗎?浮點數不會截斷,10.0 / 3.0 會得到 3.3333...。
不過浮點數有一個經典的坑——精確度問題:
fn main() {
println!("{}", 0.1 + 0.2); // 0.30000000000000004
}
0.1 + 0.2 不是 0.3!這不是 Rust 的 bug,而是所有程式語言都有的浮點數精確度限制。電腦用二進位存小數,有些十進位小數沒辦法精確表示。知道有這件事就好,不用太擔心。
Rust 怎麼推斷數字型別?
當你寫 let x = 5;,Rust 預設把它當成 i32。
當你寫 let y = 3.14;,Rust 預設把它當成 f64。
但 Rust 不只看數字本身,它也會根據你怎麼使用這個變數來推斷型別。有時候根據上下文,Rust 會推斷出 i32 以外的整數型別。這個之後遇到的時候會更清楚。
不過基本上來說,整數預設就是 i32、浮點數預設就是 f64。
數字後綴(literal suffix)
如果你想要指定型別,除了 let x: i64 = 5; 之外,還有一個更簡潔的寫法——直接在數字後面加型別名稱:
fn main() {
let a = 5i32; // i32
let b = 5u8; // u8
let c = 3.14f64; // f64
let d = 2.0f32; // f32
let e = 100000i64; // i64
println!("{} {} {} {} {}", a, b, c, d, e);
}
5i32 意思就是「5 這個數字,型別是 i32」。數字和型別之間不用空格,直接接在一起。
小提醒
不同型別的數字不能直接混著算:
fn main() {
let a: i32 = 5;
let b: i64 = 10;
// println!("{}", a + b); // ❌ 編譯錯誤!i32 和 i64 不能直接相加
}
這是 Rust 的安全設計,Rust 不會自動幫你轉型別。
重點整理
- 整數分有號(
i8-i128)和無號(u8-u128),日常用i32就夠了 - 浮點數有
f32和f64,日常用f64(Rust 預設) - 數字後綴(如
5i32、3.14f64)可以直接指定型別 - 浮點數除法保留小數,但有精確度問題(
0.1 + 0.2 ≠ 0.3) - 不同型別的數字不能直接混著算,Rust 不會自動轉型
第一章第 25 集:char
本集目標
認識 char 型別——用來存放「一個字元」的型別。
正文
之前我們用過字串(用雙引號 " 包起來的文字),今天來認識一個更小的單位——字元(char)。
char 是什麼?
char 就是一個字元。注意,是「一個」,不是一串。
fn main() {
let c = 'A';
let c2 = '你';
let c3 = '🦀';
println!("{}", c);
println!("{}", c2);
println!("{}", c3);
}
A
你
🦀
單引號 vs 雙引號
這很重要:
- 單引號
'→char,只能放一個字元 - 雙引號
"→ 字串,可以放很多字元
let c = 'A'; // char,一個字元
let s = "Hello"; // 字串,五個字元
如果你用單引號放超過一個字元,Rust 會報錯:
// let c = 'AB'; // ❌ 錯誤!char 只能放一個字元
Unicode
Rust 的 char 支援 Unicode,所以不只是英文字母,中文、日文、甚至 emoji 都可以:
fn main() {
let letter = 'R';
let chinese = '美';
let japanese = 'の';
let emoji = '😊';
println!("{} {} {} {}", letter, chinese, japanese, emoji);
}
R 美 の 😊
每一個都是合法的 char。
型別標註
如果你想明確標註型別:
fn main() {
let c: char = 'Z';
println!("{}", c);
}
不過通常不用特別標,Rust 看到單引號就知道是 char。
重點整理
char是「一個字元」的型別,用單引號包起來:'A'、'你'、'🦀'- 支援 Unicode,中文、日文、emoji 都是合法的
char - 單引號
'=char(一個字元),雙引號"= 字串(一串字元),別搞混
第一章第 26 集:跳脫字元
本集目標
學會用反斜線 \ 在字串裡插入換行、tab 等特殊字元。
正文
有時候你想在字串裡面放一些「特殊」的東西,比如換行、tab、或者雙引號本身。這時候就需要跳脫字元(escape character)。
\n —— 換行
fn main() {
println!("第一行\n第二行");
}
第一行
第二行
\n 就是告訴 Rust:「這裡換一行。」它不會真的印出 \n 這兩個字,而是產生一個換行的效果。
\t —— Tab
fn main() {
println!("名字\t分數");
println!("小明\t85");
println!("小華\t92");
}
名字 分數
小明 85
小華 92
\t 會插入一個 tab 空間。
\\ —— 反斜線本身
如果你想印出反斜線 \ 本身呢?因為 \ 已經被拿來當跳脫字元的開頭了,所以要用兩個反斜線:
fn main() {
println!("檔案路徑:C:\\Users\\Andy");
}
檔案路徑:C:\Users\Andy
\" —— 雙引號
字串是用 " 包起來的,那如果字串裡面要有 " 呢?
fn main() {
println!("他說:\"你好!\"");
}
他說:"你好!"
\" 告訴 Rust:「這個雙引號是字串內容,不是字串的結尾。」
在 char 裡使用
跳脫字元在 char 裡面也能用:
fn main() {
let newline: char = '\n';
let tab: char = '\t';
let backslash: char = '\\';
print!("A{}B{}C{}", newline, tab, backslash);
}
A
B C\
\' —— 單引號
在 char 裡面,如果你想表示單引號本身,就要跳脫:
fn main() {
let quote: char = '\'';
println!("{}", quote);
}
'
因為 char 是用 ' 包起來的,所以裡面要放 ' 就得用 \'。
不需要跳脫的情況
在字串("")裡面,單引號不需要跳脫,可以直接用:
fn main() {
println!("It's a test"); // ' 在字串裡不用跳脫
}
同樣地,在 char('')裡面,雙引號也不需要跳脫:
fn main() {
let c: char = '"'; // " 在 char 裡不用跳脫
println!("{}", c);
}
簡單來說:包在外面的那個符號才需要跳脫,另一個不用。
一覽表
| 跳脫字元 | 效果 |
|---|---|
\n |
換行 |
\t |
Tab |
\\ |
反斜線 \ |
\" |
雙引號 " |
\' |
單引號 ' |
重點整理
- 跳脫字元用
\開頭,代表特殊字元:\n(換行)、\t(tab)、\\(反斜線) \"在字串裡表示雙引號本身,\'在 char 裡表示單引號本身- 規則:包在外面的那個符號才需要跳脫,另一個不用
- 跳脫字元在字串和
char裡都能使用
第一章第 27 集:if 當表達式
本集目標
學會把 if 當成一個「表達式」,直接用它來給變數賦值。
正文
這是第一章的最後一集!今天要介紹 Rust 一個很酷的特性——if 不只是判斷用的,它還可以回傳值。
先看一般的寫法
假設你要根據條件給變數不同的值,你可能會這樣寫:
fn main() {
let condition = true;
let x;
if condition {
x = 1;
} else {
x = 2;
}
println!("{}", x);
}
這樣沒問題,但 Rust 有一個更簡潔的寫法。
if 當表達式
fn main() {
let condition = true;
let x = if condition { 1 } else { 2 };
println!("{}", x);
}
跑起來印出 1。
看到了嗎?if condition { 1 } else { 2 } 整個放在 let x = 的右邊,直接把結果賦值給 x。
如果 condition 是 true,x 就是 1;如果是 false,x 就是 2。
注意:大括號裡面不加分號
let x = if condition { 1 } else { 2 };
// ^ ^
// 沒有分號 沒有分號
這些值(1 和 2)後面沒有分號。在 Rust 裡,不加分號的值就是「回傳值」。這是 Rust 的表達式語法,之後學函式的時候會更詳細地講。
兩邊型別要一致!
fn main() {
let condition = true;
// let x = if condition { 1 } else { "hello" }; // ❌ 錯誤!
}
這會報錯,因為 1 是整數,"hello" 是字串。Rust 不允許 x 有時候是數字、有時候是字串——它需要一個確定的型別。
兩邊的大括號裡,值的型別必須相同:
// ✅ 兩邊都是整數
let x = if condition { 1 } else { 2 };
// ✅ 兩邊都是字串
let msg = if condition { "好" } else { "壞" };
// ❌ 一邊整數一邊字串
// let x = if condition { 1 } else { "hello" };
這有什麼好處?
- 程式碼更簡潔
x只需要宣告一次- Rust 的設計哲學:很多東西都可以是「表達式」,都能回傳值
重點整理
if在 Rust 裡是表達式,可以直接回傳值:let x = if condition { 1 } else { 2 };- 大括號裡作為回傳值的部分不加分號
if和else兩邊的型別必須一致
恭喜你完成了第一章!🎉 你已經學會了 Rust 的基本語法,包括變數、運算、條件判斷、迴圈、型別等等。下一章我們會開始學更多 Rust 的特色功能!
第二章:函數、陣列與切片
第二章第 1 集:const
本集目標
用 const 宣告一個永遠不會變的常數,並了解它和 let 的差別。
正文
上一章我們學了 let 來宣告變數,今天來認識它的好朋友——const。
const 就是「常數」,意思是:這個值從頭到尾都不會變,而且在編譯的時候就已經決定好了。
來看語法:
fn main() {
const MAX_SCORE: i32 = 100;
println!("最高分是:{}", MAX_SCORE);
}
跑起來會印出:
最高分是:100
看起來跟 let 很像對吧?但有幾個重要的差別:
差別一:const 一定要標型別
const MAX_SCORE: i32 = 100; // ✅ 一定要寫 : i32
let max_score = 100; // ✅ let 可以省略,編譯器會自己推
用 const 的時候,你不能偷懶不寫型別,編譯器會跟你抱怨。
差別二:命名慣例是全大寫加底線
const MAX_SCORE: i32 = 100; // ✅ 全大寫,用底線分隔
const PI_VALUE: f64 = 3.14159; // ✅ 這樣
const maxScore: i32 = 100; // ⚠️ 可以編譯,但編譯器會警告你
這是 Rust 社群的慣例:常數用 SCREAMING_SNAKE_CASE(全大寫蛇形命名)。不遵守的話程式還是能跑,但編譯器會碎碎念。
差別三:const 不能用 mut
const mut MAX: i32 = 100; // ❌ 不存在這種東西
let mut x = 5; // ✅ 這個可以
常數就是常數,不能變就是不能變,沒有「可變的常數」這種矛盾的東西。
差別四:const 可以放在函數外面
const MAX_PLAYERS: i32 = 10;
fn main() {
println!("最多 {} 位玩家", MAX_PLAYERS);
}
let 只能放在函數裡面,但 const 可以放在最外層,讓整個程式都能用到。
什麼時候用 const?
當你有一個值是固定不變的,而且你在寫程式的時候就知道它是多少,就用 const。比如:
const TAX_RATE: f64 = 0.05;
const MAX_RETRY: i32 = 3;
重點整理
const宣告編譯期常數,值永遠不會變- 一定要標型別(不能省略)
- 命名慣例是全大寫加底線,像
MAX_SCORE - 不能加
mut - 可以放在函數外面,讓整個程式都能用
第二章第 2 集:shadowing
本集目標
用 let 重新宣告同名變數(shadowing),以及它和 mut 的關鍵差別。
正文
Rust 有一個很有趣的功能叫做 shadowing(遮蔽)。簡單說就是:你可以用 let 再次宣告一個同名的變數,新的會「蓋掉」舊的。
fn main() {
let x = 5;
let x = x + 1;
println!("x = {}", x);
}
結果:
x = 6
第二行的 let x = x + 1; 其實是在說:「我要建立一個全新的 x,它的值是舊的 x 加 1。」舊的 x 就被蓋掉了,從此以後 x 就是 6。
你甚至可以連續 shadow 好幾次:
fn main() {
let x = 1;
let x = x + 1; // x = 2
let x = x * 3; // x = 6
println!("x = {}", x);
}
結果:
x = 6
Shadowing vs mut:最大的差別
「等等,這跟 mut 有什麼不一樣?不都是改值嗎?」
最大的差別是:shadowing 可以換型別,mut 不行。
fn main() {
// shadowing:可以從數字變成字串
let x = 5;
let x = "hello";
println!("x = {}", x);
}
結果:
x = hello
這完全合法!因為第二個 let x 是一個全新的變數,只是剛好同名而已。
但如果用 mut 試試看:
fn main() {
let mut x = 5;
x = "hello"; // ❌ 編譯錯誤!不能把字串塞進 i32
}
mut 只是讓你改「值」,型別還是鎖死的。但 shadowing 是建立一個全新的變數,所以型別可以完全不同。
實際用途
shadowing 最常見的用途是「轉換型別但保留名字」:
fn main() {
let input = "42"; // 這是字串
let input = input.trim().parse::<i32>().expect("請輸入數字"); // 轉成數字,還是叫 input
println!("input + 1 = {}", input + 1);
}
結果:
input + 1 = 43
如果沒有 shadowing,你就得取兩個不同的名字,像 input_str 和 input_num,有點囉嗦。
Shadowing 和作用域
還記得第一章第 9 集學的作用域嗎?Shadowing 在大括號 {} 裡面也能用,而且出了大括號,遮蔽就會結束,舊的變數會「回來」:
fn main() {
let x = 1;
{
let x = 2; // 在這個區塊這行之後,x 被遮蔽為 2
println!("區塊內 x = {}", x); // 2
}
println!("區塊外 x = {}", x); // 1
}
結果:
區塊內 x = 2
區塊外 x = 1
大括號裡的 let x = 2 建立了一個新的 x,遮蔽了外面的 x。但這個遮蔽只在大括號裡面有效——一出大括號,新的 x 就消失了,原本的 x(值為 1)又可以使用了。
這跟 mut 完全不同。如果用 mut 在區塊裡改值,出了區塊值就真的變了:
fn main() {
let mut x = 1;
{
x = 2; // 直接改值,不是 shadowing
}
println!("x = {}", x); // 2
}
所以再強調一次:shadowing 是建立新變數,mut 是改舊變數的值。在作用域裡這個差別特別明顯。
重點整理
- 用
let重新宣告同名變數叫做 shadowing - 新的變數會蓋掉舊的
- 和
mut最大的差別:shadowing 可以換型別 - 實際上每次
let都是建立一個全新的變數,只是名字一樣 - 在大括號裡 shadow 的變數,出了大括號就消失,原本的變數會「回來」
第二章第 3 集:底線變數
本集目標
用底線 _ 開頭的變數名來告訴編譯器「我知道這個沒用到,別唸我」。
正文
Rust 的編譯器很貼心(有時候有點煩),如果你宣告了一個變數但沒有使用它,它會給你一個警告:
fn main() {
let x = 5;
// 沒有用到 x
}
編譯的時候會看到:
warning: unused variable: `x`
程式還是能跑,但那個黃色的警告看了就不舒服。怎麼消除呢?
方法一:加底線前綴
在變數名前面加一個底線 _:
fn main() {
let _x = 5;
// 沒有用到 _x,但編譯器不會警告了
}
這樣編譯器就懂了:「喔,你是故意不用的,好吧。」
注意,_x 還是一個正常的變數,你想用的話還是可以用:
fn main() {
let _x = 5;
println!("{}", _x); // 還是可以用
}
方法二:單獨的底線 _
如果你連名字都不想取,就直接用一個底線:
fn main() {
let _ = 42;
}
這代表「我完全不在乎這個值」。沒有名字,你之後也沒辦法用它。
_x vs _ 的差別
_x:有名字,值會被保留,之後還能用_:沒名字,值馬上就丟掉了
大部分情況下用哪個都行。
重點整理
- 變數沒用到時,Rust 編譯器會警告你
- 在變數名前面加
_(像_x)可以消除警告 - 單獨的
_代表「我完全不在乎這個值」 _x還能用,_不能用
第二章第 4 集:tuple
本集目標
用 tuple 把多個不同型別的值組合成一個,並學會怎麼取出裡面的值。
正文
到目前為止,我們一個變數只能存一個值。但如果我想把「一個整數、一個小數、一個布林值」綁在一起呢?這就是 tuple(元組)的用途。
建立 Tuple
fn main() {
let t = (1, 3.14, true);
println!("{}", t.0); // 1
println!("{}", t.1); // 3.14
println!("{}", t.2); // true
}
用小括號 () 把值包起來,用逗號隔開,就是一個 tuple 了。
要取裡面的值,用點加索引:t.0、t.1、t.2。注意索引從 0 開始喔!
Unit Type — 空的 Tuple
Rust 有一個特殊的 tuple,裡面什麼都沒有:
fn main() {
let _u: () = ();
}
這個 () 叫做 unit type(單元型別)。它代表「沒有值」。它的型別也是寫成 ()。
標型別
如果你想明確寫出 tuple 的型別:
fn main() {
let t: (i32, f64, bool) = (1, 3.14, true);
println!("{} {} {}", t.0, t.1, t.2);
}
每個位置的型別都要對應上。
單元素 Tuple — 別忘了逗號!
如果你想建立一個只有一個元素的 tuple,要記得加逗號:
fn main() {
let not_a_tuple = (5); // 這只是數字 5,加了括號而已
let a_tuple = (5,); // 這才是 tuple!注意逗號
println!("{}", a_tuple.0); // 5
}
(5) 只是一個被括號包住的數字,不是 tuple。(5,) 才是。那個逗號很重要!
型別也是一樣的寫法:
fn main() {
let t: (i32,) = (5,);
println!("{}", t.0);
}
(i32) 只是 i32 加了括號,(i32,) 才是單元素 tuple 的型別。
重點整理
- Tuple 用
()把不同型別的值打包在一起 - 用
t.0、t.1、t.2取值(索引從 0 開始) ()是空的 tuple,叫做 unit type- 單元素 tuple 要加逗號:
(5,)不是(5)
第二章第 5 集:{:?} Debug 格式
本集目標
用 {:?} 印出 tuple 等「沒辦法用 {} 印」的東西。
正文
到目前為止,我們都用 {} 來印東西:
fn main() {
let x = 42;
println!("{}", x); // 42 ✅
}
數字、bool 這些基本型別用 {} 都沒問題。但如果你試著用 {} 印一個 tuple:
fn main() {
let t = (1, 2, 3);
println!("{}", t); // ❌ 編譯錯誤!
}
編譯器會跟你說:
error: `(i32, i32, i32)` doesn't implement `std::fmt::Display`
翻譯成白話:「這個型別沒有實作 Display,我不知道要怎麼用『好看的方式』印出來。」
解決方法:用 {:?}
fn main() {
let t = (1, 2, 3);
println!("{:?}", t); // (1, 2, 3) ✅
}
結果:
(1, 2, 3)
{:?} 叫做 Debug 格式。它不是給使用者看的「漂亮格式」,而是給開發者看的「偵錯格式」。
Display {} vs Debug {:?}
對數字、bool 這些簡單型別來說,{} 和 {:?} 印出來的結果一樣。那 {:?} 的重點在哪?
它能印出 {} 印不了的東西——像 tuple。 Tuple 只有 Debug 格式,沒有 Display 格式。
美化版:{:#?}
如果資料很複雜(比如 tuple 套 tuple),可以用 {:#?} 印出「美化過的 Debug 格式」:
fn main() {
let data = ((1, 2), (3, 4), (5, 6));
println!("{:#?}", data);
}
結果會自動換行、縮排,看起來更清楚:
(
(1, 2),
(3, 4),
(5, 6),
)
小技巧:dbg! 巨集
Rust 還有一個很方便的偵錯工具 dbg!:
fn main() {
let x = 5;
dbg!(x);
dbg!(x + 1);
}
它會印出檔名、行數和值,超方便:
[src/main.rs:3] x = 5
[src/main.rs:4] x + 1 = 6
重點整理
{}是 Display 格式,給使用者看的,但不是所有型別都支援{:?}是 Debug 格式,給開發者看的,tuple 等複合型別都能用{:#?}是美化版的 Debug 格式,複雜資料用這個更清楚dbg!是快速偵錯的好幫手,會印出檔名和行數
第二章第 6 集:簡單函數
本集目標
用 fn 定義自己的函數,並在 main 裡呼叫它。
正文
到目前為止,我們幾乎所有的程式碼都寫在 main 裡面。但如果程式越來越大,全部擠在一起就很亂。這時候我們可以把一段程式碼「包裝」成一個函數(function),想用的時候呼叫它就好。
定義一個函數
fn greet() {
println!("你好!歡迎來到 Rust 的世界!");
}
fn main() {
greet();
}
結果:
你好!歡迎來到 Rust 的世界!
拆解一下語法:
fn→ 告訴 Rust「我要定義一個函數」greet→ 函數的名字()→ 參數列表(目前是空的,下一集會學){ ... }→ 函數要做的事
然後在 main 裡面寫 greet(); 就是呼叫它。
函數可以呼叫好幾次
fn greet() {
println!("哈囉!");
}
fn main() {
greet();
greet();
greet();
}
結果:
哈囉!
哈囉!
哈囉!
這就是函數的好處——寫一次,用很多次。
函數定義的位置:上面或下面都行
在某些語言裡,函數必須在使用之前先定義。但 Rust 不用!
fn main() {
greet(); // ✅ 先呼叫
}
fn greet() { // 後定義
println!("你好!");
}
這樣也完全沒問題。Rust 編譯器會先掃過整個檔案,所以不管你把函數放在 main 上面還是下面,都找得到。
函數命名慣例
Rust 的函數名用蛇形命名法(snake_case):全小寫,單字之間用底線 _ 隔開。
fn say_hello() { // ✅ 蛇形命名
println!("Hello!");
}
fn sayHello() { // ⚠️ 可以跑,但編譯器會警告
println!("Hello!");
}
重點整理
- 用
fn 名字() { ... }定義函數 - 在
main裡用名字();呼叫 - 函數定義放在
main上面或下面都可以 - 命名慣例是 snake_case(全小寫加底線)
第二章第 7 集:函數參數
本集目標
幫函數加上參數,讓它能接收外部傳進來的資料。
正文
上一集的 greet 函數每次都只能印一樣的東西,有點無聊。如果我們想讓函數更靈活——比如「你告訴我兩個數字,我幫你加起來」——就需要參數(parameter)。
加上參數
fn add(a: i32, b: i32) {
println!("{} + {} = {}", a, b, a + b);
}
fn main() {
add(3, 4);
add(10, 20);
}
結果:
3 + 4 = 7
10 + 20 = 30
語法拆解:
a: i32→ 第一個參數叫a,型別是i32b: i32→ 第二個參數叫b,型別也是i32- 參數之間用逗號隔開
呼叫的時候,add(3, 4) 就是把 3 傳給 a、4 傳給 b。
參數一定要標型別
在 Rust 裡,函數的參數一定要標型別,不能偷懶:
fn add(a, b) { // ❌ 編譯錯誤!沒標型別
println!("{}", a + b);
}
fn add(a: i32, b: i32) { // ✅ 一定要標
println!("{}", a + b);
}
「可是 let x = 5; 不是可以不標嗎?」
沒錯,let 可以讓編譯器自己推斷。但函數參數不行——因為函數是你的「對外介面」,Rust 希望介面要清清楚楚的,不要搞得模模糊糊。
多個參數、不同型別
參數可以有不同的型別:
fn describe(x: i32, is_positive: bool) {
println!("{} 是正數嗎?{}", x, is_positive);
}
fn main() {
describe(5, true);
describe(-3, false);
}
結果:
5 是正數嗎?true
-3 是正數嗎?false
一個參數也行
fn double(x: i32) {
println!("{} 的兩倍是 {}", x, x * 2);
}
fn main() {
double(5);
double(100);
}
結果:
5 的兩倍是 10
100 的兩倍是 200
重點整理
- 函數參數寫在小括號裡:
fn 名字(參數: 型別) - 多個參數用逗號隔開
- 參數一定要標型別,這是 Rust 的硬性規定
- 呼叫時傳入對應的值就好
第二章第 8 集:函數回傳值
本集目標
讓函數回傳一個值,並學會 Rust 獨特的「不加分號就是回傳值」的寫法。
正文
上一集的函數只是把結果印出來。但很多時候我們想要的是:「你算完之後把答案交回來,我自己決定要怎麼用。」
基本語法
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let result = add(3, 4);
println!("3 + 4 = {}", result);
}
結果:
3 + 4 = 7
重點來了:
-> i32寫在參數後面,告訴 Rust「這個函數會回傳一個 i32」- 函數最後一行
a + b沒有加分號 → 這就是回傳值
不加分號 = 回傳值
這是 Rust 最獨特的設計之一。函數最後一行如果不加分號,它的值就會自動被當成回傳值:
fn double(x: i32) -> i32 {
x * 2 // ✅ 沒有分號,這就是回傳值
}
加了分號會怎樣?
如果你不小心加了分號:
fn double(x: i32) -> i32 {
x * 2; // ❌ 加了分號
}
編譯器會報錯。為什麼?因為加了分號之後,x * 2 的計算結果會被丟掉,而函數最後沒有留下任何值。在這種狀況下,實際回傳的是 ()(unit type,還記得第二章第 4 集嗎?)。但你答應了要回傳 i32,型別不符,編譯器就會抱怨。
沒寫回傳值的函數
回頭看第二章第 6 集的 greet 函數,它沒有寫 -> 回傳值:
fn greet() {
println!("你好!");
}
在 Rust 裡,所有函數都有回傳值。沒寫 -> 的話,就等同於寫 -> ():
fn greet() -> () {
println!("你好!");
}
只是 -> () 通常省略不寫。println!("你好!"); 最後有分號,計算結果被丟掉,函數回傳 ()——剛好符合宣告。
接住回傳值
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let result = add(3, 4);
println!("結果:{}", result);
// 也可以直接用在表達式裡
println!("再加 10:{}", add(3, 4) + 10);
}
結果:
結果:7
再加 10:17
用 Tuple 回傳多個值
函數只能回傳「一個」值,但如果你想回傳多個呢?把它們裝在 tuple 裡就好:
fn swap(a: i32, b: i32) -> (i32, i32) {
(b, a)
}
fn main() {
let result = swap(1, 2);
println!("第一個:{},第二個:{}", result.0, result.1);
}
結果:
第一個:2,第二個:1
-> (i32, i32) 代表回傳一個包含兩個 i32 的 tuple。呼叫之後用 .0、.1 取出裡面的值。
再來一個實用的例子:
fn min_max(a: i32, b: i32) -> (i32, i32) {
if a < b {
(a, b)
} else {
(b, a)
}
}
fn main() {
let result = min_max(7, 3);
println!("最小:{},最大:{}", result.0, result.1);
}
結果:
最小:3,最大:7
重點整理
- 用
-> 型別宣告函數的回傳型別 - 函數最後一行不加分號,就是回傳值(這是 Rust 的慣用寫法)
- 加了分號就變成普通語句,回傳的會是
() - 沒寫回傳值的函數,其實回傳的是
() - 想回傳多個值?用 tuple 包起來:
-> (i32, i32),用.0、.1取值
第二章第 9 集:early return
本集目標
用 return 關鍵字在函數中途就把值回傳出去,不用等到最後一行。
正文
上一集我們學到:函數最後一行不加分號就是回傳值。但有時候你想在函數中間就回傳——遇到某個條件就提前結束。這時候就要用 return 關鍵字。
基本範例:絕對值
fn abs(x: i32) -> i32 {
if x >= 0 {
return x; // 如果 x 是正數或零,直接回傳
}
-x // 走到這裡代表 x 是負數,回傳 -x
}
fn main() {
println!("abs(5) = {}", abs(5));
println!("abs(-3) = {}", abs(-3));
println!("abs(0) = {}", abs(0));
}
結果:
abs(5) = 5
abs(-3) = 3
abs(0) = 0
注意看:
return x;→ 用return關鍵字,要加分號- 最後一行
-x→ 不加分號,這是「自然回傳」
return vs 不加分號
兩種回傳方式的比較:
// 方式一:用 return(通常用在「提前離開」)
fn abs_v1(x: i32) -> i32 {
if x >= 0 {
return x;
}
-x
}
// 方式二:純粹用表達式(整個 if-else 就是回傳值)
fn abs_v2(x: i32) -> i32 {
if x >= 0 {
x
} else {
-x
}
}
兩種都對!Rust 社群的慣例是:
- 能用表達式寫的,就用表達式(方式二)
- 需要提前離開的,才用 return(方式一)
實用場景:提前擋掉不合法的輸入
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
println!("錯誤:不能除以零!");
return 0.0; // 提前離開
}
a / b
}
fn main() {
println!("{}", divide(10.0, 3.0));
println!("{}", divide(10.0, 0.0));
}
結果:
3.3333333333333335
錯誤:不能除以零!
0.0
這種「先檢查、不對就提前走人」的寫法叫做 guard clause(守衛子句),在實務中非常常見。
不要到處用 return
雖然每個回傳值都寫 return 也能跑,但在 Rust 裡這不是好習慣:
// 不太 Rust 的寫法
fn add(a: i32, b: i32) -> i32 {
return a + b; // 可以跑,但沒必要
}
// Rust 慣用寫法
fn add(a: i32, b: i32) -> i32 {
a + b // 最後一行直接當回傳值
}
return 留給「提前離開」的場景就好。
重點整理
return 值;可以在函數中途提前回傳(記得加分號)- 最後一行不加分號的自然回傳是 Rust 的慣用寫法
return最常用在 guard clause:先檢查條件,不對就提前走人- 不要每個回傳值都寫
return,只在需要提前離開時才用
第二章第 10 集:遞迴
本集目標
讓函數呼叫自己來解決問題,這個技巧叫做「遞迴」。
正文
你有沒有想過:函數可以在自己裡面呼叫自己嗎?
答案是可以的,而且這個技巧叫做遞迴(recursion)。聽起來很玄,但其實概念很簡單。
經典範例:階乘
「5 的階乘」寫成 5!,意思是 5 × 4 × 3 × 2 × 1 = 120。
用遞迴的思路想:
5! = 5 × 4!4! = 4 × 3!3! = 3 × 2!2! = 2 × 1!1! = 1(到這裡停下來)
看到了嗎?每一步都是「自己乘以比自己小一號的階乘」,最後到 1 就停。
fn factorial(n: i32) -> i32 {
if n <= 1 {
1
} else {
n * factorial(n - 1)
}
}
fn main() {
println!("5! = {}", factorial(5));
println!("3! = {}", factorial(3));
println!("1! = {}", factorial(1));
}
結果:
5! = 120
3! = 6
1! = 1
遞迴的兩個關鍵
每個遞迴函數都需要兩樣東西:
1. Base case(基底情況):什麼時候停下來
if n <= 1 {
1 // 停!不再呼叫自己
}
2. Recursive case(遞迴情況):怎麼把問題縮小
n * factorial(n - 1) // 把問題縮小:n 變成 n-1
如果忘記寫 base case,函數就會無限呼叫自己,最後程式就炸了。
追蹤執行過程
讓我們追蹤 factorial(5) 的執行過程:
factorial(5)
= 5 * factorial(4)
= 5 * (4 * factorial(3))
= 5 * (4 * (3 * factorial(2)))
= 5 * (4 * (3 * (2 * factorial(1))))
= 5 * (4 * (3 * (2 * 1)))
= 5 * (4 * (3 * 2))
= 5 * (4 * 6)
= 5 * 24
= 120
就像俄羅斯套娃一樣,一層一層展開,到底之後再一層一層收回來。
另一個例子:倒數
fn countdown(n: i32) {
if n <= 0 {
println!("發射!🚀");
return;
}
println!("{}...", n);
countdown(n - 1);
}
fn main() {
countdown(5);
}
結果:
5...
4...
3...
2...
1...
發射!🚀
遞迴 vs 迴圈
其實上面的例子都可以用迴圈寫。那什麼時候用遞迴?什麼時候用迴圈?
- 簡單的重複 → 迴圈比較直覺
- 問題本身就是遞迴結構 → 遞迴比較自然
現在先知道遞迴怎麼寫就好,之後遇到適合的場景自然會用到。
重點整理
- 遞迴就是函數呼叫自己
- 一定要有 base case(停止條件),不然會無限迴圈
- 每次呼叫都要讓問題變小,往 base case 靠近
第二章第 11 集:陣列基礎
本集目標
用陣列(array)把多個相同型別的值排成一列,並學會怎麼存取和建立。
正文
之前學了 tuple 可以把不同型別的值打包在一起。今天來認識另一個好朋友——陣列(array)。陣列是「把一堆相同型別的值排成一列」。
建立陣列
fn main() {
let arr = [1, 2, 3, 4, 5];
println!("{:?}", arr);
}
結果:
[1, 2, 3, 4, 5]
這裡用了 {:?}(Debug 格式)來印陣列——還記得第二章第 5 集嗎?陣列和 tuple 一樣,只有 Debug 格式,不能用 {}。
用中括號 [] 包起來,逗號隔開。注意:陣列裡的值必須是同一個型別。
let arr = [1, "hello", 3.14]; // ❌ 不行!型別不同
想混不同型別?用上幾集學的 tuple。
用索引取值
fn main() {
let arr = [1, 2, 3, 4, 5];
println!("第一個:{}", arr[0]);
println!("第三個:{}", arr[2]);
println!("最後一個:{}", arr[4]);
}
結果:
第一個:1
第三個:3
最後一個:5
重點:索引從 0 開始!所以 5 個元素的索引是 0、1、2、3、4。
越界會 panic
如果你存取一個不存在的索引:
fn main() {
let arr = [1, 2, 3, 4, 5];
println!("{}", arr[10]); // 💥 index out of bounds!
}
程式會直接崩潰(panic),印出類似這樣的錯誤:
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10'
Rust 不會讓你偷偷讀到不該讀的記憶體。比起默默給你一個垃圾值,直接崩潰反而更安全——至少你馬上知道哪裡出錯了。
陣列的型別
陣列的型別寫法是 [元素型別; 長度]:
fn main() {
let arr: [i32; 5] = [1, 2, 3, 4, 5];
println!("{:?}", arr);
}
[i32; 5] 代表「一個放 5 個 i32 的陣列」。注意,長度也是型別的一部分——[i32; 3] 和 [i32; 5] 是不同的型別!
大部分時候 Rust 可以自動推斷,不用手動標。但知道怎麼寫型別在之後會很有用。
快速建立:重複語法
如果你想建立一個「5 個 0」的陣列:
fn main() {
let zeros = [0; 5];
println!("{:?}", zeros);
}
結果:
[0, 0, 0, 0, 0]
[0; 5] 的意思是「值 0,重複 5 次」。分號前面是值,後面是個數。
再來幾個例子:
fn main() {
let ones = [1; 10]; // 10 個 1
let flags = [true; 3]; // 3 個 true
println!("{:?}", ones);
println!("{:?}", flags);
}
重點整理
- 陣列用
[值1, 值2, ...]建立,所有元素必須同型別 - 索引從 0 開始,用
arr[0]取值 - 存取超出範圍的索引會 panic(程式崩潰)
- 陣列型別寫法是
[型別; 長度],如[i32; 5](長度也是型別的一部分) [值; 個數]可以快速建立重複的陣列,如[0; 5]- 用
{:?}印整個陣列
第二章第 12 集:陣列走訪
本集目標
用 for 迴圈走過陣列裡的每一個元素。
正文
上一集我們學了怎麼用 arr[0]、arr[1] 一個一個取值。但如果陣列有 100 個元素,總不能寫 100 行吧?這時候就要用 for 迴圈來走訪(iterate)整個陣列。
基本語法
fn main() {
let arr = [1, 2, 3, 4, 5];
for x in arr {
println!("{}", x);
}
}
結果:
1
2
3
4
5
for x in arr 的意思是:「把 arr 裡的元素一個一個拿出來,每次放進 x,然後執行大括號裡的程式碼。」
幫元素做運算
fn main() {
let scores = [80, 95, 72, 88, 100];
for score in scores {
if score >= 90 {
println!("{} 分 → 優秀!", score);
} else {
println!("{} 分 → 加油!", score);
}
}
}
結果:
80 分 → 加油!
95 分 → 優秀!
72 分 → 加油!
88 分 → 加油!
100 分 → 優秀!
加總所有元素
fn main() {
let arr = [1, 2, 3, 4, 5];
let mut total = 0;
for x in arr {
total += x;
}
println!("總和:{}", total);
}
結果:
總和:15
先用 let mut total = 0; 建立一個可變的累加器,每次迴圈把值加上去。
for 和 while 的差別
上一章學了 while 迴圈。走訪陣列的時候:
fn main() {
let arr = [1, 2, 3, 4, 5];
// 用 while(比較囉嗦,而且容易寫錯索引)
let mut i = 0;
while i < 5 {
println!("{}", arr[i]);
i += 1;
}
// 用 for(簡潔、安全、不會越界)
for x in arr {
println!("{}", x);
}
}
走訪陣列的時候,for 比 while 好太多了——更短、更安全、不用自己管索引。
重點整理
for x in arr { ... }走訪陣列的每個元素- 可以在迴圈裡對每個元素做運算、判斷、累加
- 走訪陣列時,
for比while更簡潔、更安全
第二章第 13 集:切片 &[T]
本集目標
用切片(slice)取出陣列的一部分,像透過窗戶看裡面的東西。
正文
有時候你不需要整個陣列,只想看其中一段。比如一個有 5 個元素的陣列,你只想看第 2 到第 4 個。這時候就可以用切片(slice)。
基本語法
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4];
println!("{:?}", slice);
}
結果:
[2, 3, 4]
和陣列、tuple 一樣,切片也只能用 {:?} 來印,不能用 {}。
&arr[1..4] 的意思是:「從索引 1 開始,到索引 4 之前為止。」
- 索引 1 → 2(包含)
- 索引 2 → 3(包含)
- 索引 3 → 4(包含)
- 索引 4 → 5(不包含)
所以結果是 [2, 3, 4]。
範圍的寫法
fn main() {
let arr = [1, 2, 3, 4, 5];
let a = &arr[0..3]; // [1, 2, 3] 從 0 到 3(不含 3)
let b = &arr[0..=2]; // [1, 2, 3] 從 0 到 2(包含 2)
let c = &arr[2..]; // [3, 4, 5] 從 2 到最後
let d = &arr[..3]; // [1, 2, 3] 從頭到 3(不含 3)
let e = &arr[..]; // [1, 2, 3, 4, 5] 整個陣列
println!("{:?}", a);
println!("{:?}", b);
println!("{:?}", c);
println!("{:?}", d);
println!("{:?}", e);
}
1..4→ 從 1 到 4(不含 4)1..=3→ 從 1 到 3(包含 3),還記得第一章第 20 集的..=嗎?一樣的用法2..→ 從 2 到結尾..3→ 從開頭到 3(不含 3)..→ 整個
切片是「視窗」,不是「複製」
這裡有個重要的觀念:切片不是把資料複製一份出來,而是「指向原本陣列的某一段」。就像透過窗戶看房間裡的東西——東西還是在房間裡,你只是從窗戶看進去。
fn main() {
let arr = [10, 20, 30, 40, 50];
let slice = &arr[1..4];
println!("陣列:{:?}", arr);
println!("切片:{:?}", slice);
}
結果:
陣列:[10, 20, 30, 40, 50]
切片:[20, 30, 40]
那個 & 是什麼?
你可能注意到切片前面有個 &。這個符號代表「借用」(borrow),是 Rust 最重要的概念之一。但現在不用深入理解——先記住「切片要加 &」就好,之後我們會詳細解釋。
現在你只需要知道:寫切片的時候前面要加 &。
切片的型別
還記得陣列的型別是 [i32; 5](型別包含長度)嗎?切片的型別是 &[i32]——沒有長度:
fn main() {
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..4];
println!("{:?}", slice);
}
&[i32] 代表「一段 i32 的切片」,不管長度是多少。這是切片和陣列最大的不同——陣列的長度是型別的一部分([i32; 3] 和 [i32; 5] 是不同型別),但切片不管長度,&[i32] 可以指向任意長度的連續區段。
走訪切片
切片也可以用 for 走訪:
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4];
for x in slice {
println!("{}", x);
}
}
結果:
2
3
4
複合型別
學完切片之後,讓我們整理一下:到目前為止,我們學過兩類型別。
基本型別(primitive types): i32、f64、bool、char 等等,每個值就是一個單獨的東西。
複合型別(compound types): 把其他型別組合在一起的型別。我們已經學了三種:
- tuple:
(i32, f64, bool)— 可以裝不同型別 - 陣列:
[i32; 5]— 同一種型別,固定長度 - 切片:
&[i32]— 同一種型別,不限長度
複合型別裡面的型別可以是任何型別,包含其他複合型別:
fn main() {
// 陣列裡面裝 tuple
let pairs: [(i32, bool); 3] = [(1, true), (2, false), (3, true)];
println!("{:?}", pairs);
// tuple 裡面裝陣列
let t: ([i32; 3], [i32; 3]) = ([1, 2, 3], [4, 5, 6]);
println!("{:?}", t);
// 陣列裡面裝陣列
let grid: [[i32; 2]; 3] = [[1, 2], [3, 4], [5, 6]];
println!("{:?}", grid);
// 切片也是複合型別
let arr: [i32; 5] = [10, 20, 30, 40, 50];
let slice: &[i32] = &arr[1..4];
println!("{:?}", slice);
// tuple 裡面裝切片
let pair: (&[i32], &[i32]) = (&arr[..2], &arr[3..]);
println!("{:?}", pair);
}
重點整理
- 切片用
&arr[start..end]或&arr[start..=end]取出陣列的一部分 start..end是「包含 start、不包含 end」start..end是「包含 start 也包含 end」- 切片是陣列的「視窗」,不是複製
- 切片型別是
&[i32](不含長度),陣列型別是[i32; 5](含長度) - 前面的
&代表借用,之後會詳細解釋 - 切片也可以用
for走訪 - tuple、陣列、切片都是複合型別——它們裡面可以裝任何型別,包含其他複合型別
第二章第 14 集:函數 + 切片參數
本集目標
用切片 &[i32] 當函數參數,這樣不管陣列多長都能傳進去。
正文
上一集學了切片,這集來看一個超實用的應用:把切片當作函數的參數。
先看問題
假設你想寫一個函數來計算陣列的總和。如果用陣列當參數:
fn sum(nums: [i32; 5]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
fn main() {
let a = [1, 2, 3, 4, 5];
println!("{}", sum(a)); // ✅ 可以
let b = [1, 2, 3];
// println!("{}", sum(b)); // ❌ 不行!b 有 3 個元素,但函數要 5 個
}
問題出在 [i32; 5]——你把長度寫死成 5 了。3 個元素的陣列就傳不進去。
解決方案:用切片
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
fn main() {
let a = [1, 2, 3, 4, 5];
let b = [10, 20, 30];
let c = [7];
println!("a 的總和:{}", sum(&a)); // 15
println!("b 的總和:{}", sum(&b)); // 60
println!("c 的總和:{}", sum(&c)); // 7
}
結果:
a 的總和:15
b 的總和:60
c 的總和:7
把參數型別從 [i32; 5] 改成 &[i32],就能接受任何長度的陣列了!
呼叫的時候要加 &:sum(&a) 表示「把 a 的切片傳進去」。
也可以傳切片的一部分
因為參數是 &[i32],你不只能傳整個陣列,也能傳一段切片:
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
fn main() {
let arr = [1, 2, 3, 4, 5];
println!("全部:{}", sum(&arr)); // 15
println!("前三個:{}", sum(&arr[..3])); // 6
println!("後三個:{}", sum(&arr[2..])); // 12
}
結果:
全部:15
前三個:6
後三個:12
這就是切片的威力——一個函數,各種用法。
為什麼切片比固定長度陣列好?
固定長度 [i32; 5] |
切片 &[i32] |
|---|---|
| 只能接受剛好 5 個元素 | 任何長度都行 |
| 換長度要重寫函數 | 一個函數通吃 |
在實務中,幾乎所有接受陣列的函數都用切片當參數。
重點整理
- 函數參數用
&[i32]而不是[i32; 5],就能接受任意長度 - 呼叫時傳
&arr或&arr[1..4]都行 - 切片參數讓函數更靈活、更通用
- 這是 Rust 實務中最常見的寫法
第二章第 15 集:字串切片 &str
本集目標
認識 &str 這個型別,原來我們一直在用的字串就是切片!
正文
前幾集我們學了陣列的切片 &[i32]。今天來認識另一種切片——字串切片。其實我們之前寫的 "hello" 就是字串切片。
字串的真面目
fn main() {
let s = "hello";
println!("{}", s);
}
這段程式碼你已經看了無數次了。但 s 的型別是什麼?
答案是:&str(字串切片)。
fn main() {
let s: &str = "hello"; // 明確標出型別
println!("{}", s);
}
&str 唸作「string slice」或「str ref」。它就像陣列切片 &[i32] 一樣,是「指向一段資料的視窗」。
和陣列切片的對比
| 陣列切片 | 字串切片 |
|---|---|
&[i32] |
&str |
| 指向一段 i32 資料 | 指向一段文字資料 |
let s = &arr[1..4]; |
let s = "hello"; |
概念完全一樣!只是一個是數字的切片,一個是文字的切片。
字串切片也可以取子字串
fn main() {
let s = "hello world";
let hello = &s[0..5];
let world = &s[6..11];
println!("{}", hello); // hello
println!("{}", world); // world
}
&s[0..5] 就是取 s 的前 5 個位元組(注意是位元組,不是字元)。
和陣列切片一樣,也可以用 ..= 來包含結尾:
fn main() {
let s = "hello world";
let hello = &s[0..=4]; // 包含索引 4,等同於 &s[0..5]
println!("{}", hello); // hello
}
⚠️ 中文字串切片要小心!
英文字母一個字佔 1 個位元組,但中文字通常佔 3 個位元組。如果你切的位置剛好在一個中文字的「中間」,程式會直接崩潰:
fn main() {
let s = "你好";
let first = &s[0..3]; // ✅ "你"(剛好 3 個位元組)
println!("{}", first);
}
你
但如果你試著切 &s[0..1]:
fn main() {
let s = "你好";
let oops = &s[0..1]; // ❌ 程式崩潰!
println!("{}", oops);
}
thread 'main' panicked at 'byte index 1 is not a char boundary'
因為「你」佔了 3 個位元組(索引 0、1、2),你切到索引 1 是這個字的「中間」,Rust 不允許這樣做。
簡單來說:對英文字串做切片很安全,但對中文字串做切片時,要確保切的位置剛好在字元的邊界上。如果不確定,先不要對中文字串用 &s[start..end]。
函數參數用 &str
現在你知道字串是 &str 了,就可以把它當函數參數:
fn greet(name: &str) {
println!("嗨,{}!", name);
}
fn main() {
greet("Andy");
greet("小明");
}
結果:
嗨,Andy!
嗨,小明!
"Andy" 本身就是 &str 型別,所以直接傳進去就行。
重點整理
"hello"的型別是&str,就是字串切片&str和陣列切片&[i32]的概念一樣——都是「指向一段資料的視窗」- 函數參數寫
&str就能接受字串 - 也可以用
&s[start..end]取子字串
恭喜你完成了第二章!🎉 這一章我們學到了更多組織程式的方法——函數、陣列、切片,還有各種讓程式碼更清晰的技巧。下一章我們將開始自訂型別,用 struct 和 enum 來描述你自己的資料!
第三章:Struct、Enum 與 Pattern Matching
第三章第 1 集:struct(named fields)
本集目標
學會用 struct 把多個相關的值組合在一起,形成一個自訂型別。
概念說明
到目前為止,我們用過的型別都是 Rust 內建的:i32、f64、bool、char,還有 tuple、陣列和切片。但實際寫程式的時候,你會需要自己定義新的型別。
struct 就是 Rust 讓你定義新型別的方式之一。定義一個 struct,就是在告訴 Rust:「我要一個新的型別,它裡面包含這些欄位。」
比如說,一個「點」有 x 座標和 y 座標。我們可以用 tuple (i32, i32) 來表示,但 tuple 只能用 .0、.1 取值,看不出哪個是 x、哪個是 y。用 struct 就能幫每個欄位取名字。
定義 struct 的語法是:
struct Point {
x: i32,
y: i32,
}
struct 定義一般放在 fn main() 外面,這樣其他函數也能用到。放在上面或下面都可以(和函數一樣,不受定義順序限制)。
建立一個 struct 的值時,要用 型別名 { 欄位名: 值 } 的寫法。取值的時候用 .欄位名。如果要修改 struct 的欄位,變數必須加 mut。
範例程式碼
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 3, y: 7 };
println!("x 座標是 {}", p.x);
println!("y 座標是 {}", p.y);
// Point 是一個型別,就像 i32 一樣,可以用在型別標注上
let p2: Point = Point { x: 100, y: 200 };
println!("p2 的座標是 ({}, {})", p2.x, p2.y);
// 也可以用 mut 讓 struct 的值可以修改
let mut q = Point { x: 0, y: 0 };
q.x = 10;
q.y = 20;
println!("q 的座標是 ({}, {})", q.x, q.y);
}
補充:trailing comma(結尾逗號)
注意 struct 定義裡,最後一個欄位後面也有逗號:
struct Point {
x: i32,
y: i32, // ← 這個逗號可加可不加
}
Rust 允許在 struct 定義、struct 建立、函數呼叫等地方的最後一個項目後面加逗號。這叫做 trailing comma(結尾逗號)。加了不會錯,而且好處是之後新增欄位時,不用回去幫上一行補逗號,git diff 也比較乾淨。
Rust 社群慣例是加上 trailing comma。
重點整理
struct讓你定義一個有名字欄位的自訂型別- struct 定義一般放在
fn main()外面,上面或下面都可以(和函數一樣) - 建立 struct 值的語法:
Point { x: 1, y: 2 } - 用
.欄位名取得欄位的值,例如p.x - 如果要修改 struct 的欄位,變數必須加
mut - 最後一個欄位後面的逗號(trailing comma)可加可不加,慣例是加
第三章第 2 集:tuple struct 與 unit struct
本集目標
學會用 tuple struct 定義沒有欄位名的 struct,以及完全沒有欄位的 unit struct。
概念說明
上一集學的 struct 每個欄位都有名字。但有時候,欄位的意義已經很明顯了,不需要特別取名。這時候可以用 tuple struct——它長得像 tuple 和 struct 的混合體。
struct Point(i32, i32);
建立值的時候用 Point(3, 7)——注意,這裡的 Point 既是型別的名字,也是建立值時使用的名字。取值用 .0、.1,就像 tuple 一樣。
上一集的 named field struct 也是同樣的道理:Point 既是型別名,也是建立值時寫 Point { x: 1, y: 2 } 用的名字。
另外還有一種更極端的情況:struct 完全沒有欄位,叫做 unit struct。它通常用來當作一個「標記」,表示某種身份或角色,但本身不帶任何資料。
struct Marker;
範例程式碼
// tuple struct:欄位沒有名字,用位置存取
struct Point(i32, i32);
// 另一個 tuple struct 的例子
struct Color(i32, i32, i32);
// unit struct:完全沒有欄位
struct Marker;
fn main() {
let p: Point = Point(3, 7);
println!("x = {}, y = {}", p.0, p.1);
let red: Color = Color(255, 0, 0);
println!("R={}, G={}, B={}", red.0, red.1, red.2);
// unit struct 建立時不需要括號或大括號
let _m: Marker = Marker;
println!("Marker 被建立了!(它不帶任何資料)");
}
重點整理
- tuple struct:
struct Point(i32, i32);,用.0、.1取值 - unit struct:
struct Marker;,沒有任何欄位 - tuple struct 適合欄位意義明顯、不需要命名的情況
- unit struct 適合當標記用,本身不攜帶資料
- 即使兩個 tuple struct 的欄位型別完全一樣,它們也是不同的型別(例如
Point(i32, i32)和Color(i32, i32, i32)是不同型別)
第三章第 3 集:enum(C-style)
本集目標
學會用 enum 定義一組固定的選項,讓變數只能是其中一個值。
概念說明
上一集我們學了 struct——用來定義「把多個值組合在一起」的新型別。這一集要學另一種定義新型別的方式:enum。
有時候我們想表達「這個東西只能是幾個選項之一」。比如說,一個交通燈只能是紅、黃、綠其中一種。
enum(enumeration,列舉)就是用來定義這種「多選一」的型別。和 struct 一樣,定義一個 enum 就是在告訴 Rust:「我要一個新的型別,它的值只能是這幾個選項之一。」最簡單的 enum 長這樣:
enum Color {
Red,
Green,
Blue,
}
每一個選項叫做一個 variant(變體)。建立 enum 值的時候,要用 型別名::變體名 的寫法:
let c = Color::Red;
注意中間是兩個冒號 ::,這在 Rust 裡叫做「路徑運算子」,表示「Color 底下的 Red」。
這種最基本的 enum——每個 variant 都不帶任何額外資料——有時候稱為 C-style enum,因為 C 語言的 enum 就是這樣。
範例程式碼
enum Direction {
Up,
Down,
Left,
Right,
}
fn main() {
let dir = Direction::Up;
// 目前我們還不會用 enum 做太多事
// 下一集會學 match,就能根據 enum 的值做不同的事情
// 這邊先展示怎麼建立不同的 enum 值
let _d1 = Direction::Down;
let _d2 = Direction::Left;
let _d3 = Direction::Right;
println!("方向已經設定好了!");
println!("(下一集學 match 之後,就能根據方向做不同的事)");
}
重點整理
enum和struct一樣,都是定義新型別的方式struct:把多個值組合在一起;enum:從多個選項中選一個- 用
::來指定是哪一個 variant,例如Direction::Up - C-style enum 的每個 variant 都不帶額外資料
- 和 struct 一樣,enum 定義一般放在
fn main()外面,上面或下面都可以 - 目前還無法直接印出 enum 的值(下一集學 match 就可以了)
第三章第 4 集:match C-style enum
本集目標
學會用 match 來根據 enum 的值執行不同的程式碼,並理解「窮舉」的概念。
概念說明
上一集我們定義了 enum,但沒辦法根據它的值做不同的事。現在來學 match——Rust 最強大的模式比對工具。
match 的基本語法是:
match 變數 {
模式1 => 做某件事,
模式2 => 做另一件事,
模式3 => 做第三件事,
}
每一行叫做一個「分支」(arm)。Rust 會從上到下檢查,找到第一個符合的模式就執行對應的程式碼。
最重要的規則:match 必須窮舉所有可能的值。 如果你的 enum 有三個 variant,你就必須處理全部三個。少寫一個,編譯器就會報錯。這是 Rust 幫你抓 bug 的方式——確保你不會忘記處理某種情況。
和 struct 和 enum 一樣,match 的最後一個分支後面也可以加 trailing comma(結尾逗號)。Rust 社群慣例是加上的。
範例程式碼
enum Color {
Red,
Green,
Blue,
}
fn main() {
let c = Color::Green;
match c {
Color::Red => println!("紅色"),
Color::Green => println!("綠色"),
Color::Blue => println!("藍色"),
}
// 再來一個例子
let light = Color::Red;
match light {
Color::Red => println!("停下來!"),
Color::Green => println!("可以走了!"),
Color::Blue => println!("這個交通燈有點奇怪..."),
}
}
重點整理
match會根據值比對不同的模式,執行對應的分支- 每個分支用
=>分隔模式和要執行的程式碼 - match 必須窮舉所有 variant——少一個就編譯失敗
- 分支從上到下比對,第一個符合的就會執行
- match 是 Rust 處理 enum 最基本的方式
第三章第 5 集:match 當表達式
本集目標
學會把 match 當作表達式使用,讓它回傳一個值。
概念說明
還記得第一章學過 if 可以當表達式嗎?
let x = if condition { 1 } else { 2 };
match 也可以!你可以把整個 match 放在 let 的右邊,讓每個分支回傳一個值:
let msg = match c {
Color::Red => "紅色",
Color::Green => "綠色",
Color::Blue => "藍色",
};
注意最後面有一個分號 ;,因為整個 let msg = match ... { ... }; 是一個 let 陳述式。
每個分支回傳的值的型別必須一致。如果第一個分支回傳 &str,其他分支也都要回傳 &str。
範例程式碼
enum Season {
Spring,
Summer,
Autumn,
Winter,
}
fn main() {
let s = Season::Autumn;
// match 當表達式,回傳 &str
let name = match s {
Season::Spring => "春天",
Season::Summer => "夏天",
Season::Autumn => "秋天",
Season::Winter => "冬天",
};
println!("現在是{}", name);
// match 當表達式,回傳 i32
let temp = match s {
Season::Spring => 22,
Season::Summer => 35,
Season::Autumn => 18,
Season::Winter => 8,
};
println!("大約 {} 度", temp);
}
重點整理
match可以當表達式,整個 match 會回傳一個值- 用法:
let x = match ... { ... };(最後別忘了分號) - 所有分支的回傳值型別必須一致
- 這和
if表達式的概念相同——Rust 裡很多東西都可以是表達式
第三章第 6 集:block 表達式
本集目標
學會用大括號 {} 建立 block 表達式,在裡面執行多行程式碼後回傳一個值。
概念說明
在 Rust 裡,一對大括號 {} 不只是作用域,它本身也是一個表達式,可以回傳值。規則很簡單:block 裡面最後一行如果不加分號,那一行的值就是整個 block 的回傳值。
let x = {
let y = 5;
y + 1 // 沒有分號 → 這就是 block 的回傳值
};
// x 現在是 6
這個概念在 match 裡特別有用。之前的 match 分支都只有一行,但如果你想在某個分支裡做多件事,就可以用 block:
match c {
Color::Red => {
println!("是紅色!");
"red"
}
// ...
}
block 裡可以宣告變數、做計算,最後一行不加分號就是回傳值。
注意:如果 match 的分支用了 block {},後面的逗號可以省略。因為 } 本身就是明確的結束標記,Rust 不需要逗號來分隔。但如果分支只有一行(沒有用 block),後面的逗號就不能省。
match s {
Season::Summer => {
println!("好熱啊!");
"炎熱的夏天"
} // ← 沒有逗號,OK
Season::Autumn => "涼爽的秋天", // ← 一行的分支,要逗號
// ...
}
範例程式碼
enum Season {
Spring,
Summer,
Autumn,
Winter,
}
fn main() {
// block 表達式的基本用法
let result = {
let a = 10;
let b = 20;
a + b // 最後一行不加分號 → 回傳值
};
println!("result = {}", result);
// 在 match 分支裡使用 block
let s = Season::Summer;
let description = match s {
Season::Spring => {
let temp = 22;
println!("春暖花開");
if temp > 20 {
"溫暖的春天"
} else {
"還有點涼的春天"
}
}
Season::Summer => {
println!("好熱啊!");
"炎熱的夏天"
}
Season::Autumn => "涼爽的秋天",
Season::Winter => "寒冷的冬天",
};
println!("{}", description);
}
重點整理
{}block 本身是一個表達式,最後一行不加分號就是回傳值let x = { ... };可以在 block 裡做多行計算後把結果賦值給 x- match 分支可以用
=> { ... }來執行多行程式碼,而且 block 後面不用加逗號 - block 裡宣告的變數只在 block 內有效(作用域)
- block 表達式在 Rust 裡非常常見,是很重要的基礎概念
第三章第 7 集:enum 攜帶 tuple variant
本集目標
學會讓 enum 的 variant 攜帶額外的資料,像 tuple 一樣。
概念說明
之前學的 C-style enum,每個 variant 就只是一個名字,不帶任何資料。但很多時候,不同的選項需要攜帶不同的資料。
比如說,「形狀」可以是圓形或長方形。圓形需要一個半徑,長方形需要寬和高——它們需要的資料不一樣。在 Rust 裡,你可以讓每個 variant 攜帶資料:
enum Shape {
Circle(f64), // 攜帶一個 f64(半徑)
Rectangle(i32, i32), // 攜帶兩個 i32(寬、高)
}
這種寫法像是在 variant 名字後面加上 tuple 的欄位,所以叫做 tuple variant。
建立值的方式就像呼叫函數一樣,把資料放在括號裡:
let s = Shape::Circle(3.14);
let r = Shape::Rectangle(10, 20);
注意:現在我們知道怎麼建立帶資料的 enum 了,但要「取出」裡面的資料,需要用 match——這個我們第 9 集會學。
範例程式碼
enum Shape {
Circle(f64),
Rectangle(i32, i32),
}
enum Message {
Quit, // 不帶資料(就像 C-style)
Echo(i32), // 帶一個 i32
Move(i32, i32), // 帶兩個 i32
}
fn main() {
let s1 = Shape::Circle(5.0);
let s2 = Shape::Rectangle(10, 20);
let m1 = Message::Quit;
let m2 = Message::Echo(42);
let m3 = Message::Move(3, 7);
// 目前先建立值就好
// 第 9 集會學怎麼用 match 取出裡面的資料
println!("形狀和訊息都建立好了!");
// 同一個 enum 裡,不同 variant 可以帶不同數量、不同型別的資料
// 甚至有些 variant 不帶資料也完全沒問題(像 Message::Quit)
}
重點整理
- enum variant 可以攜帶資料:
Circle(f64)表示 Circle 帶一個 f64 - 建立帶資料的 variant:
Shape::Circle(5.0) - 同一個 enum 裡,不同 variant 可以帶不同的資料
- 有些 variant 可以不帶資料,有些帶一個,有些帶多個——很靈活
- 要取出 variant 裡的資料,需要用 match(第 9 集會學)
第三章第 8 集:enum 攜帶 struct variant
本集目標
學會讓 enum 的 variant 用類似 struct 的方式攜帶有名字的欄位。
概念說明
上一集學了 tuple variant,欄位沒有名字,用位置來區分。但如果一個 variant 攜帶的資料比較多,沒有名字就很容易搞混。
Rust 允許你用類似 named-field struct 的寫法,讓 variant 的每個欄位都有名字:
enum Shape {
Circle { radius: f64 },
Rectangle { width: i32, height: i32 },
}
建立值的時候就像建立 struct 一樣:
let s = Shape::Circle { radius: 5.0 };
let r = Shape::Rectangle { width: 10, height: 20 };
同一個 enum 裡,有些 variant 可以用 tuple 形式,有些可以用 struct 形式,甚至有些不帶資料,完全可以混搭。
範例程式碼
enum Shape {
Circle { radius: f64 },
Rectangle { width: i32, height: i32 },
Dot, // 不帶資料的 variant 也可以混在一起
}
fn main() {
let s1 = Shape::Circle { radius: 5.0 };
let s2 = Shape::Rectangle { width: 10, height: 20 };
let s3 = Shape::Dot;
// 目前還不能直接取出裡面的欄位
// 第 10 集會學怎麼用 match 取出 struct variant 的資料
println!("三種形狀都建立好了!");
// 一個更生活化的例子
let event = Event::Click { x: 100, y: 200 };
println!("事件已建立!");
}
enum Event {
Click { x: i32, y: i32 },
KeyPress(char), // tuple 形式也可以混搭
Quit, // 不帶資料也行
}
重點整理
- variant 可以用 struct 形式攜帶有名字的欄位:
Circle { radius: f64 } - 建立值:
Shape::Circle { radius: 5.0 } - 同一個 enum 可以混搭:有的用 tuple 形式、有的用 struct 形式、有的不帶資料
- struct 形式的好處是欄位有名字,程式碼更容易讀懂
- 取出欄位資料需要用 match(第 10 集會學)
第三章第 9 集:match 取出 tuple variant 資料
本集目標
學會用 match 取出 enum tuple variant 裡面攜帶的資料。
概念說明
第 7 集我們學了怎麼建立帶資料的 enum variant,但一直沒辦法取出裡面的資料。現在終於可以了!
在 match 的模式裡,你可以用變數名來「接住」variant 裡的資料:
match s {
Shape::Circle(r) => println!("半徑是 {}", r),
Shape::Rectangle(w, h) => println!("寬 {},高 {}", w, h),
}
Shape::Circle(r) 裡的 r 不是固定的名字——你可以取任何名字。它的意思是「如果 s 是 Circle,就把裡面的那個 f64 值取出來,叫做 r」。
這就是所謂的模式比對(pattern matching):match 不只是比對「是哪個 variant」,還能同時把裡面的資料取出來給你用。
範例程式碼
enum Shape {
Circle(f64),
Rectangle(i32, i32),
}
fn main() {
let s = Shape::Circle(5.0);
match s {
Shape::Circle(r) => {
println!("這是一個圓形");
println!("半徑是 {}", r);
let area = r * r * 3.14159;
println!("面積大約是 {}", area);
}
Shape::Rectangle(w, h) => {
println!("這是一個長方形");
println!("寬 {},高 {}", w, h);
let area = w * h;
println!("面積是 {}", area);
}
}
// 再一個例子
let action = Action::Move(3, -2);
match action {
Action::Stop => println!("停止不動"),
Action::Move(dx, dy) => {
println!("往 x 方向移動 {},往 y 方向移動 {}", dx, dy);
}
}
}
enum Action {
Stop,
Move(i32, i32),
}
重點整理
- 在 match 模式裡,可以用變數名接住 tuple variant 的資料
Shape::Circle(r)→ 把 Circle 裡的值取出來叫做rShape::Rectangle(w, h)→ 把 Rectangle 裡的兩個值分別叫做w和h- 變數名可以自己取
- match 依然要窮舉所有 variant
第三章第 10 集:match 取出 struct variant 資料
本集目標
學會用 match 取出 enum struct variant 裡面攜帶的有名字欄位。
概念說明
第 9 集學了怎麼取出 tuple variant 的資料(用位置),現在來學取出 struct variant 的資料(用欄位名)。
語法是在模式裡用 欄位名: 變數名 的寫法:
match s {
Shape::Circle { radius: r } => println!("半徑 {}", r),
Shape::Rectangle { width: w, height: h } => println!("{}x{}", w, h),
}
radius: r 的意思是「把 radius 這個欄位的值取出來,叫做 r」。冒號左邊是欄位名,右邊是你自己取的變數名。
這和建立 struct variant 的語法很像,只是方向相反:建立是「把值放進去」,match 是「把值拿出來」。
範例程式碼
enum Shape {
Circle { radius: f64 },
Rectangle { width: i32, height: i32 },
}
fn main() {
let s = Shape::Rectangle { width: 10, height: 5 };
match s {
Shape::Circle { radius: r } => {
println!("這是圓形,半徑 = {}", r);
let area = r * r * 3.14159;
println!("面積大約 {}", area);
}
Shape::Rectangle { width: w, height: h } => {
println!("這是長方形");
println!("寬 = {},高 = {}", w, h);
let area = w * h;
println!("面積 = {}", area);
let perimeter = 2 * (w + h);
println!("周長 = {}", perimeter);
}
}
}
一般的 struct 也能用同樣的方式
不只 enum 的 struct variant,一般的 named field struct 也可以在 match 裡解構:
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 3, y: 0 };
match p {
Point { x: 0, y: 0 } => println!("原點"),
Point { x: a, y: 0 } => println!("在 x 軸上,x = {}", a),
Point { x: 0, y: b } => println!("在 y 軸上,y = {}", b),
Point { x: a, y: b } => println!("在 ({}, {})", a, b),
}
}
語法完全一樣——型別名 { 欄位名: 變數名 }。
重點整理
- struct variant 在 match 裡用
欄位名: 變數名來取出資料 Shape::Circle { radius: r }→ 把 radius 欄位取出來叫做r- 冒號左邊是欄位名(必須和定義一樣),右邊是你自己取的變數名
- 所有欄位都要寫出來(目前是這樣,之後會學怎麼忽略)
- 一般的 named field struct 也能用同樣的方式在 match 裡解構
第三章第 11 集:field shorthand
本集目標
學會用 field shorthand 簡化 struct 的建立和模式比對。
概念說明
上一集在 match 裡寫了 radius: r,意思是把 radius 欄位取出來叫做 r。但如果你想讓變數名就叫做 radius 呢?按照之前的寫法要寫 radius: radius——欄位名和變數名重複了,有點囉嗦。
Rust 提供了一個簡寫:如果變數名和欄位名一樣,可以只寫一次:
// 完整寫法
Shape::Circle { radius: radius }
// 簡寫(field shorthand)
Shape::Circle { radius }
這個簡寫不只在 match 裡可以用,建立 struct 的時候也可以用:
let x = 3;
let y = 7;
// 完整寫法
let p = Point { x: x, y: y };
// 簡寫
let p = Point { x, y };
只要變數名和欄位名一樣,就能省略 : 變數名 的部分。
範例程式碼
struct Point {
x: i32,
y: i32,
}
enum Shape {
Circle { radius: f64 },
Rectangle { width: i32, height: i32 },
}
fn main() {
// 建立 struct 時使用 field shorthand
let x = 5;
let y = 10;
let p = Point { x, y }; // 等同於 Point { x: x, y: y }
println!("點的座標:({}, {})", p.x, p.y);
// 建立 enum struct variant 時也可以用
let radius = 3.5;
let s = Shape::Circle { radius }; // 等同於 Shape::Circle { radius: radius }
// match 裡也可以用 field shorthand
match s {
Shape::Circle { radius } => {
println!("圓形,半徑 = {}", radius);
}
Shape::Rectangle { width, height } => {
println!("長方形 {}x{}", width, height);
}
}
}
重點整理
- 當變數名和欄位名相同時,可以只寫一次:
Point { x, y }等同於Point { x: x, y: y } - 這個簡寫叫做 field shorthand
- 建立 struct / enum variant 時可以用
- match 模式裡也可以用
- 這是一個很常見的寫法,實際寫 Rust 時常常用簡寫
第三章第 12 集:tuple pattern
本集目標
學會在 match 裡解構一般的 tuple 與 tuple struct。
概念說明
第 9 集學了怎麼在 match 裡取出 enum variant 的資料。其實不只 enum,一般的 tuple 也可以拿來 match!
let point = (3, 7);
match point {
(0, 0) => println!("原點"),
(x, 0) => println!("在 x 軸上,x = {}", x),
(0, y) => println!("在 y 軸上,y = {}", y),
(x, y) => println!("在 ({}, {})", x, y),
}
match 會從上到下比對:
(0, 0)→ 兩個值都是 0 才會符合(x, 0)→ 第二個值是 0,第一個值隨便(取出來叫 x)(0, y)→ 第一個值是 0,第二個值隨便(x, y)→ 什麼都會符合(最後一個分支當「預設」)
你可以在模式裡混用「固定的值」和「變數」。固定的值用來比對,變數用來接住資料。
範例程式碼
fn main() {
let point = (2, 0);
match point {
(0, 0) => println!("原點"),
(x, 0) => println!("在 x 軸上,x = {}", x),
(0, y) => println!("在 y 軸上,y = {}", y),
(x, y) => println!("一般的點 ({}, {})", x, y),
}
// 用 match 搭配 tuple 做簡單的分類
let score = (85, 90);
match score {
(100, 100) => println!("雙滿分!"),
(a, b) => {
println!("國文 {},數學 {}", a, b);
let total = a + b;
println!("總分 {}", total);
}
}
}
tuple struct 也能用同樣的方式
還記得第 2 集學的 tuple struct 嗎?它的解構方式和一般 tuple 一模一樣:
struct Point(i32, i32);
fn main() {
let p = Point(3, 0);
match p {
Point(0, 0) => println!("原點"),
Point(x, 0) => println!("在 x 軸上,x = {}", x),
Point(0, y) => println!("在 y 軸上,y = {}", y),
Point(x, y) => println!("在 ({}, {})", x, y),
}
}
唯一的差別是模式前面要加上型別名稱 Point(...),而普通 tuple 直接寫 (...)。
重點整理
- 一般的 tuple 也可以拿來 match
- 模式裡可以混用固定值和變數:
(0, y)表示「第一個是 0,第二個取出來叫 y」 - match 從上到下比對,第一個符合的分支會被執行
- 最後放一個
(x, y)可以接住所有剩餘情況 - tuple struct 也能用同樣的方式解構,只是前面要加型別名稱:
Point(x, y)
第三章第 13 集:slice pattern
本集目標
學會在 match 裡解構陣列和切片,用 slice pattern 取出元素。
概念說明
在 match 裡解構陣列
上一集我們學了在 match 裡解構 tuple,其實陣列和切片也可以!語法就是用 [a, b, c] 來比對陣列的每個元素:
let rgb = [255, 128, 0];
match rgb {
[255, 0, 0] => println!("純紅色"),
[0, 255, 0] => println!("純綠色"),
[0, 0, 255] => println!("純藍色"),
[r, g, b] => println!("自訂顏色:R={}, G={}, B={}", r, g, b),
}
跟 tuple pattern 很像,你可以在模式裡混用「固定的值」和「變數」。固定的值用來比對,變數用來接住資料。
部分位置用固定值
你可以只固定某些位置,其餘用變數接住:
let point = [0, 5, 3];
match point {
[0, y, z] => println!("在 YZ 平面上:y={}, z={}", y, z),
[x, 0, z] => println!("在 XZ 平面上:x={}, z={}", x, z),
[x, y, 0] => println!("在 XY 平面上:x={}, y={}", x, y),
[x, y, z] => println!("一般的點:({}, {}, {})", x, y, z),
}
切片也能用
不只固定長度的陣列,切片(&[T])也能用 slice pattern。差別在於切片的長度在編譯期是未知的,所以你可以用不同長度的模式來匹配:
fn describe(numbers: &[i32]) {
match numbers {
[] => println!("空的"),
[x] => println!("只有一個元素:{}", x),
[x, y] => println!("兩個元素:{} 和 {}", x, y),
[x, y, z] => println!("三個元素:{}, {}, {}", x, y, z),
_ => println!("超過三個元素"),
}
}
固定長度的陣列(例如 [i32; 3])永遠是那個長度,所以 [] 或 [x] 的分支永遠不會匹配到。切片才需要考慮不同長度的情況。
範例程式碼
fn describe(data: &[i32]) {
match data {
[] => println!("空的切片"),
[only] => println!("只有一個元素:{}", only),
[first, second] => println!("兩個元素:{} 和 {}", first, second),
_ => println!("有很多元素,第一個是 {}", data[0]),
}
}
fn main() {
// 固定長度陣列的 slice pattern
let rgb = [255, 128, 0];
match rgb {
[255, 0, 0] => println!("純紅色"),
[0, 255, 0] => println!("純綠色"),
[0, 0, 255] => println!("純藍色"),
[r, g, b] => println!("自訂顏色:R={}, G={}, B={}", r, g, b),
}
println!("---");
// 切片的 slice pattern——可以匹配不同長度
describe(&[]);
describe(&[42]);
describe(&[1, 2]);
describe(&[10, 20, 30, 40, 50]);
}
重點整理
- 陣列可以在 match 裡用
[a, b, c]解構,跟 tuple pattern 類似 - 模式裡可以混用固定值和變數
- 切片
&[T]長度不固定,可以用不同長度的模式來匹配([]、[x]、[x, y]……)
第三章第 14 集:巢狀 pattern matching
本集目標
學會在 match 裡面再解構更深層的結構——巢狀的模式比對。
概念說明
到目前為止,我們的 match 都只解構一層。但如果資料結構是巢狀的呢?比如一個 tuple 裡面包著 enum,或是一個 enum 裡面包著另一個 struct?
Rust 的 pattern matching 可以一次解構好幾層,就像剝洋蔥一樣,一層一層往裡面拿。
比如說,你有一個 tuple (i32, Shape),你可以在 match 裡同時解構 tuple 和裡面的 Shape:
match data {
(id, Shape::Circle(r)) => println!("#{} 是圓形,半徑 {}", id, r),
(id, Shape::Rectangle(w, h)) => println!("#{} 是長方形 {}x{}", id, w, h),
}
一個模式裡,外層解構 tuple 取出 id 和 Shape,內層再解構 Shape 取出裡面的資料。全部在一行完成!
範例程式碼
enum Color {
Red,
Green,
Blue,
}
struct Point {
x: i32,
y: i32,
}
enum Shape {
Circle(f64),
Rectangle(i32, i32),
}
fn main() {
// 範例一:tuple 裡面包 enum
let data = (1, Shape::Circle(5.0));
match data {
(id, Shape::Circle(r)) => {
println!("形狀 #{} 是圓形,半徑 {}", id, r);
}
(id, Shape::Rectangle(w, h)) => {
println!("形狀 #{} 是長方形 {}x{}", id, w, h);
}
}
// 範例二:tuple 裡面包 struct
let item = ("原點", Point { x: 0, y: 0 });
match item {
(name, Point { x, y }) => {
println!("{}:座標 ({}, {})", name, x, y);
}
}
// 範例三:tuple 裡面包 enum 和 i32
let colored_value = (Color::Red, 42);
match colored_value {
(Color::Red, n) => println!("紅色,數值 {}", n),
(Color::Green, n) => println!("綠色,數值 {}", n),
(Color::Blue, n) => println!("藍色,數值 {}", n),
}
}
重點整理
- Rust 的 pattern matching 可以解構多層巢狀結構
- 可以在一個模式裡同時解構 tuple + enum、tuple + struct 等
- 巢狀解構讓你不需要寫多個 match,一次就能把所有資料取出來
- 寫法就是把模式一層一層嵌進去,和資料的結構對應
第三章第 15 集:_ wildcard
本集目標
學會用 _ 來忽略不關心的值,以及在 match 裡建立預設分支。
概念說明
有時候在 match 裡,我們只關心某幾種情況,其他的都想「忽略」。Rust 提供了 _(底線)作為 wildcard(萬用字元),它可以匹配任何值,但不會把值綁定到變數上。
最常見的用法有兩種:
1. 預設分支:_ => ...
放在 match 的最後面,表示「其他所有情況都走這裡」:
match score {
100 => println!("滿分!"),
_ => println!("不是滿分"),
}
2. 忽略某個位置的值
在 tuple 或 enum 的模式裡,用 _ 佔住不需要的位置:
match point {
(0, _) => println!("在 y 軸上"), // 不關心第二個值
(_, 0) => println!("在 x 軸上"), // 不關心第一個值
(_, _) => println!("其他位置"),
}
範例程式碼
enum Direction {
Up,
Down,
Left,
Right,
}
fn main() {
// _ 作為預設分支
let dir = Direction::Left;
match dir {
Direction::Up => println!("往上"),
_ => println!("不是往上(可能是下、左、右)"),
}
// _ 忽略 tuple 裡的某個值
let record = ("Alice", 95, 'A');
match record {
(name, _, _) => println!("名字是 {}", name),
}
// 混合使用
let data = (1, Direction::Up);
match data {
(_, Direction::Up) => println!("方向是上(不管編號是多少)"),
(id, _) => println!("編號 {}(方向不是上)", id),
}
// 在 i32 上使用 _ 作為預設
let score = 87;
match score {
100 => println!("滿分!"),
0 => println!("零分..."),
_ => println!("得了 {} 分", score),
}
}
重點整理
_是 wildcard,匹配任何值但不綁定變數_ => ...放在 match 最後面當「預設分支」,處理所有未列出的情況- 在 tuple 或 enum 模式裡用
_忽略不需要的欄位 - 有了
_,match 就不用每個 variant 都寫出來了
第三章第 16 集:.. 忽略多個值
本集目標
學會用 .. 一次忽略 struct 或 tuple 中多個不關心的值。
概念說明
上一集學了用 _ 忽略一個值。但如果一個 struct 有很多欄位,而你只關心其中一兩個呢?每個不要的都寫 _ 太麻煩了。
Rust 提供了 ..(兩個點),意思是「剩下的我都不要了」。
在 match struct 時使用
struct Player {
id: i32,
hp: i32,
mp: i32,
level: i32,
}
let p = Player { id: 1, hp: 0, mp: 50, level: 10 };
match p {
Player { hp: 0, .. } => println!("這個玩家倒下了!"),
Player { level, .. } => println!("等級 {}", level),
}
Player { hp: 0, .. } 表示「hp 是 0,其他欄位我不管」。不用每個不要的欄位都寫 _。
在 match tuple 時使用
let scores = (90, 85, 78, 92, 88);
match scores {
(first, ..) => println!("第一科:{}", first),
}
match scores {
(.., last) => println!("最後一科:{}", last),
}
match scores {
(first, .., last) => println!("第一科 {},最後一科 {}", first, last),
}
(first, ..) 只取第一個,(.., last) 只取最後一個,(first, .., last) 取頭和尾。
在陣列和切片裡使用
第 13 集學過 slice pattern,.. 在陣列和切片裡也一樣好用:
let data: &[i32] = &[10, 20, 30, 40, 50];
match data {
[first, .., last] => println!("頭 = {},尾 = {}", first, last),
[only] => println!("只有一個:{}", only),
[] => println!("空的"),
}
注意:.. 只能出現一次
.. 在一個模式裡只能出現一次,因為如果出現兩次,Rust 會不知道中間的值怎麼分配。
範例程式碼
struct Player {
id: i32,
hp: i32,
mp: i32,
level: i32,
}
fn main() {
// struct 上使用 ..
let p1 = Player { id: 1, hp: 100, mp: 50, level: 10 };
match p1 {
Player { hp, .. } => println!("HP = {}", hp),
}
let p2 = Player { id: 2, hp: 0, mp: 30, level: 5 };
match p2 {
Player { hp: 0, .. } => println!("這個玩家已經倒下了!"),
Player { level, .. } => println!("等級 {}", level),
}
// tuple 上使用 ..
let scores = (90, 85, 78, 92, 88);
match scores {
(first, ..) => println!("第一科:{}", first),
}
match scores {
(.., last) => println!("最後一科:{}", last),
}
match scores {
(first, .., last) => println!("第一科 {},最後一科 {}", first, last),
}
// 切片上使用 ..
let data: &[i32] = &[10, 20, 30, 40, 50];
match data {
[first, .., last] => println!("頭 = {},尾 = {}", first, last),
[only] => println!("只有一個:{}", only),
[] => println!("空的"),
}
}
重點整理
..用來一次忽略多個欄位或值- match struct 時:
Player { hp, .. }只取 hp,其他全部忽略 - match tuple 時:
(first, ..)只取第一個,(.., last)只取最後一個 - 陣列和切片裡:
[first, ..]取第一個,[first, .., last]取頭和尾 ..在一個模式裡只能出現一次
第三章第 17 集:range pattern
本集目標
學會在 match 裡用範圍來比對數值。
概念說明
之前學 match 的時候,我們都是一個一個值去比對。但如果想比對「1 到 5 之間的任何數字」呢?總不能寫五個分支吧。
Rust 提供了 range pattern,讓你在 match 裡用範圍來比對:
match score {
1..=5 => println!("低分"),
}
1..=5 代表 1、2、3、4、5(包含頭尾)。這個 ..= 和第一章學的 for i in 0..=5 是一樣的語法。
除了 ..=(包含結尾),也可以用 ..(不包含結尾):
match score {
0..50 => println!("不及格"), // 0 到 49
50..=100 => println!("及格"), // 50 到 100(包含)
}
注意:兩種 .. 不要搞混!
上一集的 .. 和這一集的 .. 長得一模一樣,但意義完全不同:
- 上一集:
Point { x, .. }→ 忽略剩餘欄位,..代表「其他我不管了」 - 這一集:
0..50→ 數值範圍,..代表「從某個數到某個數」
Rust 編譯器會根據前後文判斷是哪一種,不會搞混。但初學時要注意分辨。
單邊範圍
range pattern 也支援只寫一邊:
match temperature {
..0 => println!("零下"), // 小於 0
0..=30 => println!("普通"), // 0 到 30
31.. => println!("很熱"), // 31 以上
}
char 也能用
range pattern 不只能用在數字,也能用在 char:
match c {
'a'..='z' => println!("小寫英文字母"),
'A'..='Z' => println!("大寫英文字母"),
'0'..='9' => println!("數字"),
_ => println!("其他字元"),
}
範例程式碼
fn main() {
// 用 range pattern 判斷分數等級
let score = 78;
match score {
90..=100 => println!("A"),
80..90 => println!("B"),
70..80 => println!("C"),
60..70 => println!("D"),
0..60 => println!("F"),
_ => println!("分數超出範圍"),
}
// 單邊範圍
let temperature = -5;
match temperature {
..0 => println!("零下,很冷!"),
0..=35 => println!("還可以"),
36.. => println!("太熱了!"),
}
// char 的 range pattern
let c = 'G';
match c {
'a'..='z' => println!("'{}' 是小寫字母", c),
'A'..='Z' => println!("'{}' 是大寫字母", c),
'0'..='9' => println!("'{}' 是數字", c),
_ => println!("'{}' 是其他字元", c),
}
}
重點整理
..和..=除了能用在 for 迴圈上,還能用來當作 pattern1..=5→ 包含頭尾(1, 2, 3, 4, 5)0..50→ 包含頭、不包含尾(0 到 49)..0→ 小於 0;31..→ 31 以上(單邊範圍)char也能用 range pattern:'a'..='z'- 這裡的
..是「數值範圍」,和上一集忽略欄位的..是不同的東西,不要搞混
第三章第 18 集:多個值 |
本集目標
學會在 match 的同一個分支裡比對多個可能的值。
概念說明
有時候你想讓好幾個值都執行同樣的程式碼。比如說,星期六和星期天都是假日,不需要分開寫兩個分支。
Rust 用 |(直線符號)來表示「或」:
match day {
6 | 7 => println!("假日"),
_ => println!("工作日"),
}
6 | 7 的意思是「6 或 7」。你可以用 | 串接任意多個值:
match n {
1 | 2 | 3 => println!("前三名"),
_ => println!("其他"),
}
也可以搭配 enum 使用:
match color {
Color::Red | Color::Blue => println!("冷暖色"),
Color::Green => println!("綠色"),
}
範例程式碼
enum Season {
Spring,
Summer,
Autumn,
Winter,
}
fn main() {
// 數字的多值比對
let month = 7;
match month {
3 | 4 | 5 => println!("春天"),
6 | 7 | 8 => println!("夏天"),
9 | 10 | 11 => println!("秋天"),
12 | 1 | 2 => println!("冬天"),
_ => println!("無效月份"),
}
// enum 的多值比對
let s = Season::Autumn;
let is_hot = match s {
Season::Summer => true,
Season::Spring | Season::Autumn | Season::Winter => false,
};
println!("天氣熱嗎?{}", is_hot);
// 搭配 range pattern 和 |
let ch = '5';
match ch {
'a'..='z' | 'A'..='Z' => println!("字母"),
'0'..='9' => println!("數字"),
' ' | '\t' | '\n' => println!("空白字元"),
_ => println!("其他"),
}
}
重點整理
|在 match 裡表示「或」,讓同一個分支比對多個值- 語法:
模式1 | 模式2 | 模式3 => ... - 可以搭配 enum variant 使用
- 也可以搭配 range pattern 使用:
'a'..='z' | 'A'..='Z' - 當多個值要做相同處理時,用
|比寫多個分支更簡潔
第三章第 19 集:@ 綁定
本集目標
學會用 @ 在比對範圍的同時,把符合的值綁定到一個變數上。
概念說明
上一集學了 range pattern:1..=5 可以比對 1 到 5 之間的值。但有個問題——比對成功後,你沒辦法知道「到底是 1、2、3、4 還是 5」,因為你只知道它在這個範圍裡。
@(at 符號)可以解決這個問題。它讓你在比對的同時,把實際的值存到一個變數裡:
match age {
n @ 0..=12 => println!("{}歲,是小孩", n),
n @ 13..=19 => println!("{}歲,是青少年", n),
n => println!("{}歲,是大人", n),
}
n @ 0..=12 的意思是「如果值在 0 到 12 之間,就把這個值叫做 n」。這樣你就能同時做範圍檢查和取值了。
範例程式碼
fn main() {
let age = 15;
match age {
n @ 0..=6 => println!("{}歲,學齡前", n),
n @ 7..=12 => println!("{}歲,國小", n),
n @ 13..=15 => println!("{}歲,國中", n),
n @ 16..=18 => println!("{}歲,高中", n),
n => println!("{}歲,已成年", n),
}
// 搭配 char 使用
let ch = 'k';
match ch {
c @ 'a'..='m' => println!("'{}' 在字母表前半段", c),
c @ 'n'..='z' => println!("'{}' 在字母表後半段", c),
c => println!("'{}' 不是小寫字母", c),
}
// 更實用的例子:HTTP 狀態碼
let status = 404;
match status {
code @ 200..=299 => println!("成功!狀態碼:{}", code),
code @ 300..=399 => println!("重新導向,狀態碼:{}", code),
code @ 400..=499 => println!("用戶端錯誤,狀態碼:{}", code),
code @ 500..=599 => println!("伺服器錯誤,狀態碼:{}", code),
code => println!("未知狀態碼:{}", code),
}
}
@ 不只能搭配 range
@ 可以搭配任何模式,不只是 range。比如搭配 |(多個值):
fn main() {
let day = 6;
match day {
d @ (1 | 7) => println!("第{}天,是週末", d),
d @ (2 | 3 | 4 | 5 | 6) => println!("第{}天,是工作日", d),
d => println!("第{}天,不是合法的星期", d),
}
}
d @ (1 | 7) 的意思是「如果值是 1 或 7,就把它叫做 d」。注意 | 的部分要用括號包起來。
重點整理
n @ 1..=5在比對範圍的同時,把值綁定到變數n- 語法:
變數名 @ 模式 @可以搭配任何模式,不只是 range:d @ (1 | 7)也行- 如果不用
@,你只知道值符合模式,但不知道具體是多少 @的概念是「把符合這個模式的值,用這個名字存起來」
第三章第 20 集:match guard
本集目標
學會在 match 分支加上額外的條件判斷(guard)。
概念說明
有時候光靠模式比對還不夠,你還需要加上一些額外的條件。比如說,你想比對「是正數的 i32」,但 range pattern 只能寫固定範圍,沒辦法用變數或複雜的條件。
Rust 的 match guard 讓你在模式後面加上 if 條件:
match n {
x if x > 0 => println!("{} 是正數", x),
x if x < 0 => println!("{} 是負數", x),
_ => println!("是零"),
}
x if x > 0 的意思是「先把值綁定到 x,然後額外檢查 x > 0 是否成立」。只有模式匹配而且 guard 條件為 true 的時候,這個分支才會被執行。
注意:guard 不算在「窮舉」的判斷裡。就算你寫了所有可能的 guard,Rust 可能還是會要求你加 _ 預設分支。
範例程式碼
fn main() {
let n = -3;
match n {
x if x > 0 => println!("{} 是正數", x),
x if x < 0 => println!("{} 是負數", x),
_ => println!("是零"),
}
// 搭配 tuple 使用
let point = (3, 7);
match point {
(x, y) if x == y => println!("在對角線上:({}, {})", x, y),
(x, y) if x > 0 && y > 0 => println!("({}, {}) 在第一象限", x, y),
(x, y) => println!("其他的點 ({}, {})", x, y),
}
// 搭配 enum 使用
let score = 75;
match score {
s if s >= 90 => println!("{} 分,優等!", s),
s if s >= 60 => println!("{} 分,及格", s),
s => println!("{} 分,不及格", s),
}
}
重點整理
- match guard:在模式後面加
if 條件做額外判斷 - 語法:
模式 if 條件 => ... - 只有模式匹配且條件為 true 時,分支才會執行
- guard 可以使用模式裡綁定的變數(如
x if x > 0) - guard 條件不算窮舉,通常最後還是要加
_預設分支
第三章第 21 集:let 解構 tuple
本集目標
學會用 let 直接把 tuple 的值拆開,分別賦值給不同的變數。
概念說明
之前我們學了在 match 裡解構 tuple,像是 (x, y) => ...。但其實不用 match,用 let 就可以直接解構!
let (x, y) = (1, 2);
這一行做了兩件事:
- 建立一個 tuple
(1, 2) - 把第一個值取出來叫
x,第二個值取出來叫y
這比先建立 tuple 再用 .0、.1 取值要方便得多。
之前在第二章學 tuple 時,都是用 t.0、t.1 來取值。現在學了解構,你可以一行就把所有值拆開,每個值都有一個好讀的名字。
之前學的 _ 和 .. 也可以在 let 解構裡使用。
mut 在綁定上
之前在第一章學了 let mut x = 5;。其實 mut 不是型別的一部分——它是綁定(binding)的修飾。
既然 let 解構就是在做 binding,自然也可以對個別變數加 mut:
let (mut a, b) = (1, 2);
a += 10; // OK,a 是可變的
// b += 10; // 錯誤,b 是不可變的
同一個 pattern 裡,有些變數可以 mut,有些不用——各自獨立。
這個規則不只適用於 let,任何綁定變數的地方都能加 mut:
- 函數參數:
fn foo(mut x: i32) { x += 1; } - for 迴圈:
for mut x in [1, 2, 3] { ... } - match 分支:
Some(mut x) => { x += 1; }
之後學到的綁定變數也一樣。都是同一件事——mut 修飾的是 binding,不是型別。
範例程式碼
fn main() {
// 基本的 let 解構
let (x, y) = (10, 20);
println!("x = {}, y = {}", x, y);
// 三個值的 tuple 也可以
let (name, score, grade) = ("小明", 95, 'A');
println!("{} 得了 {} 分,等級 {}", name, score, grade);
// 搭配 _ 忽略不需要的值
let (_, second, _) = (1, 2, 3);
println!("只要第二個:{}", second);
// 搭配 .. 忽略多個值
let (first, ..) = (100, 200, 300, 400);
println!("只要第一個:{}", first);
// 個別加 mut
let (mut a, b) = (1, 2);
a += 10;
println!("a = {}, b = {}", a, b);
// 函數回傳 tuple,直接解構
let (min, max) = min_max(7, 3);
println!("最小 {},最大 {}", min, max);
}
fn min_max(a: i32, b: i32) -> (i32, i32) {
if a < b {
(a, b)
} else {
(b, a)
}
}
重點整理
let (x, y) = (1, 2);可以直接把 tuple 拆開- 解構 tuple 比用
.0、.1更好讀 - 可以搭配
_忽略單個值,搭配..忽略多個值 mut是 binding 的修飾,不是型別的一部分——任何綁定變數的地方都能加mut- 函數回傳 tuple 時,可以用 let 解構直接取出每個值
- 這就是 destructuring(解構),把結構化的資料拆成個別變數
第三章第 22 集:let 解構 struct
本集目標
學會用 let 直接把 struct 的欄位拆開,分別賦值給變數。
概念說明
上一集學了 let 解構 tuple,現在來解構 struct。概念完全一樣——用 let 把 struct 的欄位一次拆開:
let Point { x, y } = p;
這一行會把 p.x 的值放進變數 x,p.y 的值放進變數 y。這裡用的是 field shorthand(第 13 集學的),所以 x 既是欄位名也是變數名。
如果你想要的變數名和欄位名不同,可以用 欄位名: 變數名 的寫法:
let Point { x: px, y: py } = p;
// 現在變數叫 px 和 py
之前學的 .. 也可以用,只取你需要的欄位:
let Point { x, .. } = p;
// 只取 x,忽略其他欄位
範例程式碼
struct Point {
x: i32,
y: i32,
}
struct Rectangle {
x: i32,
y: i32,
width: i32,
height: i32,
}
fn main() {
let p = Point { x: 5, y: 10 };
// let 解構 struct(用 field shorthand)
let Point { x, y } = p;
println!("x = {}, y = {}", x, y);
// 用不同的變數名
let p2 = Point { x: 3, y: 7 };
let Point { x: px, y: py } = p2;
println!("px = {}, py = {}", px, py);
// 搭配 .. 只取部分欄位
let rect = Rectangle { x: 0, y: 0, width: 100, height: 50 };
let Rectangle { width, height, .. } = rect;
println!("寬 {},高 {}", width, height);
let area = width * height;
println!("面積 = {}", area);
}
重點整理
let Point { x, y } = p;把 struct 的欄位拆成個別變數- 用 field shorthand:欄位名直接當變數名
- 用
欄位名: 變數名可以取不同的名字 ..可以忽略不需要的欄位- let 解構在取出 struct 資料時非常方便
第三章第 23 集:函數參數解構
本集目標
學會在函數的參數位置直接解構 tuple 或 struct。
概念說明
我們已經學了在 let 和 match 裡解構。其實函數的參數也可以解構!
假設你有一個函數,接收一個 tuple (i32, i32) 代表座標。與其在函數內再拆開,不如直接在參數位置就拆好:
fn print_point((x, y): (i32, i32)) {
println!("({}, {})", x, y);
}
注意語法:(x, y) 是模式(pattern),: (i32, i32) 是型別標註。模式和型別之間用 : 分隔。
呼叫的時候和平常一樣,傳一個 tuple 進去:
print_point((3, 7));
struct 也可以在參數位置解構:
fn print_point_struct(Point { x, y }: Point) {
println!("({}, {})", x, y);
}
範例程式碼
struct Point {
x: i32,
y: i32,
}
// 函數參數解構 tuple
fn add_coordinates((x1, y1): (i32, i32), (x2, y2): (i32, i32)) -> (i32, i32) {
(x1 + x2, y1 + y2)
}
// 函數參數解構 struct
fn describe_point(Point { x, y }: Point) {
if x == 0 && y == 0 {
println!("原點");
} else if x == 0 {
println!("在 y 軸上,y = {}", y);
} else if y == 0 {
println!("在 x 軸上,x = {}", x);
} else {
println!("一般的點 ({}, {})", x, y);
}
}
fn main() {
// 傳 tuple 給函數
let a = (1, 2);
let b = (3, 4);
let result = add_coordinates(a, b);
println!("({}, {}) + ({}, {}) = ({}, {})", a.0, a.1, b.0, b.1, result.0, result.1);
// 傳 struct 給函數
let p = Point { x: 0, y: 5 };
describe_point(p);
let origin = Point { x: 0, y: 0 };
describe_point(origin);
}
為什麼 tuple 和 struct 能用 let 解構?
你可能會好奇:為什麼 tuple 和 struct 可以在 let 和函數參數裡直接解構,但 enum 不行?
let (x, y) = (1, 2); // OK
let Point { x, y } = p; // OK
// let Color::Red = c; // 不行!
答案是:tuple 和 struct 的解構不會失敗。一個 (i32, i32) 一定有兩個值,一個 Point 一定有 x 和 y——沒有其他可能。
但 enum 不一樣。一個 Color 可能是 Red、Green、或 Blue。如果你寫 let Color::Red = c,但 c 其實是 Green 呢?這就失敗了。Rust 不允許 let 裡出現可能失敗的模式。
這種一定會成功的模式叫做 irrefutable pattern(不可反駁的模式),可能失敗的叫做 refutable pattern(可反駁的模式)。let 和函數參數只接受 irrefutable pattern。
想處理可能失敗的模式?下一集會教 if let。
重點整理
- 函數參數可以直接用模式解構:
fn foo((x, y): (i32, i32)) - 語法是
模式: 型別,模式在前,型別在後 - tuple 和 struct 都可以在參數位置解構
- 呼叫時和平常一樣傳值,解構是函數內部的事
let和函數參數只接受不會失敗的模式(irrefutable pattern),所以 tuple 和 struct 可以,enum 不行
第三章第 24 集:if let
本集目標
學會用 if let 來簡化「只關心一種模式」的 match。
概念說明
有時候你只關心 enum 的某一個 variant,其他的都不在意。用 match 寫的話,必須處理所有情況,就算你只想處理一個:
match c {
Color::Red => println!("是紅色!"),
_ => {} // 其他情況什麼都不做
}
那個 _ => {} 看起來很多餘。Rust 提供了 if let 語法來簡化這種情況:
if let Color::Red = c {
println!("是紅色!");
}
if let 模式 = 值 的意思是「如果這個值符合這個模式,就執行大括號裡的程式碼」。
你也可以加上 else 處理不符合的情況:
if let Color::Red = c {
println!("是紅色!");
} else {
println!("不是紅色");
}
注意:if let 裡的 = 是一個等號,不是兩個。這不是在做比較,而是在做「模式比對」。
範例程式碼
enum Color {
Red,
Green,
Blue,
}
enum Shape {
Circle(f64),
Rectangle(i32, i32),
}
fn main() {
let c = Color::Red;
// 用 if let 檢查是不是 Red
if let Color::Red = c {
println!("是紅色!");
}
// 搭配 else
let c2 = Color::Blue;
if let Color::Red = c2 {
println!("是紅色!");
} else {
println!("不是紅色");
}
// if let 也可以取出 variant 裡的資料
let s = Shape::Circle(5.0);
if let Shape::Circle(r) = s {
println!("是圓形!半徑 = {}", r);
let area = r * r * 3.14159;
println!("面積大約 {}", area);
}
// 如果不是 Circle 就不會執行
let s2 = Shape::Rectangle(10, 20);
if let Shape::Circle(r) = s2 {
println!("這行不會被執行,因為 s2 是 Rectangle");
println!("半徑 {}", r);
} else {
println!("不是圓形");
}
}
重點整理
if let 模式 = 值 { ... }是 match 只有一個分支時的簡寫- 只在值符合模式時執行大括號裡的程式碼
- 可以加
else處理不符合的情況 - 可以在模式裡取出資料,像
if let Shape::Circle(r) = s - 比起寫 match +
_ => {},if let更簡潔
第三章第 25 集:while let
本集目標
學會用 while let 在迴圈中持續做模式比對,直到模式不再符合為止。
概念說明
上一集學了 if let——「如果符合模式就執行一次」。while let 則是「只要符合模式就一直執行」,是 if let 的迴圈版本。
語法:
while let 模式 = 值 {
// 迴圈本體
}
每次迴圈開始前,Rust 會檢查「值是否符合模式」。符合就繼續跑,不符合就停下來。
為了示範 while let,我們用一個自訂 enum 來模擬「可能有值、可能結束」的情況:
enum Step {
Value(i32),
Done,
}
範例程式碼
enum Step {
Value(i32),
Done,
}
fn get_step(index: i32) -> Step {
if index < 5 {
Step::Value(index * 10)
} else {
Step::Done
}
}
fn main() {
let mut i = 0;
// while let:只要 get_step 回傳 Value,就繼續
while let Step::Value(v) = get_step(i) {
println!("第 {} 步,值 = {}", i, v);
i += 1;
}
println!("結束了!總共跑了 {} 步", i);
println!();
// 另一個例子:倒數計時
let mut count = 5;
// 利用自訂 enum 模擬倒數
while let Countdown::Tick(n) = get_countdown(count) {
println!("倒數 {}...", n);
count -= 1;
}
println!("發射!🚀");
}
enum Countdown {
Tick(i32),
Launch,
}
fn get_countdown(n: i32) -> Countdown {
if n > 0 {
Countdown::Tick(n)
} else {
Countdown::Launch
}
}
重點整理
while let 模式 = 值 { ... }是if let的迴圈版本- 只要值符合模式,就持續執行迴圈
- 值不符合模式時,迴圈自動結束
第三章第 26 集:let else
本集目標
學會用 let...else 在 pattern 不匹配時提前離開,寫出更扁平的程式碼。
概念說明
if let 的反面
上一集學了 while let,再上一集學了 if let——「如果匹配成功就做某件事」。但有時候你想要的是反過來:「如果匹配失敗就提前離開,成功的話繼續往下走。」
假設我們有這個 enum:
enum Color {
Red,
Green,
Blue,
Custom(i32, i32, i32),
}
用 if let 寫的話:
fn describe(color: Color) {
if let Color::Custom(r, g, b) = color {
println!("自訂顏色:{} {} {}", r, g, b);
} else {
println!("不是自訂顏色,結束");
return;
}
// 這裡想用 r, g, b⋯⋯但它們已經不在作用域了!
}
r、g、b 只活在 if let 的 {} 裡面,後面的程式碼用不到。
let...else 語法
let...else 讓綁定的變數活在後面的程式碼裡,而不是只活在 {} 裡面:
fn describe(color: Color) {
let Color::Custom(r, g, b) = color else {
println!("不是自訂顏色,結束");
return;
};
// r, g, b 在這裡可以直接用!
println!("紅:{},綠:{},藍:{}", r, g, b);
}
意思是:
- 嘗試用 pattern 匹配
color - 如果成功,
r、g、b被綁定,程式繼續往下 - 如果失敗,執行
else裡面的程式碼
else 裡面必須離開
else 區塊不能只是「做點事然後繼續」——它必須讓程式離開當前的流程。合法的寫法包括:
return—— 離開函數break—— 離開迴圈continue—— 跳到迴圈下一輪
為什麼?因為如果 pattern 不匹配,變數就沒有被綁定。如果 else 之後程式繼續往下跑,那些變數就是未定義的——Rust 不允許這種事。
和 if let 的比較
if let:匹配成功才進入{}區塊,綁定的變數只活在裡面let...else:匹配失敗就離開,綁定的變數活在後面所有的程式碼裡
let...else 讓程式碼更扁平——不用多縮排一層。
範例程式碼
enum Shape {
Circle(f64),
Rectangle(i32, i32),
}
fn print_circle_info(shape: Shape) {
let Shape::Circle(radius) = shape else {
println!("不是圓形,跳過");
return;
};
// radius 在這裡可以直接用
println!("圓形,半徑 = {}", radius);
}
fn main() {
print_circle_info(Shape::Circle(3.14));
print_circle_info(Shape::Rectangle(10, 20));
// 在迴圈裡搭配 continue
let shapes = [
Shape::Rectangle(3, 4),
Shape::Circle(1.0),
Shape::Rectangle(5, 6),
Shape::Circle(2.5),
];
println!("\n只印圓形:");
for shape in shapes {
let Shape::Circle(r) = shape else {
continue; // 不是圓形,跳過這一輪
};
println!("半徑:{}", r);
}
}
重點整理
let pattern = expr else { return/break/continue };在匹配失敗時提前離開else裡面必須離開當前流程(return/break/continue)- 匹配成功的話,綁定的變數在後續程式碼都能使用
- 比
if let更適合「失敗就離開,成功繼續」的場景——程式碼更扁平
第三章第 27 集:associated function
本集目標
學會用 impl 為 struct 或 enum 定義 associated function(關聯函數),以及用 :: 呼叫。
概念說明
到目前為止,我們的函數都是「獨立的」——定義在最外層,和任何型別沒有關係。但很多時候,某些函數和特定的型別密切相關。比如說,「建立一個新的 Point」這件事,和 Point 這個型別有直接關係。
Rust 用 impl 區塊讓你把函數「附加」到型別上:
impl Point {
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
}
這樣定義的函數叫做 associated function(關聯函數),因為它和 Point 這個型別「關聯」在一起。呼叫的時候用 :::
let p = Point::new(3, 7);
Point::new 看起來是不是有點眼熟?之前用 enum 的時候也是用 :: 啊!像 Color::Red。概念是一樣的——:: 表示「某個型別底下的東西」。
associated function 最常見的用途就是 new——作為「建構函數」來建立型別的值。
範例程式碼
struct Point {
x: i32,
y: i32,
}
impl Point {
// associated function:建立一個新的 Point
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// 也可以定義其他 associated function
fn origin() -> Point {
Point { x: 0, y: 0 }
}
}
// enum 也可以有 impl!
enum Color {
Red,
Green,
Blue,
}
impl Color {
fn from_number(n: i32) -> Color {
match n {
0 => Color::Red,
1 => Color::Green,
_ => Color::Blue,
}
}
}
fn main() {
// 用 :: 呼叫 associated function
let p1 = Point::new(3, 7);
println!("p1 = ({}, {})", p1.x, p1.y);
let p2 = Point::origin();
println!("p2 = ({}, {})", p2.x, p2.y);
// enum 的 associated function
let c = Color::from_number(1);
match c {
Color::Red => println!("紅"),
Color::Green => println!("綠"),
Color::Blue => println!("藍"),
}
}
重點整理
impl 型別名 { ... }為型別定義 associated function- associated function 用
型別名::函數名()呼叫 - 最常見的用途是
new函數,作為建構函數 - struct 和 enum 都可以有
impl區塊
第三章第 28 集:method
本集目標
學會用 self 定義 method(方法),讓函數可以用 . 在值上面呼叫。
概念說明
上一集學了 associated function,它是用 :: 呼叫的,和「型別」相關。但有時候我們想對一個已經存在的值做操作,比如「算出這個 Point 的 x + y」。
這就是 method(方法)——參數列表的第一個位置放 self,代表「呼叫這個方法的那個值本身」:
impl Point {
fn sum(self) -> i32 {
self.x + self.y
}
}
呼叫的時候用 .(點)而不是 :::
let p = Point::new(3, 7);
let s = p.sum(); // 用 . 呼叫 method
注意:呼叫 p.sum() 的時候,不需要再手動傳入 self。. 前面的 p 會自動變成方法裡的 self。所以雖然定義時寫了 fn sum(self),呼叫時只要寫 p.sum() 而不是 p.sum(p)。
associated function vs method 的差別:
- associated function:沒有
self,用::呼叫 →Point::new(3, 7) - method:第一個參數是
self,用.呼叫 →p.sum()
範例程式碼
struct Point {
x: i32,
y: i32,
}
impl Point {
// associated function(沒有 self)
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// method(第一個參數是 self)
fn sum(self) -> i32 {
self.x + self.y
}
// 另一個 method
fn is_origin(self) -> bool {
self.x == 0 && self.y == 0
}
}
enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
// enum 也可以有 method
fn is_horizontal(self) -> bool {
match self {
Direction::Left => true,
Direction::Right => true,
Direction::Up => false,
Direction::Down => false,
}
}
}
fn main() {
let p = Point::new(3, 7); // :: 呼叫 associated function
let s = p.sum(); // . 呼叫 method
println!("3 + 7 = {}", s);
let p2 = Point::new(0, 0);
let is_origin = p2.is_origin();
println!("是原點嗎?{}", is_origin);
// enum 的 method
let dir = Direction::Left;
let horizontal = dir.is_horizontal();
println!("是水平方向嗎?{}", horizontal);
}
重點整理
- method 的第一個參數是
self,代表值本身 - method 用
.呼叫:p.sum(),.前面的值自動成為self,不需要手動傳入 - struct 和 enum 都可以有 method
第三章第 29 集:大寫 Self
本集目標
學會用大寫 Self 作為「目前正在 impl 的型別」的別名,讓程式碼更簡潔。
概念說明
上一集我們在 impl 裡面寫了這樣的程式碼:
impl Point {
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
}
注意到 Point 這個名字出現了三次:impl Point、-> Point、Point { x, y }。如果型別名很長(例如 Rectangle),一直重複寫就很囉嗦。
Rust 提供了大寫 Self(注意 S 是大寫的!),它在 impl 區塊裡面代表「目前正在 impl 的型別」。所以上面的程式碼可以改成:
impl Point {
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
Self 就是 Point 的別名。這樣寫有兩個好處:
- 更簡潔,尤其是型別名很長的時候
- 如果之後改了型別名,impl 裡面不用每個地方都改
注意區分:
- 小寫 self:代表「這個值本身」(method 的第一個參數)
- 大寫 Self:代表「目前的型別」
範例程式碼
struct Point {
x: i32,
y: i32,
}
impl Point {
// 用 Self 代替 Point
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
fn origin() -> Self {
Self { x: 0, y: 0 }
}
// method 裡也可以用 Self
fn flip(self) -> Self {
Self { x: self.y, y: self.x }
}
fn sum(self) -> i32 {
self.x + self.y
}
}
// enum 也可以用 Self
enum Light {
Red,
Yellow,
Green,
}
impl Light {
fn next(self) -> Self {
match self {
Self::Red => Self::Green,
Self::Green => Self::Yellow,
Self::Yellow => Self::Red,
}
}
fn is_stop(self) -> bool {
match self {
Self::Red => true,
Self::Yellow => true,
Self::Green => false,
}
}
}
fn main() {
// struct 使用 Self
let p = Point::new(3, 7);
println!("原始:({}, {})", p.x, p.y);
let p2 = Point::new(3, 7);
let flipped = p2.flip();
println!("翻轉:({}, {})", flipped.x, flipped.y);
let p3 = Point::origin();
println!("原點:({}, {})", p3.x, p3.y);
// enum 使用 Self
let light = Light::Red;
let stop = light.is_stop();
println!("需要停嗎?{}", stop);
let light2 = Light::Red;
let next_light = light2.next();
let stop2 = next_light.is_stop();
println!("下一個燈需要停嗎?{}", stop2);
}
重點整理
- 大寫
Self在impl區塊裡代表「目前的型別」 Self可以用在回傳型別-> Self、建構值Self { ... }、以及 enum variantSelf::Red- 小寫
self= 值本身,大寫Self= 型別本身 - struct 和 enum 的 impl 裡都可以用
Self - 使用
Self讓程式碼更簡潔,也更容易維護
恭喜你完成了第三章!🎉 這一章你學會了 struct、enum、pattern matching(match、if let、while let)、解構、associated function 和 method。你現在已經能用 Rust 的型別系統來組織資料和行為了。下一章我們要進入 Rust 最核心也最獨特的概念——所有權(ownership)!
第四章:所有權與借用
第四章第 1 集:所有權(鑰匙圈比喻)
本集目標
用生活化的鑰匙圈比喻,理解 Rust 最核心的概念——所有權。
概念說明
今天我們不寫程式,先來聊一個 Rust 最重要的概念:所有權(ownership)。
鑰匙圈比喻
想像你有一個鑰匙圈。鑰匙圈上可能掛著一些小裝飾(很輕、隨身帶著),也可能掛著一把鑰匙,這把鑰匙可以打開一個保險箱。規則很簡單:
每個鑰匙圈只能在一個人手上。
這就是 Rust 的所有權規則。你手上拿著鑰匙圈,上面的裝飾和鑰匙都是你的。
移轉(move)= 交出去就沒了
如果有人跟你說:「把你的鑰匙圈給我。」你把整個鑰匙圈交給對方之後,你手上就什麼都沒了。你不能再用那把鑰匙去開保險箱,因為鑰匙已經不在你手上了。
在 Rust 裡面,這叫做 move(移轉)。當你把一個值交給別人(例如賦值給另一個變數),原本的變數就不能再用了。
為什麼不能複製鑰匙?
你可能會想:「那我去複製一把鑰匙不就好了?」
問題在這裡:如果兩個人各拿一把鑰匙,都可以打開同一個保險箱,那就可能出事了——
- A 正在整理保險箱裡的東西
- B 同時也打開保險箱,把東西拿走了
- A 回頭一看:「咦?我的東西呢?」
這就是所謂的「資料競爭(data race)」。Rust 的所有權規則就是為了從根本上防止這種問題。
Clone = 買一個新的保險箱
那如果我真的需要兩份一樣的資料怎麼辦?
答案是:不要複製鑰匙,而是買一個新的保險箱,把裡面的東西複製一份放進去,然後配一把新的鑰匙。
這樣兩個人各有自己的保險箱、自己的鑰匙,互不干擾。
在 Rust 裡面,這叫做 Clone(克隆)。它會完整複製一份資料,產生一個全新的、獨立的副本。
為什麼 Rust 要這麼嚴格?
大部分的程式語言不管這些,讓你隨便複製、隨便共用,然後等出了 bug 再說。Rust 不一樣——它在你寫程式的時候就幫你把關,確保不會有兩個人同時亂動同一份資料。
這就是 Rust 的核心哲學:在編譯時期就防止錯誤,而不是等到程式跑起來才出事。
重點整理
- 每個值都有一個「擁有者」,就像每個鑰匙圈只能在一個人手上
- move(移轉):把鑰匙圈交給別人,你就沒有了
- 不能簡單地複製鑰匙去開同一個保險箱,這可能會造成資料競爭
- Clone(克隆):買新的保險箱 + 複製內容物 + 配新鑰匙,兩份完全獨立
- Rust 在編譯時就強制執行所有權規則,防止資料競爭
第四章第 2 集:trait 簡介
本集目標
學會定義 trait 和為型別實作 trait,並認識 #[derive] 這個自動產生實作的捷徑。
概念說明
什麼是 Trait?
在進入所有權的主題之前,我們先來學一個重要的工具:trait。它和上一集的鑰匙圈比喻沒有直接關係,但之後講 Clone、Copy 等概念的時候會用到,所以先學起來。
在第三章,我們學會了用 impl 幫 struct 和 enum 加上 method。但如果我們想要規定「某些型別都必須有某個功能」呢?
比如說,我想規定:「某些型別都必須能打招呼。」這就是 trait 的用途——它定義了一組「能力」或「行為」,然後不同的型別可以各自實作這些行為。
trait 就像一張「規格表」,上面寫著:「你要符合這個規格,就必須提供這些功能。」
定義 Trait
用 trait 關鍵字來定義:
trait Greet {
fn greet(self);
}
這段程式碼的意思是:「凡是實作了 Greet 這個 trait 的型別,都必須有一個 greet method。」
為型別實作 Trait
impl Greet for Cat {
fn greet(self) {
println!("喵~");
}
}
之前我們寫 impl Cat { ... } 是直接幫 Cat 加 method。現在寫 impl Greet for Cat { ... } 是說「Cat 符合 Greet 這個規格」,然後在裡面提供 Greet 要求的 method。
derive:自動產生實作的捷徑
有些 trait 的實作方式很固定,Rust 可以幫你自動產生。這時候就用 #[derive(...)]:
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
還記得第二章我們用 {:?} 印出 tuple 和陣列嗎?其實 {:?} 就是在使用 Debug 這個 trait。tuple 和陣列內建就有 Debug,但我們自己定義的 struct 和 enum 沒有——所以要加 #[derive(Debug)],讓 Rust 自動幫我們產生 Debug 的實作。
範例程式碼
// 定義一個 trait:所有實作者都必須能「打招呼」
trait Greet {
fn greet(self);
}
// 定義兩種動物
struct Cat;
struct Dog;
// 幫 Cat 實作 Greet
impl Greet for Cat {
fn greet(self) {
println!("我是一隻貓咪喵~");
}
}
// 幫 Dog 實作 Greet
impl Greet for Dog {
fn greet(self) {
println!("我是一隻狗狗汪!");
}
}
// 用 derive 讓 Rust 自動產生 Debug 實作
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let cat = Cat;
let dog = Dog;
// 呼叫 trait method
cat.greet();
dog.greet();
// 用 {:?} 印出 struct(因為有 #[derive(Debug)])
let p = Point { x: 3, y: 7 };
println!("{:?}", p);
}
重點整理
- trait 是一組行為的規格定義,像是一張「能力清單」
- 用
impl TraitName for TypeName來幫型別實作 trait(例如impl Greet for Cat) #[derive(Debug)]讓 Rust 自動幫你的 struct/enum 實作Debugtrait- 加了
#[derive(Debug)]之後,就可以用{:?}來印出自訂的 struct/enum
第四章第 3 集:move 與 Clone
本集目標
理解 Rust 的 move 語意——賦值和傳入函數都會轉移所有權——以及用 Clone 來複製資料。
概念說明
move:交出去就沒了
上一集我們學了 trait,現在來看所有權在程式碼裡的樣子。
在 Rust 裡,當你把一個 struct 的值賦給另一個變數,原本的變數就不能再用了。這就是第 1 集講的「把鑰匙圈交出去」:
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // p1 的所有權移轉給 p2
// 從這裡開始,p1 不能再用了!
這個行為叫做 move(移轉)。Rust 編譯器會在編譯時就檢查這件事,如果你在 move 之後還嘗試使用原本的變數,編譯器會直接報錯。
傳進函數也是 move
不只是賦值,把值傳進函數也會發生 move:
fn print_point(p: Point) {
println!("({}, {})", p.x, p.y);
}
let p1 = Point { x: 1, y: 2 };
print_point(p1); // p1 被 move 進函數了
// p1 不能再用了!
因為函數的參數就像一個新的變數,值被「交」給了它。
Clone:完整複製一份
如果你需要保留原本的值,又想要一份副本,就用 Clone。
首先,你的型別要加上 #[derive(Clone)](當然也可以順便加 Debug):
#[derive(Debug, Clone)]
struct Point {
x: i32,
y: i32,
}
然後用 .clone() 來複製:
let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone(); // 複製一份,p1 還在
println!("{:?}", p1); // OK!p1 還能用
println!("{:?}", p2); // p2 是獨立的副本
回想第 1 集的比喻:clone 就是「複製一份完整的鑰匙圈和保險箱」。兩個變數各自擁有自己的資料,互不干擾。
整數不會 move?
你可能會注意到,整數的行為不太一樣:
let a = 42;
let b = a;
println!("{}", a); // 這居然可以!
為什麼整數不會 move?這個問題我們下一集再來解答。
範例程式碼
#[derive(Debug, Clone)]
struct Point {
x: i32,
y: i32,
}
fn print_point(p: Point) {
println!("函數收到的點:({}, {})", p.x, p.y);
}
fn main() {
let p1 = Point { x: 10, y: 20 };
// 用 clone 複製一份,這樣 p1 不會被 move 走
let p2 = p1.clone();
println!("p1 = {:?}", p1);
println!("p2 = {:?}", p2);
// 傳進函數也是 move,所以先 clone
print_point(p1.clone());
println!("p1 還在:{:?}", p1);
// 如果不 clone,直接傳進去,p1 就被 move 走了
print_point(p1);
// 下面這行如果取消註解,編譯器會報錯:
// println!("p1 不見了:{:?}", p1);
}
重點整理
let p2 = p1;會 move,之後p1不能再用- 把值傳進函數也是 move
#[derive(Clone)]+.clone()可以複製一份獨立的副本- Clone 之後,原本的變數還可以繼續使用
- 整數(i32 等)不會 move——下一集會解釋為什麼
第四章第 4 集:Copy
本集目標
理解 Copy trait——為什麼整數、浮點數、布林值、字元在賦值時不會 move。
概念說明
上一集的問題
上一集我們發現,struct 的值在賦值或傳入函數時會被 move,但整數不會:
let a = 42;
let b = a;
println!("{}", a); // 完全沒問題!
為什麼?答案就是 Copy trait。
什麼是 Copy?
Copy 是一個特殊的 trait。如果一個型別實作了 Copy,那麼在賦值或傳入函數時,Rust 會自動複製一份,而不是 move。
你可以把 Copy 想像成:「這個東西太小了、太簡單了,複製一份根本不費力,所以 Rust 直接幫你複製,不用特別寫 .clone()。」
哪些型別自動有 Copy?
以下這些型別天生就有 Copy:
- 整數:
i8,i16,i32,i64,i128,u8,u16,u32,u64,u128,isize,usize - 浮點數:
f32,f64 - 布林值:
bool - 字元:
char
另外,tuple 和陣列如果裡面每個元素都是 Copy 的,那它們整體也是 Copy 的:
let t = (1, true, 'a'); // (i32, bool, char) → 全部 Copy → tuple 也是 Copy
let t2 = t;
println!("{:?}", t); // OK!
let arr = [1, 2, 3]; // [i32; 3] → i32 是 Copy → 陣列也是 Copy
let arr2 = arr;
println!("{:?}", arr); // OK!
這就是為什麼你在前面幾章寫的程式碼裡,整數、tuple、陣列可以隨便賦值給多個變數、傳進多個函數,完全不會有問題。
除了 Copy 之外,當 tuple 的每個型別都有實作 Clone 時,tuple 也會自動實作 Clone。事實上 tuple 對很多其他 trait 也有同樣的行為——只要所有元素都有實作某個 trait,tuple 整體就會有。這點以後不再贅述。
自己的型別也可以加 Copy
如果你的型別裡面所有值都是 Copy 的型別,那你的型別也可以加上 #[derive(Copy, Clone)]:
#[derive(Debug, Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
注意:#[derive(Copy)] 一定要同時加 Clone——如果只寫 #[derive(Copy)] 而不寫 Clone,編譯器會報錯。
為什麼?因為 Rust 規定:任何可以 Copy 的東西,也必須可以 Clone。Copy 是「自動複製」,Clone 是「手動複製」。如果一個東西連手動複製都不行,那自動複製當然更不行。所以 Copy 要求你先有 Clone。
加上之後,Point 的行為就跟整數一樣了——賦值不會 move:
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // 自動複製,p1 還在!
println!("{:?}", p1); // OK
Copy 和 Clone 的差別
| Copy | Clone | |
|---|---|---|
| 觸發方式 | 自動(賦值、傳入函數) | 手動(.clone()) |
| 適用場景 | 小而簡單的資料 | 任何資料 |
| 使用限制 | 所有欄位都必須是 Copy | 沒有特別限制 |
簡單來說:Copy 是自動的複製,Clone 是手動的複製。
範例程式碼
#[derive(Debug, Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
fn print_point(p: Point) {
println!("函數收到:({}, {})", p.x, p.y);
}
fn double(n: i32) -> i32 {
n * 2
}
fn main() {
// 整數自動 Copy
let a = 42;
let b = a;
println!("a = {}, b = {}", a, b); // 兩個都能用
// bool 也是 Copy
let flag = true;
let flag2 = flag;
println!("flag = {}, flag2 = {}", flag, flag2);
// 整數傳進函數不會 move
let x = 10;
let result = double(x);
println!("x = {}, result = {}", x, result);
// 自訂的 struct 加上 Copy 後也不會 move
let p1 = Point { x: 3, y: 7 };
let p2 = p1; // 自動複製
print_point(p1); // p1 還能用
println!("p1 = {:?}", p1); // 還是能用!
println!("p2 = {:?}", p2);
}
不要隨便幫自己的型別加 Copy
看完這一集,你可能會想:「那我以後每個 struct 都加 #[derive(Copy, Clone)] 不就好了?」
請不要這樣做。 原因是:一旦加了 Copy,使用你這個型別的程式碼就會依賴「賦值時自動複製」的行為。如果有一天你需要修改這個 struct,加了一個不是 Copy 的欄位,你就必須拿掉 Copy。
問題來了:拿掉 Copy 之後,原本寫 let p2 = p1; 的地方全部會從「自動複製」變成「move」,p1 就不能再用了。所有用到這個型別的程式碼都可能因此壞掉,而且壞的地方可能很多、很分散。
所以好的習慣是:只有你確定這個型別永遠都會很小、很簡單,而且不會再加非 Copy 的欄位時,才加 Copy。 像 Point { x: i32, y: i32 } 這種就很適合。如果不確定,只加 Clone 就好——需要複製的時候手動寫 .clone(),未來要改也不會影響其他程式碼。
重點整理
- Copy 是一個 trait,讓型別在賦值和傳入函數時自動複製,而不是 move
i32、f64、bool、char等基本型別天生就有 Copy- tuple 和陣列如果所有元素都是 Copy,整體也是 Copy
- tuple 對很多 trait(Copy、Clone 等)都有同樣的行為:所有元素都有實作 → tuple 就有實作
- 自訂 struct 可以加
#[derive(Copy, Clone)],但所有欄位都必須是 Copy 的型別 - Copy 一定要搭配 Clone 一起 derive
- Copy = 自動複製,Clone = 手動複製(
.clone()) - 不要隨便加 Copy——未來拿掉會讓所有依賴自動複製的程式碼壞掉。不確定就只加 Clone
第四章第 5 集:借用 &
本集目標
學會用 & 借用值,不需要 move 也不需要 clone,就能讓別人讀取你的資料。
概念說明
Move 和 Clone 都有代價
前面我們學了兩種方式來處理所有權:
- move:交出去就沒了,原本的變數不能再用
- Clone:複製一份,但如果資料很大,複製就很浪費
有沒有辦法不交出去、不複製,只是借別人看一下?
有!這就是借用(borrowing),用 & 符號。
& 就是「借」
let p = Point { x: 1, y: 2 };
let r = &p; // r 借用了 p,p 還是擁有者
&p 的意思是:「我不要拿走 p 的所有權,我只是借來看看。」p 還在,你隨時可以繼續用 p。
函數參數用 & 就不會 Move
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
}
let p1 = Point { x: 10, y: 20 };
print_point(&p1); // 傳 &p1,只是借,不是 move
println!("{:?}", p1); // p1 還在!
注意兩個地方:
- 函數參數的型別寫
&Point(前面加&) - 呼叫時傳
&p1(也加&)
這樣函數只是「借」了 p1 來看,用完就還回去,p1 的所有權完全沒有改變。
之前的 & 原來是借用!
還記得之前學的 &[i32](切片)和 &str(字串切片)嗎?當時我們說「先照抄」,現在謎底揭曉了——那些 & 就是借用!
&[i32]是借用一段陣列的資料,不擁有它&str是借用一段文字的資料,不擁有它
所以像這樣的函數:
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
用 for x in nums 就能走訪切片裡的每個元素,跟之前走訪陣列的方式一樣。函數只是借用了陣列的一段切片,不會把整個陣列 move 走。
* 解參考
& 是「借」,反過來 * 就是「順著借用找到原本的值」,叫做解參考(dereference):
let x = 42;
let r = &x;
println!("{}", *r); // 42,和 x 一樣
不過大部分情況下你不需要手動寫 *——Rust 在用 . 存取欄位、呼叫 method、或 println! 的時候都會自動幫你解參考。所以目前知道有這個東西就好,下一集會用到它。
借用是唯讀的
用 & 借用的時候,你只能讀,不能改。如果你想借來改,那是下一集的內容。
範例程式碼
#[derive(Debug, Clone)]
struct Point {
x: i32,
y: i32,
}
// 用借用,不會 move
fn print_point(p: &Point) {
println!("({}, {})", p.x, p.y);
}
// 切片參數就是借用
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
fn main() {
let p1 = Point { x: 10, y: 20 };
// 借用:傳 &p1,p1 不會被 move
print_point(&p1);
print_point(&p1); // 可以借很多次!
println!("p1 還在:{:?}", p1);
// 陣列切片也是借用
let numbers = [1, 2, 3, 4, 5];
let total = sum(&numbers);
println!("總和 = {}", total);
println!("numbers 還在:{:?}", numbers);
// &str 也是借用
let greeting: &str = "你好";
println!("{}", greeting);
println!("{}", greeting); // 可以用很多次
}
重點整理
&是借用,不轉移所有權,原本的變數還能繼續用- 函數參數寫
&Type,呼叫時傳&value - 借用可以多次進行,不像 move 只能一次
- 借用是唯讀的,不能修改借來的資料
&[i32]和&str就是借用——之前「先照抄」的&,現在解謎了
第四章第 6 集:可變借用 &mut
本集目標
學會用 &mut 借用值並修改它,不需要 move 就能改變別人的資料。
概念說明
上一集的限制
上一集我們學了 & 借用,但借用是唯讀的——你只能看,不能改。如果我們想借別人的東西來修改呢?
&mut 就是「借來改」
let mut x = 10;
let r = &mut x; // 可變借用
*r = 20; // 透過 r 修改 x 的值
幾個重點:
- 原本的變數必須是
let mut(因為你要改它) - 借用時寫
&mut x - 要透過借用去修改值,要寫
*r(上一集學的解參考——順著借用找到原本的值)
函數參數用 &mut
更常見的用法是在函數裡:
fn add_ten(n: &mut i32) {
*n += 10;
}
let mut x = 5;
add_ten(&mut x);
println!("{}", x); // 15
函數拿到的是 &mut i32——一個可變借用。透過 *n 可以修改原本的值。呼叫時傳 &mut x。
struct 的可變借用
對 struct 也一樣:
fn move_right(p: &mut Point) {
p.x += 1; // struct 的欄位不需要寫 *,Rust 會自動處理
}
注意:修改 struct 的欄位時,不需要寫 (*p).x += 1,直接寫 p.x += 1 就好——上一集提過 Rust 會自動解參考。
範例程式碼
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
// 透過可變借用修改整數
fn add_ten(n: &mut i32) {
*n += 10;
}
// 透過可變借用修改 struct 的欄位
fn move_right(p: &mut Point) {
p.x += 1;
}
fn move_up(p: &mut Point) {
p.y += 1;
}
fn main() {
// 修改整數
let mut score = 80;
println!("修改前:{}", score);
add_ten(&mut score);
println!("修改後:{}", score);
// 修改 struct
let mut pos = Point { x: 0, y: 0 };
println!("起始位置:{:?}", pos);
move_right(&mut pos);
move_right(&mut pos);
move_up(&mut pos);
println!("移動後:{:?}", pos);
// 直接用 &mut 修改
let mut val = 100;
let r = &mut val;
*r += 50;
println!("val = {}", val);
}
重點整理
&mut是可變借用,借來之後可以修改原本的值- 原本的變數必須是
let mut - 函數參數寫
&mut Type,呼叫時傳&mut value - 修改基本型別要用
*r解參考,修改 struct 欄位可以直接r.field(自動解參考) - 下一集會學到可變借用的重要限制規則
第四章第 7 集:借用規則
本集目標
理解 Rust 的借用規則:同時只能有一個 &mut 或多個 &,以及懸垂參考的問題。
概念說明
為什麼需要規則?
上一集我們學了 &mut 可變借用。但如果 Rust 允許你同時有多個可變借用,會怎樣?
想像你有一串鑰匙圈。借給很多人看(&)沒問題——大家都只是看,不會改變鑰匙圈上有什麼。但如果同時借給兩個人修改(&mut)——A 在加一把新鑰匙,B 同時在拆掉那把——結果就不可預測了。
這也是資料競爭(data race),會導致各種奇怪的 bug。所以 Rust 制定了嚴格的借用規則。
規則一:同時只能有一個 &mut
在同一個時間點,一個值最多只能有一個可變借用:
let mut x = 10;
let r1 = &mut x;
// let r2 = &mut x; // 編譯錯誤!已經有一個 &mut 了
*r1 += 1;
規則二:& 和 &mut 不能同時存在
如果有人在讀(&),就不能有人在改(&mut);反過來也是:
let mut x = 10;
let r1 = &x; // 唯讀借用
// let r2 = &mut x; // 編譯錯誤!已經有 & 了,不能再 &mut
println!("{}", r1);
規則三:多個 & 可以同時存在
多個人同時讀,沒有任何問題:
let x = 10;
let r1 = &x;
let r2 = &x;
let r3 = &x;
println!("{} {} {}", r1, r2, r3); // 完全OK
懸垂參考(Dangling Reference)
還有一個重要的規則:你不能回傳一個區域變數的參考。
// 這段程式碼不會通過編譯!
fn bad() -> &i32 {
let x = 42;
&x // x 在函數結束時就被丟棄了,參考會指向一個已經不存在的值
}
這叫做懸垂參考——參考指向的資料已經消失了,如果 Rust 允許這樣做,你就會讀到垃圾資料。Rust 的編譯器會在編譯時就阻止這種事情發生。
至於 Rust 是怎麼追蹤「參考還有沒有效」的,那就是之後會學到的生命週期(lifetime)概念了。這裡先知道「不能回傳區域變數的參考」就好。
範例程式碼
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
// 多個不可變借用:OK
let p = Point { x: 1, y: 2 };
let r1 = &p;
let r2 = &p;
println!("r1 = {:?}, r2 = {:?}", r1, r2);
// 一個可變借用:OK
let mut p2 = Point { x: 10, y: 20 };
{
let r3 = &mut p2;
r3.x += 5;
println!("修改後:{:?}", r3);
} // r3 離開作用域,可變借用結束
// 可變借用結束了,現在可以用 & 借用
let r4 = &p2;
println!("唯讀借用:{:?}", r4);
// 示範:同時多個唯讀借用
let nums = [10, 20, 30, 40, 50];
let slice1 = &nums[0..3];
let slice2 = &nums[2..5];
println!("slice1 = {:?}", slice1);
println!("slice2 = {:?}", slice2);
}
重點整理
- 允許無限制地借用同樣會造成資料競爭,所以 Rust 制定了借用規則
- 同一時間只能有一個
&mut,不能同時有兩個可變借用 &和&mut不能同時存在——要嘛大家都只讀,要嘛只有一個人在改- 多個
&可以同時存在,多人同時讀沒問題 - 懸垂參考:不能回傳區域變數的參考(值消失了,參考就無效了)
- Rust 靠這些規則在編譯時就防止資料競爭,之後會學到生命週期來更精確地追蹤參考的有效期
第四章第 8 集:self vs &self vs &mut self
本集目標
學會在 method 中選擇 self、&self、&mut self,以及函數參數的 T / &T / &mut T 怎麼選。
概念說明
回顧:第三章的 self
在第三章,我們學了 impl 和 method,當時所有的 method 都用 self 傳值:
impl Cat {
fn meow(self) {
println!("喵~");
}
}
但 self 傳值會消耗這個值——呼叫完之後,原本的變數就不能再用了(因為被 move 了)。
現在我們學了借用,就可以用更聰明的方式了!
三種 self
| 寫法 | 意思 | 效果 |
|---|---|---|
self |
取得所有權 | 呼叫後原本的變數不能再用(move) |
&self |
唯讀借用自己 | 呼叫後原本的變數還能用,但不能改 |
&mut self |
可變借用自己 | 呼叫後原本的變數還能用,而且可以改 |
怎麼選?
- 只是要讀取資料 → 用
&self(最常用!) - 要修改自己的欄位 → 用
&mut self - 要轉移所有權(呼叫後原本的變數不能再用) → 用
self
大部分的 method 都用 &self,因為你通常只是想「看看這個東西的狀態」,不需要消耗它。
函數參數也一樣
不只是 method 的 self,一般函數參數也是同樣的邏輯:
| 參數型別 | 意思 |
|---|---|
p: Point |
拿走所有權(move) |
p: &Point |
唯讀借用 |
p: &mut Point |
可變借用 |
選擇的原則一樣:
- 只讀 →
&T - 要改 →
&mut T - 要消耗 →
T
範例程式碼
#[derive(Debug)]
struct Counter {
id: i32,
count: i32,
}
impl Counter {
// associated function:建立新的 Counter
fn new(id: i32) -> Self {
Counter { id, count: 0 }
}
// &self:只讀
fn get_count(&self) -> i32 {
self.count
}
// &self:只讀,印出資訊
fn display(&self) {
println!("計數器 {}:目前計數 = {}", self.id, self.count);
}
// &mut self:可變借用,修改 count
fn increment(&mut self) {
self.count += 1;
}
// self:取得所有權,回傳最終結果
fn finish(self) -> i32 {
println!("計數器 {} 結束!最終計數 = {}", self.id, self.count);
self.count
}
}
// 一般函數也一樣的邏輯
fn print_counter(c: &Counter) {
println!("(函數版)計數器 {}:{}", c.id, c.count);
}
fn reset_counter(c: &mut Counter) {
c.count = 0;
}
fn main() {
let mut c = Counter::new(1);
// &self:只讀
c.display();
println!("目前:{}", c.get_count());
// &mut self:修改
c.increment();
c.increment();
c.increment();
c.display();
// 一般函數的 &T 和 &mut T
print_counter(&c);
reset_counter(&mut c);
c.display();
c.increment();
c.increment();
// self:取得所有權
let final_count = c.finish();
println!("回傳的最終計數:{}", final_count);
// c 的所有權已經被 finish 拿走了,下面這行會編譯錯誤:
// c.display();
}
呼叫時不用手動加 & 或 &mut
你可能注意到了——呼叫的時候我們只寫 c.display()、c.increment(),不用寫 (&c).display() 或 (&mut c).increment()。Rust 會根據 method 的 self 參數自動幫你加上 & 或 &mut。你當然也能寫 (&c).display() 或 (&mut c).increment(),但沒有必要。
重點整理
&self:唯讀借用,最常用,呼叫後還能繼續用&mut self:可變借用,可以修改欄位,呼叫後還能繼續用self:消耗所有權,呼叫後變數就不能再用了- 選擇原則:只讀 →
&self,要改 →&mut self,要消耗 →self - 一般函數參數也一樣:只讀 →
&T,要改 →&mut T,要消耗 →T - 呼叫 method 時直接寫
c.method(),Rust 會自動加&或&mut
第四章第 9 集:stack 與 heap
本集目標
理解 stack 和 heap 的差別,並揭秘第 1 集鑰匙圈比喻的真正含義。
概念說明
記憶體的兩個區域
程式執行的時候,資料會被放在記憶體裡。記憶體有兩個主要的區域:
堆疊(Stack):
- 快速
- 大小在編譯時就確定了
- 函數的區域變數、整數、浮點數、布林值、char 都放在這裡
- 函數結束時,這些變數就自動被清掉了
堆積(Heap):
- 比較慢,但更靈活
- 大小可以在程式執行時才決定(例如一段文字,你不知道使用者會輸入多長)
- 需要手動管理(在其他語言裡),或靠所有權系統自動管理(在 Rust 裡)
鑰匙圈比喻揭秘!
還記得第 1 集的鑰匙圈比喻嗎?現在來揭秘它真正的意思:
- 鑰匙圈上的裝飾 = stack 上的資料(小而固定,跟著鑰匙圈走)
- 保險箱 = heap 上的資料(大而靈活,存在別的地方)
- 鑰匙 = 指標(pointer),記錄了保險箱在記憶體中的位置
所以當我們說「move 是把鑰匙圈交出去」:
- 如果鑰匙圈上只有裝飾(stack 資料),交出去很便宜,甚至可以直接複製一份(這就是 Copy!)
- 如果鑰匙圈上有鑰匙(指標),交出去的是指標,保險箱(heap 資料)不會複製
為什麼整數是 Copy?
現在你應該懂了:整數(i32 等)就像鑰匙圈上的小裝飾,完全在 stack 上,小小的、複製起來零成本。所以 Rust 讓它們自動 Copy。
而像之後會學到的 String 那樣的型別,它的資料在 heap 上。如果隨便複製,就等於複製了整個保險箱裡的東西,代價很大。所以 Rust 預設用 move,要複製就得明確呼叫 .clone()。
範例程式碼
#[derive(Debug, Copy, Clone)]
struct StackData {
x: i32,
y: i32,
active: bool,
}
fn main() {
// 這些都在 stack 上
let a = 42; // i32,4 bytes,stack
let b = 3.14; // f64,8 bytes,stack
let c = true; // bool,1 byte,stack
let ch = '🦀'; // char,4 bytes,stack
println!("整數:{},浮點:{},布林:{},字元:{}", a, b, c, ch);
// struct 裡面全是 stack 資料,所以整個 struct 也在 stack 上
let data = StackData { x: 10, y: 20, active: true };
let data2 = data; // Copy!data 還能用
println!("data = {:?}", data);
println!("data2 = {:?}", data2);
// 陣列也在 stack 上(大小固定)
let arr = [1, 2, 3, 4, 5];
println!("陣列:{:?}", arr);
// tuple 也在 stack 上
let t = (42, true, 'A');
println!("tuple:{:?}", t);
// 之後會學 String 和 Vec,它們的資料在 heap 上
// 到時候 move 和借用的重要性就更明顯了!
}
重點整理
- Stack(堆疊):快速、大小固定。整數、浮點、布林、char、小 struct 都在這裡
- Heap(堆積):靈活、大小可變。大型或動態大小的資料放在這裡
- 鑰匙圈比喻揭秘:裝飾 = stack 資料、保險箱 = heap 資料、鑰匙 = 指標
- 整數是 Copy,因為它們完全在 stack 上,複製幾乎沒有成本
- Heap 資料預設 move(只轉移指標),要完整複製就用 clone(複製整個保險箱的內容)
第四章第 10 集:String
本集目標
認識 Rust 的 String 型別——一個擁有資料、可以修改的字串。
概念說明
之前的字串都是借來的
從第一章開始,我們一直在用 &str 這個型別:
let greeting = "你好";
"你好" 這個字串是直接寫在程式碼裡的,它的資料被編譯進程式本身。&str 是一個借用——你只是在看這段文字,但你不擁有它,也不能修改它。
String:你擁有的字串
String 是一個你可以擁有、可以修改的字串型別。它的資料存在 heap 上。
用 String::from() 來建立:
let s = String::from("你好");
String::from 是一個 associated function(跟第三章學的一樣,用 :: 呼叫),它會把 &str 的內容複製一份到 heap 上,建立一個你擁有的 String。
push_str:在後面加上文字
String 可以修改!用 push_str 來接上更多文字:
let mut s = String::from("你好");
s.push_str(",世界!");
println!("{}", s); // 你好,世界!
注意變數要宣告成 let mut,因為我們要修改它。
format!:組合多個值成字串
format! 跟 println! 的用法一模一樣,只是它不會印出來,而是回傳一個 String:
let name = "小明";
let age = 20;
let msg = format!("我叫{},今年{}歲", name, age);
println!("{}", msg);
String 也適用所有權規則
因為 String 的資料在 heap 上,所以它不是 Copy。賦值和傳入函數都會 move:
let s1 = String::from("hello");
let s2 = s1; // move!s1 不能再用了
這跟之前學的一樣——想保留 s1 就用 .clone() 或 & 借用。
範例程式碼
fn main() {
// 建立 String
let mut greeting = String::from("你好");
println!("{}", greeting);
// push_str:接上更多文字
greeting.push_str(",世界");
greeting.push_str("!");
println!("{}", greeting);
// format!:組合多個值
let name = "小花";
let score = 95;
let report = format!("{}同學的成績是{}分", name, score);
println!("{}", report);
// String 會 move(不是 Copy)
let s1 = String::from("Rust");
// let s2 = s1; // 如果這樣寫,s1 就被 move 走了,不能再用
let s2 = s1.clone(); // 用 clone 複製一份,s1 還在
println!("s1 = {}", s1);
println!("s2 = {}", s2);
// 傳進函數:用借用就不會 move
let s3 = String::from("哈囉");
print_string(&s3);
println!("s3 還在:{}", s3);
// Debug 格式也能用
let s4 = String::from("debug 測試");
println!("{:?}", s4);
}
fn print_string(s: &String) {
println!("函數收到:{}", s);
}
重點整理
String是擁有資料的字串型別,資料存在 heap 上String::from("...")建立新的 Stringpush_str在字串後面接上更多文字(需要let mut)format!跟println!語法一樣,但回傳 String 而不是印出來- String 不是 Copy,賦值和傳入函數會 move
- 要保留原本的 String,用
.clone()或&借用
第四章第 11 集:String vs &str
本集目標
搞清楚 String 和 &str 的差別,以及函數參數該用哪一個。
概念說明
兩種字串,到底差在哪?
| String | &str | |
|---|---|---|
| 擁有資料? | ✅ 擁有 | ❌ 只是借用 |
| 資料在哪? | heap 上 | 可能在程式碼裡,也可能借用 String 的資料 |
| 可以修改? | ✅ 可以(push_str 等) | ❌ 不行 |
| 會 move? | ✅ 會 | ❌ 不會(它就是個借用) |
&String 會自動轉成 &str
當你有一個 String,想把它的參考傳給接受 &str 的函數時,Rust 會自動幫你轉換:
fn greet(name: &str) {
println!("你好,{}!", name);
}
let s = String::from("小明");
greet(&s); // &String 自動轉成 &str,完全OK!
為什麼可以這樣?之後會學到。現在只要知道:傳 &String 的地方如果參數型別是 &str,Rust 會自動處理。
函數參數偏好 &str
如果你的函數只需要「讀」一段文字,不需要擁有它,參數型別就寫 &str:
fn count_chars(s: &str) -> i32 {
let mut count = 0;
for _c in s.chars() {
count += 1;
}
count
}
這裡用到的 .chars() 是一個 method——String 和 &str 都有實作。它會把字串拆成一個一個字元讓你走訪。
這樣做的好處是:
- 傳
&str(字串字面值)可以用 - 傳
&String也可以用(自動轉換) - 不會 move 任何東西
這就是為什麼 Rust 社群普遍建議:函數參數用 &str 而不是 &String。
什麼時候用 String?
- 你需要擁有這段文字(存在 struct 裡、回傳給呼叫者)
- 你需要修改這段文字(push_str 等)
範例程式碼
// 參數用 &str:既能接 &str,也能接 &String
fn greet(name: &str) {
println!("你好,{}!", name);
}
fn char_count(s: &str) -> i32 {
let mut count = 0;
for _c in s.chars() {
count += 1;
}
count
}
fn main() {
// &str:字串字面值
let literal = "世界";
greet(literal);
// String:擁有的字串
let owned = String::from("小花");
greet(&owned); // &String 自動轉 &str
// 兩種都能傳給接受 &str 的函數
println!("「{}」有 {} 個字元", literal, char_count(literal));
println!("「{}」有 {} 個字元", owned, char_count(&owned));
// String 可以修改,&str 不行
let mut s = String::from("Rust");
s.push_str(" 好好玩");
println!("{}", s);
// String 會 move
let s1 = String::from("hello");
let s2 = s1; // move
// println!("{}", s1); // 編譯錯誤!
println!("{}", s2);
// &str 不會 move(它本身就是借用)
let greeting: &str = "哈囉";
let greeting2 = greeting; // 這是 Copy!(&str 是 Copy 的)
println!("{}", greeting); // OK
println!("{}", greeting2); // OK
}
重點整理
- String 擁有資料(heap 上),可以修改,會 move
- &str 是借用,不擁有資料,不能修改,不會 move
&String會自動轉成&str- 函數參數偏好用
&str——接受範圍更廣(&str和&String都能傳) - 需要擁有或修改字串時才用
String
第四章第 12 集:Vec 基礎
本集目標
學會使用 Vec——一個可以動態增長的陣列。
概念說明
陣列的限制
我們在第二章學了陣列 [i32; 5],但陣列的大小是固定的——宣告時就決定了,之後不能加東西也不能減東西。
如果我們需要一個大小可以變化的集合呢?比如:使用者一筆一筆輸入資料,或者程式在執行過程中不斷累積結果。
這就需要 Vec。Vec 就像一個可以伸縮的陣列,資料存在 heap 上。
建立 Vec
最簡單的方式是用 vec! 巨集:
let nums = vec![1, 2, 3, 4, 5];
這樣就建立了一個包含 5 個 i32 的 Vec。Rust 會根據你放的值自動推斷型別。
你也可以建立空的 Vec,然後一個一個加:
let mut nums = Vec::new();
nums.push(10);
nums.push(20);
Rust 會在你第一次 push 的時候推斷出型別。
索引和走訪
Vec 的索引跟陣列一樣,用 [i]:
let nums = vec![10, 20, 30];
println!("{}", nums[0]); // 10
println!("{}", nums[2]); // 30
走訪也跟陣列一樣,用 for:
for n in &nums {
println!("{}", n);
}
注意:走訪的時候用 &nums(借用),這樣 nums 不會被 move 走。下一集會詳細說明。
push:加入新元素
let mut fruits = Vec::new();
fruits.push("蘋果");
fruits.push("香蕉");
fruits.push("櫻桃");
println!("{:?}", fruits);
push 會把新元素加到最後面。注意 Vec 必須是 let mut 才能 push。
len:取得長度
let nums = vec![1, 2, 3];
println!("長度:{}", nums.len());
範例程式碼
fn main() {
// 用 vec! 建立
let scores = vec![85, 92, 78, 95, 88];
println!("成績:{:?}", scores);
println!("第一筆:{}", scores[0]);
println!("共 {} 筆", scores.len());
// 空的 Vec,用 push 加入
let mut names = Vec::new();
names.push("小明");
names.push("小花");
names.push("阿旺");
println!("名單:{:?}", names);
// 走訪
println!("逐一列出:");
for name in &names {
println!(" - {}", name);
}
// 用 for 走訪並加總
let nums = vec![10, 20, 30, 40, 50];
let mut total = 0;
for x in &nums {
total += x;
}
println!("總和 = {}", total);
// Vec 可以一直 push
let mut growing = Vec::new();
for i in 0..5 {
growing.push(i * 10);
}
println!("動態建立:{:?}", growing);
}
重點整理
- Vec 是可以動態增長的陣列,資料存在 heap 上
vec![1, 2, 3]建立有初始值的 Vec,Rust 自動推斷型別Vec::new()建立空的 Vecpush在最後面加入元素(需要let mut)- 索引用
v[0]、v[1]等,長度用v.len()(method,回傳元素個數) - 走訪用
for x in &v(借用,不 move) - Vec 和陣列的操作方式很像,但 Vec 大小可以變化
第四章第 13 集:Vec 與所有權
本集目標
理解 Vec 的所有權行為,以及它和 String/&str 的對稱關係。
概念說明
Vec 和 String 是一對
在前面幾集,我們學了 String 和 &str 的關係:
| 擁有版本 | 借用版本 |
|---|---|
| String | &str |
Vec 也有完全一樣的對應:
| 擁有版本 | 借用版本 |
|---|---|
| Vec | &[T](切片) |
String 擁有一段文字,&str 借用一段文字。Vec 擁有一組元素,&[T] 借用一段元素。概念完全對稱。
Vec 會 move
Vec 的資料在 heap 上,所以它不是 Copy。賦值和傳入函數都會 move:
let v1 = vec![1, 2, 3];
let v2 = v1; // move!v1 不能再用了
跟 String 一模一樣。
函數參數用切片 &[i32]
跟 String/&str 的建議一樣——如果函數只需要讀取 Vec 的內容,用切片 &[i32]:
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
let v = vec![1, 2, 3, 4, 5];
let total = sum(&v); // &Vec 自動轉成 &[i32]
println!("v 還在:{:?}", v);
就像 &String 會自動轉成 &str,i32 的 &Vec 也會自動轉成 &[i32]。
for 迴圈與所有權
這是很重要的一點:for 迴圈走訪 Vec 時,有兩種寫法:
for x in v——move!
let v = vec![1, 2, 3];
for x in v {
println!("{}", x);
}
// v 被 move 走了,不能再用!
for x in v 會消耗整個 Vec。迴圈結束後,v 就不存在了。
for x in &v——borrow!
let v = vec![1, 2, 3];
for x in &v {
println!("{}", x);
}
println!("v 還在:{:?}", v); // OK!
for x in &v 只是借用,v 不會被消耗。
大部分情況你應該用 for x in &v,除非你確定不再需要這個 Vec。
String 也是一樣的
順帶一提,String 和 for 迴圈也有類似的行為。如果你要走訪 String 的字元,用 .chars():
let s = String::from("你好");
for c in s.chars() {
println!("{}", c);
}
// s 還在!因為 .chars() 的參數是 &self,只是借用 s,不會 move
println!("{}", s);
範例程式碼
// 參數用切片:&Vec 自動轉 &[i32]
fn sum(nums: &[i32]) -> i32 {
let mut total = 0;
for x in nums {
total += x;
}
total
}
fn print_all(nums: &[i32]) {
let mut first = true;
for x in nums {
if first {
first = false;
} else {
print!(", ");
}
print!("{}", x);
}
println!();
}
fn main() {
// Vec 會 move
let v1 = vec![10, 20, 30];
let v2 = v1.clone(); // clone 保留 v1
println!("v1 = {:?}", v1);
println!("v2 = {:?}", v2);
// 函數用切片參數(借用)
let scores = vec![85, 92, 78, 95, 88];
println!("總分 = {}", sum(&scores));
print_all(&scores);
println!("scores 還在:{:?}", scores);
// 切片操作
let slice = &scores[1..4]; // 借用一部分
println!("中間三筆:{:?}", slice);
println!("中間三筆的總分 = {}", sum(slice));
// for x in &v:借用走訪
println!("逐一列出(借用):");
for s in &scores {
println!(" {}", s);
}
println!("scores 還在:{:?}", scores);
// for x in v:move 走訪(用完就沒了)
let temp = vec![1, 2, 3];
println!("消耗走訪:");
for x in temp {
println!(" {}", x);
}
// temp 已經被 move 了,下面會編譯錯誤:
// println!("{:?}", temp);
// 對稱關係整理
// String ↔ &str (擁有 ↔ 借用 文字)
// Vec ↔ &[T] (擁有 ↔ 借用 一組值)
println!("--- 對稱關係 ---");
let s = String::from("hello");
let s_ref: &str = &s; // &String → &str
println!("String: {}, &str: {}", s, s_ref);
let v = vec![1, 2, 3];
let v_ref: &[i32] = &v; // &Vec → &[i32]
println!("Vec: {:?}, slice: {:?}", v, v_ref);
}
重點整理
- Vec 和 String 的所有權行為完全對稱:都在 heap 上,都會 move,都可以 clone
String↔&str就像Vec↔&[T](擁有 ↔ 借用)&Vec會自動轉成&[T](跟&String自動轉&str一樣)- 函數參數偏好用切片
&[T]而不是&Vec for x in v:move,消耗整個 Vecfor x in &v:borrow,只是借用,Vec 還在- 大部分情況用
for x in &v,除非你確定不再需要這個 Vec
恭喜你完成了第四章!🎉 這一章你學會了 Rust 最核心的概念——所有權、move、clone、copy、borrowing,還有 String 和 Vec 這兩個最常用的非 Copy 型別。這些概念是 Rust 和其他語言最大的不同,也是 Rust 能在不需要垃圾回收的情況下保證記憶體安全的關鍵。下一章我們將進入泛型、trait bound 和生命週期——讓你的程式碼能處理任意型別,同時保持型別安全!
第五章:泛型、Trait Bound 與生命週期
第五章第 1 集:泛型函數
本集目標
學會用 <T> 定義泛型函數,讓同一個函數可以處理不同型別。
概念說明
在第四章,我們學了 Vec,用它來存一堆 i32。但你有沒有注意到,我們總是寫 vec![1, 2, 3],讓 Rust 自己推斷型別?
其實,Vec 不是一個完整的型別。它的完整寫法是 Vec<i32>、Vec<String>、Vec<bool>——角括號 <> 裡面放的是「這個 Vec 要裝什麼型別的東西」。
第四章故意沒提角括號,因為當時我們還沒學過泛型。現在,是時候揭開這個秘密了。
什麼是泛型?
假設你想寫一個函數,傳入兩個值,回傳第一個:
fn first_i32(a: i32, b: i32) -> i32 {
a
}
如果又需要處理 f64 呢?難道要再寫一個 first_f64?
泛型就是解決這個問題的。我們用一個「型別參數」T 來代替具體的型別:
fn first<T>(a: T, b: T) -> T {
a
}
這裡的 <T> 寫在函數名後面,表示「這個函數有一個型別參數叫 T」。然後參數 a 和 b 的型別都是 T,回傳值也是 T。
當你呼叫 first(10, 20) 的時候,Rust 看到 10 是 i32,就知道 T = i32。呼叫 first(3.14, 2.71) 的時候,T = f64。同一個函數定義,自動適用於不同型別。
命名慣例
型別參數通常用單個大寫字母:T(Type)、U、V。如果有語意的話也會用比較長的名字,但目前用 T 就好。
範例程式碼
// 泛型函數:回傳兩個值中的第一個
fn first<T>(a: T, _b: T) -> T {
a
}
// 可以有多個型別參數
fn make_pair<T, U>(a: T, b: U) -> (T, U) {
(a, b)
}
fn main() {
// T 被推斷為 i32
let x = first(10, 20);
println!("{}", x);
// T 被推斷為 &str
let y = first("hello", "world");
println!("{}", y);
// T = i32, U = &str
let pair = make_pair(42, "hello");
println!("{:?}", pair);
}
重點整理
Vec的完整寫法是Vec<T>,角括號裡放型別參數——第四章故意省略,現在正式學習- 泛型函數用
<T>宣告型別參數,讓同一個函數可以處理不同型別 - Rust 會根據傳入的值自動推斷
T是什麼型別 - 可以有多個型別參數:
<T, U> - 型別參數慣例用大寫字母:
T、U、V
第五章第 2 集:泛型 struct
本集目標
學會定義帶型別參數的 struct,讓同一個結構可以存放不同型別的資料。
概念說明
上一集我們學了泛型函數。其實 struct 也可以有型別參數!
回想一下,Vec<i32> 和 Vec<String> 就是同一個 struct 定義,只是裡面放的型別不同。我們也可以自己定義這樣的泛型 struct。
定義泛型 Struct
struct Pair<T> {
first: T,
second: T,
}
這裡的 <T> 寫在 struct 名稱後面,表示「Pair 有一個型別參數 T」。first 和 second 的型別都是 T,所以它們必須是同一種型別。
使用的時候:
let p = Pair { first: 1, second: 2 }; // T = i32
let q = Pair { first: "hi", second: "yo" }; // T = &str
多個型別參數
如果你希望 first 和 second 可以是不同型別,就用兩個型別參數:
struct MixedPair<T, U> {
first: T,
second: U,
}
這和上一集的 make_pair<T, U> 概念一模一樣。
範例程式碼
// 兩個欄位必須同型別
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
// 兩個欄位可以不同型別
#[derive(Debug)]
struct MixedPair<T, U> {
first: T,
second: U,
}
fn main() {
let int_pair = Pair { first: 10, second: 20 };
println!("{:?}", int_pair);
let str_pair = Pair { first: "hello", second: "world" };
println!("{:?}", str_pair);
// Pair<T> 的兩個欄位必須同型別,以下會編譯錯誤:
// let bad = Pair { first: 42, second: "oops" };
let mixed = MixedPair { first: 42, second: "answer" };
println!("{:?}", mixed);
}
重點整理
- struct 可以用
<T>宣告型別參數,讓同一個定義適用於不同型別 Pair<T>的兩個欄位都是T,所以必須同型別- 需要不同型別時,用多個型別參數:
MixedPair<T, U> - 和泛型函數一樣,Rust 會根據使用方式自動推斷型別參數
第五章第 3 集:泛型 enum
本集目標
學會定義帶型別參數的 enum。
概念說明
上一集學了泛型 struct,這一集來看泛型 enum。其實概念完全一樣——在 enum 名稱後面加 <T>,讓 variant 攜帶的資料可以是任何型別。
定義泛型 Enum
假設我們想做一個「也許有值」的型別,裡面可能有東西,也可能是空的:
enum Maybe<T> {
Something(T),
Nothing,
}
Something(T) 攜帶一個 T 型別的值,Nothing 什麼都不帶。
泛型 enum 也可以有多個型別參數。比如一個「二選一」的型別:
enum Either<L, R> {
Left(L),
Right(R),
}
Either<L, R> 要嘛是 Left(L),要嘛是 Right(R)——兩個型別完全獨立。
範例程式碼
// 自己定義的泛型 enum
#[derive(Debug)]
enum Maybe<T> {
Something(T),
Nothing,
}
// 兩個型別參數的泛型 enum
#[derive(Debug)]
enum Either<L, R> {
Left(L),
Right(R),
}
fn main() {
let a: Maybe<i32> = Maybe::Something(42);
let b: Maybe<i32> = Maybe::Nothing;
println!("{:?}", a);
println!("{:?}", b);
// 用 match 取出值
match a {
Maybe::Something(val) => println!("裡面有:{}", val),
Maybe::Nothing => println!("空的"),
}
// 兩個型別參數
let x: Either<i32, &str> = Either::Left(100);
let y: Either<i32, &str> = Either::Right("hello");
println!("{:?}", x);
println!("{:?}", y);
}
重點整理
- enum 也可以帶型別參數:
enum Maybe<T> { ... } - variant 攜帶的資料型別可以用
T來泛化 - 可以有多個型別參數:
enum Either<L, R> { Left(L), Right(R) } - 標準庫裡有很多重要的泛型 enum,之後會陸續認識
第五章第 4 集:Turbofish 語法
本集目標
學會用 ::<> turbofish 語法手動指定型別參數,理解它和泛型定義的關係。
概念說明
前幾集我們學了泛型——函數、struct、enum 都可以有型別參數 <T>。大部分時候 Rust 能自動推斷 T 是什麼,但有時候編譯器推不出來,就需要我們手動告訴它。
Turbofish 是什麼?
還記得第一章學 parse 的時候,我們寫過這樣的程式碼嗎?
let num = input.trim().parse::<i32>().expect("請輸入數字");
當時我們把 ::<i32> 當黑盒子照抄。現在學了泛型,終於可以理解它了!
.parse() 是一個泛型方法,有一個型別參數 T,代表「你想把字串轉成什麼型別」。但光看 input.trim().parse() 這段程式碼,編譯器不知道你想轉成 i32 還是 f64 還是其他東西。
所以我們用 ::<i32> 手動指定 T = i32。這個 ::<> 語法就叫做 turbofish(因為 ::<> 看起來像一條魚 🐟)。
Turbofish 的本質
Turbofish 就是「手動填入泛型定義裡角括號的型別參數」:
- 泛型定義:
fn parse<T>(...)——這裡的<T>是宣告 - Turbofish:
.parse::<i32>()——這裡的::<i32>是填入
函數、方法、型別都可以用 turbofish:
// 函數的 turbofish
func::<i32>(arg);
// 型別的 turbofish
Vec::<i32>::new();
.parse() 做了什麼?
順便完整解釋一下 parse:它把字串轉換成你指定的型別。轉換可能失敗(比如 "abc" 不能轉成數字),所以需要搭配 .expect() 處理失敗的情況——這點在第一章就用過了。
範例程式碼
fn first<T>(a: T, _b: T) -> T {
a
}
fn main() {
// 通常 Rust 能自動推斷,不需要 turbofish
let x = first(10, 20);
println!("{}", x);
// 手動用 turbofish 指定型別
let y = first::<f64>(3.14, 2.71);
println!("{}", y);
// Vec 的 turbofish
let v = Vec::<i32>::new();
println!("{:?}", v);
// parse 的 turbofish——呼應第一章的黑盒子
let input = "42";
let num = input.parse::<i32>().expect("不是數字");
println!("{}", num);
let pi = "3.14".parse::<f64>().expect("不是數字");
println!("{}", pi);
}
重點整理
- Turbofish
::<>是手動指定泛型型別參數的語法 - 大部分時候 Rust 能自動推斷,不需要 turbofish
- 當編譯器推不出型別時(例如
.parse()),就需要用 turbofish 手動指定 - 第一章寫的
.parse::<i32>()其實就是 turbofish——現在我們理解它為什麼這樣寫了 .parse()把字串轉成指定型別,轉換可能失敗所以搭配.expect()使用
第五章第 5 集:placeholder type _
本集目標
學會用 _ 在型別標注中讓編譯器推斷部分型別。
概念說明
上一集學了 turbofish,可以手動指定所有型別參數。但有時候你只想指定一部分,剩下的讓 Rust 自己推斷。這時候就用 _ 作為型別層級的萬用字元。
_ 當型別佔位符
看這個例子:
let v: Vec<_> = vec![1, 2, 3];
這裡我們告訴 Rust「這是一個 Vec」,但裡面的元素型別用 _ 表示「你自己推斷吧」。Rust 看到 1, 2, 3 是整數,就推斷 _ = i32。
Turbofish 裡也可以用 _:
let v = Vec::<_>::new();
不過這樣寫其實和直接寫 Vec::new() 讓 Rust 全部推斷沒什麼差別。_ 更常用在你需要指定外層型別、但內層型別讓 Rust 推斷的情況。
什麼時候有用?
當型別有多個參數,你只想標注一部分的時候。_ 的威力在型別越複雜時越明顯——之後學到更多標準庫型別時會自然體會到。
範例程式碼
fn main() {
// 用 _ 讓 Rust 推斷 Vec 的元素型別
let v: Vec<_> = vec![1, 2, 3];
println!("{:?}", v);
// turbofish 裡也能用 _
let v2 = Vec::<_>::new(); // 和 Vec::new() 一樣,_ 讓 Rust 推斷
let v2: Vec<i32> = v2; // 之後透過使用方式確定型別
println!("{:?}", v2);
// 比較:完全不標型別 vs 用 _ 部分標注
let a = vec![true, false]; // Rust 全部推斷:Vec<bool>
let b: Vec<_> = vec![true, false]; // 告訴 Rust 是 Vec,元素型別自己推斷
println!("{:?}", a);
println!("{:?}", b);
}
重點整理
_可以在型別標注中當佔位符,讓 Rust 推斷該位置的型別- 適合用在「外層型別我知道,內層讓 Rust 推斷」的情況
- Turbofish 和
let標注都可以使用_
第五章第 6 集:型別別名
本集目標
學會用 type 為型別建立別名,讓複雜的泛型型別變得更好讀。
概念說明
隨著我們學了泛型,型別會越來越複雜。比如一個三維的資料結構:
Vec<Vec<Vec<i32>>>
每次都寫完整型別有點累,而且不好讀。Rust 提供了 type 關鍵字來建立型別別名:
type Grid3D = Vec<Vec<Vec<i32>>>;
從此以後,Grid3D 和 Vec<Vec<Vec<i32>>> 就是同一個型別——只是換了個名字。它不會建立新型別,就只是一個簡寫。
簡單的別名
type Name = String;
Name 和 String 完全等價,可以互換使用。
帶參數的型別別名
型別別名也可以帶泛型參數:
type Pair<T> = (T, T);
這樣 Pair<i32> 就等於 (i32, i32),Pair<String> 就等於 (String, String)。
注意
型別別名只是簡寫,不是新型別。Name 和 String 完全可以互換使用,編譯器視它們為同一個型別。
範例程式碼
// 簡單的型別別名
type Name = String;
// 簡化複雜的巢狀型別
type Grid3D = Vec<Vec<Vec<i32>>>;
// 帶泛型參數的別名
type Pair<T> = (T, T);
fn main() {
// Name 就是 String
let greeting: Name = String::from("你好");
println!("{}", greeting);
// 三維 Vec 用別名就很清爽
let mut grid: Grid3D = vec![vec![vec![0; 3]; 3]; 3];
grid[1][1][1] = 42;
println!("grid[1][1][1] = {}", grid[1][1][1]);
// Pair<i32> 就是 (i32, i32)
let point: Pair<i32> = (3, 7);
println!("{:?}", point);
let coords: Pair<f64> = (1.5, 3.7);
println!("{:?}", coords);
}
重點整理
type Name = ExistingType;建立型別別名,只是簡寫,不是新型別- 型別別名可以帶泛型參數:
type Pair<T> = (T, T); - 常見用途:簡化複雜的巢狀型別(如
Vec<Vec<Vec<i32>>>) - 別名和原型別完全等價,可以互換使用
第五章第 7 集:泛型 impl
本集目標
學會為泛型 struct 實作方法,理解 impl<T> 語法中兩個 T 的含義。
概念說明
第二集我們定義了泛型 struct Pair<T>。這集要幫它 impl。
回想第三章,impl struct 是這樣寫的:
impl Point {
fn sum(&self) -> i32 {
self.x + self.y
}
}
那泛型 struct 呢?
impl<T> 的語法
impl<T> Pair<T> {
fn new(first: T, second: T) -> Pair<T> {
Pair { first, second }
}
}
注意這裡有兩個 T 出現在不同位置,它們的角色不一樣:
impl<T>的<T>:宣告一個型別參數 T。告訴 Rust「接下來我要用一個叫 T 的型別參數」Pair<T>的<T>:使用剛才宣告的 T。告訴 Rust「我要幫的是Pair<T>這個型別」
換句話說:impl<T> 宣告 T,然後把 T 傳給 Pair<T>——「對於任何型別 T,幫 Pair<T> 實作以下方法」。
如果你只寫 impl Pair<T> 而不加 impl<T>,Rust 會以為 T 是一個具體的型別名稱(就像 i32 或 String 一樣),然後找不到叫 T 的型別就報錯。
反過來,如果你寫 impl Pair<i32>(不需要 impl<T>),那就是只幫 Pair<i32> 這一種加方法,Pair<String> 或其他的都不會有。
方法裡使用 T
宣告了 T 之後,在整個 impl 區塊裡都可以使用它:
impl<T> Pair<T> {
fn new(first: T, second: T) -> Pair<T> {
Pair { first, second }
}
fn first(&self) -> &T {
&self.first
}
}
範例程式碼
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
// associated function
fn new(first: T, second: T) -> Pair<T> {
Pair { first, second }
}
// method:回傳 first 的借用
fn first(&self) -> &T {
&self.first
}
// method:回傳 second 的借用
fn second(&self) -> &T {
&self.second
}
}
fn main() {
let p = Pair::new(10, 20);
println!("first = {}", p.first());
println!("second = {}", p.second());
println!("{:?}", p);
let q = Pair::new("hello", "world");
println!("first = {}", q.first());
println!("second = {}", q.second());
}
重點整理
- 為泛型 struct 實作方法時,寫
impl<T> Pair<T> { ... } impl<T>的<T>是宣告 T,Pair<T>的<T>是使用 T- 結論:
impl<T>宣告 T,然後把 T 傳給Pair<T> - 宣告之後,整個
impl區塊裡的方法都可以使用T
第五章第 8 集:Option
本集目標
認識 Rust 標準庫最重要的泛型 enum——Option<T>,理解它如何取代 null 並防止 runtime 錯誤。
概念說明
null 的問題
在某些程式語言裡,任何變數都可能是 null(空值)。這導致一個經典問題:你以為變數有值,用了它,結果 runtime 炸掉——「null pointer exception」。null 的發明者 Tony Hoare 甚至稱它為「十億美金的錯誤」。
Rust 的解法很簡單:沒有 null。
取而代之的是一個泛型 enum:Option<T>。
Option 的定義
Option<T> 長這樣(標準庫已經幫你定義好了):
enum Option<T> {
Some(T),
None,
}
看起來是不是很像第 3 集我們自己寫的 Maybe<T>?沒錯!概念完全一樣:
Some(T)表示「有一個T型別的值」None表示「沒有值」
強制處理 None
Option 的厲害之處在於:編譯器強制你處理「沒有值」的情況。你不能直接把 Option<i32> 當成 i32 來用,必須先檢查它到底是 Some 還是 None。
這就是用 match 的時候了:
match maybe_value {
Some(v) => println!("有值:{}", v),
None => println!("沒有值"),
}
Option 不用寫完整路徑
因為 Option、Some、None 實在太常用了,Rust 預設就把它們引入到每個檔案裡。所以你不需要寫 Option::Some(42),直接寫 Some(42) 就好。
零成本的秘密:Niche Optimization
一個有趣的小知識:Option<&T> 和普通的參考 &T 佔用一樣大的記憶體!
因為參考 &T 不可能是 null,所以 Rust 在記憶體中聰明地用 null 來代表 None,不需要額外的空間。這叫做 niche optimization——利用型別中「不可能出現的值」來塞額外的資訊。
範例程式碼
// 在切片中找到第一個偶數,找不到就回傳 None
fn find_even(numbers: &[i32]) -> Option<i32> {
for n in numbers {
if n % 2 == 0 {
return Some(*n);
}
}
None
}
fn main() {
let nums = vec![1, 3, 5, 8, 11];
let result = find_even(&nums);
// 用 match 取出 Option 的值
match result {
Some(n) => println!("找到偶數:{}", n),
None => println!("沒有偶數"),
}
let odds = vec![1, 3, 5, 7];
let result2 = find_even(&odds);
match result2 {
Some(n) => println!("找到偶數:{}", n),
None => println!("沒有偶數"),
}
}
重點整理
Option<T>是 Rust 用來表達「可能沒有值」的泛型 enum,取代了其他語言的 nullSome(T)表示有值,None表示沒有值- 編譯器強制你處理
None的情況,runtime 不會有 null pointer exception Option、Some、None太常用,Rust 預設就引入了,不需要額外路徑- Niche optimization:
Option<&T>和&T大小相同,零額外成本
第五章第 9 集:Option 常用方法
本集目標
學會 Option 的常用方法:unwrap、expect、unwrap_or、flatten,以及用 if let 取值。
概念說明
上一集我們用 match 來處理 Option,這是最安全的方式。但每次都寫 match 有時候太囉嗦了。Rust 提供了一些方便的方法。
unwrap:暴力取值
let x: Option<i32> = Some(42);
let value = x.unwrap(); // 42
如果是 Some,直接拿到裡面的值。但如果是 None,程式會 panic(崩潰)!所以 unwrap 要小心用——只在你確定不會是 None 的時候才用。
expect:帶訊息的 unwrap
let x: Option<i32> = None;
let value = x.expect("不應該是 None"); // panic,印出你的訊息
和 unwrap 一樣,但 panic 時會印出你自訂的訊息,方便除錯。
unwrap_or:提供預設值
let x: Option<i32> = None;
let value = x.unwrap_or(0); // 0
如果是 Some 就取出值,如果是 None 就用你給的預設值。不會 panic,很安全。
flatten:把巢狀 Option 壓平
有時候你會碰到 Option<Option<T>> 這種巢狀結構:
let nested: Option<Option<i32>> = Some(Some(42));
let flat: Option<i32> = nested.flatten(); // Some(42)
flatten 把兩層 Option 壓成一層。如果外層或內層是 None,結果就是 None。
範例程式碼
fn find_even(numbers: &[i32]) -> Option<i32> {
for n in numbers {
if *n % 2 == 0 {
return Some(*n);
}
}
None
}
fn main() {
let nums = [1, 3, 5, 7];
let has_even = [2, 4, 6];
// unwrap_or:安全地提供預設值
let result = find_even(&nums).unwrap_or(0);
println!("偶數(沒找到就給 0):{}", result);
// expect:確定有值時使用
let result2 = find_even(&has_even).expect("應該要有偶數");
println!("找到偶數:{}", result2);
// if let:第三章學過的語法
if let Some(n) = find_even(&has_even) {
println!("用 if let 取出:{}", n);
}
// flatten:壓平巢狀 Option
let nested: Option<Option<i32>> = Some(Some(42));
let flat = nested.flatten();
println!("{:?}", flat);
let nested_none: Option<Option<i32>> = Some(None);
let flat_none = nested_none.flatten();
println!("{:?}", flat_none);
let outer_none: Option<Option<i32>> = None;
let flat_outer = outer_none.flatten();
println!("{:?}", flat_outer);
}
重點整理
unwrap():取出 Some 的值,None 時 panic——小心使用expect("訊息"):和 unwrap 一樣,但 panic 時印出自訂訊息unwrap_or(預設值):None 時回傳預設值,不會 panicflatten():把Option<Option<T>>壓成Option<T>- 搭配
if let Some(x) = ...(第三章學的)也很方便
第五章第 10 集:Result
本集目標
學會使用 Result<T, E> 處理可能失敗的操作,理解它和 Option 的對稱關係。
概念說明
上兩集學了 Option<T>——「可能有值,可能沒有」。但有時候,「沒有值」不夠——你還需要知道為什麼沒有。
比如解析數字,失敗時你想知道是「格式錯誤」還是「數字太大」。這就是 Result<T, E> 的用途。
Result 的定義
enum Result<T, E> {
Ok(T),
Err(E),
}
Ok(T)表示成功,裡面包著成功的值Err(E)表示失敗,裡面包著錯誤資訊
和 Option 一樣,Result、Ok、Err 也是 Rust 預設就引入到每個檔案裡的。
Result 和 Option 的對稱
| Option | Result |
|---|---|
| Some(T) | Ok(T) |
| None | Err(E) |
Option 只知道「有或沒有」,Result 還知道「為什麼沒有」。
回顧第一章的黑盒子
還記得第一章的 .expect("讀取失敗") 和 .parse::<i32>().expect("請輸入數字") 嗎?
.parse() 回傳的就是 Result。expect 的行為和 Option 的 expect 一模一樣——成功就取出 Ok 的值,失敗就 panic 並印出你的訊息。
現在我們終於能完整理解第一章的那段「黑盒子」程式碼了。
常用方法
和 Option 一樣,Result 也有:
unwrap():成功取出值,失敗 panicexpect("訊息"):和 unwrap 一樣,但自訂 panic 訊息unwrap_or(預設值):失敗時用預設值
範例程式碼
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("除數不能是零"))
} else {
Ok(a / b)
}
}
fn main() {
// 用 match 處理 Result
let result = divide(10, 3);
match result {
Ok(value) => println!("10 / 3 = {}", value),
Err(msg) => println!("錯誤:{}", msg),
}
// 除以零的情況
let bad = divide(10, 0);
match bad {
Ok(value) => println!("結果:{}", value),
Err(msg) => println!("錯誤:{}", msg),
}
// unwrap_or:失敗時用預設值
let safe = divide(10, 0).unwrap_or(0);
println!("安全的結果:{}", safe);
// 回顧第一章:parse 回傳 Result
let input = "42";
let num: Result<i32, _> = input.parse::<i32>();
match num {
Ok(n) => println!("解析成功:{}", n),
Err(e) => println!("解析失敗:{:?}", e),
}
// expect:確定不會失敗時使用
let num2 = "100".parse::<i32>().expect("這不應該失敗");
println!("{}", num2);
}
重點整理
Result<T, E>表示「成功(Ok)或失敗(Err)」,比 Option 多了錯誤資訊Ok(T)對應成功,Err(E)對應失敗Result、Ok、Err和 Option 一樣,是 Rust 預設就引入每個檔案的unwrap()、expect()、unwrap_or()的用法和 Option 完全對稱- 第一章的
.parse().expect(...)就是在用 Result——現在我們完全理解了
第五章第 11 集:? 運算子
本集目標
學會用 ? 運算子簡化錯誤傳播,避免一次又一次的 match。
概念說明
上一集學了 Result,我們用 match 來處理成功和失敗。但如果一個函數裡有好幾個可能失敗的操作呢?
fn do_stuff() -> Result<i32, String> {
let a = match "42".parse::<i32>() {
Ok(n) => n,
Err(e) => return Err(format!("{:?}", e)),
};
let b = match "10".parse::<i32>() {
Ok(n) => n,
Err(e) => return Err(format!("{:?}", e)),
};
Ok(a + b)
}
每個 parse 都要 match 一次,太囉嗦了。? 運算子就是用來解決這個問題的。
? 的本質
? 放在 Result 後面,做的事情就是:
- 如果是
Ok(v),把v取出來,繼續往下跑 - 如果是
Err(e),直接return Err(e),提前離開函數
所以 ? 就是 match + early return 的簡寫。
注意:錯誤型別要一致
使用 ? 的時候,Err 裡的型別必須和函數回傳的 Err 型別一致。如果不一致,需要手動轉換。
比如 .parse() 的錯誤型別是 std::num::ParseIntError,但你的函數回傳 Result<_, String>,這時候就需要用 match 把錯誤轉成 String:
let n = match input.parse::<i32>() {
Ok(v) => v,
Err(e) => return Err(format!("{:?}", e)),
};
? 也能用在 Option
? 不只能用在 Result 上,也能用在 Option 上——如果是 None,就直接 return None。
main 也能回傳 Result
如果 main 函數回傳 Result<(), String>,你就可以在 main 裡使用 ?。
範例程式碼
// 手動轉換錯誤型別的輔助函數
fn parse_i32(input: &str) -> Result<i32, String> {
match input.parse::<i32>() {
Ok(n) => Ok(n),
Err(e) => Err(format!("解析 '{}' 失敗:{:?}", input, e)),
}
}
// 使用 ? 簡化錯誤傳播
fn add_two_strings(a: &str, b: &str) -> Result<i32, String> {
let x = parse_i32(a)?; // Ok 就取值,Err 就提前回傳
let y = parse_i32(b)?;
Ok(x + y)
}
// ? 用在 Option 上:第一個元素是正數嗎?
fn first_is_positive(numbers: &[i32]) -> Option<bool> {
// 如果切片是空的,.first() 回傳 None,? 直接 return None
let first = numbers.first()?;
Some(*first > 0)
}
// main 也能回傳 Result,這樣就能用 ?
fn main() -> Result<(), String> {
let result = add_two_strings("42", "10")?;
println!("42 + 10 = {}", result);
// 錯誤的情況
let bad = add_two_strings("42", "abc");
match bad {
Ok(n) => println!("結果:{}", n),
Err(e) => println!("錯誤:{}", e),
}
let nums = [3, 7, 2];
match first_is_positive(&nums) {
Some(true) => println!("第一個元素是正數"),
Some(false) => println!("第一個元素不是正數"),
None => println!("空的切片"),
}
let empty: &[i32] = &[];
match first_is_positive(empty) {
Some(b) => println!("結果:{}", b),
None => println!("空的切片,沒有第一個元素"),
}
Ok(())
}
重點整理
?是 match + early return 的簡寫Result上用?:Ok 取值,Err 提前回傳Option上用?:Some 取值,None 提前回傳- 使用
?時,錯誤型別必須和函數回傳型別一致——不一致時用 match 手動轉換 fn main() -> Result<(), String>讓 main 也能使用?
第五章第 12 集:多個方法的 Trait 與預設實作
本集目標
學會在 trait 中定義多個方法,以及用預設實作讓實作者只需覆寫需要的部分。
概念說明
第四章學 trait 的時候,我們的 trait 都只有一個方法。其實 trait 可以有很多個方法,而且有些方法可以提供預設實作——也就是先寫好一個「通用版本」,實作者不喜歡再覆寫。
多個方法
trait Describe {
fn name(&self) -> String;
fn description(&self) -> String;
}
實作的時候,所有方法都必須提供:
impl Describe for Cat {
fn name(&self) -> String { ... }
fn description(&self) -> String { ... }
}
預設實作
有些方法可以先寫好一個合理的預設版本:
trait Describe {
fn name(&self) -> String;
fn description(&self) -> String {
let n = self.name();
let mut result = String::from("我是 ");
result.push_str(&n);
result
}
}
description 有預設實作,它呼叫了 name() 來組合字串。實作 Describe 的時候,只需要提供 name() 就好——description() 會自動使用預設版本。
當然,你也可以覆寫預設實作,提供自己的版本。
預設實作可以呼叫其他方法
注意上面的 description 預設實作裡呼叫了 self.name()。這是允許的——預設實作可以使用同一個 trait 中的其他方法。這讓你可以建立「只要提供幾個基本方法,其他方法就自動有了」的設計。
範例程式碼
trait Describe {
// 必須實作的方法
fn name(&self) -> String;
// 預設實作:可以直接用,也可以覆寫
fn description(&self) -> String {
let n = self.name();
let mut result = String::from("我是 ");
result.push_str(&n);
result
}
}
struct Cat {
nickname: String,
}
struct Dog {
nickname: String,
}
// Cat 只實作 name,description 用預設的
impl Describe for Cat {
fn name(&self) -> String {
self.nickname.clone()
}
}
// Dog 覆寫 description
impl Describe for Dog {
fn name(&self) -> String {
self.nickname.clone()
}
fn description(&self) -> String {
let n = self.name();
let mut result = String::from("汪汪!我叫 ");
result.push_str(&n);
result.push_str(",我是一隻狗!");
result
}
}
fn main() {
let cat = Cat { nickname: String::from("小橘") };
let dog = Dog { nickname: String::from("阿柴") };
// Cat 用預設的 description
println!("{}", cat.name());
println!("{}", cat.description());
// Dog 用自訂的 description
println!("{}", dog.name());
println!("{}", dog.description());
}
重點整理
- trait 可以定義多個方法
- 方法可以提供預設實作——在方法後面直接寫
{ ... }而不是; - 預設實作可以呼叫同一個 trait 中的其他方法
- 實作 trait 時,有預設實作的方法可以不寫(使用預設版本),也可以覆寫
第五章第 13 集:trait bound
本集目標
學會用 trait bound 限制泛型參數的能力,以及用條件式 impl 為符合條件的型別加方法。
概念說明
第一集學泛型函數的時候,我們寫了 fn first<T>(a: T, b: T) -> T。但如果你想在泛型函數裡 clone 一個值呢?
fn duplicate<T>(x: &T) -> (T, T) {
(x.clone(), x.clone()) // 編譯錯誤!
}
編譯器會報錯:「不是所有 T 都有 clone() 方法。」
這很合理——T 可以是任何型別,萬一有個型別沒有實作 Clone 呢?
Trait Bound:限制 T 的能力
解法是加上 trait bound,告訴 Rust「T 必須實作 Clone」:
fn duplicate<T: Clone>(x: &T) -> (T, T) {
(x.clone(), x.clone())
}
T: Clone 的意思是「T 必須實作 Clone trait」。這樣 Rust 就知道 x.clone() 一定可以呼叫。
到處都能加 trait bound
Trait bound 不只能用在函數上。幾乎所有有泛型參數的地方都能加——struct、enum、impl 定義裡都可以:
// struct 上:只有 Clone 的型別才能放進 Wrapper
struct Wrapper<T: Clone> {
value: T,
}
條件式 impl
其中最實用的是在 impl 區塊上加 trait bound。這叫做條件式 impl——只有當型別參數符合某些條件時,才提供特定的方法。
impl<T: Clone> Pair<T> {
fn to_tuple(&self) -> (T, T) {
(self.first.clone(), self.second.clone())
}
}
這段的意思是:只有當 T 實作了 Clone 的時候,Pair<T> 才有 to_tuple 方法。
實際效果
let p1 = Pair::new(1, 2); // i32 有 Clone
let t = p1.to_tuple(); // 可以呼叫 ✓
let p2 = Pair::new(Pair::new(1, 2), Pair::new(3, 4)); // Pair 沒有 derive Clone
// p2.to_tuple(); // 編譯錯誤!Pair<i32> 沒有實作 Clone
Pair<Pair<i32>> 不能呼叫 to_tuple(),因為 Pair<i32> 沒有實作 Clone(我們沒有幫它 derive Clone)。
範例程式碼
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
// 所有 Pair<T> 都有 new
impl<T> Pair<T> {
fn new(first: T, second: T) -> Pair<T> {
Pair { first, second }
}
}
// 只有 T: Clone 的 Pair<T> 才有 to_tuple
impl<T: Clone> Pair<T> {
fn to_tuple(&self) -> (T, T) {
(self.first.clone(), self.second.clone())
}
}
// 泛型函數 + trait bound
fn duplicate<T: Clone>(x: &T) -> (T, T) {
(x.clone(), x.clone())
}
fn main() {
// i32 有 Clone,所以 Pair<i32> 有 to_tuple
let p = Pair::new(10, 20);
let t = p.to_tuple();
println!("{:?}", t);
// 泛型函數也可以用
let pair = duplicate(&42);
println!("{:?}", pair);
let pair2 = duplicate(&String::from("hello"));
println!("{:?}", pair2);
// Pair<Pair<i32>> 不能呼叫 to_tuple
// 因為 Pair<i32> 沒有 derive Clone
let nested = Pair::new(Pair::new(1, 2), Pair::new(3, 4));
println!("{:?}", nested);
// nested.to_tuple(); // 編譯錯誤!Pair<i32> 沒有實作 Clone
}
重點整理
- Trait bound
T: Clone限制 T 必須實作特定 trait - Trait bound 可以加在函數、struct、enum、impl 等各種泛型參數上
- 沒有 trait bound 的話,泛型函數/方法不能假設 T 有任何能力
- 條件式 impl:
impl<T: Clone> Pair<T> { ... }只在 T 符合條件時提供方法 Pair<Pair<i32>>無法呼叫to_tuple(),因為Pair沒有 derive Clone
第五章第 14 集:use 基礎
本集目標
學會用 use 把長路徑縮短,並理解為什麼之前不用 use 就能用 Option、Vec 等型別。
概念說明
你可能已經注意到,我們一直在用 Vec、String、Option、Result 這些型別,從來沒有寫過完整路徑。但 Rust 的標準庫其實有很深的模組結構,這些型別的完整路徑像是 std::vec::Vec、std::string::String、std::option::Option。
為什麼不用寫完整路徑?因為 Rust 有一個叫 prelude 的機制——Rust 預設就把最常用的型別和 trait 引入到每個檔案裡。Vec、String、Option、Result、Some、None、Ok、Err,還有 Clone、Copy 等常用 trait,都在 prelude 裡面。
但不是所有東西都在 prelude 裡。比如 std::fmt::Display 這個 trait,就不在 prelude 裡。如果你想用它,就要寫完整路徑——或者用 use 把它引入。
use 的語法
use std::fmt::Display;
這行的意思是:「把 std::fmt::Display 引入到當前的作用域,之後直接寫 Display 就好。」
use 不會引入新功能,它只是讓長路徑變短。沒有 use,你寫 std::fmt::Display;有了 use,你只需要寫 Display。
為什麼之前不需要 use?
因為我們用的東西幾乎都在 prelude 裡——Vec、String、Option、Clone 等等。從下一集開始,我們會用到不在 prelude 裡的東西(像是 Display),所以現在學 use 剛好。
範例程式碼
use std::mem::size_of;
fn main() {
// 沒有 use 的話,要寫完整路徑:
println!("i32 的大小:{} bytes", std::mem::size_of::<i32>());
// 有了 use,直接寫 size_of 就好:
println!("bool 的大小:{} bytes", size_of::<bool>());
println!("f64 的大小:{} bytes", size_of::<f64>());
println!("char 的大小:{} bytes", size_of::<char>());
}
重點整理
use std::fmt::Display;把長路徑縮短,之後直接寫Displayuse只是路徑的簡寫,不引入新功能- prelude 是 Rust 預設引入的常用型別和 trait(Vec、String、Option、Clone 等)
- 不在 prelude 裡的東西(如 Display)需要寫完整路徑或用
use引入
第五章第 15 集:Display trait
本集目標
學會為自訂型別實作 Display trait,理解 Display 和 Debug 的差別,以及 Display 和 ToString 的關係。
概念說明
第二章我們學了 {:?} 來印出 tuple、陣列和加了 #[derive(Debug)] 的 struct。但 {:?} 是給開發者看的「debug 格式」。如果你想用 {} 來印出自訂型別,就需要實作 Display trait。
Display vs Debug
- Debug(
{:?}):給開發者看的格式,可以用#[derive(Debug)]自動產生 - Display(
{}):給使用者看的格式,必須手動實作,不能 derive
為什麼要分開?因為開發者需要看到所有欄位、型別資訊(debug 格式),但使用者只需要看到好讀的文字。兩者的需求不同,所以不能用同一個 trait 解決。
實作 Display
use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Result;
impl Display for Point {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fmt 方法接收一個 Formatter,你用 write! 巨集把想要的格式寫進去。write! 的用法和 println! 幾乎一樣,只是第一個參數是 f。
Display 和 ToString 的關係
Rust 有一個 ToString trait,它只有一個方法:
fn to_string(&self) -> String
重點來了——你不需要自己實作 ToString。標準庫裡有這樣一段程式碼:
impl<T: Display> ToString for T {
fn to_string(&self) -> String {
// 內部用 Display 的 fmt 方法來產生字串
// ...
}
}
這段的意思是:「對於所有實作了 Display 的型別 T,自動幫它實作 ToString。」這叫做 blanket implementation(毯子式實作)——像一條毯子,蓋住所有符合條件的型別。
所以只要實作 Display,你的型別就自動有 .to_string() 方法,不用額外做任何事。
範例程式碼
use std::fmt::Display;
use std::fmt::Formatter;
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
// 手動實作 Display
impl Display for Point {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
#[derive(Debug)]
struct Color {
r: u8,
g: u8,
b: u8,
}
impl Display for Color {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "R{}G{}B{}", self.r, self.g, self.b)
}
}
fn main() {
let p = Point { x: 3, y: 7 };
// Debug 格式(給開發者看)
println!("Debug: {:?}", p);
// Display 格式(給使用者看)
println!("Display: {}", p);
// 因為有 Display,自動獲得 .to_string()
let s = p.to_string();
println!("to_string: {}", s);
let c = Color { r: 255, g: 128, b: 0 };
println!("Debug: {:?}", c);
println!("Display: {}", c);
println!("to_string: {}", c.to_string());
}
重點整理
Displaytrait 讓你的型別可以用{}格式印出Debug({:?})給開發者看,可以 derive;Display({})給使用者看,必須手動實作- 實作方式:
impl Display for MyType,在fmt方法裡用write!寫格式 - 實作
Display會自動獲得.to_string()方法(blanket implementation)
第五章第 16 集:多個 trait bound 與 where
本集目標
學會用 + 組合多個 trait bound,以及用 where 子句讓複雜的 bound 更好讀。
概念說明
第 13 集我們學了 T: Clone,要求 T 必須實作 Clone。但如果你想同時要求 T 實作多個 trait 呢?
多個 Trait Bound
用 + 把多個 trait bound 串起來:
fn show_clone<T: Clone + std::fmt::Display>(x: &T) {
let cloned = x.clone();
println!("原始:{}", x);
println!("克隆:{}", cloned);
}
T: Clone + Display 表示 T 必須同時實作 Clone 和 Display。
where 子句
當 trait bound 很長的時候,寫在 <> 裡面會很擠。Rust 提供 where 子句,放在函數簽名後面:
fn show_clone<T>(x: &T)
where
T: Clone + std::fmt::Display,
{
let cloned = x.clone();
println!("原始:{}", x);
println!("克隆:{}", cloned);
}
兩種寫法完全等價,只是 where 比較好讀。
where 比角括號更靈活
where 子句的冒號前面不只能放 T,還能放更複雜的東西。比如一個 tuple 型別:
fn clone_pair<T, U>(a: &T, b: &U) -> (T, U)
where
// 編譯器知道 (T, U): Clone 代表 T: Clone 和 U: Clone
// 所以我們也能呼叫 a.clone() 和 b.clone()
(T, U): Clone,
{
let pair = (a.clone(), b.clone());
pair
}
(T, U): Clone 這種寫法只能出現在 where 子句裡,不能放在 <> 裡。
當你寫 (T, U): Clone 時,編譯器知道這隱含了 T: Clone 和 U: Clone——因為 tuple 要能 clone,裡面的每個元素都必須能 clone。
範例程式碼
use std::fmt::Display;
// 多個 trait bound:Clone + Display
// clone 一份,印出原始值,然後回傳複製品
fn clone_and_show<T: Clone + Display>(x: &T) -> T {
println!("複製了:{}", x);
x.clone()
}
// 用 where 子句:有時候比較好讀
fn show_pair<T, U>(a: &T, b: &U)
where
T: Display,
U: Display,
{
println!("a = {}, b = {}", a, b);
}
fn main() {
// 多個 trait bound
let cloned = clone_and_show(&42);
println!("拿到的複製品:{}", cloned);
let cloned2 = clone_and_show(&String::from("hello"));
println!("拿到的複製品:{}", cloned2);
// where 子句
show_pair(&10, &"world");
}
重點整理
- 用
+組合多個 trait bound:T: Clone + Display where子句是另一種寫 trait bound 的方式,更好讀where比角括號更靈活,冒號前面可以放 tuple 等複雜型別(如(T, U): Clone)
第五章第 17 集:impl Trait 語法
本集目標
學會用 impl Trait 作為 trait bound 的簡寫,理解它在參數和回傳值中的不同含義。
概念說明
上一集我們學了 trait bound:fn foo<T: Display>(x: &T)。Rust 還提供了一種更簡潔的寫法:impl Trait。
參數位置的 impl Trait
fn show(x: &impl Display) {
println!("{}", x);
}
這和 fn show<T: Display>(x: &T) 完全等價——都是說「x 的型別必須實作 Display」。只是寫法更簡潔。
每個 impl Trait 是獨立的型別
重要觀念:參數中的每個 impl Trait 代表一個獨立的型別。
fn show_two(a: &impl Display, b: &impl Display) {
println!("{} {}", a, b);
}
a 和 b 可以是不同的型別——只要它們都實作了 Display。比如 a 可以是 i32,b 可以是 String。
如果你要求 a 和 b 必須是同一個型別,就要用具名的型別參數:
fn show_same<T: Display>(a: &T, b: &T) {
println!("{} {}", a, b);
}
回傳位置的 impl Trait
impl Trait 也可以用在回傳值:
fn greeting() -> impl Display {
String::from("你好")
}
這表示「我會回傳一個實作了 Display 的型別,但不告訴你具體是什麼型別」。呼叫者只知道回傳值可以用 Display 的方法(像 println!("{}", greeting())),不知道具體是 String 還是其他什麼。
範例程式碼
use std::fmt::Display;
// 參數位置的 impl Trait
fn show(x: &impl Display) {
println!("顯示:{}", x);
}
// 每個 impl Trait 是獨立型別,a 和 b 可以不同型別
fn show_pair(a: &impl Display, b: &impl Display) {
println!("{} 和 {}", a, b);
}
// 要求同一型別,用泛型
fn show_same<T: Display>(a: &T, b: &T) {
println!("{} 和 {}", a, b);
}
// 回傳位置的 impl Trait
fn make_greeting(name: &str) -> impl Display {
let mut s = String::from("你好,");
s.push_str(name);
s.push_str("!");
s
}
fn main() {
// 參數位置
show(&42);
show(&String::from("hello"));
// 兩個參數可以不同型別
show_pair(&42, &"hello");
// 要求同型別
show_same(&10, &20);
// show_same(&10, &"hello"); // 編譯錯誤!i32 和 &str 不同型別
// 回傳 impl Trait
let greeting = make_greeting("世界");
println!("{}", greeting);
// greeting 的型別是 `impl Display`,不是 `String`
// 所以你不能把它當 String 用:
// greeting.push_str("!!!"); // 編譯錯誤!impl Display 沒有 push_str 方法
// 即使我們知道裡面其實是 String,編譯器只認 Display 這個 trait
}
重點整理
fn foo(x: &impl Display)是fn foo<T: Display>(x: &T)的簡寫- 每個
impl Trait參數代表獨立的型別——兩個impl Display可以是不同型別 - 要求同型別,用具名的型別參數
<T: Display> - 回傳位置的
-> impl Trait隱藏具體型別,呼叫者只知道它實作了什麼 trait
第五章第 18 集:多參數 trait
本集目標
學會定義帶型別參數的 trait,讓同一個型別可以針對不同目標型別實作同一個 trait。
概念說明
到目前為止,我們的 trait 都比較簡單——Clone、Display、Describe,沒有型別參數。但有時候你想定義的行為和另一個型別有關。
比如「轉換」這件事:i32 可以轉成 f64,也可以轉成 String。同一個型別,轉換的目標不同,邏輯也不同。
帶型別參數的 Trait
trait Convert<T> {
fn convert(self) -> T;
}
Convert<T> 的意思是:「可以轉換成 T 型別」。同一個型別可以實作 Convert<f64>、Convert<String> 等不同版本。
實作多參數 Trait
impl Convert<(i32,)> for i32 {
fn convert(self) -> (i32,) {
(self,)
}
}
這裡 i32 實作了 Convert<(i32,)>——把自己轉成單元素 tuple。
同一個型別可以實作多次,只要型別參數不同:
impl Convert<String> for i32 {
fn convert(self) -> String {
// 用 ToString trait(i32 已經有了)
self.to_string()
}
}
和沒有額外參數的 trait 的差別
Clone(無額外參數):一個型別只能實作一次 CloneConvert<T>(有參數):一個型別可以實作Convert<String>、Convert<(i32,)>等多個版本
範例程式碼
// 定義一個帶型別參數的 trait
trait Convert<T> {
fn convert(self) -> T;
}
// i32 轉成單元素 tuple
impl Convert<(i32,)> for i32 {
fn convert(self) -> (i32,) {
(self,)
}
}
// i32 轉成 String
impl Convert<String> for i32 {
fn convert(self) -> String {
self.to_string()
}
}
// bool 轉成 i32
impl Convert<i32> for bool {
fn convert(self) -> i32 {
if self {
1
} else {
0
}
}
}
fn main() {
// i32 -> (i32,)
let x: i32 = 42;
let tuple: (i32,) = x.convert();
println!("{:?}", tuple);
// i32 -> String
let y: i32 = 100;
let s: String = y.convert();
println!("{}", s);
// bool -> i32
let b = true;
let n: i32 = b.convert();
println!("{}", n);
}
重點整理
- Trait 可以帶型別參數:
trait Convert<T> { ... } - 同一個型別可以對不同的
T實作同一個 trait(例如Convert<String>和Convert<(i32,)>) - 這和無額外參數的 trait 不同——一個型別只能實作一次那些 trait
- 多參數 trait 讓「和另一個型別相關的行為」可以統一定義
第五章第 19 集:From / Into
本集目標
學會使用標準庫的 From 和 Into trait 做型別轉換,理解「實作 From 就自動獲得 Into」的機制。
概念說明
上一集我們自己定義了 Convert<T> trait。其實 Rust 標準庫已經有一套更完整的轉換機制:From 和 Into。
From
From<T> 的定義(簡化):
trait From<T> {
fn from(value: T) -> Self;
}
它的意思是:「我可以從 T 轉換而來。」
你一定見過這個:
let s = String::from("hello");
這就是 String 實作了 From<&str>——從 &str 轉換成 String。
Into
Into<T> 是 From 的反方向:
trait Into<T> {
fn into(self) -> T;
}
重點:你只需要實作 From,就自動獲得 Into。 不需要自己實作 Into。
這又是一個 blanket implementation——Rust 有一個規則是「如果 Y: From<X>,那 X 自動實作 Into<Y>」。
TryFrom / TryInto
有些轉換可能失敗——比如把一個很大的 i64 轉成 i32 可能會溢位。這時候用 TryFrom 和 TryInto,它們回傳 Result 而不是直接回傳值。
和 From/Into 一樣,實作 TryFrom 就自動獲得 TryInto。
範例程式碼
use std::fmt::Display;
use std::fmt::Formatter;
struct Celsius {
value: f64,
}
struct Fahrenheit {
value: f64,
}
impl Display for Celsius {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "{}°C", self.value)
}
}
impl Display for Fahrenheit {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "{}°F", self.value)
}
}
// 實作 From:從 Celsius 轉成 Fahrenheit
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Fahrenheit {
Fahrenheit {
value: c.value * 1.8 + 32.0,
}
}
}
fn main() {
// String::from——我們一直在用的
let s = String::from("hello");
println!("{}", s);
// 自訂的 From
let boiling = Celsius { value: 100.0 };
println!("攝氏:{}", boiling);
let f = Fahrenheit::from(Celsius { value: 100.0 });
println!("華氏:{}", f);
// 自動獲得 Into(不需要另外實作)
let body_temp = Celsius { value: 37.0 };
let f2: Fahrenheit = body_temp.into();
println!("體溫:{}", f2);
// TryFrom 的例子:i32 轉 u8 可能失敗
let big: i32 = 300;
let result = u8::try_from(big);
match result {
Ok(n) => println!("轉換成功:{}", n),
Err(e) => println!("轉換失敗:{:?}", e),
}
let small: i32 = 42;
let ok = u8::try_from(small);
match ok {
Ok(n) => println!("轉換成功:{}", n),
Err(e) => println!("轉換失敗:{:?}", e),
}
}
重點整理
From<T>定義「從 T 轉換而來」:String::from("hello")就是這個- 實作
From就自動獲得Into——不需要另外實作 into()是from()的反方向:let f: Fahrenheit = celsius.into();TryFrom/TryInto用於可能失敗的轉換,回傳Result- 實作
TryFrom也會自動獲得TryInto
第五章第 20 集:Drop
本集目標
學會用 Drop trait 定義值離開作用域時的清理行為,以及手動提前釋放資源。
概念說明
到目前為止,我們知道值離開作用域就不能用了。但其實 Rust 在背後還做了一件事——值離開作用域時,Rust 會自動丟棄(drop)它,釋放它佔用的資源(包括記憶體)。大部分時候你不需要在意這件事,但有時候你想在值被丟棄的那一刻做一些額外的事情,比如印一條訊息、關閉檔案、清理暫存等。
Drop Trait
Drop trait 讓你自訂「被丟棄時要做什麼」:
impl Drop for MyType {
fn drop(&mut self) {
println!("MyType 被丟棄了!");
}
}
Rust 會在值離開作用域時自動呼叫 drop。你不能手動呼叫 x.drop()——Rust 禁止這樣做,因為值被 drop 之後又被自動 drop 一次會出問題。
手動提前釋放
如果你想提前釋放一個值,用 drop():
let x = MyType { name: String::from("小明") };
drop(x); // 提前丟棄
// x 不能再用了
drop 是一個函數(不是 method),它會取走值的所有權,然後讓值離開作用域,觸發 Drop。
有 Drop 的型別不能部分 move
這是一個重要的限制。如果一個 struct 實作了 Drop,你就不能從它的欄位 move 出值:
struct Resource {
name: String,
id: i32,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("釋放 {}", self.name);
}
}
let r = Resource { name: String::from("A"), id: 1 };
// let n = r.name; // 編譯錯誤!不能部分 move
為什麼?因為 drop 需要完整的 self。如果你把 name move 走了,drop 執行時 self.name 就不存在了——這不安全。所以 Rust 禁止有 Drop 的型別做部分 move。
範例程式碼
struct Resource {
name: String,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("釋放資源:{}", self.name);
}
}
fn main() {
let a = Resource { name: String::from("資料庫連線") };
let b = Resource { name: String::from("檔案處理器") };
println!("建立了兩個資源");
// 手動提前釋放 a
drop(a);
println!("a 已經被提前釋放了");
// a 不能再用了
// println!("{}", a.name); // 編譯錯誤!
println!("接下來 b 會在 main 結束時自動釋放");
// 作用域示範
{
let c = Resource { name: String::from("暫時的資源") };
println!("c 在這個作用域裡");
} // c 在這裡被自動 drop
println!("c 已經被釋放了,b 還在");
} // b 在這裡被自動 drop
重點整理
Droptrait 讓你自訂值離開作用域時的清理行為- Rust 在值離開作用域時自動呼叫 drop,不能手動呼叫
.drop() - 想提前釋放,用
drop(value) - 有 Drop 的型別不能部分 move——因為 drop 需要完整的 self
第五章第 21 集:Box<T>
本集目標
學會用 Box<T> 把資料放在 heap 上,理解它在遞迴型別中的必要性。
概念說明
還記得第四章的保險箱比喻嗎?鑰匙圈上掛著鑰匙,鑰匙可以打開保險箱,保險箱裡放著真正的東西。
Box<T> 就是那個保險箱——它把資料放在 heap(堆積)上,然後在 stack(堆疊)上留一把鑰匙(指標)。
為什麼需要 Box?
大部分時候,Rust 把資料直接放在 stack 上就好了。但有兩種情況需要 Box:
1. 資料太大
如果一個 struct 有很多欄位、佔很多空間,放在 stack 上可能不太好(stack 空間有限)。用 Box 把它移到 heap 上,stack 上只留一個指標。
2. 遞迴型別
這是更重要的原因。假設你想定義一個連結串列(linked list):
enum List {
Node(i32, List), // 編譯錯誤!
Empty,
}
Rust 需要在編譯時知道每個型別的大小。但這裡有個問題:要知道 List 的大小,你需要知道 Node 有多大。Node 包含一個 i32 和一個 List——所以你需要知道 List 有多大。但 List 裡面又有 List⋯⋯
展開來看:List 的大小 = i32 + List 的大小 = i32 + i32 + List 的大小 = ⋯⋯ 永遠算不完。編譯器在這裡直接報錯:「recursive type has infinite size(遞迴型別大小無限大)」。
解法就是用 Box:
enum List {
Node(i32, Box<List>),
Empty,
}
Box<List> 的大小是固定的(就是一個指標的大小),問題就解決了。
Box 的使用
let x = Box::new(42);
println!("{}", x); // 可以直接用,Rust 會自動解參考
Box::new(value) 把值搬到 heap 上。Box 擁有裡面的值,離開作用域時會自動釋放(因為 Box 實作了 Drop)。
範例程式碼
// 用 Box 的遞迴型別:連結串列
enum List {
Node(i32, Box<List>),
Empty,
}
// 印出串列
fn print_list(list: &List) {
match list {
List::Node(value, next) => {
print!("{} -> ", value);
print_list(next);
}
List::Empty => {
println!("end");
}
}
}
fn main() {
// 基本的 Box 使用
let x = Box::new(42);
println!("Box 裡的值:{}", x);
// 一步一步建立連結串列:3 -> 2 -> 1 -> end
// 從最後面開始建立
let list = List::Empty; // end
let list = List::Node(1, Box::new(list)); // 1 -> end
let list = List::Node(2, Box::new(list)); // 2 -> 1 -> end
let list = List::Node(3, Box::new(list)); // 3 -> 2 -> 1 -> end
print_list(&list);
// Box 是唯一擁有者——鑰匙不是 Copy,所以 let b = a 是 move
let a = Box::new(String::from("hello"));
let b = a; // 鑰匙從 a 交給 b,a 就空了
// println!("{}", a); // 編譯錯誤!a 已經被 move 了
println!("{}", b);
}
重點整理
Box<T>把資料放在 heap 上,stack 上只留一個指標(保險箱比喻的「鑰匙」)- 最重要的用途:遞迴型別(如連結串列)需要 Box 來打破無限大小的問題
Box::new(value)建立 Box,離開作用域時自動釋放- Box 是唯一擁有者,move 語義和其他非 Copy 的 type 一樣
第五章第 22 集:Rc<T>
本集目標
學會用 Rc<T> 讓多個擁有者共享同一份資料,理解參考計數的原理。
概念說明
上一集學了 Box<T>——一個保險箱只有一把鑰匙,一個擁有者。但有時候你需要多個擁有者共享同一份資料。
問題:一個值只能有一個擁有者
let a = Box::new(String::from("hello"));
let b = a; // move!a 不能再用了
如果你希望 a 和 b 都能用這個值,怎麼辦?
你可能會想:「那 clone 一份不就好了?」
let a = Box::new(String::from("hello"));
let b = a.clone(); // 複製了整個 String 的內容
這確實能讓 a 和 b 都能用。但問題是——clone 是真的把 heap 上的資料完整複製了一份。如果資料很大(比如一個很長的 Vec),每次 clone 都是一筆不小的開銷。而且 a 和 b 指向的是兩份獨立的資料,改了 a 不會影響 b。
如果你需要的是「多個人共享同一份資料」,clone 就不是正確的工具了。
Rc:參考計數
還記得第四章的保險箱比喻嗎?Rust 預設的規則是「一個保險箱只有一把鑰匙」——這就是所有權。但 Rc<T> 打破了這個預設:它讓你配好幾把鑰匙,都能打開同一個保險箱。
Rc<T>(Reference Counting)用一個「計數器」來追蹤目前有幾把鑰匙:
- 建立
Rc時,計數 = 1 .clone()時,計數 +1(不會複製資料,只是增加計數)- 某個 Rc 離開作用域時,計數 -1
- 計數歸零時,資料才會被真正釋放
Rc 的 .clone() 不是深度複製
對 Rc 呼叫 .clone() 和我們之前學的 .clone() 不一樣——它不會複製裡面的資料,只是增加參考計數。所以速度很快,成本很低。
Rc 的 Clone 不是用 #[derive(Clone)] 產生的,而是標準庫自己手動實作的。如果用 derive,它會深度複製裡面的資料;但 Rc 的 clone 只是增加計數器,行為完全不同。
Rc 是唯讀的
Rc<T> 只提供不可變的存取——所有擁有者都只能讀,不能改。如果需要可以改,之後會學 RefCell。
範例程式碼
use std::rc::Rc;
fn main() {
// 建立 Rc,計數 = 1
let a = Rc::new(String::from("共享的資料"));
println!("建立 a,計數 = {}", Rc::strong_count(&a));
// clone 只是增加計數,不複製資料
let b = a.clone();
println!("clone 給 b,計數 = {}", Rc::strong_count(&a));
let c = a.clone();
println!("clone 給 c,計數 = {}", Rc::strong_count(&a));
// a, b, c 都可以讀取
println!("a = {}", a);
println!("b = {}", b);
println!("c = {}", c);
{
let d = a.clone();
println!("在作用域裡,計數 = {}", Rc::strong_count(&a));
} // d 被 drop,計數 -1
println!("離開作用域後,計數 = {}", Rc::strong_count(&a));
// 實際用途:多個 struct 共享同一份資料
let shared_name = Rc::new(String::from("Rust"));
let greeting1 = shared_name.clone();
let greeting2 = shared_name.clone();
println!("1: {}", greeting1);
println!("2: {}", greeting2);
}
重點整理
Rc<T>用參考計數讓多個擁有者共享同一份資料Rc::new(value)建立時計數為 1.clone()計數 +1,只增加計數,不複製內部資料——成本很低- 某個 Rc 被 drop 時計數 -1,歸零時才釋放資料
- Rc 是唯讀的——所有擁有者只能讀,不能改
- 用
Rc::strong_count(&x)查看目前的參考計數
第五章第 23 集:Cell<T>
本集目標
學會用 Cell<T> 在不可變參考的情況下修改值,理解它的限制。
概念說明
第四章學了借用規則:要嘛一個 &mut,要嘛多個 &,不能同時。這很安全,但有時候你在只有 &(不可變參考)的情況下,還是想修改值。
Cell 的概念
Cell<T> 提供一種「繞過借用規則」的方式——它用 .get() 取值、.set() 設值,不需要可變參考。
use std::cell::Cell;
let x = Cell::new(42);
x.set(100); // 不需要 mut!
println!("{}", x.get()); // 100
等等,這不會違反安全性嗎?不會,因為 Cell 有一個重要的限制:
T 必須是 Copy
Cell<T> 的 .get() 會把值複製一份出來(不是借用)。所以 T 必須實作 Copy。
你不能 Cell<String>,因為 String 不是 Copy。只能用 Copy 的型別(i32、f64、bool 等)。
為什麼不用 mut?
有些情況下你不方便拿到 &mut。比如一個 struct 被多處共享參考(&self),但你想修改裡面的某個計數器。Cell 就很適合這種場景。
Rc 就是用 Cell 實作的
上一集學的 Rc<T> 需要一個參考計數器——每次 clone 時計數 +1,drop 時計數 -1。但 Rc 對外只提供 &self(不可變參考),計數器卻需要被修改。怎麼辦?答案就是用 Cell!Rc 內部的計數器就是 Cell<usize>,所以即使只有 &self 也能更新計數。
範例程式碼
use std::cell::Cell;
struct Counter {
count: Cell<i32>,
name: String,
}
impl Counter {
fn new(name: String) -> Counter {
Counter {
count: Cell::new(0),
name,
}
}
// 注意:只需要 &self,不需要 &mut self
fn increment(&self) {
let current = self.count.get();
self.count.set(current + 1);
}
fn get_count(&self) -> i32 {
self.count.get()
}
}
fn main() {
// 基本用法
let x = Cell::new(42);
println!("原始值:{}", x.get());
x.set(100);
println!("修改後:{}", x.get());
// 在 struct 裡使用 Cell
let counter = Counter::new(String::from("訪問次數"));
// 只有 &counter(不可變參考),但可以修改 count
counter.increment();
counter.increment();
counter.increment();
println!("{} 的計數:{}", counter.name, counter.get_count());
}
重點整理
Cell<T>讓你在不需要&mut的情況下修改值.get()複製值出來,.set()寫入新值- T 必須是 Copy——因為 get 是複製,不是借用
- 適合用在「只有
&self但想修改某個欄位」的場景
第五章第 24 集:RefCell<T>
本集目標
學會用 RefCell<T> 在執行期檢查借用規則,搭配 Rc 實現可變的共享資料。
概念說明
上一集學了 Cell<T>,但它的限制是 T 必須是 Copy。如果你想修改一個 String 或 Vec 呢?
RefCell:執行期的借用檢查
RefCell<T> 和 Cell 類似——讓你在不需要 &mut 的情況下修改值。差別在於:
Cell<T>:用 get/set,T 必須 Copy,零成本(編譯後和直接存取沒有差別)RefCell<T>:用.borrow()和.borrow_mut()取得參考,T 不需要 Copy,但有執行期成本(每次借用都要檢查有沒有違反規則)
use std::cell::RefCell;
let x = RefCell::new(String::from("hello"));
x.borrow_mut().push_str(" world"); // 修改裡面的 String
println!("{}", x.borrow()); // 借用來讀
執行期檢查
普通的 & 和 &mut 是在編譯時期檢查借用規則。RefCell 把這個檢查移到了執行時期。規則一模一樣(一個 &mut 或多個 &),只是違反時不是編譯錯誤,而是 panic。
let x = RefCell::new(42);
let a = x.borrow(); // 不可變借用
let b = x.borrow_mut(); // panic!已經有不可變借用了
所以 RefCell 不是「繞過」借用規則,而是「延後檢查」。
Rc + RefCell:可變的共享資料
Rc<T> 可以共享資料,但不能改。RefCell<T> 可以改,但不能共享。把它們組合起來:
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
這樣多個 Rc 可以共享同一份資料,而且透過 borrow_mut() 可以修改它。
範例程式碼
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
// 基本的 RefCell 用法
let data = RefCell::new(String::from("hello"));
// 不可變借用
{
let borrowed = data.borrow();
println!("讀取:{}", borrowed);
} // borrowed 離開作用域,釋放借用
// 可變借用
{
let mut borrowed_mut = data.borrow_mut();
borrowed_mut.push_str(" world");
} // borrowed_mut 離開作用域,釋放借用
println!("修改後:{}", data.borrow());
// 違反借用規則 → panic!
// 取消下面的註解就會在執行時 panic
// {
// let r1 = data.borrow(); // 不可變借用
// let r2 = data.borrow_mut(); // 同時可變借用 → panic!
// }
// Rc + RefCell:可變的共享資料
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = shared.clone();
let b = shared.clone();
// 透過 a 修改
a.borrow_mut().push(4);
// 透過 b 也能看到修改
println!("透過 b 讀取:{:?}", b.borrow());
// 透過 b 修改
b.borrow_mut().push(5);
// 透過 a 也能看到
println!("透過 a 讀取:{:?}", a.borrow());
}
重點整理
RefCell<T>把借用規則的檢查從編譯時移到執行時.borrow()取得不可變參考,.borrow_mut()取得可變參考- T 不需要 Copy(和 Cell 的差別)
- Cell 是零成本的,但 RefCell 每次借用都有執行期檢查的開銷
- 違反借用規則時會 panic(不是編譯錯誤)
Rc<RefCell<T>>組合:可變的共享資料
第五章第 25 集:生命週期基礎
本集目標
理解為什麼需要生命週期標注 'a,學會在函數回傳借用時標注生命週期。
概念說明
第四章講借用的時候,我們留了一個伏筆:「不能回傳區域變數的參考。」現在來正式面對這個問題。
問題一:回傳區域變數的參考
fn make_greeting() -> &str {
let s = String::from("哈囉");
&s // 編譯錯誤!
} // s 在這裡被釋放了,回傳的參考指向一塊已經不存在的記憶體
這個比較好理解——s 離開函數就沒了,回傳它的參考毫無意義。Rust 直接擋掉。
問題二:多個參考,回傳哪一個?
但這個情況就比較複雜了:
fn longer(a: &str, b: &str) -> &str {
if a.len() > b.len() {
a
} else {
b
}
}
這段程式碼也會編譯失敗。a 和 b 都是外面傳進來的參考,不會在函數結束時消失,那為什麼不行?
因為 Rust 在檢查呼叫端的時候,需要知道回傳值的參考能「活多久」。看這個例子:
let s1 = String::from("hello world");
let result;
{
let s2 = String::from("hi");
result = longer(&s1, &s2);
} // s2 在這裡被釋放了
println!("{}", result); // result 到底還能不能用?
如果 longer 回傳了 a(也就是 &s1),result 是安全的,因為 s1 還活著。但如果回傳了 b(也就是 &s2),result 就是懸垂參考——s2 已經被釋放了。
問題是:編譯器在檢查 longer 的呼叫端時,不會去看 longer 的函數體。它只看函數簽名。而簽名上寫 -> &str,沒有任何資訊告訴它回傳值和哪個參數的壽命有關。
生命週期標注 'a
解法是用生命週期標注,明確描述回傳值和參數的關係:
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() {
a
} else {
b
}
}
'a 是一個生命週期參數(和型別參數 T 類似,但用 ' 開頭)。這段簽名告訴 Rust:「a、b 和回傳值都標注了同一個 'a。所以回傳值的壽命不能超過 a 和 b 中較短的那個。」注意生命週期參數和型別參數一樣寫在 <> 裡面。如果同時有生命週期和型別參數,生命週期要寫在前面:fn foo<'a, T>(x: &'a T) -> &'a T。
為什麼是較短的?
因為 a 和 b 共用同一個 'a,Rust 會取兩者的交集——也就是兩者都還活著的那段時間。
回到剛才的例子:
let s1 = String::from("hello world"); // s1 的壽命比較長
let result;
{
let s2 = String::from("hi"); // s2 的壽命比較短
result = longer(&s1, &s2);
println!("{}", result); // ✓ 這裡 s1 和 s2 都還活著
} // s2 在這裡被釋放
// println!("{}", result); // ✗ 不行!'a 是取 s1 和 s2 的交集,s2 已經死了
'a 被推斷為 s2 的壽命(較短的那個),所以 result 只能在 s2 還活著的範圍內使用。
&'a mut T
可變參考也可以加生命週期標注,寫成 &'a mut T——就是把 'a 放在 & 和 mut 之間。'a 一樣描述這個參考能活多久。
fn replace<'a>(target: &'a mut String, new_value: &str) {
target.clear();
target.push_str(new_value);
}
生命週期不改變壽命
重要觀念:生命週期標注不會讓任何參考活得更久或更短。它只是描述已有的關係,幫助編譯器做檢查。就像型別標注不會改變值的內容一樣。
不是所有函數都要標
如果函數只有一個參考參數,Rust 能自動推斷(下一集會詳細講):
fn first_char(s: &str) -> &str {
&s[..1] // 回傳值的壽命顯然和 s 一樣,不用手動標
}
'static 生命週期
有一個特殊的生命週期:'static,表示「活到程式結束」。
字串字面值就是 'static——"hello" 的型別是 &'static str,因為字串字面值被寫死在程式碼裡,整個程式執行期間都存在。
範例程式碼
// 回傳借用時,需要標注生命週期
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() {
a
} else {
b
}
}
// 回傳值只和 a 有關,b 不影響
fn always_first<'a>(a: &'a str, _b: &str) -> &'a str {
a
}
fn main() {
// 例子一:兩個參數壽命一樣長
let s1 = String::from("很長的字串");
let s2 = String::from("短");
let result = longer(&s1, &s2);
println!("比較長的是:{}", result);
// 例子二:兩個參數壽命不同
let s3 = String::from("hello world");
let r;
{
let s4 = String::from("hi");
r = longer(&s3, &s4);
println!("在作用域內:{}", r); // ✓ s3 和 s4 都還活著
}
// println!("{}", r); // ✗ 編譯錯誤!s4 已經被釋放,r 的生命週期不夠長
// 例子三:回傳值只借用其中一個參數
let s5 = String::from("我會被回傳");
let r2;
{
let s6 = String::from("我不會");
r2 = always_first(&s5, &s6);
}
// r2 只借用 s5,所以即使 s6 被釋放也沒關係
println!("{}", r2); // ✓ s5 還活著,r2 可以用
// 'static 生命週期
let s: &'static str = "我是靜態字串,活到程式結束";
println!("{}", s);
}
重點整理
- 當函數回傳借用時,Rust 需要知道回傳值能活多久——這就是生命週期標注的目的
'a是生命週期參數,描述參考之間的壽命關係- 多個參數共用同一個
'a時,Rust 取交集(較短的那個) - 生命週期標注不改變壽命,只是描述已有的關係
'static表示「活到程式結束」——字串字面值的型別是&'static str
第五章第 26 集:生命週期省略規則
本集目標
理解 Rust 的生命週期省略規則,知道為什麼大部分時候不需要手動寫生命週期標注。
概念說明
上一集學了生命週期標注,你可能會擔心:「每個有參考的函數都要寫 'a 嗎?好麻煩!」
好消息是:大部分時候不用。Rust 有一套省略規則(elision rules),會自動幫你補上生命週期標注。
三條省略規則
Rust 編譯器按照這三條規則嘗試推斷生命週期:
規則一:每個參數能放生命週期的位置各自獲得獨立的生命週期
fn foo(a: &str, b: &str)
// 編譯器看成:fn foo<'a, 'b>(a: &'a str, b: &'b str)
規則二:如果經過規則一之後只有一個 input lifetime,回傳值的生命週期就等於它
fn first_word(s: &str) -> &str
// 規則一:fn first_word<'a>(s: &'a str) -> &str
// 規則二:只有一個 input lifetime 'a → fn first_word<'a>(s: &'a str) -> &'a str
這就是為什麼上面的 first_word 不用寫 'a——只有一個 input lifetime,規則二自動搞定。
注意一個參數可能帶有多個 input lifetime——比如 &'a &'b T(參考的參考)就有兩個('a 和 'b)。如果有兩個以上的 input lifetime,規則二就不適用了。
規則三:如果有 &self 或 &mut self 參數,回傳值的生命週期就等於 self 的
impl MyStruct {
fn name(&self) -> &str { ... }
// 編譯器看成:fn name<'a>(&'a self) -> &'a str
}
什麼時候規則不夠用?
當有多個參考參數、但回傳值的生命週期不確定跟哪個綁定時——就是上一集 longer 函數的情況。這時候就必須手動標注。
總結
- 一個參考參數 → 幾乎不用寫
- method 回傳
&self的一部分 → 不用寫 - 多個參考參數且回傳參考 → 要寫
範例程式碼
// 規則二:一個 input lifetime,自動推斷
fn trim_hello(s: &str) -> &str {
if s.len() >= 5 {
&s[5..]
} else {
s
}
}
struct Article {
title: String,
content: String,
}
impl Article {
fn new(title: String, content: String) -> Article {
Article { title, content }
}
// 規則三:&self 參數,回傳值生命週期跟 self 綁定
fn title(&self) -> &str {
&self.title
}
fn summary(&self) -> &str {
&self.content
}
}
// 多個參考參數 + 回傳參考 → 需要手動標注
fn pick_longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() {
a
} else {
b
}
}
fn main() {
// 規則二:不用寫生命週期
let greeting = String::from("Hello, world!");
let trimmed = trim_hello(&greeting);
println!("{}", trimmed);
// 規則三:method 不用寫生命週期
let article = Article::new(
String::from("Rust 生命週期"),
String::from("其實沒那麼可怕"),
);
println!("標題:{}", article.title());
println!("摘要:{}", article.summary());
// 多個參考參數:需要手動標注
let a = String::from("hello");
let b = String::from("hi");
let result = pick_longer(&a, &b);
println!("比較長的:{}", result);
}
重點整理
- Rust 有三條省略規則,大部分時候會自動補上生命週期標注
- 規則一:每個參數能放生命週期的位置各自獲得獨立的生命週期
- 規則二:只有一個 input lifetime → 回傳值的生命週期自動等於它
- 規則三:method 有
&self或&mut self→ 回傳值的生命週期自動等於 self - 有多個 input lifetime 且回傳型別有 lifetime 時,才需要手動標注
第五章第 27 集:型別上的生命週期
本集目標
學會為包含參考的 struct 和 enum 標注生命週期,以及用 '_ 匿名生命週期簡化標注。
概念說明
到目前為止,我們的 struct 和 enum 都擁有自己的資料(String、i32 等)。但有時候你想讓它們借用別人的資料——例如存一個 &str 而不是 String。
型別裡放參考
struct Excerpt {
text: &str, // 編譯錯誤!
}
這會報錯。因為 Rust 需要知道:「這個 &str 能活多久?」如果借來的資料被釋放了,struct 裡的參考就變成懸垂參考。
解法是加上生命週期參數:
struct Excerpt<'a> {
text: &'a str,
}
'a 告訴 Rust:「這個 struct 的壽命不能超過它借用的資料。」
Enum 也一樣——如果 variant 攜帶參考,就需要生命週期:
enum Token<'a> {
Word(&'a str),
Number(i32),
}
Token::Word 借用了一段文字,所以 Token 的壽命不能超過那段文字。Token::Number 本身不包含任何參考,但因為它和 Word 是同一個 enum,建立 Token::Number(42) 時仍然需要指定 'a——只是這個 'a 對 Number 來說不起實際作用。
使用帶生命週期的型別
let novel = String::from("很長的故事...");
let excerpt = Excerpt { text: &novel };
excerpt 借用了 novel 的資料,所以 excerpt 不能活得比 novel 更久。
'_ 匿名生命週期
當生命週期可以被推斷的時候,你可以用 '_ 來簡化:
fn print_excerpt(e: &Excerpt<'_>) {
println!("{}", e.text);
}
'_ 告訴 Rust「我知道這裡需要一個生命週期,你自己推斷吧」。還記得第 5 集學的型別佔位符 _ 嗎?'_ 就是它的生命週期版本。
impl 帶生命週期的 Struct
impl<'a> Excerpt<'a> {
fn text(&self) -> &str {
self.text
}
}
和泛型 struct 的 impl 一樣——impl<'a> 宣告生命週期參數,Excerpt<'a> 使用它。
注意 fn text(&self) -> &str 不需要寫任何生命週期標注——上一集學的省略規則第三條在這裡生效了:method 有 &self 時,回傳值的生命週期自動等於 self。
帶 lifetime 的型別作為函數參數
如果函數接收帶 lifetime 的型別,可以搭配 '_ 讓編譯器推斷:
fn into_text(e: Excerpt<'_>) -> &str {
e.text
}
注意這裡不能直接寫 Excerpt 不加任何東西——Excerpt 有一個必要的生命週期參數,就像 Vec 有一個必要的型別參數一樣,不能省略。但我們可以用 '_ 讓編譯器推斷。
完整寫出來是:
fn into_text<'a>(e: Excerpt<'a>) -> &'a str {
e.text
}
省略規則看到 Excerpt<'_> 帶有一個 input lifetime,規則二把回傳值的生命週期也設為同一個。
注意這裡 e 本身是 owned 的(不是參考),函數結束時 e 會被 drop。但回傳的 &'a str 不是借用 e,而是借用 e 裡面存的那段文字——那段文字的壽命是 'a,跟 e 本身的壽命無關。
範例程式碼
// struct 裡放參考,需要生命週期標注
struct Excerpt<'a> {
text: &'a str,
page: i32,
}
impl<'a> Excerpt<'a> {
fn new(text: &'a str, page: i32) -> Excerpt<'a> {
Excerpt { text, page }
}
fn text(&self) -> &str {
self.text
}
fn summary(&self) -> String {
let mut s = String::from("第 ");
let page_str = self.page.to_string();
s.push_str(&page_str);
s.push_str(" 頁:");
s.push_str(self.text);
s
}
}
// 用 '_ 匿名生命週期
fn print_excerpt(e: &Excerpt<'_>) {
println!("[p.{}] {}", e.page, e.text);
}
fn main() {
let novel = String::from("在很久很久以前,有一個程式設計師...");
// excerpt 借用了 novel 的資料
let excerpt = Excerpt::new(&novel[..15], 1);
println!("{}", excerpt.text());
println!("{}", excerpt.summary());
// 用匿名生命週期的函數
print_excerpt(&excerpt);
// excerpt 不能活得比 novel 更久
// 如果 novel 被 drop 了,excerpt 就不能用了
}
重點整理
- struct 裡放參考時,必須標注生命週期:
struct Excerpt<'a> { text: &'a str } - 生命週期保證 struct 不會活得比借用的資料更久
'_是匿名生命週期,讓編譯器自己推斷(生命週期版的_)- impl 帶生命週期的 struct:
impl<'a> Excerpt<'a> { ... }
第五章第 28 集:lifetime bound
本集目標
學會 T: 'a 這種 lifetime bound,理解為什麼 &'a T 需要 T 裡面的參考都活得過 'a。
概念說明
問題:T 裡面可能有參考
到目前為止,我們的泛型函數大多處理 i32、String 這些擁有自己資料的型別。但 T 也可能是 &str 或其他包含參考的型別。
看這個 struct:
struct Ref<'a, T> {
value: &'a T,
}
如果 T 是 &'x str,那 value 就是 &'a &'x str——一個參考指向另一個參考。這時候 'x 必須活得至少和 'a 一樣長,否則外層的 &'a 還活著的時候,裡面的 &'x str 可能已經失效了。
T: 'a 的意思
T: 'a 是一個 lifetime bound,表示「T 裡面的所有參考都活得過 'a」。
如果 T 是 i32(沒有參考),T: 'a 自動滿足。
如果 T 是 &'x str,那 T: 'a 就要求 'x 至少活得和 'a 一樣長。
什麼時候要寫?
在很多情況下,編譯器看到 &'a T 就知道需要 T: 'a,會自動幫你加上。但在某些 trait 定義或比較複雜的泛型結構裡,你可能需要手動寫:
struct Ref<'a, T: 'a> {
value: &'a T,
}
這裡的 T: 'a 其實是多餘的(編譯器能從 &'a T 推出來),但手動寫出來也不會錯,而且讓意圖更清楚。
參考帶生命週期的型別
同樣的道理推廣到任何帶生命週期的型別。如果你有 &'b A<'a>——一個活 'b 那麼久的參考,指向一個 A<'a>——那 A<'a> 整體必須在 'b 的期間都是有效的。這意味著 A 裡面借用的資料必須活得過 'b,也就是 'a 必須至少和 'b 一樣長。
原因很直覺:你持有一個參考 &'b,透過它可以存取 A 裡面所有借用的資料。如果 A 借用的資料比你持有參考的時間更早失效,你就能存取到已經被回收的記憶體。所以 Rust 要求 'a 至少活得和 'b 一樣長。
範例程式碼
struct Excerpt<'a> {
text: &'a str,
}
// T: 'a 確保 T 裡的參考活得過 'a
struct Ref<'a, T: 'a> {
value: &'a T,
}
impl<'a, T: 'a> Ref<'a, T> {
fn new(value: &'a T) -> Ref<'a, T> {
Ref { value }
}
fn get(&self) -> &T {
self.value
}
}
fn main() {
// T = i32(沒有參考,T: 'a 自動滿足)
let num = 42;
let r = Ref::new(&num);
println!("Ref<i32>: {}", r.get());
// T = &str(T 本身就是參考)
let text = String::from("hello");
let slice: &str = &text;
let r2 = Ref::new(&slice);
println!("Ref<&str>: {}", r2.get());
// &'b A<'a> 的例子
let novel = String::from("很長的故事...");
let excerpt = Excerpt { text: &novel };
let r3 = &excerpt; // &'b Excerpt<'a>
// 這裡 'a 是 novel 的壽命,'b 是 r3 借用 excerpt 的時間
// novel 至少活得和 r3 一樣久,所以 'a 活得過 'b,條件滿足
println!("透過參考讀取:{}", r3.text);
// T = String(擁有資料,沒有參考,T: 'a 自動滿足)
let s = String::from("world");
let r3 = Ref::new(&s);
println!("Ref<String>: {}", r3.get());
}
重點整理
T: 'a表示 T 裡面的所有參考都活得過'a- 如果 T 沒有參考(如
i32、String),T: 'a自動滿足 &'a T要合法就需要T: 'a——大部分情況編譯器能自動推斷- 理解 lifetime bound 才能讀懂標準庫裡比較複雜的泛型簽名
第五章第 29 集:supertrait
本集目標
學會用 supertrait 定義 trait 之間的依賴關係,理解 Copy: Clone 的設計原理。
概念說明
有時候一個 trait 需要建立在另一個 trait 的基礎之上。
Supertrait 語法
trait Summarize: std::fmt::Display {
fn summary(&self) -> String;
}
Summarize: Display 的意思是:「要實作 Summarize,你必須先實作 Display。」Display 就是 Summarize 的 supertrait,反過來說,Summarize 是 Display 的 subtrait。
好處是在 Summarize 的預設實作或使用者程式碼裡,可以確定 self 一定有 Display 的功能。
注意:實作 Summarize 不會自動幫你實作 Display。你必須自己手動 impl Display,然後才能 impl Summarize。Supertrait 只是一個「前提條件」,不是「自動贈送」。
Copy: Clone
第四章學過 Copy 和 Clone。它們之間就是 supertrait 的關係:
trait Copy: Clone { }
這表示:要實作 Copy,必須先實作 Clone。
為什麼?因為 Copy 是一種「自動複製」的能力,而 Clone 是「手動複製」的能力。邏輯上,如果你能自動複製,那你也一定能手動複製。所以 Copy 要求 Clone 作為前提。
這就是為什麼 #[derive(Copy, Clone)] 要同時寫兩個——只寫 derive(Copy) 會報錯,因為 Copy 要求 Clone。
範例程式碼
use std::fmt::Display;
use std::fmt::Formatter;
// 定義一個 supertrait:Summarize 要求 Display
trait Summarize: Display {
fn summary(&self) -> String {
// 因為有 Display supertrait,可以用 to_string()
let full = self.to_string();
if full.len() > 10 {
let mut s = String::new();
// 取前 10 個字元
let mut count = 0;
for c in full.chars() {
if count >= 10 {
break;
}
s.push(c);
count += 1;
}
s.push_str("...");
s
} else {
full
}
}
}
struct Article {
title: String,
content: String,
}
// 必須先實作 Display(supertrait)
impl Display for Article {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "{}: {}", self.title, self.content)
}
}
// 然後才能實作 Summarize
impl Summarize for Article {}
// Copy: Clone 的示範
#[derive(Debug, Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let article = Article {
title: String::from("Rust"),
content: String::from("一門很棒的程式語言,值得學習"),
};
// 用 Display(supertrait)
println!("完整:{}", article);
// 用 Summarize(預設實作會用 Display)
println!("摘要:{}", article.summary());
// Copy 需要 Clone 的示範
let p = Point { x: 1, y: 2 };
let p2 = p; // Copy(自動複製)
let p3 = p.clone(); // Clone(手動複製)也可以用
println!("{:?} {:?} {:?}", p, p2, p3);
}
重點整理
trait A: B表示「要實作 A,必須先實作 B」——B 是 A 的 supertraitCopy: Clone——Copy 要求 Clone,所以 derive 時必須同時寫兩個- 實作 subtrait 不會自動實作 supertrait——你必須自己先 impl supertrait
- subtrait 的預設實作裡可以使用 supertrait 的方法
第五章第 30 集:常見的 derive trait
本集目標
學會 PartialEq、Eq、PartialOrd、Ord 等常見 derive trait 的用途和差別。
概念說明
第四章我們學了 Debug、Clone、Copy。Rust 標準庫還有其他可以 derive 的 trait,今天來認識最常用的幾個。
PartialEq 和 Eq
PartialEq 讓你的型別可以用 == 和 != 比較。
#[derive(PartialEq)]
struct Point { x: i32, y: i32 }
Eq 是 PartialEq 的 supertrait(上一集學的),它保證自反性——每個值都等於自己。
「等一下,什麼值不等於自己?」——f64::NAN!在浮點數規範裡,NAN != NAN。所以 f64 只有 PartialEq,沒有 Eq。
如果你的型別不包含浮點數,通常 PartialEq 和 Eq 都可以 derive。
PartialOrd 和 Ord
PartialOrd 讓你的型別可以用 <、>、<=、>= 比較。
Ord 是完整排序——保證任意兩個值都能比大小。f64 因為有 NAN,所以只有 PartialOrd,沒有 Ord。
NAN 和任何值比較都會回傳 false——包括它自己:
let nan = f64::NAN;
println!("{}", nan < 1.0); // false
println!("{}", nan > 1.0); // false
println!("{}", nan == nan); // false
println!("{}", nan <= nan); // false
這就是為什麼 f64 不能有 Ord——你沒辦法把 NAN 放進一個排序裡,因為它和誰比結果都是 false,沒有一個合理的位置可以放它。
四個 trait 的完整關係
先看它們的定義(簡化版):
pub trait PartialEq { ... }
pub trait Eq: PartialEq { }
pub trait PartialOrd: PartialEq { ... }
pub trait Ord: PartialOrd + Eq { ... }
整理成繼承關係:
Eq: PartialEq— 要有完整等價,先要有部分等價PartialOrd: PartialEq— 要能比大小,先要能比相不相等(因為<=包含了==)Ord: PartialOrd + Eq— 要有完整排序,先要有部分排序和完整等價
為什麼 PartialOrd 要求 PartialEq?因為「比大小」本身隱含了「能判斷相等」——如果 a <= b 且 b <= a,那 a == b。
為什麼 Ord 要求 Eq?因為完整排序必須能比較任意兩個值,包括相等的情況。而且 Ord 保證所有值都有確定的位置,所以不允許 NAN 這種「和自己不相等」的值。
這就是為什麼 f64 只能走一邊(PartialEq + PartialOrd),無法走到另一邊(Eq + Ord)。
範例程式碼
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
struct Student {
grade: i32,
name: String,
}
fn main() {
let alice = Student { grade: 90, name: String::from("Alice") };
let bob = Student { grade: 85, name: String::from("Bob") };
let alice2 = Student { grade: 90, name: String::from("Alice") };
// PartialEq:== 和 !=
println!("alice == alice2: {}", alice == alice2);
println!("alice == bob: {}", alice == bob);
println!("alice != bob: {}", alice != bob);
// PartialOrd:< > <= >=
// derive 的 Ord 按欄位順序比較(先比 grade,再比 name)
println!("alice > bob: {}", alice > bob);
println!("bob < alice: {}", bob < alice);
// 排序需要 Ord
let mut students = vec![
Student { grade: 70, name: String::from("Charlie") },
Student { grade: 90, name: String::from("Alice") },
Student { grade: 85, name: String::from("Bob") },
];
students.sort();
for s in &students {
println!("{}: {}", s.name, s.grade);
}
// f64 的特殊情況:NAN
let nan = f64::NAN;
println!("NAN == NAN: {}", nan == nan); // false!
println!("NAN < 1.0: {}", nan < 1.0); // false!
println!("NAN > 1.0: {}", nan > 1.0); // false!
// f64 沒有 Ord,所以不能用 .sort()
// let mut floats = vec![1.0_f64, 2.0, f64::NAN];
// floats.sort(); // 編譯錯誤!f64 沒有實作 Ord
}
重點整理
PartialEq:==、!=比較;Eq:保證自反性(NAN 是例外)PartialOrd:<、>、<=、>=比較;Ord:保證完整排序f64因為 NAN 的存在,只有 Partial 版本,沒有完整版- derive 的 Ord 按欄位宣告順序逐一比較
第五章第 31 集:associated type
本集目標
學會在 trait 中定義 associated type(關聯型別),理解它和泛型參數的差別。
概念說明
第 18 集我們學了泛型 trait:trait Convert<T>。但有時候,型別參數不是「開放的」——一個型別只會有一種合理的實作。
問題:泛型 trait 太自由了
想像一個「容器」的 trait。容器裡面裝什麼型別的元素?用泛型 trait 的話:
trait Container<T> {
fn first(&self) -> Option<&T>;
}
但這意味著同一個型別可以同時實作 Container<i32> 和 Container<String>——通常容器只會有一種元素型別。
Associated Type:一對一的關係
Associated type 解決了這個問題:
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
}
type Item; 宣告了一個 associated type。實作的時候必須指定它是什麼:
impl Container for NumberList {
type Item = i32;
fn first(&self) -> Option<&i32> {
self.data.first()
}
}
當 Self(NumberList)和角括號裡的參數(這裡沒有)都確定了,Item 就唯一確定是 i32,不會有歧義。
和泛型參數的差別
你可以把 trait 想像成一個函數,它接受一些「輸入」然後決定一些「輸出」:
- 輸入(input):
Self(誰來實作這個 trait)和角括號裡的型別參數(<T>) - 輸出(output):associated type(
type Item)
輸入決定了輸出——當你確定了「誰」(Self)和「角括號裡的參數」,associated type 就唯一確定了。
舉例來說,Convert<T> 裡的 T 是輸入,所以同一個 Self 搭配不同的 T 可以有不同的實作:i32 可以同時實作 Convert<String> 和 Convert<(i32,)>。
但 Container 的 Item 是輸出。當你確定了 Self 是 NumberList,Item 就只能有一個答案——i32。
用哪個?如果「確定了所有 input 之後,這個型別就只有一個合理的答案」,把它放在 associated type(output)。如果「同一組 input 可以搭配多種不同答案」,把它放在角括號裡(input)。
在 Trait Bound 中指定 Associated Type
你可以在 trait bound 裡指定 associated type 的具體型別:
fn print_first<C: Container<Item = i32>>(c: &C) { ... }
Container<Item = i32> 表示「實作了 Container,而且 Item 是 i32」。
範例程式碼
use std::fmt::Display;
// 用 associated type 定義容器 trait
trait Container {
type Item;
fn first(&self) -> Option<&Self::Item>;
fn last(&self) -> Option<&Self::Item>;
fn len(&self) -> usize;
}
struct NumberList {
data: Vec<i32>,
}
impl Container for NumberList {
type Item = i32; // 指定 associated type
fn first(&self) -> Option<&i32> {
self.data.first()
}
fn last(&self) -> Option<&i32> {
self.data.last()
}
fn len(&self) -> usize {
self.data.len()
}
}
struct WordList {
words: Vec<String>,
}
impl Container for WordList {
type Item = String; // 不同的型別,不同的 Item
fn first(&self) -> Option<&String> {
self.words.first()
}
fn last(&self) -> Option<&String> {
self.words.last()
}
fn len(&self) -> usize {
self.words.len()
}
}
// 在 trait bound 中用 associated type
fn print_first_item<C>(c: &C)
where
C: Container,
C::Item: Display,
{
match c.first() {
Some(item) => println!("第一個元素:{}", item),
None => println!("容器是空的"),
}
}
fn main() {
let nums = NumberList { data: vec![10, 20, 30] };
let words = WordList {
words: vec![
String::from("hello"),
String::from("world"),
],
};
println!("數字容器長度:{}", nums.len());
print_first_item(&nums);
println!("文字容器長度:{}", words.len());
print_first_item(&words);
// last
match nums.last() {
Some(n) => println!("最後一個數字:{}", n),
None => println!("空的"),
}
}
重點整理
type Item;在 trait 中定義 associated type- 實作時用
type Item = i32;指定具體型別 - input vs output:Self 和角括號參數是 input,associated type 是 output。input 決定 output
- 在 trait bound 中用
Container<Item = i32>指定 associated type
第五章第 32 集:Cow
本集目標
學會使用 Cow<'a, str> 實現「能借就借,需要時才 clone」的彈性策略。
概念說明
有些函數有時候可以直接回傳借用的資料,有時候又需要回傳擁有的資料。
舉個例子
假設你有一個函數,幫字串加上問候語。如果字串已經有「你好」開頭,直接回傳原字串就好(借用)。如果沒有,就要建一個新的字串(擁有)。
回傳型別是 &str 還是 String?兩個都不完全對。
Cow 來拯救
Cow 的全名是 Clone on Write(寫入時才複製)。它定義在 std::borrow 模組裡。來看它的定義(省略了一些我們還沒學的部分):
enum Cow<'a, B>
where
B: 'a + ToOwned,
{
Borrowed(&'a B),
Owned(B::Owned), // ToOwned 的 associated type
}
一行一行看:
'a:生命週期參數,代表借用資料的壽命B: 'a:lifetime bound(上一集學的),B 裡面的參考必須活得過'aB: ToOwned:trait bound,B 必須實作ToOwnedBorrowed(&'a B):借用的版本,存一個&'a BOwned(...):擁有的版本,型別由ToOwned的 associated typeOwned決定
ToOwned 是一個 trait,它有一個 associated type Owned,代表「擁有版本的型別」。
對 str 來說:
str實作了ToOwned,type Owned = String- 所以
Cow<'a, str>=Borrowed(&'a str)或Owned(String)
對 [T] 來說:
[T]實作了ToOwned,type Owned = Vec<T>- 所以
Cow<'a, [T]>=Borrowed(&'a [T])或Owned(Vec<T>)
不管是哪種,Cow<str> 都可以當 &str 來用。
常用方法
to_mut():如果是 Borrowed,先 clone 成 Owned,然後回傳可變參考。如果已經是 Owned,直接回傳。這就是「寫入時才複製」的核心。into_owned():不管是 Borrowed 還是 Owned,都轉成擁有的值。Borrowed 會 clone 一份,Owned 則直接拿走。
範例程式碼
use std::borrow::Cow;
// 如果字串已經是「你好」開頭,直接借用回傳
// 否則建立新的 String
fn ensure_greeting(s: &str) -> Cow<'_, str> {
if s.starts_with("你好") {
// 不需要修改,直接借用
Cow::Borrowed(s)
} else {
// 需要修改,建立新字串
let mut greeting = String::from("你好,");
greeting.push_str(s);
Cow::Owned(greeting)
}
}
fn main() {
// 已經有「你好」開頭 → 借用,不花成本
let s1 = "你好世界";
let result1 = ensure_greeting(s1);
println!("{}", result1);
// 沒有「你好」開頭 → 建立新字串
let s2 = "Rust";
let result2 = ensure_greeting(s2);
println!("{}", result2);
// 可以判斷是借用還是擁有
match ensure_greeting(s1) {
Cow::Borrowed(s) => println!("借用的:{}", s),
Cow::Owned(s) => println!("擁有的:{}", s),
}
match ensure_greeting(s2) {
Cow::Borrowed(s) => println!("借用的:{}", s),
Cow::Owned(s) => println!("擁有的:{}", s),
}
// to_mut:寫入時才複製
let mut cow: Cow<'_, str> = Cow::Borrowed("hello");
// 現在是 Borrowed,呼叫 to_mut 會先 clone 成 Owned
cow.to_mut().push_str(" world");
println!("{}", cow); // "hello world"
// into_owned:轉成擁有的 String
let cow2: Cow<'_, str> = Cow::Borrowed("bye");
let owned: String = cow2.into_owned();
println!("{}", owned);
}
重點整理
Cow<'a, str>可以是借用(&str)或擁有(String),視情況而定- Cow 利用
ToOwnedtrait 的 associated type 來決定擁有版本的型別(str→String、[T]→Vec<T>) to_mut():寫入時才複製(Borrowed → clone 成 Owned → 回傳可變參考)into_owned():不管哪種都轉成擁有的值- 適合用在「大部分時候不修改,偶爾需要修改」的場景
恭喜你完成了第五章!🎉 這一章的內容非常紮實——從泛型、trait bound、生命週期,到 Box、Rc、Cell、RefCell 等智慧指標,再到 Display、Associated Type、Cow。這些是 Rust 型別系統最強大的武器,也是讀懂標準庫原始碼的基礎。下一章我們將進入閉包與迭代器——Rust 最優雅的函數式程式設計風格!
第六章:閉包與迭代器
第六章第 1 集:function pointer
本集目標
認識函數指標(function pointer)型別,學會把函數名稱當成值來傳遞和儲存。
概念說明
在 Rust 裡,函數不只能被呼叫——還能像值一樣被傳來傳去、存進變數、放進 Vec。要做到這件事,我們需要認識函數指標(function pointer)型別。
函數指標的寫法
假設你有一個函數:
fn add_one(x: i32) -> i32 {
x + 1
}
這個函數的函數指標型別是 fn(i32) -> i32。注意這裡的 fn 是小寫的——它代表函數指標型別,不是定義函數的關鍵字。
把函數存進變數
你可以把函數名稱直接賦值給一個變數:
let f: fn(i32) -> i32 = add_one;
之後就能用 f(10) 來呼叫它,效果跟直接呼叫 add_one(10) 一樣。
把函數當參數傳遞
函數指標最常用的場景之一,就是「把一個函數傳給另一個函數」:
fn apply(f: fn(i32) -> i32, value: i32) -> i32 {
f(value)
}
這讓 apply 可以接受任何簽名為 fn(i32) -> i32 的函數,非常靈活。
多個參數和不同回傳型別
函數指標的型別由參數和回傳值決定:
- 沒有參數、沒有回傳值:
fn() - 兩個參數:
fn(i32, i32) -> i32 - 回傳 String:
fn(&str) -> String
函數指標 vs 下一集的閉包
函數指標 fn(...) -> ... 是一個具體的型別,大小固定。但它有一個限制——函數體沒辦法使用呼叫處的區域變數。下一集會介紹閉包(closure),它能做到這件事。
範例程式碼
fn add_one(x: i32) -> i32 {
x + 1
}
fn double(x: i32) -> i32 {
x * 2
}
fn apply(f: fn(i32) -> i32, value: i32) -> i32 {
f(value)
}
fn pick_function(use_double: bool) -> fn(i32) -> i32 {
if use_double {
double
} else {
add_one
}
}
fn main() {
// 把函數存進變數
let f: fn(i32) -> i32 = add_one;
println!("f(5) = {}", f(5));
// 把函數當參數傳遞
println!("apply(add_one, 10) = {}", apply(add_one, 10));
println!("apply(double, 10) = {}", apply(double, 10));
// 函數也可以當回傳值
let chosen = pick_function(true);
println!("chosen(7) = {}", chosen(7));
let chosen2 = pick_function(false);
println!("chosen2(7) = {}", chosen2(7));
// 把函數放進 Vec 裡
let operations: Vec<fn(i32) -> i32> = vec![add_one, double];
for op in &operations {
println!("op(3) = {}", op(3));
}
}
重點整理
- 函數指標型別寫作
fn(參數型別) -> 回傳型別,注意是小寫fn - 函數名稱可以直接當成值,賦值給變數或傳遞給其他函數,也可以存進 Vec 等容器
- 函數指標的限制:沒辦法使用呼叫處的區域變數。下一集的閉包能做到這件事
第六章第 2 集:閉包用法展示
本集目標
學會閉包的基本語法,了解閉包如何捕捉外部變數,並看到標準庫中使用閉包的實際案例。
概念說明
閉包的語法
上一集的函數指標很好用,但有個限制:它不能使用呼叫處的區域變數。閉包(closure)就是為了解決這個問題而存在的。
閉包的基本語法用 | 來包參數:
let add_one = |x| x + 1;
你也可以加上型別標註,跟函數一樣明確:
let add_one = |x: i32| -> i32 { x + 1 };
什麼時候要加大括號?
規則很簡單:
- 只有一個表達式的時候,可以省略大括號:
|x| x + 1 - 有多行程式碼或需要
let、if等語句的時候,要用大括號包起來:
let process = |x: i32| {
let doubled = x * 2;
println!("計算中:{}", doubled);
doubled + 1
};
跟函數一樣,大括號裡最後一行不加分號就是回傳值。
另外,如果有加型別標註(-> i32),就一定要加大括號:
let add_one = |x: i32| -> i32 { x + 1 }; // 有 -> 就必須有 {}
let add_one = |x: i32| x + 1; // 沒有 -> 可以省略 {}
閉包能捕捉外部變數
這是閉包和函數指標最大的差別:
let offset = 10;
let add_offset = |x| x + offset; // 捕捉了 offset
println!("{}", add_offset(5)); // 15
add_offset 這個閉包「記住」了外部的 offset,每次呼叫都會用到它。普通函數做不到這件事。
閉包不是只有一種
根據閉包怎麼使用捕捉到的變數,Rust 會把閉包分成不同的種類——有些閉包只能呼叫一次,有些可以呼叫很多次。這一集先看兩個例子感受一下差別,下幾集再深入解釋。
Result::map —— FnOnce 的例子
標準庫很多方法都接受閉包。還記得第五章的 Result<T, E> 嗎?它有一個 map 方法,可以把 Ok 裡的值做轉換。map 只需要呼叫閉包一次,所以它接受 FnOnce——「至少能呼叫一次」就夠了。
這意味著你可以傳一個會消耗捕捉到的變數的閉包給它:
let prefix = String::from("結果是:");
let result: Result<i32, String> = Ok(42);
let message = result.map(|x| {
// prefix 被 move 進來,這個閉包只能呼叫一次
let mut s = prefix; // move!
s.push_str(&x.to_string());
s
});
println!("{:?}", message); // Ok("結果是:42")
這個閉包把 prefix move 進來了,呼叫一次之後 prefix 就沒了。但沒關係,map 本來就只呼叫接收的函數一次。
Vec::retain —— FnMut 的例子
Vec<T> 的 retain 方法會保留符合條件的元素,移除不符合的。它接受一個閉包,這個閉包接收 &T(每個元素的參考)、回傳 bool(true 保留、false 移除)。因為 retain 要對每個元素都呼叫一次,所以它要求 FnMut——「可以多次呼叫」。
你可以傳一個會修改捕捉到的變數的閉包:
let mut numbers = vec![1, 2, 3, 4, 5, 6];
let mut removed_count = 0;
numbers.retain(|x| {
if x % 2 == 0 {
true // 保留偶數
} else {
removed_count += 1; // 修改外部變數
false
}
});
println!("{:?},移除了 {} 個", numbers, removed_count);
// [2, 4, 6],移除了 3 個
這個閉包每次被呼叫都會修改 removed_count——它是 FnMut。注意它沒有 move 任何東西(只是透過 &mut 修改外部變數),所以可以被呼叫很多次。
如果把 FnOnce 傳給 retain?
上面 Result::map 那種會 move 變數的閉包,能傳給 retain 嗎?
let mut items = vec![1, 2, 3];
let header = String::from("剔除:");
// items.retain(|x| {
// if *x <= 1 {
// let mut log = header; // move header
// log.push_str(&x.to_string());
// log.push(' ');
// }
// *x > 1
// }); // 編譯錯誤!
這個閉包在第一次剔除元素時就把 header move 走了,第二次要剔除時 header 已經不存在。它只能呼叫一次(FnOnce),但 retain 需要多次呼叫(FnMut)。所以編譯器會報錯。
不捕捉變數的閉包 → 可以轉成函數指標
如果一個閉包沒有捕捉任何外部變數,它就跟普通函數沒什麼差別。Rust 允許它自動轉型成函數指標 fn:
let add_one: fn(i32) -> i32 = |x| x + 1; // 沒有捕捉,可以轉成 fn
但如果捕捉了外部變數,就不能這樣轉了。
範例程式碼
fn apply_fn_pointer(f: fn(i32) -> i32, value: i32) -> i32 {
f(value)
}
fn main() {
// 基本閉包語法
let square = |x: i32| -> i32 { x * x };
println!("square(4) = {}", square(4));
// 捕捉外部變數
let base = 100;
let add_base = |x| x + base;
println!("add_base(7) = {}", add_base(7));
// Result::map(FnOnce)
let result: Result<i32, String> = Ok(21);
let doubled = result.map(|x| x * 2);
println!("doubled = {:?}", doubled);
let err_result: Result<i32, String> = Err(String::from("oops"));
let still_err = err_result.map(|x| x * 2);
println!("still_err = {:?}", still_err);
// Vec::retain(FnMut)
let mut scores = vec![55, 72, 88, 43, 91, 60];
scores.retain(|s| *s >= 60);
println!("及格分數:{:?}", scores);
// 不捕捉變數的閉包可以轉成函數指標
let triple: fn(i32) -> i32 = |x| x * 3;
println!("apply_fn_pointer(triple, 5) = {}", apply_fn_pointer(triple, 5));
// 捕捉了變數的閉包不能轉成函數指標
// let offset = 10;
// let bad: fn(i32) -> i32 = |x| x + offset; // 編譯錯誤!
}
重點整理
- 閉包用
|參數| 表達式語法,可以省略型別標註讓 Rust 推導 - 閉包最大的特色是能捕捉外部變數,這是函數指標做不到的
Result::map接受FnOnce閉包——只需呼叫一次Vec::retain接受FnMut閉包——需要多次呼叫- 如果閉包只能呼叫一次(FnOnce),就不能傳給需要多次呼叫的方法
- 不捕捉外部變數的閉包可以自動轉型成函數指標
fn
第六章第 3 集:手動實作閉包
本集目標
透過手動把閉包拆解成 struct + 方法,理解編譯器在背後做了什麼事。你會看到三種閉包各自對應什麼樣的 struct,以及為什麼呼叫閉包其實是在呼叫方法。
概念說明
閉包 = 匿名 struct + 方法
上一集我們看到閉包可以捕捉外部變數。但它是怎麼「記住」這些變數的?
答案很直接——編譯器幫你做了兩件事:
- 建立一個匿名 struct,把捕捉的變數存成欄位
- 在那個 struct 上 impl 一個方法,方法的內容就是你寫在
||後面的閉包體
換句話說,你寫的閉包體({ ... } 裡面的程式碼)就是那個方法的實作。
今天我們就來手動做一次編譯器做的事,把三種閉包分別模擬出來。
閉包呼叫 = 方法呼叫
當你寫 f() 呼叫一個閉包,編譯器其實把它轉換成 struct 上的方法呼叫:
- FnOnce:
f()→f.call_once()— 傳self,消耗整個 struct - FnMut:
f()→f.call_mut()— 傳&mut self,可變借用 struct - Fn:
f()→f.call()— 傳&self,唯讀借用 struct
看出來了嗎?這就是第四章學的三種方法接收者:self、&mut self、&self。閉包的三種分類,本質上就是方法接收 self 的三種方式。
上一集介紹了 FnOnce(消耗捕捉的值,只能呼叫一次)和 FnMut(修改捕捉的值,可多次呼叫)。Fn 在上一集沒有出現——它是第三種:只讀取捕捉的值,不消耗也不修改,可以呼叫任意多次。
接下來我們分別用 struct 手動模擬這三種閉包。注意:三種閉包的 struct 欄位型別不同,不是同一個 struct 換三種方法。
FnOnce:struct 存擁有的值,方法接 self
假設我們有這樣的閉包:
let name = String::from("Alice");
let greet = || {
let s = name; // 閉包體內把 name 移走了
println!("Hello, {}!", s);
};
greet();
// greet(); // 編譯錯誤!name 已經被移走,不能再呼叫
編譯器會產生類似這樣的東西:
struct GreetOnce {
name: String, // 擁有 name(owned)
}
// 建立閉包 = 把捕捉的變數塞進 struct
// let greet = GreetOnce { name };
impl GreetOnce {
// 呼叫閉包 = 呼叫 struct 上的方法
fn call_once(self) {
let s = self.name; // 把 name 從 struct 裡移出來
println!("Hello, {}!", s);
}
}
因為方法接收 self(by value),呼叫的時候整個 struct 被消耗掉了,所以只能呼叫一次。這就是 FnOnce。
FnMut:struct 存可變借用,方法接 &mut self
假設閉包修改了捕捉的變數:
let mut name = String::from("Alice");
let mut greet = || {
name.push_str("!");
println!("Hello, {}", name);
};
greet();
greet(); // 可以多次呼叫
編譯器產生的東西:
struct GreetMut<'a> {
name: &'a mut String, // 可變借用 name
}
// let mut greet = GreetMut { name: &mut name };
impl<'a> GreetMut<'a> {
fn call_mut(&mut self) {
self.name.push_str("!");
println!("Hello, {}", self.name);
}
}
為什麼 struct 存 &mut,方法又接 &mut self? 因為一個閉包可能捕捉多個變數。假設閉包同時修改了 a、b、c 三個變數,struct 裡就會有三個欄位:
struct SomeClosure<'a> {
a: &'a mut i32,
b: &'a mut String,
c: &'a mut Vec<i32>,
}
方法用 &mut self 而不是 self,因為用 self 的話呼叫一次就消耗掉了——那就變成 FnOnce 了。FnMut 需要多次呼叫,所以只能借用整個 struct。
Fn:struct 存唯讀借用,方法接 &self
如果閉包只是讀取捕捉的變數,完全不修改:
let name = String::from("Alice");
let greet = || {
println!("Hello, {}!", name);
};
greet();
greet(); // 可以多次呼叫,完全沒問題
編譯器產生的東西:
struct GreetRef<'a> {
name: &'a String, // 唯讀借用 name
}
// let greet = GreetRef { name: &name };
impl<'a> GreetRef<'a> {
fn call_ref(&self) {
println!("Hello, {}!", self.name);
}
}
因為方法接收 &self,struct 不會被消耗也不會被修改,所以可以呼叫任意多次。這就是 Fn。
對照表
| self 類型 | 對應 trait | struct 欄位存什麼 | 能做什麼 |
|---|---|---|---|
self |
FnOnce | 擁有的值(如 String) |
消耗捕捉的值,只能呼叫一次 |
&mut self |
FnMut | 可變借用(如 &mut String) |
修改捕捉的值,可以多次呼叫 |
&self |
Fn | 唯讀借用(如 &String) |
只讀取,可以多次呼叫 |
小結:閉包到底是什麼?
把上面的東西串起來:
- 編譯器幫你建一個匿名 struct,把捕捉的變數存進去
- 你寫的閉包體就是那個 struct 上方法的實作
- 當你寫
f()的時候,編譯器根據閉包的種類,呼叫 struct 上的.call_once()/.call_mut()/.call()
每次你寫一個閉包,編譯器就在幕後做了「建 struct → impl 方法 → 呼叫方法」這些事。
範例程式碼
以下的完整程式碼把三種閉包都手動模擬出來。每一個 struct 對應一種閉包,欄位型別和方法接收者都不同:
// === FnOnce 模擬 ===
// struct 擁有值,方法接 self
struct GreetOnce {
name: String,
}
impl GreetOnce {
fn call_once(self) {
// 閉包體:把 name 移走
let s = self.name;
println!("[FnOnce] Hello, {}!", s);
// self 被消耗了,不能再用
}
}
// === FnMut 模擬 ===
// struct 存可變借用,方法接 &mut self
struct GreetMut<'a> {
name: &'a mut String,
}
impl<'a> GreetMut<'a> {
fn call_mut(&mut self) {
// 閉包體:修改捕捉的變數
self.name.push_str("!");
println!("[FnMut] Hello, {}", self.name);
}
}
// === Fn 模擬 ===
// struct 存唯讀借用,方法接 &self
struct GreetRef<'a> {
name: &'a String,
}
impl<'a> GreetRef<'a> {
fn call_ref(&self) {
// 閉包體:只讀取,不修改
println!("[Fn] Hello, {}!", self.name);
}
}
fn main() {
// --- FnOnce:呼叫一次就消耗 ---
let name1 = String::from("Alice");
let greet_once = GreetOnce { name: name1 };
greet_once.call_once();
// greet_once.call_once(); // 編譯錯誤!struct 已經被消耗了
// --- FnMut:可以多次呼叫,每次修改 ---
let mut name2 = String::from("Bob");
{
let mut greet_mut = GreetMut { name: &mut name2 };
greet_mut.call_mut(); // Bob!
greet_mut.call_mut(); // Bob!!
greet_mut.call_mut(); // Bob!!!
} // greet_mut 離開作用域,借用結束
println!("name2 現在是:{}", name2);
// --- Fn:只讀取,呼叫幾次都行 ---
let name3 = String::from("Charlie");
let greet_ref = GreetRef { name: &name3 };
greet_ref.call_ref();
greet_ref.call_ref();
greet_ref.call_ref();
}
執行結果:
[FnOnce] Hello, Alice!
[FnMut] Hello, Bob!
[FnMut] Hello, Bob!!
[FnMut] Hello, Bob!!!
name2 現在是:Bob!!!
[Fn] Hello, Charlie!
[Fn] Hello, Charlie!
[Fn] Hello, Charlie!
重點整理
- 閉包背後就是一個匿名 struct,捕捉的變數變成 struct 的欄位
- 三種閉包的差別在方法怎麼接收 self:
self(FnOnce)、&mut self(FnMut)、&self(Fn) - 閉包體就是 struct 上方法的實作內容
f()會被編譯器轉換成方法呼叫:self.call_once()/self.call_mut()/self.call()- Fn:只讀取,不修改不消耗,可以無限次呼叫
- 下一集會講編譯器是怎麼自動判斷一個閉包該歸類為 FnOnce、FnMut 還是 Fn
第六章第 4 集:閉包種類的推斷
本集目標
理解 Rust 如何根據閉包體的內容,自動推斷一個閉包是 FnOnce、FnMut 還是 Fn。
概念說明
上一集我們手動用 struct 模擬了三種閉包,對應 self、&mut self、&self。但你寫閉包的時候從來不需要告訴 Rust「這是 FnOnce」或「這是 FnMut」——Rust 會自動判斷。
推斷規則
Rust 看的是閉包體裡面對捕捉變數做了什麼:
- 如果閉包體裡 move 了捕捉的變數(例如
let s = captured_string;)→ 這個閉包是 FnOnce,因為 move 走了就沒了,只能呼叫一次 - 如果閉包體裡修改了捕捉的變數(例如
count += 1;)→ 這個閉包是 FnMut,可以多次呼叫但需要&mut - 如果閉包體只讀取捕捉的變數(例如
println!("{}", name);)→ 這個閉包是 Fn,只需要&self
Rust 會選能接受最多種使用方式的那個——如果只讀取,就給 Fn(因為 Fn 的閉包也能當 FnMut 和 FnOnce 用)。如果有修改,就變成 FnMut。如果有 move,就變成 FnOnce。
範例對照
let name = String::from("Alice");
// 只讀取 name → Fn
let greet = || println!("Hi, {}!", name);
// 修改 count → FnMut
let mut count = 0;
let mut increment = || { count += 1; };
// move name → FnOnce
let consume = || { let s = name; };
你不需要寫任何標記——Rust 看閉包體就知道了。
捕捉多個變數時怎麼辦?
一個閉包可能同時捕捉多個變數,而且對每個變數的用法不同:
let name = String::from("Alice");
let mut count = 0;
let closure = || {
count += 1; // 修改 count → 需要 &mut
println!("{}", name); // 只讀取 name → 只需要 &
};
想像成 struct 的話,這個閉包的匿名 struct 會有兩個欄位:count(需要 &mut)和 name(只需要 &)。但呼叫閉包時只有一個 self——而 &mut self 裡面可以做 & 的操作,反過來不行——所以整個閉包是 FnMut(&mut self)。在 &mut self 裡面,你仍然可以對某些欄位只做 & 的操作——就像一個 method 接收 &mut self,但裡面不一定每個欄位都要改:
struct Data<'a> {
count: &'a mut i32,
name: &'a String,
}
impl<'a> Data<'a> {
fn increment_and_greet(&mut self) {
*self.count += 1; // 修改 count
println!("Hello, {}!", self.name); // 只讀取 name
}
}
閉包也是同樣的道理。
同理,FnOnce 的 self 裡面的值當然也能取 & 或 &mut——擁有一個值就包含了可以借用它。
如果沒有捕捉任何變數呢?
沒有捕捉變數的閉包自動是 Fn,因為它不需要存取任何外部狀態:
let add_one = |x: i32| x + 1; // Fn
第 2 集提到的「不捕捉變數的閉包可以轉成函數指標」也是因為這個原因——它連匿名 struct 都不需要。
重點整理
- Rust 根據閉包體的內容自動推斷閉包的種類:move → FnOnce、修改 → FnMut、只讀 → Fn
- 不需要手動標記,編譯器會自動選擇能接受最多種使用方式的閉包
- 沒有捕捉變數的閉包是 Fn,也可以轉成函數指標
- Fn 的閉包可以傳給 FnMut 和 FnOnce;FnMut 可以傳給 FnOnce;反過來不行
第六章第 5 集:Fn / FnMut / FnOnce
本集目標
理解 Fn、FnMut、FnOnce 是 trait 而非型別,掌握它們的繼承關係,並學會在 API 設計中選擇正確的 bound。
概念說明
它們是 trait,不是型別
前幾集我們一直說 FnOnce、FnMut、Fn,但還沒正式說明——它們其實是 trait。就像第五章學的 Clone、Display 一樣,Fn / FnMut / FnOnce 是定義在標準庫裡的 trait。每個閉包的匿名 struct 會自動 impl 對應的 trait(上一集講的推斷規則決定 impl 哪些)。
那這些 trait 到底長什麼樣?
FnOnce(Args) -> Ret:可以被呼叫至少一次(可能會消耗自己)FnMut(Args) -> Ret:可以被多次呼叫(可能會修改內部狀態)Fn(Args) -> Ret:可以被多次呼叫(不會修改任何東西)
注意!fn(i32) -> i32(小寫)是函數指標型別,而 Fn(i32) -> i32(大寫)是 trait。兩個完全不同的東西。
繼承關係
這三個 trait 有繼承(supertrait)關係:
Fn : FnMut : FnOnce
意思是:
- 所有實作
Fn的東西,自動也實作FnMut和FnOnce - 所有實作
FnMut的東西,自動也實作FnOnce - 但
FnOnce不一定有FnMut,FnMut不一定有Fn
為什麼是這個方向?
- Fn → FnMut:如果一個閉包只需要
&self就能執行,那給它&mut self當然也行(只是多給了它不需要的修改權限)。 - FnMut → FnOnce:如果一個閉包用
&mut self就能執行,那給它self(整個擁有權)當然也行——擁有一個東西就包含了可以修改它。只是呼叫完之後 struct 被消耗了,不能再呼叫第二次。
反過來就不行——一個需要消耗自己(FnOnce)的閉包,不能保證多次呼叫(FnMut)。
用 impl Trait 接受閉包
還記得第五章的 impl Trait 嗎?用它來接受閉包參數:
fn call_once(f: impl FnOnce() -> String) -> String {
f()
}
fn call_many_times(mut f: impl FnMut()) {
f();
f();
f();
}
fn call_readonly(f: impl Fn() -> i32) -> i32 {
f() + f()
}
注意 FnMut 的參數要加 mut——因為呼叫 FnMut 閉包需要 &mut self,而 f 擁有這個閉包,所以 f 本身要是 mut 的。
API 設計原則:選能接受最多種閉包的 bound
當你設計一個接受閉包的函數時,應該選能接受最多種閉包的 trait bound:
- 先試
FnOnce—— 如果你只需要呼叫一次 - 不夠再用
FnMut—— 如果你需要多次呼叫 - 最後才用
Fn—— 如果你需要多次呼叫且不允許修改
為什麼?因為 FnOnce 能接受所有閉包(所有閉包都至少是 FnOnce),而 Fn 只能接受不修改狀態的閉包。選能接受最多種的 bound,使用者傳入的自由度最高。
實務上 Fn 很少用到——大部分需要多次呼叫閉包的 API 用 FnMut 就夠了(FnMut 也能接受 Fn 的閉包)。只有少數場景需要保證閉包不修改狀態時才會用 Fn。
函數指標也實作了這三個 trait
普通的函數(和函數指標 fn)自動實作了 Fn、FnMut、FnOnce。所以你可以把函數名稱傳給任何接受這三個 trait 的地方。
範例程式碼
// 只需要呼叫一次 → 用 FnOnce(能接受最多種閉包)
fn consume_and_print(f: impl FnOnce() -> String) {
let result = f();
println!("結果:{}", result);
}
// 需要多次呼叫 → 用 FnMut
fn repeat_three_times(mut f: impl FnMut()) {
f();
f();
f();
}
// 需要多次呼叫且不修改 → 用 Fn
fn sum_two_calls(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x) + f(x)
}
fn main() {
// FnOnce:閉包消耗了捕捉的值
let name = String::from("Rust");
consume_and_print(|| {
let s = name; // move name
format!("Hello, {}!", s)
});
// FnMut:閉包修改了捕捉的變數
let mut count = 0;
repeat_three_times(|| {
count += 1;
println!("第 {} 次呼叫", count);
});
println!("總共呼叫了 {} 次", count);
// Fn:閉包只讀取
let multiplier = 3;
let result = sum_two_calls(|x| x * multiplier, 5);
println!("sum_two_calls 結果:{}", result);
// 普通函數也能傳進去
fn double(x: i32) -> i32 {
x * 2
}
let result2 = sum_two_calls(double, 10);
println!("用普通函數:{}", result2);
// Fn 的閉包也可以傳給 FnOnce 的參數(因為 Fn: FnMut: FnOnce)
let greeting = String::from("哈囉");
consume_and_print(|| {
format!("{}, 世界!", greeting) // 只是讀取 greeting,是 Fn
});
// greeting 還活著,因為閉包只是借用了它
println!("greeting 還在:{}", greeting);
}
重點整理
Fn、FnMut、FnOnce是 trait,不是型別;fn才是函數指標型別- 繼承關係:
Fn⊂FnMut⊂FnOnce(FnOnce 能接受所有閉包,Fn 只接受不修改的) - 用
impl FnOnce()/impl FnMut()/impl Fn()來接受閉包參數 FnMut的參數要加mut- API 設計原則:先選 FnOnce,需要多次呼叫再改 FnMut,需要保證不修改才用 Fn
- 普通函數和函數指標自動實作了 Fn + FnMut + FnOnce
第六章第 6 集:move 閉包
本集目標
學會用 move 關鍵字強制閉包以 by-value 方式捕捉外部變數,理解它為什麼能解決生命週期問題。
概念說明
預設的捕捉行為
Rust 的閉包很聰明,會自動選擇「最輕量」的捕捉方式:
- 如果只讀取變數 → 用
&T(借用) - 如果需要修改 → 用
&mut T(可變借用) - 如果需要消耗 → 用
T(移動)
大部分時候這很好用。但有些情況下,借用會造成生命週期的問題。
問題場景:回傳閉包
假設你想寫一個函數,回傳一個閉包:
fn make_greeter(name: String) -> impl Fn() {
|| println!("Hello, {}!", name) // 編譯錯誤!
}
為什麼錯?因為閉包預設用借用的方式捕捉 name(&name),但 name 是函數的局部變數,函數結束後就被丟掉了。閉包裡的借用就變成了懸垂參考——第四章的老朋友。
move 關鍵字
加上 move 就解決了:
fn make_greeter(name: String) -> impl Fn() {
move || println!("Hello, {}!", name)
}
move 告訴 Rust:「不要用借用,把所有捕捉的變數都搬進閉包裡。」這樣 name 就歸閉包所有了,不管原本的作用域怎麼結束,閉包都能繼續用 name。
move 閉包的匿名 struct
回想前幾集——閉包是匿名 struct。沒有 move 的時候,struct 的欄位可能是參考(&T 或 &mut T);加了 move 之後,所有欄位都變成擁有的值(T):
// 沒有 move:閉包借用 name,struct 裡存的是參考
let name = String::from("Alice");
let greet = || println!("{}", name);
// name 還能用,因為閉包只是借用
// 有 move:name 被搬進 struct,閉包擁有它
let name = String::from("Alice");
let greet = move || println!("{}", name);
// name 不能再用了,已經被搬進閉包裡
因為所有欄位都是 owned 的,這個 struct 不借用任何東西,所以沒有 lifetime 的問題——可以安全地從函數回傳、存進 struct。
move 不影響閉包是哪種 Fn trait
很多人會搞混:move 閉包不代表它是 FnOnce!
move 只影響怎麼捕捉(by value),不影響怎麼使用:
let name = String::from("Alice");
let greet = move || println!("Hello, {}!", name);
// name 被 move 進閉包了,但閉包只是「讀取」name
// 所以這個閉包是 Fn,可以多次呼叫
greet();
greet();
閉包自動實作的 trait
閉包能不能 Clone 或 Copy,取決於它捕捉的變數——跟 tuple 類似,如果裡面的東西都能 Copy,整體就能 Copy:
- 所有捕捉的變數都是 Copy → 閉包也是 Copy
- 所有捕捉的變數都是 Clone → 閉包也是 Clone
- 其他某些 trait 也是同理
let x = 42;
let f = move || x + 1; // x 是 i32(Copy),所以 f 也是 Copy
let g = f; // Copy 了 f
println!("{}", f()); // f 還能用
println!("{}", g());
範例程式碼
// 回傳閉包時,通常需要 move
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn make_counter(start: i32) -> impl FnMut() -> i32 {
let mut count = start;
move || {
count += 1;
count
}
}
fn main() {
// move 讓閉包擁有捕捉的值,可以安全回傳
let add_five = make_adder(5);
println!("10 + 5 = {}", add_five(10));
println!("20 + 5 = {}", add_five(20));
// move + FnMut:閉包擁有 count,並且每次修改它
let mut counter = make_counter(0);
println!("計數:{}", counter());
println!("計數:{}", counter());
println!("計數:{}", counter());
// move 不代表 FnOnce
let name = String::from("Bob");
let greet = move || {
println!("Hi, {}!", name); // 只是讀取 name,所以是 Fn
};
greet();
greet(); // 可以多次呼叫,不是 FnOnce
// 捕捉 Copy 型別的閉包可以 Copy
let factor = 3;
let multiply = move |x: i32| x * factor;
let multiply_copy = multiply; // Copy 了
println!("multiply(4) = {}", multiply(4)); // 原本的還能用
println!("multiply_copy(4) = {}", multiply_copy(4));
// 捕捉 String(非 Copy)的 move 閉包不能 Copy
let label = String::from("result");
let show = move |x: i32| {
println!("{}: {}", label, x);
};
// let show2 = show; // 這會 move show,不是 Copy
show(42);
}
重點整理
move強制閉包以 by-value 方式捕捉所有外部變數- 回傳閉包時通常需要
move,避免懸垂參考 move不影響閉包是 Fn / FnMut / FnOnce——那取決於閉包怎麼使用捕捉的值- 閉包能否 Clone / Copy 取決於捕捉的變數是否 Clone / Copy
move閉包擁有所有捕捉的值,不依賴外部借用,適合需要長壽命的場景
第六章第 7 集:Option / Result 的閉包方法
本集目標
用閉包方法重寫第五章的 Option 和 Result 操作,體會閉包如何讓程式碼更簡潔流暢。
概念說明
第五章我們用 match 處理 Option 和 Result,每次都要展開兩個分支。現在學了閉包,很多操作可以一行搞定。
Option 的閉包方法
map —— 轉換 Some 裡的值
// fn map<U>(self, f: impl FnOnce(T) -> U) -> Option<U>
let x: Option<i32> = Some(5);
let y = x.map(|v| v * 2); // Some(10)
如果是 None,map 什麼都不做,直接回傳 None。不用寫 match。
and_then —— 鏈式操作(可能失敗)
map 的閉包回傳普通值,但如果你的轉換本身也可能回傳 None 呢?用 and_then:
// fn and_then<U>(self, f: impl FnOnce(T) -> Option<U>) -> Option<U>
let x: Option<i32> = Some(5);
let y = x.and_then(|v| if v > 3 { Some(v * 2) } else { None });
and_then 的閉包回傳 Option,避免了 Option<Option<T>> 的巢狀問題。其實 and_then 就等於先 map 再 flatten——map 會產生 Option<Option<U>>,flatten 再把它攤平成 Option<U>。and_then 一步到位。
unwrap_or_else —— 給一個計算 default 的閉包
// fn unwrap_or_else(self, f: impl FnOnce() -> T) -> T
let x: Option<i32> = None;
let y = x.unwrap_or_else(|| {
println!("沒有值,計算預設值...");
42
});
跟 unwrap_or 不同,unwrap_or_else 的預設值是懶惰計算的——只有在真的是 None 的時候才會執行閉包。
filter —— 條件過濾
// fn filter(self, predicate: impl FnOnce(&T) -> bool) -> Option<T>
let x: Option<i32> = Some(4);
let y = x.filter(|v| v % 2 == 0); // Some(4),因為 4 是偶數
let z = x.filter(|v| v % 2 != 0); // None,因為 4 不是奇數
Result 的閉包方法
Result 也有類似的一套方法。
map —— 轉換 Ok 的值
// fn map<U>(self, f: impl FnOnce(T) -> U) -> Result<U, E>
let r: Result<i32, String> = Ok(10);
let doubled = r.map(|v| v * 2); // Ok(20)
map_err —— 轉換 Err 的值
跟 map 相反——map 對 Ok 做事、Err 不動;map_err 對 Err 做事、Ok 不動。
// fn map_err<F>(self, f: impl FnOnce(E) -> F) -> Result<T, F>
let r: Result<i32, String> = Err(String::from("not found"));
let r2 = r.map_err(|e| format!("錯誤:{}", e));
and_then —— 鏈式操作
// fn and_then<U>(self, f: impl FnOnce(T) -> Result<U, E>) -> Result<U, E>
let r: Result<i32, String> = Ok(5);
let r2 = r.and_then(|v| {
if v > 0 {
Ok(v * 10)
} else {
Err(String::from("必須是正數"))
}
});
跟 Option 一樣,and_then 就等於 map 再 flatten。
unwrap_or_else —— 從 Err 計算 default
// fn unwrap_or_else(self, f: impl FnOnce(E) -> T) -> T
let r: Result<i32, String> = Err(String::from("oops"));
let value = r.unwrap_or_else(|e| {
println!("發生錯誤:{},使用預設值", e);
0
});
跟 match 的比較
用 match:
let result = match opt {
Some(v) => Some(v * 2),
None => None,
};
用閉包方法:
let result = opt.map(|v| v * 2);
一行搞定,而且意圖更清晰——「對 Some 裡的值做轉換」。
範例程式碼
fn parse_and_double(input: &str) -> Result<i32, String> {
input
.parse::<i32>()
.map_err(|e| format!("解析失敗:{}", e))
.and_then(|n| {
if n >= 0 {
Ok(n * 2)
} else {
Err(String::from("不接受負數"))
}
})
}
fn find_even(numbers: &[i32]) -> Option<i32> {
for n in numbers {
if n % 2 == 0 {
return Some(*n);
}
}
None
}
fn main() {
// Option::map
let maybe_num: Option<i32> = Some(21);
let doubled = maybe_num.map(|n| n * 2);
println!("map: {:?}", doubled);
// Option::and_then
let result = maybe_num.and_then(|n| {
if n > 10 { Some(n - 10) } else { None }
});
println!("and_then: {:?}", result);
// Option::filter
let even = maybe_num.filter(|n| n % 2 == 0);
println!("filter(偶數): {:?}", even);
// Option::unwrap_or_else
let none_value: Option<i32> = None;
let default = none_value.unwrap_or_else(|| {
println!("計算預設值中...");
99
});
println!("unwrap_or_else: {}", default);
// Result 鏈式操作
println!("\n--- Result 鏈式操作 ---");
let good = parse_and_double("21");
println!("parse_and_double(\"21\") = {:?}", good);
let bad_parse = parse_and_double("abc");
println!("parse_and_double(\"abc\") = {:?}", bad_parse);
let negative = parse_and_double("-5");
println!("parse_and_double(\"-5\") = {:?}", negative);
// Result::unwrap_or_else
let safe_value = parse_and_double("oops").unwrap_or_else(|e| {
println!("錯誤處理:{}", e);
0
});
println!("安全取值:{}", safe_value);
// 組合 Option 方法
println!("\n--- Option 鏈式操作 ---");
let numbers = vec![1, 3, 5, 8, 11];
let result = find_even(&numbers)
.filter(|n| *n > 5)
.map(|n| n * 10);
println!("找第一個偶數,> 5 才乘 10:{:?}", result);
}
重點整理
Option::map/Result::map對內部值做轉換,None / Err 時不執行and_then用於閉包本身也回傳 Option / Result 的情況,避免巢狀unwrap_or_else懶惰計算預設值,只在 None / Err 時才執行閉包Option::filter根據條件決定保留 Some 或轉成 NoneResult::map_err可以轉換錯誤型別,方便錯誤處理鏈- 這些方法可以鏈式呼叫,比層層 match 更簡潔易讀
- 你可能已經注意到:光看型別簽名就能猜出方法在做什麼(Option 的
map接受FnOnce(T) -> U,回傳Option<U>)。這是函數式程式設計的一大特色——型別本身就是文件
第六章第 8 集:Iterator trait
本集目標
認識 Iterator trait 的核心——只要實作 next() 方法,就能免費獲得數十個好用的方法。
概念說明
Iterator 的定義
Iterator trait 的核心簡單到不行:
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
就這樣。只有一個必須實作的方法 next(),它每次被呼叫就回傳:
Some(值)—— 還有下一個元素None—— 迭代結束了
還記得第五章學的 associated type 嗎?type Item 就是一個 associated type,代表「這個迭代器產出的元素型別」。
手動呼叫 next
你可以直接手動呼叫 next() 來逐一取得元素:
let v = vec![10, 20, 30];
let mut iter = v.iter();
println!("{:?}", iter.next()); // Some(&10)
println!("{:?}", iter.next()); // Some(&20)
println!("{:?}", iter.next()); // Some(&30)
println!("{:?}", iter.next()); // None
注意 iter 必須是 mut 的,因為每次呼叫 next() 都會推進內部狀態。
只需實作 next,其他方法免費送
Iterator trait 提供了大量的預設實作(還記得第五章嗎?)。因為所有的迭代操作本質上都是「不斷呼叫 next 直到 None」,所以只要你實作了 next(),像 map、filter、count、sum 等幾十個方法全部自動可用。
這就是 trait default method 的威力!
自訂 Iterator
讓我們自己做一個迭代器。假設我們想要一個「倒數計時器」:
struct Countdown {
value: i32,
}
impl Iterator for Countdown {
type Item = i32;
fn next(&mut self) -> Option<i32> {
if self.value > 0 {
let current = self.value;
self.value -= 1;
Some(current)
} else {
None
}
}
}
只要實作了 next,map、filter、sum、collect 等幾十個方法全部自動可用。這些方法接下來幾集會陸續學到。
標準庫的迭代器工廠
標準庫提供了一些方便的函數來建立迭代器:
std::iter::repeat(value)—— 無限重複同一個值std::iter::from_fn(closure)—— 用閉包來決定每次next()回傳什麼
use std::iter;
// 無限產生 42
let mut repeater = iter::repeat(42);
println!("{:?}", repeater.next()); // Some(42)
println!("{:?}", repeater.next()); // Some(42)(永遠不會 None)
// 用閉包產生遞增數字
let mut n = 0;
let mut counter = iter::from_fn(move || {
n += 1;
Some(n)
});
println!("{:?}", counter.next()); // Some(1)
println!("{:?}", counter.next()); // Some(2)
注意 repeat 和 from_fn 產生的迭代器可能是無限的——永遠不會回傳 None。第 14 集會深入討論這個特性。
範例程式碼
use std::iter;
// 自訂迭代器:費氏數列(無限!)
struct Fibonacci {
a: u64,
b: u64,
}
impl Fibonacci {
fn new() -> Fibonacci {
Fibonacci { a: 0, b: 1 }
}
}
impl Iterator for Fibonacci {
type Item = u64;
fn next(&mut self) -> Option<u64> {
let current = self.a;
let new_b = self.a + self.b;
self.a = self.b;
self.b = new_b;
Some(current) // 永遠不回傳 None
}
}
fn main() {
// 手動呼叫 Vec 的 iter().next()
let names = vec!["Alice", "Bob", "Charlie"];
let mut name_iter = names.iter();
println!("第一個:{:?}", name_iter.next());
println!("第二個:{:?}", name_iter.next());
println!("第三個:{:?}", name_iter.next());
println!("結束了:{:?}", name_iter.next());
// 自訂 Iterator:費氏數列(手動呼叫 next)
println!("\n費氏數列:");
let mut fib = Fibonacci::new();
println!("{:?}", fib.next()); // Some(0)
println!("{:?}", fib.next()); // Some(1)
println!("{:?}", fib.next()); // Some(1)
println!("{:?}", fib.next()); // Some(2)
println!("{:?}", fib.next()); // Some(3)
println!("{:?}", fib.next()); // Some(5)
// 永遠不會 None——這是一個無限迭代器
// std::iter::repeat:無限重複
let mut threes = iter::repeat(3);
println!("\nrepeat(3):");
println!("{:?}", threes.next()); // Some(3)
println!("{:?}", threes.next()); // Some(3)
println!("{:?}", threes.next()); // Some(3)(永遠不會 None)
// std::iter::from_fn:用閉包控制產出
let mut n = 0;
let mut squares = iter::from_fn(|| {
n += 1;
if n <= 3 {
Some(n * n)
} else {
None
}
});
println!("\nfrom_fn(前 3 個平方數):");
println!("{:?}", squares.next()); // Some(1)
println!("{:?}", squares.next()); // Some(4)
println!("{:?}", squares.next()); // Some(9)
println!("{:?}", squares.next()); // None
}
重點整理
Iteratortrait 的核心是next(&mut self) -> Option<Self::Item>- 只需實作
next(),就能免費獲得數十個 default method(接下來幾集會陸續學到) - 自訂 struct 實作
Iterator很簡單——定義type Item和next()就好 std::iter::repeat(value)建立無限重複的迭代器std::iter::from_fn(closure)用閉包來控制每次產出的值- 迭代器可以是無限的(永不回傳 None)
第六章第 9 集:for 迴圈的真面目
本集目標
揭開 for 迴圈的語法糖,理解它背後其實是 IntoIterator + while let 的組合。
概念說明
for 迴圈不是魔法
從第一章開始我們就在用 for 迴圈:
let v = vec![1, 2, 3];
for x in v {
println!("{}", x);
}
看起來很簡單對吧?但這背後到底發生了什麼事?
語法糖展開
上面的 for 迴圈,編譯器其實會轉換成這樣:
let v = vec![1, 2, 3];
let mut iter = v.into_iter();
while let Some(x) = iter.next() {
println!("{}", x);
}
三個步驟:
- 呼叫
v.into_iter()把v轉成迭代器 - 反覆呼叫
iter.next() - 用
while let Some(x)解構(還記得第三章的 while let 嗎?),直到拿到None就結束
IntoIterator trait
IntoIterator 是一個 trait,定義了「如何把自己轉成迭代器」:
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>;
fn into_iter(self) -> Self::IntoIter;
}
任何實作了 IntoIterator 的型別都可以用 for 迴圈。Vec、陣列、字串切片的 .chars()⋯⋯背後都是因為實作了這個 trait。
Iterator 也實作了 IntoIterator
有個很方便的設計:每個 Iterator 都自動實作了 IntoIterator(into_iter() 直接回傳自己)。所以你可以把迭代器直接丟進 for:
let v = vec![1, 2, 3];
let iter = v.iter(); // 這是一個 Iterator
for x in iter { // Iterator 也實作了 IntoIterator
println!("{}", x);
}
範例程式碼
fn main() {
// 正常的 for 迴圈
let fruits = vec!["蘋果", "香蕉", "橘子"];
println!("--- for 迴圈 ---");
for fruit in &fruits {
println!("水果:{}", fruit);
}
// 手動展開成 while let(完全等價)
println!("\n--- 手動展開 ---");
let mut iter = fruits.into_iter();
while let Some(fruit) = iter.next() {
println!("水果:{}", fruit);
}
// 自訂型別實作 IntoIterator
println!("\n--- 自訂 IntoIterator ---");
let countdown = Countdown { value: 5 };
for n in countdown {
print!("{} ", n);
}
println!("發射!");
// 迭代器本身也可以放進 for
println!("\n--- Iterator 直接用 for ---");
let numbers = vec![10, 20, 30, 40, 50];
for n in numbers.iter() {
if *n > 20 {
println!("大於 20 的:{}", n);
}
}
// Range 也實作了 IntoIterator
println!("\n--- Range ---");
for i in 1..=5 {
print!("{} ", i);
}
println!();
}
// 自訂迭代器
struct Countdown {
value: i32,
}
impl Iterator for Countdown {
type Item = i32;
fn next(&mut self) -> Option<i32> {
if self.value > 0 {
let current = self.value;
self.value -= 1;
Some(current)
} else {
None
}
}
}
重點整理
for x in v是語法糖,展開後是v.into_iter()+while let Some(x) = iter.next()IntoIteratortrait 定義了「如何把自己轉成迭代器」- 任何實作了
IntoIterator的型別都能用for迴圈 - 每個
Iterator自動實作了IntoIterator - 之所以能寫
for i in 1..5或for i in 1..=5,就是因為 range 實作了IntoIterator
第六章第 10 集:iter / into_iter / iter_mut
本集目標
搞懂三種迭代模式的差別——借用、消耗、可變借用——以及它們和所有權系統的關係。
概念說明
三種迭代方式
前面提到過 for x in &v 和 for x in v 的差別。今天來正式介紹 Vec 提供的三個方法:
| 方法 | 產出型別 | 語意 | Vec 之後還能用嗎? |
|---|---|---|---|
.iter() |
&T |
借用每個元素 | ✓ 可以 |
.into_iter() |
T |
消耗整個集合 | ✗ 不行 |
.iter_mut() |
&mut T |
可變借用每個元素 | ✓ 可以(已修改) |
.iter() —— 只是看看
let names = vec![String::from("Alice"), String::from("Bob")];
for name in names.iter() {
println!("{}", name); // name 是 &String
}
println!("names 還在:{:?}", names); // 沒問題,只是借用
.iter() 回傳 &T 的迭代器。集合本身不受影響,用完還在。
.into_iter() —— 拿走一切
let names = vec![String::from("Alice"), String::from("Bob")];
for name in names.into_iter() {
println!("{}", name); // name 是 String(擁有所有權)
}
// println!("{:?}", names); // 編譯錯誤!names 被消耗了
.into_iter() 把每個元素的所有權交出來。集合本身被消耗,之後不能再用。
其實 for name in names 就等於 for name in names.into_iter()。
.iter_mut() —— 借來改改
let mut scores = vec![60, 70, 80];
for score in scores.iter_mut() {
*score += 10; // score 是 &mut i32
}
println!("{:?}", scores); // [70, 80, 90]
.iter_mut() 回傳 &mut T,讓你可以原地修改每個元素。
對應關係
這三種方法其實對應第四章學的三種所有權操作:
| 所有權概念 | 迭代方法 | for 語法糖 |
|---|---|---|
&T(共享借用) |
.iter() |
for x in &v |
T(移動所有權) |
.into_iter() |
for x in v |
&mut T(可變借用) |
.iter_mut() |
for x in &mut v |
背後的 IntoIterator
上一集學到 for x in something 會呼叫 something.into_iter()。那三種 for 語法糖是怎麼運作的?
其實是因為 Vec<T>、&Vec<T>、&mut Vec<T> 分別實作了 IntoIterator:
impl<T> IntoIterator for Vec<T> {
type Item = T;
fn into_iter(self) -> ... { /* 消耗 Vec,產出 T */ }
}
impl<'a, T> IntoIterator for &'a Vec<T> {
type Item = &'a T;
fn into_iter(self) -> ... { /* 等同於 .iter(),產出 &T */ }
}
impl<'a, T> IntoIterator for &'a mut Vec<T> {
type Item = &'a mut T;
fn into_iter(self) -> ... { /* 等同於 .iter_mut(),產出 &mut T */ }
}
所以 for x in &v 其實是對 &v(型別是 &Vec<T>)呼叫 into_iter(),走到 &Vec<T> 的那個 impl,最終拿到 &T。
大部分集合型別(Vec、String、陣列等)都遵循這個模式——為自己、&self、&mut self 三種各實作一次 IntoIterator。
選哪一個?
- 只需要讀取 →
.iter()(最常用) - 需要拿走元素的所有權 →
.into_iter() - 需要原地修改 →
.iter_mut()
原則跟所有權一樣:不要拿你不需要的權限。
範例程式碼
fn main() {
// .iter() —— 只讀借用
let animals = vec![
String::from("貓"),
String::from("狗"),
String::from("兔子"),
];
println!("--- .iter()(借用) ---");
for animal in animals.iter() {
println!("動物:{}", animal);
}
println!("animals 還在:{:?}", animals);
// .iter_mut() —— 可變借用,原地修改
let mut prices = vec![100, 200, 300];
println!("\n--- .iter_mut()(修改) ---");
println!("打折前:{:?}", prices);
for price in prices.iter_mut() {
*price = *price * 8 / 10; // 打八折
}
println!("打折後:{:?}", prices);
// .into_iter() —— 消耗所有權
let words = vec![
String::from("hello"),
String::from("world"),
];
println!("\n--- .into_iter()(消耗) ---");
for word in words.into_iter() {
println!("拿到了:{}", word); // word 是 String(擁有所有權)
}
// println!("{:?}", words); // 編譯錯誤!words 被消耗了
// for 語法糖的對應
println!("\n--- for 語法糖 ---");
let nums = vec![1, 2, 3];
// for x in &nums 等於 for x in nums.iter()
for x in &nums {
print!("{} ", x);
}
println!("← &nums(借用)");
// for x in nums 等於 for x in nums.into_iter()
for x in nums {
print!("{} ", x);
}
println!("← nums(消耗)");
// nums 已經不能用了
}
重點整理
.iter()產出&T,借用元素,集合不受影響.into_iter()產出T,消耗整個集合,拿走所有權.iter_mut()產出&mut T,可以原地修改元素for x in &v=.iter(),for x in v=.into_iter(),for x in &mut v=.iter_mut()- 選擇原則:不要拿超過需要的權限——只讀就
.iter(),要改就.iter_mut(),要消耗就.into_iter()
第六章第 11 集:收集
本集目標
學會用 .collect() 把迭代器收集成各種集合型別,以及用 turbofish 語法指定目標型別。
概念說明
平常我們不會花這麼多集數在介紹方法,但迭代器實在太重要了——它是 Rust 日常寫程式碼最常用的工具之一,所以接下來幾集會多花點時間。不過就算介紹了很多方法,一定還是會漏掉不少。有需要的話,請參考官方文件的 Iterator trait 頁面。
collect() —— 迭代器的終點站
前幾集我們建立了迭代器、做了轉換和過濾,但迭代器本身是惰性的(第 15 集會詳細講)——它不會真的執行,直到有人「拉動」它。.collect() 就是最常用的拉動方式:把迭代器的所有元素收集成一個集合。
let v: Vec<i32> = (1..=5).collect();
你可能注意到了——1..=5 是第一章學的 range 語法,它也實作了 Iterator!所以可以直接對它呼叫 .collect() 和其他迭代器方法。
Turbofish 語法
.collect() 的回傳型別取決於你要收集成什麼。Rust 通常需要你告訴它目標型別。有兩種方式:
方式一:型別標註
let v: Vec<i32> = (1..=5).collect();
方式二:Turbofish ::<>
let v = (1..=5).collect::<Vec<i32>>();
兩種寫法效果一樣,看個人偏好。鏈式呼叫的時候 turbofish 比較方便,因為不用另外宣告變數。
收集成 String
collect() 不只能收集成 Vec。如果迭代器產出的是 char 或 &str,可以直接收集成 String:
let chars = vec!['R', 'u', 's', 't'];
let word: String = chars.into_iter().collect();
println!("{}", word); // "Rust"
.last() —— 取最後一個元素
.last() 會消耗整個迭代器,回傳最後一個元素(Option<T>):
let v = vec![10, 20, 30];
let last = v.iter().last();
println!("{:?}", last); // Some(&30)
注意它需要走完整個迭代器才能知道最後一個是什麼。
範例程式碼
fn main() {
// 基本 collect —— Range 轉 Vec
let numbers: Vec<i32> = (1..=10).collect();
println!("1 到 10:{:?}", numbers);
// turbofish 語法
let numbers2 = (1..=5).collect::<Vec<i32>>();
println!("turbofish:{:?}", numbers2);
// 收集成 String
let greeting: String = vec!['你', '好', '世', '界'].into_iter().collect();
println!("字串:{}", greeting);
// .last()
let last_num = (1..=100).last();
println!("\n1..=100 的最後一個:{:?}", last_num);
let empty: Vec<i32> = vec![];
let last_empty = empty.iter().last();
println!("空 Vec 的 last:{:?}", last_empty);
}
重點整理
.collect()把迭代器的元素收集成目標集合型別- 用型別標註
let v: Vec<i32>或 turbofish.collect::<Vec<i32>>()告訴 Rust 目標型別 - 可以收集成
Vec、String等多種型別 .last()消耗整個迭代器,回傳Option包裝的最後一個元素
第六章第 12 集:聚合
本集目標
學會用迭代器的聚合方法把一整個序列「摺疊」成一個值。
概念說明
什麼是聚合?
前幾集我們學了怎麼建立迭代器、怎麼 collect 成集合。但有時候你不需要一個集合,你要的是一個單一的值——總和、最大值、個數⋯⋯這就是聚合(aggregation)。
.count() —— 數有幾個
let names = vec!["Alice", "Bob", "Charlie"];
let count = names.iter().count(); // 3
.sum() 和 .product()
let total: i32 = (1..=10).sum(); // 55
let factorial: i64 = (1..=10).product(); // 3628800
注意 .sum() 和 .product() 需要知道回傳型別,因為不同數字型別的加法/乘法結果不同。通常用型別標註解決。
.min() 和 .max()
let v = vec![3, 1, 4, 1, 5, 9, 2, 6];
let smallest = v.iter().min(); // Some(&1)
let largest = v.iter().max(); // Some(&9)
回傳 Option,因為迭代器可能是空的(空的就回傳 None)。
.fold() —— 最通用的聚合
fold 是所有聚合方法的「老大」。它的型別:
fn fold<B>(self, init: B, f: impl FnMut(B, Self::Item) -> B) -> B
接受一個初始值 init(型別 B)和一個閉包,每一步把「累積值」和「當前元素」組合成新的累積值:
let sum = (1..=5).fold(0, |acc, x| acc + x);
// 步驟:0+1=1, 1+2=3, 3+3=6, 6+4=10, 10+5=15
其實本集介紹的其他方法都能用 fold 實作:
// count = fold 從 0 開始,每次 +1
let count = (1..=5).fold(0, |acc, _x| acc + 1);
// sum = fold 從 0 開始,每次加上元素
let sum = (1..=5).fold(0, |acc, x| acc + x);
// product = fold 從 1 開始,每次乘上元素
let product = (1..=5).fold(1, |acc, x| acc * x);
// min/max 的實作留給底下的 reduce 做——用 fold 的話不太自然
fold 還能做更靈活的事情。想把數字串成字串?想同時追蹤多個值?都可以:
let text = (1..=5).fold(String::new(), |mut acc, x| {
if !acc.is_empty() {
acc.push_str(", ");
}
acc.push_str(&x.to_string());
acc
});
// "1, 2, 3, 4, 5"
.reduce() —— 沒有初始值的 fold
reduce 跟 fold 很像,但它用第一個元素當初始值:
let product = vec![2, 3, 4].into_iter().reduce(|acc, x| acc * x);
// Some(24):2*3=6, 6*4=24
因為可能沒有第一個元素(迭代器是空的),所以 reduce 回傳 Option。
用 reduce 實作 min 和 max 就很自然:
let min = vec![3, 1, 4, 1, 5].into_iter()
.reduce(|a, b| if a < b { a } else { b });
// Some(1)
let max = vec![3, 1, 4, 1, 5].into_iter()
.reduce(|a, b| if a > b { a } else { b });
// Some(5)
因為 reduce 本身就回傳 Option,空迭代器自動得到 None—— fold 需要特別處理空迭代器的情形。
範例程式碼
fn main() {
let scores = vec![85, 92, 78, 95, 88, 76, 91];
// .count()
let total = scores.iter().count();
println!("總共 {} 個分數", total);
// .sum()
let sum: i32 = scores.iter().sum();
println!("總分:{}", sum);
// .min() / .max()
let min = scores.iter().min();
let max = scores.iter().max();
println!("最低分:{:?},最高分:{:?}", min, max);
// .product()
let factorial: i64 = (1..=10).product();
println!("\n10! = {}", factorial);
// .fold() —— 計算平均分
let (count2, sum2) = scores.iter().fold((0, 0), |(c, s), &score| {
(c + 1, s + score)
});
println!("\n用 fold 算平均:{} / {} = {}", sum2, count2, sum2 / count2);
// .fold() —— 把數字串成字串
let nums = vec![1, 2, 3, 4, 5];
let formatted = nums.iter().fold(String::new(), |mut acc, &n| {
if !acc.is_empty() {
acc.push_str(" → ");
}
acc.push_str(&n.to_string());
acc
});
println!("連接:{}", formatted);
// .reduce() —— 找最長的字串
let words = vec!["cat", "elephant", "dog", "hippopotamus"];
let longest = words
.iter()
.reduce(|a, b| if a.len() >= b.len() { a } else { b });
println!("\n最長的字:{:?}", longest);
// .reduce() 回傳 Option(空迭代器的情況)
let empty: Vec<i32> = vec![];
let result = empty.into_iter().reduce(|a, b| a + b);
println!("空 Vec 的 reduce:{:?}", result);
}
重點整理
.count()計算元素個數.sum()和.product()計算總和與乘積,需要標註回傳型別.min()和.max()回傳Option,因為迭代器可能是空的.fold(init, |acc, x| ...)是最通用的聚合——用初始值和閉包逐步累積.reduce(|acc, x| ...)類似 fold 但用第一個元素當初始值,回傳Option- 聚合方法會消耗整個迭代器,產出一個單一的值
第六章第 13 集:組合與截取
本集目標
學會用 .zip()、.chain()、.take()、.skip()、.flatten()、.flat_map() 來組合和截取迭代器。
概念說明
這集會用到下一集才正式介紹的 .map(),但對讀到這裡的你來說肯定不是難事吧~它就是「對每個元素做一個轉換」,看程式碼就懂了。
.zip() —— 把兩個迭代器配對
zip 把兩個迭代器「拉鍊式」地配對起來,產出 tuple:
let names = vec!["Alice", "Bob", "Charlie"];
let scores = vec![90, 85, 92];
let paired: Vec<_> = names.iter().zip(scores.iter()).collect();
// [("Alice", 90), ("Bob", 85), ("Charlie", 92)]
如果兩個迭代器長度不同,zip 在較短的那個結束時就停止。
.chain() —— 串接兩個迭代器
chain 把兩個迭代器首尾相接:
let first = vec![1, 2, 3];
let second = vec![4, 5, 6];
let all: Vec<i32> = first.into_iter().chain(second.into_iter()).collect();
// [1, 2, 3, 4, 5, 6]
.take(n) —— 只取前 n 個
let first_three: Vec<i32> = (1..=100).take(3).collect();
// [1, 2, 3]
take 特別適合用在無限迭代器上——沒有 take,無限迭代器永遠不會結束。
.skip(n) —— 跳過前 n 個
let after_skip: Vec<i32> = (1..=10).skip(7).collect();
// [8, 9, 10]
.flatten() —— 把巢狀結構攤平
如果迭代器的元素本身也是迭代器(或 Option、Vec 等),flatten 可以把它攤平一層:
let nested = vec![vec![1, 2], vec![3, 4], vec![5]];
let flat: Vec<i32> = nested.into_iter().flatten().collect();
// [1, 2, 3, 4, 5]
Option 也可以 flatten——Some(value) 被取出,None 被忽略:
let options = vec![Some(1), None, Some(3), None, Some(5)];
let values: Vec<i32> = options.into_iter().flatten().collect();
// [1, 3, 5]
.flat_map() —— map + flatten
flat_map 等於先 map 再 flatten。每個元素經過閉包轉換成一個迭代器(或 Option/Result),然後全部攤平:
let words = vec!["hello world", "foo bar"];
let chars: Vec<char> = words.iter().flat_map(|s| s.chars()).collect();
// ['h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r']
還記得第 6 集 Option 和 Result 的 and_then 嗎?flat_map 在迭代器上做的事情本質上一樣——「轉換,但因為轉換結果本身也是容器那就攤平」。
範例程式碼
fn main() {
// .zip() —— 名字和分數配對
let students = vec!["小明", "小華", "小美"];
let grades = vec![88, 95, 72];
println!("--- zip ---");
for (name, grade) in students.iter().zip(grades.iter()) {
println!("{}:{} 分", name, grade);
}
// .chain() —— 串接兩個 Vec
let morning = vec!["開會", "寫報告"];
let afternoon = vec!["寫程式", "code review"];
let all_tasks: Vec<&&str> = morning.iter().chain(afternoon.iter()).collect();
println!("\n今日行程:{:?}", all_tasks);
// .take() 和 .skip()
let numbers: Vec<i32> = (1..=20).collect();
let first_five: Vec<&i32> = numbers.iter().take(5).collect();
let last_five: Vec<&i32> = numbers.iter().skip(15).collect();
println!("\n前 5 個:{:?}", first_five);
println!("跳過 15 個後:{:?}", last_five);
// .take() + .skip() 組合:取中間的
let middle: Vec<&i32> = numbers.iter().skip(5).take(5).collect();
println!("第 6~10 個:{:?}", middle);
// .flatten() —— 攤平巢狀 Vec
let matrix = vec![
vec![1, 2, 3],
vec![4, 5, 6],
vec![7, 8, 9],
];
let flat: Vec<i32> = matrix.into_iter().flatten().collect();
println!("\n攤平矩陣:{:?}", flat);
// .flatten() —— 過濾 Option
let maybe_values = vec![Some(10), None, Some(30), None, Some(50)];
let real_values: Vec<i32> = maybe_values.into_iter().flatten().collect();
println!("有值的:{:?}", real_values);
// .flat_map() —— 每個字拆成字元
let words = vec!["Rust", "好棒"];
let all_chars: Vec<char> = words.iter().flat_map(|w| w.chars()).collect();
println!("\n所有字元:{:?}", all_chars);
// .flat_map() 類似 and_then
let inputs = vec!["42", "not_a_number", "7"];
let parsed: Vec<i32> = inputs
.iter()
.flat_map(|s| s.parse::<i32>())
.collect();
println!("成功解析的:{:?}", parsed);
// .zip() + .map() 組合
println!("\n--- zip + map ---");
let prices = vec![100, 200, 300];
let quantities = vec![2, 1, 4];
let grand_total: i32 = prices.iter()
.zip(quantities.iter())
.map(|(p, q)| p * q)
.sum();
println!("總計:{}", grand_total);
}
重點整理
.zip()把兩個迭代器配對成 tuple,以較短的為準.chain()把兩個迭代器首尾串接.take(n)只取前 n 個元素,.skip(n)跳過前 n 個.flatten()把巢狀結構攤平一層(Vec<Vec<T>> → Vec<T>,也適用於 Option).flat_map()=.map()+.flatten(),概念上跟 Option/Result 的and_then類似- 這些方法可以自由組合,打造出強大的資料處理管道
第六章第 14 集:轉換與過濾
本集目標
學會迭代器最常用的轉換與過濾方法,以及如何用鏈式呼叫組合出強大的資料管道。
概念說明
.map() —— 轉換每個元素
map 對每個元素套用閉包,產出轉換後的新元素:
let doubled: Vec<i32> = vec![1, 2, 3].iter().map(|x| x * 2).collect();
// [2, 4, 6]
注意!.iter() 產出 &T,所以閉包的參數是 &i32。如果不想處理參考,可以搭配 .copied()(等等會講)。
.filter() —— 過濾元素
filter 只保留閉包回傳 true 的元素:
let evens: Vec<&i32> = vec![1, 2, 3, 4, 5].iter().filter(|&&x| x % 2 == 0).collect();
// [&2, &4]
filter 的閉包接收 &&T(因為 .iter() 已經是 &T,filter 再借用一次就是 &&T)。這是初學者常被搞混的地方,但寫多了就習慣了。
.enumerate() —— 帶上索引
let names = vec!["Alice", "Bob", "Charlie"];
for (i, name) in names.iter().enumerate() {
println!("第 {} 個:{}", i, name);
}
enumerate 把每個元素包成 (index, element) 的 tuple,索引從 0 開始。
.copied() 和 .cloned()
當迭代器產出參考(&T)但你想要值(T)時:
.copied()—— 要求 T: Copy,用 Copy 語意取值.cloned()—— 要求 T: Clone,用 Clone 語意取值
let numbers = vec![1, 2, 3];
let owned: Vec<i32> = numbers.iter().copied().collect();
// 從 &i32 變成 i32
.copied() 常搭配 .filter() 一起用,可以避免 &&T 的困擾:
let evens: Vec<i32> = vec![1, 2, 3, 4, 5]
.iter()
.copied()
.filter(|x| x % 2 == 0)
.collect();
// [2, 4],乾淨多了!
.rev() —— 反轉迭代順序
let reversed: Vec<i32> = (1..=5).rev().collect();
// [5, 4, 3, 2, 1]
.rev() 需要迭代器實作 DoubleEndedIterator trait——也就是說,它必須能從兩端取元素。Vec、陣列、Range 等都支援,但像 from_fn 產出的迭代器就不支援(因為沒有「尾端」的概念)。
鏈式呼叫的威力
迭代器的方法可以自由串接,形成資料處理管道:
let result: Vec<String> = names
.iter()
.enumerate()
.filter(|(_, name)| name.len() > 3)
.map(|(i, name)| format!("#{}: {}", i + 1, name))
.collect();
每一步都做一件小事,串在一起就能做很複雜的操作。而且因為迭代器是惰性的(下一集會講),中間不會產生額外的 Vec。
範例程式碼
fn main() {
let scores = vec![55, 82, 91, 47, 73, 88, 69, 95];
// .map() —— 每個分數加 5 分(加分調整)
let adjusted: Vec<i32> = scores.iter().map(|s| s + 5).collect();
println!("加分後:{:?}", adjusted);
// .filter() —— 篩出及格的
let passing: Vec<i32> = scores.iter().copied().filter(|&s| s >= 60).collect();
println!("及格的:{:?}", passing);
// .enumerate() —— 帶索引
println!("\n--- 成績單 ---");
for (i, &score) in scores.iter().enumerate() {
let status = if score >= 60 { "及格" } else { "不及格" };
println!("第 {} 號:{} 分({})", i + 1, score, status);
}
// .copied() —— 從 &i32 變成 i32
let max_score: Option<i32> = scores.iter().copied().max();
println!("\n最高分:{:?}", max_score);
// .cloned() —— 從 &String 變成 String
let names = vec![String::from("Alice"), String::from("Bob")];
let cloned_names: Vec<String> = names.iter().cloned().collect();
println!("cloned: {:?}", cloned_names);
println!("原本還在:{:?}", names);
// .rev() —— 反轉
let countdown: Vec<i32> = (1..=5).rev().collect();
println!("\n倒數:{:?}", countdown);
// 鏈式組合
println!("\n--- 鏈式組合 ---");
let long_words: Vec<&str> = vec!["hi", "hello", "hey", "howdy", "greetings"]
.into_iter()
.filter(|w| w.len() >= 4)
.collect();
println!("4 字以上的:{:?}", long_words);
// filter + map 組合
let words = vec!["hello", "hi", "hey", "howdy", "greetings"];
let long_upper: Vec<String> = words
.iter()
.filter(|w| w.len() >= 4)
.map(|w| w.to_uppercase())
.collect();
println!("\n4 字以上轉大寫:{:?}", long_upper);
}
重點整理
.map()轉換每個元素,.filter()過濾不符合條件的元素.enumerate()為每個元素加上從 0 開始的索引.copied()把&T轉成T(需要 T: Copy),.cloned()類似但用 Clone.rev()反轉迭代順序,需要DoubleEndedIterator- 這些方法可以自由鏈式呼叫,形成清晰的資料處理管道
- 配合
.copied()可以避免filter中惱人的&&T問題
第六章第 15 集:惰性求值
本集目標
理解迭代器的惰性(lazy)本質——.map() 和 .filter() 不會立刻執行,而是建立巢狀結構,等 .collect() 或 for 才逐一拉動。
概念說明
迭代器是惰性的
這可能是整個第六章最重要的概念:迭代器的轉換方法不會立刻執行。
let v = vec![1, 2, 3, 4, 5];
let iter = v.iter().map(|x| {
println!("處理 {}", x);
x * 2
});
// 到這裡為止,什麼都沒有印出來!
map 並沒有「跑過」每個元素。它只是建立了一個新的迭代器結構,記錄了「等下要做什麼」。直到有人呼叫 collect()、for、sum() 等「消費」方法時,才會一個一個元素地拉動。
俄羅斯套娃
每次呼叫 .map() 或 .filter(),你其實是在迭代器外面「套一層」。就像俄羅斯套娃:
v.iter() // 最內層:原始迭代器
.filter(|x| **x > 2) // 第二層:Filter 結構,存著 inner + 閉包
.map(|x| x * 10) // 第三層:Map 結構,存著 inner + 閉包
每一層都是一個 struct,裡面存著內層的迭代器和自己的閉包。標準庫的 Map 和 Filter 大致長這樣:
struct Map<I, F> {
iter: I, // 內層迭代器
f: F, // 要套用的閉包
}
struct Filter<I, P> {
iter: I, // 內層迭代器
predicate: P, // 過濾條件的閉包
}
它們的 next() 實作也很直覺:
// Map 的 next():從內層拿一個元素,套用閉包
impl<I: Iterator, F: FnMut(I::Item) -> B> Iterator for Map<I, F> {
type Item = B;
fn next(&mut self) -> Option<B> {
let x = self.iter.next()?; // 問內層要一個元素
Some((self.f)(x)) // 套用閉包回傳
}
}
// Filter 的 next():不斷從內層拿,直到找到符合條件的
impl<I: Iterator, P: FnMut(&I::Item) -> bool> Iterator for Filter<I, P> {
type Item = I::Item;
fn next(&mut self) -> Option<I::Item> {
loop {
let x = self.iter.next()?; // 問內層要一個元素
if (self.predicate)(&x) {
return Some(x); // 符合條件,回傳
}
// 不符合,繼續問下一個
}
}
}
所以整條鏈就是一堆 struct 套在一起——呼叫最外層的 next(),它去問內層,內層再問更內層,一路拉到最底。
pull-based:一次只處理一個元素
當你呼叫 .collect() 或 for 迴圈時,最外層的迭代器開始「拉」:
- 最外層(Map)問第二層(Filter):「給我下一個元素」
- Filter 問最內層(原始迭代器):「給我下一個元素」
- 最內層回傳
Some(&1) - Filter 檢查條件:
1 > 2?不通過。再問一次。 - 最內層回傳
Some(&2) - Filter 檢查:
2 > 2?不通過。再問。 - 最內層回傳
Some(&3) - Filter 檢查:
3 > 2?通過!回傳給 Map。 - Map 套用閉包:
3 * 10 = 30,回傳Some(30)
每個元素是一路到底處理完的——不像先做完所有 filter,再做所有 map。這意味著中間不需要任何暫存的 Vec。
無限迭代器
因為是惰性的,迭代器可以是無限的。std::iter::repeat 和 std::iter::from_fn 都可以產生永遠不回傳 None 的迭代器:
use std::iter;
// 永遠產出 1, 2, 3, 4, 5, ...
let mut n = 0;
let naturals = iter::from_fn(move || {
n += 1;
Some(n)
});
這不會無窮迴圈,因為迭代器是惰性的——沒人呼叫 next() 就什麼都不會發生。
.take() 馴服無限迭代器
用 .take(n) 就能從無限迭代器中取出有限個元素:
let first_ten: Vec<i32> = naturals.take(10).collect();
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
這就是惰性求值的威力——你可以先描述一個「概念上無限」的計算,最後再決定要取多少。
不小心忘記消費?
因為迭代器是惰性的,如果你寫了 .map() 但忘記 .collect() 或 for,什麼事都不會發生。Rust 編譯器會發出警告:
warning: unused `Map` that must be used
note: iterators are lazy and do nothing unless consumed
看到這個警告就知道:你忘了消費迭代器了。
範例程式碼
use std::iter;
fn main() {
// 惰性示範:map 不會立刻執行
println!("--- 惰性示範 ---");
let v = vec![1, 2, 3];
let iter = v.iter().map(|x| {
println!(" 處理 {}", x);
x * 2
});
println!("map 建立完了,但還沒執行...");
println!("現在開始 collect:");
let result: Vec<i32> = iter.collect();
println!("結果:{:?}", result);
// pull-based:filter + map 一次處理一個元素
println!("\n--- Pull-based 示範 ---");
let data = vec![1, 2, 3, 4, 5, 6];
let processed: Vec<i32> = data
.iter()
.filter(|&&x| {
println!(" filter 檢查 {}", x);
x % 2 == 0
})
.map(|&x| {
println!(" map 處理 {}", x);
x * 10
})
.collect();
println!("結果:{:?}", processed);
// 注意印出的順序!filter 和 map 是交替執行的
// from_fn 建立無限迭代器(取前 10 個質數)
let mut candidate = 1;
let primes: Vec<i32> = iter::from_fn(move || {
loop {
candidate += 1;
let is_prime = (2..candidate).all(|d| candidate % d != 0);
if is_prime {
return Some(candidate);
}
}
})
.take(10)
.collect();
println!("前 10 個質數:{:?}", primes);
// 不需要中間 Vec——全部在一條管道裡
println!("\n--- 零中間 Vec ---");
let sum_of_even_squares: i32 = (1..=100)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.sum();
println!("1~100 偶數的平方和:{}", sum_of_even_squares);
// 沒有任何中間的 Vec 被建立,全部是一次一個元素處理完的
}
重點整理
- 迭代器的
.map()/.filter()等方法是惰性的,不會立刻執行 - 每次呼叫轉換方法都是在外面「套一層」struct(俄羅斯套娃)
- 消費(
.collect()、for、.sum()等)才會觸發執行 - 執行方式是 pull-based——一次拉一個元素,完整通過所有層,不需要中間 Vec
- 因為惰性,迭代器可以是無限的——
repeat、from_fn永不回傳 None - 用
.take(n)從無限迭代器中取出有限個元素 - 忘記消費迭代器的話,編譯器會發出警告提醒你
恭喜你完成了第六章!🎉 從函數指標到閉包的三種 Fn trait,再到迭代器的惰性求值——這一章結合了所有權、trait、泛型等前面學過的概念,展現了 Rust 函數式程式設計的威力。你現在已經能寫出簡潔、高效、不需要中間暫存的資料處理管道了。下一章我們將學習 Cargo、crate 與 mod 系統——讓你的程式碼從單一檔案擴展到真正的專案結構!
第七章:Cargo、Crate 與 Mod 系統
第七章第 1 集:Cargo 與 crates.io
本集目標
認識 Cargo 的完整功能以及如何透過 crates.io 使用社群套件。
概念說明
我們從第一章開始就在用 cargo new 和 cargo run。其實 cargo run 背後做了兩件事:先編譯你的程式碼,再執行編譯出來的執行檔。如果你只想編譯但不執行,可以用 cargo build——它只會產生執行檔,放在 target/debug/ 資料夾裡。
這一集我們要把 Cargo 的全貌攤開來看,特別是怎麼引入外部套件。
dev build vs release build
cargo build 和 cargo run 預設跑的是 debug 模式——編譯快但執行慢(沒有最佳化)。當你要發布程式的時候,加上 --release:
cargo build --release
這會產生最佳化過的執行檔,放在 target/release/ 而不是 target/debug/。差異可以非常大——有些程式 release 版本跑起來快好幾倍。
Cargo.toml
每個 Rust 專案的根目錄都有 Cargo.toml。TOML 是一種設定檔格式,設計得讓人好讀好寫。
一個典型的 Cargo.toml 長這樣:
[package]
name = "my_project"
version = "0.1.0"
edition = "2024"
[dependencies]
[package]:專案的基本資訊(名稱、版本、Rust edition)[dependencies]:這個專案用到的外部套件
其中 edition 是 Rust 的版本號——但不是 Rust 編譯器的版本,而是語言規格的版本。Rust 每隔幾年會發布一個新的 edition(2015、2018、2021、2024),每次可能會微調一些語法或預設行為。不同 edition 的 crate 可以互相搭配使用,所以不用擔心相容性問題。cargo new 會自動幫你設成最新的 edition。
加入外部套件
想用別人寫好的套件?最簡單的方式:
cargo add rand
這會自動在 Cargo.toml 的 [dependencies] 加上類似這樣的一行:
[dependencies]
rand = "0.10"
實際加上的版本號取決於你執行 cargo add 時的最新版本,不一定和這裡寫的一樣。
crates.io
crates.io 是 Rust 的官方套件庫。你可以在上面搜尋套件、看下載數、閱讀文件。每個套件頁面都會有:
- 使用說明和版本歷史
- 連結到 docs.rs 的自動產生文件
- 下載數(可以當作套件熱門程度的參考)
依賴的版本語意
在 [dependencies] 裡指定外部套件的版本時,有不同的寫法:
"^1.0"(或直接寫"1.0"):相容1.x.y的任何版本,但不會升到2.0"=1.0.0":鎖定剛好這個版本">=1.2, <1.5":指定範圍
大多數時候用預設的 ^ 就好,Cargo 會幫你選合適的版本。更多細節可以參考官方文件。
Cargo features
有些套件提供可選功能,用 features 開啟:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
這樣就能用 serde 的 #[derive(Serialize, Deserialize)],而不需要的功能不會被編譯進來。
範例程式碼
用 rand 套件產生隨機數:
// 先執行:cargo add rand
use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let n: u32 = rng.random_range(1..=100);
println!("隨機數字:{}", n);
let coin: bool = rng.random();
if coin {
println!("正面!");
} else {
println!("反面!");
}
}
重點整理
cargo build --release產生最佳化的執行檔,適合發布Cargo.toml用 TOML 格式,[package]記專案資訊,[dependencies]記外部套件edition是 Rust 語言規格的版本(2015、2018、2021、2024),不同 edition 的 crate 可以互相搭配cargo add <套件名>是加入外部套件最快的方式- crates.io 是 Rust 的官方套件庫,docs.rs 提供自動產生的文件
- 版本號
"1.0"等同"^1.0",允許相容升級;"=1.0.0"鎖定精確版本 features可以開啟套件的可選功能
第七章第 2 集:mod
本集目標
學會用 mod 將程式碼組織成有層次的結構。
概念說明
當程式越寫越長,全部塞在一個 main.rs 裡面會變得很難維護。這時候我們需要把相關的函式、struct、enum 分組——在 Rust 裡,這個分組機制就是 mod。
在同一個檔案裡定義 mod
最簡單的用法:直接在檔案裡用 mod 關鍵字建立一個區塊。
mod math {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn multiply(a: i32, b: i32) -> i32 {
a * b
}
}
要呼叫 mod 裡的函式,用 :: 路徑語法:
let result = math::add(3, 5);
注意那個 pub——mod 裡的東西預設是私有的。如果不加 pub,外面就看不到、用不了。關於 pub 的完整規則我們在第 4 集會詳細講,這裡先記住:想讓外面用,就加 pub。
巢狀 mod
mod 可以一層一層巢狀:
mod math {
pub mod basic {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
}
pub mod advanced {
pub fn power(base: i32, exp: u32) -> i32 {
let mut result = 1;
for _ in 0..exp {
result *= base;
}
result
}
}
}
呼叫的時候就用完整路徑:
let sum = math::basic::add(2, 3);
let p = math::advanced::power(2, 10);
這就像檔案系統的資料夾結構一樣——math 底下有 basic 和 advanced 兩個子 mod。
mod 的預設可見性
一個很重要的觀念:mod 裡的所有項目預設都是私有的。同一個 mod 內部的程式碼可以互相存取,但外部看不到。這是 Rust 用來保護封裝性的設計。我們第 4 集會深入探討。
範例程式碼
mod geometry {
pub struct Rectangle {
pub width: f64,
pub height: f64,
}
impl Rectangle {
pub fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
pub fn area(&self) -> f64 {
self.width * self.height
}
}
pub mod utils {
pub fn describe_shape(name: &str, area: f64) {
println!("{} 的面積是 {}", name, area);
}
}
}
fn main() {
let rect = geometry::Rectangle::new(10.0, 5.0);
let area = rect.area();
geometry::utils::describe_shape("長方形", area);
}
重點整理
mod name { ... }在同一個檔案裡建立 mod- mod 裡的東西用
mod_name::item的路徑語法呼叫 - mod 可以巢狀,路徑就越來越長:
a::b::c::func() - mod 內的所有項目預設是私有的,要讓外部使用需加
pub - mod 是 Rust 組織程式碼的基本單位,就像資料夾組織檔案一樣
第七章第 3 集:檔案 mod
本集目標
學會將 mod 拆分到不同檔案,理解 Rust 的檔案與 mod 對應規則。
概念說明
上一集我們把 mod 寫在同一個檔案裡,但實際專案不可能全部塞在一起。Rust 提供了一套規則,讓你把 mod 拆到獨立的檔案中。
基本拆分:mod + 獨立檔案
假設你有一個 math mod,想把它搬到自己的檔案。做法很簡單:
- 在
main.rs(或lib.rs)裡寫mod math;(注意是分號,不是大括號) - 建立
math.rs,把 mod 的內容放進去
src/
├── main.rs
└── math.rs
main.rs:
mod math;
fn main() {
let result = math::add(3, 5);
println!("3 + 5 = {}", result);
}
math.rs:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
注意 math.rs 裡面不需要再寫 mod math { ... }——檔案本身就代表那個 mod。
子 mod 的資料夾結構
如果 math mod 底下還有子 mod,有兩種組織方式:
方式一:用 mod.rs(傳統風格)
src/
├── main.rs
└── math/
├── mod.rs
├── basic.rs
└── advanced.rs
math/mod.rs 就是 math mod 的入口,裡面聲明子 mod:
// math/mod.rs
pub mod basic;
pub mod advanced;
方式二:同名檔案 + 資料夾(推薦)
src/
├── main.rs
├── math.rs ← math mod 的入口
└── math/
├── basic.rs
└── advanced.rs
// math.rs
pub mod basic;
pub mod advanced;
兩種方式效果完全一樣,選你喜歡的就好。比較新的專案傾向用方式二,因為不會有一堆檔案都叫 mod.rs,在編輯器裡比較好辨認。
lib.rs vs main.rs
一個 Rust 專案(crate)有兩種類型:
- binary crate:有
src/main.rs,會編譯成可執行檔 - library crate:有
src/lib.rs,給別人使用的程式庫
一個專案可以同時有 main.rs 和 lib.rs。main.rs 是 binary crate 的根,lib.rs 是 library crate 的根。
src/
├── main.rs ← binary crate root
├── lib.rs ← library crate root
├── math.rs
└── math/
├── basic.rs
└── advanced.rs
在 main.rs 裡可以用 crate 名稱引用 lib.rs 裡的東西:
// main.rs
// 假設 Cargo.toml 的 [package] name = "my_project"
use my_project::math;
fn main() {
let result = math::basic::add(1, 2);
println!("{}", result);
}
完整的多檔案範例
src/
├── main.rs
├── math.rs
└── math/
├── basic.rs
└── advanced.rs
main.rs:
mod math;
fn main() {
let sum = math::basic::add(10, 20);
println!("10 + 20 = {}", sum);
let p = math::advanced::power(2, 8);
println!("2 ^ 8 = {}", p);
}
math.rs:
pub mod basic;
pub mod advanced;
math/basic.rs:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
math/advanced.rs:
pub fn power(base: i32, exp: u32) -> i32 {
let mut result = 1;
for _ in 0..exp {
result *= base;
}
result
}
範例程式碼
由於檔案 mod 涉及多個檔案,無法用單一檔案示範。請參考上方的完整多檔案範例,建立對應的檔案結構後用 cargo run 執行。
重點整理
mod math;(分號結尾)告訴 Rust 去找子 mod- 被拆出去的檔案裡不需要再寫
mod math { ... },檔案本身就是 mod - 子 mod 可以用
math/mod.rs(傳統)或math.rs+math/資料夾(推薦) main.rs是 binary crate 的根,lib.rs是 library crate 的根- 一個專案可以同時是 binary crate 和 library crate
第七章第 4 集:pub 可見性
本集目標
完整理解 Rust 的可見性規則,掌握 pub 的各種用法。
概念說明
第 2 集提到 mod 裡的東西預設是私有的,這一集我們來把可見性規則講清楚。
預設私有
Rust 的哲學是預設封閉——所有東西一開始都是私有的,你必須明確地用 pub 開放。這跟有些語言預設 public 的設計完全相反。
mod secrets {
fn hidden() {
// 外面看不到我
}
pub fn visible() {
// 外面可以呼叫我
hidden(); // 同 mod 內可以互相呼叫
}
}
fn main() {
secrets::visible(); // OK
// secrets::hidden(); // 編譯錯誤!hidden 是私有的
}
你可能會好奇:fn main() 和 mod secrets 都沒加 pub,為什麼 main 能看到 secrets?因為它們都定義在根 mod 裡——同一個 mod 的成員互相看得到,不需要 pub。pub 是用來讓其他 mod 看到你的東西的。
pub fn
函式加 pub 就對外公開,沒什麼好說的。
pub struct —— 欄位要個別標記
struct 加 pub 只是讓這個型別公開,欄位還是私有的!每個欄位要個別加 pub:
mod user {
pub struct Profile {
pub name: String, // 外部可讀寫
pub email: String, // 外部可讀寫
age: u32, // 私有!外部看不到
}
impl Profile {
pub fn new(name: String, email: String, age: u32) -> Profile {
Profile { name, email, age }
}
pub fn age(&self) -> u32 {
self.age // 透過方法公開唯讀存取
}
}
}
fn main() {
let p = user::Profile::new(
String::from("Yaju"),
String::from("yaju@senpai.com"),
24,
);
println!("名字:{}", p.name); // OK,name 是 pub
println!("年齡:{}", p.age()); // OK,透過方法存取
// println!("{}", p.age); // 編譯錯誤!age 欄位是私有的
}
這個設計很重要——它讓你可以控制哪些欄位要暴露、哪些要隱藏。如果 struct 有任何私有欄位,外部就無法直接用 StructName { ... } 建構,必須透過你提供的建構函式。
pub enum —— variants 自動公開
enum 跟 struct 不一樣:只要 enum 本身是 pub,所有 variants 都自動公開。
mod status {
pub enum Color {
Red,
Green,
Blue,
}
}
fn main() {
let c = status::Color::Red; // 所有 variant 都可用
match c {
status::Color::Red => println!("紅色"),
status::Color::Green => println!("綠色"),
status::Color::Blue => println!("藍色"),
}
}
這很合理——如果你公開了一個 enum 但藏了某些 variant,別人根本沒辦法正確 match,那還不如不公開。
pub trait 和 impl
trait 加 pub 後,裡面的 fn 不用也不能個別加 pub——它們的可見性自動跟著 trait 走。如果 trait 是公開的,裡面的 fn 就是公開的;如果 trait 是私有的,裡面的 fn 就是私有的。這很合理:trait 是一個「契約」,如果你公開了這個契約,契約裡的所有條款當然也要公開,不然別人怎麼實作?
mod animal {
pub trait Speak {
fn speak(&self); // 不用加 pub,跟著 trait 走
}
pub struct Dog;
impl Speak for Dog {
fn speak(&self) {
println!("汪!");
}
}
}
fn main() {
use animal::Speak; // trait 要在作用域內才能呼叫它的方法
let d = animal::Dog;
d.speak();
}
注意 use animal::Speak; 這行——即使 Dog 已經實作了 Speak,你還是要把 Speak trait 引入作用域才能呼叫它的方法。如果拿掉這行,d.speak() 會編譯錯誤。這是 Rust 的規則:trait 的方法只有在 trait 被 use 進來之後才能呼叫。
// 沒有 use animal::Speak;
// let d = animal::Dog;
// d.speak(); // 編譯錯誤!Speak 不在作用域內
impl 區塊本身不需要也不能加 pub。對於 impl Type(不是 impl Trait for Type),裡面的 fn 各自用 pub 控制可見性:
mod shapes {
pub struct Circle {
pub radius: f64,
}
impl Circle {
pub fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
// 這是私有方法,只有 mod 內部能用
fn internal_check(&self) -> bool {
self.radius > 0.0
}
}
}
pub(crate)、pub(super)、pub(in path)
有時候你不想完全公開,但又想讓 crate 內部的其他 mod 使用。Rust 提供了精細的控制:
pub(crate):整個 crate 內部可見,但外部(別的 crate)看不到pub(super):只有父 mod 可見pub(in crate::some::path):只對指定的 mod 路徑可見——最精細的控制
mod database {
pub(crate) fn connect() -> String {
// 整個 crate 內部都能呼叫,但如果這是 library,
// 使用你 library 的人看不到這個函式
String::from("connected")
}
// 注意 queries 本身是 pub——如果這個 mod 不是 pub,
// 那裡面的東西即使標了 pub(super) 也沒用,
// 因為外面根本看不到這個 mod,更別說裡面的東西了。
pub mod queries {
pub(super) fn raw_query() -> String {
// 只有 database mod(父 mod)能看到
String::from("SELECT * FROM users")
}
pub fn safe_query() -> String {
let raw = raw_query(); // 同 mod 內可以呼叫
format!("SAFE: {}", raw)
}
}
}
// pub(in path) 的例子
mod app {
pub mod api {
pub mod internal {
// 只有 app::api 能看到這個函數
pub(in crate::app::api) fn secret_key() -> &'static str {
"super-secret"
}
}
pub fn get_key() -> &'static str {
internal::secret_key() // OK,我們在 app::api 裡
}
}
}
// app::api::internal::secret_key() 在這裡看不到
// 因為 pub(in crate::app::api) 限制了只有 app::api 能存取
// 注意:pub(in path) 的 path 必須是「包含你的」mod(從你往外數的某一層)。
// 如果你寫了一個跟你無關的 mod 路徑,例如:
// pub(in crate::some_unrelated_mod) fn foo() {}
// 編譯器會直接報錯——你不能對一個「不包含你」的 mod 開放可見性。
fn main() {
let conn = database::connect(); // OK,我們在同一個 crate
let q = database::queries::safe_query(); // OK,pub
println!("{}, {}", conn, q);
// database::queries::raw_query(); // 編譯錯誤!pub(super) 只給父 mod
}
範例程式碼
這一集的概念說明中已經包含了完整的程式碼範例,不另外重複。
重點整理
- Rust 預設一切私有,必須明確加
pub才公開 pub struct只公開型別名稱,每個欄位需要個別加pub- 有私有欄位的 struct 無法從外部直接建構,必須提供建構函式
pub enum的所有 variants 自動公開impl Trait for T裡的 fn 可見性跟著 trait 走,不加pub;impl T裡的 fn 各自用pub控制pub(crate):crate 內部可見,外部不可見pub(super):只有父 mod 可見pub(in path):只對指定的 mod 路徑可見
第七章第 5 集:use
本集目標
學會用 use 簡化路徑,理解 Rust 的路徑解析規則和各種匯入方式。
概念說明
在前面我們已經初步接觸過 use,這裡要把所有用法和路徑規則講完整。
為什麼需要 use
每次呼叫都寫完整路徑很累:
let sum = crate::math::basic::add(1, 2);
let diff = crate::math::basic::subtract(5, 3);
用 use 把路徑帶進來,之後就能直接用短名稱:
use crate::math::basic::add;
use crate::math::basic::subtract;
let sum = add(1, 2);
let diff = subtract(5, 3);
絕對路徑 vs 相對路徑
Rust 有兩種路徑起點:
絕對路徑——從 crate root 開始:
use crate::math::add; // 自己這個 crate 裡的 math mod
相對路徑——從當前 mod 的位置開始:
use math::add; // 當前 mod 底下的 math 子 mod
外部 crate 的路徑
在 Cargo.toml 加了外部 crate 後,直接用 crate 名稱作為路徑開頭:
use rand::Rng;
use std::collections::HashMap;
std 是 Rust 的標準函式庫(standard library)——Rust 內建的一組工具,包含我們已經用過的 Vec、String、Option、Result、println! 等等,以及更多像是檔案操作、網路、集合等功能。你不需要在 Cargo.toml 加 dependency 就能用它,因為每個 Rust 程式都會自動連結 std。使用時路徑寫法跟外部 crate 一樣——std::collections::HashMap、std::fmt::Display 等。不只 std 會被自動連結,std 中的 prelude 更會被自動引入——也就是說,Vec、String、Option、Result、Clone、Copy 等最常用的型別和 trait,不用寫 use 就能直接用。這就是為什麼我們在最前面好幾章沒寫 use 也能用這些東西。
如果你想明確強調「這是外部 crate」,可以用 :: 開頭:
use ::rand::Rng; // 明確表示 rand 是外部 crate,不是本地 mod
這在你的 crate 裡也有一個叫 rand 的 mod 時特別有用,可以避免歧義。
super:: 和 self::
super:::往上一層,指向父 modself:::指向當前 mod(通常省略,但有時在use中有用)
mod outer {
pub fn greet() -> String {
String::from("Hello from outer")
}
pub mod inner {
pub fn call_parent() -> String {
super::greet() // 呼叫父 mod 的 greet
}
}
}
一次 use 多個東西
匯入同一個路徑底下的多個東西,可以用大括號合併:
use std::io::{self, Read, Write};
// 等同於:
// use std::io;
// use std::io::Read;
// use std::io::Write;
self 在這裡代表 std::io 本身,所以你既匯入了 io 這個 mod,也匯入了裡面的 Read 和 Write。
use ... as(別名)
如果兩個不同地方有同名的東西,可以用 as 取別名:
use std::fmt::Result as FmtResult;
use std::io::Result as IoResult;
fn format_something() -> FmtResult {
Ok(())
}
fn read_something() -> IoResult<()> {
Ok(())
}
use 的名字衝突
如果你 use 了兩個同名的東西到同一個作用域,Rust 會直接報錯:
mod a {
pub fn hello() -> &'static str { "from a" }
}
mod b {
pub fn hello() -> &'static str { "from b" }
}
use a::hello;
use b::hello; // 編譯錯誤!hello 已經被定義了
這時候就用 as 取別名來解決。
但如果是不同作用域,內層的 use 會遮蔽(shadow)外層的——就像 let 的 shadowing:
use a::hello;
fn main() {
println!("{}", hello()); // "from a"
{
use b::hello; // 在這個作用域裡 shadow 了外面的 hello
println!("{}", hello()); // "from b"
}
println!("{}", hello()); // "from a"(回到外層)
}
glob import(星號匯入)
* 會把 mod 底下所有 pub 的東西全部帶進來:
use std::collections::*; // HashMap, BTreeMap, HashSet... 全部可用
一般不推薦在正式程式碼裡用,因為不清楚到底帶了什麼進來,容易衝突。但在測試裡很常見——use super::*; 可以把父 mod 的所有東西帶進測試 mod。下一集我們會教怎麼用 cargo test 寫測試,到時候就會看到這個用法。
use enum variant 和 associated function
use 不只能匯入 mod 底下的東西,也能匯入 enum 的 variant:
use std::cmp::Ordering::{Less, Equal, Greater};
fn compare(a: i32, b: i32) {
match a.cmp(&b) {
Less => println!("小於"),
Equal => println!("相等"),
Greater => println!("大於"),
}
}
不用每次都寫 Ordering::Less,直接用 Less 就好。這在 match 很多 variant 的時候特別方便。
範例程式碼
mod math {
pub mod basic {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
}
pub mod advanced {
pub fn power(base: i32, exp: u32) -> i32 {
let mut result = 1;
for _ in 0..exp {
result *= base;
}
result
}
pub fn factorial(n: u64) -> u64 {
let mut result: u64 = 1;
for i in 1..=n {
result *= i;
}
result
}
}
}
// 各種 use 的方式
use math::basic::add;
use math::basic::subtract;
use math::advanced::{power, factorial};
fn main() {
println!("3 + 5 = {}", add(3, 5));
println!("10 - 4 = {}", subtract(10, 4));
println!("2 ^ 10 = {}", power(2, 10));
println!("10! = {}", factorial(10));
}
重點整理
use將路徑帶入作用域,讓你不必每次寫完整路徑- 絕對路徑用
crate::開頭,相對路徑從當前 mod 位置開始 - 外部 crate 直接用名稱開頭;加
::前綴可以明確標記為外部 crate std是標準函式庫,不用加 dependency 就能用,prelude 也在裡面super::指向父 mod,self::指向當前 moduse a::b::{self, X, Y};一次 use 多個東西use X as Alias;取別名,解決名字衝突- 同作用域 use 同名會報錯;不同作用域會 shadow(內層遮蔽外層)
use something::*;星號匯入——測試裡常用,正式程式碼少用- enum 的 variant 也可以被
use
第七章第 6 集:cargo test
本集目標
學會用 #[test] 寫測試、用 assert! 系列巨集驗證結果、用 cargo test 跑測試。
概念說明
為什麼要寫測試?
程式碼寫完之後,你怎麼確定它是對的?手動跑一遍?那下次改了程式碼又要再跑一遍。自動化測試讓你寫一次,之後隨時都能驗證——一個指令就知道有沒有東西壞掉。
最簡單的測試
在函數上面加 #[test],它就變成測試函數:
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
跑 cargo test,Rust 會自動找出所有標了 #[test] 的函數並執行它們。
assert 系列巨集
assert!(condition)— 如果condition是false,測試失敗assert_eq!(left, right)— 如果left != right,測試失敗assert_ne!(left, right)— 如果left == right,測試失敗
assert_eq! 和 assert_ne! 在失敗時會印出兩個值的 Debug 格式,方便你看到底哪裡不對。
assert! 系列不只能用在測試裡——你也可以在普通程式碼裡用它們來檢查條件。這時候就有一個值得注意的差別:assert! 在 debug 和 release 模式下都會執行,即使是正式發布的程式,失敗了一樣會 panic。如果你只想在 debug 模式檢查(release 時自動移除),可以用 debug_assert!、debug_assert_eq!、debug_assert_ne!——它們在 release 模式下會被編譯器完全忽略。
不過在測試裡面,直接用 assert! 系列就好——反正測試不會被 release build 影響。
測試 mod 的慣用結構
上一集學了 use super::*;——測試最常這樣用。慣例是在檔案底部加一個測試 mod:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn multiply(a: i32, b: i32) -> i32 {
a * b
}
#[cfg(test)]
mod tests {
use super::*; // 把父 mod 的所有東西引進來
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_multiply() {
assert_eq!(multiply(3, 4), 12);
}
}
幾個重點:
#[cfg(test)]告訴編譯器:這個 mod 只在跑測試時才編譯。正式發布的程式不會包含測試程式碼。mod tests是一個普通的 mod,只是慣例叫tests。use super::*;把父 mod(也就是這個檔案的最外層)的所有東西引進來,這樣測試裡就能直接呼叫add、multiply等函數。
cargo test
cargo test
這個指令會:
- 編譯你的程式碼(包含測試)
- 執行所有
#[test]函數 - 報告哪些通過、哪些失敗
測試私有函數
因為 mod tests 是你程式碼的子 mod,而同一個 mod 裡的東西互相看得到——所以測試可以直接測試私有函數,不需要 pub。
範例程式碼
fn is_even(n: i32) -> bool {
n % 2 == 0
}
fn abs(n: i32) -> i32 {
if n >= 0 { n } else { -n }
}
fn clamp(value: i32, min: i32, max: i32) -> i32 {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_even() {
assert!(is_even(4));
assert!(!is_even(7));
assert!(is_even(0));
}
#[test]
fn test_abs() {
assert_eq!(abs(5), 5);
assert_eq!(abs(-3), 3);
assert_eq!(abs(0), 0);
}
#[test]
fn test_clamp() {
assert_eq!(clamp(5, 0, 10), 5); // 在範圍內,不變
assert_eq!(clamp(-3, 0, 10), 0); // 低於下限,變成 min
assert_eq!(clamp(15, 0, 10), 10); // 超過上限,變成 max
}
#[test]
fn test_not_equal() {
assert_ne!(abs(-5), -5); // abs(-5) 應該是 5,不是 -5
}
}
fn main() {
// main 裡可以不用寫什麼——測試透過 cargo test 跑
println!("用 cargo test 來跑測試!");
}
重點整理
#[test]標記測試函數,cargo test自動找到並執行所有測試assert!(condition)、assert_eq!(a, b)、assert_ne!(a, b)驗證結果(debug 和 release 都會執行)debug_assert!、debug_assert_eq!、debug_assert_ne!只在 debug 模式執行,release 時會被忽略#[cfg(test)]讓測試 mod 只在測試時編譯use super::*;引入父 mod 的所有東西——測試最常用的寫法- 測試可以直接測試私有函數(因為測試 mod 是子 mod)
第七章第 7 集:pub use
本集目標
學會用 pub use 重新匯出(re-export)內部項目,讓使用者不需要知道你的 mod 結構。
概念說明
假設你寫了一個 library,內部結構長這樣:
src/
├── lib.rs
├── math.rs
└── math/
├── basic.rs
└── advanced.rs
如果不做任何處理,使用你 library 的人得寫:
use your_crate::math::basic::add;
use your_crate::math::advanced::power;
這很麻煩——使用者根本不在意你內部怎麼分資料夾,他只想用 add 和 power。
pub use 的魔法
pub use 把內部的東西「重新匯出(re-export)」到當前 mod,讓外部可以用更短的路徑存取:
// lib.rs
mod math;
// 重新匯出,讓使用者不需要知道 math::basic:: 的路徑
pub use math::basic::add;
pub use math::advanced::power;
現在使用你 library 的人只需要:
use your_crate::add;
use your_crate::power;
乾淨多了。
注意:pub use 只能匯出本來就是 pub 的東西。如果你試圖 pub use 一個 private 的 item,編譯器會報錯——你不能把別人藏起來的東西公開出去。
re-export 其他 crate 的東西
pub use 不只能匯出自己 mod 的內容,也能匯出其他 crate 的東西:
// lib.rs
pub use rand::Rng; // 使用者 use your_crate::Rng 就好,不用自己加 rand 依賴
這在 library 設計裡很常見——你的 library 依賴了某個 crate,但你想讓使用者透過你的 crate 就能用到那些型別,不用自己在 Cargo.toml 加依賴。
分層 re-export
你也可以在中間層的 mod 做 re-export,建立更有層次的公開 API:
// math.rs
pub mod basic;
pub mod advanced;
// 把常用的函式提升到 math 層級
pub use basic::add;
pub use basic::subtract;
pub use advanced::power;
這樣外部可以用 your_crate::math::add,不需要知道 basic 這一層。
re-export 的好處
- 簡化公開 API:使用者不需要知道你的內部結構,用更短的路徑存取
- 自由重構:你可以隨意改內部的 mod 結構,只要
pub use保持不變,使用者的程式碼不會壞
實際案例
很多知名的 Rust library 都大量使用 re-export。比如你寫 use std::io::Read;,其實 Read 可能定義在更深層的地方,只是被 re-export 到 std::io 了。
範例程式碼
mod shapes {
pub mod circle {
pub struct Circle {
pub radius: f64,
}
impl Circle {
pub fn new(radius: f64) -> Circle {
Circle { radius }
}
pub fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
}
pub mod rectangle {
pub struct Rectangle {
pub width: f64,
pub height: f64,
}
impl Rectangle {
pub fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
pub fn area(&self) -> f64 {
self.width * self.height
}
}
}
// 重新匯出:使用者不需要知道 circle 和 rectangle 這兩個子 mod
pub use circle::Circle;
pub use rectangle::Rectangle;
}
// 直接從 shapes 拿,不需要 shapes::circle::Circle
use shapes::{Circle, Rectangle};
fn main() {
let c = Circle::new(5.0);
println!("圓形面積:{}", c.area());
let r = Rectangle::new(4.0, 6.0);
println!("長方形面積:{}", r.area());
}
重點整理
pub use path::Item;把內部的東西重新匯出,讓外部用更短的路徑存取- 使用者不需要知道你內部的 mod 結構——你可以自由重構而不影響外部
- 可以匯出自己 mod 的內容,也可以匯出其他 crate 的東西
- library 的
lib.rs常用pub use把重要型別提升到 crate 頂層
第七章第 8 集:orphan rule
本集目標
理解 Rust 的 orphan rule(孤兒規則),以及當你想為外部型別實作外部 trait 時該怎麼辦。
概念說明
在第五章我們學過 trait——你可以為自己的型別實作任何 trait。但你有沒有試過這樣:
use std::fmt;
impl fmt::Display for Vec<i32> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "my vec")
}
}
編譯器會直接拒絕你。為什麼?
Orphan Rule(孤兒規則)
Rust 有一條規則:
要 impl 一個 trait,trait 或型別至少有一個必須是你這個 crate 定義的。
換句話說:trait 是你的,或型別是你的,至少要符合一個。
上面的例子裡,Display 是標準函式庫定義的,Vec<i32> 也是——兩個都不是你的,所以不行。
為什麼要有這個限制
想像一下如果沒有 orphan rule:
- Crate A 為
Vec<i32>實作了Display,印出[1, 2, 3] - Crate B 也為
Vec<i32>實作了Display,印出1 | 2 | 3 - 你的程式同時用了 A 和 B……編譯器要用哪一個?
這就是衝突。Orphan rule 從根本上避免了這個問題。
合法的情況
以下這些都是合法的:
// 情況 1:你的型別 + 外部 trait
struct MyPoint {
x: f64,
y: f64,
}
impl std::fmt::Display for MyPoint {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
// 情況 2:外部型別 + 你的 trait
trait Describable {
fn describe(&self) -> String;
}
impl Describable for Vec<i32> {
fn describe(&self) -> String {
format!("一個有 {} 個元素的 Vec", self.len())
}
}
Newtype Pattern(繞過限制的方法)
如果你真的需要為外部型別實作外部 trait,可以用 newtype pattern——建立一個 tuple struct 把外部型別包起來:
use std::fmt;
struct MyVec(Vec<i32>);
impl fmt::Display for MyVec {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let items: Vec<String> = self.0.iter()
.map(|x| x.to_string())
.collect();
write!(f, "[{}]", items.join(", "))
}
}
MyVec 是你定義的型別,所以可以為它實作 Display。self.0 存取內部的 Vec<i32>。
範例程式碼
use std::fmt;
// Newtype pattern:用自己的 struct 包住外部型別
struct Scores(Vec<i32>);
impl Scores {
fn new() -> Scores {
Scores(Vec::new())
}
fn add(&mut self, score: i32) {
self.0.push(score);
}
fn total(&self) -> i32 {
self.0.iter().sum()
}
}
// 現在可以為「你的型別」實作 Display
impl fmt::Display for Scores {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let items: Vec<String> = self.0.iter()
.map(|x| x.to_string())
.collect();
write!(f, "成績:[{}],總分:{}", items.join(", "), self.total())
}
}
fn main() {
let mut scores = Scores::new();
scores.add(85);
scores.add(92);
scores.add(78);
scores.add(95);
// 因為實作了 Display,可以直接 println
println!("{}", scores);
}
多參數 trait 的情況
上面講的規則是最簡單的版本。對於多參數 trait(像第五章學的 From<T>),規則其實更複雜。簡單來說:
// OK:你的型別出現在參數裡
impl From<MyType> for String { ... }
// 不行:兩邊都是外部的
impl From<String> for Vec<i32> { ... }
完整的規則涉及「covered type parameter」等概念,超出本教學的範圍。有興趣可以參考官方文件。
重點整理
- Orphan rule:要 impl trait,trait 或型別至少有一個必須是你的 crate 定義的
- 「你的型別 + 外部 trait」✅ 合法
- 「外部型別 + 你的 trait」✅ 合法
- 「外部型別 + 外部 trait」❌ 不合法
- 這個規則是為了防止不同 crate 之間的 impl 衝突
- Newtype pattern:用
struct MyWrapper(OriginalType)把外部型別包起來,就變成你的型別了 - 多參數 trait 的 orphan rule 遠比上面講的更複雜,詳見官方文件
第七章第 9 集:文件註解
本集目標
學會撰寫文件註解,用 cargo doc 產生專業的 HTML 文件。
概念說明
Rust 把文件當作語言的一等公民——不是用外部工具硬擠出來的,而是內建在語法裡的。
/// 項目文件註解
三個斜線 /// 是用來為接下來的項目(函式、struct、enum、trait 等)寫文件:
/// 計算兩個整數的最大公因數。
///
/// 使用歐幾里得演算法,效率為 O(log(min(a, b)))。
///
/// # Examples
///
/// ```
/// let result = gcd(12, 8);
/// assert_eq!(result, 4);
/// ```
pub fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let temp = b;
b = a % b;
a = temp;
}
a
}
/// 裡面支援完整的 Markdown 語法——標題、粗體、程式碼區塊、列表,全部都能用。
//! mod/crate 層級文件
兩個斜線加驚嘆號 //! 是為包含它的項目寫文件,通常放在檔案最頂端:
//! # Math Library
//!
//! 這個 library 提供基本的數學運算函式。
//!
//! ## 功能
//!
//! - 基本算術運算
//! - 最大公因數計算
//! - 次方運算
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
放在 lib.rs 頂端就是整個 crate 的文件,放在某個 mod 檔案頂端就是那個 mod 的文件。
常用的文件段落
Rust 社群有一些約定俗成的文件段落名稱:
# Examples— 使用範例(最重要的一個!)# Panics— 什麼情況下會 panic# Errors— 如果回傳Result,什麼情況下會是Err
cargo doc
寫好文件註解後,一行指令就能產生漂亮的 HTML 文件:
cargo doc --open
這會:
- 編譯你的 crate(不執行)
- 從所有
///和//!產生 HTML 文件 - 自動在瀏覽器打開
生成的文件就跟你在 docs.rs 上看到的一模一樣。
範例程式碼
//! # 溫度轉換工具
//!
//! 提供攝氏和華氏之間的轉換函式。
/// 攝氏轉華氏。
///
/// # 公式
///
/// `F = C × 9/5 + 32`
///
/// # Examples
///
/// ```
/// let f = celsius_to_fahrenheit(100.0);
/// assert!((f - 212.0).abs() < 0.001);
/// ```
pub fn celsius_to_fahrenheit(c: f64) -> f64 {
c * 9.0 / 5.0 + 32.0
}
/// 華氏轉攝氏。
///
/// # 公式
///
/// `C = (F - 32) × 5/9`
///
/// # Examples
///
/// ```
/// let c = fahrenheit_to_celsius(32.0);
/// assert!((c - 0.0).abs() < 0.001);
/// ```
pub fn fahrenheit_to_celsius(f: f64) -> f64 {
(f - 32.0) * 5.0 / 9.0
}
/// 溫度的表示方式。
///
/// 支援攝氏和華氏兩種單位。
pub enum Temperature {
/// 攝氏溫度
Celsius(f64),
/// 華氏溫度
Fahrenheit(f64),
}
impl Temperature {
/// 將任何溫度轉換為攝氏。
pub fn to_celsius(&self) -> f64 {
match self {
Temperature::Celsius(c) => *c,
Temperature::Fahrenheit(f) => fahrenheit_to_celsius(*f),
}
}
/// 將任何溫度轉換為華氏。
pub fn to_fahrenheit(&self) -> f64 {
match self {
Temperature::Celsius(c) => celsius_to_fahrenheit(*c),
Temperature::Fahrenheit(f) => *f,
}
}
}
fn main() {
let boiling = Temperature::Celsius(100.0);
println!("水的沸點:{}°C = {}°F", boiling.to_celsius(), boiling.to_fahrenheit());
let body = Temperature::Fahrenheit(98.6);
println!("體溫:{}°F = {}°C", body.to_fahrenheit(), body.to_celsius());
}
重點整理
///為接下來的項目(fn、struct、enum 等)撰寫文件//!為包含它的項目(mod、crate)撰寫文件,通常放在檔案最頂端- 文件註解支援完整的 Markdown 語法
# Examples是最重要的文件段落——好的範例勝過千言萬語cargo doc --open一鍵產生並打開 HTML 文件- 你在 docs.rs 上看到的文件,就是用同樣的機制產生的
第七章第 10 集:cargo publish
本集目標
學會將你的 library 發布到 crates.io,讓全世界的 Rust 開發者都能使用。
概念說明
到目前為止,我們學會了怎麼組織程式碼、寫文件、使用別人的套件。這一集要反過來——把你自己的套件發布出去。
帳號設定
首先,你需要一個 crates.io 的帳號:
- 到 crates.io 用 GitHub 帳號登入
- 到帳號設定頁面,產生一個 API Token
- 在終端機執行:
cargo login
按 Enter 後,終端機會提示你貼上 Token——貼上後再按 Enter 就完成了。Token 會被存在本機,之後 publish 時自動使用。
準備 Cargo.toml
發布前,Cargo.toml 需要補上一些必要的 metadata:
[package]
name = "my-awesome-lib"
version = "0.1.0"
edition = "2024"
description = "一個很棒的數學運算 library"
license = "MIT"
repository = "https://github.com/yourname/my-awesome-lib"
readme = "README.md"
keywords = ["math", "utility"]
categories = ["mathematics"]
根據官方文件,發布前應填寫:
license(或license-file):開源授權條款(如MIT、Apache-2.0、MIT OR Apache-2.0)description:一行簡短描述homepage:專案首頁網址repository:原始碼倉庫網址readme:README 檔案路徑
另外建議但非必須:
keywords:搜尋用的關鍵字(最多 5 個)categories:分類(需符合 crates.io 的分類清單)
發布前檢查
發布前可以先用 cargo package 檢查有沒有問題:
cargo package
這會模擬打包過程,檢查有沒有缺少必要欄位或其他問題。
發布!
一切準備好後:
cargo publish
完成!你的套件現在在 crates.io 上了,任何人都可以 cargo add my-awesome-lib 來使用。
版本更新流程
套件發布後,如果要更新:
- 修改程式碼
- 更新
Cargo.toml裡的version,遵循 SemVer(語意化版本號) - 再次
cargo publish
SemVer 的規則:
- 1.0 之前(
0.x.y):整個 API 都被視為不穩定,任何版本都可能有破壞性變更 - 1.0 之後:
- bug 修復:
1.0.0→1.0.1(patch) - 新增功能(向下相容):
1.0.1→1.1.0(minor) - 破壞性變更:
1.1.0→2.0.0(major)——改第一個數字
注意:已發布的版本無法刪除或覆蓋。如果發現某個版本有嚴重問題,可以用 cargo yank 標記它為不建議使用,但已經在用的人不會受影響:
cargo yank --version 0.1.0
發布前最好做的事
- 寫好
README.md(這會顯示在 crates.io 套件頁面上) - 跑過
cargo test確認所有測試通過 - 用
///寫好文件註解(上一集學的) - 確保有範例程式碼
- 用
cargo doc --open檢查文件看起來沒問題
範例程式碼
一個準備好發布的小 library 的完整結構:
my-math-lib/
├── Cargo.toml
├── README.md
├── src/
└── lib.rs
Cargo.toml:
[package]
name = "my-math-lib"
version = "0.1.0"
edition = "2024"
description = "Simple math utility functions"
license = "MIT"
homepage = "https://example.com/my-math-lib"
repository = "https://github.com/example/my-math-lib"
readme = "README.md"
keywords = ["math", "utility"]
categories = ["mathematics"]
src/lib.rs:
//! # My Math Lib
//!
//! 提供簡單好用的數學函式。
/// 計算最大公因數。
///
/// # Examples
///
/// ```
/// use my_math_lib::gcd;
/// assert_eq!(gcd(12, 8), 4);
/// ```
pub fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let temp = b;
b = a % b;
a = temp;
}
a
}
/// 計算最小公倍數。
///
/// # Examples
///
/// ```
/// use my_math_lib::lcm;
/// assert_eq!(lcm(4, 6), 12);
/// ```
pub fn lcm(a: u64, b: u64) -> u64 {
if a == 0 || b == 0 {
return 0;
}
a / gcd(a, b) * b
}
/// 判斷一個數是否為質數。
///
/// # Examples
///
/// ```
/// use my_math_lib::is_prime;
/// assert!(is_prime(7));
/// assert!(!is_prime(4));
/// ```
pub fn is_prime(n: u64) -> bool {
if n < 2 {
return false;
}
let mut i: u64 = 2;
while i * i <= n {
if n % i == 0 {
return false;
}
i += 1;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gcd() {
assert_eq!(gcd(12, 8), 4);
assert_eq!(gcd(7, 3), 1);
assert_eq!(gcd(0, 5), 5);
}
#[test]
fn test_lcm() {
assert_eq!(lcm(4, 6), 12);
assert_eq!(lcm(0, 5), 0);
}
#[test]
fn test_is_prime() {
assert!(!is_prime(0));
assert!(!is_prime(1));
assert!(is_prime(2));
assert!(is_prime(17));
assert!(!is_prime(15));
}
}
發布指令流程:
cargo test # 確認測試通過
cargo doc --open # 檢查文件
cargo package # 模擬打包
cargo publish # 正式發布!
重點整理
- 在 crates.io 用 GitHub 登入,產生 API Token 後用
cargo login設定 Cargo.toml發布前應填寫license、description、homepage、repository、readmecargo package可以在發布前檢查問題cargo publish正式發布到 crates.io- 更新版本時修改
version欄位,遵循 SemVer(語意化版本號) - 已發布的版本無法刪除,只能用
cargo yank標記為不建議使用 - 發布前寫好 README、文件註解、測試,是對使用者的基本尊重
恭喜你完成了第七章!🎉 到這裡為止,我們已經教完了 Rust 的主要觀念——所有權、借用、泛型、trait、生命週期、閉包、迭代器,以及模組系統和套件管理。你現在已經可以獨當一面了。如果你腦中有什麼點子,現在就是動手實作的好時機!
即便如此,Rust 還有很多獨特而強大的功能。後面的章節會繼續介紹進階主題,希望能帶給你對 Rust 更完整、更全面的認識。
附錄:補充主題
附錄第 a 集:數字字面值格式
本集目標
學會用底線分隔、不同進位制、型別後綴,以及浮點數字面值的各種寫法。
本集是第一章的補充。
概念說明
第一章我們學了基本的數字寫法,像 42、3.14。但 Rust 的數字字面值其實有很多寫法,讓你寫出來的數字更好讀、更精確。
底線分隔符
當數字很大的時候,1000000 和 1_000_000 哪個比較好讀?Rust 允許你在數字字面值的任意位置插入底線 _,編譯器會直接忽略它們:
let million = 1_000_000;
let weird_but_legal = 1_00_00_00; // 合法,但別這樣寫
不同進位制
除了十進位,Rust 還支援三種進位制前綴:
0x— 十六進位(例如0xff= 255)0b— 二進位(例如0b1010= 10)0o— 八進位(例如0o77= 63)
這在處理位元運算、顏色值等場景特別實用。底線也能搭配進位制使用:0b1111_0000。
型別後綴
你可以直接在數字後面加上型別:
let byte = 0xFFu8; // 十六進位 + u8
let big = 1_000_000i64; // 底線 + i64
let pi = 3.14f32; // 浮點數 + f32
不加後綴的話,整數預設是 i32,浮點數預設是 f64。
浮點數字面值
浮點數有幾種寫法:
let a = 3.14; // 一般小數,預設 f64
let b = 3.14f32; // 指定 f32
let c = 1.0e10; // 科學記號:1.0 × 10^10
let d = 2.5E-3; // 科學記號:2.5 × 10^-3 = 0.0025
let e = 1_234.567_8; // 底線也能用在浮點數裡
注意:Rust 的浮點數字面值必須有小數點或科學記號。1f64 不合法,要寫 1.0f64 或 1.0。
範例程式碼
fn main() {
// 底線分隔
let population = 23_000_000;
println!("台灣人口約 {} 人", population);
// 十六進位
let hex_color = 0xFF5733;
println!("顏色值:{}", hex_color);
// 二進位
let bits = 0b1010_1100;
println!("位元值:{}", bits);
// 八進位
let octal = 0o755;
println!("八進位 0o755 = {}", octal);
// 型別後綴
let byte_max = 0xFFu8;
println!("u8 最大值:{}", byte_max);
// 浮點數
let pi = 3.14_159_265f64;
println!("圓周率約 {}", pi);
// 科學記號
let speed_of_light = 3.0e8;
println!("光速約 {} m/s", speed_of_light);
let tiny = 1.6e-19;
println!("電子電荷約 {} C", tiny);
}
重點整理
_可以插在數字字面值的任意位置,幫助閱讀,編譯器會忽略0x十六進位、0b二進位、0o八進位- 型別後綴如
u8、i64、f32可以直接接在數字後面 - 浮點數支援科學記號(
1.0e10、2.5E-3) - 浮點數字面值必須有小數點或科學記號
附錄第 b 集:break 回傳值
本集目標
學會用 break 從 loop 迴圈中回傳值,把迴圈當作表達式使用。
本集是第一章的補充。
概念說明
還記得 Rust 裡「幾乎所有東西都是表達式」嗎?loop 迴圈也不例外——你可以透過 break 帶一個值出來,讓整個 loop 變成一個表達式。
基本語法
let result = loop {
break 42;
};
這裡 loop { break 42; } 的型別是 i32,因為 break 帶出了 42。
為什麼只有 loop 能這樣做?
你可能會問:while 和 for 為什麼不行?
原因是:while 和 for 有可能一次都不執行。如果迴圈體從未執行,那 break 帶出的值根本不存在,編譯器就無法保證一定有回傳值。
但 loop 不同——它是無條件迴圈,一定會進入迴圈體,所以編譯器可以確定 break 一定會被執行到(否則就是無窮迴圈)。這就是為什麼只有 loop 能當表達式回傳值。
實際應用場景
最常見的用法是「在迴圈裡搜尋某個東西,找到就帶出來」:
let found = loop {
// 做一些搜尋...
if condition {
break some_value;
}
};
這比先宣告一個變數、在迴圈裡賦值、再 break 出來要簡潔得多。
範例程式碼
fn main() {
// 基本用法:loop 回傳值
let lucky_number = loop {
break 7;
};
println!("幸運數字:{}", lucky_number);
// 實用範例:找到第一個大於 100 的平方數
let mut n = 1;
let result = loop {
let square = n * n;
if square > 100 {
break square;
}
n += 1;
};
println!("第一個大於 100 的平方數:{}", result);
println!("它是 {} 的平方", n);
}
重點整理
let x = loop { break value; };讓loop成為表達式,回傳break帶出的值- 只有
loop能這樣做,while和for不行——因為它們可能一次都不執行 - 常見用途是在迴圈中搜尋,找到後用
break帶出結果
附錄第 c 集:多行字串 & raw string literal
本集目標
學會在 Rust 中撰寫多行字串、行接續符號、以及不需要跳脫字元的 raw string。
本集是第一章的補充。
概念說明
寫程式的時候,我們常常需要處理多行文字、檔案路徑、或是包含特殊字元的字串。Rust 提供了幾種好用的語法來應對這些情況。
多行字串
在 Rust 裡,字串字面值可以直接跨行:
let poem = "床前明月光,
疑是地上霜。";
換行符號會直接被包含在字串裡。
行接續符 \
如果你想把很長的字串分行寫,但不要換行符號出現在結果裡,可以在行尾加 \。它會吃掉換行以及下一行開頭的空白:
let long = "這是一段很長的句子,\
但其實只有一行。";
// 結果:"這是一段很長的句子,但其實只有一行。"
Raw string literal
有時候字串裡有很多反斜線(例如 Windows 路徑),每個都要跳脫很煩。r"..." 語法讓你完全不需要跳脫:
let path = r"C:\Users\test\documents";
// 不需要寫成 "C:\\Users\\test\\documents"
包含引號的 raw string
如果 raw string 裡面需要有雙引號怎麼辦?用 r#"..."# 語法:
let json = r#"{"name": "Andy", "age": 29}"#;
如果字串裡面連 "# 都有?那就多加幾層 #:
let tricky = r##"這裡有 "#" 符號"##;
你可以加任意多層 #,只要開頭和結尾的數量一致就好。
範例程式碼
fn main() {
// 多行字串
let haiku = "古池や
蛙飛び込む
水の音";
println!("俳句:\n{}", haiku);
println!("---");
// 行接續符:\ 吃掉換行和前導空白
let sentence = "Rust 是一門注重安全性、\
效能和並行的程式語言。";
println!("{}", sentence);
println!("---");
// Raw string:不處理跳脫字元
let win_path = r"C:\Users\Andy\Desktop\project";
println!("路徑:{}", win_path);
// 正規表達式之類的場景也很好用
let pattern = r"\d+\.\d+";
println!("正則:{}", pattern);
// 包含雙引號的 raw string
let json = r#"{"name": "小明", "score": 95}"#;
println!("JSON:{}", json);
// 多層 # —— 當字串裡有 "# 的時候
let code_sample = r##"
let s = r#"hello"#;
println!("{}", s);
"##;
println!("程式碼範例:{}", code_sample);
// Raw string 也能多行
let html = r#"
<html>
<body>
<h1>Hello, Rust!</h1>
</body>
</html>
"#;
println!("{}", html);
}
重點整理
- 字串字面值可以直接跨行,換行符號會被保留
- 行尾加
\可以接續下一行,同時忽略換行和下一行的前導空白 r"..."是 raw string,不處理任何跳脫字元(\n、\\等都照原樣保留)r#"..."#讓 raw string 裡可以包含雙引號#的層數可以增加(r##"..."##、r###"..."###),只要前後一致- Raw string 特別適合 Windows 路徑、正則表達式、JSON、嵌入程式碼等場景
附錄第 d 集:格式化字串進階
本集目標
學會 println! 的各種格式化技巧,包括變數捕獲簡寫、寬度、對齊、精度控制和進位制顯示。
本集是第二章的補充。
概念說明
我們之前一直用 println!("{}", x) 來印東西,但其實 Rust 的格式化字串功能強大得多。這集介紹最常用的技巧,但不會涵蓋所有用法——完整的格式化語法請參考官方文件。
變數捕獲簡寫
你可以直接在 {} 裡寫變數名稱:
let name = "Andy";
println!("{name}"); // 等同於 println!("{}", name)
這比一直寫 {} 然後在後面對應變數方便多了,尤其是有很多變數的時候。注意只能放變數名,不能放表達式("{x + 1}" 不行)。
小數精度
用 :.N 控制小數點後幾位:
let pi = 3.14159265;
println!("{pi:.2}"); // 印出 3.14
寬度
用 :N 指定最小寬度——不夠寬的話會用空白補齊:
let x = 42;
println!("{x:5}"); // " 42"(寬度 5,靠右,空白補齊)
對齊
用 :>N、:<N、:^N 來明確控制靠右、靠左、置中:
let name = "Andy";
println!("[{name:>10}]"); // 靠右對齊,寬度 10
println!("[{name:<10}]"); // 靠左對齊
println!("[{name:^10}]"); // 置中
填充字元
預設用空白填充,你也可以指定其他字元:
let id = 42;
println!("{id:0>5}"); // 印出 00042(用 0 填充)
進位制顯示
用 :b、:x、:o 分別以二進位、十六進位、八進位顯示數字:
let n = 255;
println!("{n:b}"); // 11111111
println!("{n:x}"); // ff
println!("{n:o}"); // 377
這些格式也可以組合——例如 {:0>8b} 是「零填充到 8 位的二進位」。
跳脫大括號
如果你想在格式化字串裡印出 { 或 } 本身,用 {{ 和 }}:
println!("這是大括號:{{}}"); // 印出:這是大括號:{}
範例程式碼
fn main() {
let name = "小明";
let score = 87.5678;
// 變數捕獲簡寫
println!("學生:{name}");
println!("分數:{score}");
// 小數精度
println!("四捨五入到兩位:{score:.2}");
// 寬度
println!("[{name:10}]"); // 字串預設靠左
let x = 42;
println!("[{x:10}]"); // 數字預設靠右
// 對齊
println!("[{name:>10}]"); // 靠右
println!("[{name:<10}]"); // 靠左
println!("[{name:^10}]"); // 置中
// 零填充
let id = 42;
println!("編號:{id:0>5}");
// 進位制顯示
let value = 255;
println!("十進位:{value}");
println!("二進位:{value:b}");
println!("十六進位:{value:x}");
println!("八進位:{value:o}");
// 組合技:十六進位 + 零填充
let byte = 10;
println!("0x{byte:0>2x}"); // 印出 0x0a
// 組合技:零填充 + 二進位 + 8 位寬
println!("{byte:0>8b}"); // 印出 00001010
// 如果要印出大括號本身,用 {{ 和 }}
println!("這是一個大括號:{{}}"); // 印出:這是一個大括號:{}
}
重點整理
println!("{x}")直接在大括號裡寫變數名,只能放變數不能放表達式{:.2}控制小數點後位數{:5}指定最小寬度{:>10}、{:<10}、{:^10}分別是靠右、靠左、置中對齊{:0>5}用0填充到寬度 5{:b}、{:x}、{:o}分別用二進位、十六進位、八進位顯示- 格式化選項可以組合使用,例如
{:0>8b}是零填充 + 8 位 + 二進位 - 要印出
{和}本身,用{{和}}跳脫
附錄第 e 集:struct/enum 放在 main 裡面
本集目標
了解 struct、enum、fn 等「項目」可以定義在函式內部,以及它們與 let 綁定在順序上的根本差異。
本集是第三章的補充。
概念說明
你可能習慣了把 struct 和 enum 定義在 fn main() 的外面,但其實把它們放在裡面也完全合法:
fn main() {
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 1, y: 2 };
println!("{}", p.x);
}
這段程式碼完全可以編譯。
限制:只在該函式內可見
放在函式內的型別定義,只有那個函式看得到。其他函式無法使用它。所以慣例上,我們還是會把型別定義放在外面——除非你確定這個型別只在一個函式裡面用到。
重要差異:項目不受順序限制
這裡有一個很多人不知道的重點。在 Rust 裡,項目(items)——包括 struct、enum、fn、trait、impl 等——不受定義順序影響。你可以先使用,後定義:
fn main() {
let p = Point { x: 1, y: 2 }; // 先使用
println!("{}", p.x);
struct Point { // 後定義
x: i32,
y: i32,
}
}
這和 let 完全不同!let 綁定必須在使用之前出現,否則編譯器會報錯。但項目定義是「全域可見」的(在它所在的作用域內),跟你寫在哪一行無關。
為什麼會這樣?
因為項目是在編譯期就確定的靜態定義,編譯器會先掃描所有項目,建立完整的型別資訊,然後才處理 let 等執行期的敘述。
範例程式碼
fn main() {
// 先呼叫,後定義——完全合法
greet();
// 先使用 struct,後定義
let color = Color::Red;
describe(color);
// 定義放在使用之後
struct Point {
x: f64,
y: f64,
}
let p = Point { x: 3.0, y: 4.0 };
println!("座標:({}, {})", p.x, p.y);
// 這些項目定義的順序完全不重要
enum Color {
Red,
Green,
Blue,
}
fn describe(c: Color) {
match c {
Color::Red => println!("紅色"),
Color::Green => println!("綠色"),
Color::Blue => println!("藍色"),
}
}
fn greet() {
println!("哈囉!");
}
// 但 let 綁定必須在使用之前!
// 以下如果取消註解會編譯失敗:
// println!("{}", not_yet);
let not_yet = 42;
println!("let 綁定必須先宣告:{}", not_yet);
}
重點整理
struct、enum、fn等項目可以合法地定義在函式內部- 定義在函式內的項目,只有該函式看得到(作用域限制)
- 一般還是把型別定義放在函式外面,除非只有單一函式使用
- 項目不受定義順序影響——可以先使用、後定義
let綁定必須在使用之前出現——這是項目和let的根本差異- 原因:項目是編譯期的靜態定義,編譯器會先掃描完所有項目再處理執行期程式碼
附錄第 f 集:struct update syntax
本集目標
學會用 .. 語法從既有的 struct 實例快速建立新實例,並理解 Copy 與 move 欄位的差異。
本集是第三章的補充。
概念說明
還記得建立 struct 的時候,每個欄位都要寫出來嗎?如果你只想改一兩個欄位,其他照舊,每次都全部寫一遍很煩。Rust 提供了 struct update syntax,用 .. 來「填入剩下的欄位」。
基本語法
let p2 = Point { x: 10, ..p1 };
意思是:p2 的 x 設為 10,其餘欄位都從 p1 複製過來。
..p1 必須放在最後面,而且前面要有逗號(如果前面有其他欄位的話)。
Copy 與 move 的差異
這裡有個重要的細節。..p1 並不是「淺複製整個 struct」,而是逐欄位處理:
- 如果欄位的型別實作了
Copy(像i32、f64、bool),就是複製 - 如果欄位的型別沒有
Copy(像String),就是 move
也就是說,如果你用 ..p1 並且移動了 p1 的某些非 Copy 欄位,那些欄位之後就不能再透過 p1 存取了。
搭配 Default
如果你的 struct 有實作 Default trait,可以用 ..Default::default() 來建立「只指定幾個欄位,其他用預設值」的實例:
let config = Config { debug: true, ..Default::default() };
這在有很多欄位的 struct 特別好用。
範例程式碼
#[derive(Debug)]
struct Config {
width: u32,
height: u32,
fullscreen: bool,
title: String,
}
impl Default for Config {
fn default() -> Self {
Config {
width: 800,
height: 600,
fullscreen: false,
title: String::from("My App"),
}
}
}
#[derive(Debug, Clone, Copy)]
struct Point {
x: f64,
y: f64,
}
fn main() {
// 基本用法:只改一個欄位
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = Point { x: 10.0, ..p1 };
println!("p1 = {:?}", p1); // p1 還能用,因為 f64 是 Copy
println!("p2 = {:?}", p2);
// 搭配 Default:只指定想改的欄位
let custom = Config {
width: 1920,
height: 1080,
..Default::default()
};
println!("自訂設定:{:?}", custom);
// 全部用預設值
let default_config = Config { ..Default::default() };
println!("預設設定:{:?}", default_config);
// 注意 move 語義!
let c1 = Config {
width: 1024,
height: 768,
fullscreen: true,
title: String::from("Game"),
};
let c2 = Config {
fullscreen: false,
..c1 // title (String) 會被 move!
};
// println!("{}", c1.title); // 編譯錯誤!title 已經被 move 了
println!("c1.width = {}", c1.width); // 但 Copy 欄位還是能用
println!("c2 = {:?}", c2);
}
重點整理
let p2 = Point { x: 1, ..p1 };用p1填入p2剩餘的欄位..source必須放在最後面- Copy 型別的欄位會被複製,非 Copy 型別的欄位會被 move
- 如果所有欄位都是 Copy,原本的 struct 還能繼續使用
- 如果有非 Copy 欄位被 move,原本 struct 的那些欄位就不能再存取
..Default::default()很適合用在「大部分欄位用預設值,只改幾個」的場景
附錄第 g 集:ref pattern 與 match ergonomics
本集目標
了解 ref 關鍵字在模式匹配中的作用,以及為什麼在現代 Rust 中幾乎不需要手動寫 ref。
本集是第三章的補充。
概念說明
這集要講一個你可能在舊程式碼裡看過、但在現代 Rust 中幾乎用不到的語法:ref。理解它的存在和原理,有助於你讀懂別人的程式碼。
ref 是什麼?
在模式中,ref 會把綁定的變數變成一個參考,而不是取得所有權:
let val = String::from("hello");
let ref r = val; // r 的型別是 &String
// 等同於:let r = &val;
你可能會想:那我直接寫 &val 不就好了?沒錯,在 let 綁定中,兩者完全等價。ref 的存在感主要在 match 裡面。
在 match 中的 ref
以前(Rust 1.26 之前),如果你想在 match 裡借用而不是 move,必須手動寫 ref:
let opt = Some(String::from("hello"));
match opt {
Some(ref s) => println!("{}", s), // 借用,不 move
None => println!("nothing"),
}
// opt 還能用,因為我們只是借用了裡面的值
如果不寫 ref,s 會拿走 String 的所有權,之後就不能再用 opt 了。
Match ergonomics(Rust 1.26+)
從 Rust 1.26 開始,編譯器變聰明了。當你 match 一個參考的時候,裡面的綁定會自動變成參考:
let opt = Some(String::from("hello"));
match &opt { // 注意這裡是 &opt
Some(s) => { // s 自動是 &String,不需要寫 ref
println!("{}", s);
}
None => println!("nothing"),
}
// opt 還能用!
這就是所謂的 match ergonomics。編譯器看到你 match 的是一個參考(&opt),就會自動幫你在模式裡加上 ref。
所以現在還需要寫 ref 嗎?
幾乎不需要了。99% 的情況你只要 match 參考(match &value),編譯器就會自動處理。但讀舊程式碼的時候,看到 ref 至少要知道它在做什麼。
範例程式碼
fn main() {
// ===== ref 基本用法 =====
let name = String::from("Rust");
let ref r = name; // r: &String
println!("ref 綁定:{}", r);
println!("原本還能用:{}", name);
// ===== 舊寫法:match 中用 ref 避免 move =====
let data = Some(String::from("重要資料"));
match data {
Some(ref s) => println!("舊寫法借用:{}", s),
None => println!("空的"),
}
println!("data 還在:{:?}", data); // 因為用了 ref,沒有 move
// ===== 新寫法:match ergonomics =====
let data2 = Some(String::from("新世界"));
match &data2 { // match 參考
Some(s) => { // s 自動是 &String
println!("新寫法借用:{}", s);
}
None => println!("空的"),
}
println!("data2 還在:{:?}", data2);
// ===== 更複雜的例子 =====
let pairs = vec![
(String::from("台北"), 25),
(String::from("東京"), 10),
(String::from("紐約"), 5),
];
// match ergonomics 讓 for 迴圈中的解構也很自然
for (city, temp) in &pairs {
// city: &String, temp: &i32(自動借用)
println!("{} 氣溫 {} 度", city, temp);
}
println!("pairs 還在,共 {} 筆", pairs.len());
}
重點整理
let ref x = val;等同於let x = &val;——在let中兩者完全一樣- 在 match 中,
Some(ref x)會借用而不是 move 內部的值 - match ergonomics(Rust 1.26+):match 一個參考時,模式中的變數自動變成參考
- 現代 Rust 幾乎不需要手動寫
ref,用match &value就好 for (k, v) in &collection也受 match ergonomics 影響,k和v自動是參考- 認識
ref主要是為了讀懂舊程式碼
附錄第 h 集:panic! / todo! / unimplemented! / unreachable!
本集目標
認識四種會讓程式立即終止的巨集,以及它們各自的使用時機。
本集是通用補充,不特定屬於哪一章。
概念說明
Rust 有四個常用的「讓程式直接掛掉」的巨集。它們都會造成 panic(程式中止),但語義不同,傳達給讀程式碼的人的訊息也不同。
panic!("訊息")
最基本的「程式出事了,直接中止」。當你遇到無法處理的錯誤時使用:
panic!("發生了不該發生的事!");
你可以帶格式化訊息:panic!("找不到 id: {}", id);
todo!()
「我還沒寫完,先放個佔位符」。開發中最常用,讓你先把程式架構搭好,細節之後再填:
fn calculate_tax(income: f64) -> f64 {
todo!() // 之後再實作
}
編譯可以通過,但執行到這裡就會 panic,訊息是「not yet implemented」。
unimplemented!()
「這個功能沒有實作」。跟 todo!() 很像,但語義不同——todo!() 明確表示「之後會做」,unimplemented!() 則不保證之後會做。可能是不打算做,可能是目前沒需求,也可能是 trait 要求的方法但對這個型別沒意義:
impl Foo for MyStruct {
fn bar(&self) -> u8 {
1 + 1
}
fn baz(&self) {
// 對 MyStruct 來說 baz 沒意義,但 trait 要求必須定義
unimplemented!()
}
}
unreachable!()
「這行程式碼不應該被執行到」。如果你確定某段邏輯不可能走到,用這個來標記:
let direction = "north";
match direction {
"north" | "south" | "east" | "west" => println!("有效方向"),
_ => unreachable!("方向只有四種,不可能走到這裡"),
}
如果真的走到了,表示你的假設有誤,panic 會幫你發現這個 bug。
四者比較
panic!— 出事了。用於無法處理的錯誤todo!— 還沒寫,之後會實作。開發中的佔位符unimplemented!— 沒有實作,不保證之後會做。可能是沒需求、可能是 trait 要求但沒意義unreachable!— 不該走到這裡。標記邏輯上不可能的分支
範例程式碼
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle(_, _, _) => todo!("三角形面積之後再實作"),
}
}
fn describe_score(score: u32) -> &'static str {
match score {
90..=100 => "優秀",
80..=89 => "良好",
70..=79 => "普通",
60..=69 => "及格",
0..=59 => "不及格",
_ => unreachable!("分數應該在 0-100 之間"),
}
}
trait Storage {
fn save(&self, data: &str);
fn load(&self) -> String;
}
struct LocalStorage;
impl Storage for LocalStorage {
fn save(&self, data: &str) {
println!("儲存到本地:{}", data);
}
fn load(&self) -> String {
// trait 要求定義,但 LocalStorage 不需要這個功能
unimplemented!()
}
}
fn main() {
// todo! — 開發中的佔位符
let circle = Shape::Circle(5.0);
println!("圓形面積:{}", area(&circle));
let rect = Shape::Rectangle(3.0, 4.0);
println!("矩形面積:{}", area(&rect));
// 如果取消下一行的註解,會 panic 並顯示 todo! 訊息
// let tri = Shape::Triangle(3.0, 4.0, 5.0);
// println!("三角形面積:{}", area(&tri));
// unreachable! — 不該走到的分支
let grade = describe_score(85);
println!("85 分的評等:{}", grade);
// unimplemented! — 沒有實作的功能
let storage = LocalStorage;
storage.save("hello");
// storage.load(); // 取消註解會 panic:not implemented
// panic! — 直接中止
// panic!("故意 panic!");
println!("程式正常結束");
}
重點整理
panic!("msg")是最基本的中止方式,用於無法處理的錯誤todo!()是開發佔位符,明確表示「之後會實作」unimplemented!()表示「沒有實作」,不保證之後會做——可能是沒需求、可能是 trait 要求但對該型別沒意義unreachable!()標記邏輯上不可能到達的程式碼路徑- 它們都會造成 panic,差別在於傳達的意圖不同——選對的那個,讓程式碼更有表達力
附錄第 i 集:Rc 迴圈與 Weak
本集目標
理解 Rc 參考迴圈會造成記憶體洩漏,並學會用 Weak 來打破迴圈。
本集是第五章的補充。
概念說明
還記得第五章學的 Rc<T> 嗎?它透過參考計數來管理記憶體——每多一個 Rc 指向同一筆資料,計數就加一;每少一個就減一;歸零時釋放記憶體。
聽起來很完美,但有一個致命弱點:參考迴圈(reference cycle)。
什麼是參考迴圈?
想像 A 持有 Rc 指向 B,B 也持有 Rc 指向 A。當我們不再需要它們的時候:
- A 的
Rc被 drop → A 的計數減一,但 B 還在指向 A → 計數不為零 → A 不釋放 - B 的
Rc被 drop → B 的計數減一,但 A 還在指向 B → 計數不為零 → B 不釋放
結果:A 和 B 永遠不會被釋放,這就是記憶體洩漏。
Weak 救場
Weak<T> 是一種「弱參考」——它指向同一筆資料,但不會增加強參考計數(strong count)。這意味著 Weak 不會阻止資料被釋放。
用法:
use std::rc::{Rc, Weak};
let strong = Rc::new(42);
let weak: Weak<i32> = Rc::downgrade(&strong);
Rc::downgrade 把 Rc 降級成 Weak。
為什麼 downgrade 不會增加 strong count?因為 Rc 內部有兩個計數器:strong count 和 weak count。clone() 增加 strong count,downgrade() 只增加 weak count。而 Rc 判斷「要不要釋放值」只看 strong count——strong count 歸零就釋放,不管 weak count 是多少。這就是 Weak 不會阻止釋放的原因。
使用 Weak 的值
因為 Weak 指向的資料可能已經被釋放了(strong count 歸零),所以你不能直接存取。必須先 upgrade():
match weak.upgrade() {
Some(rc) => println!("還在:{}", rc),
None => println!("已經被釋放了"),
}
upgrade() 回傳 Option<Rc<T>>——如果資料還在,給你一個 Rc;如果已經釋放,回傳 None。
用 Weak 打破迴圈
回到剛才 A 和 B 的例子:只要把其中一個方向改成 Weak,就能打破迴圈。通常的做法是:
- 父 → 子:用
Rc(父親擁有子女) - 子 → 父:用
Weak(子女知道父親存在,但不擁有)
這樣當外部的 Rc 都 drop 之後,strong count 能夠正常歸零,記憶體就能正確釋放。
範例程式碼
use std::rc::Rc;
fn main() {
// ===== 基本 Weak 用法 =====
let strong = Rc::new(String::from("Hello"));
println!("strong count = {}", Rc::strong_count(&strong));
let weak = Rc::downgrade(&strong);
println!("strong count = {}", Rc::strong_count(&strong)); // 還是 1
println!("weak count = {}", Rc::weak_count(&strong)); // 1
// upgrade:Weak → Option<Rc<T>>
match weak.upgrade() {
Some(rc) => println!("upgrade 成功:{}", rc),
None => println!("已被釋放"),
}
// drop 強參考
drop(strong);
// 再次 upgrade
match weak.upgrade() {
Some(rc) => println!("upgrade 成功:{}", rc),
None => println!("已被釋放——strong 沒了,Weak 也拿不到了"),
}
// ===== Weak 的生命週期 =====
let weak_ref;
{
let temporary = Rc::new(100);
weak_ref = Rc::downgrade(&temporary);
println!("scope 內 upgrade:{:?}", weak_ref.upgrade()); // Some(100)
}
// temporary 已被 drop
println!("scope 外 upgrade:{:?}", weak_ref.upgrade()); // None
}
重點整理
Rc的參考迴圈會造成記憶體洩漏——計數永遠不會歸零,記憶體永遠不會釋放Rc::downgrade(&rc)建立Weak<T>,不增加 strong countweak.upgrade()回傳Option<Rc<T>>——資料還在就給你Some(rc),被釋放了就是None- 用
Weak打破迴圈的常見模式:強方向用Rc,反向用Weak - 典型應用:樹狀結構中「父 → 子」用
Rc,「子 → 父」用Weak Rc::strong_count()和Rc::weak_count()可以查看目前的計數
附錄第 j 集:fully qualified syntax
本集目標
學會三種不同層級的方法呼叫語法,以及在 trait 方法名稱衝突時如何消歧義。
本集是第五章的補充。
概念說明
在 Rust 裡,呼叫一個方法其實有三種寫法,從簡單到完整:
第一種:方法語法
dog.speak();
最常用的寫法。編譯器會自動找到對應的方法。
第二種:指定 Trait 或型別
Animal::speak(&dog);
明確告訴編譯器「我要呼叫 Animal trait 上的 speak」。&dog 就是原本的 self。
第三種:完全限定語法(Fully Qualified Syntax)
<Dog as Animal>::speak(&dog);
最明確的寫法:「在 Dog 實作的 Animal trait 上,呼叫 speak 方法,傳入 &dog」。
什麼時候需要用到?
大部分時候第一層就夠了。但當多個 trait 定義了同名方法的時候,編譯器不知道你要呼叫哪一個,就需要更明確的語法:
trait Animal {
fn name(&self) -> &str;
}
trait Robot {
fn name(&self) -> &str;
}
如果某個型別同時實作了 Animal 和 Robot,呼叫 .name() 時編譯器會報錯。這時候就需要第二種或第三種的語法來消歧義。
關聯函式更常需要
如果是沒有 self 參數的關聯函式(associated function),因為沒有接收者可以讓編譯器推斷,更容易需要完全限定語法:
// 如果多個 trait 都有 create() 關聯函式
let x = <MyType as TraitA>::create();
範例程式碼
trait Animal {
fn speak(&self);
fn category() -> &'static str;
}
trait Robot {
fn speak(&self);
fn category() -> &'static str;
}
struct CyberDog {
name: String,
}
impl Animal for CyberDog {
fn speak(&self) {
println!("{} 汪汪叫!(動物)", self.name);
}
fn category() -> &'static str {
"哺乳類"
}
}
impl Robot for CyberDog {
fn speak(&self) {
println!("{} 嗶嗶叫!(機器人)", self.name);
}
fn category() -> &'static str {
"人工智慧"
}
}
// CyberDog 自己也有 speak
impl CyberDog {
fn speak(&self) {
println!("{} 汪嗶汪嗶!(本體)", self.name);
}
}
fn main() {
let dog = CyberDog {
name: String::from("小白"),
};
// 第一層:方法語法 — 優先呼叫型別本身的方法
dog.speak(); // "小白 汪嗶汪嗶!(本體)"
// 第二層:指定 trait
Animal::speak(&dog); // "小白 汪汪叫!(動物)"
Robot::speak(&dog); // "小白 嗶嗶叫!(機器人)"
// 第三層:完全限定語法
<CyberDog as Animal>::speak(&dog); // "小白 汪汪叫!(動物)"
<CyberDog as Robot>::speak(&dog); // "小白 嗶嗶叫!(機器人)"
// 關聯函數(沒有 self)— 更需要完全限定語法
// Animal::category(); // 編譯錯誤!編譯器不知道是哪個型別的實作
let animal_cat = <CyberDog as Animal>::category();
let robot_cat = <CyberDog as Robot>::category();
println!("動物分類:{}", animal_cat);
println!("機器人分類:{}", robot_cat);
}
重點整理
- 方法呼叫有三種層級:
obj.method()→Trait::method(&obj)→<Type as Trait>::method(&obj) - 通常用最簡單的就好,有衝突時才升級
- 當多個 trait 定義同名方法時,需要指定要呼叫哪個 trait 的版本
- 型別本身的方法優先於 trait 方法
- associated function(沒有
self)更常需要完全限定語法 - 完全限定語法的格式:
<Type as Trait>::function(args)
附錄第 k 集:DST 簡介
本集目標
理解什麼是動態大小型別(DST),以及 Sized、?Sized 在泛型中的意義。
本集是第五章的補充。
概念說明
在 Rust 的型別系統裡,大部分型別的大小在編譯期就已知——i32 是 4 bytes、bool 是 1 byte、(i32, i32) 是 8 bytes。但有些型別的大小在編譯期是未知的,這就是 DST(Dynamically Sized Types),動態大小型別。
常見的 DST
你其實已經見過它們了:
str:字串切片的「內容」型別。"hello"是 5 bytes,"哈囉"是 6 bytes,長度不固定。[T]:陣列切片的「內容」型別。[i32]可能是 3 個元素也可能是 100 個。
因為大小不固定,你不能直接把它們當作值使用:
// let s: str = "hello"; // 編譯錯誤!
// let arr: [i32] = [1, 2, 3]; // 編譯錯誤!
怎麼用?靠指標!
DST 必須藏在某種指標後面:
&str、&[T]— 參考Box<str>、Box<[T]>— 堆積上的指標
這些指標是所謂的胖指標(fat pointer)——它們不只存一個位址,還多存了一個長度資訊:
一般指標:[位址] (8 bytes)
胖指標: [位址][長度] (16 bytes)
(假設電腦是 64 位元)
所以 &str 實際上在 64 位元電腦上佔 16 bytes:8 bytes 指向字串資料,8 bytes 記錄長度。
Sized trait
Rust 有一個特殊的 trait 叫 Sized,表示「這個型別的大小在編譯期已知」。絕大多數型別都自動實作了 Sized。
而且——這是很多人不知道的——泛型參數預設有 Sized bound:
fn print_it<T>(val: T) { ... }
// 其實等同於
fn print_it<T: Sized>(val: T) { ... }
這很合理,因為如果 T 的大小未知,函式根本不知道要在 stack 上分配多少空間。
?Sized:放寬限制
有時候你希望泛型參數可以接受 DST,這時候用 ?Sized 來放寬限制:
fn print_it<T: ?Sized>(val: &T) { ... }
// ^^^^^^^ 注意:必須透過參考
?Sized 的意思是「T 可以是 Sized,也可以不是」。但因為大小可能未知,你只能透過參考或指標來使用 T。
回頭看第五章的 Cow
第五章最後一集教 Cow 的時候,我們用的是簡化版的定義:
// 第五章提供的簡化版
pub enum Cow<'a, B>
where
B: 'a + ToOwned,
{
Borrowed(&'a B),
Owned(B::Owned),
}
如果你試過要把 str 或 [T] 放進 Cow——例如寫 Cow<str>——你會發現編譯不過。因為泛型參數 B 預設要求 Sized,而 str 不是 Sized。
加上 ?Sized 就能解決:
pub enum Cow<'a, B>
where
B: 'a + ToOwned + ?Sized,
{
Borrowed(&'a B),
Owned(B::Owned),
}
Borrowed(&'a B) 裡的 B 已經在參考後面,所以即使 B 是 DST 也沒問題——胖指標會幫你搞定。
&mut [T] 與 &mut str
DST 也可以拿可變參考。&mut [T] 很實用——你可以修改切片裡的元素:
let mut arr = [1, 2, 3, 4, 5];
let slice: &mut [i32] = &mut arr[1..4];
slice[0] = 99; // arr 變成 [1, 99, 3, 4, 5]
但 &mut str 就很沒用了。雖然語法上合法,但你幾乎做不了什麼。原因是 UTF-8 編碼裡,一個字元可能佔 1~4 bytes:
'a'→ 1 byte'é'→ 2 bytes'哈'→ 3 bytes
假設你有 "哈囉"(6 bytes),想把 '哈' 改成 'a'——'a' 只有 1 byte,但 '哈' 佔了 3 bytes,你沒辦法就地替換,因為長度不同。如果硬改了第一個 byte 卻沒處理後面的,UTF-8 的多 byte 序列就斷了。而 Rust 的 str 保證內容一定是合法的 UTF-8,破壞這個保證會導致未定義行為。
所以標準庫裡 &mut str 上的方法少得可憐,基本上只有 make_ascii_uppercase() 和 make_ascii_lowercase() 這類「不會改變 byte 長度」的操作(ASCII 字母的大小寫轉換剛好是 1 byte 對 1 byte)。要修改字串,還是用 String 吧。
範例程式碼
use std::fmt::Display;
// 預設:T 必須是 Sized
fn print_sized<T: Display>(val: T) {
println!("Sized 值:{}", val);
}
// 放寬:T 可以是 DST,但必須透過參考
fn print_unsized<T: Display + ?Sized>(val: &T) {
println!("可能是 DST:{}", val);
}
// 展示 64 位元電腦上的胖指標大小
fn show_pointer_sizes() {
use std::mem::size_of;
println!("--- 指標大小比較 ---");
println!("&i32 = {} bytes", size_of::<&i32>()); // 8
println!("&[i32] = {} bytes", size_of::<&[i32]>()); // 16(胖指標)
println!("&str = {} bytes", size_of::<&str>()); // 16(胖指標)
println!("Box<i32> = {} bytes", size_of::<Box<i32>>()); // 8
println!("Box<str> = {} bytes", size_of::<Box<str>>()); // 16(胖指標)
}
fn main() {
// Sized 值:一般型別
print_sized(42);
print_sized(String::from("hello"));
// ?Sized:可以接受 &str(str 是 DST)
print_unsized("hello"); // T = str(DST)
print_unsized(&42); // T = i32(Sized,也可以)
print_unsized(&String::from("world")); // T = String(Sized)
// &str 和 &[T] 是胖指標
show_pointer_sizes();
// str 和 [T] 不能直接當值用
// let s: str = *"hello"; // 編譯錯誤!
// let a: [i32] = *&[1,2,3]; // 編譯錯誤!
// 但透過參考就沒問題
let s: &str = "hello";
let a: &[i32] = &[1, 2, 3];
println!("\n&str = {}", s);
println!("&[i32] 長度 = {}", a.len());
// Box<str> 也可以
let boxed: Box<str> = String::from("boxed string").into_boxed_str();
println!("Box<str> = {}", boxed);
}
重點整理
- DST(Dynamically Sized Types):大小在編譯期未知的型別,如
str、[T] - DST 不能直接當值使用,必須透過指標:
&str、&[T]、Box<str>等 - 指向 DST 的指標是胖指標(fat pointer):位址 + 長度,在 64 位元電腦上佔 16 bytes
Sized:表示型別大小在編譯期已知;泛型參數預設有T: Sizedbound?Sized:放寬限制,讓泛型參數可以接受 DST(但必須透過參考使用)Cow<'a, B>中的B: ?Sized就是為了讓B可以是str等 DST