Skip to content

泛型

什么是泛型

我们在编程中,经常有这样的需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。在不使用泛型的时候,通常需要为每一种类型编写一个函数:

rust
fn add_i8(a:i8, b:i8) -> i8 {
    a + b
}
fn add_i32(a:i32, b:i32) -> i32 {
    a + b
}
fn add_f64(a:f64, b:f64) -> f64 {
    a + b
}

fn main() {
    println!("add i8: {}", add_i8(2i8, 3i8));
    println!("add i32: {}", add_i32(20, 30));
    println!("add f64: {}", add_f64(1.23, 1.23));
}

于是提出了泛型的概念,泛型极大地减少了代码的重复,但它自身的语法很要求细心。也就是说,采用泛型意味着仔细地指定泛型类型具体化时,什么样的具体类型是合法的。泛型最简单和常用的用法是用于类型参数。

泛型最简单和常用的用法是用于类型参数。

例如定义一个名为 foo泛型函数,它可接受泛型参数为 T 的参数 arg。实际上在 Rust 中,泛型参数的名称可以任意起,但是出于惯例,我们都用 T。使用泛型前,需要在使用前对泛型参数进行声明。

rust
      声明  泛型类型
       |       |
fn foo<T>(arg: T) { ... }

所以上边的代码可以换成泛型写成:

rust
// 还不能正常运行
fn add<T>(a:T, b:T) -> T {
    a + b
}

fn main() {
    println!("add i8: {}", add(2i8, 3i8));
    println!("add i32: {}", add(20, 30));
    println!("add f64: {}", add(1.23, 1.23));
}

但是,上边的代码暂时还是不能运行,因为泛型可以表示的类型有很多,不是所有的类型都可以进行计算相加,因此编译器会给出建议,为泛型类型T添加类型限制。添加限制的目的就是为了让类型T能够实现可以比较或者运算的功能。有关限制(trait)的更多知识在Trait章节会介绍。现在先知道这个trait就是为了限制T的功能即可。

rust
fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
    a + b
}

结构体中的泛型

结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point,它可以存放任何类型的坐标值:

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

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

在结构体中使用泛型需要提前申明,如上<T>;如果是同一个泛型参数,那么xy必须是同类型。如果想让 xy 既能类型相同,又能类型不同,就需要使用不同的泛型参数:

rust
struct Point<T,U> {
    x: T,
    y: U,
}
fn main() {
    let p = Point{x: 1, y :1.1};
}

枚举中的泛型

在错误处理中,多次提到了Option,源码中的表示是这样的:

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

Option 是一个拥有泛型 T 的枚举,它有两个成员:Some,它存放了一个类型 T 的值,和不存在任何值的None。通过 Option 枚举可以表达有一个可能的值的抽象概念,同时因为 Option 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。

枚举也可以拥有多个泛型类型。错误处理中 Result 定义就是一个这样的例子:

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

Result 枚举有两个泛型类型,TEResult 有两个成员:Ok,它存放一个类型 T 的值,而 Err 则存放一个类型 E 的值。这个定义使得 Result 枚举能很方便的表达任何可能成功(返回 T 类型的值)也可能失败(返回 E 类型的值)的操作。

方法中的泛型

在为结构体和枚举实现方法时,一样也可以用泛型。

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

impl<T> Point<T> {
    fn x(&self) -> &T {  // 这里在 Point<T> 上定义了一个叫做 x 的方法来返回字段 x 中数据的引用。
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

注意必须在 impl 后面声明 T,这样就可以在 Point 上实现的方法中使用它了。在 impl 之后声明泛型 T ,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。因为再次声明了泛型,我们可以为泛型参数选择一个与结构体定义中声明的泛型参数所不同的名称,不过依照惯例使用了相同的名称。impl 中编写的方法声明了泛型类型可以定位为任何类型的实例,不管最终替换泛型类型的是何具体类型。

方法也可以有多个不同的泛型参数。

泛型的静态分发

我们还可以通过利用泛型的静态分发的方法,简化代码,我们举一个简单的例子解释静态分发的概念:

rust
// 首先定义两个结构体`Dog`和`Cat`分别实现 `Animal` trait。
trait Animal {
    fn speak(&self);
}
struct Dog;
impl Animal for Dog {
    fn speak(&self) {
        println!("I AM A Dog!");
    }
}
struct Cat;
impl Animal for Cat {
    fn speak(&self) {
        println!("I AM A CAT!");
    }
}

// 利用泛型实现静态分发
// 通过对泛型参数T进行限定(参数T需要实现trait Animal),实现只用一个函数即可实现两个类型的调用
fn animal_speak<T: Animal>(animal: T) {
    animal.speak();
}

fn main() {
    let dog = Dog;
    let cat = Cat;

    animal_speak(dog);
    animal_speak(cat);
}

在这里 Rust 用“单态”来进行静态分发。这意味着 Rust 会为DogCat分别创建一个特殊版本的的animal_speak(),然后将对animal_speak的调用替换为这些特殊函数。类似于下方:

rust
fn animal_speak(animal: Dog) {
    animal.speak();
}

fn animal_speak(animal: Cat) {
    animal.speak();
}

这里对静态分发进行简单的介绍,静态分发允许函数被内联调用,因为调用者在编译时就知道它,内联对编译器进行代码优化十分有利。静态分发能提高程序的运行效率,不过相应的也有它的弊端:会导致“代码膨胀”(code bloat),导致编译文件增大。因为在编译出的二进制程序中,同样的函数,对于每个类型都会有不同的拷贝存在。

更多关于泛型的知识点将在进阶中详细讲述。