除了引用,Rust还有另外一种不持有所有权的数据类型:切片(slice)。切片允许我们引用集合中某一段连续的元素序列,而不是整个集合。
考虑这样一个小问题:编写一个搜索函数,它接收字符串作为参数,并将字符串中的首个单词作为结果返回。
如果字符串中不存在空格,那么就意味着整个字符串是一个单词,直接返回整个字符串作为结果即可。让我们来看一下这个函数的签名应该如何设计:
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() {
❹ if item == b' ' {
return i;
}
}
❺ s.len()
}
这段代码首先使用 as_bytes 方法❶将 String 转换为字节数组,因为我们的算法需要依次检查 String 中的字节是否为空格。
接着,我们通过 iter 方法❸创建了一个可以遍历字节数组的迭代器。我们会在后面文章中详细讨论这里新出现的迭代器。目前,你只需要知道 iter 方法会依次返回集合中的每一个元素即可。
随后的 enumerate 则将 iter 的每个输出作为元素逐一封装在对应的元组中返回。元组的第一个元素是索引,第二个元素是指向集合中字节的引用。
使用 enumerate 可以较为方便地获得迭代索引。既然 enumerate 方法返回的是一个元组,那么我们就可以使用模式匹配来解构它,就像Rust中其他使用元组的地方一样。
在 for 循环的遍历语句中,我们指定了一个解构模式,其中 i 是元组中的索引部分,而 &item ❷则是元组中指向集合元素的引用。由于我们从 .iter().enumerate() 中获取的是产生引用元素的迭代器,所以我们在模式中使用了 &。
现在,我们初步实现了期望的功能,它能够成功地搜索并返回字符串中第一个单词结尾处的位置索引。但这里依然存在一个设计上的缺陷。
我们将一个 usize 值作为索引独立地返回给调用者,但这个值在脱离了传入的 &String 的上下文之后便毫无意义。换句话说,由于这个值独立于String而存在,所以在函数返回值后,我们就再也无法保证它的有效性了。
下面的代码示例中使用 first_word 函数演示了这种返回值失效的情形:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // 索引5会被绑定到变量word上
s.clear(); // 这里的clear方法会清空当前字符串,使之变为""
// 虽然word依然拥有5这个值,但因为我们用于搜索的字符串发生了改变,
//所以这个索引也就没有任何意义了,word到这里便失去了有效性
}
上面的程序在编译器看来没有任何问题,即便我们在调用 s.clear() 之后使用 word 变量也是没有问题的。同时由于 word 变量本身与 s 没有任何关联,所以 word 的值始终都是 5。
但当我们再次使用 5 去从变量 s 中提取单词时,一个 bug 就出现了:此时 s 中的内容早已在我们将 5 存入 word 后发生了改变。
这种 API 的设计方式使我们需要随时关注 word 的有效性,确保它与 s 中的数据是一致的,类似的工作往往相当烦琐且易于出错。这种情况对于另一个函数 second_word 而言更加明显。
这个函数被设计来搜索字符串中的第二个单词,它的签名也许会被设计为下面这样:
fn second_word(s: &String) -> (usize, usize) {
现在,我们需要同时维护起始和结束两个位置的索引,这两个值基于数据的某个特定状态计算而来,却没有跟数据产生任何程度上的联系。
于是我们有了 3 个彼此不相关的变量需要被同步,这可不妙。幸运的是,Rust为这个问题提供了解决方案:字符串切片。
1. 字符串切片
字符串切片是指向 String 对象中某个连续部分的引用,它的使用方式如下所示:
let s = String::from("hello world");
let hello = &s[0..5];
❶let world = &s[6..11];
我们可以在一对方括号中指定切片的范围区间 [starting_index.. ending_index],其中的 starting_index 是切片起始位置的索引值,ending_index 是切片终止位置的下一个索引值。
切片数据结构在内部存储了指向起始位置的引用和一个描述切片长度的字段,这个描述切片长度的字段等价于 ending_index 减去 starting_index。
所以在上面示例的❶中,world 是一个指向变量 s 第七个字节并且长度为 5 的切片。下图中所展示的是字符串切片的图解:
Rust的范围语法..有一个小小的语法糖:当你希望范围从第一个元素(也就是索引值为 0 的元素)开始时,则可以省略两个点号之前的值。
换句话说,下面两个创建切片的表达式是等价的:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
同样地,假如你的切片想要包含 String 中的最后一个字节,你也可以省略双点号之后的值。下面的切片表达式依然是等价的:
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 字符边界内。尝试从一个多字节字符的中间位置创建字符串切片会导致运行时错误。为了将问题简化,我们只会在本篇文章中使用 ASCII 字符集。
基于所学到的这些知识,让我们开始重构 first_word 函数吧!该函数可以返回一个切片作为结果。字符串切片的类型写作 &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 {
由于编译器会确保指向 String 的引用持续有效,所以我们新设计的接口变得更加健壮且直观了。还记得在上面示例中故意构造出的错误吗?
那段代码在搜索完成并保存索引后清空了字符串的内容,这使得我们存储的索引不再有效。它在逻辑上明显是有问题的,却不会触发任何编译错误,这个问题只会在我们使用第一个单词的索引去读取空字符串时暴露出来。
切片的引入使我们可以在开发早期快速地发现此类错误。在上面示例中,新的 first_word 函数在编译时会抛出一个错误,尝试运行以下代码:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // 错误!
println!("the first word is : {}", word);
}
编译错误如下所示:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let word = first_word(&s);
| - immutable borrow occurs here
5 |
6 | s.clear(); // 错误!
| ^ mutable borrow occurs here
7 | }
| - immutable borrow ends here
回忆一下借用规则,当我们拥有了某个变量的不可变引用时,我们就无法同时取得该变量的可变引用。
由于 clear 需要截断当前的 String 实例,所以调用 clear 需要传入一个可变引用。这就是编译失败的原因。Rust不仅使我们的API更加易用,它还在编译过程中帮助我们避免了此类错误。
字符串字面量就是切片
还记得我们讲过字符串字面量被直接存储在了二进制程序中吗?在学习了切片之后,我们现在可以更恰当地理解字符串字面量了:
let s = "Hello, world!";
在这里,变量 s 的类型其实就是 &str:它是一个指向二进制程序特定位置的切片。正是由于 &str 是一个不可变的引用,所以字符串字面量自然才是不可变的。
将字符串切片作为参数
既然我们可以分别创建字符串字面量和String的切片,那么就能够进一步优化first_word函数的接口,下面是它目前的签名:
fn first_word(s: &String) -> &str {
比较有经验的Rust开发者往往会采用下面的写法,这种改进后的签名使函数可以同时处理 String 与 &str:
fn first_word(s: &str) -> &str {
示例4-9:使用字符串切片作为参数s的类型来改进first_word函数
当你持有字符串切片时,你可以直接调用这个函数。而当你持有 String 时,你可以创建一个完整 String 的切片来作为参数。
在定义函数时使用字符串切片来代替字符串引用会使我们的 API 更加通用,且不会损失任何功能,尝试运行以下代码:
fn main() {
let my_string = String::from("hello world");
// first_word 可以接收String对象的切片作为参数
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word 可以接收字符串字面量的切片作为参数
let word = first_word(&my_string_literal[..]);
// 由于字符串字面量本身就是切片,所以我们可以在这里直接将它传入函数,
// 而不需要使用额外的切片语法!
let word = first_word(my_string_literal);
}
2. 其他类型的切片
从名字上就可以看出来,字符串切片是专门用于字符串的。但实际上,Rust还有其他更加通用的切片类型,以下面的数组为例:
let a = [1, 2, 3, 4, 5];
就像我们想要引用字符串的某个部分一样,你也可能会希望引用数组的某个部分。这时,我们可以这样做:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
这里的切片类型是 &[i32],它在内部存储了一个指向起始元素的引用及长度,这与字符串切片的工作机制完全一样。你将在各种各样的集合中接触到此类切片,而我们会在后面文章中讨论动态数组时再来介绍那些常用的集合。