每一种编程语言都有高效处理重复概念的工具。在 Rust 中其工具之一就是泛型。泛型是具体类型或其他属性的抽象替代。
Trait 定义了某个特定类型拥有可能与其他类型共享的功能。可以通过 Trait 以一种抽象的方式定义共同行为。可以使用 trait bounds 指定泛型是任何拥有特定行为的类型。
生命周期是另一类我们已经使用过的泛型。不同于确保类型有期望的行为,生命周期确保引用如预期一直有效。
一、泛型
我们可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。
1.1 在函数定义中使用泛型
当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。采用这种技术,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。
fn add_impl<T>(num1: T, num2: T) -> T
where
T: std::ops::Add<Output = T> + Copy,
{
num1 + num2
}
fn main() {
let a = 3.0f32;
let b = 4.5f32;
let ret = add_impl(a, b);
println!("The result of {} + {} is {}", a, b, ret);
let a1 = 3;
let b1 = 4;
let ret1 = add_impl(a1, b1);
println!("The result of {} + {} is {}", a1, b1, ret1);
}
为了参数化这个新函数中的这些类型,我们需要为类型参数命名,道理和给函数的形参起名一样。任何标识符都可以作为类型参数的名字。这里选用 T
,因为传统上来说,Rust 的类型参数名字都比较短,通常仅为一个字母,同时,Rust 类型名的命名规范是首字母大写驼峰式命名法(UpperCamelCase)。T
作为 “type” 的缩写是大部分 Rust 程序员的首选。
如果要在函数体中使用参数,就必须在函数签名中声明它的名字,好让编译器知道这个名字指代的是什么。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。类型参数声明位于函数名称与参数列表中间的尖括号 <>
中。
运行结果
The result of 3 + 4.5 is 7.5
The result of 3 + 4 is 7
在 Rust 中,where
子句用于为泛型函数或泛型结构体指定额外的约束条件。上面的代码示例中,where
子句用于对泛型类型 T
施加两个约束:
-
T: std::ops::Add<Output = T>
:这个约束指定T
必须实现Add
trait,并且Add
trait 的Output
关联类型必须是T
类型本身。std::ops::Add
trait 定义了加法操作的行为,它要求实现该 trait 的类型必须提供一个add
方法,该方法接受一个相同类型的参数并返回一个结果。这里的<Output = T>
部分是一个 trait bound,它指定了Add
trait 的Output
关联类型必须是T
。这意味着当你对两个T
类型的值执行加法操作时,结果也将是T
类型。 -
T: Copy
:这个约束指定T
必须实现Copy
trait。Copy
trait 是 Rust 中的一个标记 trait(marker trait),它指示一个类型可以被简单地复制,而不需要移动所有权或进行深拷贝。基本数字类型(如i32
,f64
等)默认实现了Copy
trait,这意味着你可以在不转移所有权的情况下复制这些类型的值。
将这两个约束结合起来,where T: std::ops::Add<Output = T> + Copy
表示 T
必须是一个可以进行加法操作并且加法结果类型与操作数类型相同,同时还可以被复制的类型。这使得 T
适用于表示那些具有自然加法操作并且可以轻松复制的值,例如整数和浮点数。
在 add_impl
函数示例中,这个 where
子句确保了 num1
和 num2
可以安全地进行加法操作,并且结果可以被返回而不需要担心所有权问题,因为 T
实现了 Copy
trait。这样,add
函数就可以接受任何满足这些约束的类型的参数,例如整数或浮点数,并返回它们的和。
1.2 结构体、方法定义中的泛型
同样也可以用 <>
语法来定义结构体,它包含一个或多个泛型参数类型字段。其语法类似于函数定义中使用泛型。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。
下面的示例展示了如何在结构体中使用泛型来创建灵活的数据结构,以及如何为这些结构体实现方法来执行特定操作。通过使用泛型,你可以编写更通用、更灵活的代码,这些代码可以与多种不同的数据类型一起工作。
// 定义一个泛型结构体 `Box<T>`,其中 `T` 是泛型参数
struct Box<T> {
width: T,
height: T,
}
// 为 `Box` 结构体实现一个方法来计算面积
impl<T> Box<T> {
// 这个方法接受一个 `Box` 实例并返回其面积
// 这里我们使用泛型参数 `T`,假设它实现了 `std::ops::Mul` 和 Copy
fn area(&self) -> T
where
T: std::ops::Mul<Output = T> + Copy,
{
self.width * self.height // 面积 = 长 * 宽
}
}
// 为 `Box` 结构体实现一个新方法,用于创建新的实例
impl<T> Box<T> {
fn new(width: T, height: T) -> Self {
Box { width, height }
}
}
fn main() {
// 创建一个整数类型的 `Box`
let int_box = Box::new(10, 20);
println!("The area of the integer box is: {}", int_box.area());
// 创建一个浮点数类型的 `Box`
let float_box = Box::new(10.5, 20.3);
println!("The area of the float box is: {}", float_box.area());
}
运行结果
The area of the integer box is: 200
The area of the float box is: 213.15
在这个示例中:
- 我们定义了一个名为
Box
的泛型结构体,它有两个字段:width
和height
,它们的类型都是泛型参数T
。 - 我们为
Box
结构体实现了一个名为area
的方法,该方法计算并返回面积。这里我们使用了泛型参数T
并为其添加了 trait bounds,确保T
可以进行乘法操作。 - 我们还为
Box
结构体实现了一个名为new
的关联函数(也称为静态方法),它接受宽度和高度作为参数,并返回一个新的Box
实例。 - 在
main
函数中,我们分别使用整数和浮点数类型创建了Box
结构体的实例,并调用了area
方法来计算和打印它们的面积。
1.3 枚举定义中的泛型
和结构体类似,枚举也可以在成员中存放泛型数据类型。比如标准库提供的 Option<T>
枚举,这里再回顾一下:
enum Option<T> {
Some(T),
None,
}
如你所见 Option<T>
是一个拥有泛型 T
的枚举,它有两个成员:Some
,它存放了一个类型 T
的值,和不存在任何值的 None
。通过 Option<T>
枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T>
是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。
当你意识到代码中定义了多个结构体或枚举,它们不一样的地方只是其中的值的类型的时候,不妨通过泛型类型来避免重复。
1.4 泛型代码的性能
泛型并不会使程序比具体类型运行得慢。Rust 通过在编译时进行泛型代码的单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
比如下面使用标准库中的 Option
枚举的例子。
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一个对应 i32
,另一个对应 f64
。为此,它会将泛型定义 Option<T>
展开为两个针对 i32
和 f64
的定义,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样(编译器会使用不同于如下假想的名字):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
泛型 Option<T>
被编译器替换为了具体的定义。因为 Rust 会将每种情况下的泛型代码编译为具体类型,使用泛型没有运行时开销。当代码运行时,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。
二、Trait
trait 类似于其他语言中的常被称为接口(interfaces)的功能,虽然有一些不同。
一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。
// 定义一个 trait `Animal`,它有两个方法:`speak` 和 `name`
pub trait Animal {
fn speak(&self);
fn name(&self) -> &str;
}
// 为 `Dog` 结构体实现 `Animal` trait
struct Dog {
name: String,
}
impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
fn name(&self) -> &str {
&self.name
}
}
// 为 `Cat` 结构体实现 `Animal` trait
struct Cat {
name: String,
}
impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
fn name(&self) -> &str {
&self.name
}
}
// 定义一个函数,它接受任何实现了 `Animal` trait 的类型作为参数
fn animal_sound<T: Animal>(animal: &T) {
println!("{} says: ", animal.name());
animal.speak();
}
fn main() {
let dog = Dog {
name: "Rex".to_string(),
};
let cat = Cat {
name: "Whiskers".to_string(),
};
animal_sound(&dog);
animal_sound(&cat);
}
运行结果
Rex says:
Woof!
Whiskers says:
Meow!
在这个示例中:
-
我们定义了一个名为
Animal
的 trait,它有两个方法:speak
和name
。speak
方法没有返回值,而name
方法返回一个字符串的引用。 -
我们定义了两个结构体:
Dog
和Cat
,它们都包含一个name
字段。 -
我们为
Dog
和Cat
结构体分别实现了Animal
trait。对于每个结构体,我们提供了speak
和name
方法的具体实现。 -
我们定义了一个名为
animal_sound
的泛型函数,它接受任何实现了Animal
trait 的类型作为参数。这个函数打印出动物的名字和它发出的声音。 -
在
main
函数中,我们创建了Dog
和Cat
的实例,然后使用animal_sound
函数来打印它们的名字和声音。
这个示例展示了如何定义 trait 并为不同的结构体实现它,以及如何使用 trait bounds 来创建可以与多种实现了相同 trait 的类型一起工作的泛型函数。通过使用 trait,我们可以确保不同的类型有一致的行为,同时保持代码的灵活性和可重用性。
有时为 trait 中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。修改上个例子的两处后。
// name 方法实现默认行为
pub trait Animal {
fn speak(&self);
fn name(&self) -> &str {
"Kate"
}
}
...
// 去除 name 方法
impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
}
运行结果
Rex says:
Woof!
Kate says:
Meow!
三、生命周期
生命周期注解甚至不是一个大部分语言都有的概念,所以这可能感觉起来有些陌生。Rust 中的每一个引用都有其 生命周期(lifetime),也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明它们的关系,这样就能确保运行时实际使用的引用绝对是有效的。
3.1 生命周期避免了悬垂引用
生命周期的主要目标是避免悬垂引用(dangling references),后者会导致程序引用了非预期引用的数据。
fn main() {
let a;
{
let b = 1;
a = &b;
println!("a: {a} b: {b}");
}
println!("a: {a}");
}
外部作用域声明了一个没有初值的变量 a,而内部作用域声明了一个初值为 1 的变量 b。在内部作用域中,我们尝试将 a 的值设置为一个 b 的引用。接着在内部作用域结束后,尝试打印出 a 的值。这段代码不能编译因为 a 引用的值在尝试使用之前就离开了作用域。
编译报错信息如下:
Compiling playground v0.0.1 (/playground)
error[E0597]: `b` does not live long enough
--> src/main.rs:6:13
|
5 | let b = 1;
| - binding `b` declared here
6 | a = &b;
| ^^ borrowed value does not live long enough
7 | println!("a: {a} b: {b}");
8 | }
| - `b` dropped here while still borrowed
9 |
10 | println!("a: {a}");
| --- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `playground` (bin "playground") due to 1 previous error
借用检查器
Rust 编译器有一个借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。
fn main() {
let a; //---------+-- 'a
// |
{ // |
let b = 1; //-+-- 'b |
a = &b; // | |
println!("a: {a} b: {b}"); // | |
} //-+ |
// |
println!("a: {a}"); // |
} //---------+
这里将 a 的生命周期标记为 'a 并将 b 的生命周期标记为 'b。内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,Rust 比较这两个生命周期的大小,并发现 a 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。
3.2 函数中的泛型生命周期
比如下面的例子 max 函数用来比较入参 x 和 y 中的最大值,不过我们使用了引用。
fn max(x: &i32, y: &i32) -> &i32 {
if (*x) > (*y) {
x
} else {
y
}
}
fn main() {
let a = 10;
let b = 20;
println!("max: {}", max(&a, &b));
}
编译后报错:
Compiling playground v0.0.1 (/playground)
error[E0106]: missing lifetime specifier
--> src/main.rs:1:29
|
1 | fn max(x: &i32, y: &i32) -> &i32 {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn max<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `playground` (bin "playground") due to 1 previous error
编译报错信息中的关键点:
缺少生命周期说明符。
此函数的返回类型包含一个借用值,但签名没有说明它是从x
还是y
借用的。
考虑引入一个命名生命周期参数。
提示文本揭示了返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向 x 或 y。事实上我们也不知道,因为函数体中 if 块返回一个 x 的引用而 else 块返回一个 y 的引用。
3.3 函数签名中的生命周期注解
生命周期注解语法
生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。
生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a 作为第一个生命周期注解。生命周期参数注解位于引用的 & 之后,并有一个空格来将引用类型与生命周期注解分隔开。
这里有一些例子:我们有一个没有生命周期参数的 i32 的引用,一个有叫做 'a
的生命周期参数的 i32 的引用,和一个生命周期也是 'a
的 i32 的可变引用:
&i32 // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用
单个的生命周期注解本身没有多少意义,因为生命周期注解告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。
例如如果函数有一个生命周期 'a
的 i32 的引用的参数 first。还有另一个同样是生命周期 'a
的 i32 的引用的参数 second。这两个生命周期注解意味着引用 first 和 second 必须与这泛型生命周期存在得一样久。
现在来修复上例中的报错。
fn max<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if (*x) > (*y) {
x
} else {
y
}
}
fn main() {
let a = 10;
let b = 20;
println!("max: {}", max(&a, &b));
}
运行结果
max: 20
这段代码定义了一个名为 max
的函数,该函数接受两个指向整数的引用作为参数,并返回一个指向整数的引用。在 main
函数中,创建了两个变量 a 和 b,并将它们的引用传递给 max
函数。
让我们详细分析生命周期:
- 在
max
函数中,我们有两个输入引用 x 和 y,它们都有相同的生命周期'a
。这意味着这两个引用必须在整个函数执行期间保持有效。 max
函数返回一个引用,其生命周期与输入引用相同,即'a
。- 在
main
函数中,我们创建了两个变量 a 和 b ,它们的生命周期从声明开始到main
函数结束。然后我们将这两个变量的引用传递给max
函数。 max
函数返回一个引用,该引用指向较大的整数。由于这个引用指向的是 a 或 b 中的一个,因此它的生命周期与 a 和 b 相同。- 最后,我们在
println!
宏中使用max
函数的返回值。由于返回值是一个引用,因此在打印之前不需要解引用。当println!
宏执行完毕后,返回的引用将被丢弃,不再使用。
3.4 结构体和方法定义中的生命周期注解
在 Rust 中,当结构体包含引用类型的字段时,需要定义生命周期参数以确保这些引用在结构体实例的生命周期内保持有效。以下是一个包含生命周期参数的结构体定义的示例:
// 定义一个结构体 `Message`,它包含一个字符串引用
struct Message<'a> {
content: &'a str,
}
// 为 `Message` 结构体实现一个方法来打印消息内容
impl<'a> Message<'a> {
fn print(&self) {
println!("The message is: {}", self.content);
}
}
fn main() {
let text = "Hello, Rust!".to_string();
let message = Message { content: &text };
message.print();
}
运行结果
The message is: Hello, Rust!
在这个示例中:
Message
结构体定义了一个生命周期参数'a
。Message
结构体有一个字段content
,它是对字符串切片的引用&'a str
。这意味着content
字段借用了一个字符串,并且这个借用的生命周期至少与Message
实例的生命周期一样长。- 我们为
Message
结构体实现了一个print
方法,它使用self
来访问content
字段,并打印出消息内容。 - 在
main
函数中,我们创建了一个String
类型的变量text
,然后创建了一个Message
实例message
,将text
的引用传递给content
字段。 - 由于
text
的生命周期与main
函数相同,它也足以覆盖message
的生命周期,因此 Rust 编译器可以保证message
中的引用在print
方法调用时是有效的。
这个示例展示了如何在结构体中使用生命周期参数来确保引用的有效性。通过定义生命周期参数并将其应用于引用字段,我们可以确保结构体实例在使用这些引用时,引用指向的数据仍然有效。这是 Rust 借用检查器确保内存安全的一种方式。
3.5 生命周期省略
在编写了很多 Rust 代码后,Rust 团队发现在特定情况下 Rust 程序员们总是重复地编写一模一样的生命周期注解。这些场景是可预测的并且遵循几个明确的模式。接着 Rust 团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制程序员显式的增加注解。
这里我们提到一些 Rust 的历史是因为更多的明确的模式被合并和添加到编译器中是完全可能的。未来只会需要更少的生命周期注解。
被编码进 Rust 引用分析的模式被称为生命周期省略规则(lifetime elision rules)。这并不是需要程序员遵守的规则;这些规则是一系列特定的场景,此时编译器会考虑,如果代码符合这些场景,就无需明确指定生命周期。
省略规则并不提供完整的推断:如果 Rust 在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。编译器会在可以通过增加生命周期注解来解决错误问题的地方给出一个错误提示,而不是进行推断或猜测。
函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期被称为输出生命周期(output lifetimes)。
编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于 fn
定义,以及 impl
块。
第一条规则是编译器为每一个引用参数都分配一个生命周期参数。换句话说就是,函数有一个引用参数的就有一个生命周期参数:fn foo<'a>(x: &'a i32)
,有两个引用参数的函数就有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
,依此类推。
第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32
。
第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 &self
或 &mut self
,说明是个对象的方法 (method),那么所有输出生命周期参数被赋予 self
的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。
3.6 静态生命周期
这里有一种特殊的生命周期值得讨论:'static
,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static
生命周期,我们也可以选择像下面这样标注出来:
let s: &'static str = "I have a static lifetime.";
这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static
的。
你可能在错误信息的帮助文本中见过使用 'static
生命周期的建议,不过将引用指定为 'static
之前,思考一下这个引用是否真的在整个程序的生命周期里都有效,以及你是否希望它存在得这么久。大部分情况中,推荐 'static
生命周期的错误信息都是尝试创建一个悬垂引用或者可用的生命周期不匹配的结果。在这种情况下的解决方案是修复这些问题而不是指定一个 'static
的生命周期。
3.7 结合泛型类型参数、trait bounds 和生命周期
以下是一个结合了泛型类型参数、trait bounds 和生命周期的例子。这个示例展示了如何在泛型结构体中使用生命周期参数来确保对引用的有效管理,并且展示了如何使用 trait bounds 来约束泛型参数实现特定的行为。
// 定义一个简单的 trait `Append`
trait Append {
fn append(&mut self, other: &str);
}
// 为 `String` 类型实现 `Append` trait
impl Append for String {
fn append(&mut self, other: &str) {
self.push_str(other);
}
}
// 定义一个泛型结构体 `Appender`,它包含一个实现了 `Append` trait 的类型参数 `T`
// 并且这个类型参数 `T` 有一个生命周期 `'a`
struct Appender<'a, T: Append> {
item: &'a mut T,
}
// 为 `Appender` 结构体实现一个方法来添加内容
impl<'a, T: Append> Appender<'a, T> {
fn add_content(&mut self, other: &str) {
self.item.append(other);
}
}
fn main() {
// 创建一个 `String` 类型的实例 `text`
let mut text = String::from("Hello, ");
// 创建一个 `Appender` 实例,其 `item` 字段包含 `text` 的可变引用
let mut appender = Appender { item: &mut text };
// 调用 `add_content` 方法来添加内容到 `Appender` 实例的 `item`
appender.add_content("world!");
// 打印最终的字符串
println!("{}", text);
}
运行结果
Hello, world!
在这个示例中:
- 我们定义了一个
Append
trait,它有一个append
方法,用于向接收者添加内容。 - 我们为
String
类型实现了Append
trait,使用push_str
方法来添加字符串。 - 我们定义了一个泛型结构体
Appender<'a, T>
,它包含一个类型为T
的可变引用item
,其中T
必须实现了Append
trait,并且有一个生命周期'a
。这意味着Appender
持有的item
在'a
生命周期内是有效的。 - 我们为
Appender
结构体实现了一个add_content
方法,它调用item
字段的append
方法来添加内容。 - 在
main
函数中,我们创建了一个String
类型的实例text
,然后创建了一个Appender
实例appender
,其item
字段包含text
的可变引用。 - 我们调用
appender
的add_content
方法来添加内容到text
。 - 最后,我们打印出最终的字符串。
参考链接
- Rust 官方网站:https://www.rust-lang.org/zh-CN
- Rust 官方文档:https://doc.rust-lang.org/
- Rust Play:https://play.rust-lang.org/
- 《Rust 程序设计语言》