Rust:泛型
Rust:泛型
- 泛型函数
- 语法
- 自动推导
- turbofish
- 复合类型泛型
- 语法
- 类型别名 type
- 泛型方法
- 方法使用复合类型的泛型
- 在方法中定义额外的泛型
- 为特定类型实现方法
- 完全实例化
- 部分实例化
- 冗余的 <>
- 单态化
- const 泛型
Rust 是一门强类型系统的语言,为了避免复制粘贴“重复劳动”,它引入了泛型(generics)。泛型是一种抽象的类型参数,让你在写函数、结构体、枚举时,不必固定死某一种具体的类型,而是保留一个占位符,在用的时候再指定。 本博客将初识Rust的泛型体系。
泛型函数
语法
看一个例子,普通的交换函数通常长这样:
fn swap_i32(a: i32, b: i32) -> (i32, i32) {(b, a)
}
这函数只能交换两个 i32。如果我们还想交换 f64 或 String 呢?难道要写三个几乎一样的函数?
如果不使用泛型,那确实需要写三个几乎一样的函数,但是有了泛型后,就不需要了。
fn swap<T>(a: T, b: T) -> (T, T) {(b, a)
}
这里的 T 就是一个类型参数,我们告诉编译器: swap 不依赖于某个具体类型,只要两个参数是一样的类型它就能工作。
在函数中使用泛型时,语法如下:
fn func<T, U, ...>(arg_1: T, arg_2: U ...) -> T {let var: T = T::new();...
}
- 在函数名后,使用
<>声明所有的泛型参数,使用逗号隔开,每个泛型参数都可以表示一个未知类型 - 在参数列表中,可以使用声明过的泛型参数
- 在返回值中,可以使用声明过的泛型参数
- 在函数内声明变量的时候,也可以使用声明过的泛型参数
其中<T, U ...> 这个整体叫做泛型声明,表示后续的块中可以使用这些已经声明过的泛型,泛型可以替代大部分具体类型的位置。
例如实现一个元组交换顺序的函数:
fn swap_tuple<T, U> (tp: (T, U)) -> (U, T) {let t: T = tp.0;let u: U = tp.1;(u, t)
}
以上代码中,T和U是两个泛型,传入一个(T, U)的元组,返回一个(U, T)的元组。
调用这个函数:
let tp_1: (i32, String) = (15, String::from("hello"));
let tp_2: (String, i32) = swap_tuple(tp_1);
在这个调用过程中,T变成了i32,U变成了String,这个从一个泛型变成一个具体类型的过程,叫做单态化。
自动推导
在绝大部分情况下,我们不需要显式地去指定泛型的具体类型,因为Rust会根据上下文进行推导,主要是依据参数和返回值这两个区域进行推导。
- 通过参数推导:
fn echo<T>(x: T) -> T {x
}let a = echo(42); // 参数是 i32,编译器自动推断 T = i32
let b = echo("hello!"); // 参数是 &str,编译器自动推断 T = &str
函数echo接受一个参数x,并把它原封不动的返回,随后调用了两次echo。第一次调用,参数为42,对应位置的x是T,因此T自动推断为i32。第二次调用同理,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
函数 make_number 只返回一个T,此处由于还没学到trait,因此我使用了一个panic做演示,panic返回的!可以转为任何类型,包括任意T。这里不关注函数的功能,重点放在它返回了一个泛型T上面。
此处调用了两次这个函数,分别用a和b接收返回值,并且a和b都显式标注了类型,所以调用时可以通过返回值推断出T分别为i32和u8。
但是如果你这样调用:
let a = make_number();
let b = make_number();
这就会导致报错,因为没有任何方式可以推断出a和b的类型,进一步导致T的类型无法正确推断。
turbofish
在某些情况,Rust无法有效的推导出泛型的具体类型,此时就需要我们进行显式的指定。
例如一个泛型函数,只用于函数体内部,而在函数签名中不包含任何泛型:
fn do_something<T, U>() {println!("sizes: T = {}, U = {}", std::mem::size_of::<T>(), std::mem::size_of::<U>());
}
这个泛型函数中,定义了两个泛型T和U,它们不包含于参数或返回值中,而是在函数内部输出了类型的大小。
如果你直接调用:
do_something();
它是会报错的,因为无法确定T和U的具体类型。
此时就需要使用turbofish语法显式指定:
do_something::<i32, u8>();
此处的 ::<i32, u8> 就是一个turbofish语法,它按顺序指定了T = i32和U = u8,这样编译期就知道了两个泛型的具体类型,编译成功。
之前的make_number也可以通过这种方式来指定具体类型:
let a = make_number::<i32>();
let b = make_number::<u8>();
由于::<>这个整体的形状像一个带有涡轮的鱼,因此这个语法被称为turbofish。
复合类型泛型
语法
除了函数,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 {x: 0,y: 0,
};let f = Point::<f64> {x: 3.14,y: 3.14,
};
以上代码中,p是一个Point<i32>,它的T就是i32,对应的x和y也就是i32了。此处的i32是自动推导出来的,因为给x和y的初始值字面量就是i32。
而f的类型是一个Point<f64>,这是通过turbofish显式指定的,直接放在结构体名称后面,当然只通过x和y的字面量也可以推断出来,我这里只是做演示。
要注意的是,一个结构体中如果泛型参数不同,它们就是不同的类型,比如Point<i32>和Point<f64>是两个不同类型。
除了结构体,联合体和枚举体也可以使用泛型。
比如最常见的Option<T>和Result<T, E>都是泛型:
enum Option<T> {Some(T),None,
}enum Result<T, E> {Ok(T),Err(E),
}
它们的用法和结构体是一致的,也就是在需要具体类型的位置,可以用泛型来替代。
类型别名 type
有时候泛型写出来太长了,可以用 type 为某种常用的泛型组合起个别名。
type Pi32 = Point<i32>;
let p: Pi32 = Point { x: 3, y: 4 };
对于某些不好写的类型,使用type可以缩短类型名,方便编码。
泛型方法
泛型不仅能用在函数和数据结构中,还能用在 方法 上。因为 Rust 的方法,本质上也是函数,只是第一个参数习惯上是 self。
方法使用复合类型的泛型
如果一个复合类型带有泛型,那么impl实现方法的语法也会略有变化。
语法:
impl<T, U ...> Type<T, U ...> {// 方法
}
重点在于,在impl后面,要添加一个<>列举出所有的泛型,这里是泛型声明。
原本的语法是impl Type {},其中Type表示一个类型。我刚才提到过,如果一个复合类型带有泛型参数,那么Type<>这个整体才算做一个类型。
因此整个impl就变成了impl<T, U ...> Type<T, U ...>,其中<T, U ...>这个整体出现了两次,第一次是泛型声明,表示后续可以使用这些泛型了,而第二次是为了和前面的Type一起来表达一个类型。
如果一个复合类型具有泛型参数,在impl实现方法时,可以直接使用这个泛型参数。
例如:
struct Point<T> {x: T,y: T,
}impl<T> Point<T> {fn x(&self) -> &T {&self.x}
}
以上代码中,在定义x方法的时候,方法名后面不再需要通过<T>来声明泛型,因为前面impl已经声明过了,此处可以直接使用T这个类型,这就是在方法中直接使用复合类型的泛型。
在方法中定义额外的泛型
除了使用结构体自己带着的泛型参数,我们还可以在方法本身声明新的泛型。
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>。
调用 p1.mixup(p2) 时,p1 的类型是 Point<i32, f64>,所以 T = i32, U = f64。 p2 的类型是 Point<&str, char> ,那么 V = &str, W = char。 返回值的类型根据函数签名是 Point<T, W>,即 Point<i32, char>。
为特定类型实现方法
使用泛型确实可以快速给一大批类型实现相同的方法,但是如果我们需要为某些特定的类型进行特殊操作怎么办?
此时有两种方案:
- 泛型参数实例化
- 使用
trait bound
这里只讨论第一种方案,第二种会在后续学习trait时讨论。
完全实例化
所谓完全实例化,就是把所有泛型参数都改为具体参数。
struct Point<T> {x: T,y: T,
}impl<T> Point<T> {fn new(x: T, y: T) -> Self {Point { x, y }}
}impl Point<i32> {fn print(&self) {println!("x: {}, y: {}", self.x, self.y);}
}
以上代码中,为Point实现了两次impl。
第一次是为所有泛型T实现的,也就是任何类型都可以调用这个new方法。
第二次是专门为i32实现的,此时Point<i32>表示一个具体类型,而当把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()会报错,因为这个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<>中进行声明。
冗余的 <>
泛型语法其实可以出现在很多不是泛型的位置,这个小节一方面是总结一下前面出现的语法,另一方面是讲解一下<>带来的坑。
整个泛型的语法都围绕着<>这个格式。
- 泛型声明:
<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又允许这么写,就可能会造成以上列举的种种情况。
单态化
泛型是一种零成本抽象,你在代码中使用泛型,不会带来任何性能的损失。
比如说一个泛型是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
三个函数的地址都不一样,说明它们已经变成了不同的函数。
最后你调用了多少个不同的版本,就会单态化出多少种不同的函数实例。在单态化过程中,把一个函数变成多个函数,实际上会导致代码量膨胀,最后产生的二进制文件变大,编译时间变长,但是这些都没有对运行时的效率造成任何影响。
const 泛型
在 Rust 1.51 之后,语言引入了一种新的泛型参数形式:const 泛型。
以前我们熟悉的都是“类型参数”,比如 T、U,是对类型进行抽象。
但 const 泛型允许你在尖括号 <> 里传入一个编译期可确定的常量值,也就是说,函数和结构体的定义不仅可以依赖类型,还可以依赖数值参数。
这和 C++ 中的 非类型模板参数 类似,只不过 Rust 在设计层面上更加严格和安全。
语法:
fn func<const N: type>() {// 函数体
}struct Point<const N: type> {
}
此处的N就是一个const泛型,它的类型type可以是各类整型,字符和布尔类型。
先来一个最直观的例子。我们定义一个固定大小的数组,然后做一些与长度相关的操作:
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 能统一针对类型和数值两种参数来进行泛型化逻辑。
