切片类型
切片 让你引用集合中的一段连续元素,而不是整个集合。切片是一种引用,因此它没有所有权。
这是一个小的编程问题:编写一个函数,该函数接受一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词。如果函数在字符串中找不到空格,则整个字符串必须是一个单词,因此应返回整个字符串。
让我们逐步了解如何在不使用切片的情况下编写此函数的签名,以理解切片将解决的问题:
fn first_word(s: &String) -> ?
first_word
函数有一个 &String
作为参数。我们不希望拥有所有权,所以这样是可以的。但是我们应该返回什么呢?我们实际上没有一种方法来讨论字符串的部分。然而,我们可以返回单词结束的位置(由空格指示)的索引。让我们尝试这样做,如清单 4-7 所示。
因为我们需要逐个元素地遍历 String
并检查一个值是否为空格,我们将使用 as_bytes
方法将 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()
}
fn main() {}
接下来,我们使用 iter
方法创建一个遍历字节数组的迭代器:
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()
}
fn main() {}
我们将在第 13 章中更详细地讨论迭代器。
目前,要知道iter
是一个返回集合中每个元素的方法,
而enumerate
则包装iter
的结果,并将每个元素作为元组的一部分返回。从
enumerate
返回的元组的第一个元素是索引,第二个元素是对元素的引用。
这比我们自己计算索引要方便一些。
因为 enumerate
方法返回一个元组,我们可以使用模式来解构这个元组。我们将在 第 6 章 中更详细地讨论模式。在 for
循环中,我们指定一个模式,该模式在元组中使用 i
作为索引,使用 &item
作为元组中的单个字节。由于从 .iter().enumerate()
获取的是元素的引用,我们在模式中使用 &
。
在 for
循环中,我们使用字节字面量语法搜索表示空格的字节。如果找到空格,我们返回其位置。否则,我们通过使用 s.len()
返回字符串的长度。
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()
}
fn main() {}
我们现在有一种方法可以找到字符串中第一个单词结尾的索引,但有一个问题。我们单独返回一个usize
,但只有在&String
的上下文中这个数字才有意义。换句话说,因为它是一个与String
分离的值,所以无法保证它将来仍然有效。考虑列表4-8中的程序,该程序使用了列表4-7中的first_word
函数。
这个程序编译时没有任何错误,即使我们在调用 s.clear()
之后使用 word
也会如此。因为 word
完全不依赖于 s
的状态,word
仍然包含值 5
。我们可以使用该值 5
与变量 s
试图提取出第一个单词,但这将是一个错误,因为自我们保存 5
到 word
以来,s
的内容已经发生了变化。
在 word
中的索引与 s
中的数据不同步是一个繁琐且容易出错的问题!如果我们要编写一个 second_word
函数,管理这些索引会更加脆弱。它的签名必须像这样:
fn second_word(s: &String) -> (usize, usize) {
现在我们正在跟踪一个起始 和 结束索引,我们甚至有更多的值是从特定状态的数据计算出来的,但完全不依赖于该状态。我们有三个互不相关的变量需要保持同步。
幸运的是,Rust 有一个解决这个问题的方案:字符串切片。
字符串切片
一个 字符串切片 是对 String
的一部分的引用,它看起来像这样:
fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
而不是对整个 String
的引用,hello
是对 String
的一部分的引用,这部分由额外的 [0..5]
指定。我们通过在括号内使用范围指定 [starting_index..ending_index]
来创建切片,其中 starting_index
是切片的第一个位置,ending_index
是切片最后一个位置的下一个位置。内部,切片数据结构存储切片的起始位置和长度,这对应于 ending_index
减去 starting_index
。因此,在 let world = &s[6..11];
的情况下,world
将是一个包含指向 s
中索引 6 的字节的指针和长度值为 5
的切片。
图4-7以图表形式展示了这一点。
使用 Rust 的 ..
范围语法,如果你想从索引 0 开始,可以省略两个点之前的值。换句话说,这些是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
同样地,如果您的切片包含 String
的最后一个字节,您可以省略尾部的数字。这意味着以下两者是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[3..len]; let slice = &s[3..]; }
你也可以丢弃两个值来获取整个字符串的切片。所以这些是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; }
注意:字符串切片的范围索引必须出现在有效的 UTF-8 字符边界上。如果你尝试在多字节字符的中间创建字符串切片,你的程序将因错误而退出。为了介绍字符串切片,本节假设只使用 ASCII;关于 UTF-8 处理的更详细讨论,请参见第 8 章的 “使用字符串存储 UTF-8 编码的文本” 部分。
考虑到所有这些信息,让我们重写 first_word
以返回一个切片。表示“字符串切片”的类型写为 &str
:
我们以与列表 4-7 中相同的方式获取单词末尾的索引,通过查找第一个空格的位置。当我们找到一个空格时,我们使用字符串的起始位置和空格的索引作为起始和结束索引来返回一个字符串切片。
现在当我们调用first_word
时,我们得到一个与底层数据关联的单一值。该值由对切片起点的引用和切片中的元素数量组成。
返回一个切片对于 second_word
函数也同样适用:
fn second_word(s: &String) -> &str {
我们现在有一个更简单的 API,它更难出错,因为编译器会确保对 String
的引用保持有效。还记得第 4-8 列表中的程序错误吗?当我们获取了第一个单词的索引,但随后清空了字符串,使我们的索引无效。那段代码逻辑上是错误的,但没有立即显示错误。如果我们继续尝试使用第一个单词的索引与一个已清空的字符串,问题会在稍后出现。切片使这个错误变得不可能,并让我们更早地知道代码有问题。使用 first_word
的切片版本会在编译时抛出错误:
这里的编译器错误:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ------ immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
回想借用规则,如果我们对某个东西有一个不可变引用,我们就不能同时获取一个可变引用。因为 clear
需要截断 String
,所以它需要获取一个可变引用。println!
在调用 clear
之后使用了 word
中的引用,因此此时不可变引用仍然必须是活跃的。Rust 禁止 clear
中的可变引用和 word
中的不可变引用同时存在,因此编译失败。Rust 不仅使我们的 API 更易于使用,而且还消除了整个一类编译时错误!
String Literals as Slices
回想我们提到字符串字面量是存储在二进制文件中的。现在我们了解了切片,可以正确理解字符串字面量:
#![allow(unused)] fn main() { let s = "Hello, world!"; }
这里的 s
的类型是 &str
:它是指向二进制文件中特定位置的切片。这也是为什么字符串字面量是不可变的;&str
是一个不可变引用。
String Slices as Parameters
知道可以对字面量和String
值进行切片,这引导我们对first_word
进行一个改进,那就是它的签名:
fn first_word(s: &String) -> &str {
一个更有经验的 Rustacean 会编写如清单 4-9 所示的签名,因为它允许我们对 &String
值和 &str
值使用相同的函数。
如果我们有一个字符串切片,我们可以直接传递它。如果我们有一个 String
,我们可以传递 String
的切片或 String
的引用。这种灵活性利用了 解引用强制转换,这是我们在第 15 章的 “函数和方法中的隐式解引用强制转换” 部分将要讨论的一个特性。
将函数定义为接受字符串切片而不是 String
的引用,可以使我们的 API 更加通用和有用,而不会失去任何功能:
其他切片
字符串切片,正如你可能想象的那样,是特定于字符串的。但也有一个更通用的切片类型。考虑这个数组:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; }
正如我们可能想要引用字符串的一部分,我们也可能想要引用数组的一部分。我们会这样做:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
这个切片的类型为&[i32]
。它的工作方式与字符串切片相同,通过存储对第一个元素的引用和长度来实现。你将使用这种切片处理各种其他集合。我们将在第 8 章讨论向量时详细讨论这些集合。
摘要
所有权、借用和切片的概念确保了 Rust 程序在编译时的内存安全。Rust 语言让你像其他系统编程语言一样控制内存使用,但数据的所有者在超出作用域时自动清理数据意味着你不必编写和调试额外的代码来获得这种控制。
所有权影响 Rust 的许多其他部分的工作方式,因此我们将在本书的其余部分进一步讨论这些概念。让我们继续第 5 章,看看如何在 struct
中将数据片段组合在一起。