Rust 開發 - 第一天
一個務實的基礎,讓你從第一次提交就能寫好 Rust 應用程式。這不是教科書。重點在於與其他語言的差異、那些從第一天就會影響一切形狀的決策,以及涵蓋大多數實際應用程式的小型精選 crate 清單。
何時使用
- 啟動一個新的 Rust 專案(CLI、服務、函式庫)
- 從 Python、JavaScript、Go、Java/C# 或 C++ 轉向 Rust
- 在擁有/借用類型、智慧指標、trait 物件與泛型之間做選擇
- 挑選錯誤處理策略(
anyhowvsthiserror) - 決定要使用哪些 crate
- 配置一個最小但具有主觀意見的
Cargo.toml、clippy 和 rustfmt
第一天設定
# 1. 安裝工具鏈 (rustup 是工具鏈管理器)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. 確認元件 (rustfmt 和 clippy 隨 stable 版本提供,rust-src 啟用 IDE 功能)
rustup component add rustfmt clippy rust-src
# 3. 建立專案
cargo new my-app # 二進位 (src/main.rs)
cargo new --lib my-lib # 函式庫 (src/lib.rs)
# 4. 開發循環 (記住這四個)
cargo check # 快速型別檢查,不產生程式碼
cargo run # 建置並執行 (二進位)
cargo test # 建置並執行測試 (包含 doctest)
cargo clippy --all-targets # lint,包含 tests/ 和 benches/ (推送前執行)
cargo fmt # 格式化
# 5. 管理相依性,不需手動編輯 Cargo.toml
cargo add tokio --features full
cargo remove tokio
cargo update # 在現有 semver 範圍內重新計算 Cargo.lock
cargo update 只會在 Cargo.toml 中已有的版本範圍內移動。跨越主版本(1.x 到 2.0)需要編輯 Cargo.toml 或使用 cargo add <crate>@2。
rust-analyzer 是必需的。 它是每個編輯器都會使用的語言伺服器(VS Code、Zed、Neovim、Helix,RustRover 使用自己的引擎但功能相當)。在 VS Code 中,安裝 rust-analyzer 擴充功能,並將 rust-analyzer.check.command 設定為 "clippy",這樣就能在儲存時獲得 lint 反饋。
想要之後使用檔案監看器? 執行 cargo install --locked bacon,然後在你的專案中執行 bacon。第一天不需要。
Rust 心智模型:5 個概念
Rust 用兩件事交換了你在大多數語言中視為理所當然的東西(垃圾回收器和例外):編譯時關於記憶體、資料競爭和錯誤處理的保證。語言的形狀來自這個交易。
1. 所有權:每個值都只有一個擁有者
把值想成實體對象。一本書、一個檔案、一個網路連線。在任何時刻,有一個變數擁有它。你可以:
- 移動它:
let b = a;將所有權交給b。a消失了。 - 不可變地借用它:
&a讓其他人查看它。允許多個讀者。 - 可變地借用它:
&mut a允許一個人修改它。獨占存取。 - 克隆它:
a.clone()建立一個深拷貝。兩者都擁有自己的副本。
當擁有者離開作用域時,該值被丟棄(記憶體釋放、檔案關閉、鎖定釋放)。沒有 GC,沒有手動的 free。這就是 RAII,由編譯器強制執行。
2. 別名異或可變性
在任何時刻,一塊資料具有要麼:
- 一個可變引用(
&mut T),要麼 - 任意數量的不可變引用(
&T),
但不能同時存在。這條規則消除了資料競爭和大多數釋放後使用錯誤。借用檢查器強制執行它。當它抱怨時,它是在告訴你你的資料所有權故事不夠清晰,而不是語言在為難你。
3. 錯誤是值,不是例外
沒有 try/catch。可能失敗的函式返回 Result<T, E>。可能不返回有用東西的函式返回 Option<T>。編譯器強制你處理這兩者。? 運算子用一個字元將錯誤向上傳播到呼叫堆疊:
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = 在 Err 時提前返回
let config = toml::from_str(&text)?;
Ok(config)
}
沒有 null。Option<T> 是 None 或 Some(value)。編譯器不會讓你忘記 None 的情況。
4. Trait 不是 Java 介面
trait 定義行為。類型 impl traits。到目前為止還算熟悉。不同之處在於:
- 靜態分派是預設的。 當你寫
fn f<T: Display>(x: T)時,編譯器會為你呼叫的每個具體T產生一個獨立的f副本(單態化,就像 C++ 模板)。執行時期零開銷。 - 動態分派是選擇加入的,透過
dyn Trait(通常是Box<dyn Trait>或&dyn Trait)。每次呼叫有一次 vtable 查找。 - 沒有繼承。 Trait 可以組合。如果你發現自己為了「擴展」一個類型而使用
Deref,停下來改用組合或列舉。 - 孤兒規則:你可以為別人的類型實作自己的 Trait,或為自己的類型實作別人的 Trait,但不能兩者都是外部的。這保持了相依性解析的合理性。
5. 借用檢查器是一個設計預言
新進者最常見的錯誤是將編譯器錯誤視為需要靜音的障礙。它們不是。幾乎每一個借用檢查錯誤都揭露了一個關於誰擁有什麼的真實問題。當你卡住時,問題很少是「我如何讓這個編譯通過」,而幾乎總是「我這裡想要的實際所有權關係是什麼?」閱讀錯誤訊息。編譯器異常地資訊豐富。
每個函式簽章的 3 個問題
在寫函式之前,問:它需要擁有、讀取還是修改輸入?
fn consume(s: String) // 擁有: 函式接管責任,呼叫者失去它
fn read(s: &str) // 讀取: 函式查看它,呼叫者保留它
fn modify(s: &mut String) // 修改: 函式就地改變它
90% 情況下適用的預設值:
- 函式參數:偏好
&str勝過String,偏好&[T]勝過Vec<T>(這些是切片,可以接受擁有和借用的呼叫者)。 - 函式返回值:返回擁有類型(
String、Vec<T>)。返回引用意味著生命週期;在需要它們之前避免使用。 - 結構體欄位:偏好擁有類型(
String、Vec<T>)。在結構體中儲存&str是新進者最常見的陷阱,它會將生命週期註解級聯到所有持有你結構體的類型。
第一天決策表
對於首先出現的選擇,提供一行答案。
| 決策 | 預設值 | 何時選擇另一個 |
|---|---|---|
String vs &str (結構體欄位) | String | 幾乎永遠不要用 &str,直到你有一個真正的理由並且理解生命週期 |
String vs &str (函式參數) | &str | 只有當你必須在內部擁有/儲存它時才使用 String |
Vec<T> vs &[T] (參數) | &[T] | 只有當你必須擁有時才使用 Vec<T> |
Box<T> vs Rc<T> vs Arc<T> | Box<T> (單一擁有者,堆積) | Arc<T> 用於跨執行緒的共享所有權。避免預設使用 Rc<T>;使用 Arc<T> 這樣當你轉向非同步時就不需要重構 |
RefCell<T> vs Mutex<T> | Mutex<T> (或 RwLock<T>) | 同樣原因:在 async/執行緒中工作,而 RefCell 不行 |
Option<T> vs Result<T, E> | Option<T> 用於「沒有值」,Result<T, E> 用於「因某種原因失敗」 | 如果缺席傳達了呼叫者應該處理的意義,則使用 Result |
dyn Trait vs impl Trait / <T: Trait> | 泛型 (<T: Trait> 或 impl Trait) - 靜態分派 | 當你需要一個異質集合時使用 Box<dyn Trait> (Vec<Box<dyn Animal>>) |
| 應用程式程式碼中的錯誤 | 處處使用 anyhow::Result<T> | - |
| 函式庫程式碼中的錯誤 | thiserror 衍生的列舉 | 永遠不要在公開函式庫 API 中使用 Box<dyn Error> - 這會強迫呼叫者進行 downcast |
&self vs &mut self vs self | &self 用於 getter,&mut self 用於 setter,self 用於 builder/消耗性操作 | - |
| 模組佈局 | 內聯模組,直到檔案變長,然後拆分 | 一個模組 = 一個檔案是 Java/C# 的直覺,不是 Rust 的 |
需要儘早內化的慣用寫法
這些幾乎出現在每個 Rust 程式中。在第一週學會它們。
? 用於錯誤傳播。 用一個字元取代九行 match。
let body = reqwest::get(url).await?.text().await?;
迭代器鏈勝過手動迴圈。 編譯成與手寫迴圈相同的機器碼(LLVM 內聯閉包)。慣用的 Rust 在風格上是函數式的。
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
match 的窮盡性。 為一個列舉添加一個新變體,而不處理它的每個 match 都會變成編譯錯誤。善用這個。這是任何語言中最強大的重構工具之一。
讓編譯器驅動重構。 同樣的技巧也適用於結構體:添加一個沒有 Default 的必填欄位,每個現有的建構子都會變成編譯錯誤 - 這是一個免費且窮盡的、需要更新的所有位置的工作清單。
if let 和 let else 用於常見的單臂 match。
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// 從這裡開始 `name` 在作用域內,沒有巢狀
From / Into 用於類型轉換。 實作 From,免費獲得 Into。? 使用 From 自動轉換錯誤類型。
Option / Result 上的組合子。 在求助於 match 之前,先嘗試 .map、.and_then、.unwrap_or、.unwrap_or_else、.ok_or。
衍生意義巨集。 #[derive(Debug, Clone, PartialEq)] 可以免費獲得 80% 的樣板程式碼。添加 #[derive(Serialize, Deserialize)] 用於 JSON。
最近的語法糖。 值得了解的已穩定功能,按最新排序:bool::ok_or 和 ok_or_else (1.98) 將條件直接變成 Result (ready.ok_or(Error::NotReady)?)。cfg_select! 是一個在 cfg 謂詞上的編譯時 match,取代了 cfg-if crate 的大部分用途,而 if let guards 作用於 match 分支 (match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }) - 兩者都是 1.95。assert_matches! 和 debug_assert_matches! (1.96) 是對模式而不是相等性進行斷言,這是列舉的自然斷言方式。你會經常看到的兩個較舊功能,不要誤以為它們是奇特的:let-chains (if let Some(x) = a && x > 3 { ... }),自 1.88 起在 edition 2024 中穩定;以及非同步閉包 (async || { ... },帶有 AsyncFn 系列約束),自 1.85 起穩定。
從 X 語言過來,這裡是會坑你的地方
從 Python 或 JavaScript 過來:
- 對於堆積上的值(如
String、Vec),let b = a;移動了它。a不再可用。請用&a來借用,或a.clone()來複製。 - 沒有 null。
Option<T>是強制性的。 - 沒有例外。
Result<T, E>和?。編譯器不允許你忽略錯誤。 - 沒有繼承。組合 + traits + 列舉。
- 變數預設是不可變的。添加
mut來使其可變。引用也是如此:&vs&mut。 - 重新宣告一個名稱是允許且慣用的:
let input = input.trim();隱藏了舊的繫結,可能具有不同的類型。這不是可變性,這就是 Rust 程式碼就地轉換值而不發明input2的方式。 - 整數類型是明確的,索引需要
usize。它們的溢出行為也根據建置而不同:debug 模式「包含整數溢位檢查,這會導致你的程式在執行時期 恐慌」,而--release模式則移除它們並執行「二的補數包裝」。一個通過的測試套件仍然可能在生產環境中包裝,因此在那些算術可能實際到達邊界的地方使用checked_*/saturating_*/wrapping_*,而不是依賴 debug 恐慌來發現它。strict_add及其同類函式(自 1.91 起穩定)在每次建置中都會在溢位時恐慌,而[profile.release]下的overflow-checks = true則在生產環境中保留 debug 行為。此外,as在整數類型之間會默默地截斷(300_u32 as u8是44)- 在任何值可能不適合的地方使用u8::try_from(x)?。 String是不可索引的。s[0]無法編譯 - 請參閱references/ownership-and-types.md了解應該用什麼替代。
從 Go 過來:
- 錯誤作為值 - 相同的直覺,但改用
?而不是if err != nil。 - 沒有
nil。Option<T>。 - 沒有 GC 也沒有 goroutine:所有權 + 借用,使用
tokio的 async/await。async 模型是協作式的(await是一個明確的讓出點),而不是搶佔式的。 interface{}變成 trait。預設使用泛型進行靜態分派;只在需要時使用Box<dyn Trait>。- 靜態連結是預設的。二進位檔更大但自足。
panic!應該保留給應用程式程式碼中無法恢復的錯誤;不要像 Go 的「記錄並繼續」那樣使用它。
從 Java 或 C# 過來:
- Trait 不是預設具有虛擬分派的介面。
<T: Trait>是單態化的。dyn Trait是選擇加入的動態版本。 - 沒有 null 引用。
Option<T>。 - 沒有例外。
Result<T, E>和?。 - 沒有類別繼承。使用列舉作為 sum 類型,使用 trait 作為共享行為。
- 沒有 GC:所有權和借用決定生命週期。
Arc<T>是最接近 Java 引用的東西。 - 泛型是單態化的,而不是類型擦除。
從 C++ 過來:
- 就像 RAII,但借用檢查器在編譯時強制執行它。
- 沒有拷貝/移動建構子。
Clone是明確的,而Copy是一個用於廉價逐位複製的標記 trait。 - 在安全程式碼中沒有未定義行為(理論上)。
&是一個編譯時檢查的借用,而不是裸指標。裸指標存在(*const T、*mut T)但需要unsafe才能解引用。- 智慧指標包括
Box<T>(unique_ptr)、Rc<T>(shared_ptr,單執行緒)、Arc<T>(shared_ptr,執行緒安全)。 - 巨集是衛生的。程序式巨集(derive、attribute、function-like)是
serde、tokio::main等工作方式的基礎。
精選 Crate 清單
這些涵蓋了大多數實際應用程式。按需添加;它們並非全部必需。
| Crate | 它給你什麼 |
|---|---|
serde + serde_json | 序列化。#[derive(Serialize, Deserialize)] 就完成了 |
tokio | 非同步執行時期。#[tokio::main]、tokio::spawn、非同步 I/O |
anyhow | 應用程式錯誤類型。anyhow::Result<T>、bail!、context() |
thiserror | 函式庫錯誤列舉。#[derive(thiserror::Error)] |
clap | CLI 參數解析。#[derive(Parser)] |
rayon | 資料並行性。將 .iter() 替換為 .par_iter() 以獲得並行性 |
reqwest | HTTP 客戶端。非同步、TLS |
axum | Web 框架。基於 tokio 和 tower 生態系統 |
sqlx | 非同步 SQL。編譯時檢查的查詢、遷移、Postgres/MySQL/SQLite |
tokio-tungstenite + futures-util | WebSocket。tokio-tungstenite 處理連線協定,futures-util 上的 StreamExt / SinkExt 處理訊息 |
chrono | 日期時間。DateTime<Utc>、期間等 |
serde_qs + serde_with | 查詢字串序列化和反序列化,以及欄位層級的序列化修飾詞 |
tower / tower-http | 中介軟體。tower:服務抽象;tower-http:HTTP 專用中介軟體(壓縮、CORS、追蹤、請求/回應記錄) |
tracing | 紀錄。tracing-subscriber 進行設定。#[instrument] 自動追蹤函式進入/離開 |
tempfile | 測試用的暫存檔案和目錄。NamedTempFile、TempDir |
similar | 差異/編輯距離。用於工具中的差異和變更檢測 |
divan | 基準測試。#[divan::bench],漂亮的輸出,強大的比較功能 |
strum | 列舉的輔助 derive 巨集。Display、EnumString、AsRefStr、IntoStaticStr、EnumIter、EnumCount、EnumDiscriminants、EnumMessage、EnumProperty、EnumTable |
serde_yaml | 如果 configuration 是 YAML 格式。否則預設使用 TOML;serde_json 甚至更快 |
toml | TOML 序列化和反序列化。透過 serde |
uuid | Uuid::new_v4()、Uuid::parse_str() |
regex | Regex::new(r"^...$")?。編譯時期檢查?regex crate 本身不行,但 Regex::new 在 const 上下文中是允許的;或者使用 regex-lite 或 once_cell/LazyLock 進行延遲初始化 |
關於錯誤處理的特別說明
anyhow 和 thiserror 解決不同的問題。在應用程式程式碼中,使用 anyhow::Result<T>,這樣你就可以用 ? 傳播任何實現 std::error::Error 的類型,並用 .context() 豐富錯誤訊息。在函式庫程式碼中,導出一個由 thiserror 衍生的具體錯誤類型;這讓呼叫者可以在 match 中使用你的錯誤,而無需 downcast。將它們兩者配對使用是慣例:函式庫導出 thiserror 類型,應用程式使用 anyhow 包裝它們。
// 函式庫
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error at line {line}: {msg}")]
Parse { line: usize, msg: String },
}
// 應用程式
fn load() -> anyhow::Result<Config> {
let raw = std::fs::read_to_string("config.toml")?; // 自動轉換 Io
let cfg: Config = toml::from_str(&raw).context("invalid config")?; // 添加上下文
Ok(cfg)
}
最低 Cargo.toml
這些是你想要在專案中設定的 clippy 和 rustfmt 設定。
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
[dependencies]
# 一組很好的預設值:
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
[lints.clippy]
cargo = "warn"
pending = "deny" # 你可以在推送到 CI 之前保留待辦事項
style = "warn" # Clippy 的風格類別設為 warn(預設)而不是 allow "。
# optional: unwrap_used = "deny" # 對 unwrap 零容忍
# optional: panic = "deny" # 對 panic 零容忍
# optional: todo = "deny" # 在 CI 中防止合併 todo!()
[profile.release]
# 限制優化以保持更快的建置,除非你需要最大吞吐量
# debug = 1 給你良好的回溯訊息
codegen-units = 1
lto = "fat"
debug = 1
# 保留 debug 建置的整數溢出行為
overflow-checks = true
edition、rust-version 和 MSRV
edition控制語法(2015、2018、2021、2024)。2024 edition 在 Rust 1.85 中穩定;現在大多數新的 crate 都使用 2021 以獲得廣泛相容性,並在準備就緒時遷移到 2024。rust-version設定最低支援的 Rust 版本(MSRV)。這會告訴工具鏈,此軟體包在舊版 Rust 上無法建置。它可以是一個完整的 semver 版本,如"1.85"。對於函式庫來說至關重要;對於應用程式來說,它是可選的(而且一般是重新評估的)。- 如何選擇:如果你以 Rust 的最新穩定版本為目標,請省略
rust-version以獲得最大的靈活性。如果你正在發布一個函式庫,請設定一個你所針對和測試的具體 MSRV。
[lints] 表格(Rust 1.74+)
[lints.clippy] 表格讓你可以將 clippy 配置保留在你的 Cargo.toml 中,而不是 .clippy.toml 或 #![allow(clippy::...)] 註解。上面的設定給了你好慣例。隨著時間的推移,針對你的領域進行調整。
CLI 專案的快速入門
cargo new my-cli && cd my-cli
cargo add clap --features derive
# 在你的 src/main.rs 中:
# use clap::Parser;
# /// 你的 CLI 文件字串
# #[derive(Parser)]
# struct Cli { ... }
# fn main() { let cli = Cli::parse(); ... }
非同步服務的快速入門
cargo new my-service && cd my-service
cargo add tokio --features full
cargo add axum serde serde_json
cargo add tower-http --features cors,compression-gzip,trace
cargo add tracing tracing-subscriber
cargo add sqlx --features runtime-tokio-rustls,postgres
tracing 設定:
// 在你的 main 函式頂部
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(tracing_subscriber::EnvFilter::from_default_env())
.init();
需要了解的資源
- 標準函式庫文件:
std::collections、std::sync、std::io、std::fs、std::path、std::net、std::time、std::thread、std::process、std::env、std::fmt、std::convert、std::cmp、std::ops、std::hash、std::iter、std::str、std::mem、std::ptr、std::slice、std::vec、String、OsString、PathBuf、Duration、SystemTime、Instant。 - Rust 非同步書:https://rust-lang.github.io/async-book/
- tokio 教學:https://tokio.rs/tokio/tutorial
- Rust API 指南:https://rust-lang.github.io/api-guidelines/
- Rust 參考資料:https://doc.rust-lang.org/reference/
- Rustonomicon(unsafe):https://doc.rust-lang.org/nomicon/
- crates.io:https://crates.io/
- lib.rs(社群評級):https://lib.rs/
- 這不是另一篇 Rust 教學 - 對替代方案的深入研究:理解當前 crate 生態系統以及為何某些選擇成為標準
- 日誌:
env_logger、tracing-subscriber、log→tracing - Web 框架:
axumvsactix-webvsrocketvswarp - ORM/DB:
dieselvssqlxvssea-orm - 序列化:
serde與miniserde、rkyv、nanoserde、
- 日誌: