泛型
在我们之前定义函数时,每一个参数都必须要有类型声明,包括返回值。但是如果有的场景需要函数的参数可能是多种类型呢
比如写一个add函数实现整型的求和,但是整型却有很多种,那这时候就需要使用泛型了
泛型可以理解为类型参数,可以让我们在使用函数时把类型像函数参数一样传递。
泛型不止可以在函数中使用,在结构体、枚举、方法、Trait中都有广泛的使用
1.泛型函数
1.1 定义
泛型函数的语法如下:
- 在函数名和参数列表的中间使用
<>来放置类型参数 - 多个类型参数使用逗号
,隔开 - 泛型参数一般使用大写字母
- 泛型参数可以作为函数参数的类型,返回值的类型,函数内部也可以用作其他数据的类型
fn func<T, U, ...>(arg_1: T, arg_2: U ...) -> T {
// ...
}T、U并不是特定的字母,他们就像()中的参数一样,是程序员自己起的名字,只不过在大部分支持泛型的语言中,程序员喜欢使用T、U、M、N这些大写字母作为泛型参数
一个简单的泛型例子
fn return_copy<T>(arg: T) -> T {
let arg_copy: T = arg;
arg_copy
}以上函数用了一个泛型参数,指定了函数参数,返回值,以及一个副本的类型
1.2 使用
在大多数情况下,直接像往常一样使用函数就行,不用像其他语言那样显式传递泛型参数
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编译期会根据传入的参数类型自动推导泛型参数的类型
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可以自动推导泛型参数的类型
fn echo<T>(x: T) -> T {
x
}
let a = echo(42); // 参数是 i32,编译器自动推断 T = i32
let b = echo("hello!"); // 参数是 &str,编译器自动推断 T = &str通过返回值推导
若函数没有参数,但是有返回值是泛型参数类型的
那么在使用时,就需要我们显式声明返回值接收变量的类型
fn make_number<T>() -> T {
panic!("something happen!")
}
let a: i32 = make_number(); // 根据左侧类型 i32 推导出 T = i32
let b: u8 = make_number(); // 根据左侧类型 u8 推导出 T = u8如果不显式声明变量的类型,那么就无法推测是什么类型,就会报错
let a = make_number();
let b = make_number();这就会导致报错,因为没有任何方式可以推断出a和b的类型,进一步导致T的类型无法正确推断。
1.4 turbofish语法
turbofish是当我们调用泛型函数时,显式传入泛型参数的类型的语法,这和其他语言的语法有些不同
虽然rust会自动推导类型参数,但也有无法推导的时候,turbofish就是用来显示传入参数的语法
fun_name::<type>();相比于其他语言,<>前面多了两个:
::<>因为形状整体的形状像一个带有涡轮的鱼,因此这个语法被称为turbofish。
turbofish主要的使用场景在于一个无法推导的泛型函数中,如下示例
size_of函数可以用来判断类型所占空间大小
我们声明一个函数用来打印泛型参数的空间大小,这样的话,函数没有参数,也没有返回值,是无法自动推导的,所以只能显式声明
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 的结构体同样可以使用泛型,让数据结构更具通用性。
当在结构体中使用泛型,可以在结构体内部定义类型可以变化的字段。语法如下:
struct name<T, U, ...> {
item_1: T,
item_2: U,
...
}在结构体名称后面通过<>指定泛型列表,在{}内的字段就可以正常使用这个泛型了。
例如一个Point类,指定它的坐标可以是任意类型:
struct Point<T> {
x: T,
y: T,
}x和y字段后续就可以是任意的类型T了,比如整形或者浮点型。
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>都是泛型:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}它们的用法和结构体是一致的,也就是在需要具体类型的位置,可以用泛型来替代。
4.类型别名 type
有时候泛型写出来太长了,可以用 type 为某种常用的泛型组合起个别名。
type Pi32 = Point<i32>;
let p: Pi32 = Point { x: 3, y: 4 };对于某些不好写的类型,使用type可以缩短类型名,方便编码。
5.泛型方法
泛型可以用在函数上,也可以用在结构体和枚举的方法上
我们可以用impl给结构体或枚举添加方法,方法也是一种函数,所以方法也是可以用泛型的
5.1 泛型方法impl的定义
如果一个复合类型带有泛型,那么impl实现方法的语法也会略有变化。语法如下:
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实现方法时,可以直接使用这个泛型参数。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}以上代码中,在定义x方法的时候,方法名后面不再需要通过<T>来声明泛型,因为前面impl已经声明过了,此处可以直接使用T这个类型,这就是在方法中直接使用复合类型的泛型。
5.3 在方法中定义额外的泛型
除了使用结构体自己带着的泛型参数,我们还可以在方法本身声明新的泛型。两者也可以在一个方法里一块使用
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 为特定类型实现方法
使用泛型确实可以快速给一大批类型实现相同的方法,但是如果我们需要为某些特定的类型进行特殊操作怎么办?
此时有两种方案:
- 泛型参数实例化
- 使用
trait bound
这里只讨论第一种方案,第二种会在后续学习trait时讨论。
完全实例化
所谓完全实例化,就是把所有泛型参数都改为具体参数。
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>。
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,可以为某些特定的类型实现特定方法。
部分实例化
当带有多个泛型参数时,可以只实例化部分参数。
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_y和 describe_x两个方法。
这种不把所有泛型参数都实例化的方式,叫做部分实例化。
部分实例化时,把确定的泛型直接写出来,而不确定的泛型写到impl<>内部。
并且支持跳跃式的实例化:
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>整体是一个类型
- 表达一个类型时,在复合类型名称后面,如
一个一个来说:
- 函数不是一个泛型函数,也可以用
<>:
fn add<>(x: i32, y: i32) -> i32 {
x + y
}- 一个复合类型不是泛型,也可以用
<>:
struct Point<> {
x: i32,
y: i32,
}- 实现方法时,即使
impl不再声明泛型,依然可以保留<>:
impl<> Point { }- 一个函数不是泛型函数,也可以用
::<>:
add::<>(1, 2); // 刚才的 i32 版本 add- 一个复合类型不是泛型,也可以用
::<>,类型末尾可以加<>:
let p: Point<> = Point::<> {
x: 1,
y: 1,
};这一段其实没什么有趣的,就是想表达:在很多不是泛型的位置,你依然可以使用泛型相关的语法,但是<>内部往往是空的,在阅读某些别人的代码时,也许这个提醒就会对你有所帮助,尤其是某些C++转Rust的程序员,可能就会保留这种空的<>写法,这与C++的模板特化有关。
比如在C++中:
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的具体类型而导致效率变低,因为这些任务在编译期就已经完成了,这个过程叫做单态化。
对于一个函数来说,它最终变成可执行程序时,每个参数和变量的类型都必须是确定的,而泛型却是一个不确定的类型。
当泛型出现,一个泛型函数会在编译期通过单态化变成多个函数,一个类型会通过单态化变为多个类型。单态化结束后,每一个函数和内部的类型,都是完全确定的。
比如说对于以下函数:
fn swap<T, U>(a: T, b: U) -> (U, T) {
(b, a)
}它最终会变成多少个函数?答案是不确定。
单态化的过程,是按需进行的,泛型被实例化为多少种具体类型,就会有多少种函数版本。
假设在程序中进行了五次调用:
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会被单态化为三个版本:
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)
}最后调用的函数,也是三个不同的函数。
可以尝试通过以下代码输出三个函数的地址:
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));输出结果:
0x7ff70dcd12f0
0x7ff70dcd1330
0x7ff70dcd1300三个函数的地址都不一样,说明它们已经变成了不同的函数。
最后你调用了多少个不同的版本,就会单态化出多少种不同的函数实例。在单态化过程中,把一个函数变成多个函数,实际上会导致代码量膨胀,最后产生的二进制文件变大,编译时间变长,但是这些都没有对运行时的效率造成任何影响。
6.const 泛型
在 Rust 1.51 之后,语言引入了一种新的泛型参数形式:const 泛型。语法如下:
fn func<const N: type>() {
// 函数体
}
struct Point<const N: type> {
}此处的N就是一个const泛型,它的类型type可以是各类整型,字符和布尔类型。此时他不再是一个类型,而是常量参数
在函数题或者结构体中可以访问这个参数的值
fn test<const N: usize>() {
println!("{:#?}", N);
}
fn main() {
test::<123>(); // 123
}一个最直观的例子。我们定义一个固定大小的数组,然后做一些与长度相关的操作:
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:
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 个字节。
这意味着:虽然函数签名看起来一模一样,但运行所需的内存大小完全不同。
从内存模型的角度讲,这三个函数并不是“同一个函数”,虽然它们代码逻辑相同,函数签名相同,但是从内存角度,它们各自所需栈空间不同,因此不能定义为同一个函数来处理。编译器只能借助于泛型的单态化功能,将他们单态化为不同的具体函数,从而实现各自内存空间的不同。

如果我们把数组长度作为普通参数传进来:
fn demo_runtime(n: usize) {
let arr = vec![0u8; n];
// 这里数组放在堆上(Vec)
println!("array len = {}", arr.len());
}这段代码也能跑,但是数组不在栈上,而是在堆上动态分配。 栈区依旧是固定大小,只存放 Vec 的结构体头部(指针+长度+容量)。内存布局和栈帧大小不随 n 改变,而是运行时决定用多少空间。 这会导致一定的运行时效率降低。
所以 const 泛型的精髓不在于可以在尖括号里写个值,而是让你能够配合其它模块在编译期确定内存布局,编译器会为不同的数值生成不同版本的函数(单态化),保证运行时不需要额外分支开销,提高运行时效率。因为编译器已经知道每个函数需要多少栈空间,所以可以做到零成本、与手写完全展开的版本一致的性能。
换句话说:const 泛型是把“数值”提升到了和“类型”一样的维度,纳入了泛型系统。这样 Rust 能统一针对类型和数值两种参数来进行泛型化逻辑。
