字符串

ch08-02-strings.md
commit d362dadae60a7cc3212b107b9e9562769b0f20e3

第四章已经讲过一些字符串的内容,不过现在让我们更深入地了解一下它。字符串是新晋 Rustacean 们通常会被困住的领域。这是由于三方面内容的结合:Rust 倾向于确保暴露出可能的错误,字符串是比很多程序员所想象的要更为复杂的数据结构,以及 UTF-8。所有这些结合起来对于来自其他语言背景的程序员就可能显得很困难了。

字符串出现在集合章节的原因是,字符串是作为字节的集合外加一些方法实现的,当这些字节被解释为文本时,这些方法提供了实用的功能。在这一部分,我们会讲到String那些任何集合类型都有的操作,比如创建、更新和读取。也会讨论String于其他集合不一样的地方,例如索引String是很复杂的,由于人和计算机理解String数据的不同方式。

什么是字符串?

在开始深入这些方面之前,我们需要讨论一下术语字符串的具体意义。Rust 的核心语言中事实上就只有一种字符串类型:str,字符串 slice,它通常以被借用的形式出现,&str。第四章讲到了字符串 slice:他们是一些储存在别处的 UTF-8 编码字符串数据的引用。比如字符串字面值被储存在程序的二进制输出中,字符串 slice 也是如此。

称作String的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。当 Rustacean 们谈到 Rust 的“字符串”时,他们通常指的是String和字符串 slice &str类型,而不是其中一个。这一部分大部分是关于String的,不过这些类型在 Rust 标准库中都被广泛使用。String和字符串 slice 都是 UTF-8 编码的。

Rust 标准库中还包含一系列其他字符串类型,比如OsStringOsStrCStringCStr。相关库 crate 甚至会提供更多储存字符串数据的选择。与*String/*Str的命名类似,他们通常也提供有所有权和可借用的变体,就比如说String/&str。这些字符串类型在储存的编码或内存表现形式上可能有所不同。本章将不会讨论其他这些字符串类型;查看 API 文档来更多的了解如何使用他们以及各自适合的场景。

新建字符串

很多Vec可用的操作在String中同样可用,从以new函数创建字符串开始,像这样:

let s = String::new();

这新建了一个叫做s的空的字符串,接着我们可以向其中装载数据。

通常字符串会有初始数据因为我们希望一开始就有这个字符串。为此,使用to_string方法,它能用于任何实现了Display trait 的类型,对于字符串字面值是这样:

let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();

这会创建一个包好initial contents的字符串。

也可以使用String::from函数来从字符串字面值创建String。如下等同于使用to_string

let s = String::from("initial contents");

因为字符串使用广泛,这里有很多不同的用于字符串的通用 API 可供选择。他们有些可能显得有些多余,不过都有其用武之地!在这个例子中,String::from.to_string最终做了完全相同的工作,所以如何选择就是风格问题了。

记住字符串是 UTF-8 编码的,所以可以包含任何可以正确编码的数据:

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

更新字符串

String的大小可以增长其内容也可以改变,就像可以放入更多数据来改变Vec的内容一样。另外,String实现了+运算符作为级联运算符以便于使用。

使用 push 附加字符串

可以通过push_str方法来附加字符串 slice,从而使String变长:

let mut s = String::from("foo");
s.push_str("bar");

执行这两行代码之后s将会包含“foobar”。push_str方法获取字符串 slice,因为并不需要获取参数的所有权。例如,如果将s2的内容附加到s1中后自身不能被使用就糟糕了:

let mut s1 = String::from("foo");
let s2 = String::from("bar");
s1.push_str(&s2);

push方法被定义为获取一个单独的字符作为参数,并附加到String中:

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

执行这些代码之后,s将会包含“lol”。

使用 + 运算符或format!宏级联字符串

通常我们希望将两个已知的字符串合并在一起。一种办法是像这样使用+运算符:

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // Note that s1 has been moved here and can no longer be used

执行完这些代码之后字符串s3将会包含Hello, world!s1在相加后不再有效的原因,和使用s2的引用的原因与使用+运算符时调用的方法签名有关,这个函数签名看起来像这样:

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

这并不是标准库中实际的签名;那个add使用泛型定义。这里的签名使用具体类型代替了泛型,这也正是当使用String值调用这个方法会发生的。第十章会讨论泛型。这个签名提供了理解+运算那奇怪的部分的线索。

首先,s2使用了&,意味着我们使用第二个字符串的引用与第一个字符串相加。这是因为add函数的s参数:只能将&strString相加,不能将两个String值相加。不过等一下——正如add的第二个参数所指定的,&s2的类型是&String而不是&str。那么为什么代码还能编译呢?之所以能够在add调用中使用&s2是因为&String可以被强转coerced)成 &str——当add函数被调用时,Rust 使用了一个被称为解引用强制多态deref coercion)的技术,你可以将其理解为它把&s2变成了&s2[..]以供add函数使用。第十五章会更深入的讨论解引用强制多态。因为add没有获取参数的所有权,所以s2在这个操作后仍然是有效的String

其次,可以发现签名中add获取了self的所有权,因为self没有使用&。这意味着上面例子中的s1的所有权将被移动到add调用中,之后就不再有效。所以虽然let s3 = s1 + &s2;看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取s1的所有权,附加上从s2中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝不过实际上并没有:这个实现比拷贝要更高效。

如果想要级联多个字符串,+的行为就显得笨重了:

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);

这些代码也会将s设置为“tic-tac-toe”。format!println!的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果的String。这个版本就好理解的多,并且不会获取任何参数的所有权。

索引字符串

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果我们尝试使用索引语法访问String的一部分,会出现一个错误。比如如下代码:

let s1 = String::from("hello");
let h = s1[0];

会导致如下错误:

error: the trait bound `std::string::String: std::ops::Index<_>` is not
satisfied [--explain E0277]
  |>
  |>     let h = s1[0];
  |>             ^^^^^
note: the type `std::string::String` cannot be indexed by `_`

错误和提示说明了全部问题:Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust 是如何在内存中储存字符串的。

内部表示

String是一个Vec<u8>的封装。让我们看看之前一些正确编码的字符串的例子。首先是这一个:

let len = String::from("Hola").len();

在这里,len的值是四,这意味着储存字符串“Hola”的Vec的长度是四个字节:每一个字符的 UTF-8 编码都占用一个字节。那下面这个例子又如何呢?

let len = String::from("Здравствуйте").len();

当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。

作为演示,考虑如下无效的 Rust 代码:

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

answer的值应该是什么呢?它应该是第一个字符З吗?当使用 UTF-8 编码时,З的第一个字节是208,第二个是151,所以answer实际上应该是208,不过208自身并不是一个有效的字母。返回208可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引零位置所能提供的唯一数据。返回字节值可能不是人们希望看到的,即便是只有拉丁字母时:&"hello"[0]会返回104而不是h。为了避免返回意想不到值并造成不能立刻发现的 bug。Rust 选择不编译这些代码并及早杜绝了误会的放生。

字节、标量值和字形簇!天呐!

这引起了关于 UTF-8 的另外一个问题:从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中字母的概念)。

比如这个用梵文书写的印度语单词“नमस्ते”,最终它储存在Vec中的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 不建议这么做,而如果你确实需要它的话则需要更加明确一些。比使用[]和单个值的索引更加明确的方式是使用[]和一个 range 来创建包含特定字节的字符串 slice:

let hello = "Здравствуйте";

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

这里,s是一个&str,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着s将会是“Зд”。

如果获取&hello[0..1]会发生什么呢?答案是:在运行时会 panic,就跟访问 vector 中的无效索引时一样:

thread 'main' panicked at 'index 0 and/or 1 in `Здравствуйте` do not lie on
character boundary', ../src/libcore/str/mod.rs:1694

你应该小心谨慎的使用这个操作,因为它可能会使你的程序崩溃。

遍历字符串的方法

幸运的是,这里还有其他获取字符串元素的方式。

如果你需要操作单独的 Unicode 标量值,最好的选择是使用chars方法。对“नमस्ते”调用chars方法会将其分开并返回六个char类型的值,接着就可以遍历结果来访问每一个元素了:

for c in "नमस्ते".chars() {
    println!("{}", c);
}

这些代码会打印出如下内容:

न
म
स
्
त
े

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

for b in "नमस्ते".bytes() {
    println!("{}", b);
}

这些代码会打印出组成String的 18 个字节,开头是这样的:

224
164
168
224
// ... etc

不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。

从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。

字符串并不简单

总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。Rust 选择了以准确的方式处理String数据作为所有 Rust 程序的默认行为,这意味着程序员们必须更多的思考如何在前台处理 UTF-8 数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发生命周期中免于处理涉及非 ASCII 字符的错误。

现在让我们转向一些不太复杂的集合:哈希 map!