iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師:借用 (Borrowing):有契約的共享

當「過戶」太昂貴時

在 Day2 中,我們見識了 Rust 所有權的特性:一個值只能有一個所有者,賦值就是「過戶」。

但現實中,我們經常需要共享資料(借用),而不是轉移它。

https://ithelp.ithome.com.tw/upload/images/20250917/20124462rOUF1habQv.png

假設你有一本珍貴的書,朋友想借閱幾天。
在 Rust 的世界裡,你不會直接把書「過戶」給朋友(因為這樣你就失去了它),而是「借出」給朋友,並約定好歸還的時間和條件。

這就是 借用(Borrowing) 的主要概念。

借用的「契約」:不可變與可變的鐵律

Rust 的借用系統建立在看似簡單,但極很強大的規則之上:

要嘛很多人一起讀(不可變借用 &T),要嘛只有一個人能寫(可變借用 &mut T

這條規則直接消滅了無數的 data race,讓我們先看看它如何在實際程式碼中運作。

不可變借用:多讀者的安全共享

let s1 = String::from("hello");
let s2 = &s1;  // s2 借用 s1 的資料
let s3 = &s1;  // s3 也借用 s1 的資料
let s4 = &s1;  // s4 同樣借用 s1 的資料

println!("s1: {}", s1);  // ✅ s1 仍然有效
println!("s2: {}", s2);  // ✅ s2 可以讀取
println!("s3: {}", s3);  // ✅ s3 可以讀取
println!("s4: {}", s4);  // ✅ s4 可以讀取

在這個例子中,s1 仍然是資料的所有者,而 s2s3s4 都是借用者

關鍵在於:所有借用都是不可變的,它們只能讀取資料,不能修改。
這就像多個人同時閱讀同一本書,只要沒有人塗改,就不會有衝突。
https://ithelp.ithome.com.tw/upload/images/20250917/20124462tYlfWd4eY8.png

可變借用:獨占寫入權

當你需要修改資料時,情況就完全不同了:

let mut s1 = String::from("hello");
let s2 = &mut s1;  // s2 獲得可變借用

// s2.push_str(" world");  // ✅ 可以修改
// println!("s1: {}", s1); // ❌ 編譯錯誤!s1 被借用了

println!("s2: {}", s2);  // ✅ s2 可以讀取和修改

注意這裡的關鍵點:

  1. 獨占性:當 s2 獲得可變借用時,s1 就不能再被使用了
  2. 排他性:不能同時有多個可變借用
  3. 明確性:必須明確宣告 mut 才能獲得可變借用

這就像圖書館的「獨占閱覽室」,一次只能有一個人使用,而且這個人可以修改書的內容。

https://ithelp.ithome.com.tw/upload/images/20250917/20124462lwq8OLFKkQ.png

借用規則的視覺化:三種狀態的切換

讓我們用一個流程圖來理解借用規則:
https://ithelp.ithome.com.tw/upload/images/20250917/20124462loO33Cs3SO.png

與其他語言的對比:從「隱式共享」到「顯式契約」

Python/JavaScript:隱式的危險共享

在 Python 或 JavaScript 中,共享是隱式的,也是危險的:

# Python
data = [1, 2, 3]
reference1 = data
reference2 = data

reference1.append(4)
print(data)        # [1, 2, 3, 4] - 意外修改!
print(reference2)  # [1, 2, 3, 4] - 也被影響了!

這裡的問題是:你不知道有多少個引用指向同一份資料,也不知道誰會在什麼時候修改它。

Golang:顯式但容易出錯

Golang 透過指標讓共享變得顯式:

// Go
data := []int{1, 2, 3}
ptr1 := &data
ptr2 := &data

(*ptr1)[0] = 999
fmt.Println(data)   // [999, 2, 3] - 被修改了
fmt.Println(*ptr2)  // [999, 2, 3] - 也被影響了

雖然共享變得顯式,但 Go 無法在編譯期阻止 data race:

// Go - 這會導致 data race,但編譯器不會阻止
go func() {
    (*ptr1)[0] = 100
}()
go func() {
    (*ptr2)[1] = 200
}()

Rust:編譯期保證的安全共享

Rust 的借用系統結合了顯式性和安全性:

let mut data = vec![1, 2, 3];
let ptr1 = &mut data;
// let ptr2 = &mut data;  // ❌ 編譯錯誤!不能同時有兩個可變借用

ptr1[0] = 999;
println!("{:?}", data);  // [999, 2, 3]

更重要的是,Rust 在編譯期就阻止了 data race:

// 這在 Rust 中根本無法編譯
let mut data = vec![1, 2, 3];
let ptr1 = &mut data;
let ptr2 = &mut data;  // ❌ 編譯錯誤!

// 即使使用多執行緒,編譯器也會阻止
std::thread::spawn(|| {
    ptr1[0] = 100;  // ❌ 編譯錯誤!ptr1 的生命週期不夠長
});

借用的生命週期:編譯器的時間戳

借用不僅有所有權的概念,還有時間的概念。

let r;  // 宣告一個引用變數
{
    let x = 5;
    r = &x;  // ❌ 編譯錯誤!x 的生命週期太短
}
println!("r: {}", r);  // r 試圖使用已經被銷毀的 x

這個例子展示了 Rust 的生命週期檢查

  • x 在內層作用域中創建
  • r 試圖借用 x
  • x 在內層作用域結束時就被銷毀了
  • r 在外層作用域中還試圖使用它

Rust 編譯器會拒絕這樣的程式碼,因為它違反了「借用不能比被借用的資料活得更久」的基本原則。

https://ithelp.ithome.com.tw/upload/images/20250917/20124462s0P6u2ntIT.png

實際應用:函式參數的借用

讓我們看看借用如何在函式參數中發揮作用:

fn calculate_length(s: &String) -> usize {
    s.len()  // 借用 s,不取得所有權
}

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // 傳遞借用
    println!("The length of '{}' is {}.", s1, len);  // s1 仍然有效
}

對比一下如果使用所有權轉移:

fn calculate_length(s: String) -> usize {
    s.len()  // 取得所有權,s 在函式結束時被銷毀
}

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(s1);  // s1 的所有權被轉移
    // println!("s1: {}", s1);  // ❌ 編譯錯誤!s1 已經無效
}

借用的限制:為什麼需要這些規則?

你可能會問:為什麼 Rust 要設計這些看似嚴格的借用規則?

答案是:這些規則直接對應到現實中的併發安全問題

1. 防止 Data Race

// 這在 Rust 中無法編譯
let mut data = 0;
let ptr1 = &mut data;
let ptr2 = &mut data;  // ❌ 編譯錯誤!

// 即使使用多執行緒也無法編譯
std::thread::spawn(|| {
    *ptr1 += 1;  // ❌ 編譯錯誤!
});
std::thread::spawn(|| {
    *ptr2 += 1;  // ❌ 編譯錯誤!
});

2. 防止懸空引用

let r;
{
    let x = 5;
    r = &x;  // ❌ 編譯錯誤!x 的生命週期不夠長
}
// r 在這裡試圖使用已經被銷毀的 x

3. 強制明確的資料流

借用規則強迫你明確表達意圖:

  • &T:我只想讀取,不會修改
  • &mut T:我需要修改,給我獨占權
  • T:我要取得所有權,用完就銷毀

借用的進階應用:切片 (Slices)

切片是 Rust 中一個優雅的借用應用:

let s = String::from("hello world");
let hello = &s[0..5];    // 借用字串的一部分
let world = &s[6..11];   // 借用字串的另一部分

println!("{}", hello);   // "hello"
println!("{}", world);   // "world"
println!("{}", s);       // "hello world" - 原始字串仍然有效

切片讓我們可以安全地引用資料的一部分,而不需要複製或轉移所有權。

總結:借用的哲學

Rust 的借用系統體現了一種深刻的哲學:

共享是可能的,但必須有明確的契約。

這個契約由編譯器在編譯期強制執行,確保:

  1. 安全性:不會有 data race 或懸空引用
  2. 明確性:每個借用的意圖都很清楚
  3. 效率:不需要複製資料,只是傳遞引用

對習慣了「隱式共享」的開發者來說,這可能看起來過於嚴格。

但好處是這嚴格性,能夠編寫出既安全又高效的併發程式碼。

在下一天中來探討生命週期 (Lifetimes),看看 Rust 如何確保「借用的時間安全性」。

相關連結與參考資源

Rust 官方文件

深入理解借用


上一篇
(Day2) Rust 所有權 (Ownership):變數的「單身證明」
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言