当前位置: 首页 > news >正文

Rust:泛型

Rust:泛型

    • 泛型函数
      • 语法
      • 自动推导
      • turbofish
    • 复合类型泛型
      • 语法
      • 类型别名 type
    • 泛型方法
      • 方法使用复合类型的泛型
      • 在方法中定义额外的泛型
      • 为特定类型实现方法
        • 完全实例化
        • 部分实例化
    • 冗余的 <>
    • 单态化
    • const 泛型


Rust 是一门强类型系统的语言,为了避免复制粘贴“重复劳动”,它引入了泛型generics)。泛型是一种抽象的类型参数,让你在写函数、结构体、枚举时,不必固定死某一种具体的类型,而是保留一个占位符,在用的时候再指定。 本博客将初识Rust的泛型体系。


泛型函数

语法

看一个例子,普通的交换函数通常长这样:

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

这函数只能交换两个 i32。如果我们还想交换 f64String 呢?难道要写三个几乎一样的函数?

如果不使用泛型,那确实需要写三个几乎一样的函数,但是有了泛型后,就不需要了。

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();...
}
  1. 在函数名后,使用<>声明所有的泛型参数,使用逗号隔开,每个泛型参数都可以表示一个未知类型
  2. 在参数列表中,可以使用声明过的泛型参数
  3. 在返回值中,可以使用声明过的泛型参数
  4. 在函数内声明变量的时候,也可以使用声明过的泛型参数

其中<T, U ...> 这个整体叫做泛型声明,表示后续的块中可以使用这些已经声明过的泛型,泛型可以替代大部分具体类型的位置

例如实现一个元组交换顺序的函数:

fn swap_tuple<T, U> (tp: (T, U)) -> (U, T) {let t: T = tp.0;let u: U = tp.1;(u, t)
}

以上代码中,TU是两个泛型,传入一个(T, U)的元组,返回一个(U, T)的元组。

调用这个函数:

let tp_1: (i32, String) = (15, String::from("hello"));
let tp_2: (String, i32) = swap_tuple(tp_1);

在这个调用过程中,T变成了i32U变成了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,对应位置的xT,因此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上面。

此处调用了两次这个函数,分别用ab接收返回值,并且ab都显式标注了类型,所以调用时可以通过返回值推断出T分别为i32u8

但是如果你这样调用:

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

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


turbofish

在某些情况,Rust无法有效的推导出泛型的具体类型,此时就需要我们进行显式的指定。

例如一个泛型函数,只用于函数体内部,而在函数签名中不包含任何泛型:

fn do_something<T, U>() {println!("sizes: T = {}, U = {}", std::mem::size_of::<T>(), std::mem::size_of::<U>());
}

这个泛型函数中,定义了两个泛型TU,它们不包含于参数或返回值中,而是在函数内部输出了类型的大小。

如果你直接调用:

do_something();

它是会报错的,因为无法确定TU的具体类型。

此时就需要使用turbofish语法显式指定:

do_something::<i32, u8>();

此处的 ::<i32, u8> 就是一个turbofish语法,它按顺序指定了T = i32U = 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,
}

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

let p = Point {x: 0,y: 0,
};let f = Point::<f64> {x: 3.14,y: 3.14,
};

以上代码中,p是一个Point<i32>,它的T就是i32,对应的xy也就是i32了。此处的i32是自动推导出来的,因为给xy的初始值字面量就是i32

f的类型是一个Point<f64>,这是通过turbofish显式指定的,直接放在结构体名称后面,当然只通过xy的字面量也可以推断出来,我这里只是做演示。

要注意的是,一个结构体中如果泛型参数不同,它们就是不同的类型,比如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 = f64p2 的类型是 Point<&str, char> ,那么 V = &str, W = char。 返回值的类型根据函数签名是 Point<T, W>,即 Point<i32, char>


为特定类型实现方法

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

此时有两种方案:

  1. 泛型参数实例化
  2. 使用 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_ydescribe_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 泛型。

以前我们熟悉的都是“类型参数”,比如 TU,是对类型进行抽象。

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 能统一针对类型数值两种参数来进行泛型化逻辑。


http://www.dtcms.com/a/597180.html

相关文章:

  • [CSP-X2025山东小学组T4]勇者斗恶龙
  • 基于单片机的多模式智能洗衣机设计
  • 【java阶段练习】----- 学生管理系统
  • 高校网站如何建设论文外国网站怎么做
  • portfolio做网站台州做网站需要多少钱
  • 网站名称 规则装修设计培训机构
  • Dify工作流如何用“拖拉拽”重构我们的自动化测试体系?
  • 【Docker】基础
  • AI应用开发的架构哲学:框架、平台与定制的协同(ComfyUI+cnb+云存储)
  • MySQL快速入门——索引
  • 舆情处置的技术实现:Infoseek 如何用 AI 重构 “识别 - 研判 - 处置” 全链路
  • gRPC vs RPC 高频面试题
  • 淘宝联盟推广网站怎么做什么是搜索引擎
  • 扬州住房城乡建设局网站设计画册
  • 在线视频网站a做免费下载中山精品网站建设价位
  • LangFlow 节点(Node)
  • Linux设置系统同步时间
  • 花垣网站建设一台主机做两个网站
  • 生成模型技术宇宙:从VAE到世界模型,揭示AIGC核心引擎
  • 网站建设中魔板免费扑克网站代码
  • 股指期货的收益和风险大吗?
  • 第12章 测试编写
  • 性能测试之使用 adb 查看设备CPU占用与数据分析
  • 【AUTOSAR SOMEIP】SD状态机
  • 海尔网站建设情况wordpress 没有保存
  • CSS 对齐
  • 从流批一体到湖仓一体架构演进的思考
  • 如何查看网站是否降权九江市住房和城乡建设厅网站
  • 从基本用法到迭代器实现—list重难点突破
  • 智能建站软件宁波房产网二手房出售