泛型的基本使用

通過泛型系統,可以減少很多冗餘代碼。

例如,不使用泛型時,定義一個參數允許為u8、i8、u16、i16、u32、i32......等類型的double函數時:

fn double_u8(i: u8) -> u8 { i + i }
fn double_i8(i: i8) -> i8 { i + i }
fn double_u16(i: u16) -> u16 { i + i }
fn double_i16(i: i16) -> i16 { i + i }
fn double_u32(i: u32) -> u32 { i + i }
fn double_i32(i: i32) -> i32 { i + i }
fn double_u64(i: u64) -> u64 { i + i }
fn double_i64(i: i64) -> i64 { i + i }

fn main(){
  println!("{}",double_u8(3_u8));
  println!("{}",double_i16(3_i16));
}

上面定義了一堆double函數,函數的邏輯部分是完全一致的,僅在於類型的不同。

泛型可以用於解決這樣因類型而代碼冗餘的問題。使用泛型時:

use std::ops::Add;
fn double<T>(i: T) -> T
  where T: Add<Output=T> + Clone + Copy {
  i + i
}

fn main(){
  println!("{}",double(3_i16));
  println!("{}",double(3_i32));
}

上面的字母T就是泛型(和變量x的含義是相同的),它用來代表各種可能的數據類型。多數時候泛型使用單個大寫字母來表示,但也可以使用多個字母來表示。

對於double函數簽名的前一部分:


#![allow(unused)]
fn main() {
fn double<T>(i: T) -> T 
}

函數名稱後面的<T>表示在函數作用域內定義一個泛型T,這個泛型只能在函數簽名和函數體內使用,就跟在一個作用域內定義一個變量,這個變量只能在該作用域內使用是一樣的。而且,泛型本就是代表各種數據類型的變量。

參數部分i: T表示參數i的類型是泛型T。

返回值部分-> T表示該函數的返回值類型是泛型T。

因此,上面這部分函數簽名表達的含義是:傳入某種數據類型的參數,也返回這種數據類型的返回值,且這種數據類型可以是任意的類型。

對於第一次接觸泛型的人來說,這可能很難理解。但是,換成類似的使用普通變量的代碼,可能就容易理解了:

# 偽代碼:傳入一個數據,返回這個數據
function f(x) {return x}

對泛型進行限制

但注意,double函數期待的是對數值進行加法操作,但泛型卻可以代表各種類型,因此,還需要對泛型T進行限制,否則在調用double函數時就允許傳遞字符串類型、Vec類型、Person類型等值作為函數參數,這偏離了期待。

例如,在double的函數體內需要對泛型T的值i進行加法操作,但只有實現了std::ops::Add Trait的類型才能使用+進行加法操作。因此要限制泛型T是那些實現了std::ops::Add的數據類型。

限制泛型也叫做Trait綁定(Trait Bound),其語法有兩種:

  • 在定義泛型類型T時,使用類似於T: Trait_Name這種語法進行限制
  • 在返回值後面、大括號前面使用where關鍵字,如where T: Trait_Name

因此,下面兩種寫法是等價的:


#![allow(unused)]
fn main() {
fn f<T: Clone + Copy>(i: T) -> T{}

fn f<T>(i: T) -> T
  where T: Clone + Copy {}

// 更復雜的示例:
fn query<M: Mapper + Serialize, R: Reducer + Serialize>(
    data: &DataSet, map: M, reduce: R) -> Results 
{
    ...
}

// 此時,下面寫法更友好、可讀性更高
fn query<M, R>(data: &DataSet, map: M, reduce: R) -> Results 
    where M: Mapper + Serialize,
          R: Reducer + Serialize
{
    ...
}
}

其中,T: Trait_Name表示將泛型T限制為那些實現了Trait_Name Trait的數據類型。因此T: std::ops::Add表示泛型T只能代表那些實現了std::ops::Add Trait的數據類型,比如各種數值類型都實現了Add Trait,因此T可以代表數值類型,而Vec類型沒有實現Add Trait,因此T不能代表Vec類型。

觀察指定變量數據類型的寫法i: i32和限制泛型的寫法T: Trait_Name,由此可知,Trait其實是泛型的數據類型,Trait限制了泛型所能代表的類型,正如數據類型限制了變量所能存放的數據。

有時候需要對泛型做多重限制,這時使用+即可。例如T: Add<Output=T>+Copy+Clone,表示限制泛型T只能代表那些同時實現了Add、Copy、Clone這三種Trait的數據類型。

之所以要做多重限制,是因為有時候限制少了,泛型所能代表的類型不夠精確或者缺失某種功能。比如,只限制泛型T是實現了std::ops::Add Trait的類型還不夠,還要限制它實現了Copy Trait以便函數體內的參數i被轉移所有權時會自動進行Copy,但Copy Trait是Clone Trait的子Trait,即Copy依賴於Clone,因此限制泛型T實現Copy的同時,還要限制泛型T同時實現Clone Trait。

簡而言之,要對泛型做限制,一方面的原因是函數體內需要某種Trait提供的功能(比如函數體內要對i執行加法操作,需要的是std::ops::Add的功能),另一方面的原因是要讓泛型T所能代表的數據類型足夠精確化(如果不做任何限制,泛型將能代表任意數據類型)。

泛型的引用類型

如果參數是一個引用,且又使用泛型,則需要使用泛型的引用&T&mut T

例如:


#![allow(unused)]
fn main() {
use std::fmt::Display;

fn f<T: Display>(i: &T) {
  println!("{}", *i);
}
}

零運行開銷的泛型:泛型代碼膨脹

rustc在編譯代碼時,會將所有的泛型替換成它所代表的具體數據類型,就像編譯期間會將變量名替換成它所代表數據的內存地址一樣。

例如,對於下面這個泛型函數:

use std::ops::Add;
fn double_me<T>(i: T) -> T
  where T: Add<Output=T> + Clone + Copy {
  i + i
}

fn main() {
  println!("{}", double_me(3u32));
  println!("{}", double_me(3u8));
  println!("{}", double_me(3i8));
}

在編譯期間,rustc會根據調用double_me()時傳遞的具體數據類型進行替換。上面示例使用了u32、u8和i8三種類型的值傳遞給泛型參數,那麼編譯期間,編譯器會對應生成三個double_me()函數,它們的參數類型分別是u32、u8和i8。

$ rustc src/main.rs
$ strings main | grep "double_me"
_ZN4main9double_me17h6d861a9e8ab36c42E
_ZN4main9double_me17ha214a9977249a1bfE
_ZN4main9double_me17hbc458c5fab68c203E

由於編譯期間,編譯器會對泛型類型進行替換,這會導致泛型代碼膨脹(code bloat),從一個函數膨脹為零個、一個或多個具體數據類型的函數。有時候這種膨脹會導致編譯後的程序文件變大很多。不過,多數情況下,代碼膨脹的問題都不是大問題。

另一方面,由於編譯期間已經將泛型替換成了具體的數據類型,因此,在程序運行期間,直接調用對應類型的函數即可,不需要再消耗任何額外的資源去計算泛型所代表的具體類型。因此,Rust的泛型是零運行時開銷的。