Rust程序设计语言
前言
本文是我在阅读Rust语言圣经过程中的一些总结,侧重点在于对比Rust和C++的语言特性:
标注🧐代表相比于C/C++,rust的比较明显的不同之处。
标注🤔代表我自己探索的一些坑
标注❗代表unsafe相关的特性
Cargo入门
1
2
cargo new <project name> // 二进制crate
cargo new --lib <project name> // 库crate
该命令生成的项目目录结构:
src 源代码目录
Cargo.toml 记录项目包依赖
.gitignore
构建cargo项目
1
2
cargo build // debug模式
cargo build −−release // release模式,速度更快
该命令额外生成的文件和目录:
target 目标文件目录
Cargo.lock 记录依赖包版本,确保可重现构建
构建并运行cargo项目
1
cargo run
检查cargo项目是否有编译错误,不生成可执行文件
1
cargo check
变量和可变性
变量
🧐rust中变量使用let关键字创建,默认是不可变的(immutable)。若希望创建可变变量,需添加mut关键字。
1
2
3
4
5
6
7
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
_____________________________________
The value of x is: 5
The value of x is: 6
常量
rust中常量使用const关键字创建,必须注明值的类型,必须赋值,一定是不可变的,无法添加mut关键字。
1
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
隐藏 shadowing🧐
在rust中,重新对同名变量进行重复声明,新声明的变量会覆盖旧变量,称为隐藏(shadowing)。可以对同一个变量隐藏多次,并且可以使用不同类型进行隐藏。需要注意隐藏是有作用域的。
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("The value of x in the inner scope is: {x}");
}
println!("The value of x is: {x}");
}
________________________________________________________________________
The value of x in the inner scope is: 12
The value of x is: 6
隐藏和mut的区别:
1
2
3
4
5
6
7
// 隐藏: 可通过编译
let spaces = " "; // string类型
let spaces = spaces.len(); // usize类型
// mut: 编译错误
let mut spaces = " ";
let spaces = spaces.len();
数据类型
rust的数据类型分为两类:标量(scalar)和复合(compound)。
标量类型
四种基本标量类型:整型、浮点型、布尔类型和字符类型。
- 整型
i32是整型数字默认类型
长度 | 有符号 | 无符号 |
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
Decimal (十进制) | 98_222 |
Hex (十六进制) | 0xff |
Octal (八进制) | 0o77 |
Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8) | b'A' |
数字字面量可以添加下划线(_)分割方便阅读,如1000_0000等同于10000000
浮点型
IEEE-754标准,f32和f64,默认为f64。布尔型
bool类型大小为1个字节。字符型
🧐char类型大小为4个字节,代表一个Unicode标量值。
1
2
3
let c = 'z';
let z: char = 'ℤ'; // with explicit type annotation
let heart_eyed_cat = '😻';
复合类型
复合类型可以将多个值组合成一个类型。rust有两个原生的复合类型:元组(tuple)和数组(array)。
- 元组类型
元组是一个将多个其他类型的值组合进一个复合类型的主要方式。元组长度固定,一旦声明,其长度不会增大或缩小。
1
let tup: (i32, f64, u8) = (500, 6.4, 1);
为了从元组中获取单个值,可以使用模式匹配(pattern matching)来解构(destructure)元组值:
1
2
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
我们也可以使用点号(.)后跟值的索引来直接访问它们。例如:
1
2
3
4
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
🧐不带任何值的元组有个特殊的名称,叫做单元(unit)元组。这种值以及对应的类型都写作 ()
,表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值。
- 数组类型
与元组不同,数组中的每个元素的类型必须相同。Rust中的数组长度是固定的。
1
let a = [1, 2, 3, 4, 5];
可以像这样编写数组的类型:在方括号中包含每个元素的类型,后跟分号,再后跟数组元素的数量:
1
let a: [i32; 5] = [1, 2, 3, 4, 5];
可以通过在方括号中指定初始值加分号再加元素个数的方式来创建一个每个元素都为相同值的数组:
1
let a = [3; 5]; // [3, 3, 3, 3, 3]
访问数组元素:
1
2
3
4
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
🧐在数组中越界访问元素,会导致一个运行时错误,程序立即在错误处退出。在Rust中,这种情况称为panic,指程序因为错误而退出的情况。
函数
在 Rust 中通过输入 fn
后面跟着函数名和一对圆括号来定义函数。
Rust 不关心函数定义所在的位置,只要函数被调用时出现在调用之处可见的作用域内就行。
参数
🧐在函数签名中,必须 声明每个参数的类型。这是 Rust 设计中一个经过慎重考虑的决定:要求在函数定义中提供类型注解。
1
2
3
4
5
6
7
fn main() {
print_labeled_measurement(5, 'h');
}
fn print_labeled_measurement(value: i32, unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}
语句和表达式
🧐Rust 是一门基于表达式(expression-based)的语言,这是一个需要理解的不同于其他语言的重要区别。
语句(Statements)是执行一些操作但不返回值的指令。 表达式(Expressions)计算并产生一个值。
1
2
3
4
5
fn main() {
let x = (let y = 6);
}
_________________________________
compile error!
🧐let y = 6
语句并不返回值,所以没有可以绑定到 x
上的值。这与其他语言不同,例如 C 和 Ruby,它们的赋值语句会返回所赋的值。在这些语言中,可以这么写 x = y = 6
,这样 x
和 y
的值都是 6
;Rust 中不能这样写。
1
2
3
4
5
6
7
8
fn main() {
let y = {
let x = 3;
x + 1
};
println!("The value of y is: {y}");
}
🧐注意
x+1
这一行在结尾没有分号,与见过的大部分代码行不同。表达式的结尾没有分号。如果在表达式的结尾加上分号,它就变成了语句,而语句不会返回值。
具有返回值的函数
我们并不对函数返回值命名,但要在箭头(->
)后声明它的类型。在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。使用 return
关键字和指定值,可从函数中提前返回;但大部分函数隐式的返回最后的表达式。
1
2
3
4
5
6
7
8
fn main() {
let x = plus_one(5);
println!("The value of x is: {x}");
}
fn plus_one(x: i32) -> i32 {
x + 1
}
控制流
if表达式
🧐if
后的条件 必须 是 bool
值。如果条件不是 bool
值,我们将得到一个错误。
在let语句中使用if
因为if
是一个表达式,我们可以在let
语句的右侧使用它。1 2 3 4 5 6
fn main() { let condition = true; let number = if condition { 5 } else { 6 }; println!("The value of number is: {number}"); }
记住,代码块的值是其最后一个表达式的值,而数字本身就是一个表达式。在这个例子中,整个
if
表达式的值取决于哪个代码块被执行。这意味着if
的每个分支的可能的返回值都必须是相同类型。如果它们的类型不匹配,如下面这个例子,则会出现一个错误:1 2 3 4 5 6 7
fn main() { let condition = true; let number = if condition { 5 } else { "six" }; println!("The value of number is: {number}"); }
循环
loop循环(无限循环)
1 2 3 4 5
fn main() { loop { println!("again!"); } }
如果将返回值加入你用来停止循环的
break
表达式,它会被停止的循环返回:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
fn main() { let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; } }; println!("The result is {result}"); } ___________________________________________ The result is 20
🧐循环标签
如果存在嵌套循环,break
和continue
应用于此时最内层的循环。你可以选择在一个循环上指定一个 循环标签(loop label),然后将标签与break
或continue
一起使用,使这些关键字应用于已标记的循环而不是最内层的循环。下面是一个包含两个嵌套循环的示例:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
fn main() { let mut count = 0; 'counting_up: loop { println!("count = {count}"); let mut remaining = 10; loop { println!("remaining = {remaining}"); if remaining == 9 { break; } if count == 2 { break 'counting_up; } remaining -= 1; } count += 1; } println!("End count = {count}"); } ____________________________________________________________ count = 0 remaining = 10 remaining = 9 count = 1 remaining = 10 remaining = 9 count = 2 remaining = 10 End count = 2
while循环
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
fn main() { let mut number = 3; while number != 0 { println!("{number}!"); number -= 1; } println!("LIFTOFF!!!"); } __________________________________ 3! 2! 1! LIFTOFF!!!
for循环
1 2 3 4 5 6 7 8 9 10 11 12 13
fn main() { let a = [10, 20, 30, 40, 50]; for element in a { println!("the value is: {element}"); } } __________________________________________________ the value is: 10 the value is: 20 the value is: 30 the value is: 40 the value is: 50
通过使用 range,它是标准库提供的类型,用来生成从一个数字开始到另一个数字之前结束的所有数字的序列,并使用
rev
反转 range:1 2 3 4 5 6 7 8 9 10 11
fn main() { for number in (1..4).rev() { println!("{number}!"); } println!("LIFTOFF!!!"); } _______________________________ 3! 2! 1! LIFTOFF!!!
Range类型
Range(..)左闭右开
1 2 3 4 5
fn main() { for i in 1..5 { println!("{}", i); // 输出1, 2, 3, 4 } }
RangeInclusive(..=)左闭右闭
1 2 3 4 5
fn main() { for i in 1..=5 { println!("{}", i); // 输出1, 2, 3, 4, 5 } }
RangeTo(..end)0到某个结束值(不包括)
1 2 3 4 5
fn main() { for i in ..5 { println!("{}", i); // 输出0, 1, 2, 3, 4 } }
RangeToInclusive(..=end))0到某个结束值(包括)
1 2 3 4 5
fn main() { for i in ..=5 { println!("{}", i); // 输出0, 1, 2, 3, 4, 5 } }
所有权🧐
所有权(ownership)是 Rust 最为与众不同的特性,对语言的其他部分有着深刻含义。它让 Rust 无需垃圾回收即可保障内存安全,因此理解 Rust 中所有权如何工作是十分重要的。
跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的主要目的就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。
所有权规则
- Rust 中的每一个值都有一个 所有者(owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
内存与分配
内存在拥有它的变量离开作用域后就被自动释放:
1
2
3
4
5
6
7
fn main() {
{
let s = String::from("hello"); // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,
// s 不再有效
}
当 s
离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop
,在这里 String
的作者可以放置释放内存的代码。Rust 在结尾的 }
处自动调用 drop
。
这个模式对编写 Rust 代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。
变量与数据交互的方式(一):移动
1 2 3 4 5 6 7 8 9
fn main() { let s1 = String::from("hello"); let s2 = s1; png println!("{}, world!", s1); } _______________________________________ compile error !
s1
s2
指向同一块堆内存,当它们离开作用域时,它们都会尝试释放相同的内存。这是一个叫做二次释放(double free)的错误。两次释放相同内存会导致内存污染,它可能会导致潜在的安全漏洞。 为了确保内存安全,在let s2 = s1;
之后,Rust 认为s1
不再有效,因此 Rust 不需要在s1
离开作用域后清理任何东西。这样就解决了我们的问题!因为只有s2
是有效的,当其离开作用域,它就释放自己的内存。另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。
变量与数据交互的方式(二):克隆
1 2 3 4 5 6
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
这段代码能正常运行,这里堆上的数据确实被复制了。当出现
clone
调用时,会进行深拷贝。只在栈上的拷贝
1 2 3 4 5 6
fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
这段代码似乎与我们刚刚学到的内容相矛盾:没有调用
clone
,不过x
依然有效且没有被移动到y
中。原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,这里没有深浅拷贝的区别,所以这里调用
clone
并不会与通常的浅拷贝有什么不同。符合该特征的数据类型有:
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
,它的值是true
和false
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有。
- 所有整数类型,比如
隐式移动发生的时机🤔
match 模式匹配
1 2 3 4 5 6 7 8 9
fn main() { let v: String = String::from("hello"); let r = &v; match v { value => println!("{v}") // 此处v被移动到value,因此无法打印v } // 释放value的堆空间 println!("{v}") // 此处v内存被释放,同样无法打印 }
1 2 3 4 5 6 7 8 9
fn main() { let v: String = String::from("hello"); let r = &v; match r { // 替换为引用即可正常运行 value => println!("{v}") } println!("{v}") }
方法参数
self
会拿走当前结构体实例(调用对象)的所有权,而&self
却只会借用一个不可变引用,&mut self
会借用一个可变引用for循环
1 2 3 4 5 6 7 8 9
fn main() { let v = vec![1, 2, 3]; let mut v1 = Vec::new(); for item in v { // 此处发生移动 v1.push(item); } assert_eq!(v, v1); }
所有权与函数
将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。下面的注释展示变量何时进入和离开作用域:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效
let x = 5; // x 进入作用域
makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,
// 所以在后面可继续使用 x
} // 这里,x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 没有特殊之处
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。
// 占用的内存被释放
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。没有特殊之处
引用和借用
引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。引用允许你使用值但不获取其所有权。
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
同理,函数签名使用 &
来表明参数 s
的类型是一个引用。让我们增加一些解释性的注释:
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize { // s 是 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生
我们将创建一个引用的行为称为 借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它,因此我们无法修改引用的变量:
1
2
3
4
5
6
7
8
9
10
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
some_string.push_str(", world");
}
________________________________________
compile error !
可变引用
我们通过一个小调整就能修复示例 4-6 代码中的错误,允许我们修改一个借用的值,这就是 可变引用(mutable reference):
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能使用该变量的另一个可变引用。同时使用两个 s
的可变引用的代码会失败:
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2); // compile error !
println!("{}", r1); // compile error !
println!("{}", r2); // 可以
}
这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有:(写写冲突)
1
2
3
4
5
6
7
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
let r2 = &mut s;
我们也不能在拥有不可变引用的同时拥有可变引用。Rust 在同时使用可变与不可变引用时也采用的类似的规则。这些代码会导致一个错误:(读写冲突)
1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题
println!("{}, {}, and {}", r1, r2, r3);
____________________________________________________
compile error !
悬空引用
在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
让我们尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:
文件名:src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // dangle 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串
&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃,其内存被释放,成为悬空引用
________________________________________________________
compile error !
这里的解决方法是直接返回 String
:
1
2
3
4
5
fn no_dangle() -> String {
let s = String::from("hello");
s
}
切片 Slice🧐
slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一类引用,所以它没有所有权。
一个切片引用占用了2个字大小的内存空间( 从现在开始,为了简洁性考虑,如无特殊原因,我们统一使用切片来特指切片引用 )。 该切片的第一个字是指向数据的指针,第二个字是切片的长度。字的大小取决于处理器架构,例如在 x86-64
上,字的大小是 64 位也就是 8 个字节,那么一个切片引用就是 16 个字节大小。
字符串slice
字符串 slice(string slice)是String
中一部分值的引用,它看起来像这样:1 2 3 4
let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11];
对于 Rust 的
..
range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:1 2 3 4
let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2];
依此类推,如果 slice 包含
String
的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:1 2 3 4 5 6
let s = String::from("hello"); let len = s.len(); let slice = &s[3..len]; let slice = &s[3..];
也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:
1 2 3 4 5 6
let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..];
🧐字符串字面值就是 slice
1
let s = "Hello, world!";
这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的:&str 是一个不可变引用。
其他类型的slice
字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:
1
let a = [1, 2, 3, 4, 5];
就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。我们可以这样做:
1
2
3
4
5
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
这个 slice 的类型是 &[i32]
。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。第八章讲到 vector 时会详细讨论这些集合。
结构体
1
2
3
4
5
6
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
通过为每个字段指定具体值来创建这个结构体的实例。创建一个实例需要以结构体的名字开头,接着在大括号中使用 key: value 键 - 值对的形式提供字段。实例中字段的顺序不需要和它们在结构体中声明的顺序一致。
1
2
3
4
5
6
7
8
9
10
fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
}
注意可变性必须作用在整个实例;Rust 并不允许只将某个字段标记为可变。
使用字段初始化
build_user
函数获取 email 和用户名并返回 User
实例:
1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}
为函数参数起与结构体字段相同的名字是可以理解的,但是不得不重复 email
和 username
字段名称与变量有些啰嗦。如果结构体有更多字段,重复每个名称就更加烦人了。幸运的是,有一个方便的简写语法, 字段初始化简写语法:
1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}
使用结构体更新语法
使用旧实例的大部分值但改变其部分值来创建一个新的结构体实例通常是很有用的。这可以通过 结构体更新语法(struct update syntax)实现。
下面演示了如何在 user2
中创建一个新 User
实例。我们为 email
设置了新的值,其他值则使用了实例 5-2 中创建的 user1
中的同名值:
1
2
3
4
5
6
7
8
9
10
fn main() {
// --snip--
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};
}
使用结构体更新语法,我们可以通过更少的代码来达到相同的效果,如示例 5-7 所示。..
语法指定了剩余未显式设置值的字段应有与给定实例对应字段相同的值。
1
2
3
4
5
6
7
8
fn main() {
// --snip--
let user2 = User {
email: String::from("another@example.com"),
..user1
};
}
🧐注意,结构更新语法就像带有 =
的赋值,因为它移动了数据。在这个例子中,总体上说我们在创建 user2
后不能就再使用 user1
了,因为 user1
的 username
字段中的 String
被移到 user2
中。如果我们给 user2
的 email
和 username
都赋予新的 String
值,从而只使用 user1
的 active
和 sign_in_count
值,那么 user1
在创建 user2
后仍然有效。
元组结构体
可以定义与元组类似的结构体,称为 元组结构体(tuple structs)。元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。
要定义元组结构体,以 struct
关键字和结构体名开头并后跟元组中的类型。例如,下面是两个分别叫做 Color
和 Point
元组结构体的定义和用法:
1
2
3
4
5
6
7
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
类单元结构体
我们也可以定义一个没有任何字段的结构体!它们被称为 类单元结构体(unit-like structs)类似于 ()
。
1
2
3
4
5
struct AlwaysEqual;
fn main() {
let subject = AlwaysEqual;
}
结构体数据的所有权
在 User
结构体的定义中,我们使用了自身拥有所有权的 String
类型而不是 &str
字符串 slice 类型。这是一个有意而为之的选择,因为我们想要这个结构体拥有它所有的数据,为此只要整个结构体是有效的话其数据也是有效的。
可以使结构体存储被其他对象拥有的数据的引用,不过这么做的话需要用上 生命周期(lifetimes),这是一个第十章会讨论的 Rust 功能。生命周期确保结构体引用的数据有效性跟结构体本身保持一致。如果你尝试在结构体中存储一个引用而不指定生命周期将是无效的,比如这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct User {
active: bool,
username: &str,
email: &str,
sign_in_count: u64,
}
fn main() {
let user1 = User {
active: true,
username: "someusername123",
email: "someone@example.com",
sign_in_count: 1,
};
}
编译器会抱怨它需要生命周期标识符:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ cargo run
Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
--> src/main.rs:3:15
|
3 | username: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | active: bool,
3 ~ username: &'a str,
|
error[E0106]: missing lifetime specifier
--> src/main.rs:4:12
|
4 | email: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | active: bool,
3 | username: &str,
4 ~ email: &'a str,
|
For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` due to 2 previous errors
第十章会讲到如何修复这个问题以便在结构体中存储引用,不过现在,我们会使用像 String
这类拥有所有权的类型来替代 &str
这样的引用以修正这个错误。
通过派生 trait 增加实用功能🧐
在调试程序时打印出 Rectangle
实例来查看其所有字段的值非常有用。示例像前面章节那样尝试使用 println!
宏。但这并不行。
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {}", rect1);
}
当我们运行这个代码时,会出现带有如下核心信息的错误:
1
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
println!
宏能处理很多类型的格式,但{}
默认告诉 println!
使用被称为 Display
的格式,结构体并没有提供一个默认Display
实现来使用 println!
与 {}
占位符。
但是如果我们继续阅读错误,将会发现这个有帮助的信息:
1
2
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
{:?}
指示符告诉 println!
我们想要使用叫做 Debug
的输出格式。Debug
是一个 trait,它允许我们以一种对开发者有帮助的方式打印结构体,以便当我们调试代码时能看到它的值。
这样调整后再次运行程序。见鬼了!仍然能看到一个错误:
1
error[E0277]: `Rectangle` doesn't implement `Debug`
不过编译器又一次给出了一个有帮助的信息:
1
2
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
Rust确实包含了打印出调试信息的功能,不过我们必须为结构体显式选择这个功能。为此,在结构体定义之前加上外部属性 #[derive(Debug)]
,
文件名:src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
现在我们再运行这个程序时,就不会有任何错误,并会出现如下输出:
1
rect1 is Rectangle { width: 30, height: 50 }
当我们有一个更大的结构体时,能有更易读一点的输出就好了,为此可以使用 {:#?}
替换 println!
字符串中的 {:?}
增加可读性。在这个例子中使用 {:#?}
风格将会输出如下:
1
2
3
4
rect1 is Rectangle {
width: 30,
height: 50,
}
另一种使用 Debug
格式打印数值的方法是使用 dbg!
宏。dbg!
宏接收一个表达式的所有权(与 println!
宏相反,后者接收的是引用),打印出代码中调用 dbg! 宏时所在的文件和行号,以及该表达式的结果值,并返回该值的所有权。
注意:调用
dbg!
宏会打印到标准错误控制台流(stderr
),与println!
不同,后者会打印到标准输出控制台流(stdout
)。
下面是一个例子,我们对分配给 width
字段的值以及 rect1
中整个结构的值感兴趣。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}
我们可以把 dbg!
放在表达式 30 * scale
周围,因为 dbg!
返回表达式的值的所有权,所以 width
字段将获得相同的值,就像我们在那里没有 dbg!
调用一样。我们不希望 dbg!
拥有 rect1
的所有权,所以我们在下一次调用 dbg!
时传递一个引用。下面是这个例子的输出结果:
1
2
3
4
5
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
width: 60,
height: 50,
}
方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}
为了使函数定义于 Rectangle
的上下文中,我们开始了一个 impl
块,这个 impl
块中的所有内容都将与 Rectangle
类型相关联。接着将 area
函数移动到 impl
大括号中,并将签名中的第一个参数和函数体中其他地方的对应参数改成 self
。
在 area
的签名中,使用 &self
来替代 rectangle: &Rectangle
,&self
实际上是 self: &Self
的缩写。在一个 impl
块中,Self
类型是 impl
块的类型的别名。方法的第一个参数必须有一个名为 self
的Self
类型的参数,所以 Rust 让你在第一个参数位置上只用 self
这个名字来缩写。注意,我们仍然需要在 self
前面使用 &
来表示这个方法借用了 Self
实例。方法可以选择获得 self
的所有权,或者像我们这里一样不可变地借用 self
,或者可变地借用 self
,就跟其他参数一样。
自动引用和解引用🧐
在 C/C++ 语言中,有两个不同的运算符来调用方法:.
直接在对象上调用方法,而 ->
在一个对象的指针上调用方法,这时需要先解引用(dereference)指针。换句话说,如果 object
是一个指针,那么 object->something()
就像 (*object).something()
一样。
Rust 并没有一个与 ->
等效的运算符;相反,Rust 有一个叫 自动引用和解引用(automatic referencing and dereferencing)的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。
它是这样工作的:当使用 object.something()
调用方法时,Rust 会自动为 object
添加 &
、&mut
或 *
以便使 object
与方法签名匹配。也就是说,这些代码是等价的:
1
2
p1.distance(&p2);
(&p1).distance(&p2);
第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self
的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self
),做出修改(&mut self
)或者是获取所有权(self
)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。
关联函数和构造函数
所有在 impl
块中定义的函数被称为 关联函数(associated functions),因为它们与 impl
后面命名的类型相关。我们可以定义不以 self
为第一参数的关联函数(因此不是方法),因为它们并不作用于一个结构体的实例。我们已经使用了一个这样的函数:在 String
类型上定义的 String::from
函数。
不是方法的关联函数经常被用作返回一个结构体新实例的构造函数。这些函数的名称通常为 new
,但 new
并不是一个关键字。例如我们可以提供一个叫做 square
关联函数,它接受一个维度参数并且同时作为宽和高,这样可以更轻松的创建一个正方形 Rectangle
而不必指定两次同样的值:
文件名:src/main.rs
1
2
3
4
5
6
7
8
impl Rectangle {
fn square(size: u32) -> Self {
Self {
width: size,
height: size,
}
}
}
关键字 Self
在函数的返回类型中代指在 impl
关键字后出现的类型,在这里是 Rectangle
。
使用结构体名和 ::
语法来调用这个关联函数:比如 let sq = Rectangle::square(3);
。这个函数位于结构体的命名空间中:::
语法用于关联函数和模块创建的命名空间。
枚举
enum枚举
可以通过在代码中定义一个 IpAddrKind
枚举来表现这个概念并列出可能的 IP 地址类型,V4
和 V6
。这被称为枚举的 成员(variants):
1
2
3
4
enum IpAddrKind {
V4,
V6,
}
现在 IpAddrKind
就是一个可以在代码中使用的自定义数据类型了。
可以像这样创建 IpAddrKind
两个不同成员的实例:
1
2
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
我们还可以直接将数据附加到枚举的每个成员上,这样就不需要一个额外的结构体了。这里也很容易看出枚举工作的另一个细节:每一个我们定义的枚举成员的名字也变成了一个构建枚举的实例的函数。也就是说,IpAddr::V4()
是一个获取 String
参数并返回 IpAddr
类型实例的函数调用。作为定义枚举的结果,这些构造函数会自动被定义。
IpAddr
枚举的新定义表明了 V4
和 V6
成员都关联了 String
值:
1
2
3
4
5
6
7
8
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
用枚举替代结构体还有另一个优势:每个成员可以处理不同类型和数量的数据。IPv4 版本的 IP 地址总是含有四个值在 0 和 255 之间的数字部分。如果我们想要将 V4
地址存储为四个 u8
值而 V6
地址仍然表现为一个 String
,这就不能使用结构体了。枚举则可以轻易的处理这个情况:
1
2
3
4
5
6
7
8
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
我们可以将任意类型的数据放入枚举成员中:例如字符串、数字类型或者结构体。甚至可以包含另一个枚举!
1
2
3
4
5
6
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
结构体和枚举还有另一个相似点:就像可以使用 impl
来为结构体定义方法那样,也可以在枚举上定义方法。这是一个定义于我们 Message
枚举上的叫做 call
的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// 在这里定义方法体
}
}
let m = Message::Write(String::from("hello"));
m.call();
方法体使用了 self
来获取调用方法的值。这个例子中,创建了一个值为 Message::Write(String::from("hello"))
的变量 m
,而且这就是当 m.call()
运行时 call
方法中的 self
的值。
Option枚举🧐
Option
是标准库定义的另一个枚举。Option
类型应用广泛因为它编码了一个非常普遍的场景,即一个值要么有值要么没值。
🧐Rust 并没有很多其他语言中有的空值功能。空值(Null )是一个值,它代表没有值。在有空值的语言中,变量总是这两种状态之一:空值和非空值。Rust 没有空值,不过它确实拥有一个可以编码存在或不存在概念的枚举。这个枚举是 Option<T>
,而且它定义于标准库中,如下:
1
2
3
4
enum Option<T> {
None,
Some(T),
}
Option<T>
枚举是如此有用以至于它甚至被包含在了 prelude 之中,你不需要将其显式引入作用域。另外,它的成员也是如此,可以不需要 Option::
前缀来直接使用 Some
和 None
。即便如此 Option<T>
也仍是常规的枚举,Some(T)
和 None
仍是 Option<T>
的成员。
Option
值的例子:
1
2
3
4
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
对于 absent_number
,Rust 需要我们指定 Option
整体的类型,因为编译器只通过 None
值无法推断出 Some
成员保存的值的类型。
如果运行下面的代码,将得到错误信息:
1
2
3
4
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
= help: the following other types implement trait `Add<Rhs>`:
<&'a f32 as Add<f32>>
<&'a f64 as Add<f64>>
<&'a i128 as Add<i128>>
<&'a i16 as Add<i16>>
<&'a i32 as Add<i32>>
<&'a i64 as Add<i64>>
<&'a i8 as Add<i8>>
<&'a isize as Add<isize>>
and 48 others
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
错误信息意味着 Rust 不知道该如何将 Option<i8>
与 i8
相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8
这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信使用而无需做空值检查。只有当使用 Option<T>
的时候需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。换句话说,在对 Option<T>
进行运算之前必须将其转换为 T
。通常这能帮助我们捕获到空值最常见的问题之一:假设某值不为空但实际上为空的情况。
总的来说,为了使用 Option<T>
值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T)
值时运行,允许这些代码使用其中的 T
。也希望一些代码只在值为 None
时运行,这些代码并没有一个可用的 T
值。match
表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。
match控制流🧐
Rust 有一个叫做 match
的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。模式可由字面值、变量、通配符和许多其他内容构成。
我们编写一个函数来获取一个未知的硬币,并以一种类似验钞机的方式,确定它是何种硬币并返回它的美分值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
我们列出 match
关键字后跟一个表达式,在这个例子中是 coin
的值。这看起来非常像 if
所使用的条件表达式,不过这里有一个非常大的区别:对于 if
,表达式必须返回一个布尔值,而这里它可以是任何类型的。
当 match
表达式执行时,它将结果值按顺序与每一个分支的模式相比较。如果模式匹配了这个值,这个模式相关联的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。
如果分支代码较短的话通常不使用大括号,正如示例 6-3 中的每个分支都只是返回一个值。如果想要在分支中运行多行代码,可以使用大括号,而分支后的逗号是可选的。例如,如下代码在每次使用Coin::Penny
调用时都会打印出 “Lucky penny!”,同时仍然返回代码块最后的值,1
:
1
2
3
4
5
6
7
8
9
10
11
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
绑定值的模式
匹配分支的另一个有用的功能是可以绑定匹配的模式的部分值。这也就是如何从枚举成员中提取值的。
作为一个例子,让我们修改枚举的一个成员来存放数据。1999 年到 2008 年间,美国在 25 美分的硬币的一侧为 50 个州的每一个都印刷了不同的设计。其他的硬币都没有这种区分州的设计,所以只有这些 25 美分硬币有特殊的价值。可以将这些信息加入我们的 enum
,通过改变 Quarter
成员来包含一个 State
值,示例 6-4 中完成了这些修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Debug)] // 这样可以立刻看到州的名称
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
示例 6-4:Quarter
成员也存放了一个 UsState
值的 Coin
枚举
想象一下我们的一个朋友尝试收集所有 50 个州的 25 美分硬币。在根据硬币类型分类零钱的同时,也可以报告出每个 25 美分硬币所对应的州名称,这样如果我们的朋友没有的话,他可以将其加入收藏。
在这些代码的匹配表达式中,我们在匹配 Coin::Quarter
成员的分支的模式中增加了一个叫做 state
的变量。当匹配到 Coin::Quarter
时,变量 state
将会绑定 25 美分硬币所对应州的值。接着在那个分支的代码中使用 state
,如下:
1
2
3
4
5
6
7
8
9
10
11
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
}
}
}
如果调用 value_in_cents(Coin::Quarter(UsState::Alaska))
,coin
将是 Coin::Quarter(UsState::Alaska)
。当将值与每个分支相比较时,没有分支会匹配,直到遇到 Coin::Quarter(state)
。这时,state
绑定的将会是值 UsState::Alaska
。接着就可以在 println!
表达式中使用这个绑定了,像这样就可以获取 Coin
枚举的 Quarter
成员中内部的州的值。
匹配 Option<T>
1
2
3
4
5
6
7
8
9
10
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five); // Some(6)
let none = plus_one(None); // None
匹配是穷尽的
match
还有另一方面需要讨论:这些分支必须覆盖了所有的可能性。考虑一下 plus_one
函数的这个版本,它有一个 bug 并不能编译:
1
2
3
4
5
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
我们没有处理 None
的情况,所以这些代码会造成一个 bug。
通配模式和 _
占位符
让我们看一个例子,我们希望对一些特定的值采取特殊操作,而对其他的值采取默认操作。想象我们正在玩一个游戏,如果你掷出骰子的值为 3,角色不会移动,而是会得到一顶新奇的帽子。如果你掷出了 7,你的角色将失去新奇的帽子。对于其他的数值,你的角色会在棋盘上移动相应的格子。这是一个实现了上述逻辑的 match
,骰子的结果是硬编码而不是一个随机值,其他的逻辑部分使用了没有函数体的函数来表示,实现它们超出了本例的范围:
1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}
对于前两个分支,匹配模式是字面值 3
和 7
,最后一个分支则涵盖了所有其他可能的值,模式是我们命名为 other
的一个变量。other
分支的代码通过将其传递给 move_player
函数来使用这个变量。
即使我们没有列出 u8
所有可能的值,这段代码依然能够编译,因为最后一个模式将匹配所有未被特殊列出的值。这种通配模式满足了 match
必须被穷尽的要求。请注意,我们必须将通配分支放在最后,因为模式是按顺序匹配的。
Rust 还提供了一个模式,当我们不想使用通配模式获取的值时,请使用 _
,这是一个特殊的模式,可以匹配任意值而不绑定到该值。这告诉 Rust 我们不会使用这个值,所以 Rust 也不会警告我们存在未使用的变量。
让我们改变游戏规则:现在,当你掷出的值不是 3 或 7 的时候,你必须再次掷出。这种情况下我们不需要使用这个值,所以我们改动代码使用 _
来替代变量 other
:
1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}
if let
简洁控制流考虑下面的程序,它匹配一个
config_max
变量中的Option<u8>
值并只希望当值为Some
成员时执行代码:1 2 3 4 5
let config_max = Some(3u8); match config_max { Some(max) => println!("The maximum is configured to be {}", max), _ => (), }
我们可以使用
if let
这种更短的方式编写。if let
是match
的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。如下代码上面的match
行为一致:1 2 3 4
let config_max = Some(3u8); if let Some(max) = config_max { println!("The maximum is configured to be {}", max); }
可以在
if let
中包含一个else
。else
块中的代码与match
表达式中的_
分支块中的代码相同,这样的match
表达式就等同于if let
和else
:1 2 3 4 5
let mut count = 0; match coin { Coin::Quarter(state) => println!("State quarter from {:?}!", state), _ => count += 1, }
等价于:
1 2 3 4 5 6
let mut count = 0; if let Coin::Quarter(state) = coin { println!("State quarter from {:?}!", state); } else { count += 1; }
引用解构🤔
1
1. 🌟🌟 使用模式 `&mut V` 去匹配一个可变引用时,你需要格外小心,因为匹配出来的 `V` 是一个值,而不是可变引用
1
2
3
4
5
6
7
8
fn main() {
let mut v = String::from("hello,");
let r = &mut v;
match r {
&mut value => value.push_str(" world!") // 此处value被解构为不可变String类型,报错
}
}
模块系统
Rust 有许多功能可以让你管理代码的组织,包括哪些内容可以被公开,哪些内容作为私有部分,以及程序每个作用域中的名字。这些功能。这有时被称为 “模块系统(the module system)”,包括:
- 包(Packages):Cargo 的一个功能,它允许你构建、测试和分享 crate。
- Crates :一个模块的树形结构,它形成了库或二进制项目。
- 模块(Modules)和 use:允许你控制作用域和路径的私有性。
- 路径(path):一个命名例如结构体、函数或模块等项的方式
本章将会涵盖所有这些概念,讨论它们如何交互,并说明如何使用它们来管理作用域。到最后,你会对模块系统有深入的了解,并且能够像专业人士一样使用作用域!
包和Crate
crate 是 Rust 在编译时最小的代码单位。如果你用 rustc
而不是 cargo
来编译一个文件,编译器还是会将那个文件认作一个 crate。crate 可以包含模块,模块可以定义在其他文件,然后和 crate 一起编译。
crate 有两种形式:二进制项和库。
二进制项 可以被编译为可执行程序,比如一个命令行程序或者一个服务器。它们必须有一个 main
函数来定义当程序被执行的时候所需要做的事情。目前我们所创建的 crate 都是二进制项。
库 并没有 main
函数,它们也不会编译为可执行程序,它们提供一些诸如函数之类的东西,使其他项目也能使用这些东西。比如 第二章 的 rand
crate 就提供了生成随机数的东西。大多数时间 Rustaceans
说的 crate 指的都是库,这与其他编程语言中 library 概念一致。
包(package)是提供一系列功能的一个或者多个 crate。一个包会包含一个 Cargo.toml 文件,阐述如何去构建这些 crate。Cargo 就是一个包含构建你代码的二进制项的包。Cargo 也包含这些二进制项所依赖的库。其他项目也能用 Cargo 库来实现与 Cargo 命令行程序一样的逻辑。
模块
在本节,我们将讨论模块和其它一些关于模块系统的部分,如允许你命名项的 路径(paths);用来将路径引入作用域的 use
关键字;以及使项变为公有的 pub
关键字。我们还将讨论 as
关键字、外部包和 glob 运算符。
这里我们提供一个简单的参考,用来解释模块、路径、use
关键词和pub
关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。我们将在本章节中举例说明每条规则,不过这是一个解释模块工作方式的良好参考。
从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而言是src/lib.rs,对于一个二进制 crate 而言是src/main.rs)中寻找需要被编译的代码。
- 声明模块: 在 crate 根文件中,你可以声明一个新模块;比如,你用
mod garden
声明了一个叫做garden
的模块。编译器会在下列路径中寻找模块代码:- 内联,在大括号中,当
mod garden
后方不是一个分号而是一个大括号 - 在文件 src/garden.rs
- 在文件 src/garden/mod.rs
- 内联,在大括号中,当
- 声明子模块: 在除了 crate 根节点以外的其他文件中,你可以定义子模块。比如,你可能在src/garden.rs中定义了
mod vegetables;
。编译器会在以父模块命名的目录中寻找子模块代码:- 内联,在大括号中,当
mod vegetables
后方不是一个分号而是一个大括号 - 在文件 src/garden/vegetables.rs
- 在文件 src/garden/vegetables/mod.rs
- 内联,在大括号中,当
模块中的代码路径: 一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的
Asparagus
类型可以在crate::garden::vegetables::Asparagus
被找到。私有 vs 公用: 一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用
pub mod
替代mod
。为了使一个公用模块内部的成员公用,应当在声明前使用pub
。use
关键字: 在一个作用域内,use
关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用crate::garden::vegetables::Asparagus
的作用域,你可以通过use crate::garden::vegetables::Asparagus;
创建一个快捷方式,然后你就可以在作用域中只写Asparagus
来使用该类型。
在模块中对相关代码进行分组
模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的 私有性。私有项是不可为外部使用的内在详细实现。我们也可以将模块和它其中的项标记为公开的,这样,外部代码就可以使用并依赖与它们。
在餐饮业,餐馆中会有一些地方被称之为 前台(front of house),还有另外一些地方被称之为 后台(back of house)。前台是招待顾客的地方,在这里,店主可以为顾客安排座位,服务员接受顾客下单和付款,调酒师会制作饮品。后台则是由厨师工作的厨房,洗碗工的工作地点,以及经理做行政工作的地方组成。
我们可以将函数放置到嵌套的模块中,来使我们的 crate 结构与实际的餐厅结构相同。通过执行 cargo new --lib restaurant
,来创建一个新的名为 restaurant
的库。然后将示例 7-1 中所罗列出来的代码放入 src/lib.rs 中,来定义一些模块和函数。
文件名:src/lib.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}
示例 7-1:一个包含了其他内置了函数的模块的 front_of_house
模块
我们定义一个模块,是以 mod
关键字为起始,然后指定模块的名字(本例中叫做 front_of_house
),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hosting
和 serving
模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。
下面展示了示例 7-1 中的模块树的结构:
1
2
3
4
5
6
7
8
9
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
引用模块项目的路径
来看一下 Rust 如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。为了调用一个函数,我们需要知道它的路径。
路径有两种形式:
- 绝对路径(absolute path)是以 crate 根(root)开头的全路径;对于外部 crate 的代码,是以 crate 名开头的绝对路径,对于当前 crate 的代码,则以字面值
crate
开头。 - 相对路径(relative path)从当前模块开始,以
self
、super
或当前模块的标识符开头。
绝对路径和相对路径都后跟一个或多个由双冒号(::
)分割的标识符。
我们在 crate 根定义了一个新函数 eat_at_restaurant
,并在其中展示调用 add_to_waitlist
函数的两种方法。eat_at_restaurant
函数是我们 crate 库的一个公共 API,所以我们使用 pub
关键字来标记它。注意,这个例子无法编译通过,我们稍后会解释原因。
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
错误信息说 hosting
模块是私有的。换句话说,我们拥有 hosting
模块和 add_to_waitlist
函数的的正确路径,但是 Rust 不让我们使用,因为它不能访问私有片段。在 Rust 中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的。如果希望创建一个私有函数或结构体,你可以将其放入一个模块。
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();
// 相对路径
front_of_house::hosting::add_to_waitlist();
}
现在代码可以编译通过了!模块公有并不使其内容也是公有的。模块上的 pub
关键字只允许其父模块引用它,而不允许访问内部代码。因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。
二进制和库 crate 包的最佳实践
我们提到过包可以同时包含一个 src/main.rs 二进制 crate 根和一个 src/lib.rs 库 crate 根,并且这两个 crate 默认以包名来命名。通常,这种包含二进制 crate 和库 crate 的模式的包,在二进制 crate 中只有足够的代码来启动一个可执行文件,可执行文件调用库 crate 的代码。又因为库 crate 可以共享,这使得其它项目从包提供的大部分功能中受益。
模块树应该定义在 src/lib.rs 中。这样通过以包名开头的路径,公有项就可以在二进制 crate 中使用。二进制 crate 就完全变成了同其它 外部 crate 一样的库 crate 的用户:它只能使用公有 API。这有助于你设计一个好的 API;你不仅仅是作者,也是用户!
super 开始的相对路径
我们可以通过在路径的开头使用 super
,从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始。这类似以 ..
语法开始一个文件系统路径。
考虑一下下面的代码,它模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。back_of_house
模块中的定义的 fix_incorrect_order
函数通过指定的 super
起始的 serve_order
路径,来调用父模块中的 deliver_order
函数:
1
2
3
4
5
6
7
8
9
10
fn deliver_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order();
}
fn cook_order() {}
}
因为我们创建了名为 Appetizer
的公有枚举,所以我们可以在 eat_at_restaurant
中使用 Soup
和 Salad
成员。
创建公有的结构体和枚举
如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub
是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub
关键字。
1
2
3
4
5
6
7
8
9
10
11
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}
pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}
使用 use
关键字将路径引入作用域
不得不编写路径来调用函数显得不便且重复。在示例中,无论我们选择 add_to_waitlist
函数的绝对路径还是相对路径,每次我们想要调用 add_to_waitlist
时,都必须指定front_of_house
和 hosting
。幸运的是,有一种方法可以简化这个过程。我们可以使用 use
关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。
在示例中,我们将 crate::front_of_house::hosting
模块引入了 eat_at_restaurant
函数的作用域,而我们只需要指定 hosting::add_to_waitlist
即可在 eat_at_restaurant
中调用 add_to_waitlist
函数。
1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
在作用域中增加 use
和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::front_of_house::hosting
,现在 hosting
在作用域中就是有效的名称了,如同 hosting
模块被定义于 crate 根一样。通过 use
引入作用域的路径也会检查私有性,同其它路径一样。
注意 use
只能创建 use
所在的特定作用域内的短路径。下面的示例将 eat_at_restaurant
函数移动到了一个叫 customer
的子模块,这又是一个不同于 use
语句的作用域,所以函数体不能编译。
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
mod customer {
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}
使用 as
关键字提供新的名称
使用 use
将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as
指定一个新的本地名称或者别名。示例展示了另一个编写的方法,通过 as
重命名其中一个 Result
类型。
1
2
3
4
5
6
7
8
9
10
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
}
fn function2() -> IoResult<()> {
// --snip--
}
使用 pub use
重导出名称
使用 use
关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub
和 use
合起来使用。这种技术被称为 “重导出(re-exporting)”:我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。
示例 7-17 将示例 7-11 根模块中的 use
改为 pub use
。
文件名:src/lib.rs
1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
在这个修改之前,外部代码需要使用路径 restaurant::front_of_house::hosting::add_to_waitlist()
来调用 add_to_waitlist
函数。现在这个 pub use
从根模块重导出了 hosting
模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist
。
使用外部包
在第二章中我们编写了一个猜猜看游戏。那个项目使用了一个外部包,rand
,来生成随机数。为了在项目中使用 rand
,在 Cargo.toml 中加入了如下行:
文件名:Cargo.toml
1
rand = "0.8.5"
在 Cargo.toml 中加入 rand
依赖告诉了 Cargo 要从 crates.io 下载 rand
和其依赖,并使其可在项目代码中使用。
接着,为了将 rand
定义引入项目包的作用域,我们加入一行 use
起始的包名,它以 rand
包名开头并列出了需要引入作用域的项。回忆一下第二章的 “生成一个随机数” 部分,我们曾将 Rng
trait 引入作用域并调用了 rand::thread_rng
函数:
1
2
3
4
5
use rand::Rng;
fn main() {
let secret_number = rand::thread_rng().gen_range(1..=100);
}
嵌套路径来消除大量的 use
行
当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。例如猜猜看章节示例 2-4 中有两行 use
语句都从 std
引入项到作用域:
文件名:src/main.rs
1
2
3
4
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--
相反,我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分,如示例 7-18 所示。
文件名:src/main.rs
1
2
3
// --snip--
use std::{cmp::Ordering, io};
// --snip--
示例 7-18: 指定嵌套的路径在一行中将多个带有相同前缀的项引入作用域
在较大的程序中,使用嵌套路径从相同包或模块中引入很多项,可以显著减少所需的独立 use
语句的数量!
我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use
语句时非常有用。例如,示例 7-19 中展示了两个 use
语句:一个将 std::io
引入作用域,另一个将 std::io::Write
引入作用域:
1
2
use std::io;
use std::io::Write;
示例 7-19: 通过两行 use
语句引入两个路径,其中一个是另一个的子路径
两个路径的相同部分是 std::io
,这正是第一个路径。为了在一行 use
语句中引入这两个路径,可以在嵌套路径中使用 self
,如示例 7-20 所示。
文件名:src/lib.rs
1
use std::io::{self, Write};
示例 7-20: 将示例 7-19 中部分重复的路径合并为一个 use
语句
这一行便将 std::io
和 std::io::Write
同时引入作用域。
通过*
运算符将所有的公有定义引入作用域
如果希望将一个路径下 所有 公有项引入作用域,可以指定路径后跟 *
,glob 运算符:
1
use std::collections::*;
这个 use
语句将 std::collections
中定义的所有公有项引入当前作用域。
将模块拆分成多个文件
我们从示例 7-17 中包含多个餐厅模块的代码开始。我们会将模块提取到各自的文件中,而不是将所有模块都定义到 crate 根文件中。在这里,crate 根文件是 src/lib.rs,不过这个过程也适用于 crate 根文件是 src/main.rs 的二进制 crate。
首先将 front_of_house
模块提取到其自己的文件中。删除 front_of_house
模块的大括号中的代码,只留下 mod front_of_house;
声明,这样 src/lib.rs 会包含如示例 7-21 所示的代码。注意直到创建示例 7-22 中的 src/front_of_house.rs 文件之前代码都不能编译。
文件名:src/lib.rs
1
2
3
4
5
6
7
mod front_of_house;
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
示例 7-21: 声明 front_of_house
模块,其内容将位于 src/front_of_house.rs
接下来将之前大括号内的代码放入一个名叫 src/front_of_house.rs 的新文件中,如示例 7-22 所示。因为编译器找到了 crate 根中名叫 front_of_house
的模块声明,它就知道去搜寻这个文件。
文件名:src/front_of_house.rs
1
2
3
pub mod hosting {
pub fn add_to_waitlist() {}
}
示例 7-22: 在 src/front_of_house.rs 中定义 front_of_house
模块
注意你只需在模块树中的某处使用一次 mod
声明就可以加载这个文件。一旦编译器知道了这个文件是项目的一部分(并且通过 mod
语句的位置知道了代码在模块树中的位置),项目中的其他文件应该使用其所声明的位置的路径来引用那个文件的代码,这在“引用模块项目的路径”部分有讲到。换句话说,mod
不是你可能会在其他编程语言中看到的 “include” 操作。
另一种文件路径
目前为止我们介绍了 Rust 编译器所最常用的文件路径;不过一种更老的文件路径也仍然是支持的。
对于声明于 crate 根的
front_of_house
模块,编译器会在如下位置查找模块代码:
- src/front_of_house.rs(我们所介绍的)
- src/front_of_house/mod.rs(老风格,不过仍然支持)
对于
front_of_house
的子模块hosting
,编译器会在如下位置查找模块代码:
- src/front_of_house/hosting.rs(我们所介绍的)
- src/front_of_house/hosting/mod.rs(老风格,不过仍然支持)
如果你对同一模块同时使用这两种路径风格,会得到一个编译错误。在同一项目中的不同模块混用不同的路径风格是允许的,不过这会使他人感到疑惑。
使用 mod.rs 这一文件名的风格的主要缺点是会导致项目中出现很多 mod.rs 文件,当你在编辑器中同时打开它们时会感到疑惑。
集合
Vector
创建Vector
创建一个新的空 vector,可以调用 Vec::new
函数,注意这里我们增加了一个类型注解。因为没有向这个 vector 中插入任何值,Rust 并不知道我们想要储存什么类型的元素。这是一个非常重要的点。
1
let v: Vec<i32> = Vec::new();
通常,我们会用初始值来创建一个 Vec<T>
, 而 Rust 会推断出储存值的类型,所以很少会需要这些类型注解。为了方便 Rust 提供了 vec!
宏,这个宏会根据我们提供的值来创建一个新的 vector。
1
let v = vec![1, 2, 3];
更新Vector
对于新建一个 vector 并向其增加元素,可以使用 push
方法:
1
2
3
4
let mut v = Vec::new();
v.push(5);
v.push(6);
示例 8-3:使用 push
方法向 vector 增加值
如第三章中讨论的任何变量一样,如果想要能够改变它的值,必须使用 mut
关键字使其可变。放入其中的所有值都是 i32
类型的,而且 Rust 也根据数据做出如此判断,所以不需要 Vec<i32>
注解。
读取 vector
有两种方法引用 vector 中储存的值:通过索引或使用 get
方法:
1
2
3
4
5
6
7
8
9
10
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
索引越界会导致panic,get
越界只会返回None。
🧐一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保 vector 内容的这个引用和任何其他引用保持有效。当我们获取了 vector 的第一个元素的不可变引用并尝试在 vector 末尾增加一个元素的时候,如果尝试在函数的后面引用这个元素是行不通的:
1
2
3
4
5
6
7
8
9
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
___________________________________________________________
compile error !
在 vector 的结尾增加新元素时,在没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历 vector
如果想要依次访问 vector 中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问:
1
2
3
4
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
我们也可以遍历可变 vector 的每一个元素的可变引用以便能改变它们:
1
2
3
4
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
使用枚举来储存多种类型
vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举:
1
2
3
4
5
6
7
8
9
10
11
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
丢弃 vector 时也会丢弃其所有元素
类似于任何其他的 struct
,vector 在其离开作用域时会被释放:
1
2
3
4
5
{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
当 vector 被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。借用检查器确保了任何 vector 中内容的引用仅在 vector 本身有效时才可用。
字符串
字符串就是作为字节的集合外加一些方法实现的,很多 Vec
可用的方法在 String
中同样可用,当这些字节被解释为文本时,这些方法提供了实用的功能。
在开始深入这些方面之前,我们需要讨论一下术语 字符串 的具体意义。Rust 的核心语言中只有一种字符串类型:字符串 slice str
,它通常以被借用的形式出现,&str
。第四章讲到了 字符串 slices:它们是一些对储存在别处的 UTF-8 编码字符串数据的引用。由于字符串字面值被储存在程序的二进制输出中,因此字符串字面值也是字符串 slices。
创建字符串
事实上 String
被实现为一个带有一些额外保证、限制和功能的字节 vector 的封装。其中一个同样作用于 Vec<T>
和 String
函数的例子是用来新建一个实例的 new
函数:
1
let mut s = String::new();
这新建了一个叫做 s
的空的字符串。
通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string
方法,它能用于任何实现了 Display
trait 的类型,比如字符串字面值:
1
2
3
4
5
6
let data = "initial contents";
let s = data.to_string();
// 该方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
更新字符串
使用
push_str
和push
附加字符串String
的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变Vec
的内容一样。另外,可以方便的使用+
运算符或format!
宏来拼接String
值。可以通过
push_str
方法来附加字符串 slice,从而使String
变长,如示例 8-15 所示。1 2
let mut s = String::from("foo"); s.push_str("bar");
执行这两行代码之后,
s
将会包含foobar
。push_str
方法采用字符串 slice,因为我们并不需要获取参数的所有权。例如,我们希望在将s2
的内容附加到s1
之后还能使用它:1 2 3 4
let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}");
push
方法被定义为获取一个单独的字符作为参数,并附加到String
中:1 2
let mut s = String::from("lo"); s.push('l');
使用
+
运算符或format!
宏拼接字符串🧐通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用
+
运算符:1 2 3
let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
执行完这些代码之后,字符串
s3
将会包含Hello, world!
。s1
在相加后不再有效的原因,和使用s2
的引用的原因,与使用+
运算符时调用的函数签名有关。+
运算符使用了add
函数,这个函数签名看起来像这样:1
fn add(self, s: &str) -> String {
首先,
s2
使用了&
,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为add
函数的s
参数:只能将&str
和String
相加,不能将两个String
值相加。不过等一下 —— 正如add
的第二个参数所指定的,&s2
的类型是&String
而不是&str
。那么为什么示例还能编译呢?之所以能够在
add
调用中使用&s2
是因为&String
可以被 强转(coerced)成&str
。当add
函数被调用时,Rust 使用了一个被称为 Deref 强制转换(deref coercion)的技术,你可以将其理解为它把&s2
变成了&s2[..]
。其次,可以发现签名中
add
获取了self
的所有权,因为self
没有 使用&
。这意味着示例 8-18 中的s1
的所有权将被移动到add
调用中,之后就不再有效。所以虽然let s3 = s1 + &s2;
看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取s1
的所有权,附加上从s2
中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有,这个实现比拷贝要更高效。如果想要级联多个字符串,
+
的行为就显得笨重了:1 2 3 4 5
let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3;
这时
s
的内容会是 “tic-tac-toe”。在有这么多+
和"
字符的情况下,很难理解具体发生了什么。对于更为复杂的字符串链接,可以使用format!
宏:1 2 3 4 5
let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}");
这些代码也会将
s
设置为 “tic-tac-toe”。format!
与println!
的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的String
。这个版本就好理解的多,宏format!
生成的代码使用引用所以不会获取任何参数的所有权。
索引字符串
在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String
的一部分,会出现一个错误:
1
2
3
4
let s1 = String::from("hello");
let h = s1[0];
______________________________________
compile error !
🧐Rust 的字符串不支持索引,因为Rust字符串采用变长的UTF-8编码。索引操作预期总是需要常数时间(O(1))。但是对于 String
不可能保证这样的性能,因为 Rust 必须从开头到索引位置遍历来确定有多少有效的字符。
索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引创建字符串 slice 时,Rust 会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 []
和单个值的索引,可以使用 []
和一个 range 来创建含特定字节的字符串 slice:
1
2
3
let hello = "Здравствуйте";
let s = &hello[0..4];
这里,s
会是一个 &str
,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着 s
将会是 “Зд”。如果获取 &hello[0..1]
会发生什么呢?答案是:Rust 在运行时会 panic,就跟访问 vector 中的无效索引时一样。
遍历字符串
操作字符串每一部分的最好的方法是明确表示需要字符还是字节。对于单独的 Unicode 标量值使用 chars
方法。对 “Зд” 调用 chars
方法会将其分开并返回两个 char
类型的值,接着就可以遍历其结果来访问每一个元素了:
1
2
3
for c in "Зд".chars() {
println!("{c}");
}
这些代码会打印出如下内容:
1
2
З
д
另外 bytes
方法返回每一个原始字节,这可能会适合你的使用场景:
1
```rust
for b in “Зд”.bytes() { println!(“{b}”); } ```
这些代码会打印出组成 String
的 4 个字节:
1
2
3
4
208
151
208
180
不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。
从字符串中获取如同天城文这样的字形簇是很复杂的,所以标准库并没有提供这个功能。
String和&str互转🤔
String
to ``&str`:
1
2
let string: String = String::new();
let slice: &str = string.as_str();
&str
to String
:
1
2
3
4
let slice: &str = "hello";
let string: String = slice.to_string();
let string: String = slice.to_owned();
let string: String = slice.into();
Hash Map
创建Hash Map
可以使用 new
创建一个空的 HashMap
,并使用 insert
增加元素:
1
2
3
4
5
6
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
注意必须首先 use
标准库中集合部分的 HashMap
。在这三个常用集合中,HashMap
是最不常用的,所以并没有被 prelude 自动引用。
读取Hash Map
可以通过
get
方法并提供对应的键来从哈希 map 中获取值,如示例 8-21 所示:1 2 3 4 5 6 7 8 9
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); let team_name = String::from("Blue"); let score = scores.get(&team_name).copied().unwrap_or(0);
可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是
for
循环:1 2 3 4 5 6 7 8 9 10
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); for (key, value) in &scores { println!("{key}: {value}"); }
这会以任意顺序打印出每一个键值对:
1 2
Yellow: 50 Blue: 10
更新Hash Map
覆盖一个值
如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。即便示例 8-23 中的代码调用了两次
insert
,哈希 map 也只会包含一个键值对,因为两次都是对蓝队的键插入的值:1 2 3 4 5 6 7 8
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Blue"), 25); println!("{:?}", scores);
这会打印出
{"Blue": 25}
。原始的值10
则被覆盖了。只在键没有对应值时插入键值对
我们经常会检查某个特定的键是否已经存在于哈希 map 中并进行如下操作:如果哈希 map 中键已经存在则不做任何操作。如果不存在则连同值一块插入。
为此哈希 map 有一个特有的 API,叫做
entry
,它获取我们想要检查的键作为参数。entry
函数的返回值是一个枚举,Entry
,它代表了可能存在也可能不存在的值。比如说我们想要检查黄队的键是否关联了一个值。如果没有,就插入值 50,对于蓝队也是如此。使用entry
API 的代码看起来像示例 8-24 这样:1 2 3 4 5 6 7 8 9
use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.entry(String::from("Yellow")).or_insert(50); scores.entry(String::from("Blue")).or_insert(50); println!("{:?}", scores);
错误处理
panic
执行会造成代码 panic 的操作(比如访问超过数组结尾的内容)或者显式调用 panic!
宏。这两种情况都会使程序 panic。通常情况下这些 panic 会打印出一个错误信息,展开并清理栈数据,然后退出。
当出现 panic 时,程序默认会开始 展开(unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。
如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml 的 [profile]
部分增加 panic = 'abort'
,可以由展开切换为终止。例如,如果你想要在 release 模式中 panic 时直接终止:
1
2
[profile.release]
panic = 'abort'
使用 panic!
的 backtrace
错误指向 main.rs
的第 4 行,这里我们尝试访问索引 99。下面的说明(note)行提醒我们可以设置 RUST_BACKTRACE
环境变量来得到一个 backtrace。backtrace 是一个执行到目前位置所有被调用的函数的列表。Rust 的 backtrace 跟其他语言中的一样:阅读 backtrace 的关键是从头开始读直到发现你编写的文件。这就是问题的发源地。这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心 Rust 代码,标准库代码或用到的 crate 代码。
让我们将 RUST_BACKTRACE
环境变量设置为任何不是 0 的值来获取 backtrace 看看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
0: rust_begin_unwind
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
2: core::panicking::panic_bounds_check
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
6: panic::main
at ./src/main.rs:4:5
7: core::ops::function::FnOnce::call_once
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
这里有大量的输出!你实际看到的输出可能因不同的操作系统和 Rust 版本而有所不同。为了获取带有这些信息的 backtrace,必须启用 debug 标识。当不使用 --release
参数运行 cargo build 或 cargo run 时 debug 标识会默认启用,就像这里一样。
Result
Result
枚举,它定义有如下两个成员,Ok
和 Err
, T
代表成功时返回的 Ok
成员中的数据的类型,而 E
代表失败时返回的 Err
成员中的错误的类型:
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}
让我们调用一个返回 Result
的函数,因为它可能会失败,如下面所示打开一个文件:
1
2
3
4
5
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
}
当 File::open
成功时,greeting_file_result
变量将会是一个包含文件句柄的 Ok
实例。当失败时,greeting_file_result
变量将会是一个包含了更多关于发生了何种错误的信息的 Err
实例。
我们需要在下面的代码中增加根据 File::open
返回值进行不同处理的逻辑:
1
2
3
4
5
6
7
8
9
10
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
注意与 Option
枚举一样,Result
枚举和其成员也被导入到了 prelude 中,所以就不需要在 match
分支中的 Ok
和 Err
之前指定 Result::
。这里我们告诉 Rust 当结果是 Ok
时,返回 Ok
成员中的 file
值,然后将这个文件句柄赋值给变量 greeting_file
。match
之后,我们可以利用这个文件句柄来进行读写。match
的另一个分支处理从 File::open
得到 Err
值的情况。在这种情况下,我们选择调用 panic!
宏。
匹配不同的错误
上面的代码不管 File::open
是因为什么原因失败都会 panic!
。我们真正希望的是对不同的错误原因采取不同的行为:如果 File::open
因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open
因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望 panic!
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error);
}
},
};
}
unwrap
和 expect
match
能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E>
类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap
,它的实现就类似于下面的 match
语句。如果 Result
值是成员 Ok
,unwrap
会返回 Ok
中的值。如果 Result
是成员 Err
,unwrap
会为我们调用 panic!
。这里是一个实践 unwrap
的例子:
1
2
3
4
5
6
7
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
1
2
3
fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}
如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap
调用 panic!
时提供的错误信息:
1
2
3
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:4:49
还有另一个类似于 unwrap
的方法它还允许我们选择 panic!
的错误信息:expect
。使用 expect
而不是 unwrap
并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect
的语法看起来像这样:
1
2
3
4
5
6
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}
expect
与 unwrap
的使用方式一样:返回文件句柄或调用 panic!
宏。expect
在调用 panic!
时使用的错误信息将是我们传递给 expect
的参数,而不像 unwrap
那样使用默认的 panic!
信息。它看起来像这样:
1
2
3
thread 'main' panicked at 'hello.txt should be included in this project: Error
{ repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4
传播错误
当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为 传播(propagating)错误。
例如,下面展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
?
运算符
这个函数可以编写成更加简短的形式, Rust 提供了 ?
问号运算符来表示错误传播:
1
2
3
4
5
6
7
8
9
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
如果 Result
的值是 Ok
,这个带?
的表达式将会返回 Ok
中的值而程序将继续执行。如果值是 Err
,Err
中的值将作为整个函数的返回值,就好像使用了 return
关键字一样,这样错误值就被传播给了调用者。
match
表达式与 ?
运算符所做的有一点不同:?
运算符所使用的错误值被传递给了 from
函数,它定义于标准库的 From
trait 中,其用来将错误从一种类型转换为另一种类型。当 ?
运算符调用 from
函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。
我们甚至可以在 ?
之后直接使用链式方法调用来进一步缩短代码:
1
2
3
4
5
6
7
8
9
10
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}
泛型、Trait和生命周期
泛型
在函数中使用泛型
函数 largest
有泛型类型 T
。它有个参数 list
,其类型是元素为 T
的 slice。largest
函数会返回一个与 T
相同类型的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}
如果现在就编译这个代码,会出现如下错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
这个错误表明 largest
的函数体不能适用于 T
的所有可能的类型。因为在函数体需要比较 T
类型的值,不过它只能用于我们知道如何排序的类型。为了开启比较功能,标准库中定义的 std::cmp::PartialOrd
trait 可以实现类型的比较功能。依照帮助说明中的建议,我们限制 T
只对实现了 PartialOrd
的类型有效后代码就可以编译了,因为标准库为 i32
和 char
实现了 PartialOrd
。
在结构体中使用泛型
1
2
3
4
5
6
7
8
9
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
注意 Point<T>
的定义中只使用了一个泛型类型,这个定义表明结构体 Point<T>
对于一些类型 T
是泛型的,而且字段 x
和 y
都是相同类型的,无论它具体是何类型。
如果想要定义一个 x
和 y
可以有不同类型且仍然是泛型的 Point
结构体,我们可以使用多个泛型类型参数。在示例 10-8 中,我们修改 Point
的定义为拥有两个泛型类型 T
和 U
。其中字段 x
是 T
类型的,而字段 y
是 U
类型的:
1
2
3
4
5
6
7
8
9
10
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
在枚举中使用泛型
和结构体类似,枚举也可以在成员中存放泛型数据类型。第六章我们曾用过标准库提供的 Option<T>
枚举,这里再回顾一下:
1
2
3
4
enum Option<T> {
Some(T),
None,
}
现在这个定义应该更容易理解了。如你所见 Option<T>
是一个拥有泛型 T
的枚举,它有两个成员:Some
,它存放了一个类型 T
的值,和不存在任何值的None
。通过 Option<T>
枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T>
是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。
枚举也可以拥有多个泛型类型。第九章使用过的 Result
枚举定义就是一个这样的例子:
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}
Result
枚举有两个泛型类型,T
和 E
。Result
有两个成员:Ok
,它存放一个类型 T
的值,而 Err
则存放一个类型 E
的值。这个定义使得 Result
枚举能很方便的表达任何可能成功(返回 T
类型的值)也可能失败(返回 E
类型的值)的操作。实际上,这就是我们在示例 9-3 用来打开文件的方式:当成功打开文件的时候,T
对应的是 std::fs::File
类型;而当打开文件出现问题时,E
的值则是 std::io::Error
类型。
当你意识到代码中定义了多个结构体或枚举,它们不一样的地方只是其中的值的类型的时候,不妨通过泛型类型来避免重复。
在方法中使用泛型
在为结构体和枚举实现方法时,一样也可以用泛型,注意必须在 impl
后面声明 T
,这样就可以在 Point<T>
上实现的方法中使用 T
了。:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
定义方法时也可以为泛型指定限制(constraint)。例如,可以选择为 Point<f32>
实例实现方法,而不是为泛型 Point
实例,这段代码意味着 Point<f32>
类型会有一个方法 distance_from_origin
,而其他 T
不是 f32
类型的 Point<T>
实例则没有定义此方法:
1
2
3
4
5
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。这个方法用 self
的 Point
类型的 x
值(类型 X1
)和参数的 Point
类型的 y
值(类型 Y2
)来创建一个新 Point
类型的实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
示例 10-11:方法使用了与结构体定义中不同类型的泛型
泛型代码的性能
泛型并不会使程序比具体类型运行得慢。Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
让我们看看这如何用于标准库中的 Option
枚举:
1
2
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一个对应 i32
另一个对应 f64
。为此,它会将泛型定义 Option<T>
展开为两个针对 i32
和 f64
的定义,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样(编译器会使用不同于如下假想的名字):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。
我们想要创建一个名为 aggregator
的多媒体聚合库用来显示可能储存在 NewsArticle
或 Tweet
实例中的数据摘要。为了实现功能,每个结构体都要能够获取摘要,这样的话就可以调用实例的 summarize
方法来请求摘要。
1
2
3
pub trait Summary {
fn summarize(&self) -> String;
}
实现trait
现在我们定义了 Summary
trait 的签名,接着就可以在多媒体聚合库中实现这个类型了。示例中展示了 NewsArticle
结构体上 Summary
trait 的一个实现,它使用标题、作者和创建的位置作为 summarize
的返回值。对于 Tweet
结构体,我们选择将 summarize
定义为用户名后跟推文的全部文本作为返回值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
现在库在 NewsArticle
和 Tweet
上实现了Summary
trait,crate 的用户可以像调用常规方法一样调用 NewsArticle
和 Tweet
实例的 trait 方法了。唯一的区别是 trait 必须和类型一起引入作用域以便使用额外的 trait 方法。这是一个二进制 crate 如何利用 aggregator
库 crate 的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
use aggregator::{Summary, Tweet};
fn main() {
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
}
这会打印出 1 new tweet: horse_ebooks: of course, as you probably already know, people
。
实现 trait 时需要注意的一个限制是,只有当至少一个 trait 或者要实现 trait 的类型位于 crate 的本地作用域时,才能为该类型实现 trait。例如,可以为 aggregator
crate 的自定义类型 Tweet
实现如标准库中的 Display
trait,这是因为 Tweet
类型位于 aggregator
crate 本地的作用域中。类似地,也可以在 aggregator
crate 中为 Vec<T>
实现 Summary
,这是因为 Summary
trait 位于 aggregator
crate 本地作用域中。
不能为外部类型实现外部 trait。例如,不能在 aggregator
crate 中为 Vec<T>
实现 Display
trait。这是因为 Display
和 Vec<T>
都定义于标准库中,它们并不位于 aggregator
crate 本地作用域中。这个限制是被称为 相干性(coherence)的程序属性的一部分,或者更具体的说是 孤儿规则(orphan rule),其得名于不存在父类型。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个 crate 可以分别对相同类型实现相同的 trait,而 Rust 将无从得知应该使用哪一个实现。
trait默认实现
示例中我们为 Summary
trait 的 summarize
方法指定一个默认的字符串值,而不是只定义方法签名:
1
2
3
4
5
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
如果想要对 NewsArticle
实例使用这个默认实现,可以通过 impl Summary for NewsArticle {}
指定一个空的 impl
块。
默认实现允许调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。如此,trait 可以提供很多有用的功能而只需要实现指定一小部分内容。例如,我们可以定义 Summary
trait,使其具有一个需要实现的 summarize_author
方法,然后定义一个 summarize
方法,此方法的默认实现调用 summarize_author
方法:
1
2
3
4
5
6
7
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
为了使用这个版本的 Summary
,只需在实现 trait 时定义 summarize_author
即可:
1
2
3
4
5
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
一旦定义了 summarize_author
,我们就可以对 Tweet
结构体的实例调用 summarize
了,而 summarize
的默认实现会调用我们提供的 summarize_author
定义。因为实现了 summarize_author
,Summary
trait 就提供了 summarize
方法的功能,且无需编写更多的代码。
1
2
3
4
5
6
7
8
9
10
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
这会打印出 1 new tweet: (Read more from @horse_ebooks...)
。
impl Trait
语法使用
impl Trait
语法,使得参数支持任何实现了指定 trait 的类型。示例中,我们可以传递任何NewsArticle
或Tweet
的实例来调用notify
,在notify
函数体中,可以调用任何来自Summary
trait 的方法,比如summarize
。任何用其它如String
或i32
的类型调用该函数的代码都不能编译,因为它们没有实现Summary
:1 2 3
pub fn notify(item: &impl Summary) { println!("Breaking news! {}", item.summarize()); }
impl Trait
语法适用于直观的例子,它实际上是一种较长形式语法的语法糖。我们称为 trait bound,它看起来像:1 2 3
pub fn notify<T: Summary>(item: &T) { println!("Breaking news! {}", item.summarize()); }
这与之前的例子相同,不过稍微冗长了一些。trait bound 与泛型参数声明在一起,位于尖括号中的冒号后面。
然而,使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。为此,Rust 有另一个在函数签名之后的
where
从句中指定 trait bound 的语法。所以除了这么写:1
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
还可以像这样使用
where
从句:1 2 3 4 5
fn some_function<T, U>(t: &T, u: &U) -> i32 where T: Display + Clone, U: Clone + Debug, {
这个函数签名就显得不那么杂乱,函数名、参数列表和返回值类型都离得很近,看起来跟没有那么多 trait bounds 的函数很像。
也可以在返回值中使用
impl Trait
语法,来返回实现了某个 trait 的类型:1 2 3 4 5 6 7 8 9 10
fn returns_summarizable() -> impl Summary { Tweet { username: String::from("horse_ebooks"), content: String::from( "of course, as you probably already know, people", ), reply: false, retweet: false, } }
通过使用
impl Summary
作为返回值类型,我们指定了returns_summarizable
函数返回某个实现了Summary
trait 的类型,但是不确定其具体的类型。在这个例子中returns_summarizable
返回了一个Tweet
,不过调用方并不知情。通过使用带有 trait bound 的泛型参数的
impl
块,可以有条件地只为那些实现了特定 trait 的类型实现方法。例如,下面代码中的类型Pair<T>
总是实现了new
方法并返回一个Pair<T>
的实例。不过在下一个impl
块中,只有那些为T
类型实现了PartialOrd
trait(来允许比较) 和Display
trait(来启用打印)的Pair<T>
才会实现cmp_display
方法:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
use std::fmt::Display; struct Pair<T> { x: T, y: T, } impl<T> Pair<T> { fn new(x: T, y: T) -> Self { Self { x, y } } } impl<T: Display + PartialOrd> Pair<T> { fn cmp_display(&self) { if self.x >= self.y { println!("The largest member is x = {}", self.x); } else { println!("The largest member is y = {}", self.y); } } }
也可以对任何实现了特定 trait 的类型有条件地实现 trait。对任何满足特定 trait bound 的类型实现 trait 被称为 blanket implementations,它们被广泛的用于 Rust 标准库中。例如,标准库为任何实现了
Display
trait 的类型实现了ToString
trait。这个impl
块看起来像这样:1 2 3
impl<T: Display> ToString for T { // --snip-- }
生命周期🧐
生命周期是另一类我们已经使用过的泛型。不同于确保类型有期望的行为,生命周期确保引用如预期一直有效。生命周期的主要目标是避免悬垂引用。考虑下面的程序,它有一个外部作用域和一个内部作用域:
1
2
3
4
5
6
7
8
9
10
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
外部作用域声明了一个没有初值的变量 r
,而内部作用域声明了一个初值为 5 的变量x
。在内部作用域中,我们尝试将 r
的值设置为一个 x
的引用。接着在内部作用域结束后,尝试打印出 r
的值。这段代码不能编译因为 r
引用的值在尝试使用之前就离开了作用域。如下是错误信息:
1
2
3
4
5
6
7
8
9
10
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {}", r);
| - borrow later used here
变量 x
并没有 “存在的足够久”。其原因是 x
在到达第 7 行内部作用域结束时就离开了作用域。不过 r
在外部作用域仍是有效的;作用域越大我们就说它 “存在的越久”。如果 Rust 允许这段代码工作,r
将会引用在 x
离开作用域时被释放的内存,这时尝试对 r
做任何操作都不能正常工作。那么 Rust 是如何决定这段代码是不被允许的呢?这得益于借用检查器。
借用检查器(borrow checker)
Rust 编译器有一个 借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。
1 2 3 4 5 6 7 8 9 10
fn main() { let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {}", r); // | } // ---------+
这里将
r
的生命周期标记为'a
并将x
的生命周期标记为'b
。如你所见,内部的'b
块要比外部的生命周期'a
小得多。在编译时,Rust 比较这两个生命周期的大小,并发现r
拥有生命周期'a
,不过它引用了一个拥有生命周期'b
的对象。程序被拒绝编译,因为生命周期'b
比生命周期'a
要小:被引用的对象比它的引用者存在的时间更短,导致悬垂指针。让我们看看下面这个并没有产生悬垂引用且可以正确编译的例子:
1 2 3 4 5 6 7 8
fn main() { let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {}", r); // | | // --+ | } // ----------+
这里
x
拥有生命周期'b
,比'a
要大。这就意味着r
可以引用x
:Rust 知道r
中的引用在x
有效的时候也总是有效的,因为数据比引用有着更长的生命周期。
函数中的泛型生命周期
我们实现一个 longest
函数,它返回两个字符串 slice 中较长者,现在还不能编译:
1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
会出现如下有关生命周期的错误:
1
2
3
4
5
6
7
8
9
10
11
12
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ 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
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
提示文本揭示了返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向 x
或 y
。借用检查器自身同样也无法确定,因为它不知道 x
和 y
的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。
生命周期注解语法
生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。
生命周期参数名称必须以撇号('
)开头,其名称通常全是小写。生命周期参数注解位于引用的 &
之后,并有一个空格来将引用类型与生命周期注解分隔开。
单个的生命周期注解本身没有多少意义,因为生命周期注解告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。让我们在 longest
函数的上下文中理解生命周期注解如何相互联系。
为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期(lifetime)参数,就像泛型类型(type)参数一样。
我们希望函数签名表达如下限制:也就是这两个参数和返回的引用存活的一样久,两个参数和返回的引用的生命周期是相关的,就像下面的函数签名在每个引用中都加上了 'a
那样,这段代码能够编译并会产生我们希望得到的结果:
1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
现在函数签名表明对于某些生命周期 'a
,函数会获取两个参数,它们都是与生命周期 'a
存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a
存在的一样长的字符串 slice。
当具体的引用被传递给 longest
时,被 'a
所替代的具体生命周期是 x
的作用域与 y
的作用域相重叠的那一部分。换一种说法就是泛型生命周期 'a
的具体生命周期等同于 x
和 y
的生命周期中较小的那一个。
让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest
函数的使用。下面是一个很直观的例子:
1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
}
在这个例子中,string1
直到外部作用域结束都是有效的,string2
则在内部作用域中是有效的,而 result
则引用了一些直到内部作用域结束都是有效的值。借用检查器认可这些代码;它能够编译和运行,并打印出 The longest string is long string is long
。
接下来,让我们尝试另外一个例子,该例子揭示了 result
的引用的生命周期必须是两个参数中较短的那个。以下代码将 result
变量的声明移动出内部作用域,但是将 result
和 string2
变量的赋值语句一同留在内部作用域中。接着,使用了变量 result
的 println!
也被移动到内部作用域之外。注意下面的代码不能通过编译:
1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}
结构体定义中的生命周期注解
目前为止,我们定义的结构体全都包含拥有所有权的类型。也可以定义包含引用的结构体,不过这需要为结构体定义中的每一个引用添加生命周期注解。下面有一个存放了一个字符串 slice 的结构体 ImportantExcerpt
:
1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}
这个结构体有唯一一个字段 part
,它存放了一个字符串 slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个注解意味着 ImportantExcerpt
的实例不能比其 part
字段中的引用存在的更久。
这里的 main
函数创建了一个 ImportantExcerpt
的实例,它存放了变量 novel
所拥有的 String
的第一个句子的引用。novel
的数据在 ImportantExcerpt
实例创建之前就存在。另外,直到 ImportantExcerpt
离开作用域之后 novel
都不会离开作用域,所以 ImportantExcerpt
实例中的引用是有效的。
生命周期省略(Lifetime Elision)
函数或方法的参数的生命周期被称为 输入生命周期(input lifetimes),而返回值的生命周期被称为 输出生命周期(output lifetimes)。编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于 fn
定义,以及 impl
块。
1
2
3
4
5
1. 第一条规则是编译器为每一个引用参数都分配一个生命周期参数。换句话说就是,函数有一个引用参数的就有一个生命周期参数:`fn foo<'a>(x: &'a i32)`,有两个引用参数的函数就有两个不同的生命周期参数,`fn foo<'a, 'b>(x: &'a i32, y: &'b i32)`,依此类推。
2. 第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:`fn foo<'a>(x: &'a i32) -> &'a i32`。
3. 第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 `&self` 或 `&mut self`,说明是个对象的方法 ,那么所有输出生命周期参数被赋予 `self` 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。
方法中定义的生命周期注解
实现方法时结构体字段的生命周期必须总是在 impl
关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。
impl
块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解。让我们看看一些使用示例 10-24 中定义的结构体 ImportantExcerpt
的例子。
首先,这里有一个方法 level
。其唯一的参数是 self
的引用,而且返回值只是一个 i32
,并不引用任何值:
1
2
3
4
5
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl
之后和类型名称之后的生命周期参数是必要的,不过因为第一条生命周期规则我们并不必须标注 self
引用的生命周期。
静态生命周期
这里有一种特殊的生命周期值得讨论:'static
,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static
生命周期,我们也可以选择像下面这样标注出来:
1
let s: &'static str = "I have a static lifetime.";
这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static
的。
总结
让我们简要的看一下在同一函数中指定泛型类型参数、trait bounds 和生命周期的语法!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
这个是示例 10-21 中那个返回两个字符串 slice 中较长者的 longest
函数,不过带有一个额外的参数 ann
。ann
的类型是泛型 T
,它可以被放入任何实现了 where
从句中指定的 Display
trait 的类型。这个额外的参数会使用 {}
打印,这也就是为什么 Display
trait bound 是必须的。因为生命周期也是泛型,所以生命周期参数 'a
和泛型类型参数 T
都位于函数名后的同一尖括号列表中。
智能指针
智能指针(smart pointers)是一类数据结构,它们的表现类似指针,但是也拥有额外的元数据和功能。在 Rust 中因为引用和借用,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反,在大部分情况下,智能指针拥有它们指向的数据。
Rc<T>
允许相同数据有多个所有者;Box<T>
和RefCell<T>
有单一所有者。Box<T>
允许在编译时执行不可变或可变借用检查;Rc<T>
仅允许在编译时执行不可变借用检查;RefCell<T>
允许在运行时执行不可变或可变借用检查。- 因为
RefCell<T>
允许在运行时执行可变借用检查,所以我们可以在即便RefCell<T>
自身是不可变的情况下修改其内部的值。
Box<T>
最简单直接的智能指针是 box,其类型是 Box<T>
。box 允许你将一个值放在堆上而不是栈上,留在栈上的则是指向堆数据的指针。Box<T>
类型是一个智能指针,因为它实现了 Deref
trait,它允许 Box<T>
值被当作引用对待。当 Box<T>
值离开作用域时,由于 Box<T>
类型 Drop
trait 的实现,box 所指向的堆数据也会被清除。
box 只提供了间接存储和堆分配,并没有任何其他特殊的功能。box 没有性能损失,它多用于如下场景:
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
- 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
- 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候
使用Box创建递归类型
递归类型(recursive type)的值可以拥有另一个同类型的值作为其自身的一部分。但是这会产生一个问题,因为 Rust 需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限地进行下去,所以 Rust 不知道递归类型需要多少空间。因为 box 有一个已知的大小,所以通过在循环类型定义中插入 box,就可以创建递归类型了。
cons list 是一个来源于 Lisp 编程语言及其方言的数据结构,它由嵌套的列表组成。例如这里有一个包含列表 1,2,3 的 cons list 的伪代码表示,其每一个列表在一个括号中:
1
(1, (2, (3, Nil)))
cons list 的每一项都包含两个元素:当前项的值和下一项。其最后一项值包含一个叫做 Nil
的值且没有下一项。cons list 通过递归调用 cons
函数产生。代表递归的终止条件(base case)的规范名称是 Nil
,它宣布列表的终止。
尝试定义一个代表 i32
值的 cons list 数据结构的枚举:
1
2
3
4
enum List {
Cons(i32, List),
Nil,
}
使用这个 cons list 来储存列表 1, 2, 3
将看起来如下所示:
1
2
3
4
5
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
但如果尝试编译上面的代码,会得到如下面所示的错误:
1
2
3
4
5
6
7
8
9
10
11
12
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ recursive type has infinite size
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
|
2 | Cons(i32, Box<List>),
| ++++ +
这个错误表明这个类型 “有无限的大小”。其原因是 List
的一个成员被定义为是递归的:它直接存放了另一个相同类型的值。这意味着 Rust 无法计算为了存放 List
值到底需要多少空间。
我们可以使用Box<T>
修改代码,这是可以编译的:
1
2
3
4
5
6
7
8
9
10
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Deref
trait
实现 Deref
trait 允许我们重载 解引用运算符(dereference operator)*
。通过这种方式实现 Deref
trait 的智能指针可以被当作常规引用来对待,可以编写操作引用的代码并用于智能指针。
1
2
3
4
5
6
7
8
9
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
type Target = T;
语法定义了用于此 trait 的关联类型。关联类型是一个稍有不同的定义泛型参数的方式,现在还无需过多的担心它;第十九章会详细介绍。
deref
方法体中写入了 &self.0
,这样 deref
返回了我希望通过 *
运算符访问的值的引用。没有 Deref
trait 的话,编译器只会解引用 &
引用类型。deref
方法向编译器提供了获取任何实现了 Deref
trait 的类型的值,并且调用这个类型的 deref
方法来获取一个它知道如何解引用的 &
引用的能力。
当我们在示例中输入 *y
时,Rust 事实上在底层运行了如下代码:
1
*(y.deref())
Rust 将 *
运算符替换为先调用 deref
方法再进行普通解引用的操作,如此我们便不用担心是否还需手动调用 deref
方法了。Rust 的这个特性可以让我们写出行为一致的代码,无论是面对的是常规引用还是实现了 Deref
的类型。
隐式 Deref 强制转换
Deref 强制转换(deref coercions)将实现了 Deref
trait 的类型的引用转换为另一种类型的引用。例如,可以将 &String
转换为 &str
,因为 String
实现了 Deref
trait 因此可以返回 &str
。
如果 Rust 没有实现 Deref 强制转换,为了使用 &MyBox<String>
类型的值调用 hello
,则必须编写如下的代码:
1
2
3
4
5
6
7
8
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&(*m)[..]);
}
当所涉及到的类型定义了 Deref
trait,Rust 会分析这些类型并使用任意多次 Deref::deref
调用以获得匹配参数的类型。这些解析都发生在编译时,所以利用 Deref 强制转换并没有运行时损耗!
类似于如何使用 Deref
trait 重载不可变引用的 *
运算符,Rust 提供了 DerefMut
trait 用于重载可变引用的 *
运算符。
Rust 在发现类型和 trait 实现满足三种情况时会进行 Deref 强制转换:
- 当
T: Deref<Target=U>
时从&T
到&U
。 - 当
T: DerefMut<Target=U>
时从&mut T
到&mut U
。 - 当
T: Deref<Target=U>
时从&mut T
到&U
。
头两个情况除了第二种实现了可变性之外是相同的:第一种情况表明如果有一个 &T
,而 T
实现了返回 U
类型的 Deref
,则可以直接得到 &U
。第二种情况表明对于可变引用也有着相同的行为。
第三个情况有些微妙:Rust 也会将可变引用强转为不可变引用,但是反之是不可能的:不可变引用永远也不能强转为可变引用。因为根据借用规则,如果有一个可变引用,其必须是这些数据的唯一引用。将一个可变引用转换为不可变引用永远也不会打破借用规则。将不可变引用转换为可变引用则需要初始的不可变引用是数据唯一的不可变引用,而借用规则无法保证这一点。因此,Rust 无法假设将不可变引用转换为可变引用是可能的。
Rc<T>
引用计数智能指针
为了启用多所有权需要显式地使用 Rust 类型 Rc<T>
,其为 引用计数(reference counting)的缩写。引用计数意味着记录一个值的引用数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。
Rc<T>
用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。注意 Rc<T>
只能用于单线程场景。
让我们回到使用 Box<T>
定义 cons list 的例子。这一次,我们希望创建两个共享第三个列表所有权的列表,其概念将会看起来如图所示:
尝试使用 Box<T>
定义的 List
实现并不能工作:
1
2
3
4
5
6
7
8
9
10
11
12
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));
}
编译会得出如下错误,Cons 成员拥有其储存的数据,所以当创建 b 列表时,a 被移动进了 b,这样 b 就拥有了 a。接着当再次尝试使用 a 创建 c 时,这不被允许,因为 a 的所有权已经被移动:
1
2
3
4
5
6
7
8
9
10
error[E0382]: use of moved value: `a`
--> src/main.rs:11:30
|
9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 | let b = Cons(3, Box::new(a));
| - value moved here
11 | let c = Cons(4, Box::new(a));
| ^ value used here after move
我们修改 List
的定义为使用 Rc<T>
代替 Box<T>
,现在每一个 Cons
变量都包含一个值和一个指向 List
的 Rc<T>
。当创建 b
时,不同于获取 a
的所有权,这里会克隆 a
所包含的 Rc<List>
,这会将引用计数从 1 增加到 2 并允许 a
和 b
共享 Rc<List>
中数据的所有权。创建 c
时也会克隆 a
,这会将引用计数从 2 增加为 3。每次调用 Rc::clone
,Rc<List>
中数据的引用计数都会增加,直到有零个引用之前其数据都不会被清理。Rc::clone
的实现并不像大部分类型的 clone
实现那样对所有数据进行深拷贝, 只会增加引用计数,这并不会花费多少时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}
通过不可变引用, Rc<T>
允许在程序的多个部分之间只读地共享数据。如果 Rc<T>
也允许多个可变引用,则会违反第四章讨论的借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。不过可以修改数据是非常有用的!在下一部分,我们将讨论内部可变性模式和 RefCell<T>
类型,它可以与 Rc<T>
结合使用来处理不可变性的限制。
RefCell<T>
和内部可变性模式 ❗
内部可变性(Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用 unsafe
代码来模糊 Rust 通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。
不同于 Rc<T>
,RefCell<T>
代表其数据的唯一的所有权。那么是什么让 RefCell<T>
不同于像 Box<T>
这样的类型呢?回忆一下第四章所学的借用规则:
- 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用 之一(而不是两者)。
- 引用必须总是有效的。
对于引用和 Box<T>
,借用规则的不可变性作用于编译时。对于 RefCell<T>
,这些不可变性作用于 运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于 RefCell<T>
,如果违反这些规则程序会 panic 并退出。
下面的代码定义了一个 MockMessenger
结构体,其 sent_messages
字段为一个 String
值的 Vec
用来记录被告知发送的消息。我们还定义了一个关联函数 new
以便于新建从空消息列表开始的 MockMessenger
值。接着为 MockMessenger
实现 Messenger
trait 这样就可以为 LimitTracker
提供一个 MockMessenger
。在 send
方法的定义中,获取传入的消息作为参数并储存在 MockMessenger
的 sent_messages
列表中。
在测试中,我们测试了当 LimitTracker
被告知将 value
设置为超过 max
值 75% 的某个值。首先新建一个 MockMessenger
,其从空消息列表开始。接着新建一个 LimitTracker
并传递新建 MockMessenger
的引用和 max
值 100。我们使用值 80 调用 LimitTracker
的 set_value
方法,这超过了 100 的 75%。接着断言 MockMessenger
中记录的消息列表应该有一条消息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
然而,这个测试是有问题的:
1
2
3
4
5
6
7
8
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
2 | fn send(&self, msg: &str);
| ----- help: consider changing that to be a mutable reference: `&mut self`
...
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
不能修改 MockMessenger
来记录消息,因为 send
方法获取了 self
的不可变引用。我们也不能参考错误文本的建议使用 &mut self
替代,因为这样 send
的签名就不符合 Messenger
trait 定义中的签名了。
这正是内部可变性的用武之地!我们将通过 RefCell
来储存 sent_messages
,然后 send
将能够修改 sent_messages
并储存消息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
// --snip--
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
现在 sent_messages
字段的类型是 RefCell<Vec<String>>
而不是 Vec<String>
。在 new
函数中新建了一个 RefCell<Vec<String>>
实例替代空 vector。
对于 send
方法的实现,第一个参数仍为 self
的不可变借用,这是符合方法定义的。我们调用 self.sent_messages
中 RefCell
的 borrow_mut
方法来获取 RefCell
中值的可变引用,这是一个 vector。接着可以对 vector 的可变引用调用 push
以便记录测试过程中看到的消息。
最后必须做出的修改位于断言中:为了看到其内部 vector 中有多少个项,需要调用 RefCell
的 borrow
以获取 vector 的不可变引用。
RefCell
在运行时记录借用
现在我们见识了如何使用 RefCell<T>
,让我们研究一下它怎样工作的!
当创建不可变和可变引用时,我们分别使用 &
和 &mut
语法。对于 RefCell<T>
来说,则是 borrow
和 borrow_mut
方法,这属于 RefCell<T>
安全 API 的一部分。borrow
方法返回 Ref<T>
类型的智能指针,borrow_mut
方法返回 RefMut<T>
类型的智能指针。这两个类型都实现了 Deref
,所以可以当作常规引用对待。
如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T>
的实现会在运行时出现 panic。这里我们故意尝试在相同作用域创建两个可变借用以便演示 RefCell<T>
不允许我们在运行时这么做:
1
2
3
4
5
6
7
8
9
10
11
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
____________________________________________________________________
panic !
结合 Rc
和 RefCell
RefCell<T>
的一个常见用法是与 Rc<T>
结合。回忆一下 Rc<T>
允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T>
的 Rc<T>
的话,就可以得到有多个所有者并且可以修改的值了!
例如,回忆cons list 的例子中使用 Rc<T>
使得多个列表共享另一个列表的所有权。因为 Rc<T>
只存放不可变值,所以一旦创建了这些列表值后就不能修改。让我们加入 RefCell<T>
来获得修改列表中值的能力。下面展示了通过在 Cons
定义中使用 RefCell<T>
,我们就允许修改所有列表中的值了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {:?}", a);
println!("b after = {:?}", b);
println!("c after = {:?}", c);
}
当我们打印出 a
、b
和 c
时,可以看到它们都拥有修改后的值 15 而不是 5:
1
2
3
4
5
6
7
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
引用循环与内存泄漏
Rust 的内存安全性保证使其难以意外地制造永远也不会被清理的内存(被称为 内存泄漏(memory leak)),但并不是不可能。Rust 并不保证完全防止内存泄漏,这意味着内存泄漏在 Rust 中被认为是内存安全的。这一点可以通过 Rc<T>
和 RefCell<T>
看出:创建引用循环的可能性是存在的。这会造成内存泄漏,因为每一项的引用计数永远也到不了 0,持有的数据也就永远不会被释放。
让我们看看引用循环是如何发生的以及如何避免它。以示例 15-25 中的 List
枚举和 tail
方法的定义开始:
文件名:src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {}
示例 15-25: 一个存放 RefCell
的 cons list 定义,这样可以修改 Cons
成员所引用的数据
这里采用了示例 15-5 中 List
定义的另一种变体。现在 Cons
成员的第二个元素是 RefCell<Rc<List>>
,这意味着不同于像示例 15-24 那样能够修改 i32
的值,我们希望能够修改 Cons
成员所指向的 List
。这里还增加了一个 tail
方法来方便我们在有 Cons
成员的时候访问其第二项。
在示例 15-26 中增加了一个 main
函数,其使用了示例 15-25 中的定义。这些代码在 a
中创建了一个列表,一个指向 a
中列表的 b
列表,接着修改 a
中的列表指向 b
中的列表,这会创建一个引用循环。在这个过程的多个位置有 println!
语句展示引用计数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack
// println!("a next item = {:?}", a.tail());
}
可以看到将列表 a
修改为指向 b
之后, a
和 b
中的 Rc<List>
实例的引用计数都是 2。在 main
的结尾,Rust 丢弃 b
,这会使 b
Rc<List>
实例的引用计数从 2 减为 1。然而,b
Rc<List>
不能被回收,因为其引用计数是 1 而不是 0。接下来 Rust 会丢弃 a
将 a
Rc<List>
实例的引用计数从 2 减为 1。这个实例也不能被回收,因为 b
Rc<List>
实例依然引用它,所以其引用计数是 1。这些列表的内存将永远保持未被回收的状态。为了更形象的展示,我们创建了一个如图所示的引用循环:
避免引用循环:将 Rc<T>
变为 Weak<T>
到目前为止,我们已经展示了调用 Rc::clone 会增加Rc<T>
实例的 strong_count,和只在其 strong_count 为 0 时才会被清理的 Rc<T>
实例。你也可以通过调用 Rc::downgrade 并传递 Rc<T>
实例的引用来创建其值的 弱引用(weak reference)。强引用代表如何共享 Rc<T>
实例的所有权。弱引用并不属于所有权关系,当 Rc<T>
实例被清理时其计数没有影响。它们不会造成引用循环,因为任何涉及弱引用的循环会在其相关的值的强引用计数为 0 时被打断。
调用 Rc::downgrade 时会得到 Weak<T>
类型的智能指针。不同于将 Rc<T>
实例的 strong_count 加 1,调用 Rc::downgrade 会将 weak_count 加 1。Rc<T>
类型使用 weak_count 来记录其存在多少个 Weak<T>
引用,类似于 strong_count。其区别在于 weak_count 无需计数为 0 就能使 Rc<T>
实例被清理。
因为 Weak<T>
引用的值可能已经被丢弃了,为了使用 Weak<T>
所指向的值,我们必须确保其值仍然有效。为此可以调用 Weak<T>
实例的 upgrade
方法,这会返回 Option<Rc<T>>
。如果 Rc<T>
值还未被丢弃,则结果是 Some
;如果 Rc<T>
已被丢弃,则结果是 None
。因为 upgrade
返回一个 Option<Rc<T>>
,Rust 会确保处理 Some
和 None
的情况,所以它不会返回非法指针。
下面的示例构建一个带有子节点的树。为了使子节点知道其父节点,需要在 Node
结构体定义中增加一个 parent
字段。问题是 parent
的类型应该是什么。我们知道其不能包含 Rc<T>
,因为这样 leaf.parent
将会指向 branch
而 branch.children
会包含 leaf
的指针,这会形成引用循环,会造成其 strong_count
永远也不会为 0。
现在换一种方式思考这个关系,父节点应该拥有其子节点:如果父节点被丢弃了,其子节点也应该被丢弃。然而子节点不应该拥有其父节点:如果丢弃子节点,其父节点应该依然存在。这正是弱引用的例子!
所以 parent
使用 Weak<T>
类型而不是 Rc<T>
,具体来说是 RefCell<Weak<Node>>
。现在 Node
结构体定义看起来像这样:
1
2
3
4
5
6
7
8
9
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
这样,一个节点就能够引用其父节点,但不拥有其父节点。在示例中,我们更新 main
来使用新定义以便 leaf
节点可以通过 branch
引用其父节点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
闭包和迭代器
闭包
Rust 的 闭包(closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。我们将展示闭包的这些功能如何复用代码和自定义行为。
1
2
3
4
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
编译器会为闭包定义中的每个参数和返回值推断一个具体类型。注意下面这个定义没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包。但是如果尝试调用闭包两次,第一次使用 String
类型作为参数而第二次使用 u32
,则会得到一个错误:
1
2
3
4
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | expected struct `String`, found integer
| arguments to this function are incorrect
|
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
第一次使用 String
值调用 example_closure
时,编译器推断这个闭包中 x
的类型以及返回值的类型是 String
。接着这些类型被锁定进闭包 example_closure
中,如果尝试对同一闭包使用不同类型则就会得到类型错误。
捕获引用
闭包可以通过三种方式捕获其环境,它们直接对应到函数获取参数的三种方式:不可变借用,可变借用和获取所有权。闭包会根据函数体中如何使用被捕获的值决定用哪种方式捕获。
下面定义了一个捕获名为 list
的 vector 的不可变引用的闭包,因为只需不可变引用就能打印其值:
1
2
3
4
5
6
7
8
9
10
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let only_borrows = || println!("From closure: {:?}", list);
println!("Before calling closure: {:?}", list);
only_borrows();
println!("After calling closure: {:?}", list);
}
这个示例也展示了变量可以绑定一个闭包定义,并且之后可以使用变量名和括号来调用闭包,就像变量名是函数名一样。
因为同时可以有多个 list
的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问 list
。代码可以编译、运行并打印:
1
2
3
4
5
6
7
8
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]
接下来,我们修改闭包体让它向 list
vector 增加一个元素。闭包现在捕获一个可变引用:
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let mut borrows_mutably = || list.push(7);
// println!("After defining closure: {:?}", list); immutable borrow occurs here
borrows_mutably();
println!("After calling closure: {:?}", list);
}
注意在 borrows_mutably
闭包的定义和调用之间不再有 println!
,当 borrows_mutably
定义时,它捕获了 list
的可变引用。闭包在被调用后就不再被使用,这时可变借用结束。因为当可变借用存在时不允许有其它的借用,所以在闭包定义和调用之间不能有不可变引用来进行打印。
移动所有权
即使闭包体不严格需要所有权,如果希望强制闭包获取它用到的环境中值的所有权,可以在参数列表前使用 move
关键字。在将闭包传递到一个新的线程时这个技巧很有用,它可以移动数据所有权给新线程。现在我们先简单探讨用 move
关键字的闭包来生成新的线程,使用 move
来强制闭包为线程获取 list
的所有权:
1
2
3
4
5
6
7
8
9
10
use std::thread;
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
thread::spawn(move || println!("From thread: {:?}", list))
.join()
.unwrap();
}
我们生成了新的线程,给这个线程一个闭包作为参数运行,闭包体打印出列表。尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move
关键字来指明 list
应当被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,或者也可能主线程先执行完。如果主线程维护了 list
的所有权但却在新线程之前结束并且丢弃了 list
,则在线程中的不可变引用将失效。因此,编译器要求 list
被移动到在新线程中运行的闭包中,这样引用就是有效的。
Fn
trait
闭包体可以做以下任何事:将一个捕获的值移出闭包,修改捕获的值,既不移动也不修改值,或者一开始就不从环境中捕获值。
将一个捕获的值移出闭包指的是将闭包捕获的值的所有权从闭包中移出,使其在闭包外部再次可用。
闭包捕获和处理环境中的值的方式影响闭包实现的 trait。Trait 是函数和结构体指定它们能用的闭包的类型的方式。取决于闭包体如何处理值,闭包自动、渐进地实现一个、两个或三个 Fn
trait:
FnOnce
适用于能被调用一次的闭包,所有闭包都至少实现了这个 trait,因为所有闭包都能被调用。一个会将捕获的值移出闭包体的闭包只实现FnOnce
trait,这是因为它只能被调用一次。FnMut
适用于不会将捕获的值移出闭包体的闭包,但它可能会修改被捕获的值。这类闭包可以被调用多次。Fn
适用于既不将被捕获的值移出闭包体也不修改被捕获的值的闭包,当然也包括不从环境中捕获值的闭包。这类闭包可以被调用多次而不改变它们的环境,这在会多次并发调用闭包的场景中十分重要。FnOnce
->FnMut
->Fn
有渐进的依赖关系,例如实现FnOnce
时,Fn
和FnMut
必须实现。
让我们来看看在 Option<T>
上的 unwrap_or_else
方法的定义:
1
2
3
4
5
6
7
8
9
10
11
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
泛型 F
的 trait bound 是 FnOnce() -> T
,这意味着 F
必须能够被调用一次,没有参数并返回一个 T
。在 trait bound 中使用 FnOnce
表示 unwrap_or_else
将最多调用 f
一次。由于所有的闭包都实现了 FnOnce
,unwrap_or_else
能接收绝大多数不同类型的闭包,十分灵活。
注意:函数也可以实现所有的三种
Fn
traits。如果我们要做的事情不需要从环境中捕获值,则可以在需要某种实现了Fn
trait 的东西时使用函数而不是闭包。举个例子,可以在Option<Vec<T>>
的值上调用unwrap_or_else(Vec::new)
以便在值为None
时获取一个新的空的 vector。
现在让我们来看定义在 slice 上的标准库方法 sort_by_key
,看看它与 unwrap_or_else
的区别以及为什么 sort_by_key
使用 FnMut
而不是 FnOnce
trait bound。这个闭包以一个 slice 中当前被考虑的元素的引用作为参数,返回一个可以用来排序的 K
类型的值。当你想按照 slice 中元素的某个属性来进行排序时这个函数很有用。我们使用 sort_by_key
按 Rectangle
的 width
属性对它们从低到高排序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
list.sort_by_key(|r| r.width);
println!("{:#?}", list);
}
sort_by_key
被定义为接收一个 FnMut
闭包的原因是它会多次调用这个闭包:每个 slice 中的元素调用一次。闭包 |r| r.width
不捕获、修改或将任何东西移出它的环境,所以它满足 trait bound 的要求。
作为对比,下面展示了一个只实现了 FnOnce
trait 的闭包(因为它从环境中移出了一个值)的例子。编译器不允许我们在 sort_by_key
上使用这个闭包:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("by key called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{:#?}", list);
}
报错指向了闭包体中将 value
移出环境的那一行。要修复这里,我们需要改变闭包体让它不将值移出环境。在环境中保持一个计数器并在闭包体中增加它的值是计算 sort_by_key
被调用次数的一个更简单直接的方法。下面的闭包可以在 sort_by_key
中使用,因为它只捕获了 num_sort_operations
计数器的可变引用,这就可以被调用多次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut num_sort_operations = 0;
list.sort_by_key(|r| {
num_sort_operations += 1;
r.width
});
println!("{:#?}, sorted in {num_sort_operations} operations", list);
}
并发
线程
Rust 标准库使用 1:1 线程实现,这代表程序的每一个语言级线程使用一个系统线程。
使用 spawn
创建新线程
为了创建一个新线程,需要调用 thread::spawn
函数并传递一个闭包(第十三章学习了闭包),并在其中包含希望在新线程运行的代码。下面的例子在主线程打印了一些文本而另一些文本则由新线程打印:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
注意当 Rust 程序的主线程结束时,新线程也会结束,而不管其是否执行完毕。这个程序的输出可能每次都略有不同,不过它大体上看起来像这样:
1
2
3
4
5
6
7
8
9
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
使用 join
等待所有线程结束
由于主线程结束,上面的代码大部分时候不光会提早结束新建线程,因为无法保证线程运行的顺序,我们甚至不能实际保证新建线程会被执行!
可以通过将 thread::spawn
的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn
的返回值类型是 JoinHandle
。JoinHandle
是一个拥有所有权的值,当对其调用 join
方法时,它会等待其线程结束。下面展示了如何使用创建的线程的 JoinHandle
并调用 join
来确保新建线程在 main
退出前结束运行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
通过调用 handle 的 join
会阻塞当前线程直到 handle 所代表的线程结束。阻塞(Blocking)线程意味着阻止该线程执行工作或退出。因为我们将 join
调用放在了主线程的 for
循环之后,运行应该会产生类似这样的输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
这两个线程仍然会交替执行,不过主线程会由于 handle.join()
调用会等待直到新建线程执行完毕。
将 move
闭包与线程一同使用
move
关键字经常用于传递给 thread::spawn
的闭包,因为闭包会获取从环境中取得的值的所有权,因此会将这些值的所有权从一个线程传送到另一个线程。
注意示例中传递给 thread::spawn
的闭包并没有任何参数:并没有在新建线程代码中使用任何主线程的数据。为了在新建线程中使用来自于主线程的数据,需要新建线程的闭包获取它需要的值。示例展示了一个尝试在主线程中创建一个 vector 并用于新建线程的例子,不过这么写还不能工作,如下所示:
1
2
3
4
5
6
7
8
9
10
11
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
闭包使用了 v
,所以闭包会捕获 v
并使其成为闭包环境的一部分。因为 thread::spawn
在一个新线程中运行这个闭包,所以可以在新线程中访问 v
。然而当编译这个例子时,会得到如下错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {:?}", v);
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {:?}", v);
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
Rust会推断如何捕获 v
,因为 println!
只需要 v
的引用,闭包尝试借用 v
。然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓对 v
的引用是否一直有效。
通过在闭包之前增加 move
关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值。下面的修改,可以按照我们的预期编译并运行:
1
2
3
4
5
6
7
8
9
10
11
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
消息传递
一个日益流行的确保安全并发的方式是 消息传递(message passing),这里线程通过发送包含数据的消息来相互沟通。为了实现消息传递并发,Rust 标准库提供了一个 信道(channel)实现。信道是一个通用编程概念,表示数据从一个线程发送到另一个线程。
这里,我们将开发一个程序,它会在一个线程生成值向信道发送,而在另一个线程会接收值并打印出来。这里会通过信道在线程间发送简单值来演示这个功能。一旦你熟悉了这项技术,你就可以将信道用于任何相互通信的任何线程,例如一个聊天系统,或利用很多线程进行分布式计算并将部分计算结果发送给一个线程进行聚合。
下面我们创建了一个信道:
1
2
3
4
5
6
7
8
9
10
11
12
13
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
这里使用 mpsc::channel
函数创建一个新的信道;mpsc
是 多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,Rust 标准库实现信道的方式意味着一个信道可以有多个产生值的 发送(sending)端,但只能有一个消费这些值的 接收(receiving)端。
mpsc::channel
函数返回一个元组:第一个元素是发送端 – 发送者,而第二个元素是接收端 – 接收者。由于历史原因,tx
和 rx
通常作为 发送者(transmitter)和 接收者(receiver)的缩写,所以这就是我们将用来绑定这两端变量的名字。这里使用了一个 let
语句和模式来解构此元组。
这里使用 thread::spawn
来创建一个新线程并使用 move
将 tx
移动到闭包中这样新建线程就拥有 tx
了。新建线程需要拥有信道的发送端以便能向信道发送消息。信道的发送端有一个 send
方法用来获取需要放入信道的值。send
方法返回一个 Result<T, E>
类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。在这个例子中,出错的时候调用 unwrap
产生 panic。不过对于一个真实程序,需要更合理地处理它。
信道的接收者有两个有用的方法:recv
和 try_recv
。这里,我们使用了 recv
,它是 receive 的缩写。这个方法会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值,recv
会在一个 Result<T, E>
中返回它。当信道发送端关闭,recv
会返回一个错误表明不会再有新的值到来了。
try_recv
不会阻塞,相反它立刻返回一个 Result<T, E>
:Ok
值包含可用的信息,而 Err
值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用 try_recv
很有用:可以编写一个循环来频繁调用 try_recv
,在有可用消息时进行处理,其余时候则处理一会其他工作直到再次检查。
通过克隆发送者来创建多个生产者
之前我们提到了mpsc
是 multiple producer, single consumer 的缩写。可以运用 mpsc
来扩展上面的代码来创建向同一接收者发送值的多个线程。这可以通过克隆发送者来做到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// --snip--
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {}", received);
}
// --snip--
这一次,在创建新线程之前,我们对发送者调用了 clone
方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的信道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向信道的接收端发送不同的消息。
如果运行这些代码,你可能会看到这样的输出:
1
2
3
4
5
6
7
8
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
虽然你可能会看到这些值以不同的顺序出现;这依赖于你的系统。这也就是并发既有趣又困难的原因。如果通过 thread::sleep
做实验,在不同的线程中提供不同的值,就会发现它们的运行更加不确定,且每次都会产生不同的输出。
共享内存
虽然消息传递是一个很好的处理并发的方式,但并不是唯一一个。另一种方式是让多个线程拥有相同的共享数据。在某种程度上,任何编程语言中的信道都类似于单所有权,因为一旦将一个值传送到信道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。第十五章介绍了智能指针如何使得多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。Rust 的类型系统和所有权规则极大的协助了正确地管理这些所有权。作为一个例子,让我们看看互斥器,一个更为常见的共享内存并发原语。
互斥器Mutex<T>
互斥器(mutex)是 mutual exclusion 的缩写,也就是说,任意时刻,其只允许一个线程访问某些数据。为了访问互斥器中的数据,线程首先需要通过获取互斥器的 锁(lock)来表明其希望访问数据。锁是一个作为互斥器一部分的数据结构,它记录谁有数据的排他访问权。因此,我们描述互斥器为通过锁系统 保护(guarding)其数据。
互斥器以难以使用著称,因为你不得不记住:
- 在使用数据之前尝试获取锁。
- 处理完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。
正确的管理互斥器异常复杂,这也是许多人之所以热衷于信道的原因。然而,在 Rust 中,得益于类型系统和所有权,我们不会在锁和解锁上出错。
作为展示如何使用互斥器的例子,让我们从在单线程上下文使用互斥器开始:
1
2
3
4
5
6
7
8
9
10
11
12
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {:?}", m);
}
像很多类型一样,我们使用关联函数 new
来创建一个 Mutex<T>
。使用 lock
方法获取锁,以访问互斥器中的数据。这个调用会阻塞当前线程,直到我们拥有锁为止。
如果另一个线程拥有锁,并且那个线程 panic 了,则 lock
调用会失败。在这种情况下,没人能够再获取锁,所以这里选择 unwrap
并在遇到这种情况时使线程 panic。
一旦获取了锁,就可以将返回值(在这里是num
)视为一个其内部数据的可变引用了。类型系统确保了我们在使用 m
中的值之前获取锁。m
的类型是 Mutex<i32>
而不是 i32
,所以 必须 获取锁才能使用这个 i32
值。我们是不会忘记这么做的,因为反之类型系统不允许访问内部的 i32
值。
正如你所怀疑的,Mutex<T>
是一个智能指针。更准确的说,lock
返回一个叫做 MutexGuard
的智能指针。这个智能指针实现了 Deref
来指向其内部数据;其也提供了一个 Drop
实现当 MutexGuard
离开作用域时自动释放锁,这正发生于示例内部作用域的结尾。为此,我们不会忘记释放锁并阻塞互斥器为其它线程所用的风险,因为锁的释放是自动发生的。丢弃了锁之后,可以打印出互斥器的值,并发现能够将其内部的 i32
改为 6。
在线程间共享 Mutex<T>
现在让我们尝试使用 Mutex<T>
在多个线程间共享值。我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。下面的例子会出现编译错误,而我们将通过这些错误来学习如何使用 Mutex<T>
,以及 Rust 又是如何帮助我们正确使用的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
这里创建了一个 counter
变量来存放内含 i32
的 Mutex<T>
。接下来遍历 range 创建了 10 个线程。使用了 thread::spawn
并对所有线程使用了相同的闭包:它们每一个都将调用 lock
方法来获取 Mutex<T>
上的锁,接着将互斥器中的值加一。当一个线程结束执行,num
会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,我们收集了所有的 join 句柄,调用它们的 join
方法来确保所有线程都会结束。这时,主线程会获取锁并打印出程序的结果。
之前提示过这个例子不能编译,让我们看看为什么!
1
2
3
4
5
6
7
8
9
10
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure
错误信息表明 counter
值在上一次循环中被移动了。所以 Rust 告诉我们不能将 counter
锁的所有权移动到多个线程中,让我们通过一个第十五章讨论过的多所有权手段来修复这个编译错误。
多线程和多所有权
在第十五章中,通过使用智能指针 Rc<T>
来创建引用计数的值,以便拥有多所有者。让我们在这也这么做看看会发生什么。将上面的 Mutex<T>
封装进 Rc<T>
中并在将所有权移入线程之前克隆了 Rc<T>
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
再一次编译并…出现了不同的错误!编译器真是教会了我们很多!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `[closure@src/main.rs:11:36: 11:43]`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
|
= help: within `[closure@src/main.rs:11:36: 11:43]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
哇哦,错误信息太长不看!这里是一些需要注意的重要部分:第一行错误表明 Rc<Mutex<i32>> cannot be sent between threads safely
。编译器也告诉了我们原因 the trait ‘Send’ is not implemented for Rc<Mutex<i32>>
。下一部分会讲到 Send:这是确保所使用的类型可以用于并发环境的 trait 之一。
不幸的是,Rc<T>
并不能安全的在线程间共享。当 Rc<T>
管理引用计数时,它必须在每一个 clone
调用时增加计数,并在每一个克隆被丢弃时减少计数。Rc<T>
并没有使用任何并发原语,来确保改变计数的操作不会被其他线程打断。在计数出错时可能会导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。我们所需要的是一个完全类似 Rc<T>
,又以一种线程安全的方式改变引用计数的类型。
原子引用计数 Arc<T>
所幸 Arc<T>
正是 这么一个类似 Rc<T>
并可以安全的用于并发环境的类型。字母 “a” 代表 原子性(atomic),所以这是一个 原子引用计数(atomically reference counted)类型。原子性是另一类这里还未涉及到的并发原语:请查看标准库中 std::sync::atomic
的文档来获取更多细节。目前我们只需要知道原子类就像基本类型一样可以安全的在线程间共享。
回到之前的例子:Arc<T>
和 Rc<T>
有着相同的 API,所以修改程序中的 use
行和 new
调用。下面的代码最终可以编译和运行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
这会打印出:
1
Result: 10
RefCell
/Rc
与 Mutex
/Arc
你可能注意到了,因为 counter
是不可变的,不过可以获取其内部值的可变引用;这意味着 Mutex<T>
提供了内部可变性,就像 Cell
系列类型那样。正如第十五章中使用 RefCell<T>
可以改变 Rc<T>
中的内容那样,同样的可以使用 Mutex<T>
来改变 Arc<T>
中的内容。
另一个值得注意的细节是 Rust 不能避免使用 Mutex<T>
的全部逻辑错误。回忆一下第十五章使用 Rc<T>
就有造成引用循环的风险,这时两个 Rc<T>
值相互引用,造成内存泄漏。同理,Mutex<T>
也有造成 死锁(deadlock)的风险。这发生于当一个操作需要锁住两个资源而两个线程各持一个锁,这会造成它们永远相互等待。如果你对这个主题感兴趣,尝试编写一个带有死锁的 Rust 程序,接着研究任何其他语言中使用互斥器的死锁规避策略并尝试在 Rust 中实现它们。标准库中 Mutex<T>
和 MutexGuard
的 API 文档会提供有用的信息。
Sync
和 Send
trait
通过 Send
允许在线程间转移所有权
Send
标记 trait 表明实现了 Send
的类型值的所有权可以在线程间传送。几乎所有的 Rust 类型都是Send
的,不过有一些例外,包括 Rc<T>
:这是不能 Send
的,因为如果克隆了 Rc<T>
的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T>
被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
因此,Rust 类型系统和 trait bound 确保永远也不会意外的将不安全的 Rc<T>
在线程间发送。当尝试在示例 16-14 中这么做的时候,会得到错误 the trait Send is not implemented for Rc<Mutex<i32>>
。而使用标记为 Send
的 Arc<T>
时,就没有问题了。
任何完全由 Send
的类型组成的类型也会自动被标记为 Send
。几乎所有基本类型都是 Send
的,除了第十九章将会讨论的裸指针(raw pointer)。
Sync
允许多线程访问
Sync
标记 trait 表明一个实现了 Sync
的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T
,如果 &T
是 Send
的话 T
就是 Sync
的,这意味着其引用就可以安全的发送到另一个线程。类似于 Send
的情况,基本类型是 Sync
的,完全由 Sync
的类型组成的类型也是 Sync
的。
智能指针 Rc<T>
也不是 Sync
的,出于其不是 Send
相同的原因。RefCell<T>
(第十五章讨论过)和 Cell<T>
系列类型不是 Sync
的。RefCell<T>
在运行时所进行的借用检查也不是线程安全的。Mutex<T>
是 Sync
的,正如 “在线程间共享 Mutex
” 部分所讲的它可以被用来在多线程中共享访问。
手动实现 Send
和 Sync
是不安全的
通常并不需要手动实现 Send
和 Sync
trait,因为由 Send
和 Sync
的类型组成的类型,自动就是 Send
和 Sync
的。因为它们是标记 trait,甚至都不需要实现任何方法。它们只是用来加强并发相关的不可变性的。
手动实现这些标记 trait 涉及到编写不安全的 Rust 代码,第十九章将会讲述具体的方法;当前重要的是,在创建新的由不是 Send
和 Sync
的部分构成的并发类型时需要多加小心,以确保维持其安全保证。“The Rustonomicon” 中有更多关于这些保证以及如何维持它们的信息。
Rust高级特性
本节将涉及如下内容:
- 不安全 Rust:用于当需要舍弃 Rust 的某些保证并负责手动维持这些保证
- 高级 trait:与 trait 相关的关联类型,默认类型参数,完全限定语法(fully qualified syntax),超(父)trait(supertraits)和 newtype 模式
- 高级类型:关于 newtype 模式的更多内容,类型别名,never 类型和动态大小类型
- 高级函数和闭包:函数指针和返回闭包
- 宏:定义在编译时定义更多代码的方式
解引用裸指针
回到第四章的 “悬垂引用” 部分,那里提到了编译器会确保引用总是有效的。不安全 Rust 有两个被称为裸指针(raw pointers)的类似于引用的新类型。和引用一样,裸指针是不可变或可变的,分别写作 *const T
和 *mut T
。这里的星号不是解引用运算符;它是类型名称的一部分。在裸指针的上下文中,不可变意味着指针解引用之后不能直接赋值。