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


我们在第 4 章中讨论了字符串,但现在我们将更深入地研究它们。新的 Rustacean 通常会卡在字符串上,原因有三个:Rust 倾向于暴露可能的错误,字符串是一种比许多程序员认为的更复杂的数据结构,以及 UTF-8。这些因素以一种当您来自其他编程语言时似乎很困难的方式组合在一起。


我们在集合的上下文中讨论字符串,因为字符串是作为字节集合实现的,加上一些方法,当这些字节被解释为文本时,这些方法可以提供有用的功能。在本节中,我们将讨论每个集合类型对 String 的作,例如创建、更新和读取。我们还将讨论 String 与其他集合不同,即索引到 String 中的方式 因人和计算机解释方式的差异而变得复杂 字符串数据。


什么是字符串?


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


String 类型由 Rust 的标准库提供,而不是编码到核心语言中,是一种可增长、可变、拥有的 UTF-8 编码字符串类型。当 Rustacean 在 Rust 中提到 “strings” 时,他们可能指的是 String 或字符串 slice &str 类型,而不仅仅是其中一种类型。虽然本节主要是关于 String 的,但这两种类型在 Rust 的标准库中都被大量使用,并且 String 和 String 切片都是 UTF-8 编码的。


创建新字符串


Vec<T> 中提供的许多相同作也可用于 String 还因为 String 实际上是作为字节向量的包装器实现的,具有一些额外的保证、限制和功能。与 Vec<T>String 以相同方式工作的函数示例是新的 函数创建一个实例,如示例 8-11 所示。

fn main() {
    let mut s = String::new();
}


示例 8-11:创建一个新的空 String


此行创建一个名为 s 的新空字符串,然后我们可以将数据加载到该字符串中。通常,我们会有一些初始数据来开始字符串。为此,我们使用 to_string 方法,该方法可用于任何实现 Display 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::from 从字符串文字创建 String。示例 8-13 中的代码等同于示例 8-12 中使用 to_string 的代码。

fn main() {
    let s = String::from("initial contents");
}


示例 8-13:使用 String::from 函数从字符串文字创建 String


因为字符串用于很多事情,所以我们可以对字符串使用许多不同的通用 API,为我们提供了很多选择。其中一些可能看起来多余,但它们都有自己的位置!在本例中,String::fromto_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 值。


更新字符串


如果你将更多数据推送到 String 中,String 的大小可能会增加,其内容也会发生变化,就像 Vec<T> 的内容一样。此外,您可以方便地使用 + 运算符或 format! 宏来连接 String 值。


使用 push_strpush 追加到 String


我们可以通过使用 push_str 方法附加一个字符串 slice 来增加一个 String,如示例 8-15 所示。

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}


示例 8-15:将字符串 slice 附加到 String 使用 push_str 方法


在这两行之后,s 将包含 foobarpush_str 方法采用 string slice 的 Fragment 中,因为我们不一定想获得 参数。例如,在示例 8-16 的代码中,我们希望能够使用 s2 中。

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}


示例 8-16:将字符串 slice 的内容附加到 String 后使用 string slice


如果 push_str 方法获得了 s2 的所有权,我们将无法在最后一行打印其值。但是,此代码的工作方式符合我们的预期!


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

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}


示例 8-17:使用 pushString 值添加一个字符


因此,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 参数 function:我们只能给 String 添加一个 &str;我们不能添加两个 String 值一起。但是等等 — &s2 的类型是 &String,而不是 &str,如第二个要添加的参数中指定的那样。那么,为什么示例 8-18 会编译呢?


我们能够在 add 调用中使用 &s2 的原因是编译器可以将 &String 参数强制转换为 &str。当我们调用 add 方法中,Rust 使用了 deref 强制转换,这里将 &s2 转换为 &s2[..]。我们将在第 15 章中更深入地讨论 deref coercion。由于 add 不获取 s 参数的所有权,因此 s2 仍将是有效的 String 在此作之后。


其次,我们可以在 signature 中看到 add 获得了 self 的所有权 因为 self 没有 &.这意味着示例 8-18 中的 s1 将被移动到 add 调用中,之后将不再有效。所以,尽管 设 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 将是井字棋。使用所有 + 角色,很难看清发生了什么。用于组合字符串 更复杂的方式,我们可以改用 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-toeformat!宏的工作方式类似于 println!,但它不是将输出打印到屏幕上,而是返回一个 String 包含内容。使用 format! 的代码版本更易于阅读,并且由 format! 宏生成的代码使用引用,因此此调用不会获得其任何参数的所有权。


索引到字符串中


在许多其他编程语言中,通过索引引用字符串中的单个字符是一种有效且常见的作。但是,如果你尝试在 Rust 中使用索引语法访问 String 的各个部分,则会收到错误。考虑示例 8-19 中的无效代码。

fn main() {
    let s1 = String::from("hello");
    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}`, which is required by `String: Index<_>`
  = 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: the trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = 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 编码时,这些字母中的每一个都占用一个字节。但是,以下行可能会让您感到惊讶(请注意,此字符串以大写的西里尔字母 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];


你已经知道答案不会是 З,第一个字母。当用 UTF-8 编码时,З 的第一个字节是 208,第二个字节是 151,所以看起来答案实际上应该是 208,但 208 本身并不是一个有效的字符。如果用户要求此字符串的第一个字母,则返回 208 可能不是用户想要的;但是,这是 Rust 在字节索引 0 处拥有的唯一数据。用户通常不希望返回字节值,即使字符串仅包含拉丁字母:如果 &“hello”[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 必须从头到索引遍历内容,以确定有多少个有效字符。


切片字符串


索引到字符串中通常是一个坏主意,因为不清楚字符串索引作的返回类型应该是什么:字节值、字符、字形簇或字符串切片。因此,如果你真的需要使用索引来创建字符串 slice,那么 Rust 会要求你更具体一些。


你可以将 [] 与范围一起使用来创建包含特定字节的字符串切片,而不是使用带有单个数字的 [] 进行索引:

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

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


这里, s 将是一个 &str ,其中包含字符串的前四个字节。 前面我们提到过,这些字符中的每一个都是两个字节,这意味着 s 将为 Зд


如果我们尝试用类似 &hello[0..1],Rust 会在运行时 panic,就像在 vector 中访问无效索引一样:

$ 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' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


在创建具有 range 的字符串切片时应小心,因为这样做可能会使程序崩溃。


迭代字符串的方法


对字符串片段进行作的最佳方法是明确说明 您需要字符或字节。对于单个 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}");
}
}


此代码将打印组成此字符串的四个字节:


208 元 151 208 180


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


与 Devanagari 脚本一样,从字符串中获取字形簇很复杂,因此标准库不提供此功能。如果这是您需要的功能,crates.io 上提供了 Crate。


字符串不是那么简单


总而言之,字符串很复杂。不同的编程语言对如何向程序员呈现这种复杂性做出了不同的选择。Rust 选择将正确处理 String 数据作为所有 Rust 程序的默认行为,这意味着程序员必须预先考虑处理 UTF-8 数据。与其他编程语言相比,这种权衡暴露了字符串的复杂性,但它可以防止您在开发生命周期的后期处理涉及非 ASCII 字符的错误。


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


让我们切换到稍微简单一点的东西:哈希映射!