Rust 基本語法
Rust 是靜態型別、編譯型的系統語言。程式在編譯期就決定型別,並用**所有權(ownership)**在不靠垃圾回收的前提下管理記憶體。日常開發以 cargo 建專案、編譯與執行。工具鏈安裝見 安裝。
本文依 Rust 2024 edition(cargo new 自 Rust 1.85 起的預設)整理常用寫法。範例都可放進 fn main() 直接跑。
第一個程式
cargo new hello
cd hello
cargo run
src/main.rs:
fn main() {
println!("Hello, world!");
}
Cargo.toml 會標 edition。新專案大致如下:
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
| 指令 | 用途 |
|---|---|
cargo run | 編譯並執行 |
cargo build | 編譯 debug 版,產物在 target/debug/ |
cargo build --release | 最佳化後的 release 版 |
cargo check | 只做型別檢查,不產生執行檔 |
cargo test | 跑 #[test] 測試 |
cargo fmt | 用 rustfmt 排版 |
cargo clippy | 靜態檢查,抓常見寫法問題 |
println! 結尾的 ! 表示這是巨集(macro),不是一般函式。格式化用 {},除錯用 {:?}(型別需實作 Debug):
let name = "Rust";
let version = 1.85;
println!("{name} {version}");
println!("name = {name:?}");
變數、常數與遮蔽
預設不可變。要改值必須標 mut:
let x = 5;
let mut y = 5;
y += 1;
同一個名稱可以再 let 一次,這叫遮蔽(shadowing)。型別可以跟著變,適合「先拿到值、再轉成另一種型別」:
let spaces = " ";
let spaces = spaces.len(); // usize
常數用 const,必須標型別,而且只能是編譯期就能算出的值:
const MAX_POINTS: u32 = 100_000;
數字可用底線分位,100_000 與 100000 相同。
static 是整個程式存活的值,可變的 static mut 要放在 unsafe 裡,一般程式少用。
基本型別
純量
| 型別 | 說明 |
|---|---|
i8 i16 i32 i64 i128 isize | 有號整數。未標型別的整數字面量預設 i32 |
u8 … usize | 無號整數。切片長度、索引是 usize |
f32 f64 | 浮點數。未標型別預設 f64 |
bool | true / false |
char | Unicode 純量值,用單引號,例如 '中',佔 4 bytes |
整數溢位:debug 建置會 panic,release 預設繞回(wrapping)。需要明確語意時用 checked_add、saturating_add、wrapping_add。
複合型別
Tuple 可混不同型別,用 .0、.1 或解構取值:
let point: (i32, f64, char) = (1, 2.5, 'a');
let (x, y, c) = point;
println!("{} {} {}", x, point.1, c);
Array 長度固定,且長度是型別的一部分。[i32; 3] 與 [i32; 4] 是不同型別:
let nums: [i32; 3] = [1, 2, 3];
let zeros = [0; 5]; // [0, 0, 0, 0, 0]
println!("{}", nums[0]);
越界索引在執行期 panic。長度會變的序列用 Vec<T>。
Slice &[T] 是一段連續元素的借用,不擁有資料:
let nums = [1, 2, 3, 4];
let mid: &[i32] = &nums[1..3]; // [2, 3]
範圍:.. 不含結尾,..= 含結尾。0..3 是 0、1、2;0..=3 是 0、1、2、3。
函式與表達式
fn add(a: i32, b: i32) -> i32 {
a + b // 沒有分號,這個表達式就是回傳值
}
fn greet(name: &str) {
println!("hi, {name}");
}
- 參數與回傳型別都要寫出來。
- 函式本體最後一個沒有分號的表達式就是回傳值。寫
return a + b;也可以。 - 沒有回傳值的函式,回傳型別是 unit
(),通常省略不寫。 - 區塊
{ ... }本身也是表達式,所以if、match、loop都能產生值。
let label = if score >= 60 { "及格" } else { "不及格" };
if 與 else 兩邊的型別必須相同。
控制流程
if / else if
條件必須是 bool。Rust 不會把數字自動轉成布林,if n { } 無法編譯。
loop、while、for
let mut n = 0;
let result = loop {
n += 1;
if n == 3 {
break n * 2; // loop 可以用 break 帶值出來
}
};
while n > 0 {
n -= 1;
}
for i in 0..3 {
println!("{i}");
}
let names = ["ann", "bo"];
for name in names {
println!("{name}");
}
continue 進入下一輪,break 離開迴圈。巢狀迴圈可 用生命週期標籤跳出外層:
'outer: for i in 0..3 {
for j in 0..3 {
if i == 1 && j == 1 {
break 'outer;
}
}
}
match
match 必須涵蓋所有可能。比對由上往下,第一個符合的分支生效:
fn describe(n: i32) -> &'static str {
match n {
0 => "零",
1..=9 => "個位數",
n if n < 0 => "負數",
_ => "其他",
}
}
_ 是其餘所有情況。分支上的 if 叫 match guard。
只關心其中一種情況時用 if let:
if let Some(value) = maybe {
println!("{value}");
}
Rust 1.88 起、且 edition 為 2024 時,可以用 && 把多個 let 串在同一個條件裡:
let first = Some(1);
let second = Some(2);
if let Some(a) = first && let Some(b) = second {
println!("{}", a + b);
}
所有權與借用
這是 Rust 和多數語言差最多的地方。三條規則:
- 每個值有一個擁有者(owner)。
- 同一時間只能有一個擁有者。
- 擁有者離開作用域,值就被丟棄(drop)。
把非 Copy 的值指派或傳進函式,所有權會移動(move),原來的變數不能再使用:
let s1 = String::from("hello");
let s2 = s1;
// println!("{s1}"); // 編譯錯誤:s1 已經被 move
println!("{s2}");
整數、bool、char、以及只含這些欄位的 tuple / array 有 Copy,賦值是複製,兩邊都還能用。String、Vec<T> 沒有 Copy,需要第二份擁有權時呼叫 .clone()(會配置新記憶體)。
借用
不想把所有權交出去,就傳參考:
| 寫法 | 意義 |
|---|---|
&T | 共享借用,可同時存在多個,不能透過它改值 |
&mut T | 可變借用,同一時間只能有一個,且不能與 &T 並存 |
fn len(s: &String) -> usize {
s.len()
}
fn push_exclaim(s: &mut String) {
s.push('!');
}
let mut name = String::from("hi");
println!("{}", len(&name));
push_exclaim(&mut name);
借用不能活得比擁有者久。回傳參考時,編譯器會用**生命週期(lifetime)**確認這一點。最常見的寫法是輸入與輸出綁同一個 'a:
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
只在函式內部建立、不把參考傳出去時,生命週期多半可以省略,編譯器會自行推斷。
String 與 &str
| 型別 | 是什麼 |
|---|---|
String | 擁有資料的可增長 UTF-8 字串,在 heap |
&str | 一段字串的借用,字面量 "hello" 的型別是 &'static str |
函式參數優先接 &str,呼叫端傳 String 或字面量都可以(String 會自動 deref 成 &str):
fn shout(s: &str) -> String {
format!("{s}!")
}
String 不能用 s[0] 取「第幾個字」。UTF-8 一個字元可能佔多個 byte,改走迭代器:
for ch in "你好".chars() {
println!("{ch}");
}
結構、列舉與特徵
struct
struct User {
name: String,
age: u8,
}
impl User {
fn new(name: impl Into<String>, age: u8) -> Self {
Self {
name: name.into(),
age,
}
}
fn greet(&self) -> String {
format!("{} 今年 {} 歲", self.name, self.age)
}
fn birthday(&mut self) {
self.age = self.age.saturating_add(1);
}
}
&self:借用自己,對應方法呼叫user.greet()。&mut self:可變借用。self:拿走所有權,呼叫後原本的值不能再用。- 沒有
self的函式是關聯函式,用User::new(...)呼叫。
欄位少、沒有名字也夠清楚時,可用 tuple struct:struct Point(i32, i32);。
用 #[derive] 讓編譯器自動實作常見特徵:
#[derive(Debug, Clone, PartialEq, Eq)]
struct Point {
x: i32,
y: i32,
}
有 Debug 才能 println!("{point:?}") 或 {:#?}(多行)。
enum
列舉的每個分支可以帶不同資料:
enum Status {
Active,
Away { minutes: u32 },
Offline,
}
fn label(status: &Status) -> String {
match status {
Status::Active => "在線".to_string(),
Status::Away { minutes } => format!("離開 {minutes} 分鐘"),
Status::Offline => "離線".to_string(),
}
}
trait
trait 定義一組方法,型別用 impl 提供實作。參數寫 impl Summary 表示「任何有實作 Summary 的型別」:
trait Summary {
fn summarize(&self) -> String;
}
fn notify(item: &impl Summary) {
println!("{}", item.summarize());
}
多個條件可寫成泛型:
fn notify<T: Summary + std::fmt::Debug>(item: &T) {
println!("{item:?}: {}", item.summarize());
}
很長的條件用 where:
fn dump<T>(item: &T)
where
T: Summary + std::fmt::Debug,
{
println!("{item:?}");
}
Option、Result 與 ?
Rust 沒有 null。可能沒有值用 Option<T>,可能失敗用 Result<T, E>。
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
兩者都在 prelude 裡,直接用 Some、None、Ok、Err 即可。
fn first_even(nums: &[i32]) -> Option<i32> {
nums.iter().copied().find(|n| n % 2 == 0)
}
let n = first_even(&[1, 3, 4]).unwrap_or(0);
| 方法 | 行為 |
|---|---|
unwrap() | None / Err 時 panic。僅適合確定有值,或原型程式 |
expect("說明") | 同上,panic 訊息自己寫 |
unwrap_or(default) | 沒值時改用預設值 |
unwrap_or_else(|err| ...) | 沒值時呼叫閉包產生預設值 |
map / and_then | 有值才轉換,維持 Option / Result |
? 表示:是 Err(或 None)就立刻從目前函式回傳,否則取出裡面的值。函式回傳型別必須相容。
fn sum_csv(input: &str) -> Result<i32, std::num::ParseIntError> {
let mut total = 0;
for part in input.split(',') {
let n: i32 = part.trim().parse()?;
total += n;
}
Ok(total)
}
main 也可以回傳 Result。錯誤時程序會以非 0 結束:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let total = sum_csv("1, 2, 3")?;
println!("{total}");
Ok(())
}
字串轉數字是 .parse(),目標型別要標出來或讓上下文推斷:
let n: i32 = "42".parse()?;
集合與迭代器
Vec<T>
let mut nums = vec![1, 2, 3];
nums.push(4);
let third: Option<&i32> = nums.get(2); // 越界得到 None,不 panic
for n in &nums {
println!("{n}");
}
nums[i] 越界會 panic;不確定有沒有這個索引時用 .get(i)。
HashMap
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("blue"), 10);
scores.entry(String::from("blue")).or_insert(0);
entry 適合「沒有這個 key 才寫入」。讀取得到 Option<&V>。
迭代器
iter() 借出元素,iter_mut() 可變借用,into_iter() 拿走所有權。鏈上的 map、filter 是惰性的,遇到 collect、for 或 sum 才真的執行:
let doubled: Vec<i32> = [1, 2, 3]
.into_iter()
.map(|n| n * 2)
.filter(|n| *n > 2)
.collect();
閉包參數寫在 |...|。編譯器會推斷它是 Fn、FnMut 還是 FnOnce。
模組與可見性
預設私有。對外要加 pub。
// src/lib.rs
mod user;
pub use user::User;
// src/user.rs
pub struct User {
pub name: String,
age: u8, // 模組外讀不到
}
| 路徑 | 意義 |
|---|---|
crate::... | 從目前 crate 根目錄起算 |
super::... | 父模組 |
self::... | 目前模組 |
use std::collections::HashMap; 把名稱引進作用域。只在這個函式用到時,寫完整路徑也行。
程式進入點:
- 執行檔:
src/main.rs的fn main - 函式庫:
src/lib.rs,給別人use你的pub項目
常見寫法速查
// 有值才做,沒值就離開目前函式
let name = name_opt?;
// 有值才綁變數
if let Some(user) = find_user(id) {
println!("{}", user.name);
}
// 斷言,失敗即 panic。多放在測試或「理論上不可能」的分支
assert_eq!(add(2, 3), 5);
// 轉成擁有的 String
let owned: String = "hi".to_string();
let owned = "hi".to_owned();
// 從可能失敗的轉換取出值
let n = u32::try_from(len).unwrap_or(0);
測試寫在同一個檔案底部即可:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_plus_two() {
assert_eq!(2 + 2, 4);
}
}
執行:cargo test。