Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

切片类型

切片让你可以引用集合中连续的一段元素。切片是一种引用,因此它不拥有数据。

来看一个小编程题:编写一个函数,接收一串由空格分隔的单词,并返回找到的第一个单词。如果字符串中没有空格,那么整个字符串就是一个单词,应当返回整个字符串。

注意:为了介绍切片,本节仅假设使用 ASCII 字符;关于 UTF-8 处理的更完整讨论,请参阅第 8 章“使用字符串存储 UTF-8 编码文本”一节。

让我们先看看不使用切片时该如何编写函数签名,从而理解切片要解决的问题:

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()
}

fn main() {}
代码清单 4-7:first_word 函数返回 String 参数中的字节索引

由于我们需要逐个遍历 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 函数的程序。

文件名: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()
}

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 s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}
代码清单 4-8:保存调用 first_word 函数的结果,然后修改 String 的内容

这个程序可以成功编译;即使我们在调用 s.clear() 后使用 word,它也能编译。由于 word 与 s 的状态完全没有关联,它仍然保存着值 5。我们可以尝试使用值 5 和变量 s 提取第一个单词,但这会导致 bug,因为自从我们将 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];
}
the heap. The third table represents the stack data of the slice world, which

与引用整个 String 不同,hello 引用的是 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 用示意图展示了这一点。

三张表:一张表表示栈上的 s,并指向堆上字符串数据表中索引 0 处的字节。第三张表表示切片 world 的栈数据,长度为 5,并指向堆数据表中索引 6 处的字节。

图 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 字符边界。如果尝试在多字节字符的中间创建字符串切片,程序就会报错并退出。

了解这些信息后,让我们重写 first_word,使它返回切片。表示“字符串切片”的类型写作 &str:

Filename: 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[..]
}

fn main() {}

我们像代码清单 4-7 中那样,通过查找第一个空格来获取单词末尾的索引。找到空格后,我们以字符串开头和空格索引作为起止位置,返回一个字符串切片。

现在调用 first_word 时,我们会得到一个与底层数据关联的单一值。这个值由指向切片起始位置的引用以及切片中元素的数量组成。

让 second_word 函数返回切片也同样可行:

fn second_word(s: &String) -> &str {

现在我们有了一个简单直接、不容易出错的 API,因为编译器会确保指向 String 的引用始终有效。还记得代码清单 4-8 中的 bug 吗?我们获得第一个单词末尾的索引后清空了字符串,导致索引失效。那段代码在逻辑上有误,却不会立即报错。如果继续使用空字符串和第一个单词的索引,问题才会显现。切片让这个 bug 不可能发生,并能更早提醒我们代码存在问题。使用返回切片的 first_word 版本会触发编译时错误:

Filename: 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[..]
}

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);
}
代码清单 4-9:将 s 参数的类型改为字符串切片,以改进 first_word 函数

如果有字符串切片,可以直接传入。如果有 String,可以传入它的切片或对它的引用。这种灵活性利用了解引用强制转换功能,我们会在第 15 章“在函数和方法中使用解引用强制转换”一节介绍。

将函数参数定义为字符串切片,而不是 String 的引用,可以在不损失任何功能的前提下,让 API 更通用、更实用:

Filename: src/main.rs
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 中许多其他部分的工作方式,因此本书后续还会继续讨论这些概念。现在进入第 5 章,看看如何使用 struct 将数据组合在一起。