第一章:基礎

第一章第 1 集:安裝 Rust

本集目標

把 Rust 裝到你的電腦上,確認它可以用。

正文

哈囉!歡迎來到 Rust 教學系列!

今天是第一集,我們什麼程式都還不寫,先把工具裝好就好。就像你要煮菜,總得先有鍋子對吧?

安裝 rustup

Rust 有一個官方的安裝工具叫 rustup,它會幫你把所有需要的東西一次裝好。

打開你的瀏覽器,去這個網址:

https://rustup.rs
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 的編譯器,它負責把你寫的程式碼變成電腦能執行的東西。至於什麼是編譯器,之後我們慢慢聊,現在你只要知道「裝好了、能用了」就夠了。

重點整理


第一章第 2 集:第一個程式

本集目標

用 Cargo 建立專案,跑出你人生中第一個 Rust 程式。

正文

上一集我們裝好了 Rust,今天就來寫第一個程式吧!

用 Cargo 建立專案

Rust 有一個超好用的工具叫 Cargo,它是 Rust 的專案管理工具。你可以把它想成一個管家,幫你整理程式碼、編譯、執行,全部包辦。

打開終端機,輸入:

cargo new hello

這會幫你建立一個叫 hello 的資料夾,裡面已經幫你準備好了基本的檔案結構。

用 VS Code 打開

接著用 VS Code(或你喜歡的編輯器)打開這個 hello 資料夾。你會看到兩個重要的東西:

  1. Cargo.toml — 這是專案的設定檔,記錄你的專案叫什麼名字、用什麼版本之類的。現在不用管它,知道有這個檔案就好。

  2. 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!

看到了嗎?你改了什麼,它就印什麼。程式設計就是這麼回事——你告訴電腦要做什麼,它就照做。

重點整理


第一章第 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,但一定要賦值剛好一次,沒有賦值就使用會發生編譯錯誤。

重點整理


第一章第 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?

好問題!因為 ab 都是整數,所以 Rust 做的是整數除法,小數點後面直接砍掉。10 除以 3 等於 3.333...,砍掉小數就是 3。

% 是什麼?

% 叫做取餘數(模數運算)。10 除以 3 等於 3 餘 1,所以 10 % 3 就是 1

你可以想成:「10 裡面有幾個 3?有 3 個,然後剩下 1。」那個剩下的就是餘數。

多個 {} 的用法

你有注意到嗎?我們在 println! 裡面放了三個 {}

println!("{} + {} = {}", a, b, a + b);

Rust 會按照順序把值填進去:

幾個 {} 就對應後面幾個值,順序要對上。

重點整理


第一章第 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));
}

重點整理


第一章第 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

重點整理


第一章第 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,所以大括號裡的程式碼就被跳過了。

重點整理


第一章第 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 裡面一個很統一的規則。

重點整理


第一章第 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 想成:

如果條件成立,就做這個;否則,就做那個。

一定會走其中一邊,不會兩邊都走,也不會兩邊都不走。

重點整理


第一章第 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 會從上到下,一個一個條件去看:

  1. score >= 90?85 >= 90?不是,跳過。
  2. score >= 80?85 >= 80?是!印 "B",然後結束。
  3. 後面的都不看了。

這很重要:一旦某個條件成立,後面的都會被跳過

試試其他分數

結構

if 條件1 {
    ...
} else if 條件2 {
    ...
} else if 條件3 {
    ...
} else {
    ...(以上都不成立時)
}

你可以放任意多個 else if,最後的 else 是選擇性的(但通常建議加上去,以防漏掉什麼情況)。

重點整理


第一章第 12 集:邏輯運算子

本集目標

學會用 &&(而且)、||(或者)、!(不是)來組合多個條件。

正文

上幾集我們學了 if,但條件都很簡單——只有一個。現實中常常需要同時考慮好幾個條件,比如「年滿 18 歲而且是學生」。這就需要邏輯運算子。

&& —— 而且(AND)

兩個條件都要成立,結果才是 true

fn main() {
    let age = 24;
    let is_student = true;

    if age >= 18 && is_student {
        println!("是個成年學生");
    }
}

印出 是個成年學生。因為 24 >= 18 是 trueis_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_weekendfalse,但 is_holidaytrue,只要有一個是 true 就夠了。

! —— 不是(NOT)

true 變成 false,把 false 變成 true

fn main() {
    let raining = false;

    if !raining {
        println!("出門走走吧!");
    }
}

印出 出門走走吧!rainingfalse,加上 ! 之後就變成 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 的一個設計哲學:有意識地做出選擇

重點整理


第一章第 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 宣告的,因為你正在改變它的值。

重點整理


第一章第 15 集:stdin

本集目標

讓程式讀取使用者的鍵盤輸入——先照抄,不用完全理解每一行。

正文

到目前為止,我們程式裡的值都是寫死的。但如果想讓使用者自己輸入呢?比如讓使用者輸入名字,然後程式跟他打招呼?

先照抄這段程式碼

fn main() {
    println!("請輸入你的名字:");

    let mut input = String::new();
    std::io::stdin().read_line(&mut input).expect("讀取失敗");

    println!("你好,{}!", input.trim());
}

跑起來的效果:

請輸入你的名字:
Andy
你好,Andy!

這段在幹嘛?

我知道這段看起來有點嚇人,但別擔心,我們先把它當成一個黑盒子——你只要知道它能讀取使用者輸入就好。

大概的意思是:

  1. let mut input = String::new(); → 建立一個空的文字變數,準備接收輸入
  2. std::io::stdin().read_line(&mut input).expect("讀取失敗"); → 從鍵盤讀一行文字,存到 input
  3. input.trim() → 把多餘的空白和換行符號去掉

至於 String::new()&mut.expect() 這些是什麼意思?以後會慢慢教,現在先照抄就好。

為什麼先不解釋?

因為要解釋這段程式碼,需要先理解好幾個還沒學的觀念。與其硬塞一堆看不懂的解釋,不如先學會用,之後自然就懂了。

就像小時候你學騎腳踏車,不用先學力學和陀螺效應——先騎就對了。

重要的事

每次要讀使用者輸入,就把這三行拿去用:

let mut input = String::new();
std::io::stdin().read_line(&mut input).expect("讀取失敗");
let name = input.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("請輸入數字");

拆開來看:

  1. input.trim() → 去掉頭尾的空白和換行
  2. .parse::<i32>() → 把文字解析成整數(i32 就是一種整數型別)
  3. .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

現在你可以讀取數字並拿來運算了!

重點整理


第一章第 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

回顧一下用了哪些技巧

  1. println! → 印提示訊息(第一章第 2 集)
  2. let mut + String::new() → 準備接收輸入(第一章第 15 集)
  3. stdin().read_line() → 讀取鍵盤輸入(第一章第 15 集)
  4. .trim().parse::\<i32\>() → 文字轉數字(第一章第 16 集)
  5. if / else if / else → 條件判斷(第 8、10、11 集)

看到了嗎?你已經學會了這麼多東西,而且它們組合起來就能做出一個有互動性的小程式。這就是程式設計的魅力——把小塊的知識拼在一起,就能做出有用的東西。

挑戰

如果你想多練習,試試看:

重點整理


第一章第 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
發射!

它是怎麼運作的?

  1. count 從 5 開始
  2. 進入 loop,先檢查 count == 0 嗎?不是,印出 5,然後 count -= 1,count 變成 4
  3. 再回到 loop 的開頭,檢查 count == 0 嗎?不是,印出 4,count 變成 3
  4. ……一直重複……
  5. count 變成 0,if count == 0 成立,印出「發射!」,然後 break 跳出迴圈
  6. 程式結束

重點整理


第一章第 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 把「條件判斷」和「迴圈」合在一起了。不需要自己寫 ifbreak,只要告訴 while:「只要這個條件成立,就繼續跑。」

while 的白話文

條件成立的時候,就一直做大括號裡的事。

while count > 0 → 只要 count 大於 0,就繼續跑。count 一旦變成 0,條件不成立了,就自動停下來。

什麼時候用 loop,什麼時候用 while?

兩種都可以達成目的,只是 while 在很多情況下寫起來更簡潔。

重點整理


第一章第 20 集:for + range

本集目標

for 迴圈搭配範圍(range)來重複執行,不用自己處理計數器。

正文

前兩集我們學了 loopwhile,都需要自己手動管理計數器(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 會自動幫你宣告它。而且 ifor 後面的大括號 {} 裡面都可以使用。

要包含結尾呢?用 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,其實 whilefor 也能用。要注意的是,break 只會跳出迴圈,不會跳出 if 之類的控制結構。所以下面這段程式碼裡,break 是跳出 for 迴圈,不是跳出 if

fn main() {
    for i in 0..10 {
        if i == 5 {
            println!("找到 5 了,不找了!");
            break;
        }
        println!("{}", i);
    }
}

跑起來只會印 0~4,遇到 5 就 break 跳出迴圈了。

重點整理


第一章第 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

它是怎麼運作的?

  1. 外面的迴圈 i 從 1 跑到 9
  2. i = 1 時,裡面的迴圈 j 從 1 跑到 9 → 印出 1×1, 1×2, ... 1×9
  3. 裡面的迴圈跑完後,println!() 換行
  4. 外面的迴圈走到 i = 2,裡面的迴圈又從 1 跑到 9 → 印出 2×1, 2×2, ... 2×9
  5. 以此類推……

這裡用了一個新東西: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 的那個迴圈」。注意標籤名稱前面要加 '(單引號)。

重點整理


第一章第 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:

break vs 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=2j=3 都不會印。

重點整理


第一章第 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

那之前為什麼不用標?

因為 Rust 很聰明!它會看你給的值,自動推斷型別:

let x = 5;       // Rust 自動判斷:這是 i32
let y = 3.14;    // Rust 自動判斷:這是 f64
let z = true;    // Rust 自動判斷:這是 bool

這叫做型別推斷(type inference)。大部分的時候 Rust 都能自己搞定,你不用特別標。

重點整理


第一章第 24 集:型別(數字詳解)

本集目標

認識 Rust 所有的數字型別,以及數字後綴的用法。

正文

上一集我們簡單認識了 i32f64。今天來把 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 位元

日常用 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 不會自動幫你轉型別。

重點整理


第一章第 25 集:char

本集目標

認識 char 型別——用來存放「一個字元」的型別。

正文

之前我們用過字串(用雙引號 " 包起來的文字),今天來認識一個更小的單位——字元(char)。

char 是什麼?

char 就是一個字元。注意,是「一個」,不是一串。

fn main() {
    let c = 'A';
    let c2 = '你';
    let c3 = '🦀';

    println!("{}", c);
    println!("{}", c2);
    println!("{}", c3);
}
A
你
🦀

單引號 vs 雙引號

這很重要:

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

重點整理


第一章第 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
\\ 反斜線 \
\" 雙引號 "
\' 單引號 '

重點整理


第一章第 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

如果 conditiontruex 就是 1;如果是 falsex 就是 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" };

這有什麼好處?

  1. 程式碼更簡潔
  2. x 只需要宣告一次
  3. Rust 的設計哲學:很多東西都可以是「表達式」,都能回傳值

重點整理

恭喜你完成了第一章!🎉 你已經學會了 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;

重點整理


第二章第 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_strinput_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改舊變數的值。在作用域裡這個差別特別明顯。

重點整理


第二章第 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 _ 的差別

大部分情況下用哪個都行。

重點整理


第二章第 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.0t.1t.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 的型別。

重點整理


第二章第 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

重點整理


第二章第 6 集:簡單函數

本集目標

fn 定義自己的函數,並在 main 裡呼叫它。

正文

到目前為止,我們幾乎所有的程式碼都寫在 main 裡面。但如果程式越來越大,全部擠在一起就很亂。這時候我們可以把一段程式碼「包裝」成一個函數(function),想用的時候呼叫它就好。

定義一個函數

fn greet() {
    println!("你好!歡迎來到 Rust 的世界!");
}

fn main() {
    greet();
}

結果:

你好!歡迎來到 Rust 的世界!

拆解一下語法:

然後在 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!");
}

重點整理


第二章第 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

語法拆解:

呼叫的時候,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

重點整理


第二章第 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

重點來了:

  1. -> i32 寫在參數後面,告訴 Rust「這個函數會回傳一個 i32」
  2. 函數最後一行 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

重點整理


第二章第 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 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 社群的慣例是:

實用場景:提前擋掉不合法的輸入

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 留給「提前離開」的場景就好。

重點整理


第二章第 10 集:遞迴

本集目標

讓函數呼叫自己來解決問題,這個技巧叫做「遞迴」。

正文

你有沒有想過:函數可以在自己裡面呼叫自己嗎?

答案是可以的,而且這個技巧叫做遞迴(recursion)。聽起來很玄,但其實概念很簡單。

經典範例:階乘

「5 的階乘」寫成 5!,意思是 5 × 4 × 3 × 2 × 1 = 120

用遞迴的思路想:

看到了嗎?每一步都是「自己乘以比自己小一號的階乘」,最後到 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 迴圈

其實上面的例子都可以用迴圈寫。那什麼時候用遞迴?什麼時候用迴圈?

現在先知道遞迴怎麼寫就好,之後遇到適合的場景自然會用到。

重點整理


第二章第 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);
}

重點整理


第二章第 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);
    }
}

走訪陣列的時候,forwhile 好太多了——更短、更安全、不用自己管索引。

重點整理


第二章第 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 之前為止。」

所以結果是 [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);
}

切片是「視窗」,不是「複製」

這裡有個重要的觀念:切片不是把資料複製一份出來,而是「指向原本陣列的某一段」。就像透過窗戶看房間裡的東西——東西還是在房間裡,你只是從窗戶看進去。

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): i32f64boolchar 等等,每個值就是一個單獨的東西。

複合型別(compound types): 把其他型別組合在一起的型別。我們已經學了三種:

複合型別裡面的型別可以是任何型別,包含其他複合型別:

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);
}

重點整理


第二章第 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 個元素 任何長度都行
換長度要重寫函數 一個函數通吃

在實務中,幾乎所有接受陣列的函數都用切片當參數。

重點整理


第二章第 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 型別,所以直接傳進去就行。

重點整理

恭喜你完成了第二章!🎉 這一章我們學到了更多組織程式的方法——函數、陣列、切片,還有各種讓程式碼更清晰的技巧。下一章我們將開始自訂型別,用 struct 和 enum 來描述你自己的資料!


第三章:Struct、Enum 與 Pattern Matching

第三章第 1 集:struct(named fields)

本集目標

學會用 struct 把多個相關的值組合在一起,形成一個自訂型別。

概念說明

到目前為止,我們用過的型別都是 Rust 內建的:i32f64boolchar,還有 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

重點整理


第三章第 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 被建立了!(它不帶任何資料)");
}

重點整理


第三章第 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 之後,就能根據方向做不同的事)");
}

重點整理


第三章第 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!("這個交通燈有點奇怪..."),
    }
}

重點整理


第三章第 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);
}

重點整理


第三章第 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);
}

重點整理


第三章第 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)
}

重點整理


第三章第 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,                     // 不帶資料也行
}

重點整理


第三章第 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),
}

重點整理


第三章第 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),
    }
}

語法完全一樣——型別名 { 欄位名: 變數名 }

重點整理


第三章第 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);
        }
    }
}

重點整理


第三章第 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 會從上到下比對:

你可以在模式裡混用「固定的值」和「變數」。固定的值用來比對,變數用來接住資料。

範例程式碼

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 直接寫 (...)

重點整理


第三章第 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]);
}

重點整理


第三章第 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 取出 idShape,內層再解構 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),
    }
}

重點整理


第三章第 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),
    }
}

重點整理


第三章第 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!("空的"),
    }
}

重點整理


第三章第 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(包含)
}

注意:兩種 .. 不要搞混!

上一集的 .. 和這一集的 .. 長得一模一樣,但意義完全不同:

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),
    }
}

重點整理


第三章第 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!("其他"),
    }
}

重點整理


第三章第 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」。注意 | 的部分要用括號包起來。

重點整理


第三章第 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),
    }
}

重點整理


第三章第 21 集:let 解構 tuple

本集目標

學會用 let 直接把 tuple 的值拆開,分別賦值給不同的變數。

概念說明

之前我們學了在 match 裡解構 tuple,像是 (x, y) => ...。但其實不用 match,用 let 就可以直接解構

let (x, y) = (1, 2);

這一行做了兩件事:

  1. 建立一個 tuple (1, 2)
  2. 把第一個值取出來叫 x,第二個值取出來叫 y

這比先建立 tuple 再用 .0.1 取值要方便得多。

之前在第二章學 tuple 時,都是用 t.0t.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

之後學到的綁定變數也一樣。都是同一件事——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)
    }
}

重點整理


第三章第 22 集:let 解構 struct

本集目標

學會用 let 直接把 struct 的欄位拆開,分別賦值給變數。

概念說明

上一集學了 let 解構 tuple,現在來解構 struct。概念完全一樣——用 let 把 struct 的欄位一次拆開:

let Point { x, y } = p;

這一行會把 p.x 的值放進變數 xp.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);
}

重點整理


第三章第 23 集:函數參數解構

本集目標

學會在函數的參數位置直接解構 tuple 或 struct。

概念說明

我們已經學了在 letmatch 裡解構。其實函數的參數也可以解構

假設你有一個函數,接收一個 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 一定有 xy——沒有其他可能。

但 enum 不一樣。一個 Color 可能是 RedGreen、或 Blue。如果你寫 let Color::Red = c,但 c 其實是 Green 呢?這就失敗了。Rust 不允許 let 裡出現可能失敗的模式。

這種一定會成功的模式叫做 irrefutable pattern(不可反駁的模式),可能失敗的叫做 refutable pattern(可反駁的模式)。let 和函數參數只接受 irrefutable pattern。

想處理可能失敗的模式?下一集會教 if let

重點整理


第三章第 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!("不是圓形");
    }
}

重點整理


第三章第 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
    }
}

重點整理


第三章第 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⋯⋯但它們已經不在作用域了!
}

rgb 只活在 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);
}

意思是:

  1. 嘗試用 pattern 匹配 color
  2. 如果成功,rgb 被綁定,程式繼續往下
  3. 如果失敗,執行 else 裡面的程式碼

else 裡面必須離開

else 區塊不能只是「做點事然後繼續」——它必須讓程式離開當前的流程。合法的寫法包括:

為什麼?因為如果 pattern 不匹配,變數就沒有被綁定。如果 else 之後程式繼續往下跑,那些變數就是未定義的——Rust 不允許這種事。

和 if let 的比較

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);
    }
}

重點整理


第三章第 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!("藍"),
    }
}

重點整理


第三章第 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 的差別:

範例程式碼

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);
}

重點整理


第三章第 29 集:大寫 Self

本集目標

學會用大寫 Self 作為「目前正在 impl 的型別」的別名,讓程式碼更簡潔。

概念說明

上一集我們在 impl 裡面寫了這樣的程式碼:

impl Point {
    fn new(x: i32, y: i32) -> Point {
        Point { x, y }
    }
}

注意到 Point 這個名字出現了三次:impl Point-> PointPoint { x, y }。如果型別名很長(例如 Rectangle),一直重複寫就很囉嗦。

Rust 提供了大寫 Self(注意 S 是大寫的!),它在 impl 區塊裡面代表「目前正在 impl 的型別」。所以上面的程式碼可以改成:

impl Point {
    fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }
}

Self 就是 Point 的別名。這樣寫有兩個好處:

  1. 更簡潔,尤其是型別名很長的時候
  2. 如果之後改了型別名,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);
}

重點整理

恭喜你完成了第三章!🎉 這一章你學會了 struct、enum、pattern matching(match、if let、while let)、解構、associated function 和 method。你現在已經能用 Rust 的型別系統來組織資料和行為了。下一章我們要進入 Rust 最核心也最獨特的概念——所有權(ownership)!


第四章:所有權與借用

第四章第 1 集:所有權(鑰匙圈比喻)

本集目標

用生活化的鑰匙圈比喻,理解 Rust 最核心的概念——所有權。

概念說明

今天我們不寫程式,先來聊一個 Rust 最重要的概念:所有權(ownership)

鑰匙圈比喻

想像你有一個鑰匙圈。鑰匙圈上可能掛著一些小裝飾(很輕、隨身帶著),也可能掛著一把鑰匙,這把鑰匙可以打開一個保險箱。規則很簡單:

每個鑰匙圈只能在一個人手上。

這就是 Rust 的所有權規則。你手上拿著鑰匙圈,上面的裝飾和鑰匙都是你的。

移轉(move)= 交出去就沒了

如果有人跟你說:「把你的鑰匙圈給我。」你把整個鑰匙圈交給對方之後,你手上就什麼都沒了。你不能再用那把鑰匙去開保險箱,因為鑰匙已經不在你手上了。

在 Rust 裡面,這叫做 move(移轉)。當你把一個值交給別人(例如賦值給另一個變數),原本的變數就不能再用了。

為什麼不能複製鑰匙?

你可能會想:「那我去複製一把鑰匙不就好了?」

問題在這裡:如果兩個人各拿一把鑰匙,都可以打開同一個保險箱,那就可能出事了——

這就是所謂的「資料競爭(data race)」。Rust 的所有權規則就是為了從根本上防止這種問題

Clone = 買一個新的保險箱

那如果我真的需要兩份一樣的資料怎麼辦?

答案是:不要複製鑰匙,而是買一個新的保險箱,把裡面的東西複製一份放進去,然後配一把新的鑰匙。

這樣兩個人各有自己的保險箱、自己的鑰匙,互不干擾。

在 Rust 裡面,這叫做 Clone(克隆)。它會完整複製一份資料,產生一個全新的、獨立的副本。

為什麼 Rust 要這麼嚴格?

大部分的程式語言不管這些,讓你隨便複製、隨便共用,然後等出了 bug 再說。Rust 不一樣——它在你寫程式的時候就幫你把關,確保不會有兩個人同時亂動同一份資料。

這就是 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);
}

重點整理


第四章第 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);
}

重點整理


第四章第 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:

另外,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(),未來要改也不會影響其他程式碼。

重點整理


第四章第 5 集:借用 &

本集目標

學會用 & 借用值,不需要 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 還在!

注意兩個地方:

  1. 函數參數的型別寫 &Point(前面加 &
  2. 呼叫時傳 &p1(也加 &

這樣函數只是「借」了 p1 來看,用完就還回去,p1 的所有權完全沒有改變。

之前的 & 原來是借用!

還記得之前學的 &[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); // 可以用很多次
}

重點整理


第四章第 6 集:可變借用 &mut

本集目標

學會用 &mut 借用值並修改它,不需要 move 就能改變別人的資料。

概念說明

上一集的限制

上一集我們學了 & 借用,但借用是唯讀的——你只能看,不能改。如果我們想借別人的東西來修改呢?

&mut 就是「借來改」

let mut x = 10;
let r = &mut x; // 可變借用
*r = 20;        // 透過 r 修改 x 的值

幾個重點:

  1. 原本的變數必須是 let mut(因為你要改它)
  2. 借用時寫 &mut x
  3. 要透過借用去修改值,要寫 *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);
}

重點整理


第四章第 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);
}

重點整理


第四章第 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 可變借用自己 呼叫後原本的變數還能用,而且可以改

怎麼選?

大部分的 method 都用 &self,因為你通常只是想「看看這個東西的狀態」,不需要消耗它。

函數參數也一樣

不只是 method 的 self,一般函數參數也是同樣的邏輯:

參數型別 意思
p: Point 拿走所有權(move)
p: &Point 唯讀借用
p: &mut Point 可變借用

選擇的原則一樣:

範例程式碼

#[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(),但沒有必要。

重點整理


第四章第 9 集:stack 與 heap

本集目標

理解 stack 和 heap 的差別,並揭秘第 1 集鑰匙圈比喻的真正含義。

概念說明

記憶體的兩個區域

程式執行的時候,資料會被放在記憶體裡。記憶體有兩個主要的區域:

堆疊(Stack)

堆積(Heap)

鑰匙圈比喻揭秘!

還記得第 1 集的鑰匙圈比喻嗎?現在來揭秘它真正的意思:

所以當我們說「move 是把鑰匙圈交出去」:

為什麼整數是 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 和借用的重要性就更明顯了!
}

重點整理


第四章第 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);
}

重點整理


第四章第 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 都有實作。它會把字串拆成一個一個字元讓你走訪。

這樣做的好處是:

  1. &str(字串字面值)可以用
  2. &String 也可以用(自動轉換)
  3. 不會 move 任何東西

這就是為什麼 Rust 社群普遍建議:函數參數用 &str 而不是 &String

什麼時候用 String?

範例程式碼

// 參數用 &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
}

重點整理


第四章第 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);
}

重點整理


第四章第 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 會自動轉成 &stri32&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);
}

重點整理

恭喜你完成了第四章!🎉 這一章你學會了 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」。然後參數 ab 的型別都是 T,回傳值也是 T

當你呼叫 first(10, 20) 的時候,Rust 看到 10i32,就知道 T = i32。呼叫 first(3.14, 2.71) 的時候,T = f64。同一個函數定義,自動適用於不同型別。

命名慣例

型別參數通常用單個大寫字母:T(Type)、UV。如果有語意的話也會用比較長的名字,但目前用 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);
}

重點整理


第五章第 2 集:泛型 struct

本集目標

學會定義帶型別參數的 struct,讓同一個結構可以存放不同型別的資料。

概念說明

上一集我們學了泛型函數。其實 struct 也可以有型別參數!

回想一下,Vec<i32>Vec<String> 就是同一個 struct 定義,只是裡面放的型別不同。我們也可以自己定義這樣的泛型 struct。

定義泛型 Struct

struct Pair<T> {
    first: T,
    second: T,
}

這裡的 <T> 寫在 struct 名稱後面,表示「Pair 有一個型別參數 T」。firstsecond 的型別都是 T,所以它們必須是同一種型別。

使用的時候:

let p = Pair { first: 1, second: 2 };       // T = i32
let q = Pair { first: "hi", second: "yo" }; // T = &str

多個型別參數

如果你希望 firstsecond 可以是不同型別,就用兩個型別參數:

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);
}

重點整理


第五章第 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);
}

重點整理


第五章第 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 就是「手動填入泛型定義裡角括號的型別參數」:

函數、方法、型別都可以用 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);
}

重點整理


第五章第 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);
}

重點整理


第五章第 6 集:型別別名

本集目標

學會用 type 為型別建立別名,讓複雜的泛型型別變得更好讀。

概念說明

隨著我們學了泛型,型別會越來越複雜。比如一個三維的資料結構:

Vec<Vec<Vec<i32>>>

每次都寫完整型別有點累,而且不好讀。Rust 提供了 type 關鍵字來建立型別別名

type Grid3D = Vec<Vec<Vec<i32>>>;

從此以後,Grid3DVec<Vec<Vec<i32>>> 就是同一個型別——只是換了個名字。它不會建立新型別,就只是一個簡寫。

簡單的別名

type Name = String;

NameString 完全等價,可以互換使用。

帶參數的型別別名

型別別名也可以帶泛型參數:

type Pair<T> = (T, T);

這樣 Pair<i32> 就等於 (i32, i32)Pair<String> 就等於 (String, String)

注意

型別別名只是簡寫,不是新型別。NameString 完全可以互換使用,編譯器視它們為同一個型別。

範例程式碼

// 簡單的型別別名
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);
}

重點整理


第五章第 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 出現在不同位置,它們的角色不一樣:

  1. impl<T><T>宣告一個型別參數 T。告訴 Rust「接下來我要用一個叫 T 的型別參數」
  2. Pair<T><T>使用剛才宣告的 T。告訴 Rust「我要幫的是 Pair<T> 這個型別」

換句話說:impl<T> 宣告 T,然後把 T 傳給 Pair<T>——「對於任何型別 T,幫 Pair<T> 實作以下方法」。

如果你只寫 impl Pair<T> 而不加 impl<T>,Rust 會以為 T 是一個具體的型別名稱(就像 i32String 一樣),然後找不到叫 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());
}

重點整理


第五章第 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>?沒錯!概念完全一樣:

強制處理 None

Option 的厲害之處在於:編譯器強制你處理「沒有值」的情況。你不能直接把 Option<i32> 當成 i32 來用,必須先檢查它到底是 Some 還是 None

這就是用 match 的時候了:

match maybe_value {
    Some(v) => println!("有值:{}", v),
    None => println!("沒有值"),
}

Option 不用寫完整路徑

因為 OptionSomeNone 實在太常用了,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!("沒有偶數"),
    }
}

重點整理


第五章第 9 集:Option 常用方法

本集目標

學會 Option 的常用方法:unwrapexpectunwrap_orflatten,以及用 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);
}

重點整理


第五章第 10 集:Result

本集目標

學會使用 Result<T, E> 處理可能失敗的操作,理解它和 Option 的對稱關係。

概念說明

上兩集學了 Option<T>——「可能有值,可能沒有」。但有時候,「沒有值」不夠——你還需要知道為什麼沒有。

比如解析數字,失敗時你想知道是「格式錯誤」還是「數字太大」。這就是 Result<T, E> 的用途。

Result 的定義

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option 一樣,ResultOkErr 也是 Rust 預設就引入到每個檔案裡的。

Result 和 Option 的對稱

Option Result
Some(T) Ok(T)
None Err(E)

Option 只知道「有或沒有」,Result 還知道「為什麼沒有」。

回顧第一章的黑盒子

還記得第一章的 .expect("讀取失敗").parse::<i32>().expect("請輸入數字") 嗎?

.parse() 回傳的就是 Resultexpect 的行為和 Option 的 expect 一模一樣——成功就取出 Ok 的值,失敗就 panic 並印出你的訊息。

現在我們終於能完整理解第一章的那段「黑盒子」程式碼了。

常用方法

和 Option 一樣,Result 也有:

範例程式碼

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);
}

重點整理


第五章第 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 後面,做的事情就是:

所以 ? 就是 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(())
}

重點整理


第五章第 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());
}

重點整理


第五章第 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
}

重點整理


第五章第 14 集:use 基礎

本集目標

學會用 use 把長路徑縮短,並理解為什麼之前不用 use 就能用 OptionVec 等型別。

概念說明

你可能已經注意到,我們一直在用 VecStringOptionResult 這些型別,從來沒有寫過完整路徑。但 Rust 的標準庫其實有很深的模組結構,這些型別的完整路徑像是 std::vec::Vecstd::string::Stringstd::option::Option

為什麼不用寫完整路徑?因為 Rust 有一個叫 prelude 的機制——Rust 預設就把最常用的型別和 trait 引入到每個檔案裡。VecStringOptionResultSomeNoneOkErr,還有 CloneCopy 等常用 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 裡——VecStringOptionClone 等等。從下一集開始,我們會用到不在 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>());
}

重點整理


第五章第 15 集:Display trait

本集目標

學會為自訂型別實作 Display trait,理解 Display 和 Debug 的差別,以及 Display 和 ToString 的關係。

概念說明

第二章我們學了 {:?} 來印出 tuple、陣列和加了 #[derive(Debug)] 的 struct。但 {:?} 是給開發者看的「debug 格式」。如果你想用 {} 來印出自訂型別,就需要實作 Display trait。

Display vs Debug

為什麼要分開?因為開發者需要看到所有欄位、型別資訊(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());
}

重點整理


第五章第 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: CloneU: 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");
}

重點整理


第五章第 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);
}

ab 可以是不同的型別——只要它們都實作了 Display。比如 a 可以是 i32b 可以是 String

如果你要求 ab 必須是同一個型別,就要用具名的型別參數:

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
}

重點整理


第五章第 18 集:多參數 trait

本集目標

學會定義帶型別參數的 trait,讓同一個型別可以針對不同目標型別實作同一個 trait。

概念說明

到目前為止,我們的 trait 都比較簡單——CloneDisplayDescribe,沒有型別參數。但有時候你想定義的行為和另一個型別有關。

比如「轉換」這件事: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 的差別

範例程式碼

// 定義一個帶型別參數的 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);
}

重點整理


第五章第 19 集:From / Into

本集目標

學會使用標準庫的 FromInto trait 做型別轉換,理解「實作 From 就自動獲得 Into」的機制。

概念說明

上一集我們自己定義了 Convert<T> trait。其實 Rust 標準庫已經有一套更完整的轉換機制:FromInto

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 可能會溢位。這時候用 TryFromTryInto,它們回傳 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),
    }
}

重點整理


第五章第 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

重點整理


第五章第 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);
}

重點整理


第五章第 22 集:Rc<T>

本集目標

學會用 Rc<T> 讓多個擁有者共享同一份資料,理解參考計數的原理。

概念說明

上一集學了 Box<T>——一個保險箱只有一把鑰匙,一個擁有者。但有時候你需要多個擁有者共享同一份資料。

問題:一個值只能有一個擁有者

let a = Box::new(String::from("hello"));
let b = a; // move!a 不能再用了

如果你希望 ab 都能用這個值,怎麼辦?

你可能會想:「那 clone 一份不就好了?」

let a = Box::new(String::from("hello"));
let b = a.clone(); // 複製了整個 String 的內容

這確實能讓 ab 都能用。但問題是——clone 是真的把 heap 上的資料完整複製了一份。如果資料很大(比如一個很長的 Vec),每次 clone 都是一筆不小的開銷。而且 ab 指向的是兩份獨立的資料,改了 a 不會影響 b

如果你需要的是「多個人共享同一份資料」,clone 就不是正確的工具了。

Rc:參考計數

還記得第四章的保險箱比喻嗎?Rust 預設的規則是「一個保險箱只有一把鑰匙」——這就是所有權。但 Rc<T> 打破了這個預設:它讓你配好幾把鑰匙,都能打開同一個保險箱

Rc<T>(Reference Counting)用一個「計數器」來追蹤目前有幾把鑰匙:

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);
}

重點整理


第五章第 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());
}

重點整理


第五章第 24 集:RefCell<T>

本集目標

學會用 RefCell<T> 在執行期檢查借用規則,搭配 Rc 實現可變的共享資料。

概念說明

上一集學了 Cell<T>,但它的限制是 T 必須是 Copy。如果你想修改一個 String 或 Vec 呢?

RefCell:執行期的借用檢查

RefCell<T> 和 Cell 類似——讓你在不需要 &mut 的情況下修改值。差別在於:

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());
}

重點整理


第五章第 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
    }
}

這段程式碼也會編譯失敗。ab 都是外面傳進來的參考,不會在函數結束時消失,那為什麼不行?

因為 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:「ab 和回傳值都標注了同一個 'a。所以回傳值的壽命不能超過 ab較短的那個。」注意生命週期參數和型別參數一樣寫在 <> 裡面。如果同時有生命週期和型別參數,生命週期要寫在前面fn foo<'a, T>(x: &'a T) -> &'a T

為什麼是較短的?

因為 ab 共用同一個 '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);
}

重點整理


第五章第 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 函數的情況。這時候就必須手動標注。

總結

範例程式碼

// 規則二:一個 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);
}

重點整理


第五章第 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——只是這個 'aNumber 來說不起實際作用。

使用帶生命週期的型別

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 就不能用了
}

重點整理


第五章第 28 集:lifetime bound

本集目標

學會 T: 'a 這種 lifetime bound,理解為什麼 &'a T 需要 T 裡面的參考都活得過 'a

概念說明

問題:T 裡面可能有參考

到目前為止,我們的泛型函數大多處理 i32String 這些擁有自己資料的型別。但 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」。

如果 Ti32(沒有參考),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());
}

重點整理


第五章第 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);
}

重點整理


第五章第 30 集:常見的 derive trait

本集目標

學會 PartialEqEqPartialOrdOrd 等常見 derive trait 的用途和差別。

概念說明

第四章我們學了 DebugCloneCopy。Rust 標準庫還有其他可以 derive 的 trait,今天來認識最常用的幾個。

PartialEq 和 Eq

PartialEq 讓你的型別可以用 ==!= 比較。

#[derive(PartialEq)]
struct Point { x: i32, y: i32 }

EqPartialEq 的 supertrait(上一集學的),它保證自反性——每個值都等於自己。

「等一下,什麼值不等於自己?」——f64::NAN!在浮點數規範裡,NAN != NAN。所以 f64 只有 PartialEq,沒有 Eq

如果你的型別不包含浮點數,通常 PartialEqEq 都可以 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 { ... }

整理成繼承關係:

為什麼 PartialOrd 要求 PartialEq?因為「比大小」本身隱含了「能判斷相等」——如果 a <= bb <= 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
}

重點整理


第五章第 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 想像成一個函數,它接受一些「輸入」然後決定一些「輸出」:

輸入決定了輸出——當你確定了「誰」(Self)和「角括號裡的參數」,associated type 就唯一確定了。

舉例來說,Convert<T> 裡的 T 是輸入,所以同一個 Self 搭配不同的 T 可以有不同的實作:i32 可以同時實作 Convert<String>Convert<(i32,)>

ContainerItem 是輸出。當你確定了 Self 是 NumberListItem只能有一個答案——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!("空的"),
    }
}

重點整理


第五章第 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
}

一行一行看:

ToOwned 是一個 trait,它有一個 associated type Owned,代表「擁有版本的型別」。

str 來說:

[T] 來說:

不管是哪種,Cow<str> 都可以當 &str 來用。

常用方法

範例程式碼

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);
}

重點整理

恭喜你完成了第五章!🎉 這一章的內容非常紮實——從泛型、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 的函數,非常靈活。

多個參數和不同回傳型別

函數指標的型別由參數和回傳值決定:

函數指標 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));
    }
}

重點整理


第六章第 2 集:閉包用法展示

本集目標

學會閉包的基本語法,了解閉包如何捕捉外部變數,並看到標準庫中使用閉包的實際案例。

概念說明

閉包的語法

上一集的函數指標很好用,但有個限制:它不能使用呼叫處的區域變數。閉包(closure)就是為了解決這個問題而存在的。

閉包的基本語法用 | 來包參數:

let add_one = |x| x + 1;

你也可以加上型別標註,跟函數一樣明確:

let add_one = |x: i32| -> i32 { x + 1 };

什麼時候要加大括號?

規則很簡單:

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;  // 編譯錯誤!
}

重點整理


第六章第 3 集:手動實作閉包

本集目標

透過手動把閉包拆解成 struct + 方法,理解編譯器在背後做了什麼事。你會看到三種閉包各自對應什麼樣的 struct,以及為什麼呼叫閉包其實是在呼叫方法。

概念說明

閉包 = 匿名 struct + 方法

上一集我們看到閉包可以捕捉外部變數。但它是怎麼「記住」這些變數的?

答案很直接——編譯器幫你做了兩件事:

  1. 建立一個匿名 struct,把捕捉的變數存成欄位
  2. 在那個 struct 上 impl 一個方法,方法的內容就是你寫在 || 後面的閉包體

換句話說,你寫的閉包體({ ... } 裡面的程式碼)就是那個方法的實作。

今天我們就來手動做一次編譯器做的事,把三種閉包分別模擬出來。

閉包呼叫 = 方法呼叫

當你寫 f() 呼叫一個閉包,編譯器其實把它轉換成 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 因為一個閉包可能捕捉多個變數。假設閉包同時修改了 abc 三個變數,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 只讀取,可以多次呼叫

小結:閉包到底是什麼?

把上面的東西串起來:

  1. 編譯器幫你建一個匿名 struct,把捕捉的變數存進去
  2. 你寫的閉包體就是那個 struct 上方法的實作
  3. 當你寫 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!

重點整理


第六章第 4 集:閉包種類的推斷

本集目標

理解 Rust 如何根據閉包體的內容,自動推斷一個閉包是 FnOnce、FnMut 還是 Fn。

概念說明

上一集我們手動用 struct 模擬了三種閉包,對應 self&mut self&self。但你寫閉包的時候從來不需要告訴 Rust「這是 FnOnce」或「這是 FnMut」——Rust 會自動判斷。

推斷規則

Rust 看的是閉包體裡面對捕捉變數做了什麼

  1. 如果閉包體裡 move 了捕捉的變數(例如 let s = captured_string;)→ 這個閉包是 FnOnce,因為 move 走了就沒了,只能呼叫一次
  2. 如果閉包體裡修改了捕捉的變數(例如 count += 1;)→ 這個閉包是 FnMut,可以多次呼叫但需要 &mut
  3. 如果閉包體只讀取捕捉的變數(例如 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 都不需要。

重點整理


第六章第 5 集:Fn / FnMut / FnOnce

本集目標

理解 Fn、FnMut、FnOnce 是 trait 而非型別,掌握它們的繼承關係,並學會在 API 設計中選擇正確的 bound。

概念說明

它們是 trait,不是型別

前幾集我們一直說 FnOnce、FnMut、Fn,但還沒正式說明——它們其實是 trait。就像第五章學的 CloneDisplay 一樣,Fn / FnMut / FnOnce 是定義在標準庫裡的 trait。每個閉包的匿名 struct 會自動 impl 對應的 trait(上一集講的推斷規則決定 impl 哪些)。

那這些 trait 到底長什麼樣?

注意!fn(i32) -> i32(小寫)是函數指標型別,而 Fn(i32) -> i32(大寫)是 trait。兩個完全不同的東西。

繼承關係

這三個 trait 有繼承(supertrait)關係:

Fn : FnMut : FnOnce

意思是:

為什麼是這個方向?

反過來就不行——一個需要消耗自己(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:

  1. 先試 FnOnce —— 如果你只需要呼叫一次
  2. 不夠再用 FnMut —— 如果你需要多次呼叫
  3. 最後才用 Fn —— 如果你需要多次呼叫且不允許修改

為什麼?因為 FnOnce 能接受所有閉包(所有閉包都至少是 FnOnce),而 Fn 只能接受不修改狀態的閉包。選能接受最多種的 bound,使用者傳入的自由度最高。

實務上 Fn 很少用到——大部分需要多次呼叫閉包的 API 用 FnMut 就夠了(FnMut 也能接受 Fn 的閉包)。只有少數場景需要保證閉包不修改狀態時才會用 Fn

函數指標也實作了這三個 trait

普通的函數(和函數指標 fn)自動實作了 FnFnMutFnOnce。所以你可以把函數名稱傳給任何接受這三個 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);
}

重點整理


第六章第 6 集:move 閉包

本集目標

學會用 move 關鍵字強制閉包以 by-value 方式捕捉外部變數,理解它為什麼能解決生命週期問題。

概念說明

預設的捕捉行為

Rust 的閉包很聰明,會自動選擇「最輕量」的捕捉方式:

大部分時候這很好用。但有些情況下,借用會造成生命週期的問題。

問題場景:回傳閉包

假設你想寫一個函數,回傳一個閉包:

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:

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);
}

重點整理


第六章第 7 集:Option / Result 的閉包方法

本集目標

用閉包方法重寫第五章的 Option 和 Result 操作,體會閉包如何讓程式碼更簡潔流暢。

概念說明

第五章我們用 match 處理 OptionResult,每次都要展開兩個分支。現在學了閉包,很多操作可以一行搞定。

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)

如果是 Nonemap 什麼都不做,直接回傳 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 就等於先 mapflatten——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 就等於 mapflatten

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);
}

重點整理


第六章第 8 集:Iterator trait

本集目標

認識 Iterator trait 的核心——只要實作 next() 方法,就能免費獲得數十個好用的方法。

概念說明

Iterator 的定義

Iterator trait 的核心簡單到不行:

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

就這樣。只有一個必須實作的方法 next(),它每次被呼叫就回傳:

還記得第五章學的 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(),像 mapfiltercountsum 等幾十個方法全部自動可用。

這就是 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
        }
    }
}

只要實作了 nextmapfiltersumcollect 等幾十個方法全部自動可用。這些方法接下來幾集會陸續學到。

標準庫的迭代器工廠

標準庫提供了一些方便的函數來建立迭代器:

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)

注意 repeatfrom_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
}

重點整理


第六章第 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);
}

三個步驟:

  1. 呼叫 v.into_iter()v 轉成迭代器
  2. 反覆呼叫 iter.next()
  3. 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 都自動實作了 IntoIteratorinto_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
        }
    }
}

重點整理


第六章第 10 集:iter / into_iter / iter_mut

本集目標

搞懂三種迭代模式的差別——借用、消耗、可變借用——以及它們和所有權系統的關係。

概念說明

三種迭代方式

前面提到過 for x in &vfor 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

選哪一個?

原則跟所有權一樣:不要拿你不需要的權限。

範例程式碼

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 已經不能用了
}

重點整理


第六章第 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);
}

重點整理


第六章第 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

reducefold 很像,但它用第一個元素當初始值:

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);
}

重點整理


第六章第 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() —— 把巢狀結構攤平

如果迭代器的元素本身也是迭代器(或 OptionVec 等),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 等於先 mapflatten。每個元素經過閉包轉換成一個迭代器(或 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 集 OptionResultand_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);
}

重點整理


第六章第 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() 已經是 &Tfilter 再借用一次就是 &&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)時:

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);
}

重點整理


第六章第 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()forsum() 等「消費」方法時,才會一個一個元素地拉動。

俄羅斯套娃

每次呼叫 .map().filter(),你其實是在迭代器外面「套一層」。就像俄羅斯套娃:

v.iter()                   // 最內層:原始迭代器
    .filter(|x| **x > 2)   // 第二層:Filter 結構,存著 inner + 閉包
    .map(|x| x * 10)       // 第三層:Map 結構,存著 inner + 閉包

每一層都是一個 struct,裡面存著內層的迭代器和自己的閉包。標準庫的 MapFilter 大致長這樣:

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 迴圈時,最外層的迭代器開始「拉」:

  1. 最外層(Map)問第二層(Filter):「給我下一個元素」
  2. Filter 問最內層(原始迭代器):「給我下一個元素」
  3. 最內層回傳 Some(&1)
  4. Filter 檢查條件:1 > 2?不通過。再問一次。
  5. 最內層回傳 Some(&2)
  6. Filter 檢查:2 > 2?不通過。再問。
  7. 最內層回傳 Some(&3)
  8. Filter 檢查:3 > 2?通過!回傳給 Map。
  9. Map 套用閉包:3 * 10 = 30,回傳 Some(30)

每個元素是一路到底處理完的——不像先做完所有 filter,再做所有 map。這意味著中間不需要任何暫存的 Vec

無限迭代器

因為是惰性的,迭代器可以是無限的std::iter::repeatstd::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 被建立,全部是一次一個元素處理完的
}

重點整理

恭喜你完成了第六章!🎉 從函數指標到閉包的三種 Fn trait,再到迭代器的惰性求值——這一章結合了所有權、trait、泛型等前面學過的概念,展現了 Rust 函數式程式設計的威力。你現在已經能寫出簡潔、高效、不需要中間暫存的資料處理管道了。下一章我們將學習 Cargo、crate 與 mod 系統——讓你的程式碼從單一檔案擴展到真正的專案結構!


第七章:Cargo、Crate 與 Mod 系統

第七章第 1 集:Cargo 與 crates.io

本集目標

認識 Cargo 的完整功能以及如何透過 crates.io 使用社群套件。

概念說明

我們從第一章開始就在用 cargo newcargo run。其實 cargo run 背後做了兩件事:先編譯你的程式碼,再執行編譯出來的執行檔。如果你只想編譯但不執行,可以用 cargo build——它只會產生執行檔,放在 target/debug/ 資料夾裡。

這一集我們要把 Cargo 的全貌攤開來看,特別是怎麼引入外部套件。

dev build vs release build

cargo buildcargo 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]

其中 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 的官方套件庫。你可以在上面搜尋套件、看下載數、閱讀文件。每個套件頁面都會有:

依賴的版本語意

[dependencies] 裡指定外部套件的版本時,有不同的寫法:

大多數時候用預設的 ^ 就好,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!("反面!");
    }
}

重點整理


第七章第 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 底下有 basicadvanced 兩個子 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);
}

重點整理


第七章第 3 集:檔案 mod

本集目標

學會將 mod 拆分到不同檔案,理解 Rust 的檔案與 mod 對應規則。

概念說明

上一集我們把 mod 寫在同一個檔案裡,但實際專案不可能全部塞在一起。Rust 提供了一套規則,讓你把 mod 拆到獨立的檔案中。

基本拆分:mod + 獨立檔案

假設你有一個 math mod,想把它搬到自己的檔案。做法很簡單:

  1. main.rs(或 lib.rs)裡寫 mod math;(注意是分號,不是大括號)
  2. 建立 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)有兩種類型:

一個專案可以同時main.rslib.rsmain.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 執行。

重點整理


第七章第 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 的成員互相看得到,不需要 pubpub 是用來讓其他 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 提供了精細的控制:

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
}

範例程式碼

這一集的概念說明中已經包含了完整的程式碼範例,不另外重複。

重點整理


第七章第 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 內建的一組工具,包含我們已經用過的 VecStringOptionResultprintln! 等等,以及更多像是檔案操作、網路、集合等功能。你不需要在 Cargo.toml 加 dependency 就能用它,因為每個 Rust 程式都會自動連結 std。使用時路徑寫法跟外部 crate 一樣——std::collections::HashMapstd::fmt::Display 等。不只 std 會被自動連結,std 中的 prelude 更會被自動引入——也就是說,VecStringOptionResultCloneCopy 等最常用的型別和 trait,不用寫 use 就能直接用。這就是為什麼我們在最前面好幾章沒寫 use 也能用這些東西。

如果你想明確強調「這是外部 crate」,可以用 :: 開頭:

use ::rand::Rng;  // 明確表示 rand 是外部 crate,不是本地 mod

這在你的 crate 裡也有一個叫 rand 的 mod 時特別有用,可以避免歧義。

super:: 和 self::

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,也匯入了裡面的 ReadWrite

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));
}

重點整理


第七章第 6 集:cargo test

本集目標

學會用 #[test] 寫測試、用 assert! 系列巨集驗證結果、用 cargo test 跑測試。

概念說明

為什麼要寫測試?

程式碼寫完之後,你怎麼確定它是對的?手動跑一遍?那下次改了程式碼又要再跑一遍。自動化測試讓你寫一次,之後隨時都能驗證——一個指令就知道有沒有東西壞掉。

最簡單的測試

在函數上面加 #[test],它就變成測試函數:

#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

cargo test,Rust 會自動找出所有標了 #[test] 的函數並執行它們。

assert 系列巨集

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);
    }
}

幾個重點:

cargo test

cargo test

這個指令會:

  1. 編譯你的程式碼(包含測試)
  2. 執行所有 #[test] 函數
  3. 報告哪些通過、哪些失敗

測試私有函數

因為 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 來跑測試!");
}

重點整理


第七章第 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;

這很麻煩——使用者根本不在意你內部怎麼分資料夾,他只想用 addpower

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 的好處

  1. 簡化公開 API:使用者不需要知道你的內部結構,用更短的路徑存取
  2. 自由重構:你可以隨意改內部的 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());
}

重點整理


第七章第 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:

這就是衝突。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 是你定義的型別,所以可以為它實作 Displayself.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」等概念,超出本教學的範圍。有興趣可以參考官方文件

重點整理


第七章第 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 社群有一些約定俗成的文件段落名稱:

cargo doc

寫好文件註解後,一行指令就能產生漂亮的 HTML 文件:

cargo doc --open

這會:

  1. 編譯你的 crate(不執行)
  2. 從所有 /////! 產生 HTML 文件
  3. 自動在瀏覽器打開

生成的文件就跟你在 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());
}

重點整理


第七章第 10 集:cargo publish

本集目標

學會將你的 library 發布到 crates.io,讓全世界的 Rust 開發者都能使用。

概念說明

到目前為止,我們學會了怎麼組織程式碼、寫文件、使用別人的套件。這一集要反過來——把你自己的套件發布出去。

帳號設定

首先,你需要一個 crates.io 的帳號:

  1. crates.io 用 GitHub 帳號登入
  2. 到帳號設定頁面,產生一個 API Token
  3. 在終端機執行:
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"]

根據官方文件,發布前應填寫:

另外建議但非必須:

發布前檢查

發布前可以先用 cargo package 檢查有沒有問題:

cargo package

這會模擬打包過程,檢查有沒有缺少必要欄位或其他問題。

發布!

一切準備好後:

cargo publish

完成!你的套件現在在 crates.io 上了,任何人都可以 cargo add my-awesome-lib 來使用。

版本更新流程

套件發布後,如果要更新:

  1. 修改程式碼
  2. 更新 Cargo.toml 裡的 version,遵循 SemVer(語意化版本號)
  3. 再次 cargo publish

SemVer 的規則:

注意:已發布的版本無法刪除或覆蓋。如果發現某個版本有嚴重問題,可以用 cargo yank 標記它為不建議使用,但已經在用的人不會受影響:

cargo yank --version 0.1.0

發布前最好做的事

範例程式碼

一個準備好發布的小 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        # 正式發布!

重點整理

恭喜你完成了第七章!🎉 到這裡為止,我們已經教完了 Rust 的主要觀念——所有權、借用、泛型、trait、生命週期、閉包、迭代器,以及模組系統和套件管理。你現在已經可以獨當一面了。如果你腦中有什麼點子,現在就是動手實作的好時機!

即便如此,Rust 還有很多獨特而強大的功能。後面的章節會繼續介紹進階主題,希望能帶給你對 Rust 更完整、更全面的認識。


附錄:補充主題

附錄第 a 集:數字字面值格式

本集目標

學會用底線分隔、不同進位制、型別後綴,以及浮點數字面值的各種寫法。

本集是第一章的補充。

概念說明

第一章我們學了基本的數字寫法,像 423.14。但 Rust 的數字字面值其實有很多寫法,讓你寫出來的數字更好讀、更精確。

底線分隔符

當數字很大的時候,10000001_000_000 哪個比較好讀?Rust 允許你在數字字面值的任意位置插入底線 _,編譯器會直接忽略它們:

let million = 1_000_000;
let weird_but_legal = 1_00_00_00; // 合法,但別這樣寫

不同進位制

除了十進位,Rust 還支援三種進位制前綴:

這在處理位元運算、顏色值等場景特別實用。底線也能搭配進位制使用: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.0f641.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);
}

重點整理


附錄第 b 集:break 回傳值

本集目標

學會用 breakloop 迴圈中回傳值,把迴圈當作表達式使用。

本集是第一章的補充。

概念說明

還記得 Rust 裡「幾乎所有東西都是表達式」嗎?loop 迴圈也不例外——你可以透過 break 帶一個值出來,讓整個 loop 變成一個表達式。

基本語法

let result = loop {
    break 42;
};

這裡 loop { break 42; } 的型別是 i32,因為 break 帶出了 42

為什麼只有 loop 能這樣做?

你可能會問:whilefor 為什麼不行?

原因是:whilefor 有可能一次都不執行。如果迴圈體從未執行,那 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);
}

重點整理


附錄第 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);
}

重點整理


附錄第 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!("這是一個大括號:{{}}");  // 印出:這是一個大括號:{}
}

重點整理


附錄第 e 集:struct/enum 放在 main 裡面

本集目標

了解 struct、enum、fn 等「項目」可以定義在函式內部,以及它們與 let 綁定在順序上的根本差異。

本集是第三章的補充。

概念說明

你可能習慣了把 structenum 定義在 fn main() 的外面,但其實把它們放在裡面也完全合法:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }
    let p = Point { x: 1, y: 2 };
    println!("{}", p.x);
}

這段程式碼完全可以編譯。

限制:只在該函式內可見

放在函式內的型別定義,只有那個函式看得到。其他函式無法使用它。所以慣例上,我們還是會把型別定義放在外面——除非你確定這個型別只在一個函式裡面用到。

重要差異:項目不受順序限制

這裡有一個很多人不知道的重點。在 Rust 裡,項目(items)——包括 structenumfntraitimpl 等——不受定義順序影響。你可以先使用,後定義:

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);
}

重點整理


附錄第 f 集:struct update syntax

本集目標

學會用 .. 語法從既有的 struct 實例快速建立新實例,並理解 Copy 與 move 欄位的差異。

本集是第三章的補充。

概念說明

還記得建立 struct 的時候,每個欄位都要寫出來嗎?如果你只想改一兩個欄位,其他照舊,每次都全部寫一遍很煩。Rust 提供了 struct update syntax,用 .. 來「填入剩下的欄位」。

基本語法

let p2 = Point { x: 10, ..p1 };

意思是:p2x 設為 10,其餘欄位都從 p1 複製過來。

..p1 必須放在最後面,而且前面要有逗號(如果前面有其他欄位的話)。

Copy 與 move 的差異

這裡有個重要的細節。..p1 並不是「淺複製整個 struct」,而是逐欄位處理:

也就是說,如果你用 ..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);
}

重點整理


附錄第 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 還能用,因為我們只是借用了裡面的值

如果不寫 refs 會拿走 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());
}

重點整理


附錄第 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。

四者比較

範例程式碼

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!("程式正常結束");
}

重點整理


附錄第 i 集:Rc 迴圈與 Weak

本集目標

理解 Rc 參考迴圈會造成記憶體洩漏,並學會用 Weak 來打破迴圈。

本集是第五章的補充。

概念說明

還記得第五章學的 Rc<T> 嗎?它透過參考計數來管理記憶體——每多一個 Rc 指向同一筆資料,計數就加一;每少一個就減一;歸零時釋放記憶體。

聽起來很完美,但有一個致命弱點:參考迴圈(reference cycle)

什麼是參考迴圈?

想像 A 持有 Rc 指向 B,B 也持有 Rc 指向 A。當我們不再需要它們的時候:

  1. A 的 Rc 被 drop → A 的計數減一,但 B 還在指向 A → 計數不為零 → A 不釋放
  2. 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::downgradeRc 降級成 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 都 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
}

重點整理


附錄第 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;
}

如果某個型別同時實作了 AnimalRobot,呼叫 .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);
}

重點整理


附錄第 k 集:DST 簡介

本集目標

理解什麼是動態大小型別(DST),以及 Sized?Sized 在泛型中的意義。

本集是第五章的補充。

概念說明

在 Rust 的型別系統裡,大部分型別的大小在編譯期就已知——i32 是 4 bytes、bool 是 1 byte、(i32, i32) 是 8 bytes。但有些型別的大小在編譯期是未知的,這就是 DST(Dynamically Sized Types),動態大小型別。

常見的 DST

你其實已經見過它們了:

因為大小不固定,你不能直接把它們當作值使用:

// let s: str = "hello";        // 編譯錯誤!
// let arr: [i32] = [1, 2, 3];  // 編譯錯誤!

怎麼用?靠指標!

DST 必須藏在某種指標後面:

這些指標是所謂的胖指標(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:

假設你有 "哈囉"(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);
}

重點整理