Skip to content

泛型

在我们之前定义函数时,每一个参数都必须要有类型声明,包括返回值。但是如果有的场景需要函数的参数可能是多种类型呢

比如写一个add函数实现整型的求和,但是整型却有很多种,那这时候就需要使用泛型了

泛型可以理解为类型参数,可以让我们在使用函数时把类型像函数参数一样传递

泛型不止可以在函数中使用,在结构体、枚举、方法、Trait中都有广泛的使用

1.泛型函数

1.1 定义

泛型函数的语法如下:

  • 在函数名和参数列表的中间使用<>来放置类型参数
  • 多个类型参数使用逗号,隔开
  • 泛型参数一般使用大写字母
  • 泛型参数可以作为函数参数的类型返回值的类型函数内部也可以用作其他数据的类型
rust
fn func<T, U, ...>(arg_1: T, arg_2: U ...) -> T {
	// ...
}

T、U并不是特定的字母,他们就像()中的参数一样,是程序员自己起的名字,只不过在大部分支持泛型的语言中,程序员喜欢使用T、U、M、N这些大写字母作为泛型参数

一个简单的泛型例子

rust
fn return_copy<T>(arg: T) -> T {
    let arg_copy: T = arg;
    arg_copy
}

以上函数用了一个泛型参数,指定了函数参数,返回值,以及一个副本的类型

1.2 使用

在大多数情况下,直接像往常一样使用函数就行,不用像其他语言那样显式传递泛型参数

rust
fn return_copy<T>(arg: T) -> T {
    let arg_copy: T = arg;
    arg_copy
}

fn main() {
    let num = return_copy(111);

    let str = return_copy("123");

    let c = return_copy('A');

    let bool = return_copy(true);

    println!("{},{},{},{}", num, str, c, bool); // 111,123,A,true
}

1.3 泛型的类型推导

rust编译期会根据传入的参数类型自动推导泛型参数的类型

rust
fn main() {
    let num = return_copy(111);

    let str = return_copy("123");

    let c = return_copy('A');

    let bool = return_copy(true);

    println!("{},{},{},{}", num, str, c, bool); // 111,123,A,true
}

通过参数推导

根据传入参数的类型,rust可以自动推导泛型参数的类型

rust
fn echo<T>(x: T) -> T {
    x
}

let a = echo(42);        // 参数是 i32,编译器自动推断 T = i32
let b = echo("hello!");  // 参数是 &str,编译器自动推断 T = &str

通过返回值推导

若函数没有参数,但是有返回值是泛型参数类型的

那么在使用时,就需要我们显式声明返回值接收变量的类型

rust
fn make_number<T>() -> T {
    panic!("something happen!")
}

let a: i32 = make_number(); // 根据左侧类型 i32 推导出 T = i32
let b: u8 = make_number();  // 根据左侧类型 u8 推导出 T = u8

如果不显式声明变量的类型,那么就无法推测是什么类型,就会报错

rust
let a = make_number();
let b = make_number();

这就会导致报错,因为没有任何方式可以推断出ab的类型,进一步导致T的类型无法正确推断。

1.4 turbofish语法

turbofish是当我们调用泛型函数时,显式传入泛型参数的类型的语法,这和其他语言的语法有些不同

虽然rust会自动推导类型参数,但也有无法推导的时候,turbofish就是用来显示传入参数的语法

rust
fun_name::<type>();

相比于其他语言,<>前面多了两个:

::<>因为形状整体的形状像一个带有涡轮的鱼,因此这个语法被称为turbofish

turbofish主要的使用场景在于一个无法推导的泛型函数中,如下示例

size_of函数可以用来判断类型所占空间大小

我们声明一个函数用来打印泛型参数的空间大小,这样的话,函数没有参数,也没有返回值,是无法自动推导的,所以只能显式声明

rust
use std::mem::size_of;

fn print_size<T>() {
    println!("sizes: T = {}", size_of::<T>());
}

fn main() {
    print_size::<i32>();  // sizes: T = 4
}

2.结构体泛型

除了函数,Rust结构体同样可以使用泛型,让数据结构更具通用性。

当在结构体中使用泛型,可以在结构体内部定义类型可以变化的字段。语法如下:

rust
struct name<T, U, ...> {
	item_1: T,
	item_2: U,
	...
}

在结构体名称后面通过<>指定泛型列表,在{}内的字段就可以正常使用这个泛型了。

例如一个Point类,指定它的坐标可以是任意类型:

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

xy字段后续就可以是任意的类型T了,比如整形或者浮点型。

rust
let p = Point {    // p没有显式的传入泛型,但是x和y默认类型为i32,所以rust会自动推导p是一个 Point<i32>
    x: 0,
    y: 0,
};

let f = Point::<f64> {   // 也可以使用turbofish语法显式声明泛型参数的类型
    x: 3.14,
    y: 3.14,
};

TIP

要注意的是,一个结构体中如果泛型参数不同,它们就是不同的类型,比如Point<i32>Point<f64>是两个不同类型。

3.枚举泛型

除了结构体,联合体和枚举体也可以使用泛型。

比如最常见的Option<T>Result<T, E>都是泛型:

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

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

它们的用法和结构体是一致的,也就是在需要具体类型的位置,可以用泛型来替代。

4.类型别名 type

有时候泛型写出来太长了,可以用 type 为某种常用的泛型组合起个别名。

rust
type Pi32 = Point<i32>;

let p: Pi32 = Point { x: 3, y: 4 };

对于某些不好写的类型,使用type可以缩短类型名,方便编码。

5.泛型方法

泛型可以用在函数上,也可以用在结构体和枚举的方法上

我们可以用impl给结构体或枚举添加方法,方法也是一种函数,所以方法也是可以用泛型的

5.1 泛型方法impl的定义

如果一个复合类型带有泛型,那么impl实现方法的语法也会略有变化。语法如下:

rust
struct Type<T, U...>

impl<T, U ...> Type<T, U ...> {
	// 方法
}

重点在于,在impl后面,要添加一个<>列举出所有的泛型,这里是泛型声明。

原本的语法是impl Type {},其中Type表示一个类型**。如果一个复合类型带有泛型参数,那么Type<>这个整体才算做一个类型。**

因此整个impl就变成了impl<T, U ...> Type<T, U ...>,其中<T, U ...>这个整体出现了两次

第一次是泛型声明,表示后续可以使用这些泛型了,而第二次是为了和前面的Type一起来表达一个类型。

5.1 方法使用复合类型的泛型

如果一个复合类型具有泛型参数,在impl实现方法时,可以直接使用这个泛型参数。

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

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

以上代码中,在定义x方法的时候,方法名后面不再需要通过<T>来声明泛型,因为前面impl已经声明过了,此处可以直接使用T这个类型,这就是在方法中直接使用复合类型的泛型。

5.3 在方法中定义额外的泛型

除了使用结构体自己带着的泛型参数,我们还可以在方法本身声明新的泛型。两者也可以在一个方法里一块使用

rust
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,
        }
    }
}

Point<T, U> 结构体自带两个泛型参数:T, U。在 impl 内部的方法 mixup 又额外声明了泛型参数 <V, W>

5.4 为特定类型实现方法

使用泛型确实可以快速给一大批类型实现相同的方法,但是如果我们需要为某些特定的类型进行特殊操作怎么办?

此时有两种方案:

  1. 泛型参数实例化
  2. 使用 trait bound

这里只讨论第一种方案,第二种会在后续学习trait时讨论。

完全实例化

所谓完全实例化,就是把所有泛型参数都改为具体参数。

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

impl<T> Point<T> {		// 为所有泛型T实现的,也就是任何类型都可以调用这个new方法。
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }
}

impl Point<i32> {		// 专门为i32实现的,此时Point<i32>表示一个具体类型
    fn print(&self) {
        println!("x: {}, y: {}", self.x, self.y);
    }
}

当把T = i32后,就不再有泛型了,因此impl后面不再需要进行泛型声明,你可以写成impl<> Point<i32>或者impl Point<i32>

rust
let p = Point::new(1, 2);
p.print();

let f = Point::new(3.14, 3.14);
f.print(); // error

以上代码中,p.print()成功执行,因为它的类型就是Point<i32>

但是f可以使用Point::new创建,因为使用的Point<T> ,在这里被推导为Point<f64>

f.point()会报错,因为这个Point<f64>没有实现print方法。

也就是说,可以通过把参数确定下来,再为其单独实现impl,可以为某些特定的类型实现特定方法。

部分实例化

当带有多个泛型参数时,可以只实例化部分参数。

rust
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,
        }
    }
}

impl<T> Point<T, i32> {
    fn describe_y(&self) {
        println!("This is a Point with generic x and an i32 y = {}", self.y);
    }
}

impl<U> Point<i32, U> {
    fn describe_x(&self) {
        println!("This is a Point with generic y and an i32 x = {}", self.x);
    }
}

这个Point带有两个泛型参数,首先为所有泛型实现了mixup方法,和之前一样。

随后为Point<T, i32>Point<i32, U>单独实现了方法。

impl<T> Point<T, i32>是当U = i32时实现的专有方法,由于T还是一个泛型,需要在impl后面进行泛型声明。同理impl<U> Point<i32, U>T = i32时的专有方法,对U需要进行泛型声明。

而对于Point<i32, i32>,它同时匹配以上两种情况,那么它可以同时调用describe_ydescribe_x两个方法。

这种不把所有泛型参数都实例化的方式,叫做部分实例化。

部分实例化时,把确定的泛型直接写出来,而不确定的泛型写到impl<>内部。

并且支持跳跃式的实例化:

rust
struct Fourth<T, U, V, W> {
    a: T,
    b: U,
    c: V,
    w: W,
}

impl<T, U, V, W> Fourth<T, U, V, W> { }

impl<V, W> Fourth<i32, i32, V, W> { }

impl<T, V> Fourth<T, i32, V, i32> { }

其中Fourth带有四个泛型参数。

impl<T, U, V, W> Fourth<T, U, V, W>是所有Fourth通用的方法。

impl<V, W> Fourth<i32, i32, V, W>是前两个泛型参数为i32时的方法。

impl<T, V> Fourth<T, i32, V, i32>是第二个和第四个泛型参数为i32时的方法。

总的来说,不论是部分实例化还是完全实例化,只需要记住:确定的类型直接写到类型后面替代泛型,还未定的泛型要在impl<>中进行声明。

4.冗余的 <>

泛型语法其实可以出现在很多不是泛型的位置,这个小节一方面是总结一下前面出现的语法,另一方面是讲解一下<>带来的坑。

整个泛型的语法都围绕着<>这个格式。

  • 泛型声明:<T, U, ...>
    • 声明函数时,函数名后面,如fn add<T>(x: T, y: T) ->T {}
    • 声明方法时,impl后面,如impl<T> Point<T> {}
    • 声明类型时,复合类型名称后面,如struct Point<T> {}
  • turbofish::<T, U ,,,>
    • 调用函数时,函数名与()之间,如add::<i32>(1, 2)
    • 创建复合类型时,类型名称与{}之间,如let p = Point::<i32> {}
  • 实例化后:<i32, ...>
    • 表达一个类型时,在复合类型名称后面,如Point<i32>整体是一个类型

一个一个来说:

  • 函数不是一个泛型函数,也可以用<>
rust
fn add<>(x: i32, y: i32) -> i32 {
	x + y
}
  • 一个复合类型不是泛型,也可以用<>
rust
struct Point<> {
    x: i32,
    y: i32,
}
  • 实现方法时,即使impl不再声明泛型,依然可以保留<>
rust
impl<> Point { }
  • 一个函数不是泛型函数,也可以用::<>
rust
add::<>(1, 2); // 刚才的 i32 版本 add
  • 一个复合类型不是泛型,也可以用::<>,类型末尾可以加<>
rust
let p: Point<> = Point::<> {
    x: 1,
    y: 1,
};

这一段其实没什么有趣的,就是想表达:在很多不是泛型的位置,你依然可以使用泛型相关的语法,但是<>内部往往是空的,在阅读某些别人的代码时,也许这个提醒就会对你有所帮助,尤其是某些C++Rust的程序员,可能就会保留这种空的<>写法,这与C++的模板特化有关。

比如在C++中:

cpp
template<typename T>
bool Less(T left, T right) {
	return left < right;
}

template<>
bool Less<int*>(int* left, int* right)
{
	return *left < *right;
}

此处的template<>就不能省略,这种习惯迁移到Rust,恰巧Rust又允许这么写,就可能会造成以上列举的种种情况。

5.单态化

泛型是一种零成本抽象,你在代码中使用泛型,不会带来任何性能的损失。

比如说一个泛型是T,在运行时Rust不会因为额外判断T的具体类型而导致效率变低,因为这些任务在编译期就已经完成了,这个过程叫做单态化

对于一个函数来说,它最终变成可执行程序时,每个参数和变量的类型都必须是确定的,而泛型却是一个不确定的类型。

当泛型出现,一个泛型函数会在编译期通过单态化变成多个函数,一个类型会通过单态化变为多个类型。单态化结束后,每一个函数和内部的类型,都是完全确定的。

比如说对于以下函数:

rust
fn swap<T, U>(a: T, b: U) -> (U, T) {
    (b, a)
}

它最终会变成多少个函数?答案是不确定。

单态化的过程,是按需进行的,泛型被实例化为多少种具体类型,就会有多少种函数版本。

假设在程序中进行了五次调用:

rust
swap::<i32, i32>(1, 2);
swap::<u8, i32>(1 as u8, 2);
swap::<i32, i32>(10, 50);
swap::<f64, f64>(3.14, 3.14);
swap::<f64, f64>(1.0, 2.0);

以上五次调用,实际上共有三种泛型组合,分别是<i32, i32><u8, i32><f64, f64>。因此最终swap会被单态化为三个版本:

rust
fn swap_i32_i32(a: i32, b: i32) -> (i32, i32) {
    (b, a)
}

fn swap_u8_i32(a: u8, b: i32) -> (i32, u8) {
    (b, a)
}

fn swap_f64_f64(a: f64, b: f64) -> (f64, f64) {
    (b, a)
}

最后调用的函数,也是三个不同的函数。

可以尝试通过以下代码输出三个函数的地址:

rust
println!("{:p}", swap::<i32, i32> as fn(i32, i32) -> (i32, i32));
println!("{:p}", swap::<u8, i32> as fn(u8, i32) -> (i32, u8));
println!("{:p}", swap::<f64, f64> as fn(f64, f64) -> (f64, f64));

输出结果:

rust
0x7ff70dcd12f0
0x7ff70dcd1330
0x7ff70dcd1300

三个函数的地址都不一样,说明它们已经变成了不同的函数。

最后你调用了多少个不同的版本,就会单态化出多少种不同的函数实例。在单态化过程中,把一个函数变成多个函数,实际上会导致代码量膨胀,最后产生的二进制文件变大,编译时间变长,但是这些都没有对运行时的效率造成任何影响。

6.const 泛型

Rust 1.51 之后,语言引入了一种新的泛型参数形式:const 泛型。语法如下:

rust
fn func<const N: type>() {
	// 函数体
}

struct Point<const N: type> {
}

此处的N就是一个const泛型,它的类型type可以是各类整型,字符和布尔类型。此时他不再是一个类型,而是常量参数

在函数题或者结构体中可以访问这个参数的值

rust
fn test<const N: usize>() {
    println!("{:#?}", N);
}

fn main() {
    test::<123>(); // 123
}

一个最直观的例子。我们定义一个固定大小的数组,然后做一些与长度相关的操作:

rust
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
    let mut sum = 0;
    for i in 0..N {
        sum += arr[i];
    }
    sum
}

fn main() {
    let arr10 = [1; 10];
    let arr20 = [1; 20];
    let arr30 = [1; 30];

    println!("sum10 = {}", sum_array::<10>(arr10));
    println!("sum20 = {}", sum_array::<20>(arr20));
    println!("sum30 = {}", sum_array::<30>(arr30));
}

这里的 sum_array 接收一个 const 泛型参数 N,表示数组长度。

调用时传入 <10><20><30>,编译器会根据不同的数值单态化出不同的函数版本

有人可能会疑问:“既然只是传一个数字,那直接把 N 作为普通函数参数不就好了?为什么要单态化呢?”

核心原因在于 函数栈帧模型

在编译期,函数的栈帧大小必须完整确定,这样程序运行到这个函数时,才能立即为它分配合适的栈区空间(这在我之前的博客讲过)。

假设我们写的函数包含一个数组,这个数组的大小取决于 N

rust
fn demo<const N: usize>() {
    let arr: [u8; N] = [0; N]; 
    // arr 在栈上开辟 N 个字节
    println!("array size = {}", core::mem::size_of_val(&arr));
}
  • 如果 N = 10,栈区会至少分配 10 个字节。
  • 如果 N = 20,栈区会至少分配 20 个字节。
  • 如果 N = 30,栈区会至少分配 30 个字节。

这意味着:虽然函数签名看起来一模一样,但运行所需的内存大小完全不同

从内存模型的角度讲,这三个函数并不是“同一个函数”,虽然它们代码逻辑相同,函数签名相同,但是从内存角度,它们各自所需栈空间不同,因此不能定义为同一个函数来处理。编译器只能借助于泛型的单态化功能,将他们单态化为不同的具体函数,从而实现各自内存空间的不同。

在这里插入图片描述

如果我们把数组长度作为普通参数传进来:

rust
fn demo_runtime(n: usize) {
    let arr = vec![0u8; n]; 
    // 这里数组放在堆上(Vec)
    println!("array len = {}", arr.len());
}

这段代码也能跑,但是数组不在栈上,而是在堆上动态分配。 栈区依旧是固定大小,只存放 Vec 的结构体头部(指针+长度+容量)。内存布局和栈帧大小不随 n 改变,而是运行时决定用多少空间。 这会导致一定的运行时效率降低。

所以 const 泛型的精髓不在于可以在尖括号里写个值,而是让你能够配合其它模块在编译期确定内存布局,编译器会为不同的数值生成不同版本的函数(单态化),保证运行时不需要额外分支开销,提高运行时效率。因为编译器已经知道每个函数需要多少栈空间,所以可以做到零成本、与手写完全展开的版本一致的性能。

换句话说:const 泛型是把“数值”提升到了和“类型”一样的维度,纳入了泛型系统。这样 Rust 能统一针对类型数值两种参数来进行泛型化逻辑。

MIT Licensed