写点什么

Rust 从 0 到 1- 集合 - 字符串

用户头像
关注
发布于: 2021 年 05 月 17 日
Rust从0到1-集合-字符串

前面介绍所有权概念的时候我们使用字符串做过例子,过现在我们将更深入地讨论它。出于以下三方面的原因,容易造成对字符串理解上的困难:

  • Rust 设计上倾向于提前发现可能的错误

  • String 的数据结构比它表面上看上去复杂的多

  • UTF-8 字符集

我们把字符串在集合的章节中进行讨论是因为字符串本身就是字节的集合加上一些方法实现的,这些方法提供了对文本操作的一些实用功能。字符串作为集合我们除了会讨论前面介绍的创建、修改和读取等相关的内容;此外,我们还会讨论字符串与其他集合不一样的地方,譬如,由于我们和计算机理解字符串 的方式不同,通过索引获取字符串的内容实际上会比较复杂。

什么是字符串

在开始之前,我们首先讨论一下术语“字符串”的具体含义。在 Rust 语言核心中只有一种字符串类型:字符串切片(slices) str,并且通常是以引用的形式使用,即 &str。前面介绍字符串切片的时候说过:它们是对以 UTF-8 编码存储的字符串数据的引用,譬如,被储存在程序的二进制中我们直接硬编码的字符串文本。

String 类型是由标准库提供的,并没有被写入 Rust 语言的核心部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。当我们谈到 Rust 的 “字符串”时,通常指的是 String 和字符串切片引用 &str 两者。虽然本章节我们讨论的内容主要是关于 String 的,不过这两种类型在 Rust 标准库中都经常被用到,并且他们都是基于 UTF-8 编码的。

Rust 标准库中还包含有其他字符串类型,比如 OsString、OsStr、CString 和 CStr 等。Crate 库上还有更多字符串类型的变体选择,它们名字的结尾 String 或是 Str 对应着它们是具有所有权还是借用,就像 String 和 str,这些字符串类型的变体能够以不同的编码,或者内存使用上以不同的形式等来存储字符串文本内容。他们的使用方式和适合场景请参考对应的 API 文档,我们在此不会讨论。

创建一个字符串

前面介绍 Vec 时的很多操作同样适用于 String ,譬如使用 new 创建字符串:

let mut s = String::new();
复制代码

上例中我们新建了空字符串(注意,和 Vec 不同,这里不需要指定变量类型,我想是因为字符串集合只能包含字符,不可能有别的类型)。

通常我们在定义字符串变量时会同时为其赋初值,我们也经常会用到 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();
复制代码

还可以使用 String::from 函数来创建 String(下面的例子等同于 to_string):

let s = String::from("initial contents");
复制代码

由于字符串实在是特别常用到,因此 Rust 提供了很多不同的用于字符串的 API 可供选择,其中有一些看起来功能是重复的,不过他们都有对应的应用场景!在前面的例子中,String::from 和 to_string 做了完全相同的工作,如何选择就是代码风格问题。

此外,因为字符串是基于 UTF-8 编码的,所以可以包含任何可以正确编码的文本:

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 值。

修改字符串

当我们放入更多数据的时候 Vec 的大小和内容都会改变,String 也一样。另外,还可以使用 + 运算符或 format! 宏来拼接 String。

使用 push_str 和 push 拼接字符串

可以通过 push_str 方法来将字符串切片拼接到字符串末尾:

let mut s = String::from("foo");s.push_str("bar");
复制代码

上面的例子中 s 的内容最终将变为 foobar。push_str 方法的参数为字符串切片。通常我们将字符串进行拼接后,并不希望原字符串变为不可用,因此我们并不需要获取参数的所有权:

let mut s1 = String::from("foo");let s2 = "bar";s1.push_str(s2);println!("s2 is {}", s2);
复制代码

在上面的例子中,假如 push_str 方法获取了 s2 的所有权,将会在打印的时候报错。

push 方法的参数为一个单独的字符,并将其拼接到 String 中:

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

上面的例子中,s 的内容最终变为 lol。

使用 + 或 format! 拼接字符串

我们经常会碰到需要将两个 String 拼接在一起的场景。一种方法是使用 + 运算符:

let s1 = String::from("Hello, ");let s2 = String::from("world!");let s3 = s1 + &s2; // 注意 s1 被移动了,不能继续使用
复制代码

上面的例子中字符串 s3 的内容最终为 Hello, world!。我们在 + 操作中使用的是 s2 的引用,并且 s1 在相加后不再有效,这是由于 + 运算符使用了 add 函数,这个函数看起来像这样:

fn add(self, s: &str) -> String {
复制代码

上面的函数定义和标准库实际的实现并不一样;标准库中的 add 使用的是泛型。出于便于理解,在这里我们使用具体的类型代替了泛型(后面章节会具体讨论泛型)。在例子中我们使用的是字符串 s2 的引用( &String)与第一个字符串相加。这是因为 add 函数的参数只能将 &str 和 String 相加,不能将两个 String 相加。不过等一下 ,例子中我们使用的并不是 &str,而是 s2 的引用 &String。那么这个错误是 Rust 没有发现吗?

之所以能够在 add 函数中使用 s2 的引用是因为 &String 可以被强制转换成 &str。当我们把 s2 作为参数调用 add 函数时,Rust 使用了一种被称为 deref coercion 的技术,我们可以理解为它把 &s2 变成了 &s2[..](后面的章节我们会具体讨论这个技术)。因为 add 使用的是 s2 的引用作为参数,所以 s2 在操作后仍然有效。但是,add 获取了 self 的所有权,这意味着例子中 s1 的所有权将被移动到 add 函数中,s1 之后就不再有效。所以 let s3 = s1 + &s2; 实际上是获取 s1 的所有权,然后拼接上 s2 拷贝,并返回结果(包括所有权)。看上去像做了很多次 copy 操作,但是实际上的实现比 copy 要高效。

如果想拼接多个字符串,+ 操作就显得不那么方便了:

let s1 = String::from("tic");let s2 = String::from("tac");let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
复制代码

上例中 s 最终的内容是 tic-tac-toe。但是,在做了这么多次拼接后我们可能已经不清楚过程中具体发生了什么。因此,Rust 提供了 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! 的原理相同,不过它会返回一个字符串,而不是打印到屏幕上,并且不会获取任何参数的所有权,这样就容易理解多了。

通过索引访问字符串

在很多其它语言中,通过索引来访问字符串中的单个字符是常见的操作。但是在 Rust 中,如果我们尝试使用索引访问 String ,在编译时就会报错:

let s1 = String::from("hello");let h = s1[0];
复制代码

这是由于在 Rust 中字符串不支持索引。下面我们将介绍为什么会这样。

内部实现

String 实际上是基于 Vec<u8> 进行的封装。我们先来看一个例子:

let len = String::from("Hola").len();
复制代码

len 的值是 4 ,这意味着储存字符串 Hola 的 Vec 的长度是四个字节,即每个字母的 UTF-8 编码都占用一个字节。接着来看下面这个例子(注意这个字符串中的首字母是西里尔字母的 Ze 而不是阿拉伯数字 3 ):

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

这个字符是多长?12?然而,实际上 len 的值是 24。这是使用 UTF-8 编码 Здравствуйте 后的字节数,因为其中每个 Unicode 标量值(Unicode scalar value)需要两个字节存储。因此字符串字节的索引并不总是对应一个有效的 Unicode 标量值:

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

answer 的值应该是什么?是第一个字符 З 吗?当使用 UTF-8 编码时,З 的第一个字节是 208,第二个字节是 151,因此 answer 对应的应该是 208,不过 208 并不是一个有效的字母。因此,通常返回 208 并不是我们预期的结果,但它确实是 Rust 在索引 0 的位置所存储的数据。我们通常不会想要返回一个字节中存储的值。为了避免这种意外以及造成潜在的缺陷,Rust 在编译时就不允许这些代码通过。

字节、标量值和字形簇

这就造成了从 Rust 的角度存在三种方式去理解字符串:字节、标量值和字形簇(Grapheme Clusters,最接近通常我们理解的“字母”的概念)。

比如这个用梵文书写的印度语单词 “नमस्ते”,从字节的角度看,最终它储存在 vector 中的 u8 值看起来像这样:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,224, 165, 135]
复制代码

这里有 18 个字节,也是计算机最终储存数据的形式。如果从 Unicode 标量值的角度去看,看起来像这样(也就是 Rust 的 char 类型):

['न', 'म', 'स', '्', 'त', 'े']
复制代码

这里有 6 个 char,不过第 4 个和第 6 个是发音符号,本身并没有任何意义。最后,如果以字形簇的角度去看,就会得到我们通常所说的构成这个单词的四个字母:

["न", "म", "स्", "ते"]
复制代码

Rust 提供了多种不同的方式来展现计算机储存的原始字符串数据,这样我们就可以根据需要选择不同的表现方式,而不管是使用的那种人类语言。

此外,还有一个 Rust 不允许使用索引访问 String 的原因,就是索引操作预期性能是常数时间 (O(1))。但是对于 String ,如果要保证结果正确,必须从开头到索引位置进行遍历来确定有多少是有效的字符。

将字符串切片

通过索引访问字符串不是一个好主意,因为返回的结果类型是不确定的,可能是字节值、字符、字形簇或者字符串切片。但是,如果我们真的希望通过索引创建字符串切片,Rust 也为我们提供了方法,不过需要我们在 [ ] 中增加一个索引范围来更明确的说明我们要创建一个包含特定范围字节的字符串切片:

let hello = "Здравствуйте";let s = &hello[0..4];
复制代码

上例中 s 的类型是 &str,它包含字符串 hello 的前四个字节。前面我们说过这些字母都是两个字节长的,所以 s 最终包含的字符是 Зд。

如果我们获取第一个字节的字符串切片 &hello[0..1] 会发生什么?答案是:Rust 在运行时会报错,就和我们在访问 vector 时发生越界错误一样。这回让我们的程序崩溃,因此,我们应该非常小心谨慎的使用这个操作。

遍历字符串

除了切片以外,Rust 还提供了其他获取字符串元素的方式。如果我们需要以 Unicode 标量值的方式访问字符串,那么可以选择使用 chars 方法。对 नमस्ते 调用 chars 方法会返回六个 char 类型的值,然后我们就可以对其遍历来访问其中每一个元素:

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

bytes 方法返回每一个原始字节:

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

不过我们需要记住,有效的 Unicode 标量值可能是由多个字节组成。

标准库并没有提供返回字形簇的功能,因为这个比较复杂,但是,crates.io 上有提供这样功能的 crate 库。

字符串并不简单

总之,字符串是比较复杂的。不同的语言选择了不同的方式展示或隐藏其复杂性。Rust 选择了向我们展示出更加偏向底层的 String 数据(个人认为越偏向底层,代表更灵活,更好性能,更多可能性),这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,同时也尽量让我们免于处理涉及非 ASCII 字符的错误。再次强调,我们在处理 UTF-8 数据的时候要特别小心。

发布于: 2021 年 05 月 17 日阅读数: 33
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-集合-字符串