一句话总结
Rust 的字符串底层是 UTF-8 字节序列,一个字符可能占 1 到 4 个字节.
正因如此,s[i] 这种下标取字符的写法被语言直接禁止--它无法在 O(1) 时间内给出"第 i 个字符",还可能切在字符中间制造乱码.
前置回顾
第 01 篇讲了 String 与 &str 的所有权分工和内存布局.本篇深入它们共同的底层表示--UTF-8 编码,理解 Rust 为什么在字符串操作上"限制这么多".
UTF-8:变长编码的字节序列
Rust 规定所有 str 和 String 内部必须是合法的 UTF-8 字节.UTF-8 是一种变长编码:不同字符占用的字节数不同.
| 字符范围 | 占用字节 | 例子 |
|---|---|---|
| ASCII(英文,数字,常见符号) | 1 字节 | a 0 ! |
| 拉丁扩展,希腊字母等 | 2 字节 | é λ |
| 中日韩文字,大部分符号 | 3 字节 | 中 あ |
| Emoji,罕见字符 | 4 字节 | 😀 |
看一个具体的例子,字符串 "a中😀" 在内存里的实际字节布局:
|
这里 "a中😀".len() 返回的是 8,而不是 3.len() 永远返回字节数,这是新手最容易误判的地方.
把 UTF-8 字符串想象成一列长度不一的车厢连成的火车:英文字符是单节小车厢,中文是三节,Emoji 是四节.没法靠"第 4 节车厢"直接定位到"第 2 个乘客",因为每位乘客占的车厢数不一样.
为什么 s[i] 被禁止
在 Python 里 "中"[0] 能直接取字符,为什么 Rust 不行?编译下面的代码会直接报错:
|
Rust 禁止它,有两个理由:
- 性能不可预期:下标访问
s[i]在所有语言中都被期望是 O(1).但在变长编码里,要找到"第 i 个字符"必须从头逐字节扫描,是 O(n).Rust 不愿意为一个看起来 O(1) 的语法偷偷藏一个 O(n) 的实现. - 语义有歧义:
s[i]中的i到底指第 i 个字节,第 i 个 Unicode 标量值,还是第 i 个"看起来的字符"?三者结果可能完全不同.与其猜,不如让开发者显式选择.
字节,字符,字形是三个不同概念
同一个字符串可以从三个层次度量:
字节(byte, u8) 是存储单位;字符(char) 是一个 Unicode 标量值,固定 4 字节;字形簇(grapheme cluster) 才是人眼看到的"一个字".
例如带音标的 é 可能由两个 char(基础字母 + 组合音标)拼成,却是一个字形.标准库只提供前两者,字形簇需要 unicode-segmentation 这类外部 crate.
切片可以,但要小心边界
虽然不能用单个整数下标,Rust 却允许用范围切片,例如 &s[0..3].代价是:这里的范围是字节索引,且必须恰好落在字符边界上,否则运行时 panic.
|
切片越界是运行时 panic,不是编译错误
&s[0..2] 切在了"中"这个三字节字符的中间,编译能通过,但运行时直接 panic 并终止程序.
处理用户输入或不确定内容的字符串时,用裸字节范围切片是高风险操作.需要安全截断时,应使用 s.char_indices() 找到合法边界,或用 s.get(0..2)--后者返回 Option,边界非法时给 None 而非 panic.
正确的遍历方式
既然不能下标访问,遍历就成了操作字符串的主力手段.标准库提供两条主要途径,按关心的"单位"选择.
按字符遍历:chars()
|
chars() 把字符串解析成一个个 char(Unicode 标量值).要"取第 n 个字符",惯用法是 s.chars().nth(n),它返回 Option<char>,本质仍是从头扫描.
按字节遍历:bytes()
|
当做的是底层协议解析,校验和计算等不关心字符语义的工作时,直接遍历字节更高效.
同时要字符和位置:char_indices()
|
注意输出的索引是 0 / 1 / 4,而非 0 / 1 / 2--返回的是每个字符的起始字节位置.这正是做安全切片时定位合法边界的工具.
三种遍历方法速查
chars():逐个处理"字符"时用,产出charbytes():做底层字节处理时用,产出u8char_indices():既要字符又要它的字节位置时用,产出(usize, char)
一个经典陷阱:字符串反转
"把字符串反转"看似简单,却是 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!格式化体系
动手练习
- len vs count:对字符串
"Rust语言😀",分别打印len()和chars().count(),解释两个数字为何不同. - 安全截取:用
char_indices()写一个函数,安全地截取字符串的前 N 个字符(而非字节),保证不会 panic. - 观察 panic:尝试对
"中文"执行&s[0..2],观察 panic 信息;再改用s.get(0..2),确认它返回None.