从 C 的混乱到 Rust 的优雅:字符串处理为什么这么难

本文通过一组 C 与 Rust 的对比实验,解释 Rust 为什么要设计 String&str 两种字符串类型,以及这背后所保护的安全边界。


一、先从 C 的字符串说起

学过 C 的人都知道,字符串就是 char*,一个内存地址。写一个打印命令行参数的程序,简单到不能再简单:

c 复制代码
#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 程序把每个字符单独打印,中间加空格:

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 字节编码:

makefile 复制代码
第一字节: 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 关键字,看起来可以保护字符串不被修改。但只要一个类型转换,就能绕过去:

c 复制代码
int len(const char *s) {
    char *S = (void *) s;
    S[0] = '\0'; // 悄悄清空了字符串
    return 0;
}

编译器不报错,运行也不崩溃。const 提供的只是一种"君子协定",而不是真正的保护。

陷阱二:内存泄漏

写一个返回大写字符串的函数,最自然的做法是在函数内部 strdup 一份再处理:

c 复制代码
char *uppercase(char *s) {
    s = strdup(s);
    // ... 处理 ...
    return s;
}

问题在于:strdup 申请了堆内存,调用方必须记得 free。但函数签名只是 char *,没有任何提示说"这块内存是你的,你要负责释放"。忘了 free,就是内存泄漏。

陷阱三:malloc 少算了 1

为字符串分配内存时,需要为 null 终止符多留一个字节:

c 复制代码
char *upp = malloc(strlen(arg) + 1); // 注意这个 +1

忘了 +1,就会写越界。Valgrind 会告诉你:Invalid write of size 1。这种错误安静地存在于大量生产代码里,CVE 列表为证

陷阱四:use-after-free

c 复制代码
char *upp = uppercase(arg);
free(upp);
printf("upp = %s\n", upp); // 用了已经释放的内存

程序可能正常输出,也可能崩溃,也可能输出乱码。undefined behavior 的世界里,什么都有可能。

这些问题的共同特征是:C 编译器无法在编译期阻止你做这些事


四、Rust 怎么做到的

现在我们来看 Rust 实现同样功能的代码:

rust 复制代码
fn main() {
    let arg = std::env::args()
        .skip(1)
        .next()
        .expect("should have one argument");

    println!("{}", arg.to_uppercase());
}

测试几个边界情况:

arduino 复制代码
$ 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 的陷阱

防止修改只读数据

rust 复制代码
fn uppercase(s: &str) -> String {
    s.to_uppercase()
}

&str 是不可变借用。不用 unsafe,根本无法修改它。这不是约定,是语言层面的强制保证。

防止 use-after-free

rust 复制代码
fn main() {
    let stripped;
    {
        let original = String::from("  hello  ");
        stripped = strip(&original);
    } // original 在这里被释放
    println!("{}", stripped); // 编译器直接报错
}

编译器会拒绝这段代码,因为 stripped 持有对 original 的借用,而 original 的生命周期更短。这正是 Rust 生命周期系统的核心价值:让悬空指针成为编译期错误,而不是运行期崩溃。

自动内存管理

rust 复制代码
let mut upp = String::new();
uppercase(&arg, &mut upp);

String 实现了 Drop trait,离开作用域时自动释放内存。不需要 free,也不会泄漏。

无效 UTF-8 的安全处理

如果命令行参数不是有效的 UTF-8,std::env::args() 会 panic,而不是静默地继续读取内存:

rust 复制代码
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "\xC3"'

反观 C 的实现:传入截断的 UTF-8 字节,我们的程序会把 null 终止符误判为延续字节,然后继续读取内存,直到碰到 CDPATH=.:/home/... 这样的环境变量。在 Web 服务场景下,这很可能暴露 SECRET_API_TOKEN

切片越界的精准报错

Rust 的字符串切片以字节为单位。如果你试图在多字节字符的中间位置切开:

rust 复制代码
let s = "🙈🙉🙊💥";
let _ = &s[..2]; // panic!
arduino 复制代码
thread 'main' panicked at 'byte index 2 is not a char boundary;
it is inside '🙈' (bytes 0..4) of `🙈🙉🙊💥`'

错误信息精确到字符边界。不是 undefined behavior,不是内存乱读,而是明确的 panic,并告诉你哪里出了问题。


五、&str 的一个妙用:零拷贝切片

考虑一个去除字符串首尾空格的函数:

rust 复制代码
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 指向的是原始字符串的同一块内存,只是起止偏移量不同。整个过程没有任何堆分配,也没有数据复制。这是 &strString 分离设计最直接的性能红利。


六、总结

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...](https://link.juejin.cn?target=https%3A%2F%2Fhsivonen.fi%2Fstring-length%2F "https://hsivonen.fi/string-length/") "It's Not Wrong that "🤦🏼‍♂️".length == 7")

  • The Secret Life Of Cows(关于 Cow 的进阶阅读)
相关推荐
jieyucx1 小时前
Go 语言进阶:结构体指针、new 关键字与匿名结构体/成员详解
开发语言·后端·golang·结构体
IT大家说1 小时前
那些没人主动教你的代码小技巧,写完代码干净又优雅
后端
摇滚侠1 小时前
Spring 面试题 真正的 offer 偏方 Java 基础 Java 高级
java·后端·spring
用户78937733908532 小时前
前端转后端生存指南(中):化身架构师,用 ORM 魔法掌控数据库
后端·python
Master_Azur2 小时前
JavaEE之文件操作 字符集 IO流
后端
传说之后2 小时前
GO 语言单元测试入门
后端
古城小栈2 小时前
Bun从Zig迁移至Rust:有何重大意义?
开发语言·后端·rust
虎子_layor2 小时前
给 Agent 接入新模型的推理模式:从配置开关到协议适配
后端·架构
IT_陈寒2 小时前
Java的Stream.peek()千万别乱用,血泪教训
前端·人工智能·后端