Rust 切片类型(slice type)
文章目录
- 切片类型
- 字符串切片
- 字符串字面量也是切片
- 用切片作为参数
- 小结
切片类型
切片允许你引用集合中一段连续的元素,而不是整个集合。切片是一种引用,因此它不拥有所有权。
这里有一个小编程问题:编写一个函数,接收一个由空格分隔的单词字符串,并返回该字符串中的第一个单词。如果字符串中没有空格,则整个字符串就是一个单词,应返回整个字符串。
让我们先不使用切片,来写这个函数的签名,以理解切片要解决的问题:
fn first_word(s: &String) -> ?
first_word
函数的参数是 &String
。我们不需要所有权,所以这样没问题。(在惯用的 Rust 中,函数不会获取参数的所有权,除非确实需要,随着学习你会明白原因!)但我们应该返回什么呢?我们实际上没有办法只描述字符串的一部分。不过,我们可以返回单词结尾的索引,也就是空格的位置。让我们试试,如清单 4-7 所示。
文件名:src/main.rs
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()
}
以上函数返回 String 参数字节索引值的 first_word 函数
因为我们需要逐个检查 String 的元素是否为空格,所以我们用 as_bytes 方法将 String 转换为字节数组。
let bytes = s.as_bytes();
接下来,我们用 iter 方法创建字节数组的迭代器:
for (i, &item) in bytes.iter().enumerate() {
iter
方法会返回集合中的每个元素,而 enumerate
会包装 iter
的结果,并将每个元素作为元组的一部分返回。元组的第一个元素是索引,第二个元素是元素的引用。这样比我们自己计算索引更方便。
因为 enumerate
返回的是元组,我们可以用模式解构这个元组。在 for
循环中,我们指定了 i
作为元组中的索引,&item
作为单个字节。由于 .iter().enumerate()
返回的是元素的引用,所以我们在模式中使用 &。
在 for
循环内部,我们用字节字面量语法查找表示空格的字节。如果找到空格,就返回该位置。否则,返回字符串的长度 s.len()
。
if item == b' ' {return i;}}s.len()
现在我们可以找到字符串中第一个单词结尾的索引了,但这里有个问题。我们返回的是 usize
,但它只有在 &String
的上下文中才有意义。换句话说,因为它是与 String
分离的值,无法保证它在将来仍然有效。
文件名:src/main.rs
fn main() {let mut s = String::from("hello world");let word = first_word(&s); // word 得到值 5s.clear(); // 清空 String,变为 ""// 此时 word 仍然是 5,但 s 已经没有任何内容// 用 5 去索引 s 已经毫无意义,word 现在是无效的!
}
上述代码调用 first_word 后再修改 String 内容
这个程序可以编译通过,即使在调用 s.clear()
后再用 word
也不会报错。因为 word
与 s
的状态完全无关,word
仍然是 5。我们可以用 5 去索引 s 提取第一个单词,但这会出 bug,因为 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
的引用,而是对其中一部分的引用,通过 [0…5] 指定。我们用中括号加范围 [起始索引…结束索引] 创建切片,起始索引是切片的第一个位置,结束索引是最后一个位置加一。内部,切片结构存储起始位置和长度(结束索引减起始索引)。比如 let world = &s[6..11];
,world
是指向 s
第 6 个字节、长度为 5 的切片。
三张表:一张表示 s 的栈数据,指向堆上 “hello world” 字节数据表的第 0 个字节。第三张表表示 world 切片的栈数据,长度为 5,指向堆数据表的第 6 个字节。
用 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:
文件名: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[..]
}
当找到空格时,返回从字符串开头到空格索引的切片。
现在调用 first_word
时,返回的是和底层数据关联的单一值。它由切片起始点的引用和切片长度组成。
second_word 也可以返回切片:
fn second_word(s: &String) -> &str {
现在 API 更简单,且更难出错,因为编译器会确保 String 的引用有效。如果我们拿到第一个单词的索引后清空字符串,索引就无效了。那段代码逻辑上有错,但不会立刻报错。如果继续用 first_word
索引空字符串,问题才会暴露。切片让这种 bug 不可能发生,能更早发现问题。用切片版 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}");
}
编译器报错如下:
$ 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);
| – 此处发生不可变借用
17 |
18 | s.clear(); // 报错!
| ^^^^^^^^^ 此处发生可变借用
19 |
20 | println!(“the first word is: {word}”);
| ------ 不可变借用稍后在此处使用
查看更多信息请运行 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 更易用,还在编译期消除了整类错误!
字符串字面量也是切片
还记得我们说过字符串字面量存储在二进制文件中。现在了解切片后,可以更好地理解字符串字面量:
let s = "Hello, world!";
这里 s 的类型是 &str:它是指向二进制中特定位置的切片。这也是为什么字符串字面量是不可变的;&str 是不可变引用。
用切片作为参数
既然可以对字面量和 String
取切片,我们还可以改进 first_word
的签名:
fn first_word(s: &String) -> &str {
更有经验的 Rustacean 会写成下面这样,因为这样既能处理 &String
,也能处理 &str
:
fn first_word(s: &str) -> &str {
如果有字符串切片,可以直接传递。如果有 String
,可以传 String 的切片或引用。这种灵活性得益于 deref
强制转换。
让函数接收字符串切片而不是 &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 的引用,相当于整个切片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]
。它和字符串切片一样,存储对第一个元素的引用和长度。你会在各种集合中用到这种切片。
小结
所有权、借用和切片确保了 Rust 程序在编译期的内存安全。Rust 让你像其他系统编程语言一样控制内存,但数据所有者自动在作用域结束时清理数据,无需你手动写和调试额外的代码。