简介
欢迎来到切片类型实验。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将通过编写一个函数来解决一个编程问题,该函数接受一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词,然后我们将讨论使用索引表示子字符串的局限性以及在 Rust 中使用字符串切片解决此问题的方法。
This tutorial is from open-source community. Access the source code
💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版
欢迎来到切片类型实验。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将通过编写一个函数来解决一个编程问题,该函数接受一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词,然后我们将讨论使用索引表示子字符串的局限性以及在 Rust 中使用字符串切片解决此问题的方法。
切片允许你引用集合中元素的连续序列,而不是整个集合。切片是一种引用,所以它不拥有所有权。
这里有一个小的编程问题:编写一个函数,该函数接受一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词。如果函数在字符串中没有找到空格,那么整个字符串必须是一个单词,所以应该返回整个字符串。
让我们逐步了解如何在不使用切片的情况下编写这个函数的签名,以便理解切片将解决的问题:
fn first_word(s: &String) ->?
first_word
函数有一个 &String
作为参数。我们不想要所有权,所以这样没问题。但是我们应该返回什么呢?我们实际上没有办法谈论字符串的一部分。然而,我们可以返回单词结束的索引,由空格表示。让我们试试,如清单4-7所示。
文件名:src/main.rs
fn first_word(s: &String) -> usize {
1 let bytes = s.as_bytes();
for (2 i, &item) in 3 bytes.iter().enumerate() {
4 if item == b' ' {
return i;
}
}
5 s.len()
}
清单4-7:first_word
函数,它返回 String
参数中的字节索引值
因为我们需要逐个遍历 String
的元素并检查某个值是否是空格,所以我们将使用 as_bytes
方法将 String
转换为字节数组 [1]。
接下来,我们使用 iter
方法创建一个字节数组的迭代器 [3]。我们将在第13章更详细地讨论迭代器。目前,要知道 iter
是一个返回集合中每个元素的方法,而 enumerate
包装了 iter
的结果,并将每个元素作为元组的一部分返回。从 enumerate
返回的元组的第一个元素是索引,第二个元素是对该元素的引用。这比我们自己计算索引要方便一些。
因为 enumerate
方法返回一个元组,所以我们可以使用模式来解构该元组。我们将在第6章更详细地讨论模式。在 for
循环中,我们指定一个模式,元组中的索引用 i
表示,元组中的单个字节用 &item
表示 [2]。因为我们从 .iter().enumerate()
中得到了对元素的引用,所以我们在模式中使用 &
。
在 for
循环内部,我们使用字节字面量语法搜索表示空格的字节 [4]。如果我们找到一个空格,就返回位置。否则,我们使用 s.len()
返回字符串的长度 [5]。
现在我们有了一种方法来找出字符串中第一个单词结束的索引,但有一个问题。我们单独返回一个 usize
,但它只有在 &String
的上下文中才有意义。换句话说,因为它是与 String
分开的值,所以不能保证它在未来仍然有效。考虑清单4-8中的程序,它使用了清单4-7中的 first_word
函数。
// src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word 将得到值 5
s.clear(); // 这会清空 String,使其等于 ""
// 这里 word 仍然有值 5,但没有更多的字符串可以有意义地使用值 5 了。word 现在完全无效!
}
清单4-8:调用 first_word
函数并存储结果,然后更改 String
的内容
这个程序编译时没有任何错误,如果我们在调用 s.clear()
后使用 word
,它也会编译通过。因为 word
与 s
的状态完全没有关联,所以 word
仍然包含值 5
。我们可以使用那个值 5
和变量 s
来尝试提取第一个单词,但这会是一个错误,因为自从我们在 word
中保存 5
以来,s
的内容已经改变了。
必须担心 word
中的索引与 s
中的数据不同步既繁琐又容易出错!如果我们编写一个 second_word
函数,管理这些索引会更脆弱。它的签名必须如下所示:
fn second_word(s: &String) -> (usize, usize) {
现在我们要跟踪一个起始索引和一个结束索引,并且有更多从特定状态的数据计算出来但与该状态完全没有关联的值。我们有三个不相关的变量在四处浮动,需要保持同步。
幸运的是,Rust 有解决这个问题的办法:字符串切片。
字符串切片是对 String
一部分的引用,它看起来像这样:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello
不是对整个 String
的引用,而是对 String
一部分的引用,由额外的 [0..5]
部分指定。我们通过在方括号内指定一个范围 [起始索引..结束索引]
来创建切片,其中 起始索引
是切片中的第一个位置,结束索引
比切片中的最后一个位置大1。在内部,切片数据结构存储起始位置和切片的长度,这对应于 结束索引
减去 起始索引
。所以,对于 let world = &s[6..11];
,world
将是一个切片,它包含一个指向 s
中索引6处字节的指针,长度值为 5
。
图4-6用图表展示了这一点。
图4-6:指向 String
一部分的字符串切片
使用 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;关于 UTF-8 处理的更全面讨论在“使用字符串存储 UTF-8 编码的文本”中。
记住所有这些信息后,让我们重写 first_word
以返回一个切片。表示“字符串切片”的类型写作 &str
:
文件名:src/main.rs
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[..]
}
我们通过查找第一个出现的空格来获取单词结束的索引,方式与清单4-7中相同。当我们找到一个空格时,我们使用字符串的起始位置和空格的索引作为起始和结束索引来返回一个字符串切片。
现在当我们调用 first_word
时,我们得到一个与底层数据相关联的单一值。该值由切片起始点的引用和切片中的元素数量组成。
返回一个切片对于 second_word
函数也适用:
fn second_word(s: &String) -> &str {
我们现在有了一个简单直接的 API,更难出错,因为编译器会确保对 String
的引用保持有效。还记得清单4-8中的程序中的错误吗?当时我们得到了第一个单词结束的索引,但随后清空了字符串,所以我们的索引变得无效。那段代码在逻辑上是不正确的,但没有立即显示任何错误。如果我们继续尝试对一个已清空的字符串使用第一个单词的索引,问题会在后面出现。切片使这个错误不可能发生,并让我们更早地知道代码有问题。使用 first_word
的切片版本会抛出一个编译时错误:
文件名:src/main.rs
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: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
回顾借用规则,如果我们对某个东西有一个不可变引用,我们就不能再获取一个可变引用。因为 clear
需要截断 String
,它需要获取一个可变引用。对 clear
的调用之后的 println!
使用了 word
中的引用,所以在那个时候不可变引用必须仍然有效。Rust 不允许 clear
中的可变引用和 word
中的不可变引用同时存在,编译失败。Rust 不仅使我们的 API 更易于使用,还在编译时消除了一整类错误!
回想一下,我们提到过字符串字面量存储在二进制文件中。既然我们已经了解了切片,就可以正确理解字符串字面量了:
let s = "Hello, world!";
这里 s
的类型是 &str
:它是一个指向二进制文件中那个特定位置的切片。这也是字符串字面量是不可变的原因;&str
是一个不可变引用。
知道了你可以获取字面量和 String
值的切片后,我们可以对 first_word
进行进一步改进,那就是它的签名:
fn first_word(s: &String) -> &str {
更有经验的 Rust 开发者会写成清单4-9所示的签名,因为这样我们就可以在 &String
值和 &str
值上使用同一个函数。
fn first_word(s: &str) -> &str {
清单4-9:通过将 s
参数的类型改为字符串切片来改进 first_word
函数
如果我们有一个字符串切片,就可以直接传递它。如果我们有一个 String
,我们可以传递 String
的切片或者对 String
的引用。这种灵活性利用了解引用强制转换,这是一个我们将在“函数和方法的隐式解引用强制转换”中介绍的特性。
定义一个函数来接受字符串切片而不是对 String
的引用,会使我们的 API 更通用、更有用,同时不会损失任何功能:
文件名:src/main.rs
fn main() {
let my_string = String::from("hello world");
// `first_word` 适用于 `String` 的切片,无论是部分切片还是整个切片
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` 也适用于对 `String` 的引用,这等同于 `String` 的整个切片
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` 适用于字符串字面量的切片,无论是部分切片还是整个切片
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// 因为字符串字面量本身就是字符串切片,所以这样也可以,无需切片语法!
let word = first_word(my_string_literal);
}
正如你可能想象的那样,字符串切片特定于字符串。但也有更通用的切片类型。考虑这个数组:
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]
。它的工作方式与字符串切片相同,通过存储对第一个元素的引用和一个长度。你会在各种其他集合中使用这种切片。当我们在第8章讨论向量时,会详细讨论这些集合。
恭喜你!你已经完成了“切片类型”实验。你可以在LabEx中练习更多实验来提升你的技能。