Rust 基础入门(三)(更新中)
本文为阅读《Rust圣经》的第三篇笔记,内容包括《Rust基础入门》8~10章节,阅读本文前建议先阅读本网站的文章《Rust基础入门(一)》《Rust基础入门(二)》,后续章节请移步到本网站文章《Rust基础入门(四)》等,如果想查看原书请搜索“Rust圣经”或点击此链接:[Rust语言圣经(Rust Course)](https://course.rs/about-book.html)
泛型和特征
泛型 Generics
泛型的格式
1 | // 单一泛型参数 |
泛型参数的名称你可以任意起,但是出于惯例,我们都用 T (T 是 type 的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。
注意:泛型的T可以是任何类型,但是上述的这些例子中,并不是所有类型都能比较(比较出largest或比较出 T、U 之间的大小关系),所以,我们需要对参数 T(和 U)加一个类型限制,可以使用std::cmp::PartialOrd 特征(Trait)对 T 进行限制,该特征的目的就是让类型实现可比较的功能。
修改如下(这里的修改还不完整,具体还要再继续学习 Trait 的原理):
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T { |
泛型在各个情况下的运用
结构体中使用泛型
举个例子:
1 | struct Point<T> { |
使用上述泛型的要求是:
- 提前声明,跟泛型函数定义类似,首先我们在使用泛型参数之前必需要进行声明
Point<T>,接着就可以在结构体的字段类型中使用 T 来替代具体的类型(即上述例子的前四行) - x 和 y 是相同的类型
如果 x 和 y 是不同的类型,那么就要用如下代码:
1 | struct Point<T,U> { |
枚举中使用泛型
Option 的例子:
1 | enum Option<T> { |
Option<T> 是一个拥有泛型 T 的枚举类型,它第一个成员是 Some(T),存放了一个类型为 T 的值。得益于泛型的引入,我们可以在任何一个需要返回值的函数中,去使用 Option<T> 枚举类型来做为返回值,用于返回一个任意类型的值 Some(T),或者没有值 None。
Result 的例子:
1 | enum Result<T, E> { |
这个枚举和 Option 一样,主要用于函数返回值,与 Option 用于值的存在与否不同,Result 关注的主要是值的正确性。
如果函数正常运行,则最后返回一个 Ok(T),T 是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E),E 是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File),因此 T 对应的是 std::fs::File 类型;而当打开文件时出现问题时,返回 Err(std::io::Error),E 对应的就是 std::io::Error 类型。
方法中使用泛型
举个例子:
1 | struct Point<T> { |
使用泛型参数前,依然需要提前声明:impl<T>,只有提前声明了,我们才能在 Point<T>中使用它,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 <T> 不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T> 而不再是 Point。
除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:
1 | struct Point<T, U> { |
上述代码运行结果为:
1 | p3.x = 5, p3.y = c |
这个例子中,T,U 是定义在结构体 Point 上的泛型参数,V,W 是单独定义在方法 mixup 上的泛型参数,它们并不冲突,说白了,你可以理解为,一个是结构体泛型,一个是函数泛型。
对于具体特定的泛型类型也可以给出其专属的方法:
1 | impl Point<f32> { |
这段代码的方法 distance_from_origin(&self) 只会在 T 恰好为 f32 类型时才可以使用,其它泛型类型则没有定义该方法。
const 泛型
在数组那节,有提到过很重要的一点:[i32; 2] 和 [i32; 3] 是不同的数组类型,比如下面的代码:
1 | fn display_array(arr: [i32; 3]) { |
这段代码会报错
从易到难的解决方案如下:
方案一:使用切片
1 | fn display_array(arr: &[i32]) { |
局限性:必须用引用
方案二:使用泛型加切片
1 | fn display_array<T: std::fmt::Debug>(arr: &[T]) { |
要注意的是需要对 T 加一个限制 std::fmt::Debug,该限制表明 T 可以用在 println!("{:?}", arr) 中,因为 {:?} 形式的格式化输出需要 arr 实现该特征。
局限性:还是不能摆脱引用的限制
方案三:使用 const 泛型
1 | fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { |
如上所示,我们定义了一个类型为 [T; N] 的数组,其中 T 是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N 这个泛型参数,它是一个基于值的泛型参数,因为它用来替代的是数组的长度。
N 就是 const 泛型,定义的语法是 const N: usize,表示 const 泛型 N ,它基于的值类型是 usize。
const fn,常量函数
通常情况下,函数是在运行时被调用和执行的。然而,在某些场景下,我们希望在编译期就计算出一些值,以提高运行时的性能或满足某些编译期的约束条件。例如,定义数组的长度、计算常量值等。
有了 const fn,我们可以在编译期执行这些函数,从而将计算结果直接嵌入到生成的代码中。这不仅提高了运行时的性能,还使代码更加简洁和安全。
要定义一个常量函数,只需要在函数声明前加上 const 关键字。例如:
1 | const fn add(a: usize, b: usize) -> usize { |
虽然 const fn 提供了很多便利,但是由于其在编译期执行,以确保函数能在编译期被安全地求值,因此有一些限制,例如,不可将随机数生成器写成 const fn。
无论在编译时还是运行时调用 const fn,它们的结果总是相同,即使多次调用也是如此。唯一的例外是,如果你在极端情况下进行复杂的浮点操作,你可能会得到(非常轻微的)不同结果。因此,不建议使 数组长度 (arr.len()) 和 Enum判别式 依赖于浮点计算。
以下是一个结合了 const fn 和 const 泛型的例子:
1 | struct Buffer<const N: usize> { |
程序运行结果为:
1 | Buffer size: 4096 bytes |
特征 Trait
特征的使用
特征是 Rust 的核心概念之一,它定义了类型之间共享的行为接口。你可以把它理解成其他语言中的接口(Interface)或抽象类,但功能更强大。
如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
举个例子: 文章 Post 和 Weibo 两种内容载体都可以进行内容总结,那么我们就可以定义一个特征:
1 | pub trait Summary { |
这里使用 trait 关键字来声明一个特征,Summary 是特征名。在大括号中定义了该特征的所有方法,在这个例子中是: fn summarize(&self) -> String。
特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此,我们只定义特征方法的签名,而不进行实现,此时方法签名结尾是 ;,而不是一个 {}。
接下来,每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现 Summary 特征的类型都拥有与这个签名的定义完全一致的 summarize 方法。
Rust 标准库也有一些特征,比如:可以使用 use std::fmt::Display; 将 Rust 标准库中的 Display 特征导入当前作用域,让自定义类型能够实现用户友好的格式化输出。Display 特征的作用是让类型能通过 {} 格式化输出为用户友好的文本形式。
特征的使用示例:
1 | pub trait Summary { |
运行结果为:
1 | 文章 Rust 语言简介, 作者是Sunface |
上面我们将 Summary 定义成了 pub 公开的。这样,如果他人想要使用我们的 Summary 特征,则可以引入到他们的包中,然后再进行实现。
孤儿规则
如果你想要为类型 A 实现特征 T,那么 A 或者 T 至少有一个是在当前作用域中定义的。
例如我们可以为上面的 Post 类型实现标准库中的 Display 特征,这是因为 Post 类型定义在当前的作用域中。同时,我们也可以在当前包中为 String 类型实现 Summary 特征,因为 Summary 定义在当前作用域中。
但是你无法在当前作用域中,为 String 类型实现 Display 特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域。
默认实现
就是给出如果不重写方法,该怎么做的一个特征定义方法
1 | pub trait Summary { |
运行结果为:
1 | (Read more...) |
使用特征作为函数参数
(这是特征的重要用法)
1 | pub fn notify(item: &impl Summary) { |
在这段代码后,你可以使用任何实现了 Summary 特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法,例如 summarize 方法。具体的说,可以传递 Post 或 Weibo 的实例来作为参数,而其它类如 String 或者 i32 的类型则不能用做该函数的参数,因为它们没有实现 Summary 特征。
特征约束
上面这个例子的完整写法如下:
1 | pub fn notify<T: Summary>(item: &T) { |
根据这种完整格式还可以写出以下功能:
1 | pub fn notify(item1: &impl Summary, item2: &impl Summary) {} |
当需要限定多个约束条件,还可以写出如下代码:
1 | pub fn notify(item: &(impl Summary + Display)) {} |
如果这么写显得复杂,可以进行 where 约束
1 | fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {} |
下面是一个不同条件下实现方法的例子:
1 | use std::fmt::Display; |
cmp_display 方法,并不是所有的 Pair<T> 结构体对象都可以拥有,只有 T 同时实现了 Display + PartialOrd 的 Pair<T> 才可以拥有此方法。
有条件地实现特征,以标准库为任何实现了 Display 特征的类型实现了 ToString 特征为例:
1 | impl<T: Display> ToString for T { |
函数返回中的 impl Trait
下列代码:
1 | fn returns_summarizable() -> impl Summary { |
这里的 impl Trait 可以返回一个实现了 Summary 特征的返回值,但不知道是什么类型。这种返回适用于返回的真实类型非常复杂,你不知道该怎么声明时。这种返回值方式有一个很大的限制:只能有一个具体的类型。
泛型和特征的结合
以 largest 函数为例
1 | fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { |
如果并不希望限制 largest 函数只能用于实现了 Copy 特征的类型,我们可以在 T 的特征约束中指定 Clone 特征 而不是 Copy 特征。并克隆 list 中的每一个值使得 largest 函数拥有其所有权。使用 clone 函数意味着对于类似 String 这样拥有堆上数据的类型,会潜在地分配更多堆上空间,而堆分配在涉及大量数据时可能会相当缓慢。
另一种 largest 的实现方式是返回在 list 中 T 值的引用。如果我们将函数返回值从 T 改为 &T 并改变函数体使其能够返回一个引用,我们将不需要任何 Clone 或 Copy 的特征约束而且也不会有任何的堆分配。
通过 derive 派生特征
derive 是一种特征派生语法,被 derive 标记的对象会自动实现对应的默认特征代码,继承相应的功能。
比如 #[derive(Debug)] 可以为一段代码提供 Debug 派生的特征
生命周期
什么是生命周期
Rust 的生命周期是一种标记系统,用于跟踪引用的有效时间,防止悬垂引用,并在编译时确保内存安全。
悬垂指针和生命周期
生命周期的主要作用是避免悬垂引用,它会导致程序引用了本不该引用的数据:
1 |
|
这段代码运行会报错 error[E0597]: 'x' does not live long enough 。
借用检查
1 | { |
上面的代码可以看出生命周期 'b 比 'a 小很多。在编译期,Rust 会比较两个变量的生命周期,结果发现 r 明明拥有生命周期 'a,但是却引用了一个小得多的生命周期 'b,在这种情况下,编译器会认为我们的程序存在风险,因此拒绝运行。
如果想要编译通过,也很简单,只要 'b 比 'a 大就好。总之,x 变量只要比 r 活得久,那么 r 就能随意引用 x 且不会存在危险:
1 | { |
生命周期标注
生命周期引发的函数报错
1 | fn main() { |
这段代码会由于生命周期的问题导致报错,具体原因如下:
1 | fn main() { |
在这句话中:
1 | fn longest(x: &str, y: &str) -> &str |
编译器需要知道:
- 返回的引用应该和
x一样长?还是和y一样长? - 因为函数体有
if-else,运行时才知道实际返回哪个
内存图示:
1 | 时间轴: |
这个时候就出现了“在存在多个引用时,编译器有时会无法自动推导生命周期”的现象,需要手动标注。
生命周期标注语法
生命周期标注的语法格式:
1 | &i32 // 一个引用 |
函数签名中的生命周期标注
注意:标记的生命周期只是为了取悦编译器,让编译器不要难为我们。在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。
以下列的函数为例:
1 | fn example<'a>(x: &'a str, y: &'a str) -> &'a str |
关键点:
- 需先声明生命周期参数:
<'a> - 标注表示参数和返回值至少活得和
'a一样久 - 实际上,
'a取所有标注参数生命周期的最小值
注意:生命周期 'a 不代表生命周期等于 'a,而是大于等于 'a
当把具体的引用传给 example 时,那生命周期 'a 的大小就是 x 和 y 的作用域的重合部分,换句话说,'a 的大小将等于 x 和 y 中较小的那个。由于返回值的生命周期也被标记为 'a,因此返回值的生命周期也是 x 和 y 中作用域较小的那个。
一个具体例子:
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
这段代码会报错:'string2' does not live long enough
在上述代码中,result 必须要活到 println!处,因为 result 的生命周期是 'a,因此 'a 必须持续到 println!。
生命周期的核心思想是:通过标注参数和返回值之间的生命周期关系,让编译器能够静态检查引用的有效性。这不是在指定具体的存活时间,而是在建立一种约束关系,确保:
- 返回的引用不会比它引用的数据存活更久
- 所有引用在使用时都指向有效的数据
这样Rust就能在编译时防止悬垂引用,同时保持零运行时开销。
结构体中的生命周期
为什么结构体需要生命周期:
1 | // 拥有所有权 - 不需要生命周期 |
此时生命周期的含义:
1 | struct ImportantExcerpt<'a> { |
生命周期消除规则
三个生命周期基本概念:
- 输入生命周期:函数参数的生命周期
- 输出生命周期:返回值的生命周期
- 消除规则:编译器自动添加生命周期的规则
三条核心消除规则:
编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。
每个引用参数获得独立生命周期
1
2
3
4
5// 一个参数 → 一个生命周期
fn foo(x: &i32) // 编译器理解为:fn foo<'a>(x: &'a i32)
// 两个参数 → 两个生命周期
fn bar(x: &i32, y: &i32) // 编译器理解为:fn bar<'a, 'b>(x: &'a i32, y: &'b i32)只有一个输入生命周期时,赋给所有输出
1
2
3
4
5
6// 手写代码
fn first_word(s: &str) -> &str
// 编译器应用规则后的理解
fn first_word<'a>(s: &'a str) -> &'a str
// 参数和返回值的生命周期相同有&self或&mut self时,self的生命周期赋给输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
// 手写代码
fn get_part(&self) -> &str {
self.part
}
// 编译器理解成
fn get_part(&'a self) -> &'a str {
// 返回值的生命周期与self相同
self.part
}
}
方法中的生命周期
将泛型的语法和具有生命周期的结构体的方法对比:
1 | // 泛型 |
注意:
impl 中必须使用结构体的完整名称,包括 <’a>,因为生命周期标注也是结构体类型的一部分!
方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则
1
2
3
4
5
6
7// 例:
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}
静态生命周期 ‘static
'static 生命周期:引用与程序同寿
字符串字面量天然是 ‘static(硬编码进二进制文件
1 | let s: &'static str = "我没啥优点,就是活得久,嘿嘿"; |






