想象你是一艘深海潜水艇的艇长。Rust 就是这艘艇的自动航行系统——它内置了一整套传感器和规则引擎(所有权、借用检查器),确保你的代码像艇身一样坚固,绝不会有数据竞争或悬空指针这类「漏水」事故。自动模式很安全,但有些任务它做不了:比如绕过体检直接读取一块原始内存、与海底的未知生物(C 库)对话、调整全船共享的仪表盘。这时候,你需要一把特殊的钥匙——unsafe。它打开驾驶舱里五个手动装置:解裸指针、调用 unsafe 函数、修改可变静态变量、实现 unsafe trait、访问联合体字段。每次用完,你都要在航行日志上签字负责,因为从手动打开发动机的那一刻起,编译器这个副舰长就把安全检查的职责全部移交给了你。
对 AI 从业者而言,理解 unsafe 是读懂 PyTorch、llama.cpp 等高性能推理引擎底层的钥匙,也是从「调库选手」进阶为「系统级优化者」的必经关卡。
可视化演示
展示Rust unsafe关键字解锁的五大超能力:解裸指针、调用unsafe函数、访问可变静态变量、实现unsafe trait、访问联合体字段,及其安全封装策略。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+captions.ptr+'
概览与代价
潜水艇的自动航行系统由三套互锁装置构成:所有权系统确保每件设备只有一个主人,借用检查器确保你借出的工具不会被别人偷偷扔掉,类型系统确保你不会把鱼雷塞进氧气阀。这三者组合起来,让编译器能够在代码跑起来之前就把「漏水隐患」全部排查干净。但总有些操作是自动系统刻意拒绝的——比如直接查看一块未经登记的原始内存,或者跟一座没有应答协议的陌生基站通信。
unsafe 关键字就是打开驾驶舱检修门的钥匙。它本身并不关闭自动航行,只是为你打开五个手动装置:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。进入 unsafe 块后,编译器不再替你检查内存安全,转而要求你以艇长身份亲自确认每一项操作的合法性。 这正是「五大超能力」的代价:你接管了安全检查,也就接管了出错的责任。
从编程模型上看,Rust 对一段代码安全性的定义,可以简化为「程序满足编译器施加的别名与生命周期约束」:
回到艇长的比喻:自动航行时你只管下达指令;走进检修门动手修理时,你必须亲手确认氧气阀的朝向、线路的绝缘、螺栓的扭矩。unsafe 不是让你乱来的许可,而是把「自动检查」换成「人工确认」的责任交接仪式。
- 别名规则 Aliasing一个可变引用(类似「唯一钥匙」)存在时,不得同时存在其他引用指向同一份数据。
- 生命周期规则 Lifetime引用的存活范围必须被包含在所指向数据的存活范围之内。
- 类型正确 TypeCorrect操作的数据类型与声明一致,不把整数当指针用。
解裸指针
驾驶舱里有一排被透明防护罩盖住的接线端子——它们就是裸指针 *const T 和 *mut T。自动模式下,你只能通过按钮(引用 &T)操作设备:按下按钮,系统帮你找到对应线路,松手就断开。但有些底层场景,比如与 C 库交换原始内存地址、手写内存池,你必须掀开防护罩,直接捏住那根裸露的铜线。
裸指针与引用的本质区别在于:引用保证「这根铜线永远接着有效的设备,且不会同时被两个人拧」,而裸指针只是一根铜线的坐标。编译器不再帮你验证它是否还连着设备、是否还处于有效电压范围内——这一切都要你自己在解引用(捏住铜线)之前确认。
解引用裸指针的语法非常简单,但隐含的义务不简单:
回到接线端子的画面:掀开防护罩前,你必须用万用表量过电压、确认线缆没有裸露在外的破损。Rust 团队有一条经验法则——永远不要在 unsafe 块里执行超过一行「确认逻辑」的操作,把裸指针的创建与解引用分开,让每一次危险动作都有据可查。
*const T 转换为引用 &T 的过程必须在 unsafe 块中进行,并且需要你手动保持三个条件:指针非空、指向的内存仍然有效、没有悬空。任何一条不满足,程序就会像潜水艇撞上暗礁一样——未定义行为。- 转换操作符
*const T表示「指向 T 的只读裸指针」,*&*表示解引用后再取引用。 - 非空条件指针不得为 null,否则解引用就是读取地址 0。
- 有效条件指针指向的内存必须仍然归本程序所有,且类型正确。
unsafe函数
有些设备一旦启动就进入高能状态,比如应急动力系统的反应堆——自动系统拒绝一键启动,因为启动后的安全完全取决于启动前的准备。Rust 里这种设备就是 unsafe fn:一个标注了 unsafe 的函数。调用它不需要在 unsafe 块里,但调用者必须亲自保证函数文档里列出的前置条件。
比如标准库的 slice::from_raw_parts,它接受一个指针和一个长度,直接为你构造出一个切片。它的前提条件包括:指针必须非空且对齐、长度对应的内存必须已初始化、整个生命周期内该内存不得被并发写。这些条件编译器无法验证,所以这个函数被标注为 unsafe——调用它,就像你在艇长日志上签字确认「反应堆冷却水已注满」。
函数调用的安全问题可以形式化为:
用反应堆来记忆:unsafe fn 的文档注释就是操作规程,调用前逐条核对是你的义务。标准库中大量 unsafe fn 的典型应用是性能临界区——例如从已对齐的缓冲区构造切片、绕过边界检查直接索引,为 AI 推理引擎省下每一次内存访问的验证开销。
- 前置条件集合每个 unsafe 函数必须用文档注释列出所有调用前提。
- 合取关系所有条件必须同时成立,缺一条就可能引发 UB。
- 义务转移条件不满足时调用,责任在调用者而非函数作者。
可变静态变量
驾驶舱最显眼的位置挂着一块全船共享的气压表——任何舱室都能读数,理论上任何舱室也都能调整它。在 Rust 里,static mut 就是这样一块全局共享的可变仪表。自动系统绝不允许这种装置存在,因为如果两个线程同时拧动这块表,你根本无法判断最终读数是谁拧出来的——这就是数据竞争。
声明一个可变静态变量本身不需要 unsafe,但读取或写入它的值必须在 unsafe 块中。因为编译器无法追踪所有线程对它的访问顺序。数据竞争是 Rust 安全系统最深恶痛绝的未定义行为之一,它比单纯的内存泄漏更危险——可能导致读取到半更新状态、指令重排引发的逻辑错乱。
它的风险模型可以写成:
在 AI 场景中,全局缓存计数器、推理批次统计这类跨线程共享状态,几乎总能找到比 static mut 更安全的替代品:AtomicUsize 处理计数、OnceLock 处理一次性初始化、Mutex<Option<T>> 处理复杂可变状态。不到万不得已,不要拧那块共享气压表。
- 写与读并发两个线程对同一地址的访问在时间轴上重叠。
- 无序访问访问之间没有 happens-before 关系,无法确定先后顺序。
- 结果不确定最终读取值取决于调度器,导致逻辑不可复现。
unsafe trait
潜水艇上有些特种设备需要额外资质才能操作,比如深潜服——只有经过训练的蛙人才能穿。Rust 用 unsafe trait 来表达这类「需要实现者亲自保证安全契约」的能力标记。实现一个 unsafe trait,就等于你向编译器宣誓:「我保证我的类型满足这条 trait 的全部安全义务。」
最典型的是 Send 和 Sync。Send 表示「这个类型的值可以安全地转移到另一个线程」;Sync 表示「这个类型的值可以被多个线程同时借用来访问」。编译器会自动为绝大多数类型推导这两种 trait,但如果你手写了一个包含裸指针的结构体,编译器就无法自动判断——它不知道你的裸指针背后是否藏着非线程安全的数据。
unsafe trait 的实现义务可以表示为:
用蛙人来记忆:穿上深潜服前,你要保证自己的训练合格、装备无破损。实现 unsafe trait 不是在告诉编译器「我很安全」,而是在告诉编译器「我把安全责任全部包下了,你不用再检查」。这比任何普通 trait 都严肃——所以 Send 和 Sync 的文档被称为「写给编译器看的宣誓书」。
- 义务清单每个 unsafe trait 的文档中都列明实现者的责任条款。
- 全称承诺所有条款必须为真,编译器会假设它们成立。
- 违反后果承诺被打破时,使用者可能遭遇数据竞争甚至崩溃。
联合体字段
潜水艇的储物舱里有几种多功能隔间:同一个空间,既可以存放压缩食品(按字节解读),也可以存放医疗包(按另一种格式解读)。Rust 的 union 就是这样的内存视角转换器——它在同一块内存上定义了多个字段类型,但同一时刻只有一个字段的值被「认为是有效的」。
与 enum 不同,union 没有标签来记录当前存的是哪一种类型。你得自己记住上次写入的是哪个字段。读取任何字段都被视为潜在危险,因为编译器无法确认这块内存当前的有效类型。如果按错误类型解读数据,就像把压缩食品包装当医疗包打开——里面的东西完全不可预期。
联合体的大小由最大字段决定,访问规则可以表述为:
联合体在 AI 底层主要出现在 FFI 场景:与 C 库交换「多态数据」时,C 侧经常用 union 表达不同类型的数据载荷。安全 Rust 中几乎总是优先用 enum——它用额外的一个字节记录当前类型标签,把「我自己记住」变成「编译器帮我记住」,代价仅仅是几字节内存。
- 大小规则所有字段共享同一块起始地址,总大小按最大字段分配。
- 写入规则写入某个字段是安全的,因为写操作定义了该块内存的新类型。
- 读取规则读取前必须由程序员保证最近一次写入的是同一字段。
最小化范围
手动驾驶最危险的地方在于操作面太大——如果整个检修舱都敞开着,你很难盯住每一个阀门。Rust 社区的经验法则是:让 unsafe 块尽可能小,最好小到只有一两行。与其把整个函数标为 unsafe,不如只在真正需要解除检查的那一行包上 unsafe 块,其余部分继续享受自动安全检查。
这个原则可以形式化为「危险暴露面与 unsafe 代码量成正比」:
典型的安全封装模式是:内部函数处理裸指针的一切细节,外部函数只接受普通引用并返回安全类型。这样调用者永远接触不到 unsafe,而出 bug 时你只需要审查那一个块——把检修舱压缩成只能伸进一只手的小窗口,盯起来就轻松多了。
- 代码行数因子unsafe 块内的每一行都需要人工验证。
- 接口面积因子公开的 API 边界越大,可能被误用的入口越多。
- 风险递减策略把 unsafe 藏进最内层,用安全函数包装。
审计验证
手动驾驶不等于盲目驾驶。优秀的艇长每次手动操作后都会进行系统自检:先用仪器扫描一遍,再亲自复查关键节点。Rust 生态为 unsafe 代码提供了同样分层级的验证工具链。
第一层是 Miri——Rust 官方提供的「未定义行为探测器」。它通过解释执行代码,精确追踪每次内存访问是否违反安全规则,能捕获悬空指针、对齐错误、未初始化读取等问题。第二层是 Sanitizer 系列(如 AddressSanitizer、ThreadSanitizer),在编译时插入检查代码,运行时捕捉越界访问和数据竞争。第三层才是人工审查——逐行核对 unsafe 块的前置条件。
验证的深度也可以看作一个递进链:
用潜水艇来收尾:Miri 就像声呐扫描整片海域,ASan 像实时监测船体应力,人工审查则是艇长拿着对照表亲手拧一遍关键螺栓。三者配合,才能把手动驾驶的风险压到最低。 这也是为什么 Rust 标准库的 unsafe 代码经过二十年锤炼后,仍能保持极高可信度——每一行都经过了这三级检查的洗礼。
- 动态检测层Miri 在解释器里逐指令验证未定义行为。
- 运行时消毒层ASan/TSan 在编译产物中插桩监测。
{\displaystyle {\text{ASan}}: {\text{mem-access}}\Rightarrow {\text{valid}}}} - 人工审查层对照 unsafe 文档的契约清单逐条打勾。
AI 应用场景
高性能推理引擎的内存池
阶段:模型部署与推理优化
解决的问题:AI 推理(如 llama.cpp 的 KV cache)需要频繁为 token 缓存分配和释放内存块。传统 safe Rust 每次分配都要经过所有权检查和潜在边界检查,在每 token 毫秒级延迟的推理路径上开销不可忽略。用 unsafe 裸指针实现内存池,可以在初始化时一次性申请大块内存,后续分配只用指针偏移计算,避免运行时检查。
具体案例:llama.cpp 的 KV cache 管理器用一个 static mut 风格的全局缓存池记录每个 token 的 key/value 张量位置,推理循环中通过裸指针直接读写缓存块。这些 unsafe 操作被封装在 CachePool::insert 和 CachePool::get 两个安全方法内,外部推理代码完全感知不到指针的存在。
深度学习框架的 C FFI 绑定
阶段:框架底层开发与算子实现
解决的问题:PyTorch 等框架需要调用 cuBLAS、cuDNN 等 C 语言高性能库。Rust 绑定层必须将 Rust 的张量数据指针传给 C 函数——这个过程中创建裸指针、在 C 侧写数据后再转回切片,全部依赖 unsafe。正确封装后,上层 Rust 代码保持完全安全。
具体案例:tch-rs 项目在调用 ATen 库时,用 unsafe 块将 Tensor 内部数据指针传给 C++ 侧,并通过安全包装函数保证「调用期间张量不被释放、不被并发修改」。所有 unsafe fn 的文档都明确列出了指针有效期和别名约束。
Python 演示
# 模拟 Rust unsafe 最小化原则的 Python 演示# 思路:把 "危险操作" 隔离在私有方法中,对外只暴露安全接口 class MemorySlab: """模拟 Rust 中基于裸指针的内存池 内部 _raw_* 方法相当于 unsafe 块中的裸指针操作, 外部只能通过安全方法 allocate/store/load/free 访问。 """ def __init__(self, capacity): self._storage_map = {} # 模拟原始内存地址空间 self._capacity = capacity def _raw_write(self, addr, value): # 模拟 unsafe: 直接写内存,不做任何检查 self._storage_map[addr] = value def _raw_read(self, addr): # 模拟 unsafe: 直接读内存,不验证是否已释放 return self._storage_map.get(addr) def allocate(self): # 安全接口:扫描空闲地址,相当于内存池的分配器 for addr in range(self._capacity): if addr not in self._storage_map: return addr raise MemoryError("内存池已满") def store(self, addr, value): # 安全接口:写入前检查地址仍有效(防止 use-after-free) if addr not in self._storage_map: raise RuntimeError("use-after-free: 向已释放的地址写入") self._raw_write(addr, value) def load(self, addr): # 安全接口:读取前检查地址仍有效 if addr not in self._storage_map: raise RuntimeError("use-after-free: 读取已释放的地址") return self._raw_read(addr) def free(self, addr): # 安全接口:释放前检查地址确实存在(防止 double free) if addr not in self._storage_map: raise RuntimeError("double free: 重复释放") del self._storage_map[addr] def demo(self): # 演示整个流程:分配 -> 写入 -> 读取 -> 释放 -> 错误捕获 a = self.allocate() print(f"[安全] 分配地址 {a}") self.store(a, 42) print(f"[安全] 写入 42,读取得到 {self.load(a)}") self.free(a) print(f"[安全] 释放地址 {a}") try: self.load(a) except RuntimeError as exc: print(f"[拦截] 释放后再读取被拒绝: {exc}") if __name__ == "__main__": slab = MemorySlab(8) slab.demo()
_raw_*)被私有化,外部只能调用安全方法,完美复刻「最小化 unsafe 范围」的设计思想。class MemorySlab对应 Rust 中一个安全封装的内存池结构体。self._storage_map = {}模拟内存地址空间,键为地址、值为数据。def _raw_write(self, addr, value)模拟 unsafe 块中的裸指针写操作,不做任何检查。def _raw_read(self, addr)模拟 unsafe 中的裸指针读,可能读到已释放数据。def allocate(self)安全接口,相当于内存池分配器,返回一个可用地址。if addr not in self._storage_map:这是关键安全检查,等价于 Rust 安全包装中的「有效性验证」。raise RuntimeError("use-after-free")模拟 Rust 中依赖安全检查拦截未定义行为。def demo(self)演示完整的分配-使用-释放生命周期。slab.demo()程序入口,执行整个安全流程演示。
常见误区
误区「只要函数里出现 unsafe 关键字,整个函数的所有操作都不再受编译器保护,我必须手动检查每一行代码。」
正解实际上,只有 unsafe {} 块内部的操作才免除编译器的检查,块外的代码依然享有全部安全保障。这就像驾驶舱里只有一个红色旋钮需要手动操作,舱内其他自动仪表仍在正常工作。你只需要为旋钮的转向负责,不需要重新检查整艘船的电路。
为何常见:因为 unsafe 关键字的视觉冲击力太强,加上很多教程把「unsafe 函数」和「unsafe 块」混为一谈,容易让人误以为整个作用域都进入了无人监管状态。
误区「我创建了一个裸指针,并用它修改了数据,借用检查器应该能帮我发现悬空指针或重复释放的问题。」
正解裸指针完全不受借用检查器的约束。你可以同时创建多个 *mut T、可以随意拷贝、可以在数据释放后仍然持有。编译器对这些行为保持沉默——这正是需要 unsafe 的原因。引用像系着安全绳的攀岩者(绳子的另一端在检查员手里),裸指针则是徒手攀岩者(你自己的手就是安全绳)。
为何常见:因为裸指针和引用的语法非常接近(*const T 与 &T),初学者容易默认它们遵循同一套规则,忽略了 * 前缀背后「脱离监管」的含义。
误区「我需要在多个线程间共享一个全局计数器,用 `static mut` 加 unsafe 块读写就行,反正我小心点就不会有数据竞争。」
正解数据竞争是未定义行为的一种,即使你「小心」,编译器和 CPU 也可能对你的访问进行重排,导致你精心设计的逻辑依然出错。正确做法是用 AtomicUsize 或 Mutex 这类同步原语,它们是安全的、编译器能验证的。手动拧共享气压表(static mut)之前,先确认你确实买不到更好的仪表(原子类型)。
为何常见:很多 C 语言背景的开发者习惯了「全局变量 + 锁」甚至「全局变量不加锁」的写法,初学 Rust 时自然想用最熟悉的工具,而 static mut 看起来就是最接近的对应物。
误区「实现 `unsafe trait Send` 不就是写一行 `unsafe impl Send for MyType {}` 吗?我写上就行,编译器又不会检查我。」
正解unsafe trait 的核心在于「编译器不检查,但你必须保证」。实现 Send 意味着你向编译器承诺:这个类型的所有字段在跨线程转移时不会引发数据竞争。如果承诺是假的——比如你悄悄藏了一个 Rc 或裸指针——那使用者的程序会在运行时莫名其妙崩溃。这就像签署安全责任书:不用考试,但出了事故你负责。
为何常见:因为 unsafe trait 的实现体往往是空的,从语法上看「好像啥也没干」,初学者自然低估了它的效力,忽略了文档中列出的义务清单。
误区「只要把热点循环里的代码包进 unsafe,性能就会提升,因为跳过了所有检查。」
正解unsafe 只会移除编译器的安全检查,不会改变代码本身的执行效率。边界检查在多数情况下会被 LLVM 优化掉(比如迭代器的 zip、get_unchecked 替代品),实际性能提升往往微乎其微。真正值得用 unsafe 的场景是:调用 C 库 FFI、实现自定义内存布局、需要并发无锁结构。为了「觉得快」而用 unsafe,是拿安全性换幻觉。
为何常见:因为「跳过检查 = 更快」的直觉很强烈,加上一些博文夸大 unsafe 的性能收益,导致大家忽略了编译器的优化能力。
面试题
Q1. 在 Rust 中,unsafe 关键字的核心作用是什么?
- A.关闭编译器并加快程序运行速度
- B.解除部分安全检查并转移责任给开发者← 正确答案
- C.允许使用任何未定义的语法特性
- D.将代码标记为仅供内部测试使用
答案:B。unsafe 并不是关闭编译器或加速,而是临时豁免借用检查等安全限制,同时把保证内存安全的责任交给开发者。A 错在「关闭编译器」,C 错在「未定义语法」,D 是误把 unsafe 当成了测试标记。它本质上是安全责任的交接仪式。
Q2. 下列哪一项不属于 Rust unsafe 的「五大超能力」?
- A.解引用裸指针
- B.调用 unsafe 函数
- C.自动实现内存对齐← 正确答案
- D.访问 union 的字段
答案:C。五大超能力是:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。内存对齐是编译器自动处理的职责,不属于 unsafe 解锁的能力。它像潜水艇的自动配平系统,不需要艇长手动操作。
Q3. 裸指针 *const T 与引用 &T 的最大区别是什么?
- A.裸指针更快但占用更多内存
- B.裸指针不受借用检查器约束← 正确答案
- C.引用只能指向栈上的数据
- D.裸指针必须手动释放内存
答案:B。裸指针与引用的本质区别在于借用检查器的监管范围:引用必须遵守所有权与借用规则,裸指针则完全不在监管内。A 中「更快」无依据,C 中引用同样能指向堆数据,D 中裸指针不负责内存释放——它只是地址坐标,不是分配器。
Q4. 调用标准库的 slice::from_raw_parts 时,调用者需要自己保证什么?
- A.指针非空、内存有效且已初始化← 正确答案
- B.切片长度是偶数
- C.数据必须存放在栈上
- D.数据的所有权属于当前线程
答案:A。slice::from_raw_parts 是 unsafe fn,要求调用者保证指针非空、对齐、指向的内存已初始化且在生命周期内有效。这就像手动启动反应堆前确认冷却水已注满。B、C、D 都不是该函数的实际前置条件。
Q5. 关于 static mut,下列说法正确的有哪些?
- A.读写它都需要 unsafe 块← 正确答案
- B.多线程访问必然导致数据竞争
- C.可以用 AtomicUsize 替代计数场景← 正确答案
- D.它比 static 变量占用更少内存
答案:AC。static mut 的读写必须在 unsafe 块中(A 对);多线程访问如果没有任何同步则构成数据竞争,但「必然」过于绝对(B 错);原子类型是安全替代方案(C 对);内存占用与是否可变无关(D 错)。它就像共享气压表,每次拧动都要签字,能不用就不用。
Q6. 实现 unsafe trait 时,编译器对实现者的要求是什么?
- A.编译器会逐字段检查安全性
- B.实现者必须保证 trait 文档中的不变式← 正确答案
- C.必须在 unsafe 块内编写实现体
- D.实现体必须为空且只含标记
答案:B。unsafe trait 的语义是「信任声明、责任自担」——编译器不做检查,转而要求实现者保证文档中声明的每条安全不变式。A 错在编译器不检查;C 错在实现体本身不需要 unsafe 块;D 错在实现体可以包含方法定义。这像签署安全责任书:不考试,但出了事故你负责。
Q7. 与 enum 相比,union 最主要的缺点是什么?
- A.占用更多内存空间
- B.没有标签记录当前有效类型← 正确答案
- C.无法存储整数类型的数据
- D.不能用于结构体内部
答案:B。union 所有字段共享同一块内存,但没有判别标签来记录当前存的是哪个字段——读取时必须自己记住并写进 unsafe。enum 用额外字节记录标签,换取编译器的类型安全。A 反了(union 更省内存),C、D 均不符合事实。
Q8. 以下哪些操作属于 Rust unsafe 的「五大超能力」?
- A.解引用裸指针← 正确答案
- B.访问可变静态变量← 正确答案
- C.自动推导泛型类型
- D.实现 unsafe trait← 正确答案
答案:ABD。五大超能力为:解裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、访问联合体字段。A、B、D 都在其中;C「自动推导泛型」是类型推断的正常功能,与 unsafe 无关。
Q9. 最符合 Rust 社区「最小化 unsafe」原则的写法是?
- A.把整个函数声明为 unsafe fn
- B.将危险操作收进小块并封装安全接口← 正确答案
- C.在所有代码前加上 unsafe 关键字
- D.禁用编译器的安全检查
答案:B。最小化原则的核心是:unsafe 块尽可能小、危险细节藏在最里层、对外暴露安全 API。这样调用者感知不到危险,审查者只需盯住一小块代码。A 扩散了 unsafe 面,C 是所有操作都豁免检查,D 是关闭编译器的错误做法。
Q10. 哪些工具可以用于验证 Rust unsafe 代码的正确性?
- A.Miri 未定义行为检测器← 正确答案
- B.AddressSanitizer 内存消毒器← 正确答案
- C.rustfmt 代码格式化工具
- D.ThreadSanitizer 数据竞争检测器← 正确答案
答案:ABD。Miri 在解释器中检测未定义行为,ASan 和 TSan 在运行时捕获内存错误与数据竞争,都属于 unsafe 验证工具链。rustfmt 只负责格式化代码风格,不检测任何安全问题。这就像声呐、应力监测、人工复查的分工——格式化工具只是把日志写得整齐,不负责发现漏水。