位元詩人 [Rust] 程式設計教學:泛型 (Generics)

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

有時候,我們希望同一個實作可以套用在不同的型別上,在動態型別的語言中,例如 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 類別,這個類別會實作 DataIntoData 這兩個 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 函式隱藏了一些內部的操作,對於程式設計者來說,只要實作 DataIntoData 後,從外部程式呼叫時,不需要在意其中操作的細節,這也是物件導向的優點之一。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。