Rust 的模式匹配機制簡化了複雜資料結構的處理流程,尤其在搭配參照使用時,更能展現其靈活性。開發者可以利用模式匹配安全地存取和操作資料,同時避免所有權轉移帶來的問題。理解 ref、ref mut 和 & 的區別,以及如何結合模式守衛和 @ 繫結,是撰寫高效 Rust 程式碼的關鍵。此外,Rust 的列舉型別與模式匹配的結合,讓程式碼更加簡潔易懂,並提升了程式碼的安全性。特徵和泛型則提供了多型特性,讓程式碼可以更具彈性地處理不同型別的資料,進一步提升程式碼的可重用性和可維護性。
深入理解 Rust 中的模式匹配(Pattern Matching)與參照
在 Rust 程式設計中,模式匹配是一種強大且靈活的工具,用於解構和檢查資料結構。透過模式匹配,開發者可以更簡潔地處理不同的資料情況,並有效地避免錯誤。本文將探討 Rust 中的模式匹配機制,特別是在處理參照(references)時的行為和最佳實踐。
模式匹配基礎
Rust 的模式匹配允許開發者根據不同的模式來執行不同的程式碼路徑。最常見的模式匹配是在 match 表示式中使用。例如:
match account {
Account { name, language, .. } => {
ui.greet(name, language);
}
}
在這個例子中,Account 結構體被解構,其中的 name 和 language 欄位被提取出來。然而,如果 account 之後還需要被使用,這種做法會導致編譯錯誤,因為 name 和 language 被移動(moved)出了 account。
使用 ref 關鍵字借用匹配值
為了避免移動值,可以使用 ref 關鍵字來借用匹配的值:
match account {
Account { ref name, ref language, .. } => {
ui.greet(name, language);
ui.show_settings(&account); // 現在這行是合法的
}
}
這裡,name 和 language 成為對應欄位的參照,因此 account 沒有被消費,可以繼續使用。
程式碼解析:
ref name和ref language:這兩個表示式借用了account中對應欄位的參照,避免了值的移動。ui.show_settings(&account):由於account僅被借用而非被消費,因此這行程式碼是合法的。
處理可變參照
如果需要修改匹配的值,可以使用 ref mut 來獲得可變參照:
match line_result {
Err(ref err) => log_error(err), // err 是 &Error(分享參照)
Ok(ref mut line) => { // line 是 &mut String(可變參照)
trim_comments(line); // 直接修改 String
handle(line);
}
}
程式碼解析:
Ok(ref mut line):匹配成功結果,並借用一個可變參照到成功值。trim_comments(line):利用可變參照直接修改line所指向的String。
使用 & 模式匹配參照
與 ref 相反,& 模式用於匹配參照:
match sphere.center() {
&Point3d { x, y, z } => ...
}
這裡假設 sphere.center() 傳回一個對 Point3d 的參照。使用 & 模式可以解構這個參照並提取其內部的欄位。
程式碼解析:
&Point3d { x, y, z }:這個模式匹配一個對Point3d的參照,並將其欄位解構到x、y和z中。- Rust 自動處理參照:在模式匹配中,Rust 自動跟隨指標,這與通常使用
*運算子的行為相反。
同時匹配多個可能值
Rust 允許使用垂直線(|)來組合多個模式:
let at_end = match chars.peek() {
Some(&'\r') | Some(&'\n') | None => true,
_ => false
};
程式碼解析:
Some(&'\r') | Some(&'\n') | None:這個表示式匹配多個不同的模式,只要其中一個匹配就執行對應的程式碼。
範圍模式匹配
Rust 支援使用 ... 進行範圍模式匹配,例如匹配所有 ASCII 數字:
match next_char {
'0'...'9' => self.read_number(),
'a'...'z' | 'A'...'Z' => self.read_word(),
' ' | '\t' | '\n' => self.skip_whitespace(),
_ => self.handle_punctuation()
}
程式碼解析:
'0'...'9':這個範圍模式匹配所有 ASCII 數字字元。
模式守衛(Pattern Guards)
可以為模式新增守衛條件,只有當條件滿足時才會匹配成功:
match robot.last_known_location() {
Some(point) if self.distance_to(point) < 10 => short_distance_strategy(point),
Some(point) => long_distance_strategy(point),
None => searching_strategy()
}
程式碼解析:
Some(point) if self.distance_to(point) < 10:這個模式只有在point存在且距離小於 10 時才會匹配成功。
@ 模式繫結
使用 @ 可以將整個匹配值繫結到一個變數上:
match self.get_selection() {
rect @ Shape::Rect(..) => optimized_paint(&rect),
other_shape => paint_outline(other_shape.get_outline()),
}
程式碼解析:
rect @ Shape::Rect(..):將匹配到的Shape::Rect繫結到變數rect上。
Rust 中的模式匹配與列舉(Enums and Patterns)
Rust 語言中的模式匹配(Pattern Matching)與列舉(Enums)是強大的工具,能夠幫助開發者處理複雜的資料結構並撰寫出更安全、更高效的程式碼。這些特性源自函式式程式設計語言,已被證明在多種情境下非常有用。
使用模式匹配處理列舉
在 Rust 中,列舉是一種可以包含不同型別值的資料結構。模式匹配允許我們根據列舉值的不同變體(Variants)執行不同的程式碼。
範例:使用 if let 簡化列舉處理
if let RoughTime::InTheFuture(_, _) = user.date_of_birth() {
user.set_time_traveler(true);
}
這段程式碼檢查 user.date_of_birth() 是否為 RoughTime::InTheFuture 變體,如果是,則將 user 標記為時空旅人。
範例:使用 while let 處理迭代器
while let Some(_) = lines.peek() {
read_paragraph(&mut lines);
}
這段程式碼不斷檢查 lines.peek() 是否傳回 Some 值,如果是,則讀取一段文字,直到 lines 被完全處理完畢。
實作二元搜尋樹(Binary Search Tree)
以下是一個使用列舉和模式匹配實作二元搜尋樹的例子:
enum BinaryTree<T> {
Empty,
NonEmpty(Box<TreeNode<T>>)
}
struct TreeNode<T> {
element: T,
left: BinaryTree<T>,
right: BinaryTree<T>
}
impl<T: Ord> BinaryTree<T> {
fn add(&mut self, value: T) {
match *self {
BinaryTree::Empty =>
*self = BinaryTree::NonEmpty(Box::new(TreeNode {
element: value,
left: BinaryTree::Empty,
right: BinaryTree::Empty
})),
BinaryTree::NonEmpty(ref mut node) =>
if value <= node.element {
node.left.add(value);
} else {
node.right.add(value);
}
}
}
}
程式碼解析:
- 定義二元樹列舉:
BinaryTree列舉有兩個變體,Empty和NonEmpty,後者包含一個TreeNode。 add方法實作:使用模式匹配檢查樹是否為空。如果為空,則建立一個新的節點;如果非空,則遞迴地將新值加入左子樹或右子樹。
Rust 列舉的優勢
Rust 的列舉提供了多項優勢,包括:
- 記憶體安全:透過嚴格的模式匹配和編譯時檢查,Rust 能夠避免諸如 null 指標解參照等常見錯誤。
- 高效性:列舉的設計允許編譯器進行最佳化,使得根據列舉的程式碼執行效率更高。
- 簡潔性:使用列舉和模式匹配可以簡化程式碼,避免冗長的條件判斷和型別檢查。
特徵(Traits)與泛型(Generics)
在程式設計的世界中,有一個重要的發現,那就是能夠撰寫出可以操作多種不同型別值的程式碼,甚至是那些尚未被創造出來的型別。以下是兩個例子:
Vec<T>是泛型的:你可以建立任何型別值的向量,包括那些Vec作者從未預料到的、在你的程式中定義的型別。- 許多事物都有
.write()方法,包括Files和TcpStreams。你的程式碼可以透過參考取得一個寫入器(writer),任何寫入器,並將資料傳送給它。你的程式碼不需要關心寫入器的型別。稍後,如果有人新增了一種新的寫入器型別,你的程式碼將自動支援它。
當然,這種能力在 Rust 中並不是新鮮事。它被稱為多型(polymorphism),是 1970 年代熱門的程式語言技術。如今,它幾乎是普遍的。Rust 支援具有兩個相關特性的多型:特徵(traits)和泛型(generics)。這些概念對許多程式設計師來說都很熟悉,但 Rust 採用了受 Haskell 型別類別(typeclasses)啟發的新穎方法。
特徵(Traits)
特徵是 Rust 對介面或抽象基底類別的實作。一開始,它們看起來就像 Java 或 C# 中的介面。用於寫入位元組的特徵被稱為 std::io::Write,其在標準函式庫中的定義如下:
trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()> { ... }
...
}
這個特徵提供了幾個方法;我們只展示了前三個。
標準型別 File 和 TcpStream 都實作了 std::io::Write。Vec<u8> 也實作了它。所有這三種型別都提供了名為 .write()、.flush() 等的方法。
使用不關心其型別的寫入器的程式碼如下所示:
use std::io::Write;
fn say_hello(out: &mut dyn Write) -> std::io::Result<()> {
out.write_all(b"hello world\n")?;
out.flush()
}
out 的型別是 &mut dyn Write,表示「一個可變的參考,指向任何實作 Write 特徵的值」。
use std::fs::File;
let mut local_file = File::create("hello.txt")?;
say_hello(&mut local_file)?; // 可行
let mut bytes = vec![];
say_hello(&mut bytes)?; // 也可行
assert_eq!(bytes, b"hello world\n");
本章首先展示如何使用特徵、它們的工作原理以及如何定義自己的特徵。但特徵的功能遠不止於此。我們將使用它們為現有的型別(甚至是像 str 和 bool 這樣的內建型別)新增擴充方法。我們將解釋為什麼為型別新增特徵不會佔用額外的記憶體,以及如何使用特徵而無需虛擬方法呼叫的開銷。我們將看到內建特徵是 Rust 提供的用於運算元多載(operator overloading)和其他功能的語言鉤子(hook)。我們還將介紹 Self 型別、關聯方法(associated methods)和關聯型別(associated types),這三個功能是 Rust 從 Haskell 中借鑒而來的,能夠優雅地解決其他語言需要透過變通方法和 hack 來解決的問題。
泛型(Generics)
泛型是 Rust 中的另一種多型形式。與 C++ 範本類別似,泛型函式或型別可以用於多種不同型別的值。
/// 給定兩個值,選出較小的一個。
fn min<T: Ord>(value1: T, value2: T) -> T {
if value1 <= value2 {
value1
} else {
value2
}
}
這個函式中的 <T: Ord> 表示 min 可以用於任何實作 Ord 特徵的型別 T 的引數——即任何有序型別。編譯器會為你實際使用的每個型別 T 生成自訂的機器碼。
內容解密:
此段程式碼定義了一個泛型函式 min,它接受兩個相同型別 T 的引數,並傳回其中較小的一個。T 必須實作 Ord 特徵,這意味著 T 必須支援比較運算。編譯器會根據實際使用的型別 T 生成對應的機器碼,以確保效能和型別安全。
泛型和特徵密切相關。Rust 要求我們在事先宣告 T: Ord 需求(稱為約束),然後再使用 <= 運算元比較兩個 T 型別的值。因此,我們還將討論 &mut dyn Write 和 <T: Write> 之間的相似之處、差異以及如何選擇使用特徵的這兩種方式。
使用特徵
特徵是一種特性,任何給定的型別都可以支援或不支援它。大多數情況下,特徵代表了一種能力:某個型別可以做某件事情。
- 一個實作了
std::io::Write的值可以寫出位元組。 - 一個實作了
std::iter::Iterator的值可以產生一序列的值。 - 一個實作了
std::clone::Clone的值可以在記憶體中建立自己的複製品。 - 一個實作了
std::fmt::Debug的值可以使用println!()和{?:}格式規範器進行列印。
這些特徵都是 Rust 標準函式庫的一部分,許多標準型別都實作了它們。
std::fs::File實作了Write特徵;它將位元組寫入本機檔案。std::net::TcpStream將位元組寫入網路連線。Vec<u8>也實作了Write;對位元組向量的每個.write()呼叫都會將一些資料附加到向量的末尾。Range<i32>(例如0..10的型別)實作了Iterator特徵,其他與切片、雜湊表等相關的迭代器型別也是如此。- 大多數標準函式庫型別都實作了
Clone。主要的例外是像TcpStream這樣代表的不僅僅是記憶體中的資料的型別。 - 同樣,大多數標準函式庫型別都支援
Debug。
關於特徵方法,有一個不尋常的規則:特徵本身必須在作用域中。否則,它的所有方法都會被隱藏。
let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?; // 錯誤:沒有名為 `write_all` 的方法
內容解密:
此段程式碼嘗試呼叫 write_all 方法,但由於沒有將 Write 特徵引入作用域,因此編譯器無法找到該方法。解決方法是新增 use std::io::Write; 陳述,將 Write 特徵引入作用域,使其方法可供使用。
使用特徵(Traits)進行多型程式設計
在Rust中,特徵(Traits)是一種定義分享行為的方式,讓不同的型別可以實作相同的介面。本章將介紹如何使用特徵來撰寫多型程式碼,主要涵蓋特徵物件(Trait Objects)和泛型(Generics)兩種方法。
為什麼需要引入特徵?
在前面的章節中,我們已經看到了std::io::Write特徵的例子。Write特徵定義了一組方法,例如write_all和flush,這些方法可以被多種型別實作,例如Vec<u8>和File。當我們想要撰寫可以處理多種型別的程式碼時,特徵就變得非常有用。
特徵物件(Trait Objects)
Rust不允許直接使用特徵型別作為變數型別,因為編譯器需要在編譯時期知道變數的大小。例如,下面的程式碼無法編譯:
use std::io::Write;
let mut buf: Vec<u8> = vec![];
let writer: Write = buf; // 錯誤:`Write` 沒有固定的大小
要解決這個問題,我們可以使用特徵物件,也就是對某個特徵的參照。下面的程式碼展示瞭如何使用特徵物件:
let mut buf: Vec<u8> = vec![];
let writer: &mut dyn Write = &mut buf; // 正確
在記憶體中,特徵物件是一個胖指標(Fat Pointer),它包含兩個部分:指向實際值的指標和指向該值型別資訊表的指標。這使得Rust可以在執行時期根據實際型別呼叫正確的方法。
特徵物件的記憶體佈局
下圖展示了特徵物件在記憶體中的佈局:
+
---
-
---
-
---
-
---
+
| 指標 |
| (指向實際值) |
+
---
-
---
-
---
-
---
+
| vtable 指標 |
| (指向型別資訊) |
+
---
-
---
-
---
-
---
+
這種佈局類別似於C++中的虛表(vtable)。Rust在編譯時期生成vtable,並在執行時期使用它來呼叫正確的方法。
泛型函式(Generic Functions)
除了使用特徵物件外,Rust還提供了泛型函式來實作多型。泛型函式是一種可以處理多種型別的函式。下面是一個例子:
fn say_hello<W: Write>(out: &mut W) -> std::io::Result<()> {
out.write_all(b"hello world\n")?;
out.flush()
}
在這個例子中,W是一個型別引數,它代表某個實作了Write特徵的型別。泛型函式可以提供更好的效能,因為編譯器可以在編譯時期根據實際型別生成特定的程式碼。
#### 內容解密:
fn say_hello<W: Write>(out: &mut W) -> std::io::Result<()> { ... }:這是一個泛型函式的定義,W是型別引數,必須實作Write特徵。out.write_all(b"hello world\n")?;:呼叫write_all方法將字串寫入輸出。out.flush():呼叫flush方法確保資料被寫入。-> std::io::Result<()>:函式傳回一個結果型別,表示操作是否成功。
泛型函式和特徵物件都可以實作多型,但它們有不同的使用場景和效能特點。選擇合適的方法取決於具體的需求和效能要求。