使用字符串存储 UTF-8 编码文本
我们在第4章讨论过字符串,但现在将更深入地探讨它们。新手 Rustacean 常常因为三个原因而卡在字符串上:Rust 倾向于暴露可能的错误、字符串比许多程序员想象的要复杂得多,以及 UTF-8。这些因素结合起来,对于来自其他编程语言的人来说,可能显得很难理解。
我们在集合的上下文中讨论字符串,因为字符串是作为字节集合实现的,并附带一些方法,当这些字节被解释为文本时提供有用功能。在本节中,我们将谈论 String 上每个集合类型都有的操作,如创建、更新和读取。我们还会讨论 String 与其他集合不同之处,即索引一个 String 时,由于人类和计算机对 String 数据解释方式不同,这一过程变得复杂。
什么是字符串?
首先定义“字符串”这个术语。Rust 核心语言只有一种字符串类型,即通常以借用形式 &str 出现的字符串切片 str。在第4章,我们讲过字符串切片,它们是对存储在别处某些 UTF-8 编码数据的引用。例如,字符串字面量存储在程序二进制文件中,因此它们就是字符串切片。
String 类型由 Rust 标准库提供,而非内置核心语言,是一种可增长、可变、有所有权且采用 UTF-8 编码的字符串类型。当 Rustacean 提到 Rust 中“strings”时,他们可能指的是 String 或者 字符串切片 &str 两种类型中的任意一种,而不仅仅是一种。虽然本节主要讲解 String,但这两种类型都广泛用于标准库,而且都是 UTF-8 编码。
创建新的 String
许多 Vec 可用操作同样适用于 String,因为实际上,String 是围绕字节向量封装的一层包装器,并增加了一些额外保证、限制和能力。例如,用来创建实例的新函数 new,在 Vec 和 String 中工作方式相同,如清单 8-11 所示:
let mut s = String::new();
清单 8-11:创建一个新的空白 String
这一行代码创建了一个名为 s 的新空串,我们可以往里加载数据。通常,我们会有一些初始数据想放入该串,为此可以使用 to_string 方法,该方法适用于任何实现了 Display trait 的类型,比如字符字面量。如清单 8-12 所示:
let data = "initial contents";
let s = data.to_string();
// 此方法也能直接作用于字面量:
let s = "initial contents".to_string();
清单 8-12:使用 to_string 方法从字符字面量创建一个 String
这段代码生成包含初始内容的 string。
我们也可以使用函数 String::from
从字符字面量创建一个 String
。如清单 8-13,其代码等价于使用 to_string
的版本:
let s = String::from("initial contents");
清单 8-13:利用 String::from
函数从字符字面量构造 String
由于 strings 用途广泛,可以通过很多通用 API 操作它们,给开发者提供大量选择。有些看似冗余,但各有其用途!这里,String::from
和 to_string
功能相同,你选哪个取决于风格与可读性。
记住,strings 是 UTF-8 编码,所以你可以包含任何正确编码的数据,如下例(见清单 8-14)所示:
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 ("Здравствуйте") ;
lethello=Str ing :: from ("Hola") ;
清单 8-14 : 将不同语言问候语存入 strings
以上均为有效的 string
值。
更新一个 string
像 Vec 一样,一个 string 可以增长并改变其内容,只要你往里面推送更多数据。此外,还可以方便地用 + 运算符或 format! 宏连接多个 string 值。
通过 push_str 和 push 向 string 添加内容
我们可以调用 push_str 方法追加一个 string 切片,从而扩展已有 string,如下(见清单8-15):
let mut s = String::from("foo");
s.push_str("bar");
清 单8-15 : 使用push_str方法把string slice添加到string后
执行完上述两行后,s就成了foobar 。push_str 接受参数为string slice ,因为不一定需要取得参数所有权。例如,下面代码(见 清 单8-16)希望追加完之后还能继续访问s2 :
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
清 单8-16 : 在追加后仍然能访问原来的slice
如果push_str取得了s2'所有权,那么最后一行打印
s2’值就无法成功。但实际运行结果符合预期!
push 方法接受的是 char 类型参数,将该字符添加至末尾。如以下例子(见 清 单8-17 )把’l’加到了 ‘lo’:
let mut s = String::from("lo");
s.push('l');
清 单8-17 : 用push给string添加1个char
结果s == lol
.
+运算符或format!宏进行拼接
经常需要合并两个已存在 strings,一种做法是 + 运算符,例如(见 清 单8-18 ):
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意:此处移动了's1',不能再用了。
清 单8-18 : 利用+运算符合并两个Strings得到新值
变量s3== Hello, world!
. 为什么`s1’失效?为什么传递的是&s2?这是因为 + 调用了 add 函数,其签名大致如下:
fn add(self, s: &str) -> String {
标准库里add定义较复杂,这里简化说明。当调用add时,第2个参数必须是&str,不支持两个完整 Strings 相加;但&s2 实际上是 &St ring ,为何编译没错呢?
答案是在调用 add 时发生了解引用强制转换(deref coercion),即把 &S tring 转换成对应范围内(&[…]) 的&st r . 我们将在第15章详细介绍deref coercion 。此外,由于是引用传参,没有转移所有权,所以$s2依旧有效。而 self 参数没有 &, 表明拥有self所有权,也就是说 $S1 被移动进add 调用了。因此表达式看似复制两次其实只移动一次,更高效无冗余拷贝.
当需拼接多个 strings 时,+ 会让表达式变得难懂,例如:
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! 宏代替:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
同样赋值$tic-tac-toe. format! 类似println!, 不过不是输出屏幕,而返回含格式化内容的新 String 。且内部采用引用,不转移参数所有权,使代码更易读、更安全.
字符串索引
在许多其他编程语言中,通过索引访问字符串中的单个字符是一种有效且常见的操作。然而,如果你尝试在 Rust 中使用索引语法访问 String 的部分内容,会得到一个错误。请看清单 8-19 中的无效代码。
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`
|
= 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<str>` is not implemented for `{integer}`
but 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>
的封装。让我们看看之前 UTF-8 编码正确的示例字符串(清单 8-14)中的一些例子。首先是:
let hello = String::from("Hola");
此时,len 为4,意味着存储字符串 “Hola” 的向量长度为4字节。每个字母用 UTF-8 编码时占用一个字节。然而,下面这一行可能会让你感到惊讶(注意该字符串以大写西里尔字母 Ze 开头,而不是数字3):
let hello = String::from("Здравствуйте");
如果被问及这个字符串有多长,你可能会说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 在 byte index=0 时拥有的数据。如果&“hi”[0] 是合法代码且返回的是原始字节,它将返回104而非’h’。
因此,为避免返回意外值并导致难以发现的 bug,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 类型,这些 bytes 对应于:
[‘न’, ‘म’, ‘स’, ‘्’, ‘त’, ‘े’]
这里有6个 char 值,但第四和第六不是独立意义上的“字符”:它们是不完整不能独立存在的变音符号。最后,如果从图形簇角度看,则相当于人们认知中的四个印地文字母组成词汇:
[“न”, “म”, “स्”, “ते”]
Rust 提供不同方法解释底层原始数据,使程序可以根据需求选择合适的人类语言处理方式。
另一个原因是不允许通过索引直接获取某一位置上的字符,是因为期望所有索引操作都能保证常数时间复杂度(O(1))。但对于 String 来说无法保证这一点,因为必须从开头遍历直到目标位置才能确定有多少有效字符。
切片 Strings
对 string 索引用途往往不好界定——到底应该返回什么类型?是单一 byte 值、char 字符、图形簇还是 string slice?如果确实需要基于范围创建切片,那么 Rust 要求更明确指定范围,而非简单数字下标,例如:
let hello ="Здравствуйте";
let s =&hello[0..4];
此处 s 是包含前四个 bytes 的 &str 切片。如前所述,每两个 bytes 表示一个 character,因此s 包含 “Зд”。
若尝试只截取部分 character 所占 bytes,比如 &hello[0…1] ,则运行时会 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
因此,在使用范围创建 string slices 时务必小心,否则程序可能崩溃。
迭代 Strings 方法
处理 strings 最好明确自己想要的是 characters 或者 bytes 。针对单独 Unicode 标量,可以调用 chars 方法。“Зд”.chars() 会分离出两个 char 类型元素,可迭代访问:
for c in "Зд".chars() {
println!("{c}");
}
输出结果为:
З
д
另外也可调用 bytes 返回每个位元组(byte),适用于特定场景:
for b in "Зд".bytes() {
println!("{b}");
}
输出四个位元组(bytes):
208
151
208
180
但请记住,有效 Unicode 标量可能由多个 byte 构成,不可简单按 byte 操作替代 char 。
由于提取如天城文等复杂脚本中的 grapheme clusters 较困难,此功能未纳入标准库。如需此功能,可查找 crates.io 上相关第三方库实现。
Strings 并非那么简单
总结来说,string 很复杂,不同语言对此做出了不同设计权衡。Rust 默认要求正确处理 String 数据,这使得程序员必须提前认真考虑如何处理 UTF-8 数据。这虽然暴露了更多细节,却避免了后续因非 ASCII 字符带来的潜在错误风险。
好消息是标准库提供大量基于 String 和 &str 类型的方法帮助正确应对这些复杂情况,比如 contains 用于搜索,以及 replace 用于替换子串等,非常实用,请务必查看官方文档了解详情。
接下来,让我们转向稍微简单一点的话题:哈希映射(hash maps)!