【Rust 学习记录】10. 泛型、trait与生命周期
泛型
泛型是一种具体类型或者其他属性的抽象替代,通常用来减少代码的重复,接下来将从泛型的几个实际应用场景开始介绍泛型
应用场景
在函数定义中使用
现在假设我们要写一个寻找数组最大值的功能,我要怎么实现既能从字符数组里查找最大值,又能从整数数组里查找最大值?定义两个函数分别查找的话难免重复性有点高,这时候就需要使用泛型。
1 | fn largest<T>(list: &<T>) -> T{ |
以上代码定义了一个寻找数组最大值的泛型函数,首先
- 需要声明一个泛型名称
T
,放置在函数名和参数的圆括号之间,用尖括号括起来,largest(list: &<T>)
- 后续的类型声明,就都可以用
T
来代替了
但以上代码暂时无法提示,rust-analyzer会报错binary operation '>' cannoy be applied to type 'T'
,也就是说>
运算符不能直接用于泛型参数,这个问题会在后续解决,现在重点先放在泛型的应用场景。
在结构体定义中使用
1 |
|
结构体中,泛型名称声明在结构体名字后面,Point
,这段代码是可以编译通过的。
但注意,当你使用两种类型的变量创建泛型结构体时,就无法编译通过了。
1 |
|
报错:expected integer, found floating-point number
这是因为,当你向泛型T
传入第一次传入值5的时候,编译器会自动为T
赋值为和5相同的类型,即整型。也就是说,泛型并不是代表能接受所有类型的变量,而是编译器自动帮你识别为第一次接收到的变量类型。
但如果我们就是可能传入两个类型呢?解决这个问题也简单,我们声明两个泛型,存储两个类型即可
1 |
|
声明多个泛型只需要在尖括号内用逗号隔开即可。
这段代码里,我们声明了两个泛型名称T, U
,这时候我们分别为类型为T, U
的变量x, y
传入5,10.0,对应的,此时T
代表整型,U
代表浮点型
在方法定义中使用
有了泛型的结构体,自然也就能有泛型的结构体方法了
1 | struct Point<T> { |
注意,这里我们使用两次,也就是说,我们需要在impl
后声明一次泛型名称,再在后续指定泛型。
这是因为,在泛型结构体里我们可以单独的为某个类型实现方法,而不是一定要所有类型都使用同一个方法,例如:
1 | struct Point<T> { |
在这段代码里,我们就相当于单独的为f32
类型设定了方法x,只有当T
的类型为f32
时可以使用这个方法。这种写法可以很经常的被用于处理不同类型的不同情况。
因此,我们需要先声明以下泛型名字,才能确保编译器知道你后面的尖括号到底是泛型还是具体类型。
当然,我们的方法也可以和函数一样,再次声明自己的泛型名称
1 | impl <T, U>Point<T, U> { |
这段代码里,我们在mixup
函数里新定义了V, W
两个泛型,用来接收可能不同类型的其他Point
实例,并把两个实例的类型进行混合后,作为新的Point
返回
在枚举类型定义中使用
在之前章节的学习里,我们就知道了Result
和Option
枚举。其中Option
就是典型的单泛型枚举,Result
就是典型的包含两个泛型的枚举
1 | enum Option<T>{ |
泛型的性能
可能有人会担心,泛型会不会和Python一样,使得程序有运行时的性能影响?
实际上是不会的,Rust的泛型和c++的auto差不多,会在编译器就静态固定好对应的类型,因此不会产生运行时的损耗,只会在编译期有性能损耗
trait:定义共享行为
trait
(特征?)是用来描述一个类型的功能,可以用来和多个类型共享。例如说求和,每个类型的求和都不尽相同的时候,你可以定义一个trait名为sum,然后再分别为不同的类型实现sum
trait
可能和其他语言的interface
功能类似,但也不完全相同
定义trait
现在假设我们有两个结构体类型,一个是文章(Article),一个是推特(Tweet),我们需要同时为这两个文字内容主体生成摘要,于是我们就可以定义一个Summary的trait,来规定一个适用于所有类型的生成摘要的接口。
接下来我们新建一个库文件lib.rs
,然后定义一个trait
1 | pub trait Summary { |
在这段代码里,首先我们定义了一个公有的trait Summary
,并规定了trait里有一个函数summarize
,在 trait 里,称作签名,它传入类型实例自己,返回String
,但这里我们省略了函数的具体实现,具体实现交由不同的类型按照自己的规则来进行实现。
当然一个trait里可以有多个签名,这里只定义了一个。
实现trait
接下来,我们就需要给文章和推特两个结构体类型实现一下用于提取摘要的trait
1 | pub struct Article{ |
这段代码,我们定义了两个公有的结构体Article
和Tweet
,并使用impl Summary for xxx
语句,声明为结构体实现Summary
这个trait,然后再在impl
块里,实现trait内的签名summarize
,这时候就可以根据实际情况来返回不同的摘要了。代码中我们是使用format!
格式化返回不同的内容。
实现后,我们就可以在具体的实例里使用这个trait了
1 | use test10::{Tweet, Summary}; |
注意看我们的use代码use test10::{Tweet, Summary}
,这里我们同时引进Tweet
类型和Summary
这个trait,才能让Tweet
实例使用trait对应的成员函数,否则会编译报错,不信你可以试试。(test10是我自己创建的根目录名字)
这一点和其他语言都不一样,有点让人迷惑,实现了trait之后难道不是相当于结构体的成员函数了吗?为什么成员还需要额外引进才能使用?
这是因为trait提供了相当的灵活性,以至于编译器并不好自动检查怎么使用,例如以下场景:我们实现了两个trait,Summary1 和 Summary2,并且这两个trait里都有一个签名叫做 summarize ,然后我们还在Tweet 结构体里同时实现了这两个 trait
是的,Rust允许这种场景的存在,那你说这时候调用 summarize时,应该调用的是Summary1 还是 Summary2?因此,必须显示引入,才能正常使用。
使用就如此,那实现自然也是,如果你想实现别人定义的 trait,那你就需要把别人的 trait 显示引入当前的作用域,才能实现别人的 trait。
默认实现
前面我们没有在trait内实现summarize
签名,交由每个类型自己实现,但实际上我们也可以为其定义一个默认实现。
1 | pub trait Summary { |
这里我们在trait的定义内实现了summarize
签名,默认返回一个 (Read more…) 的字符串
然后我们修改一下Article
的实现
1 | pub struct Article{ |
我们在实现Summary
的时候,直接使用空的花括号,没有实现具体的 trait 签名,然后我们再使用看一下效果。
1 | use test10::{Tweet, Article, Summary}; |
没有意外,正常的输出 (Read more…),并且 Tweet 的输出正常,不会受到影响。这个概念也很常见,也就是重载。
把trait作为参数
trait 甚至能作为函数的参数传入,见以下示例
1 | use test10::{Tweet, Article, Summary}; |
我们定义了一个函数summarize
来调用每个实现了 Summary trait 的类型的 summarize 函数。这里的impl Summary
就是指代的所有实现了 Summary trait 的类型。
trait约束
以上impl Summary
实际上只是一个语法糖,它的完整声明形式称作 triat 约束,写作如下
1 | fn summarize<T: Summary>(item: T) -> String{ |
意思就是声明了一个泛型T
,并使用:Summary
对泛型T指代的类型进行了约束,使它只能代表实现了 Summary trait 的类型。
实际上在函数较复杂的时候,triat 约束要比之前的语法糖要好用
1 | fn summarize<T: Summary>(item1: T, item2:T, item3: T) -> String{ |
对比一下这两种写法,是不是在复杂的情况,反而 triat 约束更简洁了一些?
多个trait约束
如果我想让泛型 T 指代实现了多个 triat 的类型怎么办?使用 + 法可以解决这个问题
1 | fn summarize<T: Summary + Display>(item: T) -> String{ |
这里就表示,传入的 item 必须是同时实现了 Summary trait 和标准库的 Display trait 的类型。
简化trait约束
当有多个泛型参数,每个泛型参数有多个 trait 约束的时候,会写成这样
1 | fn summarize<T: Summary + Display, U: Summary + Display>(item1: T, item2: U) -> String{ |
这样就会导致函数定义很长又有很多重复内容,阅读费劲,难以理解,所以 rust 提供了一个 where 从句的方法,提高这种情况下的可读性
1 | fn summarize<T, U>(item1: T, item2: U) -> String |
我们可以在返回值的类型后面,加上一个 where 从句,把每个泛型的 trait 约束换一行之后再定义,就美观多了。
把trait作为函数返回值类型
既然能作为函数参数传入,自然也能作为函数返回值进行返回了
1 | fn summarize() -> impl Summary |
这一段代码则让 summarize 函数固定返回实现了 Summary 的 Tweet 类型,但这种用法似乎感觉没有什么用?没关系,书上说后续讲解闭包等概念的时候,会使用到这种语法。
需要注意的是,Rust 编译器同样会对 impl Trait 进行静态推理保存,碍于 impl Trait 工作方式的限制,所以你只能在返回一个类型的时候,使用 trait 作为返回值类型,如果你既想返回 Tweet 也想返回 Article 是不行的。
如以下的代码就会报错
1 | fn summarize(switch: bool) -> impl Summary |
练手
还记得我们之前在讲泛型的时候使用的查找最大值的例子吗,之前代码编译不通过,但现在的我们已经有办法修复它了。
先回顾以下这段代码
1 | fn largest<T>(list: &[T]) -> T{ |
报错的是 >
号不能用于泛型T
,而 >
实际上是一个叫做 PartialOrd
的 trait,所以这段代码报错的核心是,不是每个类型都实现了 PartialOrd
,所以我们可以给它加个 trait 约束。
1 | fn largest<T: PartialOrd>(list: &[T]) -> T{ |
因为 PartialOrd
是预导入模块,所以我们可以直接使用,而不需要 use。我们修改完后,这段代码出现了新的报错:cannot move out of here | move occurs because list[_] has type T, which does not implement the Copy trait
;意思就是,不是每个类型都实现了 Copy trait,所以我们没有办法把泛型 T 列表内的元素赋值出来,解决也很简单,那就是再加个 Copy 约束即可。
以下这段代码,就能正确的编译并找到不同类型数组的最大值了:
1 | fn largest<T: PartialOrd+Copy>(list: &[T]) -> T{ |
PS:
这里我有点迷惑,这难道不是对应赋值吗?什么类型不能赋值出来?我查了一下资料,大致得出的结论如下,不知道对不对:
翻译应该沾点锅,看了下原文的意思应该是:不是所有类型都有 Copy trait,这里的 Copy trait 指的是深拷贝,在浅拷贝的变量里,赋值操作应该是 move,而 move 则对应了所有权的转移,对于一个列表内的变量,我们把它所有权转移出来之后,但数组自己是不知道自己的元素所有权已经没有了,这不就出问题了?
所以最重要的原因还是在于这一句代码:
let mut largest = list[0];
这里把list[0]
的元素所有权移出来了,自然有问题。所以我把代码改成下面这个样子,多加了一些引用的使用,不转移所有权,也就不需要使用Copy约束了。
1
2
3
4
5
6
7
8
9 fn largest<T: PartialOrd>(list: &[T]) -> &T{
let mut largest = &list[0];
for item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
通过约束为指定类型实现方法
也就是当我们定义了一个泛型结构体时,可以让这个结构体内的一些方法只能让指定的类型调用。
例如:
1 | use std::fmt::Display; |
这里我们就规定了 Pair
结构体里只有存放的类型是同时实现了可以比较可以打印两个 trait 的类型,才能调用cmp_display
这个方法。(注意 Display
trait 不是预导入的,虽然是标准库,也要自己 use)
当然,基于此,自然也可以为结构体的指定类型实现 trait。
1 | impl<T:Display> ToString for Pair<T> { |
生命周期
普通的泛型可以用来消除重复代码,也可以向编译器指明程序员希望这些类型拥有什么样的行为,而生命周期就是一种特殊的泛型,用来确保引用在我们使用的过程中一直有效
前言
在介绍生命周期前,我们需要介绍编译器是怎么检查因为超出作用域而导致的悬垂引用问题的。我们来看看以下这个例子
这个例子中,我们定义了一个变量r
,在下一个花括号中,我们定义了一个变量x
,并把x
的所有权借给r
,但x的作用域只在这个花括号为止,超出了这个花括号之后所有权就被回收了,r
也就成了悬垂引用。
右边的'a,'b
就分别代表了r和x的生命周期,我们可以明显的看到,x的生命周期’b明显要比r的生命周期’a短,所以编译器就可以通过检查生命周期的长短来查找你可能的悬垂引用问题,进而提出报错。
那我们为什么要指定生命周期?让我们来写一段代码
1 | fn longest(x: &str, y: &str) -> &str { |
这个函数用于比较得到长度更长的字符串,因为不想只是比较以下就夺取所有权,所以使用引用的方式传入,也使用引用的方式返回。
不出意外,会有错误提示missing lifetime specifier
,我们回顾以下编译器检查悬垂引用的方式,需要比较一下引用的生命周期长短,而在以上情况中,我们有一个分支判断,既可能返回x,也可能返回y,编译器不知道会返回x还是返回y,也不知道该比较哪个和哪个引用之间的长短,也就无法进行检查,进而报错,提示我们需要指定生命周期,明确一下引用之间的关系,方便编译器进行比较。
标注生命周期
基本语法
标注生命周期的语法很简单,和我们之前举例的命名一样,生命周期的命名以'
开头,如'a
1 | &i32 // 这是一个普通引用 |
函数中的生命周期标注
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
可见,我们用类似定义泛型的方式,定义了一个生命周期,名为'a
,并且给后面的引用都指定了生命周期为'a
,也就是告诉编译器,这个函数里传入的引用必然都是相同的生命周期,放心比较!于是编译器就会选择一个引用,推导出实际的生命周期'a
,然后和函数外的实际拥有所有权的变量进行比较,然后发现外边的变量生命周期都比'a
长,最后得出结果,这段代码可以编译通过。
那x和y是两个不同的变量啊,这里编译器最终得到的生命周期’a究竟是什么地方的生命周期?
答案也很简单,就是x和y重叠部分的生命周期
1 | fn main(){ |
如以上代码,'a
的长度等同于变量b
的生命周期(a,b重叠部分,也就是取最短的一个就行),我们定义了返回的引用生命周期也是'a
,因此返回的result
生命周期也应该是在b
的生命周期范围内,这段代码里和b
一起被回收,所以没有问题,编译通过。
错误示例如下,result变量的生命周期要长于b的生命周期,则无法通过编译:
1 | fn main(){ |
深入理解生命周期
由上面我们可以知道,其实标注生命周期的作用就是为了方便编译器检查。
所以自然而然的,不需要参与检查的变量也就不是必须标注的了,如:
1 | fn longest<'a>(x: &'a str, y: &str) -> &'a str { |
这里我们规定直接返回x,所以编译器只会顺着第一个参数x进行检查,所以我们只标注了x的生命周期,不标注y也是可以编译通过的,因为y和返回值没有半毛钱关系。
其次,我们标注生命周期,只是向编译器声明了以下传入的引用的生命周期关系,并没有改变任意一方的生命周期
1 | fn longest<'a>(x: &str, y: &str) -> &'a str { |
例如这一段代码,我们给返回值声明了生命周期'a
,但返回值result
是在函数内定义的,他也就只能活在这个函数里,并不是说我们给他声明了一个生命周期,他就能活到外面去了。
结构体中的生命周期
一般情况下结构体都是存储自持有的变量,但实际上也可以存储引用,这时候就需要用到生命周期
1 | struct ImportantExcerpt<'a> { |
定义的语法也是类似即可。
生命周期省略
可以说,所有引用必然是需要有自己的生命周期的,但其实以前编写的很多函数都没有指明生命周期,也能传入引用,是为什么呢?
其实早期的Rust是所有引用都必须显示标注生命周期的,但随着慢慢的发展,Rust的团队发现有很多情况下,是能够使用编译器推导出返回值的生命周期的,重复的写生命周期有点烦,也就把这部分情况,写成了可省略的生命周期规则。
编译器检查生命周期的规则有以下三条:
- 每一个引用的参数,都有自己的生命周期
- 当只存在一个输入的生命周期参数时,这个生命周期会被赋予给所有输出的生命周期参数
- 当拥有多个输入的生命周期参数时,若其中一个是
&self
或&mut self
,self
的生命周期会被赋予给所有输出的生命周期参数 - 若以上三条规则使用完毕,编译器仍然无法推导出所有生命周期,则报错,让用户指定。
这些规则帮助我们省略了很多生命周期的编写。为了更好了理解这些规则,我们举一些例子看看。
例如这段代码:
1 | fn test(s: &str)->&str{ |
按照规则1,编译器先给所有输入参数赋予自己的生命周期
1 | fn test<'a>(s: &'a str)->&str{ |
由于只有一个输入参数s,满足规则2,编译器把生命周期赋予给所有输出的参数
1 | fn test<'a>(s: &'a str)->&'a str{ |
至此,编译器自己推导出了所有参数的生命周期,也就不用我们写了。
接着,我们再距离说明一下规则3
1 | struct ImportantExcerpt<'a> { |
impl <'a> ImportantExcerpt<'a>
这个声明语句中的生命周期声明不能省略(我也不知道为什么)
在方法announce_and_return_part
中,编译器会首先按照规则1,赋予声明周期
1 | fn announce_and_return_part(&'a self, announcement: &'b str) -> &str { |
因为有两个参数,规则不生效
最后因为参数里有self,所以按照规则3,赋予输出参数self的声明周期
1 | fn announce_and_return_part(&'a self, announcement: &'b str) -> &'a str { |
这时候,所有生命周期也就推导完毕了,不需要手动指定,也可编译通过。
如果不返回
self.part
,返回announcement
一样会报错,但此时报错的提示是announcement的生命周期不一定比self长,而不是缺少生命周期声明,可见编译器确实给输出赋予了self的声明周期,并进行检查
静态生命周期
一种特殊的生命周期,意味在整个程序的执行期中都可以存活
1 | let s:&'static str = "I have a static lifetime."; |
但使用需要谨慎,1:你需要确保他确实可以在整个程序的生命周期存活;2:你确定它真的需要活这么长时间。
总结
最后用一段代码,同时使用泛型、trait约束、生命周期
1 | use std::fmt::Display; |
简单解释一下代码
- 定义了一个声明周期
'a
,用来声明传入的两个字符串x, y的生命周期,以及返回字符串的生命周期 - 定义了一个泛型
T
- 约束了泛型
T
只能是实现了Display
这个trait的类型,方便后续直接使用println!
输出
- 标题: 【Rust 学习记录】10. 泛型、trait与生命周期
- 作者: TwoSix
- 创建于 : 2023-05-08 21:17:12
- 更新于 : 2024-07-04 23:52:28
- 链接: https://twosix.page/2023/05/08/【Rust-学习记录】10-泛型、trait与生命周期/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。