使用字符串储存 UTF-8 编码的文本
ch08-02-strings.md
commit 668c64760b5c7ea654facb4ba5fe9faddfda27cc
第四章已经讲过一些字符串的内容,不过现在让我们更深入地了解它。字符串是新晋 Rustacean 们通常会被困住的领域,这是由于三方面理由的结合:Rust 倾向于确保暴露出可能的错误,字符串是比很多程序员所想象的要更为复杂的数据结构,以及 UTF-8。所有这些要素结合起来对于来自其他语言背景的程序员就可能显得很困难了。
在集合章节中讨论字符串的原因是,字符串就是作为字节的集合外加一些方法实现的,当这些字节被解释为文本时,这些方法提供了实用的功能。在这一部分,我们会讲到 String
中那些任何集合类型都有的操作,比如创建、更新和读取。也会讨论 String
与其他集合不一样的地方,例如索引 String
是很复杂的,由于人和计算机理解 String
数据方式的不同。
什么是字符串?
在开始深入这些方面之前,我们需要讨论一下术语 字符串 的具体意义。Rust 的核心语言中只有一种字符串类型:字符串 slice str
,它通常以被借用的形式出现,&str
。第四章讲到了 字符串 slices:它们是一些对储存在别处的 UTF-8 编码字符串数据的引用。举例来说,由于字符串字面值被储存在程序的二进制输出中,因此字符串字面值也是字符串 slices。
字符串(String
)类型由 Rust 标准库提供,而不是编入核心语言,它是一种可增长、可变、可拥有、UTF-8 编码的字符串类型。当 Rustaceans 提及 Rust 中的 "字符串 "时,他们可能指的是 String
或 string slice &str
类型,而不仅仅是其中一种类型。虽然本节主要讨论 String
,但这两种类型在 Rust 的标准库中都有大量使用,而且 String
和 字符串 slices 都是 UTF-8 编码的。
新建字符串
很多 Vec
可用的操作在 String
中同样可用,事实上 String
被实现为一个带有一些额外保证、限制和功能的字节 vector 的封装。其中一个同样作用于 Vec<T>
和 String
函数的例子是用来新建一个实例的 new
函数,如示例 8-11 所示。
fn main() { let mut s = String::new(); }
这新建了一个叫做 s
的空的字符串,接着我们可以向其中装载数据。通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用 to_string
方法,它能用于任何实现了 Display
trait 的类型,比如字符串字面值。示例 8-12 展示了两个例子。
fn main() { let data = "initial contents"; let s = data.to_string(); // 该方法也可直接用于字符串字面值: let s = "initial contents".to_string(); }
这些代码会创建包含 initial contents
的字符串。
也可以使用 String::from
函数来从字符串字面值创建 String
。示例 8-13 中的代码等同于使用 to_string
。
fn main() { let s = String::from("initial contents"); }
因为字符串应用广泛,这里有很多不同的用于字符串的通用 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"); }
所有这些都是有效的 String
值。
更新字符串
String
的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变 Vec
的内容一样。另外,可以方便的使用 +
运算符或 format!
宏来拼接 String
值。
使用 push_str
和 push
附加字符串
可以通过 push_str
方法来附加字符串 slice,从而使 String
变长,如示例 8-15 所示。
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
执行这两行代码之后,s
将会包含 foobar
。push_str
方法采用字符串 slice,因为我们并不需要获取参数的所有权。例如,示例 8-16 中我们希望在将 s2
的内容附加到 s1
之后还能使用它。
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}"); }
如果 push_str
方法获取了 s2
的所有权,就不能在最后一行打印出其值了。好在代码如我们期望那样工作!
push
方法被定义为获取一个单独的字符作为参数,并附加到 String
中。示例 8-17 展示了使用 push
方法将字母 "l" 加入 String
的代码。
fn main() { let mut s = String::from("lo"); s.push('l'); }
执行这些代码之后,s
将会包含 “lol”。
使用 +
运算符或 format!
宏拼接字符串
通常你会希望将两个已知的字符串合并在一起。一种办法是像这样使用 +
运算符,如示例 8-18 所示。
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用 }
执行完这些代码之后,字符串 s3
将会包含 Hello, world!
。s1
在相加后不再有效的原因,和使用 s2
的引用的原因,与使用 +
运算符时调用的函数签名有关。+
运算符使用了 add
函数,这个函数签名看起来像这样:
fn add(self, s: &str) -> String {
在标准库中你会发现,add
的定义使用了泛型和关联类型。在这里我们替换为了具体类型,这也正是当使用 String
值调用这个方法会发生的。第十章会讨论泛型。这个签名提供了理解 +
运算那微妙部分的线索。
首先,s2
使用了 &
,意味着我们使用第二个字符串的 引用 与第一个字符串相加。这是因为 add
函数的 s
参数:只能将 &str
和 String
相加,不能将两个 String
值相加。不过等一下 —— &s2
的类型是 &String
, 而不是 add
第二个参数所指定的 &str
。那么为什么示例 8-18 还能编译呢?
之所以能够在 add
调用中使用 &s2
是因为 &String
可以被 强转(coerced)成 &str
。当add
函数被调用时,Rust 使用了一个被称为 Deref 强制转换(deref coercion)的技术,你可以将其理解为它把 &s2
变成了 &s2[..]
。第十五章会更深入的讨论 Deref 强制转换。因为 add
没有获取参数的所有权,所以 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("hello");
let h = s1[0];
}
这段代码会导致如下错误:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
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`
= help: the following other types implement trait `Index<Idx>`:
<String as Index<RangeFrom<usize>>>
<String as Index<RangeFull>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeToInclusive<usize>>>
<String as Index<std::ops::Range<usize>>>
<str as Index<I>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
错误和提示说明了全部问题:Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust 是如何在内存中储存字符串的。
内部表现
String
是一个 Vec<u8>
的封装。让我们看看示例 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"); }
在这里,len
的值是 4,这意味着储存字符串 “Hola” 的 Vec
的长度是四个字节:这里每一个字母的 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 标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。作为演示,考虑如下无效的 Rust 代码:
let hello = "Здравствуйте";
let answer = &hello[0];
我们已经知道 answer
不是第一个字符 3
。当使用 UTF-8 编码时,(西里尔字母的 Ze)З
的第一个字节是 208
,第二个是 151
,所以 answer
实际上应该是 208
,不过 208
自身并不是一个有效的字母。返回 208
可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引 0 位置所能提供的唯一数据。用户通常不会想要一个字节值被返回。即使这个字符串只有拉丁字母,如果 &"hello"[0]
是返回字节值的有效代码,它也会返回 104
而不是 h
。
为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
字节、标量值和字形簇!天呐!
这引起了关于 UTF-8 的另外一个问题:从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中 字母 的概念)。
比如这个用梵文书写的印度语单词 “नमस्ते”,最终它储存在 vector 中的 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
索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引创建字符串 slice 时,Rust 会要求你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 []
和单个值的索引,可以使用 []
和一个 range 来创建含特定字节的字符串 slice:
#![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 [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
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}"); } }
这些代码会打印出组成 String
的 4 个字节:
208
151
208
180
不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。
从字符串中获取如同天城文这样的字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。
字符串并不简单
总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。Rust 选择了以准确的方式处理 String
数据作为所有 Rust 程序的默认行为,这意味着程序员们必须更多的思考如何预先处理 UTF-8 数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发周期后期免于处理涉及非 ASCII 字符的错误。
好消息是标准库提供了很多围绕 String
和 &str
构建的功能,来帮助我们正确处理这些复杂场景。请务必查看这些使用方法的文档,例如 contains
来搜索一个字符串,和 replace
将字符串的一部分替换为另一个字符串。
称作 String
的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。当 Rustacean 们谈到 Rust 的 “字符串”时,它们通常指的是 String
或字符串 slice &str
类型,而不特指其中某一个。虽然本部分内容大多是关于 String
的,不过这两个类型在 Rust 标准库中都被广泛使用,String
和字符串 slices 都是 UTF-8 编码的。
现在让我们转向一些不太复杂的集合:哈希 map!