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

在第 4 章中,就曾谈到过字符串,而现在则要深入审视他们。萌新 Rust 公民,通常会由于以下三个搅在一起的原因,而被字符串给卡住:作为 Rust 暴露各种可能错误的选择;相比于许多程序员意识到的复杂度,字符串是一种更具复杂度的数据结构;还有就是 UTF-8。在从别的语言转到 Rust 时,这些因素就会有以看起来有难度的方式,纠缠在一起。

由于字符串是作为字节集合,加上一些在将这些字节解析为文本时,提供有用功能的方法,这样来实现的,因此这里就在集合的语境中,来讨论字符串了。在本小节,将谈及在 String 类型上的那些所有集合都有的操作,比如创建、更新与读取等等。这里也会讨论 String 与其他集合的不同之处,即通过对比人类与机器解读 String 类型数据的不同之处,来搞清楚索引进到某个 String 有何等复杂。

何为 String

这里首先就要定义一下,字符串(string) 这个名词指的是什么。Rust 在其核心语言中,只有一种字符串类型,那就是字符串切片类型 str,该类型通常是以其被借用的形式 &str 而出现。在第 4 章中,就讲到过 字符串切片(string slices),他们是到一些存储在各处的、以 UTF-8 编码的字符串数据的引用。

String 类型,则是由 Rust 标准库所提供,而非编码进核心语言的,一种可增长、可变、(所有权)被持有的、UTF-8 编码的字符串类型(the String type, which is provided by Rust's standard library rather than coded into the core lanuage, is a growable, mutable, owned, UTF-8 encoded string type)。在 Rust 公民提到 Rust 中的 “字符串” 时,他们可能指的既是 String,也可可能是字符串切片的 &str 类型,而不仅仅是这些类型其中之一。虽然这个小节很大部分讲的是 String,但在 Rust 标准库中,两种类型都有重度使用,且 String 与字符串切片,都是 UTF-8 编码的。

Rust 标准库还包含了一些其他字符串类型,比如 OsStringOsStrCStringCStr 等等。一些库代码箱则可提供到甚至更多的用于存储字符串数据的选项。发现这些名称都是以 StringStr 结尾的了吧?他们指向的都是是有所有权的与借用的变种,就跟先前所见到的 Stringstr 类型一样。比如,这些字符串类型就可存储不同编码或在内存中以不同方式表示的文本。本章中不会讨论这些其他字符串类型;请参阅 API 文档,了解更多有关如何使用他们,以及何时哪个是恰当的字符串类型的更多知识。

创建一个新的 String

Vec<T> 的许多同样操作,对 String 也是可用的,这里就以创建一个新字符串的 new 函数开始,如下清单 8-11 中所示。

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

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

这行代码就创建了一个新的、名为 s 的空字符串,随后就可以将数据加载进这个空字符串了。通常,这里会有一些初始数据作为字符串的开头。为此,就要使用 to_string 方法,这个方法在所有实现了 Display 特质(the Display trait),正如字符串字面值这样的类型上,都是可用的。下面清单 8-12 给出了两个示例。

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

    let s = data.to_string();

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

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

此代码传教了一个包含 初始内容 的字符串。

这里也可以使用函数 String::from 来从字符串字面值创建 String。下面清单 8-13 众多的代码,与使用 to_string 的清单 8-12 中的代码等价。

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

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

由于字符串有相当多的用途,因此就可以使用字符串的众多不同的通用 API,而赋予到很多选择。这些字符串通用 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

在将更多数据压入到其中时,String 可以增长大小,且就跟 Vec<T> 的内容一样,内容可以改变。此外,还可以方便地使用 + 运算符或 format! 宏,来连接一些 String 值。

使用 push_strpush,往 String 追加数据

通过使用 push_str 方法来追加一个字符串切片,就可以增大 String,如下清单 8-15 中所示的那样。

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

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

在这两行之后,s 就会包含 foobar。由于这里并非真的想要取得那个参数的所有权,因此这个 push_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 为 {}", s2);
}

清单 8-16:在将一个字符串切片的内容追加到某个 String 后再对其加以使用

若这个 push_str 方法取得了 s2 的所有权,那么这里就无法在最后一行打印其值。然而,这段代码正如预期那样运作了!

相比 push_str 方法,这个 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 方法,而该方法的签名使用了 add 方法,add 方法的签名,看起来与下面的类似:

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

在标准库中,就会看到使用泛型定义的 add 函数。这里已将泛型的那些参数,用具体类型进行了替换,即在以 String 类型值调用是所发生的。在第 10 章就会讨论泛型。这个函数签名,提供了了解那个 + 运算符棘手之处所需的线索。

首先,这里的 s2 有个 &,表示这里这里正将第二个字符串的 引用,添加到第一个字符串。这是由于 add 函数中的那个 s 参数的原因:这里只能将一个 &str 添加到某个 String;这里是无法将两个 String 相加在一起的。不过稍等一下 -- &s2 的类型是 &String,而非在 add 函数的第二个参数中所指明的 &str。那为何清单 8-18 会编译呢?

这里之所以能在到 add 的调用中使用 &s2 的原因,在于编译器可将那个 &String 参数,强制转换&str 类型。在调用 add 方法时,Rust 使用了 解引用强制转换(deref coercion) 特性,在这里该特性就将 &s2 转换为了 &s2[..]。在第 15 章就将深入讨论这个解引用强制转换。由于 add 方法并未占据那个 s 参数的所有权,因此 s2 在此运算之后,仍将有效。

其次,这里可以看到,在该方法签名中,由于 self 没有 &,那么 add 就取得了 self 的所有权。这就意味着清单 8-18 中的 s1 将被迁移到那个 add 调用中,并在那之后便不再有效。这样看来,尽管 let s3 = s1 + &s2; 这个语句看起来将同时拷贝这两个字符串,并创建一个新的字符串,不过此语句实际上是要取得 s1 的所有权,追加 s2 内容的一份拷贝,进而随后返回该运算结果的所有权。也就是说,看起来这行语句构造了很多拷贝,但并没有;这样的实现比拷贝更为高效。

在需要连接多个字符串时,这个 + 运算符的行为就变得笨拙了:

#![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-toc-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-toe。这个 format! 宏与 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:尝试在某个字符串上使用索引语法

该代码将引发下面的错误:

$ 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> 的一种封装(a String is a wrapper over a Vec<v8>)。下面来看看清单 8-14 中,那里的一些以 UTF-8 良好编码的示例字符串。首先是这个:

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

在此示例中,len 将为 4,这表示这个存储着字符串 “Hola” 的矢量长度为 4 个字节。这些字母在以 UTF-8 编码时,每个占用 1 个字节。而接下来的这行,就会惊讶到你了。(请注意这个字符串是以大写西里尔字母 Ze 开头,而非阿拉伯数字 3。)

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

在被问及这个字符串有多长时,你可能会讲是 12。事实上,Rust 的答案是 24:由于那个字符串中的每个 Unicode 标量值,都有占用 2 字节的存储,故那就是以 UTF-8 编码 Здравствуйте 所用占用的字节数。由于这个缘故,到该字符串的那些字节的所以,就不会总是对应到某个有效的 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 处有的唯一数据了。即使字符串只包含拉丁字母,用户也通常不想要那个返回的字节值:即便 &"hello"[0] 是返回了字节值的有效代码,他也会返回 104,而非 h

那么答案就是,为避免返回一个不期望的值,以及避免引发一些可能无法立即发现的程序错误,Rust 就根本不会编译此代码,并阻止了在开发过程早期阶段的这些误解。

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

Bytes and Scalar Values and Grapheme Clusters! Oh My!)

有关 UTF-8 的另一点,即为从 Rust 视角看待字符串,事实上有三种相关方式:视为字节、标量值与字素簇(而字素簇则是与我们称之为 文字/letters 的东西的最接近的事物了)。

在看到以梵文字书写的印地语词汇 "नमस्ते" 时,这个词汇是以看起来像下面这样的一些 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 值了,但其中第四与第六个并不是文字(letters):他们是自身并无意义的变音符号。最后,在将这些 Unicode 标量值视为字素簇时,就得到了人类所称呼的、四个构成了那个印地词语的四个文字:

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

Rust 提供了解析计算机存储的原始字符串数据的数种不同方式,因此各个程序就可以选择他所需的解析方式,这与该数据为何种人类语言无关。

Rust 不允许索引进入 String 来获取某个字符的终极原因,即是索引操作,被认为总是消耗固定的时间(即 O(1))。但由于 Rust 将不得不从开头遍历到索引位置,来确定那里有多少个有效字符,由此对于在 String 上执行索引操作,所消耗的时间是无法确保持续一致的。

对字符串进行切片操作

Slicing Strings

由于字符串索引操作的返回值类型不明朗:可能是字节值、字符、字素簇,或者字符串切片,因此索引到字符串中去,通常是个糟糕的主意。而在确实需要使用索引来创建字符串切片时,那么 Rust 就要求提供更具体的索引。

与使用带有单个数字的 [] 相比,可使用带有范围的 [],来创建包含一些特定字节的字符串切片:

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

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

这里的 s 将是个包含该字符串头 4 个字节的 &str。早先曾提到过,每个的这些字符都是 2 字节,那么这就意味着 s 将为 Зд

而在尝试使用类似 &hello[0..1] 这样的操作,来对某个字符的那些字节的一部分进行切片时,Rust 就会在运行时,以与在矢量中访问无效索引时同样的方式终止运行:

thread 'main' panicked at '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 s.bytes() {
        println!("{}", b);
    }
}

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

224
164
168
224
164
174
224
164
184
224
165
141
224
164
164
224
165
135

不过要确保记住,有效的 Unicode 标量值可能有多余 1 个字节组成。

而从字符串获取字素簇则是复杂的,因此这项功能并未由标准库提供。在需要该功能时,在 crates.io 上有一些可用的代码箱。

字符串并不简单

Strings Are Not So Simple

总的来说,字符串是复杂的。不同编程语言,在以何种程度将这种复杂度呈现给编程者上,做出了不同的选择。Rust 选择了将正确处理 String 数据,作为所有 Rust 程序的默认行为,这就意味着 Rust 程序员就必须在处理 UTF-9 数据时,要提前投入更多思考。这种权衡暴露了相较于其他编程语言,更多的字符串复杂度,但这防止了在软件开发生命周期后期,将涉及到的非 ASCII 字符的错误处理。

接下来就要换到,有点复杂的东西:哈希图!

Last change: 2023-11-30, commit: 6021b30