指標(pointer)是一個將變數儲存記憶體位址的通用概念。此位址指向一些其他資料。
Rust 最常見的指標是參考:以&
符號作為指示並借用它們指向的數值。參考只借用資料,沒有額外功能或性能開銷。
智慧指標(smart pointer)則是在指標的基礎上,增強了資源管理能力,通常擁有資料的所有權,並提供自動釋放記憶體等額外的功能或行為。
例如之前看過的String
、 Vec(T)
都是一種智慧指標,它們內部使用結構體來實作,以管理資料和資源。
有兩個智慧指標的關鍵特徵:
Deref
特徵賦予了智慧指標類似於參考的行為,在使用智慧指標時,可以像操作普通參考一樣來操作它指向的數據。Drop
特徵可以用來自訂當智慧指標實例離開作用域時要執行的程式碼,也就是在介紹所有權舉過的例子,智慧指標透過實作Drop
確保資源在不再需要時被正確釋放,避免記憶體洩漏等問題。首先介紹很常用到而且相對單純的智慧指標Box<T>
,它會把泛型型別資料存放到 Heap 上,所以我們也可以透過它把一些平常會存在 Stack 的基本型別資料存到 Heap 上。除此之外它沒有什麼額外功能,相對於其他智慧指標效能開銷極小。
看一個簡單的例子:
fn main() {
let b = Box::new(5);
println!("b = {}", b);
}
我們用 Box::new
建立一個新的 Box
實體,因為是泛型設計,所以我們可以用任何型別當參數傳進去,這邊用的是 5
,型別是預設 i32
。 b
擁有這個實體的所有權,所以在它離開作用域的時候,stack 上的指標,以及指標指向的 Heap 上的資料(這邊是 5)記憶體都會被釋放。
通常情況我們不會特別對本來就存在 Stack 上的型別使用 Box
,畢竟這類型別本來就比較適合放在 Stack,效能也比較好。
會用到 Box
的情境大致如下:
適用於第一個情況的是遞迴型別(recursive type),這種型別會把相同型別的其他數值當成自己一部分。Rust 需要在編譯的時候就知道每個型別佔用的空間,但這種遞迴結構會讓 Rust 無法在編譯期推斷出具體大小。
以 cons list 為例,這是一種來自 Lisp 的資料結構,它是通過遞迴構建的鏈表結構,如下所示:
(1, (2, (3, Nil)))
可以當成一種 linked list,每個 cons list 的項目都包含兩個元素:目前項目的數值與下一個項目。列表中的最後一個項目只會包含一個數值叫做 Nil
,代表結束,後面沒有其他節點了。
Rust 裡面大概會寫成這樣,不過實際如前述原因是無法編譯過的。
enum List {
Cons(i32, List),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
編譯器處理的過程如下:
Cons
變體開始,包含一個 i32
和一個 List
型別List
型別的大小,再進去看 List
底下的變體 Cons
因為 Rust 的編譯器會陷入無限遞迴的邏輯,無法計算 List
型別的具體大小。
如編譯器提示,解決這個情境的其中一個方法就是用 Box(T)
來間接表示遞迴型別。
當用 Box(T)
來儲存這個結構的時候,Rust 只需要知道存在 stack 上的這個指標需要多少空間,而且 Rust 會知道,因為指標的大小不會隨著指向的資料數量而改變,所以不論實際的 list 有多長,每個指標的大小是固定的。
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
這樣 Cons
變體需要的大小為 i32
加上儲存 Box
指標的空間,Nil
變體沒有儲存任何數值。編譯器就知道儲存一個 List
數值所需要的大小了。
第二種情況以結構體舉例,結構體的資料可能存在 Stack 或 Heap,大部分的情況,而今天如果把擁有這個實體所有權的變數賦值給另外一個變數,要看這個型別有沒有 Copy
的特徵,有的話可能會不小心發生複製。
fn main() {
let s1 = MyStruct{};
let s2 = s1; // <= 要看型別細節才知道是複製還是轉移所有權
}
如果剛好這個資料很佔空間又被複製的話,會造成多餘的效能損失。
解決的方法就是把這筆資料傳給 Box
,抽象理解就是把資料放進這個 Box
中,這個操作會轉移資料所有權給這個 Box
,後續每次把它賦值給其他變數的時候會把所有權交出去,確定不會發生複製。 Box
不會複製的原因是避免重複釋放,因為 Box
是指標,複製它會導致多個指標指向同一記憶體,這可能導致重複釋放記憶體,進而引發錯誤。
第三種情況是在特徵提過的:有時候我們只關心型別具有某種特徵而不在意它實際型別是什麼,那時有寫一個函數想要回傳不同型別但都具有相同特徵的型別,因為回傳的型別不是同一種而報錯。
舉一個類似的例子不過把定義的結構體再簡化一些:
trait Draw {
fn draw(&self);
}
struct Circle;
struct Rectangle;
impl Draw for Circle {
fn draw(&self) {
println!("Drawing a circle");
}
}
impl Draw for Rectangle {
fn draw(&self) {
println!("Drawing a rectangle");
}
}
fn main() {
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle),
Box::new(Rectangle),
];
for shape in shapes {
shape.draw();
}
}
我們定義了兩種結構體 Circle
和 Rectangle
,他們都有實作 Draw
特徵,接著宣告 shapes
,這是一個具有 Draw
特徵物件的向量。如果沒有Box
的話編譯器無法知道這個向量的元素會佔多少記憶體,而用 Box
把具有特徵的型別包起來後,向量上存的就是指向這些資料的指標,所以向量知道他所包含的元素會佔多少記憶體了。不管最後是怎樣或多大的型別實作 Draw
特徵,存在向量上的指標大小都是固定的。
dyn Draw
這樣的寫法稱作特徵物件,允許我們在程式執行的時候透過多形性來操作不同型別,它代表的是任何有實作 Draw
特徵的型別,類似於動態型別語言中鴨子型別(duck typing)的概念。
Rust 通常會偏向靜態分配(編譯期確定類型),但當我們是一個函數庫,無法知道外部打算如何實作 Draw
特徵或具體型別以及大小,這種時候就無法靠靜態分配來實做,因此用上 dyn
是動態分配(dynamic dispatch)的關鍵字, dyn Draw
告訴 Rust 這是對實現了 Draw
的物件的引用,而不用知道具體的型別,Rust 會生成一個指向實際類型的指針和虛表(table)。
虛表包含了這個物件具體實現的 draw
方法的位置。每當調用 draw
Rust 會通過這個虛表找到對應的方法實現。
不過這個查找的動作會比起靜態分派多一些性能開銷,而且會犧牲部分編譯器優化為代價。
總結來說,動態分派適合於我們無法在編譯期確定具體型別的場景,像是構建擴展性較強的函數庫或框架。不過,由於動態分派有額外性能開銷,要求效能的話就要謹慎考慮。
本篇介紹了了什麼是智慧指標,並介紹Box<T>
作為一個基礎的智慧指標,展示如何有效地將資料存放在 heap 上,並且避免了 Rust 編譯期的大小推導問題。也補上Box(T)
如何和特徵物件搭配使用,讓我們在 Rust 即使沒有繼承機制的情況下也能達成多型的效果。
總結來說,Box<T>
的特點是:
即使是最基礎的智慧指標 Box<T>
,也為 Rust 提供了更靈活的資源管理和多型支援,解決了許多 Rust 編譯時期的限制。掌握 Box<T>
之後,將更容易理解其他複雜的智慧指標,如 Rc<T>
或 RefCell<T>
。