本文通过一组 C 与 Rust 的对比实验,解释 Rust 为什么要设计
String和&str两种字符串类型,以及这背后所保护的安全边界。
一、先从 C 的字符串说起
学过 C 的人都知道,字符串就是 char*,一个内存地址。写一个打印命令行参数的程序,简单到不能再简单:
#include <stdio.h>
int main(int argc, char **argv) {
for (int i = 0; i < argc; i++) {
printf("%s\n", argv[i]);
}
return 0;
}
看起来没问题。argv 是一组指针,每个指针指向一段字节序列。printf 的 %s 格式符从那个地址开始读,一直读到……哪里?
没有长度,没有结束标记——printf 怎么知道该在哪里停下来?
答案是:空字符(null terminator)。C 的字符串以值为 0 的字节结尾,这种设计叫做 null-terminated string。只要遇到 \0,就停止读取。
这个设计简洁,但也为后面所有的问题埋下了伏笔。
二、UTF-8:字符不等于字节
我们试着让这个 C 程序把每个字符单独打印,中间加空格:
printf("%c ", character);
对 ASCII 字符串 "eat the rich" 当然没问题。但如果输入 "élément":
l m e n t
"é" 不见了,出现了奇怪的乱码。原因很简单:"é" 在 UTF-8 编码下不是一个字节,而是两个字节 0xC3 0xA9。
UTF-8 编码简介
UTF-8 是一种变长编码。ASCII 字符(0–127)依然是单字节,和 ASCII 完全兼容。而超出 ASCII 范围的字符,则使用 2 到 4 个字节来表示,通过字节开头的特定位模式来标识序列长度:
- 以
110开头:这是一个 2 字节序列的第一个字节 - 以
1110开头:3 字节序列 - 以
11110开头:4 字节序列 - 以
10开头:多字节序列的"延续字节"
以 "é"(Unicode 码点 U+00E9)为例,它的二进制是 11101001,需要用 2 字节编码:
第一字节: 11000011 → 0xC3
第二字节: 10101001 → 0xA9
这就是为什么 "é" 在内存里是 c3 a9,是两个字节,而不是一个 char。
C 的 char 本质上是一个有符号的 8 位整数。它根本不知道什么是 Unicode,更不知道什么是多字节字符。用 C 逐字节处理字符串,对非 ASCII 文本几乎必然出错。
还有更深的坑:grapheme cluster
即便你正确实现了 UTF-8 解码,也未必够用。Unicode 中存在"组合字符",例如 U+0308 是一个"组合分音符(combining diaeresis)",它并不是独立字符,而是附加到前一个字符上。
noël 可以用两种方式编码:
- 直接使用
ë(U+00EB,带分音符的 e) - 使用
e+ 组合分音符(U+0308)
两种方式看起来一样,但字节序列完全不同。把它们拆开打印,会发现组合分音符是独立的,导致渲染错位。
这种"多个码点共同构成一个可见字符"的单位,叫做 grapheme cluster。处理它需要专门的 Unicode 算法,远超 UTF-8 解码本身的复杂度。
三、C 字符串的安全陷阱
理解了字符编码问题后,我们再看 C 在内存安全上的缺陷。
陷阱一:修改"只读"数据
C 里有 const 关键字,看起来可以保护字符串不被修改。但只要一个类型转换,就能绕过去:
int len(const char *s) {
char *S = (void *) s;
S[0] = '\0'; // 悄悄清空了字符串
return 0;
}
编译器不报错,运行也不崩溃。const 提供的只是一种"君子协定",而不是真正的保护。
陷阱二:内存泄漏
写一个返回大写字符串的函数,最自然的做法是在函数内部 strdup 一份再处理:
char *uppercase(char *s) {
s = strdup(s);
// ... 处理 ...
return s;
}
问题在于:strdup 申请了堆内存,调用方必须记得 free。但函数签名只是 char *,没有任何提示说"这块内存是你的,你要负责释放"。忘了 free,就是内存泄漏。
陷阱三:malloc 少算了 1
为字符串分配内存时,需要为 null 终止符多留一个字节:
char *upp = malloc(strlen(arg) + 1); // 注意这个 +1
忘了 +1,就会写越界。Valgrind 会告诉你:Invalid write of size 1。这种错误安静地存在于大量生产代码里,CVE 列表为证。
陷阱四:use-after-free
char *upp = uppercase(arg);
free(upp);
printf("upp = %s\n", upp); // 用了已经释放的内存
程序可能正常输出,也可能崩溃,也可能输出乱码。undefined behavior 的世界里,什么都有可能。
这些问题的共同特征是:C 编译器无法在编译期阻止你做这些事。
四、Rust 怎么做到的
现在我们来看 Rust 实现同样功能的代码:
fn main() {
let arg = std::env::args()
.skip(1)
.next()
.expect("should have one argument");
println!("{}", arg.to_uppercase());
}
测试几个边界情况:
$ cargo run -- "noël"
NOËL
$ cargo run -- "heinz große"
HEINZ GROSSE
最后一个尤其值得注意。德语中 ß(eszett)的大写是 SS,是一个字符变成了两个字符。Rust 的标准库原生正确处理了这种情况——这在 C 中需要引入完整的 ICU 库才能做到。
String 和 &str 是什么
String 是堆分配的、可增长的 UTF-8 字符串,拥有自己的所有权。
&str 是字符串的借用视图(slice),它不拥有数据,仅是对某段有效 UTF-8 字节序列的引用。这个引用可以指向 String 的内部、字符串字面量(存储在程序的数据段),或者其他任何地方。
它们的分工其实就对应着 C 里两种最常见的使用场景:
- 需要拥有并管理字符串数据 →
String - 只需要读取一段字符串,不关心谁拥有它 →
&str
所有权如何消灭那些 C 的陷阱
防止修改只读数据
fn uppercase(s: &str) -> String {
s.to_uppercase()
}
&str 是不可变借用。不用 unsafe,根本无法修改它。这不是约定,是语言层面的强制保证。
防止 use-after-free
fn main() {
let stripped;
{
let original = String::from(" hello ");
stripped = strip(&original);
} // original 在这里被释放
println!("{}", stripped); // 编译器直接报错
}
编译器会拒绝这段代码,因为 stripped 持有对 original 的借用,而 original 的生命周期更短。这正是 Rust 生命周期系统的核心价值:让悬空指针成为编译期错误,而不是运行期崩溃。
自动内存管理
let mut upp = String::new();
uppercase(&arg, &mut upp);
String 实现了 Drop trait,离开作用域时自动释放内存。不需要 free,也不会泄漏。
无效 UTF-8 的安全处理
如果命令行参数不是有效的 UTF-8,std::env::args() 会 panic,而不是静默地继续读取内存:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "\xC3"'
反观 C 的实现:传入截断的 UTF-8 字节,我们的程序会把 null 终止符误判为延续字节,然后继续读取内存,直到碰到 CDPATH=.:/home/... 这样的环境变量。在 Web 服务场景下,这很可能暴露 SECRET_API_TOKEN。
切片越界的精准报错
Rust 的字符串切片以字节为单位。如果你试图在多字节字符的中间位置切开:
let s = "🙈🙉🙊💥";
let _ = &s[..2]; // panic!
thread 'main' panicked at 'byte index 2 is not a char boundary;
it is inside '🙈' (bytes 0..4) of `🙈🙉🙊💥`'
错误信息精确到字符边界。不是 undefined behavior,不是内存乱读,而是明确的 panic,并告诉你哪里出了问题。
五、&str 的一个妙用:零拷贝切片
考虑一个去除字符串首尾空格的函数:
fn strip(src: &str) -> &str {
let mut dst = &src[..];
while dst.starts_with(" ") {
dst = &dst[1..];
}
while dst.ends_with(" ") {
dst = &dst[..dst.len() - 1];
}
dst
}
返回的 &str 指向的是原始字符串的同一块内存,只是起止偏移量不同。整个过程没有任何堆分配,也没有数据复制。这是 &str 与 String 分离设计最直接的性能红利。
六、总结
Rust 的字符串系统看起来比 C 复杂,但这种复杂性是有代价换来的保证:
| C 的问题 | Rust 的回答 |
|---|---|
| const 随时可被绕过 | 不可变借用在类型系统层面强制 |
| malloc/free 手动配对 | 所有权系统自动管理生命周期 |
| null terminator 容易漏算 | 字符串带长度,不依赖终止符 |
| 无效 UTF-8 静默继续执行 | 类型系统保证 String 始终是合法 UTF-8 |
| 切片越界是 UB | 运行期 panic,并给出明确报错 |
String 与 &str 的两类设计,不是故意为难开发者,而是在帮你把"我拥有这段数据"和"我只是借用这段数据"两件事,从心智模型变成可被编译器验证的事实。
这就是 Rust 的字符串为什么这样设计,也是它值得信任的原因。
参考原文:Working with strings in Rust,作者 Amos Wenger
延伸阅读:
- [It's Not Wrong that "🤦🏼♂️".length == 7](hsivonen.fi/string-leng… "It's Not Wrong that "🤦🏼♂️".length == 7")
- Breaking Our Latin-1 Assumptions
- The Secret Life Of Cows(关于 Cow 的进阶阅读)