Skip to main content

Command Palette

Search for a command to run...

Rust 技術面試經驗分享

我在電話訪談中被問到的技術題目與回覆攻略筆記

Updated
•15 min read•View as Markdown
W

Please don't OSINT me, I'd be shy. 🫣

最近有幸收到一些 Rust Software Engineer 的面試邀請。自認在技術問答階段沒有準備的很完善,加上臨場有點緊張,導致回答很粗淺、沒有發揮出應有的表現。

我想,根本原因是過去在學 Rust 時雖然可以透過看影片、看文件可以學到概念,但沒有真的吸收並轉換成有效的輸出。透過這次 Interview,發現自己過去把大部分時間花在「無效學習」。

因此,透過這篇文章記錄一下在 Screening 階段,整理面試階段常被問到的問題,以及整理出的筆記,希望未來也對讀者尋找 Rust 工作有幫助。


從淺入深

  1. .unwrap() 為什麼很危險?

  2. String 和 &str 的差別?

  3. Option 和 Result 的差別?

  4. Borrow 和 Reference 有什麼差別?

  5. Send 和 Sync 的差別?

  6. Clone 和 Copy 的差別?

  7. Box 和 Rc & Arc 的差別?

  8. Cell 和 RefCell 的差別?請解釋 Interior Mutability。

  9. Trait System 與其他語言的差別(例如 TypeScript's Interface)

  10. Unpin 是什麼?什麼時候會用到?

  11. 闡述一下你對於 Ownership System 的理解?


👨‍⚖️:.unwrap() 為什麼很危險?

👨‍💻 簡短一句話:.unwrap() 使用不當會導致服務 panic!()。

.unwrap() 危險的地方不是 Memory-Safty 問題,而是開發者在處理函數返回值過於樂觀(有疏失)。

.unwrap() 假設 Option/Result 解包出來的值一定是 Some/Ok,當這個假設不成立的時候,.unwrap() 就會引起 panic!(),最終造成 Process 被殺掉、服務斷線。

一個經典的事故是 2025 年 11 月發生的 Cloudflare 大當機事件。
根本原因就是開發者用 .unwrap() 導致 panic,後端就會回覆 HTTP 500。

.unwrap() 在爆炸半徑有限的場景(比方說寫小腳本),用於快速測試程式邏輯會不會如預期運作的時候,很好用,因為你不用花時間思考 Fallback Strategy、Error Handling。

但放到像是 BE Server 這種需要長時間維持 Availability 的場景就必須禁止使用。因此,開發的時候要謹慎使用 .unwrap(),不要把含有 .unwrap() 的程式碼推到 production。


👨‍⚖️:String 與 &str 的差別?

👨‍💻 簡短一句話:String 是放在 Heap 的東西,&str 是編譯好寫在 .rodata 區段的東西。

🕵️‍♂️ 再深入一點的說法:String 是放在 Heap 的東西,字串內容和記憶體佔用空間都是可變的。&str 則是放在 .rodata 區段的東西,在程式編譯好就不可變了,我們只能去取字串切片出來。雖然兩者的 Type 不一樣,但他們各自都有一些 Method 可以讓我們做互相的轉換。

順帶一提,String 是有 Ownership 的,&str 只能去 Borrow 它。

String &str
所有權系統 ✅ 有 Owner ❎ 只能 Borrow
資料儲存位置 Heap .rodata 區段
長度與內容 ✅ 動態、可變 ❎ 固定、不可變

不像 Python,Rust 是沒有 str 可以用的。

我指的是 Rust 在開發者體感上沒有 str 可以用,但底層還是有這個 Primitive Type 的。

這是因為 str 只是代表一段連續的 UTF-8 Raw Bytes,而 &str 就是一個「紀錄具體來說這個 String Slice 佔用了多少 Bytes」 的胖指標。

&str = [ptr, len] - 佔用 16 bytes (in 64-bit OS)
ptr = *const u8 - 佔用 8 bytes
len = usize -佔用 8 bytes

至於 String 則是一個可以被 Owned、具有動態大小、可變的 UTF-8 資料結構。

String 的動態大小,指的是「在 Heap 佔用的那塊空間 - 是動態大小」
「String 指標」本身,因為仍是放在 Stack 上的,所以是固定大小。

String 和 &str 是可以互相轉換的,下面示範:

#!/usr/bin/env rust-script

// 把 &str 轉成 String
fn str_to_string() {
  let hello = "Hello";

  // 三種做法都可以,依照常用次數排序
  // 都會為變數分配一個 Heap 空間出來 (高成本)
  let s1 = String::from(hello);
  let s2 = hello.to_string();
  let s3 = hello.to_owned();
}

fn main() {
  string_to_str();
}

💡 把 &str 轉成 String 部分,在 s1 或 s2 寫法之間抉擇就好,用哪個讀起來會比較自然就用哪個,你爽就好。

我個人覺得在字串型別轉換時,盡可能不要用到 s3 寫法比較好。因為 s2 的方法是透過 std::string::ToString 特徵來的,s3 則是 std::borrow::ToOwned 特徵來的。

從上面語意就能知道 .to_owned() 不是 String 特有的特徵,因此我並不太喜歡 s3 這種有時候會讓人 code review 節奏停頓的寫法 🥶。

#!/usr/bin/env rust-script

// 把 String 轉成 &str
fn string_to_str() {
  let hello = String::from("Hello");

  // 三種做法都可以,依照常用次數排序
  // 僅生成胖指標 (低成本)
  let s4 = &hello;
  let s5 = hello.as_str();
  let s6 = &hello[..];
}

fn main() {
  string_to_str();
}

s4 之所以可以直接加上 &,從 String 變成 &str,是因為 String 有寫 Deref 特徵並且 impl 時有支援 str,這個叫做 Deref 強制轉型(Deref Coercion)。

點我看 String 的 Deref Coercion 的原理

std::string::String 的定義:

pub trait Deref {
  type Target: ?Sized;
  fn deref(&self) -> &Self::Target;
}

文件連結:https://doc.rust-lang.org/std/ops/trait.Deref.html

std::string::String 的定義:

impl std::ops::Deref for String {
  type Target = str; // String 解引用後就是 str
  #[inline]
  fn deref(&self) -> &str {
    // 返回指向 Heap 上實際文字內容的 &str
    unsafe { str::from_utf8_unchecked(&self.vec) }
  }
}

文件連結:https://doc.rust-lang.org/src/alloc/string.rs.html#2818

這邊有一個重點必須記住:Deref Coercion 不適用於泛型(Generic)!

因為 Generic 的任務是在編譯時,告訴編譯器「去推導 T 的型別」,而不是「幫 T 做轉型」。

因此,如果 T = &String 時,它不會自動轉成 T = &str 而是繼續用原本的 T = &String 去生成 Polymorphism Code。

在這種狀況下,就應該使用 s5 的語法:.as_str()。

它出場的時機就是 &String 不能解決問題的時候:

  • 餵給 Generic 參數的時候 T = String.as_str()

  • 做 Method Chaining 的時候 .map(String::as_str)

  • 高效能處理 (Zero-Copy),例如:

    fn handle_command(cmd: String) {
        // ❌ error[E0308]: mismatched types
        //  "START" expected `String`, found `&str`
        //
        // match cmd {
        //     "START" => println!("Go!"),
        //     _ => println!("Unknown"),
        // }
    
        // ✅ 使用 .as_str() 零成本轉換
        match cmd.as_str() {
            "START" => println!("Go!"),
            "STOP" => println!("Halt!"),
            _ => println!("Unknown"),
        }
    }
    

👨‍⚖️:Option 和 Result 的差別?

👨‍💻 簡短一句話:Option 用來表達「有沒有值」,而 Result 用來表達「成不成功」。一般來說,有涉及到 I/O 的操作,返回值就會用 Result 來表達「本次操作有沒有成功?」。

Option Result<T, E>
核心意義 返回值存在與否 操作結果失敗與否
State Variants Some(T) 或 None Ok(T) 或 Err(E)
Use Cases 找陣列元素、找 Hashmap 值、可選的 struct 欄位 I/O 操作、資料格式解析、狀態轉換
Example 資料有找到/沒找到 轉帳成功/轉帳失敗 - 餘額不足
兩者互轉 把 None 賦予錯誤原因就變成 Result 把 E 拔掉就變成 Option

Tips: 使用 anyhow,可以直接簡略成 Result<T>,也就是省略掉 E。

在本段落尾部,我整理了 Option 與 Result 各種處理的方法速查表。以下先對 Option 與 Result 做詳細說明。


如果一個函式可能會回傳 Null,則它指定的 Return Type 應該用 Option<T>。

什麼樣的函式會用到 Option?例如一個允許外部不可控輸入參數的函式。

下面是一個使用 Option,以及如何處理 Option 返回值的實用範例:

#!/usr/bin/env rust-script

// 註:在生產環境中,不會使用 &'static str 放使用者資訊
// 因此,有些地方須在改成 String、String::from()、String.clone()
// 麻煩讀者自己注意一下這些細微差異。

struct User {
  uid: u32,
  name: &'static str,
}

const USERS: [User; 3] = [
  User { uid: 1, name: "Alice" },
  User { uid: 2, name: "Bob" }, 
  User { uid: 3, name: "Charlie" },
];

/// 給外部呼叫者傳入 uid,返回使用者名稱的函式
pub fn get_user_name(uid: u32)-> Option<String> {
  // 這是 Prod 推薦寫法。
  // .iter() 會回傳一個迭代器 Iter<&User>
  //   .find() 會回傳 Option<&User>
  //   .map() 會把 Option<&User> 轉成 Option<String>
  USERS.iter() 
    .find(|user| user.uid == uid) 
    .map(|user| user.name.to_string()) 

  /* 直觀的 Python-like 程式碼長這樣:
  這樣寫也行,但不夠優雅

  for user in &USERS {
    if user.uid == uid {
      return Some(user.name.to_string());
    }
  }
  None

 */
}

fn main() {
  let uid = 4;

  // 用 match 處理返回值
  match get_user_name(uid) {
    Some(name) => println!("User name: {}", name),
    None => println!("User with uid {} not found", uid),
  }

  // 或者用 if let 處理返回值,具體就看你的使用場景來決定
  if let Some(name) = get_user_name(uid) {
    println!("User name: {}", name);
  } else {
    println!("User with uid {} not found", uid);
  }

  // 一般來說,如果你是處理二元狀態,用 if-let 比較好
  // 例如: 
  // 只有拿到 HTTP 2xx 才往下跑業務邏輯,不然一律走 fallback 路徑。
}

如果一個函式可能會執行失敗,則它指定的 Return Type 應該要寫成 Result<T>。

什麼樣的函式可能會執行失敗?例如一個涉及 Network I/O 的函式。

下面是一個使用 Result,以及如何處理 Result 返回值的實用範例:

#!/usr/bin/env rust-script

// 註: 此範例的 Error 類型是 String,生產環境上不會這樣做。
// 一般來說會自定義 enum ErrorCode,並讓它 derive thiserror

pub fn fetch_btc_price() -> Result<f64, String> {
    let network_ok = true; // 模擬網路狀態

    if network_ok {
        Ok(70000.0)
    } else {
        Err("無法獲得 BTC 現貨價格".to_string())
    }
}

fn print_oracle_status() -> Result<(), String> {
    let btc_price = fetch_btc_price()?;
    
    // 用 `?` 來 propagate 錯誤。
    // 前提:E 必須也是 String type
    // 但這是比較簡化的說法,詳細事實是有實作
    // trait std::convert::From<InnerErrorType> for <OutterErrorType> 

    println!("報價機正常運作中!");
    println!("目前 BTC 現貨價格: ${}", btc_price);

    Ok(())
}


fn main() {
    if let Ok(_) = print_oracle_status() {
        println!("一切順利!");
    } else {
        println!("報價機出現問題,請檢查網路連線或服務狀態。");
    }
}

執行它,看輸出:

❯ rust-script Result.rs 
報價機正常運作中!
目前 BTC 現貨價格: $70000
一切順利!

由於 Hashnode 的 Markdown Table 顯示有點狀況,我另外整理了一張處理 Option<T> 與 Result<T, E> 的方法速查表:

https://docs.google.com/spreadsheets/d/1\_yr76AGxGDIgnTi4YfM\_914xNjfvuVQNuY\_cDfwVm8Y/edit?usp=sharing

這邊整理一套速查口訣:

  • unwarp_* :解包取值,失敗則 panic 或取預設值

  • map:用來改內值,Option 改 Some 內值,Result 改 Ok 內值。

  • map_err:只有 Result 有,用來改 Err 內值。

  • and_then:鏈式運算,回傳同類值。

  • or_else:解包失敗時,用閉包算出備援預設值。

  • filter:只有 Option 有,用來過濾內值,只保留想要的值。

  • ok/err:Result 換成 Option,或是 Option 換 Result (僅 ok 能用)。

  • transpose:內外層翻轉,Result<Option<T>, E> 變成 Option<Result<T, E>>。

  • flatten:只有 Result 有,壓平 Result<Result<T, E>, E> 變成 Result<T, E>。

  • take/replace/insert:原地改 self。

  • inspect*:除錯用,不影響 self。


👨‍⚖️:Borrow 和 Reference 有什麼差別?

這一題其實有一點不好回答,因為可以想像 Borrow 本身是一個「動詞」,並不方便和 Reference 這個「名詞」做二元分類。

因此我認為比較正確的回答策略是直接闡述你對於這兩個詞的理解程度,不要掉入面試官的定義陷阱。

👨‍💻 簡短一句話:這兩個東西不能簡單地做差異類比,因為 Reference 解決的是「定義存取資料的方式」,Borrow 解決的是「誰有權存取資料」。

🕵️‍♂️ 再深入一點的說法:

先從 Reference 講起。Reference 直觀上來看,有點像 C/C++ 語言的 Pointer 的概念,但 Reference 帶有 Rust 編譯器的安全保證。Reference 必須指向有效資料,不可為 null、不能像 Pointer 那樣做 offset 運算。如果在 Rust 要做這些不安全操作,就需要使用到 unsafe raw pointer。

在 Rust 語法中看到 & 就代表這個變數是一個 Reference。不用管它有沒有帶 mut 關鍵字,因為這是 Borrowing Rules 在管理的。

Borrow 則是 Rust Ownership System 中的一種機制,用來在不轉移 Ownership 的前提下,暫時取得資料的存取權。當一個值被 Borrow 時,原本的 Owner 仍然擁有該資料,但 Compiler 會根據 Borrow 的種類,限制其他程式碼對該資料的存取方式,來避免 Data Race、Dangling Pointer、Use-After-Free 等問題。

借用規則 Borrowing Rules:

  1. 允許多個讀取者 - 可以有多個 Immutable Borrow

  2. 寫入必須獨佔 - Mutable Borrow 只能有一個

  3. 不能同時又讀又寫 - Mutable Borrow 與 Immutable Borrow 不可共存

  4. 別忘記生命週期 - Borrow 的 Lifetime 不可超過 Owner 的 Lifetime

Borrowing Rules 確保了在任何時刻:一份資料要嘛能有多個讀取者,要嘛只有一個修改者。

let s = String::from("hello");
let r = &s;

在這段程式碼中,s 是字串的擁有者、r 是 Reference,在建立 r 的過程中發生了 Borrow。r 的生命週期必須小於等於 s,否則程式無法編譯。

因此,從上面這段程式碼我們就可以回答這一題的陷阱之處在於,Borrow 和 Reference 這兩個概念並不是互斥的,Borrow 通常用 Reference 的形式來表達 (&T, &mut T)。

👨‍⚖️ Send 和 Sync 的差別?

👨‍💻 簡短一句話:Send 和 Sync 都是 Trait。Send 表示這個型別的所有權(Ownership)可以安全地轉移到另一個 Thread,Sync 表示這個型別可以被多個 Thread 同時用 &T 共享讀取。大部分常用的 Primitive Types、struct、enum 都有 Send 與 Sync,但是 Rc 是兩個 Trait 都沒有實現的,因為它專門設計在單執行緒情境使用。

粗體部分記得要講到,只講前面會很像在背誦課文,不像是真的有理解。

🕵️‍♂️ 再深入一點的說法:

Send 和 Sync 都是 Marker Trait(沒有 Method):

pub unsafe auto trait Send {} // 可以跨 thread 轉移 owner
pub unsafe auto trait Sync {} // 可以跨 thread 共享 &T

也請注意到有個 auto 關鍵字,Send、Sync 和 Unpin 是 Rust 中三大最常見到的 Auto Trait。Auto Trait 意味著當你宣告一個 struct,Compiler 會自動看你的 struct 內容,決定要不要幫你自動實現該 Trait。因此,大多數情況下除非你用到了 unsafe raw pointer,大多是不需要自己 impl Auto Trait 的。

型別 Send Sync 說明
bool, i32, u32, f32, f64.. ✅ ✅ 純值,無內部狀態
usize, isize ✅ ✅ 架構相關整數指標大小,無內部狀態
char ✅ ✅ 4 Bytes Unicode,無內部狀態
String ✅ ✅ Heap 和胖指標 (Length, Capacity),本質上是 Vec<u8> + UTF-8 合法性檢查。
Vec<T> T: Send T: Sync 取決於 T 有沒有 Send, Sync。
Box<T> T: Send T: Sync 同上。Box 僅作為一個 Owned Heap 資料的指標,不處理這件事。
Rc<T> ❌ ❌ 不論 T 是什麼,兩者皆無。
Arc<T> T: Send + Sync T: Send + Sync 要能跨執行緒共享同一份 T 所以要 Sync,Send 是因為最後一個 Owned Arc<T> 的執行緒會負責執行 T 的 Drop。
Cell<T> T: Send ❌ 由於多執行緒在 Runtime 透過 &Cell 修改內部資料必定會觸發 Data Race,因此必定無 Sync。但還是可以把資料丟給其他執行緒(只要 T 可以被丟到其他執行緒)。
RefCell<T> T: Send ❌ 大致同上。只是變成操作 Reference,內部的借用計數器非 Atomic,所以會導致計數器的 Data Race。
Mutex<T> T: Send T: Send 即使多個執行緒共享 &Mutex,鎖的機制保證了同一瞬間只有一個執行緒能拿到 &mut T,因此不需要約束 T: Sync。
RwLock<T> T: Send T: Send + Sync 當多個執行緒同時獲取 Read Lock 時,它們會同時持有 &T(並行讀取),所以內部資料必須滿足 T: Sync。同時,若有執行緒獲取 Write Lock,會拿到 &mut T(等同短暫持有並可能修改),所以內部資料也必須能跨執行緒傳送,要求 T: Send。
MutexGuard<'_, T> ❌ T: Sync 由哪個執行緒上鎖的 Mutex,就必須由該執行緒解鎖,因此必定無 Send。
*const T ❌ ❌ Raw Pointer 預設禁止跨執行緒,要用就自己 unsafe impl。
*mut T ❌ ❌ 同上。

觀察上表,我們可以再探討一個問題:Rc<RefCell<T>> 可以編譯嗎?

答案其實是可以編譯的,但就無法適用多執行緒情境!

// 可以直接貼到 Rust-Playground 實驗看看!
use std::cell::RefCell;
use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(RefCell::new(42));

    let data2 = data.clone();

    thread::spawn(move || {
        *data2.borrow_mut() += 1;
    })
    .join()
    .unwrap();
}

嘗試編譯,會得到錯誤訊息輸出:

error[E0277]: `Rc<RefCell<i32>>` cannot be sent between threads safely
   --> src/main.rs:10:19
    |
 10 |       thread::spawn(move || {
    |       ------------- ^------
    |       |             |
    |  _____|_____________within this `{closure@src/main.rs:10:19: 10:26}`
    | |     |
    | |     required by a bound introduced by this call
 11 | |         *data2.borrow_mut() += 1;
 12 | |     })
    | |_____^ `Rc<RefCell<i32>>` cannot be sent between threads safely
    |
    = help: within `{closure@src/main.rs:10:19: 10:26}`, the trait `Send` is not implemented for `Rc<RefCell<i32>>`
note: required because it's used within this closure

因此,若要達成跨執行緒共享,我們需要將 Rc<RefCell<T>> 改成 Arc<Mutex<T>> 或是 Arc<RwLock<T>> (大量讀少量寫)。以下兩段代碼皆可編譯:

// 可以直接貼到 Rust-Playground 實驗看看!
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(42));

    let data2 = Arc::clone(&data);

    let handle = thread::spawn(move || {
        *data2.lock().unwrap() += 1;
    });

    handle.join().unwrap();

    println!("{}", *data.lock().unwrap());
}
// 可以直接貼到 Rust-Playground 實驗看看!
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(42));

    let data2 = Arc::clone(&data);

    let handle = thread::spawn(move || {
        let mut value = data2.write().unwrap();
        *value += 1;
    });

    handle.join().unwrap();

    let value = data.read().unwrap();
    println!("{}", *value);
}

講到這你可能也會好奇,為何 Arc 內部是使用 Mutex<T> 或 RwLock<T>,而不是 Box<T>?

關鍵就在於,Box 沒有提供 Interior Mutability:

Box<T>            ↔ ownership
Rc<T> / Arc<T>    ↔ shared ownership
Cell<T>           ↔ copy-based interior mutability
RefCell<T>        ↔ borrow-checked interior mutability
Mutex<T>          ↔ thread-safe interior mutability
RwLock<T>         ↔ thread-safe interior mutability (multi-reader)
MutexGuard<T>     ↔ exclusive access granted by Mutex

👨‍⚖️ Copy 和 Clone 的差別?

這一題其實有一點陷阱,因為這兩個 Trait 不是互斥的,如果一個型別有 Copy 就必定有 Clone,但反過來就不成立。面試官主要是想考驗你對 Copy 與 Clone 的理解有多深。

👨‍💻 簡短一句話:Copy 是隱式的位元層級 shallow-copy,只發生在 Stack 上,執行成本極低。而 Clone 是顯式呼叫的 deep-copy,通常涉及 Heap 記憶體分配與其他自定義 Clone Trait 的實作邏輯,執行成本比較高。一個 derive Copy Trait 的結構體,一定會有 derive Clone Trait。另外,基於記憶體安全,實作了 Drop Trait 的資源管理型別是絕對無法實作 Copy 的。

把粗體部分講出來,並對它有確實理解,會比較加分。

🕵️‍♂️ 再深入一點的說法:

我們上面提到了「隱式的位元層級 shallow-copy」但這到底是什麼意思?
其實我們最基本的 = 賦值操作就是 Copy。

單純這樣說會有歧義。嚴格來說,編譯器會看型別有沒有 Copy Trait 來進行分流,決定要 bitwise 複製還是 move ownership。看下面程式就秒懂了:

fn main() {
  // ========== Copy 型別:賦值 = 隱式複製 ==========
  let a = 42;
  let b = a;                     // 賦值觸發隱式 Copy ✅
  
  // ========== 非 Copy 型別:賦值 = move ==========
  let s1 = String::from("hi");
  let s2 = s1;                  // 賦值觸發 move
                                // s1 已被 move,不可用 🚫
                                // s2 有效中 ✅
  let s3 = s2.clone();          // 要保留 s1 就必須顯式 clone ✅
}

Copy 適用場景不只是 = 賦值操作,也還有函式傳參、函式回傳。

Copy 本身是 Maker Trait,所以沒有方法可以用,直接 #[derive(Copy)] 就好。

std::clone::Clone Trait 的定義,長這樣:

pub trait Clone {
  // 必要的實作:定義如何產生一個全新的 deep-copy 實例
  fn clone(&self) -> Self;

  // 可選的實作:就地複製(In-place cloning)
  // 預設行為是直接 `*self = source.clone()`
  // 也就是把舊的 Drop 掉,再賦值一個新的。
  fn clone_from(&mut self, source: &Self) {
     *self = source.clone();
  }
}

一般來說,開發者很少會自己 impl Clone For YourStruct,通常會直接 #[derive(Clone)],因此我就不貼實作示範佔用版面了。

至於為何會說欲實作 Copy 的型別,無法實作 Drop 的,原因就是為了防止 Double-Free 問題把 Allocator 的內部資料結構改壞。用假想範例解釋:

// 假設這段是合法可編譯的 Rust 程式碼

// 假設 FakeBox 是一個同時實作 Copy 與 Drop 的智慧指標型別。
let a = FakeBox::new("Password");

// 此處發生了 bitwise,因此 a 與 b 同時指向同一塊 Heap 空間了。
let b = a;

假設 b 先離開了作用域,因此觸發了 Drop,把 "Password" 記憶體釋放還給作業系統。接著 a 也離開了作用域,再次觸發了 Drop,試圖去釋放一個已經被釋放過的記憶體位址,就出現了 Double-Free 漏洞了!

重點回顧小抄:

  • Copy 只是 bitwise 很便宜,Clone 涉及記憶體分配很貴。

  • 一個有 Copy 的結構體,必定會有 Clone。

  • 一個有 Clone 的結構體,不一定會有 Copy。

  • 簡單記法:貴 > 便宜。因此 Copy ⊆ Clone。

  • Copy 型別不可以有 Drop,防止 Double-Free 漏洞出現。

  • Primitive Types 都有 Copy (i32, bool, char, &T 等)

  • 結構體通常只有 Clone (String, Vec<T>, Hashmap<K, V> 等)

  • 一個結構體有沒有 Copy 取決於它是不是 Trivial 的。

    • Trivial = 沒有 Drop + 無任何 Non-Copy 欄位。
  • 使用時機:能 derive Copy 就用,不能用,就 derive Clone(維護者會感謝你...)


👨‍⚖️ Box、Rc & Arc 的差別?

👨‍💻 簡短一句話:主要差別在於 owner 數量,以及是否 thread safety。Box<T> 是唯一的 Heap Owner,Rc 是單執行緒用的 Reference Counter,Arc 是 Rc 的 Thread-safe 版本,三者底層都是 Heap 配置和 Pointer。

重點不在於回答定義,而是面試官會希望看到你回答這三種型別的適用場景。

🕵️‍♂️ 再深入一點的說法:

在開發時,第一步先決定這份資料是不是有動態大小,或是否有很大的 Stack Copy / Move 成本。

  1. 編譯期算不出實際大小 (例:Linked List)。

  2. Trait Object (Dynamic Dispatch)。

  3. Enum variants 彼此的大小差異過大。

  4. 大型的 Data Struct。

  5. 不是 String、Vec、Hashmap。

如果上述任一符合,則我們就會需要考慮用像是 Box<T> 這樣的智慧指標,把 Data 塞到 Heap、只留記憶體指標在 Stack 上。

第二步是看這份資料是否只有單一 Owner、是否會需要多個 Owner 共享資料?
如果不需要,則直接用 Box<T> 把它包起來就好,最簡單。

第三步才是看這份資料是否需要跨執行緒共享。單執行緒用 Rc<T>,多執行緒用 Arc<T>。一般來說還會把 Rc<T> 再包成 RC<RefCell<T>> 或是 Arc<Mutex<T>>、Arc<RwLock<T>> 達成內部可變性。

整理成決策樹,會長這樣:

要不要 Heap?
├─ 否 → 用 stack value(T)或 reference(&T / &mut T)
└─ 是 → 單一 owner?
  ├─ 是 → Box<T>
  └─ 否 → 要跨 thread?
    ├─ 否 → Rc<T>(可寫:Rc<RefCell<T>>)
    └─ 是 → Arc<T>
      └─ 可寫入嗎?
          ├─ 否 → Arc<T>
          └─ 是 → 多讀少寫?
            ├─ 否 → Arc<Mutex<T>>
            └─ 是 → Arc<RwLock<T>>

Rc 和 Arc 的內部結構長得差不多,主要差別在於 Arc 的操作會用帶有硬體鎖定的前綴的組語指令。

struct RcBox<T> {
    strong: usize,
    weak: usize,
    value: T,
}

struct Rc<T> {
    ptr: std::ptr::NonNull<RcBox<T>>,,
}
struct ArcInner<T> {
    strong: AtomicUsize,
    weak: AtomicUsize,
    value: T,
}

struct Arc<T> {
    ptr: std::ptr::NonNull<ArcInner<T>>,
}

既然提到了 Rc,那就不得不提到 Strong/Weak Count。

我們常看到在過去的教學文件中會提到,Weak Count 的目的是為了解決循環引用、為了解決記憶體洩漏問題。這是相對比較抽象、難以確實理解的部分。

具體來說,我們可以把 Rc 想像成是一個空盒子 (RcBox),這個空盒子會放實際資料。

更清楚一點地說:Rc 本身是一個指標 ptr,ptr 是放在 Stack 上的一個「指向 Heap 記憶體位址」的指標(ptr 的大小是固定的)。而該 Heap 記憶體位址跳過 2 組 usize offset,即為實際存放的資料內容 value: T。

RcBox 與普通的 Box 主要差別就在於 RcBox 多了 Strong/Weak 兩個標籤:

  • Strong (所有權計數):有多少人正在 Owned 實際資料內容 value: T?

  • Weak (觀察者計數):有多少人正在「觀察」這個 RcBox?

Strong 的存在性很好理解,也是最直覺的 Rc 實現原理,畢竟這就是 Box 沒辦法做到「多人共同擁有同一份資料」的關鍵點。

當 Strong Count == 0 時,就代表沒有人「擁有」這一份資料了(包含原本正在持有它的主執行緒),所以 Runtime 就會把 value: T Drop 掉,然後記憶體就不存在 value: T 的內容了。

但是!Runtime 把 value: T Drop 掉並不代表 RcBox 自身也會跟著被 Drop 掉呀!此時此刻記憶體仍然分配著 RcBox 的空間。

也就是說,這個 RcBox 只是變成一個空盒子而已,並不代表空盒子也從記憶體中被釋放掉了。

只要 Weak Count > 0,這個空盒子 RcBox 依舊在記憶體中存在,這就是為什麼會說 Weak 是觀察者計數的原因,代表有多少人正在盯著這個盒子,不論這個盒子裡面有沒有 value。

更正一下小細節:上面提到的各種 Count,都是指 API 層面可拿到的 Count。

事實上,只要 Strong Count > 0,底層 RcBox 的 Weak Count 必定 >= 1。

原因是底層有設計一個保護機制:所有的 Strong 指標,會集體共享 1 個隱形的 Weak 計數。確保只要還有人擁有實際資料 (value: T),RcBox 就絕對不會被 Deallocate。

只有當 Strong Count == 0 觸發 Drop value,底層才會自動把這 1 個隱形計數扣除。此時如果也沒有外部的 Weak 觀察者了,底層 Weak Count 才會真正歸零,並把空盒子 RcBox從記憶體中 Deallocate。

在呼叫 API Rc::weak_count(&RcObj) 時,輸出已經幫你把底層 Weak Count - 1 了,因此你才會有機會看到 Rc::weak_count(&RcObj) = 0 的狀態。

我們可以 Breakdown 一下邏輯:

  1. 任何時刻,盒子 (RcBox) 裡面可以有東西(value)。
    當 strong_count > 0 時,資料 (value) 在記憶體中存活。
    此時不論有沒有觀察者,盒子都在記憶體中存活。
    👉 if strong_count > 0, then assert(weak_count >= 0)

  2. 任何時刻,盒子裡面也可以沒有東西。
    當 strong_count == 0 時,資料已被 Drop 銷毀。
    💡 要能看到這個狀態,你手上必定是拿著 Weak 指標在觀測。
    既然你有 Weak 指標,Weak Count 就不可能是 0。
    👉 if strong_count == 0, then assert(weak_count > 0)

  3. 只要你還能呼叫 API,盒子必定存在。
    如果 API 顯示 Weak Count 為 0 時,那必定還有 Strong Count。
    當 Strong Count 存在,就代表一定有人還擁有資料、資料還存在記憶體。

  4. 任何時刻,如果盒子不存在,那必定沒有東西。
    當底層判定 strong_count == 0 且 weak_count == 0 的那一瞬間,盒子會被記憶體釋放。因此這是一個不可觀測狀態。

最後,只要是一個具有 Parent & Child 關係的資料結構,就一定會有需要用到 Weak!一些假想場景像是:

  1. Trees 樹狀資料結構。

  2. Double Linked List 雙向連結串列。

  3. Caching System 快取資料與真實資料之間的關係。

  4. App Sub-windows 主執行緒與背景執行緒之間的關係。

  5. Event Bubbling 子執行緒將事件冒泡向上傳遞訊息給父節點。


👨‍⚖️ Cell 和 RefCell 的差別?請解釋 Interior Mutability。

👨‍💻 簡短一句話:兩者都是能用來繞過編譯器的 Borrowing Rules 限制,實現內部可變性。Cell 藉由 Copy 或 Move 整個值來修改內容,主要適用簡單型別;而 RefCell 則透過把 Borrow Check 搬到 Runtime 再檢查,提供了 Mutable Reference,適合複雜的型別。
一個需要使用 Interior Mutability 的場景像是「紀錄某個 Immutable 物件的存取次數」或是「多個物件共享一個 Rc 結構體,並且想要修改它的內部欄位」,數據本身的外觀是不可變的,但我們會想要透過某種方式繞過編譯器的檢查、在 Runtime 期間修改它的內部狀態。Interior Mutability 設計模式不代表開發者繞過了 Borrowing Rules,只是把 Borrow Checks 搬到 Runtime 期間做檢查,因此,如果 RefCell 使用不當,是會導致執行緒 Panic 的。

🕵️‍♂️ 再深入一點的說法:

先從 Cell<T> 開始講起。Cell 其實和 Java、C# 和 C++ 的 Getter / Setter 設計模式很像。

在其他語言中,Getter / Setter 主要是用來封裝一個物件裡面的欄位,因為某些欄位可能是 private 或 public 的,開發者可以在 Setter Function 裡面加一些檢查邏輯,來集中控管物件的狀態,例如輸入參數邊界檢查、觸發 UI 更新、Logger Config 等等。

但在 Rust 底下,如果我們要達成像是 Setter Function 一般的邏輯,我們必須將 Setter Function 的函式簽名改成用 &mut self。

fn set_value(&mut self, new_value: i32) {
    self.value = new_value;
}

這種限制會產生一個問題:當我們在宣告函式簽名時使用了 mut 關鍵字,就會被借用規則(Borrowing Rules)或者 Trait 給限制住。因為使用了 mut,借用者(Borrower)就必須獨佔整個 self。

但我們的用例中,可能只是想稍微變更 self 內部的某個小欄位,例如訪問計數器 + 1,並不需要獨佔整個 self。這種時候就需要用 Cell 繞過編譯器的檢查。

簡單說 Cell<T> 的底層實現邏輯是會再包一層 UnsafeCell<T>,用裸指標的方式複寫 T 的記憶體位址的內容值。

使用裸指標,聽起來很不安全?但其實 Cell<T> 不會交出 &T 或 &mut T,也不提供可以直接修改它們的方法,而且 Cell<T> 也禁止跨執行緒同步(!Sync)所以也排除了可能會發生 Data Race 的可能性。

// ⚠️ 這段程式無法編譯成功,因為 Cell<T> 是 !Sync 的
use std::cell::Cell;
use std::sync::Arc;
use std::thread;

fn main() {
  let counter = Arc::new(Cell::new(0));
  let counter_clone = Arc::clone(&counter);

  // ❌ error[E0277]: `Cell<i32>` cannot be shared between threads safely
  let handle = thread::spawn(move || {
    let val = counter_clone.get();
    counter_clone.set(val + 1);
  });

  handle.join().unwrap();
  println!("Counter: {}", counter.get());
}

讓我們看一個 Cell<T> 的用例。我們有個 AppConfig 並且假設有個 Trait 約束了它應該要實現的函式。

某天,數據部門突然發來新的開發要求:想要觀測到 AppConfig 的讀取次數。

而這正是可以用 Cell<T> 解決的問題:

use std::cell::Cell;
use std::rc::Rc;

struct AppConfig {
  api_endpoint: String,
  access_count: Cell<usize>, 
}

impl AppConfig {
  fn new(endpoint: &str) -> Self {
    AppConfig {
      api_endpoint: endpoint.to_string(),
      access_count: Cell::new(0),
    }
  }

  // 注意:這裡是 &self (Immutable Reference)
  fn get_endpoint(&self) -> &str {
    // 透過使用 Cell 變更內部狀態
    let current_count = self.access_count.get();
    self.access_count.set(current_count + 1);
    
    &self.api_endpoint
  }
}

fn main() {
  // 假設 AppConfig 被包在 Rc 中,讓多個系統元件共享
  let config = Rc::new(AppConfig::new("https://api.example.com"));

  let ui_module = Rc::clone(&config);
  let worker_module = Rc::clone(&config);

  // 這些模組都只有不可變參照,但依然可以呼叫 get_endpoint
  ui_module.get_endpoint();
  worker_module.get_endpoint();
  ui_module.get_endpoint();

  println!("設定檔總共被存取了 {} 次", config.access_count.get()); 
  // 輸出: 設定檔總共被存取了 3 次
}

一般來說,我們會用 Cell<T> 來裝具有 Copy 型別的 T。比較複雜的 Clone 型別的 T 則是會推薦用 RefCell。

Rust 2017 更新後,Cell<T> 是可以包 Non-Copy 型別的,但沒有 .get() 能使用,只能用 .set() 改資料或用 .take() / .replace() 把資料抽換掉。

接下來看 RefCell<T>,從字面意思可以知道這是設計給 Reference 使用的。

當資料結構太大、不能用 Copy 做簡單的替換,又要繞過編譯期的借用規則檢查,就需要使用 RefCell<T>。

RefCell<T> 的原理是在內部維護了一個借用計數器(Borrow Counter),用來在 Runtime 期間追蹤目標當前的借用狀態。RefCell<T> 簡化過的內部結構長這樣:

use std::cell::Cell;
use std::cell::UnsafeCell;

// Docs:
// https://doc.rust-lang.org/src/core/cell.rs.html#849

pub struct RefCell<T: ?Sized> {
  borrow: Cell<isize>,      // 記錄目前借用狀態的動態計數器
  value: UnsafeCell<T>,     // 真正存放資料的地方
}
  • borrow = 0:目前沒人借用

  • borrow > 0:當前有 N 個不可變借用 via .borrow()

  • borrow = -1:當前有 1 個可變借用 via .borrow_mut()

Runtime 期間依舊會遵循 Borrowing Rules,具體檢查方式就是根據上方的內部狀態 borrow 。

未完待續...


👨‍⚖️ Trait System 與其他語言的差別(例如 TypeScript's Interface)?

👨‍💻 簡短一句話:

🕵️‍♂️ 再深入一點的說法:


👨‍⚖️ Unpin 是什麼?什麼時候會用到?

👨‍💻 簡短一句話:

🕵️‍♂️ 再深入一點的說法:


結語

這一篇文章提到了蠻多 Smart Pointers。

之前在 YouTube 上看到 14 Rust Smart Pointers Compared 這部影片,一步影片完整介紹了所有 Rust 的 Smart Pointers。

https://www.youtube.com/watch?v=rNhfQimiwEs

未來會找時間再對這 14 個 Rust Smart Pointers 做一個詳細的介紹,當作看這一步影片的學習筆記。