切片类型
切片 允许你引用 集合 中连续的元素序列,而不是整个集合。切片是一种引用,因此它不拥有所有权。
这是一个小小的编程问题:编写一个函数,该函数接收一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词。如果该函数在字符串中没有找到空格,则整个字符串必须是一个单词,因此应返回整个字符串。
让我们逐步了解如何在不使用切片的情况下编写此函数的签名,以了解切片将解决的问题
fn first_word(s: &String) -> ?
first_word
函数的参数为 &String
。我们不想要所有权,所以这样很好。但是我们应该返回什么?我们实际上没有办法谈论字符串的一部分。但是,我们可以返回单词结尾的索引,该索引由空格指示。让我们尝试一下,如清单 4-7 所示。
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() {}
String
参数的字节索引值的 first_word
函数因为我们需要逐个遍历 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
函数。
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() { let mut s = String::from("hello world"); let word = first_word(&s); // word will get the value 5 s.clear(); // this empties the String, making it equal to "" // word still has the value 5 here, but there's no more string that // we could meaningfully use the value 5 with. word is now totally invalid! }
first_word
函数的结果,然后更改 String
内容该程序编译时没有任何错误,并且如果在调用 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]; }
hello
不是对整个 String
的引用,而是对 String
的一部分的引用,该部分在额外的 [0..5]
位中指定。我们通过指定 [starting_index..ending_index]
在方括号内使用范围来创建切片,其中 starting_index
是切片中的第一个位置,ending_index
比切片中的最后一个位置大 1。在内部,切片数据结构存储切片的起始位置和长度,该长度对应于 ending_index
减去 starting_index
。因此,在 let world = &s[6..11];
的情况下,world
将是一个切片,该切片包含一个指向 s
中索引 6 处的字节的指针,其长度值为 5
。
图 4-7 在图中显示了这一点。
图 4-7:引用 String
一部分的字符串切片
使用 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 处理的更详细的讨论位于“使用字符串存储 UTF-8 编码的文本”第 8 章的章节。
考虑到所有这些信息,让我们重写 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[..] } fn main() {}
我们通过查找第一个空格来获得单词结尾的索引,其方式与在清单 4-7 中所做的方式相同。当我们找到一个空格时,我们使用字符串的开头和空格的索引作为起始和结束索引来返回一个字符串切片。
现在,当我们调用 first_word
时,我们将获得一个与基础数据绑定的单一值。该值由对切片起点的引用和切片中元素的数量组成。
返回切片也适用于 second_word
函数
fn second_word(s: &String) -> &str {
现在我们有了一个非常简单的 API,因为编译器会确保对 String
的引用保持有效,因此很难搞砸。还记得清单 4-8 中的程序中的错误吗,当我们获得了第一个单词结尾的索引,然后清除了字符串,因此我们的索引无效?该代码在逻辑上是不正确的,但没有显示任何直接错误。如果我们继续尝试使用空字符串的第一个单词索引,则问题会在以后出现。切片使此错误成为不可能,并让我们更快地知道我们的代码有问题。使用 first_word
的切片版本会引发编译时错误
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[..]
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // error!
println!("the first word is: {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
,所以它需要获取一个可变的引用。在调用 clear
之后的 println!
中使用了 word
中的引用,因此该不可变引用必须在该点仍然处于活动状态。Rust 不允许 clear
中的可变引用和 word
中的不可变引用同时存在,因此编译失败。Rust 不仅使我们的 API 更易于使用,而且还在编译时消除了整个类别的错误!
作为切片的字符串字面量
回想一下,我们谈论过存储在二进制文件中的字符串字面量。现在我们了解了切片,我们可以正确理解字符串字面量
#![allow(unused)] fn main() { let s = "Hello, world!"; }
此处的 s
的类型为 &str
:它是指向二进制文件特定点的切片。这也是为什么字符串字面量是不可变的;&str
是一个不可变的引用。
作为参数的字符串切片
了解你可以使用字面量和 String
值来获取切片,这使我们对 first_word
进行了进一步的改进,即它的签名
fn first_word(s: &String) -> &str {
更有经验的 Rustacean 会编写如清单 4-9 中所示的签名,因为它允许我们在 &String
值和 &str
值上使用相同的函数。
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");
// `first_word` works on slices of `String`s, whether partial or whole
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` also works on references to `String`s, which are equivalent
// to whole slices of `String`s
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` works on slices of string literals, whether partial or whole
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
s
参数的类型来改进 first_word
函数如果我们有字符串切片,我们可以直接传递它。如果我们有 String
,我们可以传递 String
的切片或对 String
的引用。此灵活性利用了解引用强制转换,这是我们将在 “使用函数和方法隐式解引用强制转换”第 15 章的章节中介绍的功能。
定义一个函数以接受字符串切片而不是对 String
的引用,这使我们的 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"); // `first_word` works on slices of `String`s, whether partial or whole let word = first_word(&my_string[0..6]); let word = first_word(&my_string[..]); // `first_word` also works on references to `String`s, which are equivalent // to whole slices of `String`s let word = first_word(&my_string); let my_string_literal = "hello world"; // `first_word` works on slices of string literals, whether partial or whole let word = first_word(&my_string_literal[0..6]); let word = first_word(&my_string_literal[..]); // Because string literals *are* string slices already, // this works too, without the slice syntax! let word = first_word(my_string_literal); }
其他切片
你可能会想到,字符串切片是特定于字符串的。但是,还有一种更通用的切片类型。考虑以下数组
#![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 中许多其他部分的工作方式,所以我们会在本书的其余部分进一步讨论这些概念。让我们继续第五章,看看如何在 struct
中将数据组合在一起。