当前位置: 首页 > news >正文

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

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


一、先从 C 的字符串说起

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

#include<stdio.h>intmain(intargc,char**argv){for(inti=0;i<argc;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é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关键字,看起来可以保护字符串不被修改。但只要一个类型转换,就能绕过去:

intlen(constchar*s){char*S=(void*)s;S[0]='\0';// 悄悄清空了字符串return0;}

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

陷阱二:内存泄漏

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

char*uppercase(char*s){s=strdup(s);// ... 处理 ...returns;}

问题在于: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 实现同样功能的代码:

fnmain(){letarg=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 的陷阱

防止修改只读数据

fnuppercase(s:&str)->String{s.to_uppercase()}

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

防止 use-after-free

fnmain(){letstripped;{letoriginal=String::from(" hello ");stripped=strip(&original);}// original 在这里被释放println!("{}",stripped);// 编译器直接报错}

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

自动内存管理

letmutupp=String::new();uppercase(&arg,&mutupp);

String实现了Droptrait,离开作用域时自动释放内存。不需要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 的字符串切片以字节为单位。如果你试图在多字节字符的中间位置切开:

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{letmutdst=&src[..];whiledst.starts_with(" "){dst=&dst[1..];}whiledst.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](https://hsivonen.fi/string-length/ “It’s Not Wrong that “🤦🏼‍♂️”.length == 7”)
  • Breaking Our Latin-1 Assumptions
  • The Secret Life Of Cows(关于 Cow 的进阶阅读)
http://www.jsqmd.com/news/834278/

相关文章:

  • 从内存视角拆解float和double:用C语言和调试器带你‘看见’IEEE754的二进制世界
  • YouTube播放列表自动化导出工具:从API调用到结构化数据实战
  • Codesys ST语言PID调参避坑指南:从仿真到实战,手把手教你搞定温控/电机项目
  • 浏览器音乐解锁工具:让你的加密音乐文件重获自由
  • 从零构建自动化监控看板:基于autoshow的轻量级数据可视化实践
  • 3分钟掌握mootdx:Python通达信数据读取的终极解决方案
  • Kali Linux定制化便携U盘:打造专业渗透测试移动工作站
  • Speechless:三步完成微博PDF备份的终极免费Chrome扩展
  • 广州全区域上门回收黄金,正规平台免费上门估价结算 - 金掌柜黄金回收
  • 终极免费离线OCR解决方案:Umi-OCR完整使用指南
  • 树莓派3B+无屏幕无网线,保姆级WiFi配置与SSH远程桌面一条龙教程
  • Taotoken 多模型聚合能力如何赋能 Hermes Agent 的复杂工作流
  • 从Vue2到Vue3:v-for和template的‘键’变之旅,别再写错地方了
  • 广州专业上门回收黄金,全城覆盖一站式贵金属奢品回收 - 金掌柜黄金回收
  • 超越芯片复位:Zynq Watchdog搭配CPLD,实现整板电源监控与恢复的进阶玩法
  • D2DX:如何让经典暗黑破坏神2在现代PC上焕发新生?
  • 通过taotoken审计日志追溯api调用详情与安全分析
  • Pearcleaner:如何彻底清理Mac应用残留文件?终极免费解决方案指南
  • 2026年快速搞定论文降重,必备这些AI降重工具 - 降AI实验室
  • Unity游戏接入TapTap登录,从后台配置到打包上线的完整避坑指南
  • CentOS8实战:ZeroTier构建安全异地虚拟局域网
  • 终极指南:在Windows上直接安装安卓APK文件的5个简单步骤
  • 阿里云计算巢一键部署CoPAW:开源云成本优化实战指南
  • 我的Claude Code不再被封号,Taotoken提供了稳定可靠的替代方案
  • 告别迷茫!在嵌入式Linux上用libwebsockets v4.0实现WebSocket客户端(含SSL配置避坑)
  • Zemax非序列模式实战:用方解石和探测器,5分钟可视化双折射光线分裂效果
  • 开源智能体技术解析:从LangChain到自主抓取,构建自动化工作流
  • 解锁抖音内容生态:douyin-downloader如何重塑你的创作素材获取方式
  • 2026PE给水管厂家推荐,PE燃气管,聚乙烯PE给水管材,PE灌溉管,PE穿线管,PE排水管厂家优选指南! - 品牌鉴赏师
  • API 鉴权中如何防止 JWT Token 被窃取后的重放攻击?