键盘快捷键

按 ← 或 → 在章节间切换

按 S 或 / 搜索本书

按 ? 显示此帮助

按 Esc 隐藏此帮助

使用字符串存储 UTF-8 编码文本

我们在第 4 章讨论过字符串,现在将更深入地了解它。Rust 初学者常常会被字符串难住,主要有三个原因:Rust 倾向于暴露潜在错误;字符串是一种比许多程序员想象中更复杂的数据结构;以及 UTF-8。如果你来自其他编程语言,这些因素结合起来可能会显得棘手。

我们在集合的语境中讨论字符串,是因为字符串由字节集合实现,并附带一些方法,以便在将这些字节解释为文本时提供实用功能。本节会介绍 String 与其他集合类型一样具有的操作,例如创建、更新和读取。我们还会讨论 String 与其他集合不同之处,也就是人类和计算机对 String 数据的解释方式不同,因此对 String 进行索引会比较复杂。

定义字符串

我们先来定义“字符串”这个术语的含义。Rust 核心语言中只有一种字符串类型,即字符串切片 str,通常以其借用形式 &str 出现。在第 4 章中,我们讨论过字符串切片,它是对存储在其他位置的 UTF-8 编码字符串数据的引用。例如,字符串字面量存储在程序的二进制文件中,因此它们是字符串切片。

String 类型由 Rust 标准库提供,而不是内置于核心语言中;它是一种可增长、可变、拥有所有权且采用 UTF-8 编码的字符串类型。Rustacean 提到 Rust 中的“字符串”时,可能指 String 类型,也可能指字符串切片 &str,而不只是其中一种。虽然本节主要讨论 String,但这两种类型在 Rust 标准库中都被广泛使用,并且 String 和字符串切片都采用 UTF-8 编码。

创建新字符串

String 也支持许多与 Vec<T> 相同的操作,因为 String 实际上是一个字节向量的封装,并提供了一些额外的保证、限制和能力。Vec<T> 和 String 中工作方式相同的函数之一是用于创建实例的 new 函数,如示例 8-11 所示。

fn main() {
    let mut s = String::new();
}
示例 8-11:创建一个新的空 String

这一行创建了一个名为 s 的新空字符串,之后我们可以向其中载入数据。通常,我们会有一些想要作为字符串初始内容的数据。为此,我们可以使用 to_string 方法;任何实现了 Display trait 的类型都可以使用该方法,字符串字面量也实现了这个 trait。示例 8-12 展示了两个例子。

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
示例 8-12:使用 to_string 方法从字符串字面量创建 String

这段代码创建了一个包含 initial contents 的字符串。

我们也可以使用 String::from 函数从字符串字面量创建 String。示例 8-13 中的代码与示例 8-12 中使用 to_string 的代码等价。

fn main() {
    let s = String::from("initial contents");
}
示例 8-13:使用 String::from 函数从字符串字面量创建 String

由于字符串有许多用途,我们可以使用多种适用于字符串的通用 API,因此有很多选择。其中一些看起来可能是重复的,但它们各有用途!在这个例子中,String::from 和 to_string 完成相同的工作,因此选择哪一个取决于代码风格和可读性。

请记住,字符串采用 UTF-8 编码,因此可以在其中包含任何正确编码的数据,如示例 8-14 所示。

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
示例 8-14:在字符串中存储不同语言的问候语

这些都是有效的 String 值。

更新字符串

和 Vec<T> 一样,向 String 中添加更多数据后,它的大小可以增长,内容也可以改变。此外,你还可以方便地使用 + 运算符或 format! 宏来拼接 String 值。

使用 push_str 或 push 追加内容

我们可以使用 push_str 方法追加字符串切片来扩展 String,如示例 8-15 所示。

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
示例 8-15:使用 push_str 方法将字符串切片追加到 String

执行这两行代码后,s 将包含 foobar。push_str 方法接收字符串切片,因为我们不一定想要取得参数的所有权。例如,在示例 8-16 的代码中,我们希望将 s2 的内容追加到 s1 后,仍然能够使用 s2。

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
示例 8-16:将字符串切片的内容追加到 String 后继续使用它

如果 push_str 方法取得了 s2 的所有权,我们就无法在最后一行打印它的值。不过,这段代码的行为符合预期!

push 方法接收单个字符作为参数,并将其添加到 String 中。示例 8-17 使用 push 方法将字母 l 添加到 String 中。

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
示例 8-17:使用 push 向 String 值添加一个字符

执行后,s 将包含 lol。

使用 + 或 format! 进行拼接

通常,你会想要组合两个已有字符串。一种方法是使用 + 运算符,如示例 8-18 所示。

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
示例 8-18:使用 + 运算符将两个 String 值组合成一个新的 String 值

字符串 s3 将包含 Hello, world!。相加后 s1 不再有效,以及我们使用 s2 的引用,原因都与使用 + 运算符时所调用方法的签名有关。+ 运算符会使用 add 方法,其签名大致如下:

fn add(self, s: &str) -> String {

在标准库中,你会看到 add 使用泛型和关联类型定义。这里我们代入了具体类型,这正是使用 String 值调用该方法时发生的情况。我们会在第 10 章讨论泛型。这个签名提供了理解 + 运算符复杂之处所需的线索。

首先,s2 前面有 &,这意味着我们将第二个字符串的引用添加到第一个字符串中。这是由 add 函数的 s 参数决定的:我们只能将字符串切片添加到 String 中,不能将两个 String 值直接相加。不过,&s2 的类型是 &String,而不是 add 第二个参数指定的 &str。那么,为什么示例 8-18 可以编译?

我们能够在调用 add 时使用 &s2,是因为编译器可以将 &String 参数强制转换为 &str。调用 add 方法时,Rust 会使用解引用强制转换,在这里将 &s2 转换为 &s2[..]。我们会在第 15 章更深入地讨论解引用强制转换。由于 add 不会取得 s 参数的所有权,执行此操作后 s2 仍然是有效的 String。

其次,从签名中可以看到,add 会取得 self 的所有权,因为 self 前面没有 &。这意味着示例 8-18 中的 s1 会被移动到 add 调用中,此后不再有效。因此,尽管 let s3 = s1 + &s2; 看起来像是复制两个字符串并创建一个新字符串,这条语句实际上会取得 s1 的所有权,追加一份 s2 内容的副本,然后返回结果的所有权。换句话说,它看起来好像进行了很多复制,但实际上并没有;这种实现比复制更高效。

如果需要拼接多个字符串,+ 运算符的用法会变得很繁琐:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

此时,s 将是 tic-tac-toe。由于有很多 + 和 " 字符,代码意图不太容易看清。要以更复杂的方式组合字符串,我们可以改用 format! 宏:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

这段代码同样会将 s 设置为 tic-tac-toe。format! 宏的工作方式类似于 println!,但它不会将输出打印到屏幕上,而是返回一个包含内容的 String。使用 format! 的代码更容易阅读,而且该宏生成的代码会使用引用,因此这次调用不会取得任何参数的所有权。

字符串索引

在许多其他编程语言中,通过索引访问字符串中的单个字符是一种有效且常见的操作。然而,在 Rust 中,如果你尝试使用索引语法访问 String 的部分内容,就会遇到错误。请看示例 8-19 中无效的代码。

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
示例 8-19:尝试对 String 使用索引语法

这段代码会产生以下错误:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: `usize` implements trait `SliceIndex<T>`
 --> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/slice/index.rs:214:0
  |
  = note: `SliceIndex<[T]>`
 --> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/bstr/traits.rs:197:0
  |
  = note: `SliceIndex<ByteStr>`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

错误信息已经说明了问题:Rust 字符串不支持索引。但为什么呢?要回答这个问题,我们需要讨论 Rust 如何在内存中存储字符串。

内部表示

String 是对 Vec<u8> 的封装。我们来看看示例 8-14 中一些经过正确编码的 UTF-8 字符串。先看这个:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

在这个例子中,len 的值是 4,这意味着存储字符串 "Hola" 的向量长度为 4 字节。UTF-8 编码中的每个字母占 1 字节。然而,下面这行代码可能会让你感到意外(请注意,这个字符串以西里尔字母大写字母 Ze 开头,而不是数字 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

如果问你这个字符串有多长,你可能会回答 12。事实上,Rust 的答案是 24:这是 UTF-8 编码“Здравствуйте”所需的字节数,因为该字符串中的每个 Unicode 标量值都占用 2 字节。因此,字符串字节的索引不一定对应有效的 Unicode 标量值。下面这段无效的 Rust 代码可以说明这一点:

let hello = "Здравствуйте";
let answer = &hello[0];

你已经知道,answer 不会是第一个字母 З。在 UTF-8 编码中,З 的第一个字节是 208,第二个字节是 151,所以看起来 answer 实际上应该是 208;但 208 本身并不是有效字符。如果用户想要这个字符串的第一个字母,返回 208 很可能不是他们想要的结果;然而,这正是 Rust 在字节索引 0 处唯一拥有的数据。即使字符串只包含拉丁字母,用户通常也不希望得到字节值:如果 &"hi"[0] 是有效代码并返回字节值,那么它返回的会是 104,而不是 h。

因此,为了避免返回意料之外的值并造成可能无法立即发现的错误,Rust 完全不编译这段代码,从而在开发早期就防止误解。

字节、标量值和字素簇

关于 UTF-8 还有一点需要注意:从 Rust 的角度来看,字符串实际上有三种相关的观察方式:字节、标量值和字素簇(最接近我们所说的字母)。

以天城文书写的印地语单词“नमस्ते”为例,它以如下所示的 u8 值向量存储:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

这总计 18 字节,也是计算机最终存储这些数据的方式。如果把它们视为 Unicode 标量值,也就是 Rust 的 char 类型所表示的值,那么这些字节如下所示:

['न', 'म', 'स', '्', 'त', 'े']

这里有六个 char 值,但第四个和第六个并不是字母:它们是单独出现时没有意义的附加符号。最后,如果把它们视为字素簇,我们就会得到人们认为构成这个印地语单词的四个字母:

["न", "म", "स्", "ते"]

Rust 提供了多种解释计算机所存储原始字符串数据的方式,因此无论数据使用哪种人类语言,每个程序都可以选择自己需要的解释方式。

Rust 不允许我们通过对 String 索引来获取字符,最后一个原因是索引操作通常应始终具有常数时间复杂度(O(1))。但对 String 来说无法保证这种性能,因为 Rust 必须从头开始遍历内容,直到到达指定索引,才能确定其中包含多少个有效字符。

字符串切片

对字符串进行索引通常不是好主意,因为字符串索引操作的返回类型并不明确:它应该是字节值、字符、字素簇,还是字符串切片?因此,如果你确实需要通过索引创建字符串切片,Rust 要求你明确指定范围。

你可以不使用单个数字通过 [] 索引,而是使用 [] 和一个范围来创建包含特定字节的字符串切片:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

这里,s 将是一个 &str,包含字符串的前 4 个字节。前面提到过,这些字符各占 2 字节,因此 s 将是 Зд。

如果我们尝试使用 &hello[0..1] 之类的写法,只切取某个字符的一部分字节,Rust 就会在运行时触发 panic,和访问向量中的无效索引时一样:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' (6017738) panicked at src/main.rs:4:19:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

使用范围创建字符串切片时应当谨慎,因为这样做可能会导致程序崩溃。

遍历字符串

处理字符串片段时,最好明确说明你需要的是字符还是字节。对于单个 Unicode 标量值,请使用 chars 方法。对“Зд”调用 chars 会将其拆分并返回两个 char 类型的值,你可以遍历结果来访问每个元素:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

这段代码会打印以下内容:

З
д

或者,bytes 方法会返回每个原始字节,这可能更适合你的应用场景:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

这段代码会打印组成该字符串的 4 个字节:

208
151
208
180

但请务必记住,有效的 Unicode 标量值可能由多个字节组成。

从字符串中获取字素簇(例如天城文中的字素簇)比较复杂,因此标准库没有提供此功能。如果你需要这一功能,可以在 crates.io 上找到相关 crate。

处理字符串的复杂性

总而言之,字符串很复杂。不同编程语言对如何向程序员呈现这种复杂性做出了不同选择。Rust 选择将正确处理 String 数据作为所有 Rust 程序的默认行为,这意味着程序员必须从一开始就更加认真地考虑如何处理 UTF-8 数据。这种权衡会比其他编程语言更明显地展现字符串的复杂性,但它能让你避免在开发周期后期处理涉及非 ASCII 字符的错误。

好消息是,标准库基于 String 和 &str 类型提供了许多功能,帮助你正确处理这些复杂情况。请查看文档,了解一些实用方法,例如用于在字符串中搜索的 contains,以及用于将字符串的一部分替换为另一个字符串的 replace。

接下来我们换个简单一些的主题:哈希映射!