likes
comments
collection
share

Rust 中的泛型 Generics

作者站长头像
站长
· 阅读数 51

编程中常见的需求是:用同一功能的函数处理不同类型的数据。在不支持泛型的编程语言中,通常需要为每一种类型编写一个函数。而泛型的存在,就可以为开发者提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力。即可以用一个函数,代替很多个完成同样功能但处理不同类型数据的函数。

例如,不使用泛型时,定义一个参数允许为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的含义是相同的),它用来代表各种可能的数据类型。

在函数定义中使用泛型

当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。

在 Rust 中,泛型参数的名称可以任意起,处于惯例都是用 T(type 的首字母)作为首选。

使用泛型参数,必须在使用前对其进行声明:

fn largest<T> (list: &[T]) -> T {...}

定义泛型版本函数类型参数声明位于函数名称与参数列表中间的尖括号 <> 中,比如 largest<T> 首先对泛型参数 T 进行了声明,然后才有泛型参数 list: &[T]和返回值T

参数部分list: &[T]表示参数list的类型是泛型&[T]

返回值部分-> T表示该函数的返回值类型是泛型T

因此这个函数的定义可以理解为:函数有泛型参数 T,函数的参数是 list,其类型是元素为 T 的数组切片,函数返回值类型也是 T

综上,对于泛型函数:函数名称后面的<T>表示在函数作用域内定义一个泛型T,这个泛型只能在函数签名和函数体内使用,就跟在一个作用域内定义一个变量,这个变量只能在该作用域内使用是一样的。而且,泛型本就是代表各种数据类型的变量。

因此,上面这部分函数签名表达的含义是:传入某种数据类型的参数,也返回这种数据类型的返回值,且这种数据类型可以是任意的类型。

结构体中使用泛型

结构体中的字段类型也可以用泛型来定义,如:

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

需要注意:在使用泛型参数之前必须进行声明 Point<T>,然后才可以在结构体的字段类型中使用 T 来替代具体的类型,同时 x 和 y 是相同的类型

如果想要让 x 和 y 具有不同的类型,需要使用不同的泛型参数:

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

fn main() { 
  let p = Point{x: 1, y :1.1}; 
}

枚举中使用泛型

在枚举中使用泛型,Rust中最常见的枚举泛型类型是 Option<T>Result<T, E>

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

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

Option 和 Result 都常用于函数的返回值,Option 用于值的存在与否,Result 主要关注值的正确性。

如果函数正常运行,则Result最后返回一个 Ok(T)T 是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)E 是错误类型。

方法中使用泛型

在方法上也可以使用泛型,在使用泛型参数前,依然需要提前声明:impl<T>,只有提前声明了,才能在 Point<T> 中使用,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。

struct Point<T> {
    x: T,
    y: T,
}
impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

需要注意:方法声明中的 Point<T> 不是泛型声明,而是一个完整的结构体类型,因为定义的结构体就是 Point<T> 而不是 Point

除了结构体中的泛型参数,还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:

struct Point<T, U> { // 结构体泛型
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    // 函数泛型
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

该例子中,T, U 是定义在结构体 Point 上的泛型参数,V, W 是定义在方法上的泛型参数,它们并不冲突,可以理解为,一个是结构体泛型,一个是函数泛型。

也可以为泛型指定限制(constraint)为具体的泛型类型实现方法,比如对于 Point<T> 类型,不仅能定义基于 T 的方法,还能针对特定的具体类型进行方法定义。这意味着该特定类型会有一个定义的方法,而其他的 T 不是该类型的 Point<T> 实例则没有定义此方法。这样就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。

对泛型进行限制

限制泛型也叫做泛型约束或者Trait绑定(Trait Bound),其语法有两种:

  • 在定义泛型类型T时,使用类似于T: Trait_Name这种语法进行限制
  • 在返回值后面、大括号前面使用where关键字,如where T: Trait_Name

简而言之,要对泛型做限制,一方面的原因是函数体内需要某种Trait提供的功能,另一方面的原因是要让泛型T所能代表的数据类型足够精确化(如果不做任何限制,泛型将能代表任意数据类型)。

const 泛型

之前的泛型中,可以抽象为一句话:针对类型实现的泛型,所有的泛型都是为了抽象不同的类型。

同一类型不同长度的数组也是不同的数组类型,如 [i32, 2] 和 [i32, 3] 就是不同的数组类型。可以使用数组切片(引用)和泛型来解决处理任何类型数组的问题,例如:

fn display_array<T: std::fmt::Debug>(arr: &[T]) {
    println!("{:?}", arr);
}

但是使用上面的方法,不适用于引用不好用或不能用的场景。这时就可以用 const 泛型,也就是针对值的泛型,来处理数组长度的问题:

fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
    println!("{:?}", arr);
}

代码中定义了一个类型为 [T; N] 的数组,其中 T 是一个基于类型的泛型参数,N 是一个基于值的泛型参数,此处替代的是数组的长度。

N 就是 const 泛型,定义的语法是 const N: usize,表示 const 泛型 N,它基于的值类型是 usize。在泛型参数之前,Rust 完全不适合复杂矩阵的运算,自从有了 const 泛型,一切即将改变。

注:假设某段代码需要在内存很小的平台上工作,因此需要限制函数参数占用的内存大小,此时就可以使用 const 泛型表达式来实现。

泛型的性能

Rust 中的泛型是零成本抽象,因此在使用泛型时,完全不用担心性能上的问题。另一方面,我们失去的是编译速度和增大了最终生成文件的大小,因为 Rust 在编译期为泛型对应的多个类型都生成了各自的代码。

Rust 通过在编译时进行泛型代码的 单态化 ( monomorphization ) 来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。因此使用泛型时没有运行时开销,单态化过程正是 Rust泛型在运行时及其高效的原因。

rustc在编译代码时,会将所有的泛型替换成它所代表的具体数据类型,就像编译期间会将变量名替换成它所代表数据的内存地址一样。由于编译期间,编译器会对泛型类型进行替换,这会导致泛型代码膨胀(code bloat),从一个函数膨胀为零个、一个或多个具体数据类型的函数。有时候这种膨胀会导致编译后的程序文件变大很多。不过,多数情况下,代码膨胀的问题都不是大问题。

另一方面,由于编译期间已经将泛型替换成了具体的数据类型,因此,在程序运行期间,直接调用对应类型的函数即可,不需要再消耗任何额外的资源去计算泛型所代表的具体类型。因此,Rust的泛型是零运行时开销的。

总结

Rust中可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。可以使用泛型定义函数结构体枚举方法,使得代码适应性更强,为调用者提供更多的功能,同时也避免了代码的重复。泛型的类型参数是使用尖括号大驼峰命名的名称:<A, B, ...> 来指定的。 Rust通过在编译时进行泛型代码的 单态化monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程,这会导致泛型代码膨胀

参考

转载自:https://juejin.cn/post/7207340436648673338
评论
请登录