使用 String 存储 UTF-8 编码的文本

在第 4 章中,我们曾讨论过字符串,现在我们将对其进行更深入的研究。新的 Rust 公民通常卡在字符串上,原因有三:

  • Rust 有暴露可能错误的倾向;
  • 字符串是种比许多程序员认为的更复杂的数据结构;
  • 以及 UTF-8。

当咱们从其他编程语言转至 Rust 时,这些因素就会以看起来很难的方式,纠缠在一起。

我们之所以会在集合语境下讨论字符串,是因为字符串是作为字节集合,加上一些在这些字节被解释为文本时,提供有用功能的方法实现的。在本节中,我们将讨论那些 String 上的、每种集合类型都有的操作,比如创建、更新及读取等。我们还将讨论 String 与其他集合的不同之处,即由于人与计算机在解析 String 数据时的差异,而对 String 的索引会变得如何复杂。

何为字符串?

我们将首先定义出所谓 字符串 的含义。Rust 的核心语言中,只有一种字符串类型,即通常以其借用形式 &str 出现的字符串切片 str。在第 4 章我们曾谈到过 字符串片切片,他们是对存储在别处的 UTF-8 编码字符串数据的一些引用。例如,字符串的字面值,就存储在程序的二进制文件中,而因此属于一些字符串的切片。

所谓 String 类型,则是由 Rust 的标准库提供,而未被编码到核心语言,是种可增长、可变、有所有权、UTF-8 编码的字符串类型。当 Rust 公民提及 Rust 中的 “字符串” 时,他们可能指的是 String 或字符串切片的 &str 类型,而不单是其中一种类型。虽然本节主要讨论 String,但这两种类型在 Rust 的标准库中都有大量使用,同时 String 和字符串切片均为 UTF-8 编码的。

创建一个新的 String

Vec<T> 的许多相同操作对 String 也可使用,因为 String 实际上是作为字节矢量的封装器实现的,不过带有一些额外的保证、限制及功能。Vec<T>String 下以同一方式工作的函数示例,便是创建出一个实例的 new 函数,如下清单 8-11 所示。

#![allow(unused)]
fn main() {
    let mut s = String::new();
}

清单 8-11:创建一个新的空 String

这行代码会创建出一个名为 s 的新空字符串,然后我们就可以向其中加载数据。通常情况下,我们会使用一些咱们开始该字符串的初始数据。为此,我们会使用 to_string 方法,该方法适用于任何实现了 Display 特质的类型,正如字符串字面量那样。下面清单 8-12 展示了两个示例。

#![allow(unused)]
fn main() {
    let data = "初始内容";

    let s = data.to_string();

    // 该方法同样直接工作于字面值之上
    let s = "初始内容".to_string();
}

清单 8-12:使用 to_string 方法从某个字符串字面值创建出 String

这段代码会创建出一个包含 初始内容 的字符串。

我们还可以使用函数 String::from,从某个字符串字面量创建出字符串。下面清单 8-13 中的代码,等同于清单 8-12 中使用 to_string 的代码。

#![allow(unused)]
fn main() {
    let s = String::from("初始内容");
}

清单 8-13:使用 String::from 函数,从某个字符串字面值创建出 String

由于字符串的用途非常广泛,我们可以使用许多不同的字符串通用 API,从而为我们提供了大量选择。其中有些看起来是多余的,但他们都有自己的用武之地!在这个示例中,String::fromto_string 做的是同一件事,所以咱们选择哪个只是风格与可读性的问题。

请记住,字符串是 UTF-8 编码的,因此我们可以在其中包含任何正确编码的数据,如下清单 8-14 所示。

#![allow(unused)]
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");
    let hello = String::from("👋");
}

清单 8-14:以字符串存储不同语言的问候语

所有这些都是有效的 String 值。

更新字符串

在咱们向某个 String 中压入更多数据时,其大小和内容都会发生变化,就像 Vec<T> 的内容一样。此外,咱们还可以方便地使用 + 运算符或 format! 宏,连接多个 String 值。

使用 push_strpush 追加内容到某个 String

如下清单 8-15 所示,通过使用 push_str 方法追加一个字符串切片,我们可以增长某个 String

#![allow(unused)]
fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
    println! ("{}", s);
}

清单 8-15:使用 push_str 方法将字符串切片追加到某个 String

在这两行后,s 将包含 foobarpush_str 方法会取个字符串切片,因为我们并不会想要取得那个参数的所有权。例如,在下面清单 8-16 中的代码中,我们打算在将 s2 的内容追加到 s1 后,能够使用 s2

#![allow(unused)]
fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";

    s1.push_str(s2);

    println! ("{}", s2);
}

清单 8-16:在将其内容追加到某个 String 后,使用该字符串切片

如果 push_str 方法取得了 s2 的所有权,我们就无法在最后那行打印出他的值。然而,这段代码会如我们与其那样工作!

push 方法会取单个字符作为参数,并将其添加到 String。下面清单 8-17 使用 push 方法将字母 l 追加到某个 String

#![allow(unused)]
fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

清单 8-17:使用 push 将一个字符添加到 String

作为结果,s 将包含 lol

使用 + 运算符或 format! 宏的字符串连接

Concatenation with the + Operator or the format! Macro

通常情况下,咱们会想要合并两个现有字符串。一种方法是使用 + 运算符,如下清单 8-18 所示。

#![allow(unused)]
fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2;  // 请注意这里 s1 已被迁移,而不能再被使用
}

清单 8-18:使用 + 运算符合并两个 String 值为新的 String

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

#![allow(unused)]
fn main() {
fn add(self, s: &str) -> String
}

在标准库中,咱们会看到使用了泛型及关联类型定义的 add。在这里,我们代入了具体类型,这就是我们对 String 值调用此方法时的情况。我们将在第 10 章讨论 泛型。这个签名为我们提供了理解 + 这个运算符中,棘手部分所需的线索。

首先,s2 带有一个 &,这意味着我们是将第二个字符串的 引用,加到第一个字符串。这是因为 add 函数中的那个 s 参数:我们只能将一个 &str 加到某个 String;我们不能将两个 String 值加到一起。但是等等,&s2 的类型为 &String,而不是 &str,正如 add 的第二个参数所指定的那样。那么为什么清单 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 内容的一份副本追加到 s1 上,然后返回结果的所有权。换句话说,虽然看起来像是进行了多次拷贝,但实际上并非如此;该实现比拷贝更高效。

在我们需要连接多个字符串时,+ 运算符的行为会变得笨重:

#![allow(unused)]
fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("toc");
    let s3 = String::from("toe");

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

此时,s 将是 tic-tac-toe。由于包含大量 +" 字符,很难看清具体情况。对于更复杂的字符串合并,我们可以改用 format! 这个宏:

#![allow(unused)]
fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("toc");
    let s3 = String::from("toe");

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

这段代码也会将 s 设置为 tic-toc-toeformat! 宏与 println! 类似,但他不会将输出打印到屏幕上,而是返回一个包含内容的 String。使用 format! 的代码版本更加易于阅读,且由 format! 宏生成的代码使用了引用,因此这个调用不会取得其任何参数的所有权。

字符串的索引

Indexing into Strings

在许多其他编程语言中,通过索引引用字符串中的单个字符,是种有效且常见的操作。然而,如果咱们尝试在 Rust 中使用索引语法,访问某个 String 的一些部分,咱们将得到一个报错。请设想下面列表 8-19 中的无效代码。

#![allow(unused)]
fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

清单 8-19:尝试对某个 String 使用索引语法

这段代码将导致一些报错:

$ cargo run
   Compiling string_demo v0.1.0 (/home/peng/rust-lang/projects/string_demo)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `string_demo` due to previous error

该报错与注释,揭示了问题所在:Rust 字符串不支持索引操作。但为什么不支持呢?要回答这个问题,我们需要讨论 Rust 是如何在内存中存储字符串的。

内部表示

Internal Representation

String 是对 Vec<u8> 的封装。我们来看看列表 8-14 中,咱们的那些正确编码的 UTF-8 示例字符串。首先,这个:

#![allow(unused)]
fn main() {
let hello = String::from("Hola");
}

在这种情况下,len 值将为 4,这意味着存储字符串 "Hola" 的矢量值长度为 4 字节。在以 UTF-8 编码时,这些字母每个占用一字节。然而,下面这行代码可能会让咱们感到意外(请注意,这个字符串以大写的西里尔字母 Ze 开头,而非数字 3):

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

在咱们被问及该字符串的长度时,你可能会说 12。事实上,Rust 的答案是 24:这是以 UTF-8 编码 “Здравствуйте” 所需的字节数,因为该字符串中的每个 Unicode 标量值,都需要 2 个字节的存储空间。因此,对字符串字节的索引,并不总是对应于有效的 Unicode 标量值。为了演示,请考虑下面这段无效的 Rust 代码:

#![allow(unused)]
fn main() {
    let hello = String::from("Здравствуйте");
    let answer = &hello[0];
}

咱们已经知道 answer 不会是 З,即第一个字母。当以 UTF-8 编码时,З 的第一个字节是 208,第二个字节是 151,因此 answer 似乎应是 208,但 208 本身并不是个有效的字符。在用户询问该字符串的首字母时,返回 208 可能并非用户所期望的结果;然而,这是 Rust 在字节索引 0 处唯一拥有的数据。用户通常不想要返回的字节值,即使该字符串仅包含拉丁字母:若 &"hi"[0] 是返回字节值的有效代码,他将返回 104,而非 h

因此,答案是:为避免返回非预期值,而引发可能不会立即被发现的错误,Rust 根本不会编译这段代码,从而在开发过程早期就防止误解。

字节、标量值与字素簇!我的天!

Bytes and Scalar Values and Grapheme Clusters! Oh My!

关于 UTF-8 的另一要点,是从 Rust 角度来看,实际上存在三种相关的字符串表示方式:

  • 字节;
  • 标量值
  • 以及字素簇(与我们所说的 字母 最接近的概念)。

在我们看到以梵文,Devanagari,书写的印地语单词 “नमस्ते” 时,他会被存储为一个 u8 值的矢量,看起来像这样:

#![allow(unused)]
fn main() {
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
}

这是 18 个字节,也是计算机最终存储这个数据的方式。若我们将其视为 Unicode 的标量值,即 Rust 的 char 类型,那么这些字节看起来如下:

#![allow(unused)]
fn main() {
['न', 'म', 'स', '्', 'त', 'े']
}

这里有六个 char 值,但第四和第六个不属于字母:他们是独立存在时没有意义的音标符号。最后,在我们将其视为字素簇是,我们就会得到人类所说的,构成印地语单词的四个字母:

#![allow(unused)]
fn main() {
["न", "म", "स्", "ते"]
}

Rust 提供了解析计算机所存储原始字符串数据的多种方法,以便每个程序都能根据需要选择合适的解析方式,而无论数据使用的何种人类语言。

Rust 不允许我们通过索引访问字符串中某个字符的最后一个原因,是索引操作被认为始终会消耗常数时间的复杂度(即 O(1) )。但对于 String 而言,无法保证这种性能,因为 Rust 必须从开头遍历到索引位置的内容,以确定其间有效字符的数量。

字符串切片

Slicing Strings

字符串索引操作通常是个糟糕的主意,因为字符串索引操作的返回类型并不明确:是字节值、字符、字素簇,还是个字符串切片。因此,在咱们确实需要使用索引,创建字符串切片时,Rust 会要求咱们更加明确。

与其将 [] 与单个数字一起使用,咱们可将 [] 与某个范围一起使用,创建出包含特定字节的字符串切片:

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

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

这里,s 将是个包含着该字符串前四个字节的 &str。早先我们提到,这些字符是两个字节,这意味着 s 将是 Зд

若我们尝试以类似 &hello[0..1] 的方式,只切取该某个字符字节部分时,Rust 将以访问某个矢量值中无效索引时的同一方式终止运行:

$ cargo run
   Compiling string_demo v0.1.0 (/home/hector/rust-lang-zh_CN/projects/string_demo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
     Running `target/debug/string_demo`
thread 'main' panicked at src/main.rs:3:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`

在以范围创建字符串切片时,咱们应谨慎操作,因为此类操作可能导致咱们的程序崩溃。

迭代字符串的方法

Methods for Iterating Over Strings

对字符串片段进行操作的最佳方式,是明确指定咱们是要字符还是字节。对于单个的 Unicode 标量值,就要使用 chars 方法。对 “Зд” 调用 chars 方法,会将其拆分并返回两个 char 类型的值,咱们就可以遍历结果,访问每个元素:

#![allow(unused)]
fn main() {
    for c in "नमस्ते".chars() {
        println!("{}", c);
    }
}

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

#![allow(unused)]
fn main() {
न
म
स

त

}

或者,bytes 方法会返回可能适合咱们领域的各个原始字节:

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

该代码将打印出构成这个 String 的 18 个字节来:

208
151
208
180

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

从字符串中获取字素簇,如上面对梵文那样,较为复杂,因此标准库未提供此功能。若咱们需要此功能,可在 crates.io 上找到相关代码箱。

字符串并不简单

Strings Are Not So Simple

总而言之,字符串是复杂的。不同编程语言在向程序员呈现这种复杂性方面,会做出不同的选择。Rust 选择将正确处理 String 数据,作为所有 Rust 程序的默认行为,这意味着程序员在开发初期,必须更多地考虑如何处理 UTF-8 数据。这种权衡虽然比其他编程语言暴露了更多字符串的复杂性,但他防止了咱们在开发生命周期的后期,不得不处理那些涉及非 ASCII 字符的错误。

好消息是,标准库提供了大量基于 String&str 类型的功能,帮助正确处理这些复杂情况。建议查阅文档,了解一些有用方法,例如用于在字符串中检索的 contains 方法,以及用于将字符串的一部分替换为另一字符串的 replace 方法等。

让我们转向一个稍微简单一点的主题:哈希图!

(End)

Last change: 2025-06-07, commit: 426e179

小额打赏,赞助 xfoss.com 长存......

微信 | 支付宝

若这里内容有帮助到你,请选择上述方式向 xfoss.com 捐赠。