本文通过一组 C 与 Rust 的对比实验解释 Rust 为什么要设计String和str两种字符串类型以及这背后所保护的安全边界。一、先从 C 的字符串说起学过 C 的人都知道字符串就是char*一个内存地址。写一个打印命令行参数的程序简单到不能再简单#includestdio.hintmain(intargc,char**argv){for(inti0;iargc;i){printf(%s\n,argv[i]);}return0;}看起来没问题。argv是一组指针每个指针指向一段字节序列。printf的%s格式符从那个地址开始读一直读到……哪里没有长度没有结束标记——printf怎么知道该在哪里停下来答案是空字符null terminator。C 的字符串以值为0的字节结尾这种设计叫做 null-terminated string。只要遇到\0就停止读取。这个设计简洁但也为后面所有的问题埋下了伏笔。二、UTF-8字符不等于字节我们试着让这个 C 程序把每个字符单独打印中间加空格printf(%c ,character);对 ASCII 字符串eat the rich当然没问题。但如果输入élémentl 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 码点 U00E9为例它的二进制是11101001需要用 2 字节编码第一字节: 11000011 → 0xC3 第二字节: 10101001 → 0xA9这就是为什么é在内存里是c3 a9是两个字节而不是一个char。C 的char本质上是一个有符号的 8 位整数。它根本不知道什么是 Unicode更不知道什么是多字节字符。用 C 逐字节处理字符串对非 ASCII 文本几乎必然出错。还有更深的坑grapheme cluster即便你正确实现了 UTF-8 解码也未必够用。Unicode 中存在组合字符例如 U0308 是一个组合分音符combining diaeresis它并不是独立字符而是附加到前一个字符上。noël可以用两种方式编码直接使用ëU00EB带分音符的 e使用e 组合分音符U0308两种方式看起来一样但字节序列完全不同。把它们拆开打印会发现组合分音符是独立的导致渲染错位。这种多个码点共同构成一个可见字符的单位叫做grapheme cluster。处理它需要专门的 Unicode 算法远超 UTF-8 解码本身的复杂度。三、C 字符串的安全陷阱理解了字符编码问题后我们再看 C 在内存安全上的缺陷。陷阱一修改只读数据C 里有const关键字看起来可以保护字符串不被修改。但只要一个类型转换就能绕过去intlen(constchar*s){char*S(void*)s;S[0]\0;// 悄悄清空了字符串return0;}编译器不报错运行也不崩溃。const提供的只是一种君子协定而不是真正的保护。陷阱二内存泄漏写一个返回大写字符串的函数最自然的做法是在函数内部strdup一份再处理char*uppercase(char*s){sstrdup(s);// ... 处理 ...returns;}问题在于strdup申请了堆内存调用方必须记得free。但函数签名只是char *没有任何提示说这块内存是你的你要负责释放。忘了free就是内存泄漏。陷阱三malloc 少算了 1为字符串分配内存时需要为 null 终止符多留一个字节char*uppmalloc(strlen(arg)1);// 注意这个 1忘了1就会写越界。Valgrind 会告诉你Invalid write of size 1。这种错误安静地存在于大量生产代码里CVE 列表为证。陷阱四use-after-freechar*uppuppercase(arg);free(upp);printf(upp %s\n,upp);// 用了已经释放的内存程序可能正常输出也可能崩溃也可能输出乱码。undefined behavior 的世界里什么都有可能。这些问题的共同特征是C 编译器无法在编译期阻止你做这些事。四、Rust 怎么做到的现在我们来看 Rust 实现同样功能的代码fnmain(){letargstd::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 的陷阱防止修改只读数据fnuppercase(s:str)-String{s.to_uppercase()}str是不可变借用。不用unsafe根本无法修改它。这不是约定是语言层面的强制保证。防止 use-after-freefnmain(){letstripped;{letoriginalString::from( hello );strippedstrip(original);}// original 在这里被释放println!({},stripped);// 编译器直接报错}编译器会拒绝这段代码因为stripped持有对original的借用而original的生命周期更短。这正是 Rust 生命周期系统的核心价值让悬空指针成为编译期错误而不是运行期崩溃。自动内存管理letmutuppString::new();uppercase(arg,mutupp);String实现了Droptrait离开作用域时自动释放内存。不需要free也不会泄漏。无效 UTF-8 的安全处理如果命令行参数不是有效的 UTF-8std::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 的字符串切片以字节为单位。如果你试图在多字节字符的中间位置切开lets;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 的一个妙用零拷贝切片考虑一个去除字符串首尾空格的函数fnstrip(src:str)-str{letmutdstsrc[..];whiledst.starts_with( ){dstdst[1..];}whiledst.ends_with( ){dstdst[..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](https://hsivonen.fi/string-length/ “It’s Not Wrong that “‍♂️”.length 7”)Breaking Our Latin-1 AssumptionsThe Secret Life Of Cows关于 Cow 的进阶阅读