cv.cm

編碼 · 程式語言

Rust 入門教學

閱讀約 9 分鐘

簡答

Rust 是編譯式、靜態型別的系統程式語言,速度與 C、C++ 相當,卻能在編譯期防止記憶體錯誤,而且不需要垃圾回收。用 rustup 安裝後,執行 cargo new hello 與 cargo run。核心概念包括所有權與借用(由借用檢查器負責檢查)、搭配 match 的 struct 與 enum、Vec 和 HashMap 等集合,以及使用 Result 與 ? 運算子的錯誤處理。

開啟二進位轉換

Rust 是什麼,用來做什麼

Rust 起初是 Graydon Hoare 的個人專案,後來由 Mozilla 贊助,並於 2015 年發布 1.0 版,現在由 Rust Foundation 維護。Rust 用於速度與可靠性都重要的地方:命令列工具(ripgrep)、網路服務、Firefox 的瀏覽器元件、Linux 核心與 Windows 的部分模組、嵌入式裝置與 WebAssembly。它的編譯器很嚴格,但換來的是:在安全的 Rust 程式碼中,use-after-free 與資料競爭等整類錯誤都不可能發生。

安裝 Rust 並執行 Hello, world

從 rustup.rs 使用 rustup 安裝工具鏈。它會提供 rustc(編譯器)與 Cargo(建置工具和套件管理工具)。建立專案:

cargo new hello
cd hello
cargo run
# Hello, world!

Cargo 會產生 src/main.rs:

fn main() {
    println!("Hello, world!");
}

println! 以 ! 結尾,因為它是巨集(macro)而不是函式。巨集在編譯期產生程式碼;這裡的格式字串會在編譯時被檢查。

變數與基本型別

變數預設是不可變的;加上 mut 才允許修改。型別通常可以推論,但每個值都只有一個靜態型別,數值轉換則要用 as 明確指定。

型別範例說明
i32, i64, u8, usizelet n: i32 = 42;有號(i)與無號(u);索引使用 usize
f64let x = 3.14;預設的浮點數型別
boollet ok = true;true 或 false
charlet c = 'é';一個 Unicode 純量值,4 位元組
&strlet s = "hi";借用的字串切片
StringString::from("hi")擁有所有權、可增長的 UTF-8 文字
Tuple、陣列(1, "a"), [0; 3]固定大小
fn main() {
    let name = "Ada";
    let mut count = 7;
    count += 1;
    let avg = count as f64 / 2.0;
    println!("{name} has {count} items, avg {avg}"); // Ada has 8 items, avg 4
    let parsed: i32 = "12".parse().unwrap();
    println!("{}", parsed + 1); // 13
}

流程控制與函式

if、match 與區塊都是運算式:它們會產生值。函式最後一個沒有分號的運算式就是它的回傳值。

fn square(x: i32) -> i32 {
    x * x
}

fn main() {
    let score = 72;
    let grade = if score >= 90 { "A" } else if score >= 70 { "B" } else { "C" };
    println!("{grade}"); // B

    for i in 0..3 {
        print!("{} ", square(i)); // 0 1 4
    }
    println!();

    let mut n = 10;
    while n > 1 {
        n /= 2;
    }
    let label = loop {
        break "done"; // loop can return a value with break
    };
    println!("{n} {label}"); // 1 done
}

招牌特色:所有權與借用

每個值都恰好有一個擁有者。擁有者離開作用域時,值就會被釋放(drop)。把 String 這類堆積上的值指派給另一個變數,會將它移動(move),舊的名稱就不能再使用。若要在不取得所有權的情況下使用某個值,就用參考來借用它:同一時間可以有任意數量的共享參考 &T,或恰好一個可變參考 &mut T。

fn length(s: &str) -> usize {
    s.len() // borrows, does not take ownership
}

fn add_exclamation(s: &mut String) {
    s.push('!');
}

fn main() {
    let a = String::from("hello");
    let b = a; // ownership moves to b
    // println!("{a}"); // error: borrow of moved value: `a`
    let mut c = b.clone(); // explicit deep copy
    add_exclamation(&mut c);
    println!("{} {} {}", b, c, length(&c)); // hello hello! 6
}

這些規則由借用檢查器在編譯期強制執行,這就是 Rust 不靠垃圾回收也能達成記憶體安全的方式。整數等簡單型別實作了 Copy,所以是被複製而不是被移動。

Struct、enum 與模式比對

struct 把欄位組合起來,impl 區塊則加入方法。enum 的每個變體都可以攜帶資料,而 match 必須處理每個變體,這由編譯器檢查。標準的 Option<T> enum(Some(value) 或 None)取代了 null。

#[derive(Debug)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn distance(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() {
    let p = Point { x: 3.0, y: 4.0 };
    println!("{:?} {}", p, p.distance()); // Point { x: 3.0, y: 4.0 } 5
    println!("{}", area(&Shape::Rect { w: 2.0, h: 3.0 })); // 6
    let nums = [10, 20];
    if let Some(v) = nums.first() {
        println!("first is {v}"); // first is 10
    }
}

trait 定義共用的行為,類似介面:#[derive(Debug)] 請編譯器實作 Debug trait,這樣就能用 {:?} 印出該 struct。

集合:Vec 與 HashMap

use std::collections::HashMap;

fn main() {
    let mut nums = vec![3, 1, 2];
    nums.push(5);
    nums.sort();
    println!("{:?} {}", nums, nums.len()); // [1, 2, 3, 5] 4

    let evens: Vec<i32> = nums.iter().filter(|n| *n % 2 == 0).map(|n| n * n).collect();
    println!("{:?}", evens); // [4]

    let mut ages: HashMap<&str, u32> = HashMap::new();
    ages.insert("ann", 31);
    ages.insert("bob", 25);
    *ages.entry("ann").or_insert(0) += 1;
    println!("{:?} {:?}", ages.get("ann"), ages.get("zed")); // Some(32) None
}

搭配閉包(|n| ...)的迭代器,例如 filter、map 和 collect,編譯後的程式碼與手寫迴圈一樣快。

使用 Result 與 ? 處理錯誤

可能失敗的函式會回傳 Result<T, E>:不是 Ok(value) 就是 Err(error)。? 運算子會提早把錯誤回傳給呼叫端,讓錯誤處理路徑保持簡短。無法復原的錯誤則使用 panic!。

use std::num::ParseIntError;

fn parse_age(text: &str) -> Result<u32, ParseIntError> {
    let age: u32 = text.trim().parse()?; // returns Err early on bad input
    Ok(age)
}

fn main() {
    match parse_age("41") {
        Ok(age) => println!("age {age}"), // age 41
        Err(e) => println!("error: {e}"),
    }
    match parse_age("abc") {
        Ok(age) => println!("age {age}"),
        Err(e) => println!("error: {e}"), // error: invalid digit found in string
    }
}

完整的小程式:單字頻率統計

將這段程式碼放進 src/main.rs,然後執行 cargo run -- notes.txt。

use std::collections::HashMap;
use std::{env, fs, process};

fn main() {
    let path = env::args().nth(1).unwrap_or_else(|| {
        eprintln!("usage: words FILE");
        process::exit(1);
    });
    let text = match fs::read_to_string(&path) {
        Ok(t) => t.to_lowercase(),
        Err(e) => {
            eprintln!("cannot read {path}: {e}");
            process::exit(1);
        }
    };

    let mut counts: HashMap<&str, usize> = HashMap::new();
    for word in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
        *counts.entry(word).or_insert(0) += 1;
    }

    let mut top: Vec<(&str, usize)> = counts.into_iter().collect();
    top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    for (word, n) in top.iter().take(5) {
        println!("{n:5}  {word}");
    }
}

HashMap 儲存的是向 text 借用的 &str 切片,所以不會複製任何單字;借用檢查器保證 text 的存活時間夠長。

初學者常見錯誤

  • 在值被移動之後還繼續使用它。請借用它(&value),或刻意複製(clone)它。
  • 試圖用 s[0] 對 String 做索引。字串是 UTF-8,所以請使用 s.chars(),或在字元邊界上使用位元組切片。
  • 在持有指向 Vec 內部的參考時對它做 push;push 可能重新配置記憶體,所以編譯器會禁止這種做法。
  • 搞混 String(擁有所有權)與 &str(借用)。函式參數接受 &str 就能同時支援兩者。

接下來學什麼

閱讀「The Rust Programming Language」(俗稱 the book),可在 doc.rust-lang.org 免費取得,並用 Rust by Example 來看簡短且可執行的範例。接著深入學習 trait 與泛型、生命週期、使用 cargo test 的測試,以及 crates.io 上的套件,例如用於序列化的 serde 與用於非同步程式的 tokio。執行 cargo clippy 可以獲得額外的程式碼檢查建議。

常見問題

Rust 很難學嗎?

最初幾週會比多數語言更困難,因為所有權與借用是全新的概念。熟悉之後,編譯器會及早抓出許多錯誤,許多開發者也覺得 Rust 程式碼更容易維護。

Rust 有垃圾回收嗎?

沒有。記憶體會在擁有者離開作用域時自動釋放,這在編譯期由所有權規則決定。需要共享所有權時,則有參考計數型別 Rc 與 Arc。

什麼是 unsafe Rust?

unsafe 區塊內的程式碼可以做編譯器無法驗證的事,例如解參考原始指標或呼叫 C 函式。它只應少量使用,通常會包裝在安全的 API 之內。

Rust 還是 Go:該學哪一個?

Go 更簡單、學得更快,在網路服務上很受歡迎。Rust 提供更多控制力與效能以及更強的保證,代價是學習曲線更陡。

全部編碼教學