深度解析 Rust 字符串:String 与 &str 及 UTF-8
全面掌握 Rust 字符串体系:String 与 &str 的区别、UTF-8 底层编码原理、切片安全性、格式化以及内存优化。
Rust 的字符串处理机制常常让初学者感到困惑。为什么 Rust 同时存在 String 和 &str?为什么不能通过 s[0] 像数组那样直接下标索引?理解 Rust 字符串的底层架构,是掌握这门语言内存安全性与国际化 UTF-8 正确性的关键。
像给 10 岁孩子解释:活页笔记本 vs 透明书签#
把处理文本的两种方式想象为两种文具:
String(可自由增删页面的活页笔记本): 放在你书桌上的实体笔记本(堆内存 Heap)。你可以随时往里面写新句子、擦除段落、添加空白页(mut),或者把整本笔记本转赠给他人(所有权转移)。&str(透明书签标尺): 盖在图书馆印刷书或他人笔记本某一行文字上方的透明塑料书签。你可以直接阅读底下的内容,而无需复印纸张或拥有这本书。
graph TD
subgraph HeapMemory ["堆内存分配"]
StringBuf["String 缓冲区 (堆上的 UTF-8 字节流)"]
end
subgraph StackVariables ["栈上的局部变量"]
StringObj["String 结构体<br/>[ ptr | len | capacity ]"] -->|拥有堆缓冲区数据| StringBuf
StrSlice["&str 字符串切片<br/>[ ptr | len ]"] -->|借用视角只读访问| StringBuf
LiteralStr["静态字符串 static str<br/>[ ptr | len ]"] -->|指向二进制段只读字符串| ReadOnlyData["只读数据段 (.rodata)"]
end
String 与 &str 的核心区别#
| 属性 | String | &str (字符串切片) |
|---|---|---|
| 所有权 | 拥有底层的堆内存缓冲区 | 借用字符串数据的只读视图 |
| 可变性 | 可变,通过 mut 自由伸缩容量 | 不变,固定长度,只读视图 |
| 内存分布 | 数据存储在堆上(栈存 ptr, len, cap) | 栈上的胖指针(ptr + len) |
| 常见创建方式 | String::new(), String::from("..."), s.to_string() | "hello" (字面量), &s[0..4], &s |
字符串的创建与更新#
fn main() {
// 1. 创建 String
let mut greeting = String::from("Hello");
// 2. 追加字符和字符串切片
greeting.push(' '); // 追加单个 char
greeting.push_str("world!"); // 追加字符串切片
println!("{greeting}");
// 3. 使用 + 运算符拼接
let s1 = String::from("Hello, ");
let s2 = String::from("Rust!");
let s3 = s1 + &s2; // s1 的所有权被转移(Move),后续不可再使用
println!("{s3}");
// 4. 使用 format! 宏优雅拼接(不消耗所有权)
let part1 = "tic";
let part2 = "tac";
let part3 = "toe";
let game = format!("{part1}-{part2}-{part3}");
println!("{game}");
}rust[!NOTE]
s1 + &s2中的+运算符调用了fn add(self, s: &str) -> String。它获取s1的所有权,把s2的内容复制追加到末尾,并返回新的缓冲区。
为什么 Rust 禁止通过整数下标直接索引 (s[0])?#
在很多编程语言中,s[0] 会返回首个字符。但在 Rust 中,这会导致编译报错:
let s = String::from("hello");
// let h = s[0]; // ❌ 编译报错: the type `String` cannot be indexed by `{integer}`rustUTF-8 编码原理#
Rust 中的字符串始终是合法的 UTF-8 字节序列。在 UTF-8 中,一个 Unicode 字符占用的字节长度为 1 到 4 字节不等:
graph LR
subgraph English ["英文: 'Hello' (每个字符 1 字节)"]
H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
end
subgraph Chinese ["中文: '你好' (每个字符 3 字节)"]
C1["'你' [0xE4, 0xBD, 0xA0]"] --- C2["'好' [0xE5, 0xA5, 0xBD]"]
end
subgraph Emoji ["Emoji: '🦀' (4 字节)"]
Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80]"]
end
如果允许 s[0],获取中文字符 '你' 的首个字节只会得到不完整的 0xE4,破坏程序逻辑。此外,查找第 个字符需要从头扫描多字节字符( 复杂度),这违背了 Rust 零开销抽象(Zero-cost Abstraction)的设计哲学。
遍历字符串:字节 vs 标量字符#
进行字符串检查时,必须显式指定遍历粒度:
fn main() {
let word = "你好 🦀";
// 1. 按 Unicode 标量值(字符)遍历
println!("--- 字符 (chars) ---");
for c in word.chars() {
println!("{c}");
}
// 2. 按底层 UTF-8 原始字节遍历
println!("--- 字节 (bytes) ---");
for b in word.bytes() {
print!("{b:02X} ");
}
println!();
}rust字符串切片的安全性注意事项#
可以通过区间范围对字符串进行切片,但区间的起止下标必须精确落在 UTF-8 字符的合法边界上:
fn main() {
let hello = "Здравствуйте";
// 西里尔字符每个占用 2 字节
let s = &hello[0..4]; // 提取前 2 个字符(4 字节) -> "Зд"
println!("{s}");
// ❌ 如果从字符内部切分,运行时将直接触发 Panic:
// let invalid = &hello[0..1]; // byte index 1 is not a char boundary
}rust[!WARNING] 处理多语言国际化文本时,尽量避免直接使用字节范围切片,推荐使用
.chars().take(n)等迭代器方法以确保安全。
函数传参的最佳实践:优先接收 &str#
编写只需只读访问字符串的函数时,始终优先将参数类型声明为 &str,而不是 &String:
// 最佳实践:得益于 Deref 隐式转换,可同时接收 &String 和 &str
fn print_length(text: &str) {
println!("长度: {} 字节", text.len());
}
fn main() {
let owned = String::from("Rustacean");
let literal = "Rustacean";
print_length(&owned); // 自动将 &String 转换为 &str
print_length(literal); // 直接传递 &str
}rust总结#
String是堆上分配、拥有所有权且可伸缩的 UTF-8 缓冲区。&str是只读借用 UTF-8 数据的不可变切片(视图)。- 禁止整数下标索引
s[0],因为 UTF-8 字符具有 1 到 4 字节的变长特性。 - 使用
.chars()遍历字符,使用.bytes()遍历原始字节。 - 函数参数优先使用
&str,兼具最高灵活性与零额外内存开销。