什么是字符串?
在 Rust 中,核心语言只内置了一个字符串类型 —— 字符串切片(str
),通常以借用形式 &str
出现。字符串字面量就属于这种类型,它们存储在程序的二进制文件中。
而 String
类型则是由标准库提供的一个可增长、可变、拥有所有权的 UTF-8 编码字符串。当我们谈论 Rust 中的“字符串”时,经常需要同时区分这两种类型。
创建新的 String
使用 String::new
要创建一个空字符串,可以调用 String::new
。例如:
fn main() {
// 创建一个空的 String
let mut s = String::new();
// 之后可以通过 push 或 push_str 方法添加内容
s.push_str("Hello");
println!("s = {}", s);
}
由于一开始没有数据,编译器无法推断出存储的具体内容类型,因此需要通过使用 String
本身来指定类型。
使用 to_string
和 String::from
如果我们有一个字符串字面量,想要将其转换成 String
,有两种常用方法:
fn main() {
// 方法一:使用 to_string 方法
let s1 = "initial contents".to_string();
println!("s1 = {}", s1);
// 方法二:使用 String::from 函数
let s2 = String::from("initial contents");
println!("s2 = {}", s2);
}
这两种方法最终效果相同,都是生成一个包含初始数据的 String
。你可以根据个人偏好选择其中一种。
此外,由于字符串是 UTF-8 编码的,你可以在字符串中存储任意语言的文本,例如:
fn main() {
let english = String::from("Hello!");
let spanish = String::from("¡Hola!");
let russian = String::from("Здравствуйте");
println!("English: {}\nSpanish: {}\nRussian: {}", english, spanish, russian);
}
更新字符串
与向量类似,String
是一个可变的集合,因此我们可以对其进行修改。Rust 提供了几种更新字符串的方法:
使用 push_str
push_str
方法用于将一个字符串切片追加到现有字符串后面。例如:
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
// s 现在是 "foobar"
println!("s = {}", s);
}
这种方法不会获取参数的所有权,因此在追加后仍然可以使用原始数据。
使用 push
push
方法用于向字符串追加单个字符。示例:
fn main() {
let mut s = String::from("lo");
s.push('l');
// s 现在是 "lol"
println!("s = {}", s);
}
字符串的连接操作
拼接字符串也是常见需求。在 Rust 中,可以使用 +
运算符或 format!
宏来连接多个字符串。
使用 +
运算符
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
// 注意:s1 被移动,不能再使用;s2 通过引用传入
let s3 = s1 + &s2;
println!("s3 = {}", s3);
}
在这里,+
运算符实际上调用了 add
方法,其签名类似于:
fn add(self, s: &str) -> String { /* ... */ }
这表示它获取 s1
的所有权,并将 s2
的内容追加到 s1
上,最后返回新的字符串。正因如此,s1
在拼接后不再有效。
使用 format!
宏
如果需要拼接多个字符串,并且希望各部分都不被移动,可以使用 format!
宏:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
println!("s = {}", s);
}
format!
宏的语法类似于 println!
,但它不会输出到屏幕,而是返回一个新的 String
。这不仅使代码更易读,而且避免了所有权问题。
字符串的索引与切片
在许多编程语言中,可以通过索引直接访问字符串中的字符。然而,由于 UTF-8 的复杂性,Rust 不允许对 String
直接使用索引语法。
为什么不能直接索引?
Rust 的 String
底层是一个 Vec<u8>
,而 UTF-8 编码的字符可能占用 1 到 4 个字节。例如,对于字符串 "Здравствуйте"
,每个字符占用两个字节,若使用索引 &s[0]
得到的将是第一个字节(一个 u8
),而不是一个合法的 Unicode 字符。
如果允许直接索引,就有可能返回不完整或无意义的数据。为了避免这种潜在错误,Rust 在编译时阻止了对字符串直接索引的操作。
使用切片创建字符串片段
如果你确定要获取字符串中某一部分,可以使用切片(slice)的方式,但必须保证切片的范围恰好对齐字符边界。例如:
fn main() {
let hello = "Здравствуйте";
// 这里获取的是前 4 个字节,由于每个字符占 2 个字节,所以结果为 "Зд"
let s = &hello[0..4];
println!("s = {}", s);
}
如果尝试对不对齐字符边界的范围进行切片,例如 &hello[0..1]
,程序会在运行时 panic,因为该范围无法形成有效的 UTF-8 字符串。
遍历字符串
由于无法直接索引,我们通常会采用迭代的方式访问字符串内容。Rust 提供了两种常见方法:通过 .chars()
和 .bytes()
。
迭代 Unicode 标量值
使用 chars()
方法可以按 Unicode 标量值(即 Rust 中的 char
)迭代字符串:
fn main() {
let s = "Зд";
for c in s.chars() {
println!("{}", c);
}
}
该方法会将字符串正确拆分为完整字符,即使某个字符占用多个字节,也能保证每次迭代获得一个合法的 Unicode 字符。
迭代原始字节
如果你希望直接处理字符串的底层字节,可以使用 bytes()
方法:
fn main() {
let s = "Зд";
for b in s.bytes() {
println!("{}", b);
}
}
这会输出构成字符串的每个字节,但需要你自行理解 UTF-8 编码规则。
字符串的复杂性:从字节到字符再到“字形簇”
理解字符串不仅仅是关注字符的个数,还需要考虑以下三个层次:
- 字节(Bytes):Rust 底层存储字符串的数据是一个
u8
数组,例如"Hola"
占 4 个字节。 - Unicode 标量值(Unicode Scalar Values):对应 Rust 中的
char
类型,例如"Здравствуйте"
的每个字符占 2 个字节,但逻辑上看作 6 个char
。 - 字形簇(Grapheme Clusters):这才是用户眼中“字符”的表现形式,例如某些语言中组合字符可能由多个 Unicode 标量值构成一个“字母”。
由于不同语言的需求不同,Rust 将正确处理 UTF-8 字符串的复杂性作为默认行为。虽然这会使操作字符串变得繁琐,但从长远来看,它能有效防止因为错误处理非 ASCII 字符而导致的 Bug。
总结
本文全面解析了 Rust 中如何处理 UTF-8 编码的字符串,主要内容包括:
- 字符串类型:了解 Rust 中的
&str
和String
,前者是不可变的字符串切片,而后者是可增长、可变的拥有所有权的字符串。 - 创建与初始化:使用
String::new
、to_string
和String::from
来创建新的字符串,并支持存储各种语言的文本。 - 更新与拼接:使用
push_str
、push
、+
运算符和format!
宏对字符串进行追加和拼接操作,理解所有权和引用在其中的作用。 - 索引与切片:说明为什么不能直接对字符串进行索引,如何使用切片获取子字符串,并警惕对齐问题。
- 字符串迭代:介绍如何使用
.chars()
和.bytes()
分别按字符和字节进行迭代。 - 字符串的内部表示:讨论了字符串的字节、Unicode 标量值和字形簇之间的关系,揭示了字符串操作背后的复杂性。
通过这些知识,你可以更安全、更高效地处理 Rust 中的文本数据。尽管字符串操作比其他语言复杂一些,但 Rust 的设计确保了你在处理国际化和复杂字符时能够避免许多潜在的错误。
想要了解更多细节,不妨查阅标准库文档中关于 String
和 &str
的部分,探索诸如 contains
、replace
等方法的用法。
Happy coding!