iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0

簡介

泛型(generics)是一種能讓同一個邏輯應用在不同型別的方式,讓我們能撰寫更加通用且可重用的程式碼。
特別是在強型別語言中,泛型讓我們不必為每個不同的型別寫重複的函數或結構體。

例如要處理不同數字類型的相加,沒有泛型的情況下我們要這樣寫:

fn sum_i32(n1: i32, n2: i32) -> i32 {
    n1 + n2
}

fn sum_f32(n1: f64, n2: f64) -> f64 {
    n1 + n2
}

在強型別的程式語言為了符合型別的檢查,會出現這種功能相同卻要為了不同的型別個別寫一個函數的情況,反而讓整個設計變很繁瑣而且冗長。

在 JavaScript 不檢查型別反而沒有這樣的困擾,另一方面他會給出一個結果,但結果可能不是我們預期的。

泛型就是強型別語言遇到這種情況的其中一種解法,既保留程式的通用性(重複利用程式碼),也保留了安全性(型別的檢查)。

函數使用泛型

泛型的寫法是在函數的後面加上 <> ,裡面會放泛型的參數,除了之前生命週期提到的 'a,一般習慣用 T (type)命名,在後續的函數簽名中這個T都代表同一個型別,把 sum整理成一個泛型的版本:

fn sum<T>(n1: T, n2: T) -> T {
    n1 + n2
}

這樣就不用再為每一個型別新增一個函數出來,不過上面的寫法還是無法編譯過。

error[E0369]: cannot add `T` to `T`
 --> src/main.rs:2:8
  |
2 |     n1 + n2
  |     -- ^ -- T
  |     |
  |     T
  |
help: consider restricting type parameter `T`
  |
1 | fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T {
  |         +++++++++++++++++++++++++++

這邊錯誤訊息指的是因為編譯器沒辦法確定每種 T 的型別都支援 + 操作,所以需要限制 T 的型別範圍。
可以用它給的解法,補上那段程式碼的意思代表的是把 T 的型別限制在標準函數庫提供的 std::ops::Add 特徵,並且 Output = T 限制結果也必須是 T 型別,目前對特徵的理解只要知道它是限制泛型型別範圍的一種機制就可以了,之後再來介紹特徵。

修改過的版本如下:

fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T {
    n1 + n2
}

結構體使用泛型

除了函數以外,如同之前結構體、生命週期的介紹,結構體也可以用泛型參數在一或多個欄位中,使用方式和函數類似,我們在結構體定義的名稱後面加上 <>

struct GenericPair<T> {
    first: T,
    second: T,
}

fn main() {
    let int_pair = GenericPair { first: 1, second: 2 };
    let float_pair = GenericPair { first: 0.1, second: 0.2};
}

概念上和函數的情況是一樣的,T 可以是任意型別,但是兩個欄位的型別要是同一種,不同型別的話會報錯。

fn main() {
    let different_pair = GenericPair { first: 1, second: 0.2};
}
error[E0308]: mismatched types
  --> src/main.rs:10:58
   |
10 |     let different_pair = GenericPair { first: 1, second: 0.2};
   |                                                          ^^^ expected integer, found floating-point number

如果沒有要限制兩個型別一定要一樣的話,泛型參數可以不只一個,我們再多一種 U

struct GenericPair<T, U> {
    first: T,
    second: U,
}

這樣上面兩個欄位型別不同的例子就可以通過編譯了。不過需要注意,這種寫法不會限制兩個型別一定要不一樣,所以原本的都整數或都浮點數的例子都還是可以正常編譯。

泛型參數沒有限制數量,但一般不會太多,太多的話是函數做太多事的跡象,需要拆成更小的元件了,而且太多泛型參數也會讓程式碼變得很難閱讀。

列舉使用泛型

列舉也是一樣的概念,我們重新檢視最常見的兩種列舉:

Option

enum Option<T> {
    None,
    Some(T),
}

Option有一個泛型參數T ,這個型別會被包在 Some 中,而 None 則是沒有任何數值的變體,透過泛型整個 Rust 只需要一個Option列舉就可以被廣泛地用在各個地方,而且很好的表達可能有可能沒有的概念。

Result

Result則是一個有複數泛型參數的例子:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

昨天介紹的例子就是典型的泛型用法:

fn main() -> Result<(), CustomError> {
    let result = divide(10, -2)?;
    println!("結果為:{}", result);
    Ok(())
}

這裡的TE分別就是()CustomError,和Option一樣可以很彈性的根據自己的需求來應用。
如果沒有定義完整的泛型型別編譯會報錯,例如把E對應的泛型參數拿掉:

error[E0107]: enum takes 2 generic arguments but 1 generic argument was supplied
   --> src/main.rs:20:14
    |
20  | fn main() -> Result<()> {
    |              ^^^^^^ -- supplied 1 generic argument
    |              |
    |              expected 2 generic arguments
    |
note: enum defined here, with 2 generic parameters: `T`, `E`

不過再回想一下更久之前的 echo function 程式碼是這樣:

use std::io;

fn main() -> io::Result<()> {
    let mut input = String::new();

    io::stdin().read_line(&mut input)?;

    println!("You typed: {}", input.trim());
    Ok(())
}

這裡就滿有趣了,為什麼上面的例子沒有定義E的泛型參數還是可以正常編譯🤔?

原因是read_line使用了 Rust 內建的 io::Error 作為錯誤型別。這種情況下,編譯器可以自動推斷出錯誤型別,不需要我們顯式地指定。
不過如果我們是自訂義的錯誤型別,或是使用其他非錯誤而是通用的型別的時候就必須顯式地指定,同樣是昨天的例子用String的情況就必須標了:

fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        Err(String::from("除數不能為零"))
    } else {
        Ok(dividend / divisor)
    }
}

預設泛型型別

除了編譯器可以自動推斷泛型型別的情況,我們在定義的時候還能預設泛型的型別,之後用同一個型別的時候就不用把泛型的部分寫出來:

struct MyBox<T = i32> { // 泛型型別預設為 i32
    value: T,
}

fn main() {
    let int_box = MyBox { value: 5 }; // 和預設相同不用寫泛型型別
    let float_box: MyBox<f64> = MyBox { value: 3.14 };  // 如果和預設不同還是可以另外指定
    
    println!("int_box value: {}", int_box.value);
    println!("float_box value: {}", float_box.value);
}

這種用法主要用在希望使用者主要使用某者特定類型,但還是想保留不同型別靈活性的時候。

impl 區塊與泛型

impl 區塊也可以應用泛型到關聯函數和方法上,寫法又再更複雜一些,當時介紹沒提到的一個細節是:同一個結構體或列舉可以宣告多個impl區塊
如果沒有泛型的話這樣的設計其實沒有太大的意義,然而,當引入泛型後,我們就能為不同的泛型類型定義不同的方法和約束,進一步提升靈活性與可擴展性。

舉例來說,我們先實作一個結構體Point ,有xy座標,然後因為數字有不同類型,所以先寫成泛型的版本,同時實作關聯函數new 以及兩個方法分別回傳xy的值。

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }

    fn x(&self) -> &T {
        &self.x
    }

    fn y(&self) -> &T {
        &self.y
    }
}

impl還有指定的結構體名稱後面都用<T> 帶入泛型型別,這樣就可以在impl的區塊使用那個範型。這部分是所有泛型型別的共通操作。

接著我想針對有正負號的數字型別另外實作兩個方法,以 x 或 y 軸為對稱線取得當下點的對稱位子。
接續上面的程式碼補上:

// 針對有正負號的數字型別的特殊方法
impl<T: std::ops::Neg<Output = T> + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + Copy> Point<T> {
    // 以 x 軸為對稱軸的對稱點
    fn symmetric_x(&self) -> Self {
        Point { x: self.x, y: -self.y }
    }

    // 以 y 軸為對稱軸的對稱點
    fn symmetric_y(&self) -> Self {
        Point { x: -self.x, y: self.y }
    }
}

雖然 impl 後面的部分變超長,不過現在我們先忽略它,只要知道這些是限制泛型型別的條件,它和前面基本款的 impl 結構還是一樣的 (<T>),只是限制了泛型要是特定型別。

接著我們到 main 測試不同的型別,可以看到三種型別都有共通 impl 區塊定義的方法 x 以及 y ,但是只有 u32 沒有 symmetric_xsymmetric_y 方法,原因是他不符合第二個 impl 的泛型型別,所以那個區塊的實作對象並不包含它,如果嘗試使用的話編譯器會報錯。
透過這樣的方式我們就可以用泛型既定義了共通的行為,也針對特定的型別實作了特定的方法。如果不能對同一個對象寫多個impl區塊的話是做不到這件事的。

fn main() {
    // 1. 整數沒有負號的 (u32)
    let point_u32 = Point::new(5u32, 10u32);
    println!(
        "Point<u32>: ({}, {})",
        point_u32.x(),
        point_u32.y()
    );

    // 2. 整數有負號的 (i32)
    let point_i32 = Point::new(-3i32, 7i32);
    println!(
        "Point<i32>: ({}, {}) -> symmetric_x: ({}, {}), symmetric_y: ({}, {})",
        point_i32.x(),
        point_i32.y(),
        point_i32.symmetric_x().x(),
        point_i32.symmetric_x().y(),
        point_i32.symmetric_y().x(),
        point_i32.symmetric_y().y(),
    );

    // 3. 浮點數 (f64)
    let point_f64 = Point::new(1.5f64, -2.5f64);
    println!(
        "Point<f64>: ({}, {}) -> symmetric_x: ({}, {}), symmetric_y: ({}, {})",
        point_f64.x(),
        point_f64.y(),
        point_f64.symmetric_x().x(),
        point_f64.symmetric_x().y(),
        point_f64.symmetric_y().x(),
        point_f64.symmetric_y().y(),
    );
}

再做個實驗,如果不同的 impl 有同一個方法名稱會怎麼樣呢?
我們沿用剛才的例子,在兩個 impl 區塊都定義相同名稱的方法:

impl<T> Point<T> {
    fn describe(&self) {
        println!("This is a generic Point.");
    }
}

impl<T: std::ops::Neg<Output = T> + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + Copy> Point<T> {
    fn describe(&self) {
        println!("This is a special Point.");
    }
}

答案是 Rust 不允許這樣做,編譯器會幫你檢查再報錯,還會跟你說在哪邊重複了。

error[E0592]: duplicate definitions with name `describe`
  --> src/main.rs:19:5
   |
19 |     fn describe(&self) {
   |     ^^^^^^^^^^^^^^^^^^ duplicate definitions for `describe`
...
36 |     fn describe(&self) {
   |     ------------------ other definition for `describe`

單型化

最後補充泛型的程式碼效能,根據官方文件,使用泛型型別不會比實際把每種型別定義出來的情況慢。
原因在於 Rust 在編譯時會針對使用泛型的程式碼進行單型化(monomorphization),概念上是程式碼有用到泛型的地方,編譯器會在編譯時期生成該泛型函數或結構體的具體型別版本。因此,對於每個不同的型別,編譯器都會生成對應的實現,就像我們自己手動撰寫不同類型的版本一樣,如此可以保證程式的執行效率不會受到泛型的影響。

我們程式碼寫的:

fn sum<T>(n1: T, n2: T) -> T {
    n1 + n2
}

實際編譯過後編譯器幫我們展開的概念如下:

fn sum_i32(n1: i32, n2: i32) -> i32 {
    n1 + n2
}

fn sum_f32(n1: f64, n2: f64) -> f64 {
    n1 + n2
}

結語

這篇我們整理了泛型的設計還有必要性,了解各種區塊使用泛型的方式,尤其是 impl和泛型搭配,才能理解為什麼設計上要讓同一個名稱可以impl多次。
不過還有一個問題是如何有效並且精準的限制泛型型別的範圍,而特徵就是解決這個問題的關鍵,也是泛型系統的基石,下一篇我們就來介紹它。


上一篇
Day19 - 更多的列舉
下一篇
Day21 - 特徵
系列文
螃蟹幼幼班:Rust 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言