之前的幾個帖子討論了Rust設計的兩大支柱特性:
- 無垃圾回收的安全內存管理
- 無數據競爭(Data Race)風險的并發
現在我們來討論第三個重要特性:零開銷的抽象
C++之所以適合系統編程,就是因為它提供了神奇的“零開銷抽象方式”:
C++的實現遵循“零開銷原則”:如果你不使用某個抽象,就不用為它付出開銷[Stroustrup,1994]。而如果你確實需要使用該抽象,可以保證這是開銷最小的使用方式。
— Stroustrup
以前版本的Rust由于內置垃圾回收,所以并不能實現該優點。但是現在,零開銷原則已經成為了Rust的核心原則之一。
實現這一原則的基石是Rust的特型(trait)機制:
特型是Rust唯一的接口抽象方式。 一方面,不同種類型可以實現同一特型,也可以為已有的類型添加新的特型。另一方面,當你想要對某未知類型進行抽象的時候,特型可以幫助你確定該類型可以進行的操作。
特型可以靜態生成。 考慮C++的模板,你可以為不同種類型的同一抽象靜態生成不同的代碼,而靜態生成是使用抽象的最佳方式,因為只存在實例化后的代碼,而抽象本身已經被完全抹去,因而也不會帶來任何運行開銷。
特型可以動態調用 有時你確實需要在運行時調用某種間接抽象,這時就不能靜態實例化該抽象了,因此trait同樣提供了動態調用(Dynamic Dispatch)的機制。
特型這種簡單抽象能夠解決大量的額外問題 特型可以作為類型的“標簽”使用,在這篇文章中有一個
Sender
作為例子。特型可以被用于定義“擴展方法”(對已有類型添加其他方法)來使用,因此傳統的方法重載不再必要。特型機制也使得運算符重載更加簡單。
總而言之,特型是Rust能夠經濟高效地在高階語言的語法下實現對底層代碼執行和數據表達進行精密控制的秘訣。
這篇文章我們會逐一描述以上優點,在不引進大量細節的前提下描述Rust是如何實現的。
背景知識:Rust中的成員方法(member function)
進入細節之前讓我們先來看看Rust中“函數”和“成員方法”之間的差別
Rust同時提供了成員方法和自由函數,兩者其實很相似:
// 定義Point類型
struct Point {
x: f64,
y: f64,
}
// 一個自由函數,將一個Point類型變量轉換成String
fn point_to_string(point: &Point) -> String { ... }
// 一個接口,定義了在一個Point類型變量上可以直接進行的操作
impl Point {
// Point類型的成員方法,自動借用了Point的值
fn to_string(&self) -> String { ... }
}
類似以上的to_string
方法被稱作“內含方法”,因為:
- 他們的參數是單個具體的“self”類型(self的類型通過impl 關鍵字后面的類型確定)
- 不需要考慮該方法的定義是否在作用域內:只要調用該方法的變量在域內,方法就在域內。而自由函數則無法做到這點。
一個“內含方法”的第一個參數名永遠是“self”,具體是“self”,“&mut self”還是“&self”則取決于該方法所需的“變量所有權級別”。調用內含方法的方式是使用“.”運算符,可以實現隱式借用,例子如下:
<pre>
let p = Point { x: 1.2, y: -3.7 };
// 調用一個自由函數需要用&運算符顯式借用
let s1 = point_to_string(&p);
// 調用一個方法, 隱式借用所以不需要&運算符
let s2 = p.to_string();
</pre>
方法對變量的隱式借用這一點非常重要,它使得我們可以寫出如下的“鏈式API調用”:
let child = Command::new("/bin/cat")
.arg("rusty-ideas.txt")
.current_dir("/Users/aturon")
.stdout(Stdio::piped())
.spawn();
用特型表達接口
接口(interface)指定了一段代碼使用另外一段代碼的方式,使得替換其中一段并不需要修改另外一段代碼。對于特型,這一特性圍繞著成員方法來展開。
例如如下的哈希trait:
<pre>
trait Hash {
fn hash(&self) -> u64;
}
</pre>
為了對某一類型實現該特型,你需要提供hash
函數的具體實現,例如:
<pre>
impl Hash for bool {
fn hash(&self) -> u64 {
if *self { 0 } else { 1 }
}
}
impl Hash for i64 {
fn hash(&self) -> u64 {
*self as u64
}
}
</pre>
和C#,Java,Scala不同的是,Rust的Traits能夠添加到已經存在的類型之上,可以看到上面的Hash就定義在了bool和i64兩個已經存在的類型上面。這意味著新的抽象可以定義在已有類型上面,包括任意的庫函數。
和上面提到的類型內含方法不同,特型中的方法只在該特型的定義域內有效。在Hash
這個特型的定義域內,你可以寫類似true.hash()
這樣的代碼,為已有類型添加新的特型,可以擴展該類型的使用方式。
這就是定義和使用特型的方式,特型其實很簡單,就是對多個類型上某些共同操作的一個抽象。
靜態生成
另一方面是如何調用一個特型?最常用的一種方式是通過泛型:
<pre>
fn print_hash<T: Hash>(t: &T) {
println!("The hash is {}", t.hash())
}
</pre>
print_hash
函數是一個定義在未知類型T
上的泛型,它要求T
必須實現Hash
這個特型,這意味著我們可以如此調用該函數:
<pre>
print_hash(&true); // 實例化T = bool
print_hash(&12_i64); // 實例化T = i64
</pre>
泛型是通過靜態生成的方法實例化的。這與C++的模板一致,我們針對這兩次調用生成了兩份print_hash
的代碼,也就是說內部對t.hash()
的調用是零開銷的:它被編譯到了一個對相關實現函數的直接,靜態的調用:
<pre>
// 編譯后的代碼:
__print_hash_bool(&true); // 直接調用bool類型的版本
__print_hash_i64(&12_i64); // 直接調用i64類型的版本
</pre>
這種編譯模型對print_hash
這樣的簡單函數用處不大,不過對實際情況中的哈希處理非常有用,例如我們有一個等價比較的trait:
<pre>
trait Eq {
fn eq(&self, other: &Self) -> bool;
// 這里Self類型就指代實現該特型的類型,例如impl Eq for bool的時候Self的類型就是bool
}
</pre>
我們這時就可以在類型T
上面定義一個HashMap,這里的T
要求同時實現了Hash
和Eq
兩個特型:
<pre>struct HashMap<Key: Hash + Eq, Value> { ... }</pre>
這樣的泛型靜態編譯模型有幾個好處:
每個不同的<Key,Value>類型對將生成不同的具體的HashMap的類型,因此HashMap類型實現的代碼可以把Key值和Value值按序排列,這樣有助于節省空間,提高緩存本地性。
對不同類型的<Key,Value>類型對,
HashMap
的每個方法會生成特殊化代碼(specialized code)。這意味著不會有額外的對hash和eq方法的調用,優化器可以針對具體類型進行優化。也就是說對編譯優化器而言,該抽象實際并不存在,因為在那個階段泛型已經被行內展開了。
總之,和C++模板一樣,這樣實現的泛型可以幫助你在寫高階抽象的同時保證能夠編譯到具體類型的代碼,“這已經是處理這種類型代碼的最佳方式”。
然而與C++模板不同,trait的實現函數需要提前進行完全的靜態類型檢查。也就是說你單獨編譯HashMap
的時候,它會針對Hash和Eq兩種特型來做類型正確性檢查,而不是對泛型展開之后才進行檢查。這意味著對庫函數的作者有更早,更清晰的編譯錯誤提示,以及更低的類型檢查開銷(編譯時間更短)。
動態調用
我們之前展示的特型的靜態編譯模型,但是有時我們使用抽象不僅僅是為了模塊化或者重用——有時抽象在運行時扮演了必要的角色,不能在編譯時刻被靜態處理掉。
例如,GUI框架中經常包含了響應事件的回調函數,例如鼠標點擊:
<pre>trait ClickCallback {
fn on_click(&self, x: i64, y: i64);
}</pre>
GUI元素需要允許不同的回調函數注冊到同一事件。使用泛型的話你可以這么寫:
<pre>
struct Button<T: ClickCallback> {
listeners: Vec<T>,
...
}
</pre>
但是這樣寫有個顯而易見的問題,那就是每個Button的類型只能和一個ClickCallback的實現相對應,這并不是我們想要的。我們想要的是單個的Button類型,它和一個包含很多實現了ClickCallback
特型的監聽器相綁定。
我們現在面臨的問題是,如果一個Button類型中有一個向量數組包含了很多ClickCallback
的實現實例,這些實例各自的大小又各不相同,那么我們該怎么在內存中擺放這些實例呢?解決方案是加入指針,我們在向量數組中存放指向回調函數的指針:
<pre>
struct Button {
listeners: Vec<Box<ClickCallback>>,
...
}
</pre>
這里我們把ClickCallback當作一個類型來使用,在Rust中,特型是一個類型,但是它們的大小是不定的(unsized)
,因此這意味著它們通常需要指針進行引用。可以用Box指針(指向堆內存)和&指針(可以指向任意內存)。
在Rust中,類似&ClickCallback
或者Box<ClickCallback>
的變量被稱作特型對象
,它實際包含了一個指針,指向了一個實現了ClickCallback Trait的類型的實例。它還包含了一個vtable,這個vtable里面包含了特型中定義的每個方法的函數指針,這些信息就足夠在運行時正確的調用Trait的實現方法,也能保證對每個T
都有統一的表示方式。因此Button
可以只被編譯一次,這個抽象在運行時的表示方式就是特型對象
。
靜態和動態的特型分發方式是互補的工具,可以用于不同的情況,Rust的特型為不同風格和需求的接口提供了統一簡潔的表示方式,并且它的開銷是最小化,可預測的。 特型對象的實現滿足Stroustrup的“需要多少才花銷多少”原則:當你需要運行時特型的時候才分配vtable,而同樣的特型在靜態分發的時候只編譯需要的部分。
特型的其他用途
我們已經見過了特型的基本機制和使用方法,它其實也在Rust的其他地方扮演重要角色,例如:
閉包。 類似
ClickFallBack
特型,Rust中的閉包只是一些特別的特型,這里 是Huon Wilson描述的閉包具體實現。-
條件API 泛型讓我們可以實現某些帶條件的特型:
<pre>
struct Pair<A, B> { first: A, second: B }
impl<A: Hash, B: Hash> Hash for Pair<A, B> {
fn hash(&self) -> u64 {
self.first.hash() ^ self.second.hash()
}
}
</pre>
這里對某個特定的Pair
類型實現了Hash
特型,這樣針對不同類型的Pair
可以有不同的API。這里Hash的例子很常見,因此Rust提供了內置的默認實現。
<pre>[derive(Hash)]
struct Pair<A, B> { .. }
</pre> 擴展方法 類似C#中的擴展方法,我們可以對已有類型添加新的特型的方式來提供擴展。
標記 Rust提供了一些有用的“標記(Marker)”來區分類型:
Send
,Sync
,Copy
,Sized
。這些標記僅僅是一些空的Traits,可以被用于泛型或者Trait對象之上。標記可以在庫函數中進行定義,并且通過#[derive]
風格的記號來提供默認實現:如果一個類型的所有成員都是Send
,那么這個類型自己也是Send
。這些標記很有用,它們幫助確保了Rust的線程安全。重載 Rust并不支持傳統重載方式(相同函數名根據函數簽名的不同有不同實現)。不過Traits提供了重載所能提供的大部分好處:如果某個方法是通過特型來定義的,那么任意實現了該特型的類型都可以調用這個方法。相比傳統重載有兩個優勢:首先重載的實現顯得不那么
臨時化(ad hoc)
。你一旦理解了某個特型你就理解了任意使用該特型對某方法進行重載的API。其次是可擴展性:你可以通過提供新的特型實現的方式來在下游重載某個已有的方法。操作符 Rust允許你重載類似
+
的操作符。每個操作符都對應了標準庫中的某個特型,每個實現了該Trait的類型也可以自動支持該操作符。
要點:盡管Traits很簡單,但是它為大量的應用場景和模式提供了一個同一的抽象概念,使得我們可以在保持語言特性簡單的基礎上實現諸多功能。
未來目標
Rust語言的目標是對抽象工具進行不斷的進化。在1.0以后的目標中我們有如下的計劃:
- 靜態生成輸出。
- 特殊化。
-
高階類型。 目前的特型只能被定義在類型上,而不能定義在類型生成器上,也就是說我們只能在
Vec<u8>
上面添加新特型,而不能在Vec
本身上定義新Trait。這一特性的添加將是對Rust抽象能力的極大提升。 - 高效重用。
注:
有趣的地方主要是Rust通過靜態編譯的方式實現特型的靜態分發,通過特型對象(包括一個指針和一個vtable)的方式實現Trait的動態調用。