开发环境
- Windows 10
- Rust 1.56.0
- VS Code 1.61.2
项目工程
这里继续沿用上次工程rust-demo
切片类型
另一个没有所有权的数据类型是切片。切片允许您引用集合中的连续元素序列,而不是整个集合。
下面是一个小的编程问题:编写一个函数,该函数接受一个字符串并返回它在该字符串中找到的第一个单词。如果函数在字符串中找不到空格,则整个字符串必须是一个单词,因此应该返回整个字符串。
首先我们考虑一下这个函数:
fn first_word(s: &String) -> ?
这个函数first_word有一个&String作为参数。我们不想要所有权,所以这没问题。但我们应该返回什么呢?我们没有办法谈论字符串的一部分。但是,我们可以返回单词末尾的索引。可以看下面的代码:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes(); // 将字符串转换成一个字节数组,以便下面逐个遍历
for (i, &item) in bytes.iter().enumerate() { // 使用iter()方法在字节数组上创建一个迭代器
if item == b' ' {
return i; // 返回字符的索引
}
}
s.len()
}
现在,iter()是一个方法,它返回集合中的每个元素,enumerate()枚举封装iter()的结果,然后将每个元素作为元组的一部分返回。enumerate()枚举返回的元组的第一个元素是索引,第二个元素是对元素的引用。这比自己计算索引要方便一些。
因为enumerate()枚举方法返回一个元组,所以我们可以使用模式来重构该元组。因此,在for循环中,我们指定了一个模式,它在元组中为索引指定了i,对于元组中的单个字节指定了&Item。因为我们从.iter().enumerate()获得了对元素的引用,所以我们在模式中使用&。
在for循环中,我们使用字节字面语法搜索表示空间的字节。如果我们找到一个位置,我们返回位置。否则,我们将使用s.len()返回字符串的长度。
现在我们有了一种方法来找出字符串中第一个单词末尾的索引,但是有一个问题。我们将自己返回一个usize,但它只是&string上下文中的一个有意义的数字。换句话说,因为它是一个独立于字符串的值,所以不能保证它在将来仍然有效。
测试上述代码:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // 将返回5
println!("{}", word);
s.clear(); // 清空字符串s
// word在这里仍然有值5,但是没有更多的字符串可以有意义地使用值5, word现在完全无效!
}
运行
cargo run
这个程序编译时没有任何错误,如果我们在调用s.clear()之后使用word,也会这样做。因为word根本没有连接到s的状态,word仍然包含值5。我们可以使用变量s的值5来提取出第一个单词,但是这将是一个错误,因为自从我们在word中保存了5之后,s的内容已经发生了变化。
必须担心word中的索引与s中的数据不同步是很乏味的,而且容易出错!如果我们编写second_word函数,那么管理这些索引就更加脆弱了。它必须是这样的:
fn second_word(s: &String) -> (usize, usize) {
现在我们正在跟踪一个开始和结束索引,我们有更多的值是从特定状态的数据中计算出来的,但是与状态完全没有关联。我们现在有三个不相关的变量,需要保持同步。
字符串切片
字符串片是对字符串的一部分的引用,它如下所示:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
这类似于引用整个字符串,但使用额外的[0..5]位。它不是对整个字符串的引用,而是对字符串的一部分的引用。
我们可以通过指定[start_index..end_index]来使用括号内的范围创建切片,其中start_index是片中的第一个位置,end_index比片中的最后一个位置多一个位置。在内部,片数据结构存储片的起始位置和长度,这对应于end_index减去start_index。因此,对于let world=&s[6..11];world将是一个片段,其中包含指向s的索引6处字节的指针,长度值为5。如下图所示:
用Rust的是..范围语法,如果要从索引0开始,可以在两个句点之前删除该值。换句话说,它们是相等的。
let s = String::from("hello");
// 下面两条语句等价
let slice = &s[0..2];
let slice = &s[..2];
同样,如果您的切片包含字符串的最后一个字节,则可以删除尾随数字。这意味着它们是等价的:
let s = String::from("hello");
let len = s.len();
// 下面也是等价的
let slice = &s[3..len];
let slice = &s[3..];
还可以删除两个值以获取整个字符串的一部分。所以这些是等价的:
let s = String::from("hello");
let len = s.len();
// 下面也是等价的
let slice = &s[0..len];
let slice = &s[..];
注意:字符串切片范围索引必须出现在有效的UTF-8字符边界上.如果试图在多字节字符的中间创建一个字符串片段,则程序将以错误退出。
考虑到所有这些信息,让我们重写first_word来返回一个片段。表示“String切片”的类型写为&str。可以改成下述代码:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // 返回字符串切片的引用
}
}
&s[..]
}
现在,当我们调用first_word时,我们得到一个与底层数据相关联的值。该值由对片的起始点和片中元素数的引用组成。
同样,返回一个切片也适用于second_word函数:
fn second_word(s: &String) -> &str {
我们现在有了一个简单易懂的API,因为编译器将确保对字符串的引用仍然有效。上述示例中有个bug,当我们将索引放到第一个单词的末尾,然后清除字符串时,我们的索引就无效了?该代码在逻辑上是不正确的,但没有显示任何即时错误。如果我们继续使用带有空字符串的第一个单词索引,问题会在稍后出现。切片使得这个bug不可能,并让我们知道我们的代码有一个问题更快。使用first_word的切片版本将引发编译时错误:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // 报错
println!("the first word is: {}", word);
}
运行
cargo run
编译出错
回想一下借用规则,如果我们对某物有不变的引用,我们也不能接受可变的引用。因为clear需要截断字符串,所以它需要获得一个可变的引用。println!在调用清除之后使用了word中的引用,所以不变的引用必须在这一点上仍然是活动的。Rust不允许clear的可变引用和同时存在的word中不变的引用,编译失败。Rust不仅使我们的API更易于使用,而且还在编译时消除了整个类的错误!
字符串字面值是切片
回想一下,我们说过字符串文本存储在二进制文件中。既然我们已经了解了切片,我们就可以正确地理解字符串了:
let s = "Hello, world!";
这里的s类型是&str:它是指向二进制的特定点的一个切片。这也是字符串文本不可变的原因;&str是不可变的引用。
字符串切片作为参数
知道您可以获取文本片段和字符串值,这将使我们在first_word上又有一个改进,这就是它的写法:
fn first_word(s: &String) -> &str {
更有经验的Rust开发者将编写上述所示的写法,因为它允许我们对&String值和&str值使用相同的函数。
改进后的写法:
fn first_word(s: &str) -> &str { // 字符串切片引用参数
如果我们有一个字符串切片,我们可以直接传递。如果我们有一个字符串,我们可以传递该字符串的一个片段或对该字符串的引用。定义函数来接受字符串切片而不是对字符串的引用使我们的API更加通用和有用,而不会失去任何功能。
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // 返回字符串切片的引用
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string[0..6]);
println!("{}", word);
let word = first_word(&my_string[..]);
println!("{}", word);
let word = first_word(&my_string);
println!("{}", word);
let my_string_literal = "hello world";
let word = first_word(&my_string_literal[0..6]);
println!("{}", word);
let word = first_word(&my_string_literal[..]);
println!("{}", word);
let word = first_word(my_string_literal);
println!("{}", word);
}
运行
cargo run
运行结果
其他切片
正如您想象的那样,字符串片是特定于字符串的。但也有一种更普遍的切片类型。考虑一下这个数组:
let a = [1, 2, 3, 4, 5];
正如我们可能希望引用字符串的一部分一样,我们也可能希望引用数组的一部分。我们会这样做:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
该切片具有类型&[i32]。它的工作方式与字符串切片一样,通过存储对第一个元素和长度的引用。您将在其他各种集合中使用这种切片。
总结
所有权、借用和切片的概念确保了Rust程序在编译时的内存安全。Rust语言以与其他系统编程语言相同的方式控制您的内存使用,但是当所有者超出范围时,让数据所有者自动清理该数据意味着您不必编写和调试额外的代码来获得此控件。
所有权会影响Rust的许多其他部分的工作方式。
本章重点
- 切片定义
- 切片使用