有時候,我們希望同一個實作可以套用在不同的型別上,在動態型別的語言中,例如 Python,不需要處理這個問題,因為這些語言的機制會自動處理這個問題,然而,在靜態型別的語言,像是 C++、Java 或本教程中的 Rust,無法自動套用不同型別。其中一個方式就是透過泛型 (generics) 的機制來達到這樣的效果。假設我們想實作一個向量運算的類別,如果沒有泛型,可能的 Rust 虛擬碼如下:
由以上範例可知,我們要針對不同型別重覆撰寫兩套相似的程式碼,而且,我們的實作缺乏擴充性,日後若要進行有理數 (rational number) 或是複數 (complex number) 或其他型別的運算,又得重覆撰寫相似的程式碼。泛型提供較佳的機制來解決這個問題,以泛型改寫上述例子的 Rust 虛擬碼如下:
之後,要使用此泛型類別時,只要指定型別 T
即可使用這個類別。由以上範例可知,若我們實作出泛型程式後,就可以套用在不同型別上,達到程式碼再利用的效果。
使用泛型的例子
在 Rust 的標準函式庫中,已有許多泛型程式的例子,像是 vector、map、set 等容器,在本書先前的內容中,已有一些使用容器的實例。另外,Rust 有一些特殊類別,內部也用到泛型的機制,像是 Result<T, E>
就是一個泛型 enum。以下是使用 Result
的實例:
在本例中, "12345".parse::<u32>()
使用泛型的語法指定解析字串的類別。
撰寫泛型程式
泛型程式可以用在函式或是物件的撰寫,在本節中,我們分別以泛型函式和泛型物件展示如何撰寫泛型程式。
泛型函式
泛型函式的 Rust 虛擬碼如下:
fn foo<T>(x: T)
如果有兩個以上同型別參數,則 Rust 虛擬碼如下:
fn bar<T>(x: T, y: T)
如果有兩個以上不同型別的參數,則 Rust 虛擬碼如下:
fn baz<T, U>(x: T, y: U)
以下是一個泛型函式的例子,為了簡化程式,我們引用 Num trait,這個 trait 代表該泛型變數為數字。
泛型物件
泛型物件的 Rust 虛擬碼如下:
struct Foo<T> {
x: T,
y: T
}
如果需要實作某個物件的方法,Rust 虛擬碼如下:
impl<T> Foo<T> {
fn do_something(x: T, ...) -> ... {
// Implement method here
}
}
如果要實作某個 trait 也可以:
// Say that Bar is a trait
impl<T> Bar for Foo<T> {
fn method_from_bar(x: T, ...) -> ... {
// Implement method here
}
}
以下是一個泛型物件的實例:
實際撰寫泛型程式時,設定相關的 trait 相當重要,Rust 需要足夠的資訊來判斷泛型中的變數是否能夠執行特定的行為,而這個資訊是透過 trait 來指定。
實例:實作向量運算
接下來,我們用一個比較長的例子展示如何實作泛型程式。在我們這個例子中,我們實作向量類別,這個類別可以進行向量運算;為了簡化範例,我們僅實作向量加法。首先,建立 Vector
類別,內部使用 Rust 內建的 vector 來儲存資料,在這裡一併呼叫相關的 trait:
接著,實作 Clone
trait,使得本向量類別可以像基礎型別般,在計算時拷貝向量,由於 Rust 的限制,目前不能實作 Copy
trait。
我們的建構子可接受 slice,簡化建立物件的流程:
實作 fmt::Debug
trait,之後可直接從 console 印出本類別的內容。這裡實作的方式參考 Rust 的 vector 在終端機印出的形式。
實作加法運算子,需實作 std::ops::Add
trait。向量加法的方式是兩向量間同位置元素相加,相加前應檢查兩向量是否等長。
最後,從外部程式呼叫此類別:
若我們將這個範例繼續發展下去,就可以實作具有泛型機制的向量運算類別,有興趣的讀者可以自行嘗試。由於 Rust 為了保持函式庫的相容性,現階段不允許對 non-Copy data 實作 Copy trait,像是本例的向量類別內部使用的 vector,所以,我們必需要在外部程式中明確地拷貝向量類別。經筆者實測,對於有解構子的類別也不能使用 Copy trait,所以,即使我們用 C 風格的陣列重新實作 vector,同樣也不能用 Copy trait。
另外,我們在這裡用了一個外部函式庫提供 Num trait,這個 trait 代表該型別符合數字,透過使用這個 trait,不需要重新實作代表數字的 trait,簡化我們的程式。
剛開始寫 Rust 泛型程式時,會遭到許多錯誤而無法順利編譯,讓初學者感到挫折。解決這個問題的關鍵在於 Rust 的 trait 系統。撰寫泛型程式時,若沒有對泛型變數 T 加上任何的 trait 限制,Rust 沒有足夠的資訊是否能對 T 呼叫相對應的內建 trait,因而引發錯誤訊息。即使是使用運算子,Rust 也會呼叫相對應的 trait;因此,熟悉 trait 的運作,對撰寫泛型程式有相當的幫助。
(案例選讀) 模擬方法重載
Rust 不支援方法重載,不過,可以利用泛型加上多型達到類似的效果。由於呼叫泛型函式時,不需要明確指定參數的型別,使得外部程式在呼叫該函式時,看起來像是方法重載般。接下來,我們以一個範例來展示如何模擬方法重載。首先,定義公開的 trait:
use std::fmt;
// An holder for arbitrary type
pub trait Data: fmt::Display {
// Omit interface
// You may declare more methods later.
}
pub trait IntoData {
type OutData: Data;
fn into_data(&self) -> Self::OutData;
}
接著,實作 Reader
類別,在這個類別中,實作了一個泛型函式,搭配先前的 trait 類別來模擬方法重載:
pub struct Reader {}
// Use generic method to mimic functional overloading
impl<'a> Reader {
pub fn get_data<I>(& self, data: I) -> Box<Data + 'a>
where I: IntoData<'a> + 'a {
Box::new(data.into_data())
}
}
接著,實作 StrData
類別,這個類別會實作 Data
和 IntoData
這兩個 trait,以滿足前述介面所定義的行為:
pub struct StrData<'a> {
str: &'a str
}
impl<'a> StrData<'a> {
pub fn new(s: &'a str) -> StrData<'a> {
StrData{ str: s }
}
}
impl<'a> fmt::Display for StrData<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.str)
}
}
impl<'a> Data for StrData<'a> {
// Omit implementation
}
impl<'a> IntoData<'a> for StrData<'a> {
type OutData = &'a str;
fn into_data(&self) -> &'a str {
self.str
}
}
/* Even Data trait is empty, it is necessary to
explictly implement it. */
impl<'a> Data for &'a str {
// Omit implementation
}
接著,以類似 StrData
的方式實作 IntData
:
pub struct IntData{
int: i32
}
impl IntData {
pub fn new(i: i32) -> IntData {
IntData{ int: i }
}
}
impl fmt::Display for IntData {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.int)
}
}
impl Data for IntData {
// Omit implementation
}
impl IntoData for IntData {
type OutData = i32;
fn into_data(&self) -> i32 {
self.int
}
}
/* Even Data trait is empty, it is necessary to
explictly implement it. */
impl<'a> Data for i32 {
// Omit implementation
}
最後,從外部程式呼叫:
fn main() {
let reader = Reader{};
let str_data = StrData::new("string data");
let int_data = IntData::new(10);
// Call hidden generic method to minic functional overloading
let str = reader.get_data(str_data);
let int = reader.get_data(int_data);
println!("Data from StrData: {}", str);
println!("Data form IntData: {}", int);
}
在我們這個範例中,除了用泛型的機制模擬出方法重載以外,另外一個重點在於 get_data
函式隱藏了一些內部的操作,對於程式設計者來說,只要實作 Data
和 IntoData
後,從外部程式呼叫時,不需要在意其中操作的細節,這也是物件導向的優點之一。