阶段一 · 字符串与文本

UTF-8 编码,索引与遍历

一句话总结

Rust 的字符串底层是 UTF-8 字节序列,一个字符可能占 1 到 4 个字节.
正因如此,s[i] 这种下标取字符的写法被语言直接禁止--它无法在 O(1) 时间内给出"第 i 个字符",还可能切在字符中间制造乱码.

前置回顾

第 01 篇讲了 String&str 的所有权分工和内存布局.本篇深入它们共同的底层表示--UTF-8 编码,理解 Rust 为什么在字符串操作上"限制这么多".

UTF-8:变长编码的字节序列

Rust 规定所有 strString 内部必须是合法的 UTF-8 字节.UTF-8 是一种变长编码:不同字符占用的字节数不同.

字符范围 占用字节 例子
ASCII(英文,数字,常见符号) 1 字节 a 0 !
拉丁扩展,希腊字母等 2 字节 é λ
中日韩文字,大部分符号 3 字节
Emoji,罕见字符 4 字节 😀

看一个具体的例子,字符串 "a中😀" 在内存里的实际字节布局:

字符:     a            中                  😀
字节: [ 0x61 ][ 0xE4 0xB8 0xAD ][ 0xF0 0x9F 0x98 0x80 ]
索引: 0 1 2 3 4 5 6 7

字符数 (chars) = 3
字节数 (len) = 8

这里 "a中😀".len() 返回的是 8,而不是 3.len() 永远返回字节数,这是新手最容易误判的地方.

把 UTF-8 字符串想象成一列长度不一的车厢连成的火车:英文字符是单节小车厢,中文是三节,Emoji 是四节.没法靠"第 4 节车厢"直接定位到"第 2 个乘客",因为每位乘客占的车厢数不一样.

为什么 s[i] 被禁止

在 Python 里 "中"[0] 能直接取字符,为什么 Rust 不行?编译下面的代码会直接报错:

let s = String::from("中文");
// let c = s[0]; // 编译错误:`String` cannot be indexed by `{integer}`

Rust 禁止它,有两个理由:

  1. 性能不可预期:下标访问 s[i] 在所有语言中都被期望是 O(1).但在变长编码里,要找到"第 i 个字符"必须从头逐字节扫描,是 O(n).Rust 不愿意为一个看起来 O(1) 的语法偷偷藏一个 O(n) 的实现.
  2. 语义有歧义:s[i] 中的 i 到底指第 i 个字节,第 i 个 Unicode 标量值,还是第 i 个"看起来的字符"?三者结果可能完全不同.与其猜,不如让开发者显式选择.

字节,字符,字形是三个不同概念

同一个字符串可以从三个层次度量:
字节(byte, u8) 是存储单位;字符(char) 是一个 Unicode 标量值,固定 4 字节;字形簇(grapheme cluster) 才是人眼看到的"一个字".

例如带音标的 é 可能由两个 char(基础字母 + 组合音标)拼成,却是一个字形.标准库只提供前两者,字形簇需要 unicode-segmentation 这类外部 crate.

切片可以,但要小心边界

虽然不能用单个整数下标,Rust 却允许用范围切片,例如 &s[0..3].代价是:这里的范围是字节索引,且必须恰好落在字符边界上,否则运行时 panic.

let s = String::from("中文");   // 每个汉字 3 字节,共 6 字节

let ok = &s[0..3]; // "中",正好切在字符边界
println!("{ok}");

// let bad = &s[0..2]; // 运行时 panic:byte index 2 is not a char boundary

切片越界是运行时 panic,不是编译错误

&s[0..2] 切在了"中"这个三字节字符的中间,编译能通过,但运行时直接 panic 并终止程序.

处理用户输入或不确定内容的字符串时,用裸字节范围切片是高风险操作.需要安全截断时,应使用 s.char_indices() 找到合法边界,或用 s.get(0..2)--后者返回 Option,边界非法时给 None 而非 panic.

正确的遍历方式

既然不能下标访问,遍历就成了操作字符串的主力手段.标准库提供两条主要途径,按关心的"单位"选择.

按字符遍历:chars()

let s = "a中😀";

for c in s.chars() {
println!("{c}"); // 依次输出 a / 中 / 😀
}

let count = s.chars().count(); // 3,字符个数(注意是 O(n))

chars() 把字符串解析成一个个 char(Unicode 标量值).要"取第 n 个字符",惯用法是 s.chars().nth(n),它返回 Option<char>,本质仍是从头扫描.

按字节遍历:bytes()

let s = "a中";

for b in s.bytes() {
print!("{b} "); // 97 228 184 173
}

当做的是底层协议解析,校验和计算等不关心字符语义的工作时,直接遍历字节更高效.

同时要字符和位置:char_indices()

let s = "a中😀";

for (byte_idx, c) in s.char_indices() {
println!("{byte_idx}: {c}");
// 0: a
// 1: 中
// 4: 😀
}

注意输出的索引是 0 / 1 / 4,而非 0 / 1 / 2--返回的是每个字符的起始字节位置.这正是做安全切片时定位合法边界的工具.

三种遍历方法速查

  • chars():逐个处理"字符"时用,产出 char
  • bytes():做底层字节处理时用,产出 u8
  • char_indices():既要字符又要它的字节位置时用,产出 (usize, char)

一个经典陷阱:字符串反转

"把字符串反转"看似简单,却是 UTF-8 陷阱的集中体现:

let s = "中文";

// 正确:按字符反转
let reversed: String = s.chars().rev().collect();
println!("{reversed}"); // "文中"

// 错误:如果按字节反转,会破坏 UTF-8 编码,产生乱码甚至非法字符串

连按字符反转也未必正确

对含组合字符或 Emoji 的字符串,chars().rev() 仍可能把一个字形簇拆散.例如带肤色修饰的 Emoji 由多个 char 组成,逐 char 反转会让它"散架".

真正健壮的字形级反转需要借助 unicode-segmentation 按字形簇处理.这也说明,文本处理远比看上去复杂,Rust 只是把这种复杂性显式地摆出来.

快速回顾

  • UTF-8 变长:一个字符占 1~4 字节,len() 返回的是字节数而非字符数
  • 禁止 s[i]:避免把 O(n) 伪装成 O(1),也避免"第 i 个"的语义歧义
  • 切片按字节范围:切在非字符边界会运行时 panic,get() 是安全版本
  • 三种遍历:chars() 取字符,bytes() 取字节,char_indices() 取"字符 + 字节位置"
  • 字形簇需要外部 crate,标准库只到 char 层次
  • 下一篇进入字符串的日常操作:拼接,查找,替换,以及 format! 格式化体系

动手练习

  1. len vs count:对字符串 "Rust语言😀",分别打印 len()chars().count(),解释两个数字为何不同.
  2. 安全截取:用 char_indices() 写一个函数,安全地截取字符串的前 N 个字符(而非字节),保证不会 panic.
  3. 观察 panic:尝试对 "中文" 执行 &s[0..2],观察 panic 信息;再改用 s.get(0..2),确认它返回 None.