【Rust学习】06_切片

2024-08-08 13:05:31 浏览数 (2)

前言

这一章我们一起来学习下切片类型,通过切片,您可以引用集合中连续的元素序列,而不是整个集合。切片是一种引用,因此它没有所有权。

内容

切片类型

这里有一个小的编程问题:编写一个函数,该函数接受一个由空格分隔的单词字符串,并返回它在该字符串中找到的第一个单词。如果函数在字符串中找不到空格,则整个字符串必须是一个单词,因此应返回整个字符串。

让我们来看看如何在不使用切片的情况下编写此函数的签名,以理解切片将解决的问题:

代码语言:rust复制
fn first_word(s: &String) -> ?

first_word 函数有一个参数 &String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取 部分 字符串的办法。不过,我们可以返回单词结尾的索引。

代码语言:rust复制
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 中的值是否为空格,需要用 as_bytes 方法将 String 转化为字节数组:

代码语言:rust复制
let bytes = s.as_bytes();

接下来,使用 iter 方法在字节数组上创建一个迭代器:

代码语言:rust复制
 for (i, &item) in bytes.iter().enumerate() {

我们将在后续详细讨论迭代器。现在,只需知道 iter 方法返回集合中的每一个元素,而 enumerate 包装了 iter 的结果,将这些元素作为元组的一部分来返回。从 enumerate 返回的元组的第一个元素是索引,第二个元素是对集合中元素的引用。这比自己计算索引要方便一些。

由于 enumerate 方法返回一个元组,因此我们可以使用模式来解构该元组。我们将在后续进一步讨论有关模式的问题。在 for 循环中,我们指定了一个模式,该模式 i 表示元组中的索引,&item 表示元组中的单个字节。因为我们从 .iter().enumerate() 获取了对集合元素的引用,所以我们在模式中使用 &

for 循环中,我们通过字节的字面量语法来搜索代表空格的字节。如果找到了一个空格,返回它的位置。否则,使用 s.len() 返回字符串的长度:

代码语言:rust复制
   if item == b' ' {
            return i;
        }
    }

    s.len()

我们现在有一种方法可以找出字符串中第一个单词末尾的索引,但是有一个问题。我们单独返回一个 usize,但它在 &String 的上下文中只是一个有意义的数字。换言之,由于它是独立于 String 的值,因此无法保证它将来仍然有效。现在让我们来使用first_word 函数。

代码语言:rust复制
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word 的值为 5

    s.clear(); // 这清空了字符串,使其等于 ""

    // word 在此处的值仍然是 5,
    // 但是没有更多的字符串让我们可以有效地应用数值 5。word 的值现在完全无效!
}

这个程序编译时没有任何错误,而且在调用 s.clear() 之后使用 word 也不会出错。因为 word 与 s 状态完全没有联系,所以 word 仍然包含值 5。可以尝试用值 5 来提取变量 s 的第一个单词,不过这是有 bug 的,因为在我们将 5 保存到 word 之后 s 的内容已经改变。

们不得不时刻担心 word 的索引与 s 中的数据不再同步,这很啰嗦且易出错!如果编写这么一个 second_word 函数的话,管理索引这件事将更加容易出问题。它的签名看起来像这样:

代码语言:rust复制
fn second_word(s: &String) -> (usize, usize) {

现在,我们正在跟踪起始索引和结束索引,并且我们有更多的值,这些值是从特定状态的数据计算得出的,但与该状态完全无关。我们有三个不相关的变量需要保持同步。

幸运的是,Rust 有一个解决这个问题的方法:字符串切片。

字符串切片

字符串切片是对 String 的一部分的引用,它看起来像这样:

代码语言:rust复制
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];

你好不是对整个 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

https://www.processon.com/embed/66b2bf2c80d3552cffa4ecaa?cid=66b2bf2c80d3552cffa4ecad

对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

代码语言:rust复制
fn main() {
    let s = String::from("hello");

    let slice1 = &s[0..2];
    let slice2 = &s[..2];
    println!("{}", slice1);
    println!("{}", slice2);
}

同样,如果切片包含 String 的最后一个字节,则可以删除尾随数字。

代码语言:rust复制
fn main() {
    let s = String::from("hello");

    let len = s.len();

    let slice1 = &s[3..len];
    let slice2 = &s[3..];
    println!("{}", slice1);
    println!("{}", slice2)
}

也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:

代码语言:rust复制
fn main() {
    let s = String::from("hello");

    let len = s.len();

    let slice1 = &s[..len];
    let slice2 = &s[..];
    println!("{}", slice1);
    println!("{}", slice2)
}

注意:字符串切片范围索引必须出现在有效的 UTF-8 字符边界内。如果尝试在多字节字符的中间创建字符串切片,则程序将退出并显示错误。

考虑到所有这些信息,让我们重写first_word以返回一个切片。表示“字符串切片”的类型写为 &str

代码语言:rust复制
fn main() {
    let my_string = String::from("hello world");
    let word = first_word(&my_string);
    println!("{}", 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[..]
}

我们按着前面相同的方式获取单词末尾的索引,即查找空格的第一次出现。当我们找到一个空格时,我们返回一个字符串切片,使用字符串的开始和空格的索引作为开始和结束索引。

现在,当我们调用 first_word 时,我们会返回一个与基础数据相关联的值。该值由对切片起点的引用和切片中的元素数组成。

返回切片也适用于second_word函数:

代码语言:rust复制
fn second_word(s: &String) -> &str {

我们现在有一个简单的 API,因为编译器将确保对 String 的引用保持有效。还记得前面程序中的错误吗,当时我们获取了第一个单词末尾的索引,但随后清除了字符串,因此我们的索引无效?该代码在逻辑上是错误的,但没有立即显示任何错误。如果我们继续尝试使用第一个带有空字符串的单词索引,问题就会暴露出来。slice 就不可能出现这种 bug 并让我们更早的知道出问题了。使用 slice 版本的 first_word 会抛出一个编译时错误:

代码语言:rust复制
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {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[..]
}

编译错误:

代码语言:shell复制
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:87:5
   |
85 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
86 |
87 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
88 |
89 |     println!("the first word is: {word}");
   |                                  ------ immutable borrow later used here

回忆一下借用规则,如果我们对某物有一个不可变的引用,我们就不能也接受一个可变的引用。因为 clear 需要清空 String,所以它需要获取一个可变的引用。在调用 clear 之后的 println! 使用了 word 中的引用,所以这个不可变的引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。Rust 不仅使得我们的 API 简单易用,也在编译时就消除了一整类的错误!

字符串字面量就是切片

回想一下,我们讨论过将字符串文字存储在二进制文件中。现在我们知道了切片,我们可以正确理解字符串字面量了:

代码语言:rust复制
let s = "Hello, world!";

这里的 s 类型是 &str:它是一个指向二进制文件特定位置的切片。这也是字符串字面量是不可变的原因,&str 是不可变的引用。

字符串切片作为参数

在知道了能够获取字面量和 String 的 slice 后,我们对 first_word 做了改进:

代码语言:rust复制
fn first_word(s: &String) -> &str {

更有经验的 Rustacean 会如下编写,因为它允许我们在 &String 值和 &str 值上使用相同的函数。

代码语言:rust复制
fn first_word(s: &str) -> &str {

如果我们有一个字符串切片,我们可以直接传递它。如果我们有一个 String,我们可以传递 String 的一个切片或对 String 的引用。这种灵活性利用了deref coercions 的优势。

定义一个函数来获取字符串切片而不是对 String 的引用,使我们的 API 更加通用和有用,而不会丢失任何功能:

代码语言:rust复制
fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole
    let word1 = first_word(&my_string[0..6]);
    let word2 = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s
    let word3 = first_word(&my_string);
    println!("1{}", word1);
    println!("2{}", word2);
    println!("3{}", word3);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or whole
    let word4 = first_word(&my_string_literal[0..6]);
    let word5 = first_word(&my_string_literal[..]);
    println!("4{}", word4);
    println!("5{}", word5);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word6 = first_word(my_string_literal);
    println!("6{}", word6);
}
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[..]
}

其他切片

正如您可能想象的那样,字符串切片特定于字符串。但也有一种更通用的切片类型。请考虑以下数组:

代码语言:rust复制
fn main() {
	let a = [1, 2, 3, 4, 5];
}

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。我们可以这样做:

代码语言:rust复制
fn main() {
    let a = [1, 2, 3, 4, 5];
    println!("a has {} elements", a.len());
    let slice = &a[1..3];
    println!("{:?}", slice);
}

此切片的类型为 &[i32]。它的工作方式与字符串切片相同,通过存储对第一个元素的引用和一个集合总长度。您将把这种切片用于各种其他集合。

总结

所有权、借用和切片的概念确保了 Rust 程序在编译时的内存安全。Rust 语言提供了跟其他系统编程语言相同的方式来控制你使用的内存,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。

所有权会影响 Rust 其他部分的工作方式,因此我们将在本书的其余部分进一步讨论这些概念。让我们继续阅读第 5 章,来看看如何将多份数据组合进一个 struct 中。

0 人点赞