穿过 C++ 的荆棘,重新审视 Rust
早在 2014 年,我就写过一篇《Rust 浅尝辄止》——当时的 Rust 还不成熟,而且跟其它语言格格不入,因此并未深入学习。
最近重新把它捡起来,原因有二:
这些年深入 C/C++ 跨平台开发,对内存安全有了更切身的认识,而这恰是 Rust 最大的亮点——况且它还继承了 RAII、移动语义等 C++ 的亮点;
AI coding 兴起,Rust 的借用检查由编译器强制执行,相比 C/C++「相信程序员」的哲学,更适配 AI 与自动化编码。
这次,以一个 C++ 老兵的视角重新审视 Rust。
安全模型
如果把 C++ 的坑按伤害等级排序,「安全模型」这一层当之无愧排在最前:编译器出于性能考量,把大量安全责任下放给了程序员,出了问题却往往以「未定义行为」的名义甩锅。
Rust 的安全模型可以浓缩成一句话:
把 C++ 里靠人自觉和周边工具保证的东西,尽量变成编译器能证明的东西。
这一章就沿着这条主线,看它在 UB、别名、借用与生命周期这四个战场上,各自做到了什么程度;最后再看一眼 C++ 阵营自己的回应。
未定义行为
C/C++ 的未定义行为(UB)是出了名的「暗雷」:数组越界、有符号整数溢出、悬垂解引用、访问未初始化内存……标准对这些行为不做任何规定,编译器可以假设 UB 永远不会发生,并以此为前提做激进优化。
这个假设的后果是反直觉的:含 UB 的程序可能在 -O0 下「正常运行」,在 -O2 下突然行为诡异——因为优化器已经根据「UB 不发生」把相关代码路径整个剪掉了。更极端的例子是,同一个函数加一行无关的空语句,编译器版本一变,输出就完全不同。
更完整的 UB 清单、编译器利用 UB 的「优化」案例,以及 constexpr 求值对部分 UB 的编译期检测,在前文《现代 C++ 跨平台开发 - 内存篇》里已系统整理,这里不再赘述。
Rust 对此的回应分三层:
- UB 并没有消失,而是被收进了
unsafe代码块——解引用裸指针、调用外部函数等操作全部需要显式确认。unsafe是一块立着警示牌的围栏,而不是不存在; - 安全抽象:
Vec、String这些标准库类型内部同样写着unsafe,但对外只暴露安全接口。上层调用者用它们,几乎摸不到 UB; const fn与 Miri:const fn承担了 C++constexpr的编译期检测角色;而 Miri 是一个专门用来「找 UB」的解释器——它模拟执行代码(尤其是unsafe部分),能揪出空指针/悬垂指针解引用、未初始化内存读取、对齐违规、数据竞争等问题。许多cargo run下看不出毛病的代码,cargo miri run一跑就原形毕露。
| 维度 | C/C++ | Rust |
|---|---|---|
| UB 触达面 | 日常编码处处可能无意触发 | 被限制在 unsafe 围栏内 |
| 编译器优化 | 以「UB 不存在」为前提,影响范围不可控 | 同样以此为前提,但安全代码中前提由编译器保证 |
| 检测手段 | Sanitizer、静态分析等外挂工具 | Miri、const fn 等官方工具链 |
两种语言的编译器都在赌「UB 不发生」,区别在于:C++ 让程序员下注,Rust 让编译器先验证赌注。
指针别名
UB 在优化场景下最核心的表现之一,是指针别名问题。
在 C/C++ 里,多个指针指向同一块内存(并且都能写)是家常便饭。编译器看到这两行代码时:
*ptr_a = 1;
*ptr_b = 2;
它无法确定 ptr_a 和 ptr_b 是否指向同一地址。为了正确性,只能假设「可能别名」,于是放弃大量优化:不能把 *ptr_a 缓存进寄存器,不能重排两条写入,不能向量化。
C++ 的缓解手段是 restrict 关键字和 __restrict 这类扩展——程序员手工向编译器承诺「这两个指针绝不重叠」。承诺错了,就是 UB。本质上还是让程序员下注,只是这次赌注换成了「无别名」。
Rust 的借用规则把这枚硬币直接没收了:
- 同一时刻,要么任意多个不可变引用(
&T),要么唯一一个可变引用(&mut T),二者不可兼得; - 引用的有效范围受生命周期约束,不可能指向已释放的内存。
也就是说,当代码里出现一个 &mut data 时,编译器百分之百确定在当前作用域内没有任何别的指针能碰到这块数据。这种「独占性」在 C++ 里无论如何都拿不到。
拿到铁证之后,LLVM 后端可以放手做 C/C++ 编译器不敢做的优化:重排内存访问、常量提升到寄存器、自动向量化。Rust 1.82 曾与 LLVM 团队合作,把借用规则更好地转化为优化机会,实测 Web 服务请求处理能力提升超过 20%,图像处理也有大幅加速——安全性红利反过来变成了性能红利。
memcpy 与 memmove 的区别,是别名假设最经典的具象化:
char str[] = "Hello, world!";
// 目标区域与源区域重叠
memcpy(str + 2, str, 6); // UB!memcpy 假设两块内存不重叠
memmove(str + 2, str, 6); // 安全,memmove 允许重叠(必要时反向拷贝)
memcpy 的高性能正来自「无别名」这个前提;开发者一旦用错,前提崩塌,行为即未定义。Rust 的安全代码里根本构造不出这种调用——借用检查器不允许你同时持有指向重叠区域的可写引用与可读引用。
一句话收束这段对比:
C++ 里「两个指针没有别名」是程序员写的承诺;Rust 里「两个引用没有别名」是编译器验证过的定理。
借用检查与作用域控制
Rust 的借用规则不只是防错,它还和「作用域」结合出一种非常实用的日常技巧——用大括号精确控制访问权限的释放时机。
这个习惯 C++ 程序员其实很熟:缩小临界区、提前放锁,靠的就是 std::lock_guard 加大括号。
两个语言在这里有惊人的相似性,因为底层都依赖 RAII——用栈上对象的生命周期,自动管理某种「访问权」的得失:
let mut data = vec![1, 2, 3];
{
let reader = &data; // 不可变借用(读权限)
println!("{:?}", reader);
} // 作用域结束,读权限还给编译器
let writer = &mut data; // 现在才允许可变借用
writer.push(4);
std::mutex mtx;
std::vector<int> data = {1, 2, 3};
{
std::lock_guard<std::mutex> lock(mtx); // 加锁
data.push_back(4);
} // 作用域结束,锁自动释放
手段相似,本质不同:
| 维度 | Rust 借用作用域 | C++ 锁作用域 |
|---|---|---|
| 检查时机 | 编译期 | 运行时 |
| 解决的问题 | 引用冲突 / 所有权 | 数据竞争 / 临界区 |
| 失败后果 | 编译报错,出不了二进制 | 数据竞争,行为不可预测 |
| 运行时开销 | 零(编译后不留指令) | 加解锁的原子操作开销 |
C++ 用大括号是为了「尽快还锁」,靠的是程序员纪律;Rust 用大括号是为了「尽快还权限」,由编译器强制记账。
顺带说清楚一个容易混淆的点——drop:
Droptrait:定义「值被销毁时做什么」的接口,由编译器在作用域结束时自动调用;std::mem::drop函数:真正的「强制提前回收」工具,它接收值的所有权后立即销毁。
let s = String::from("hello");
drop(s); // 强制提前回收
// println!("{}", s); // 编译错误:所有权已移交
不能直接调用 x.drop()——编译器禁止显式调用析构函数,否则作用域结束时会再析构一次,造成 double free。想提前回收,要么交给 std::mem::drop,要么干脆用大括号限制作用域。
RefCell
编译期借用检查强大但保守:有些代码逻辑上完全安全,编译器却无法证明。这时候 RefCell<T> 提供了「把借用检查推迟到运行时」的能力——违反规则不再编译报错,而是运行时 panic。
它的典型使用场景有四类:
打破编译器的误报:如「遍历的同时修改容器元素」这类编译器无法证明不冲突的场景;
内部可变性:对外暴露
&self的接口,内部却需要修改状态——最典型的例子是 UI 组件的draw(&self)方法内部维护一个绘制计数器:struct UIWidget { draw_count: RefCell<i32>, } impl UIWidget { fn draw(&self) { // 对外仍然是 &self *self.draw_count.borrow_mut() += 1; } }配合
Rc构建复杂数据结构:双向链表、树、图这类结构需要共享所有权(Rc)加内部可变(RefCell),组合成经典的Rc<RefCell<T>>;惰性初始化与缓存:首次使用时才计算并缓存结果,接口保持
&self。
但 RefCell 有一条绝对红线:它不是线程安全的,多线程场景必须换 Mutex / RwLock。
至于「度」怎么把握,可以看这张决策表:
| 场景 | 推荐方案 | 检查时机 | 失败后果 |
|---|---|---|---|
| 常规单线程可变借用 | &mut T |
编译期 | 编译错误 |
| 单线程复杂结构 / 内部可变性 | RefCell<T> |
运行时 | panic |
| 多线程共享可变状态 | Arc<Mutex<T>> / Arc<RwLock<T>> |
运行时 | 死锁 / 阻塞 |
简单 Copy 类型 |
Cell<T> |
运行时(无借用计数) | panic |
单次借用检查的开销在纳秒级别,性能通常不是问题;真正的风险是把编译期错误降级成了运行时崩溃——处处 RefCell 的代码,等于放弃了 Rust 最值钱的那道保险,退化成「带着 panic 的 C++」。原则很简单:能靠设计绕开的,永远优先调整设计;确实绕不开的,再局部引入。
生命周期标注
借用检查里最劝退初学者的,大概就是 'a 这种生命周期标注。很多人第一眼看到它,都会觉得这是纯粹的语法噪音。
先纠正一个常见误解:生命周期标注不会延长任何数据的寿命,它只是给引用打上标签,建立一份编译期契约——「只要输入的数据在 'a 期间有效,返回的引用就保证在 'a 期间有效」。数据该销毁还是销毁,只是编译器凭这份契约拒绝悬垂引用。
大多数时候这份契约不用手写,靠省略规则自动补全:
- 只有一个引用参数:返回值默认与它同生命周期;
- 方法带
&self:返回值默认绑定self; - 多个引用参数且返回引用:编译器「懵了」,必须手写。
经典的两难场景是这样的:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a 把 x、y、返回值绑进同一个「生命周期组」,实际生效取其中最短的一个。于是就有了那个著名的质疑:既然最终都是取最短,编译器为什么不自动处理?
因为「取最短」只在返回值同时依赖所有参数时才成立。看这个函数:
// 返回值只来自 x,_log 只是顺带用一下
fn process_data<'a>(x: &'a str, _log: &str) -> &'a str {
x
}
如果编译器自作主张地把 _log 也算进来取最短,那么只要 _log 命短,返回值就被冤枉地判了「死刑」——明明 x 还活得好好的。所以 Rust 的设计是「显式优于隐式」:标注哪些参数参与返回值生命周期,由程序员指定;无关的参数干脆不标,编译器自然不会把它们算进来。
理解了这一点,再看 'a 就没那么面目可憎了:
'a只是个普通的名字,可以叫'input、'buf,任何字母都行,'a只是约定俗成的习惯;- 把它读作「有效期」,
fn longest<'a>(x: &'a str, ...) -> &'a str就是「我们约定一个有效期:x有效期内,我保证返回值有效」。
我在琢磨明白之后,找到一个很贴切的类比——Android 的 Task Affinity:
| 概念 | Android Task Affinity | Rust Lifetime |
|---|---|---|
| 核心问题 | Activity 归属哪个任务栈 | 引用归属哪段数据的作用域 |
| 默认行为 | 跟随启动它的 Application | 省略规则自动推断 |
| 显式指定 | taskAffinity 强制入栈 |
'a 显式标注绑定 |
| 冲突处理 | allowTaskReparenting / launchMode |
多参数时手写 'a 明确亲和谁 |
生命周期标注本质上就是「返回值对参数的亲和性声明」。 一旦这样理解,fn process_data<'a>(x: &'a str, _log: &str) -> &'a str 的含义就非常直白:返回值亲和 x,与 _log 完全解耦。
C++ 的对应手段
C++ 没有生命周期标注,多引用参数导致的悬垂引用问题,只能靠「工具辅助 + 规范约束」来兜底:
std::weak_ptr:真正意义的「安全网」,使用前.lock()升级为shared_ptr,对象已销毁则返回空——但这是运行时检查,有引用计数开销;- ASan(AddressSanitizer):运行时监控内存访问,抓到悬垂立刻报错——但程序必须真的跑到那段错误代码,测试覆盖率不够的 Bug 依然会溜进生产环境;
- Clang-Tidy / 静态分析:编译前扫出「返回局部变量引用」这类明显问题——可能漏报、可能误报,且换一个编译器(如 MSVC)规则就未必一致;
unique_ptr与值语义:从源头减少堆内存悬垂,但只覆盖独占所有权场景;gsl::not_null、注释契约:靠文档和纪律维护「谁活得比谁久」,编译器完全不检查。
每一条单看都有效,合起来的共同问题是三个字——不强制。工具是补丁,用不用、配没配、跑没跑,全凭团队自觉;而悬垂引用的检测率,正比于你恰好触发了多少测试路径。
Rust 把这些检查放进了编译器核心——它不是外挂插件,而是地基:编译通过,悬垂引用在安全代码里就不可能存在。
Safe C++ 提案
C++ 阵营对此并非没有回应。2024 年 9 月,Circle 编译器作者 Sean Baxter 发布《Safe C++》提案,目标很直白:在 C++ 里划出一个显式的安全上下文,让区域内的代码获得与 Rust 同级的保证——内存安全、类型安全、线程安全——同时存量代码完全不受影响。
它并非停留在纸面:Circle 编译器里已有可运行的实现,safe 上下文与借用检查都能真正编译运行。
提案的核心主张可以概括为三点:
safe上下文:用显式标注划分安全区域,区域内的代码由编译器强制检查,可以逐块从旧代码迁入;- 借用检查:完整的 Rust 规则——同一时刻要么多个共享引用、要么一个独占可变引用——通过一个类似 Rust MIR 的中间表示在编译期验证;
std2标准库:一套生命周期感知的安全容器与算法,与旧标准库并存。
但这条路最终没有走通。WG21(C++ 标准委员会)的安全工作组决定优先推进「Profiles」方向——不引入新语法,只对既有特性施加限制,被认为更易落地。2025 年 9 月,Sean Baxter 确认提案不再继续:
Rust 的安全模型在委员会里不受欢迎,我在这一端的努力也改变不了这一点——Profiles 赢得了这场争论。
社区的反应分成两派:
- 一派惋惜——Safe C++ 移植的是 Rust 在生产环境验证了近十年的模型,而 Profiles 当时连可运行的原型都没有,「务实」与「激进」的标签几乎贴反;
- 另一派的态度更朴素——想要 Rust 那样的语言,直接去写 Rust 就好。
无论立场如何,这个故事给出了一个来自 C++ 内部的旁证:要拿到与 Rust 同级的保证,绕不开「把借用检查交给编译器」这一步;而这一步没被迈出,C++ 的安全责任便依旧留在「靠人自觉和周边工具」的老地方。
类型与数据建模
安全模型解决的是「程序会不会炸」的问题;再往上一层,是「数据怎么建模」的问题。
C++ 的数据建模工具箱,是一部典型的需求驱动补丁史:缺什么补什么,补丁之间还要互相兼容。
Rust 则从「拥有」与「借用」两个基本状态出发,重新设计了整套类型工具。
字符串
按照「常量、变量、视图」三种角色拆开看,两边的差距一目了然:
字符串常量:
- C 语言中字面量本质是
char[],标准甚至没有规定内容相同的字面量是否共用一份存储,因此比较两个字面量的指针地址可能得到不一致的结果; - C++ 把字面量定为
const char[n],但为了兼容旧代码,仍允许隐式转成非const的char*; - Rust 的字面量是
&'static str——包含指针与长度的胖指针,生命周期贯穿整个程序,且永远保证是合法 UTF-8。
字符串变量:
- C 没有内置的字符串类型,只有以
\0结尾的char*,拼接、替换全靠手工管理内存; - C++ 的
std::string用 RAII 自动管理堆内存,支持+拼接等丰富操作; - Rust 的
String同样基于 RAII,但拼接操作分得更细:+会移动左侧字符串的所有权,push_str原地追加,format!不消耗任何输入——所有权语义在 API 层面清晰可见。
字符串视图:
- C 没有视图,取子串必须分配新内存;
- C++ 到 C++17 才补上
std::string_view; - Rust 的
&str从第一天就是标准打法,函数参数用&str而非&String是压倒性的惯例。
于是,日常代码里会出现四种形态,但核心其实只有两个类型:
| 形态 | 说明 | C++ 对标 |
|---|---|---|
String |
堆分配、可增长、拥有所有权 | std::string |
str |
底层不可变 UTF-8 序列,动态大小类型(DST),不能直接作为变量 | char[] |
&str |
字符串切片,胖指针(指针 + 长度) | std::string_view |
&'static str |
指向二进制内字面量的切片 | 字面量 |
初学时我最大的疑惑是:Rust 明明所有类型都能取引用,为什么还要专门定义一个 &str?
答案藏在两个方面:
str是 DST,大小在编译期未知,无法放进栈上变量;必须借助「指针 + 长度」的胖指针&str才能间接操作它;- 字符串数据不止存在于堆上——可能来自静态区的字面量、栈上的数组、堆上的
String。&String只能看堆,&str才能统一地「透过任何来源看这段文本」。
引用即视图
理解 C++ 与 Rust 的 &,要注意二者语义完全不同:
- C++ 的
&偏向取地址:引用只是指针的语法糖,默认可以改数据、可以有无数个别名,编译器不跟踪它的生命周期,悬垂了照样编译通过; - Rust 的
&是借用:「我只需要看一眼数据,不拥有它」。借用规则保证它永不为空、读写二选一、生命周期受检查器追踪。
顺着这个理解往下推,会得到一个很通透的结论——引用和视图,本质上是同一个概念:强调「没有所有权」。
区别只在两种语言的抵达路径:
- C++ 是层层打补丁:早期只有值和指针 → C++98 补左值引用(省拷贝)→ C++11 补右值引用与智能指针(管所有权)→ C++17/20 才补上
string_view/span(视图)。结果是程序员脑中要同时维护四五套传参心智模型; - Rust 一上来就把状态收敛成两个:拥有(
String、Vec<T>)和借用(&T、&str、&[T])。视图不需要专门发明——&str就是字符串视图,切片&[T]就是通用内存视图(对标std::span),而且是编译期验证过生命周期的一等公民。
顺带一提切片的两个细节优势:
- 越界访问:
- C++ 的
span/string_view越界访问是 UB,零长度时内部指针还可能是nullptr; - Rust 切片越界会 panic(或用
get()返回Option优雅处理),且零长度切片的指针也保证非空、已对齐;
- C++ 的
- UTF-8 合法性:
&str在类型层面就保证 UTF-8 合法;string_view完全不关心内容是不是 UTF-8——合法性要使用者自己保证。
SSO 与 Cow
字符串的底层实现上,还有一对耐人寻味的「镜像选择」。
C++ 的 std::string 普遍带 SSO(小字符串优化),短字符串直接存在栈上的对象体内,规避堆分配。Rust 的 String 偏偏没有——这不是「不能」,而是标准库的「不为」:
- 加 SSO 需要在结构里引入标志位来区分堆/栈存储,破坏内存布局的简洁与透明;
- Rust 的取舍是把这类优化留给生态:
smartstring、compact_str等第三方库提供带 SSO 的字符串类型,短字符串密集场景能有显著的性能收益。标准库只保证简单、可预测。
Cow(写时克隆)则是一条反向的镜像:C++ 早期在 std::string 里偷偷用过 COW,后来被 C++11 明令禁止。
原因有二:
- 多线程下引用计数引入原子操作甚至锁,「优化」反而成了负担;
- 可观测性问题:一次看似普通的写操作(如
str[0] = 'a')会触发底层数据分离拷贝,导致此前拿到的所有迭代器、引用、指针瞬间失效——这种「隐藏的副作用」破坏了接口的可预测性。
Rust 则把 COW 做成了一个显式的白盒工具 std::borrow::Cow:
pub enum Cow<'a, B> {
Borrowed(&'a B), // 借用态:零成本
Owned(<B as ToOwned>::Owned), // 拥有态:独立数据
}
- 是否复制由开发者显式决策:只读时保持
Borrowed不分配,调用to_mut()才触发克隆; - 决策之所以安全,是因为有借用检查器兜底——不存在「别人偷偷改数据」的可能;
- 最典型的使用场景是「99% 的输入无需改动、1% 需要转义替换」的文本处理函数:用
Cow<str>做返回值,99% 的场景零拷贝。
两条路径的差别可以归结为一句话:
- C++ 把优化锁进标准库内部当秘密,出了可观测性问题再禁用;
- Rust 把机制交到开发者手上当工具,安全感由语言保证。
枚举与代数数据类型
Rust 的枚举和 C/C++ 的枚举几乎是两种东西:它允许每个变体携带不同类型、不同数量的数据。这个设计对应函数式语言里的代数数据类型(ADT),底层实现是标签联合体(Tagged Union)。
它带来三个连锁收益:
- 让非法状态无法表示。
- 想表达「一个值要么是整数、要么是字符串」,传统做法是定义一个带
type字段的大结构体,靠运行时检查保证type和数据匹配——写错没人拦你; - Rust 直接
enum Data { Number(i32), Text(String) },编译器保证数据必须且只能是其中一种形态;
- 想表达「一个值要么是整数、要么是字符串」,传统做法是定义一个带
- 错误与空值处理的完美载体。
Option<T>与Result<T, E>都是异构枚举:成功与失败携带的数据类型天然不同,异构能力让它们共存于一个类型定义中;配合match的穷尽性检查——漏掉一个分支就编译不过——「未捕获异常」这类问题在编译期就被消灭; - 内存效率。布局是「最大变体大小 + 一个隐藏的标签字段」,不需要为兼容性预留空间,也没有堆分配。
C++ 这边,这条路走得很艰难。C++17 之前只有两条路可选,而它们各有各的硬伤:
union:内存布局高效,但类型安全完全靠人——需要手动维护一个变量记录「当前存的是什么类型」,记错就是 UB;- 继承 + 虚函数:类型安全,但引入虚表指针、堆分配、缓存不友好等运行时开销。
std::variant(C++17)正是为填补这条中间地带而生的:既是栈内存上的紧凑布局,又有类型安全——本质上就是 C++ 版的标签联合体,和 Rust 枚举同根同源。
差别在于「待遇」:
| 维度 | Rust enum |
C++ std::variant |
|---|---|---|
| 语言地位 | 语言关键字,类型系统核心 | 标准库类模板 |
| 访问方式 | match 表达式,强制穷尽 |
std::visit + 访问者/if constexpr |
| 穷尽性检查 | 原生强制 | 语言层面无强制,需技巧或约定 |
| 错误处理配套 | Result<T, E> + ? 运算符 |
C++23 std::expected(此前靠第三方库) |
可以说,C++ 是用一个又一个库「追赶」上了这种建模能力,而 Rust 从设计之初就把它当母语。一个具体的体验差异:Rust 写 match 时心里很踏实——只要编译通过,数据状态就是合法的;C++ 写 std::visit 时,还要祈祷自己没漏掉哪个 if constexpr 分支。
类型转换
类型转换是「显式优于隐式」体现得最彻底的地方。
Rust 只提供三件工具,各有明确分工:
as:仅用于原始类型之间的底层转换(数值、指针),显式书写,截断或精度损失由开发者自负:let a: i32 = 1000; let b = a as u8; // 232,高位截断 let c = 3.14_f32 as i32; // 3,小数截断From/Into:安全无损转换的标准途径,实现From自动获得Into,调用时必须显式写出转换意图;TryFrom/TryInto:可能失败的转换,返回Result,把「失败」纳入类型系统。
C++ 这边则是一套「重型机械」:
- C 风格转换混合了所有转换语义;
static_cast、dynamic_cast、const_cast、reinterpret_cast各管一摊;- 再叠加隐式转换与重载决议——编译器在函数调用时按「精确匹配 > 提升 > 标准转换 > 用户定义转换」的严格排序自动选函数。
后者的威力与危险同样巨大:
void print(int x);
void print(double x);
print('a'); // 隐式提升到 int,选中 print(int)——你未必想要这个结果
隐式转换让代码「简洁」,但二义性报错和意外匹配,也是 C++ 编译错误晦涩、Bug 难查的根源之一。Rust 干脆不提供函数重载:要么起不同的名字,要么用 trait 表达「同一行为、多种实现」——牺牲了书写便利,换来了「每一处调用都能一眼确定调用了什么」的可读性。
| 维度 | Rust | C++ |
|---|---|---|
| 设计哲学 | 显式优于隐式 | 便利优先 |
| 自定义转换 | From/Into,显式调用 |
构造函数/转换运算符,隐式参与重载决议 |
| 失败处理 | TryFrom/TryInto 返回 Result |
无标准机制,可能 UB 或运行时错误 |
| 规则复杂度 | 简单、可预期 | 重载决议复杂,错误信息晦涩 |
元组与 Newtype
元组两边都有,但融入语言的程度不同:
| 维度 | Rust 元组 | C++ std::tuple |
|---|---|---|
| 定义 | 语言原生 (i32, &str) |
库模板 std::tuple<int, std::string> |
| 元素访问 | tup.0 / tup.1 |
std::get<0>(tup) |
| 解构 | let (a, b, _) = tup,并与 match / if let 深度结合 |
C++17 结构化绑定 auto [a, b] = tup |
| 错误提示 | 越界直接编译错误,信息精准 | 递归模板继承实现,报错宛如天书 |
| 调试打印 | 实现 Debug 即可 {:?} 输出 |
需手动重载 << |
Rust 元组还可以配合元组结构体派生出一个重要惯用法——Newtype 模式:
struct Wrapper(Vec<i32>); // 把外部类型包进自己的新类型
它的直接动机是绕开孤儿规则(为 Vec<i32> 实现 Display 会因「类型与 trait 都是外部的」被拒绝,包一层 Wrapper 就有了本地类型)。除此之外,它还顺带提供三样东西:
- 更明确的语义:如
struct Meters(u32)一看就知道不是普通整数; - 封装控制:对内部操作的控制;
- 零成本:
Wrapper这层壳在编译后会被完全剥掉,运行时没有任何痕迹。
到这里,类型和数据的故事其实还有个尾巴:泛型与抽象。这才是 Rust 真正和 C++ 分道扬镳的地方。
泛型与元编程
C++ 用模板一套机制,同时承担了「泛型约束」与「编译期代码生成」两大职责;Rust 则把它拆成了两套独立系统——泛型 + trait 负责抽象与约束,宏系统负责代码生成。拆分带来的清晰分工,是这一章的主线。
泛型与模板
两者的大目标一致:一套代码适配多种类型、编译期完成检查与生成、零运行时开销,语法上也都是 <T>。
但骨子里的构造不同:
| 维度 | Rust 泛型 | C++ 模板 |
|---|---|---|
| 实现机制 | 单态化:为每种具体类型生成专属代码 | 实例化:在调用点生成具体代码 |
| 类型约束 | trait bound(T: Display / where),先声明再使用 |
传统上靠「鸭子类型」隐式约束,C++20 才有 concepts |
| 功能边界 | 专注函数、结构体、枚举的泛型化 | 图灵完备,可做编译期类型计算(TMP) |
| 代码组织 | 声明与实现可自由分离 | 模板实现通常被迫塞进头文件,否则链接错误 |
其中对日常开发影响最大的,是约束方式:
// 约束写在前头,不满足就报「T 没有实现 Display」
fn print_value<T: Display>(value: T) {
println!("{}", value);
}
Rust 的 trait bound 要求类型能力「先声明、后使用」,编译器在检查函数体之前就知道 T 能干什么,报错因此清晰精准;C++ 的模板则是实例化时才「事后验货」,错误往往在深层嵌套的实例化堆栈里爆炸。C++20 concepts 的引入,本质就是在向 Rust 式的显式约束靠拢。
两边共享的代价是单态化膨胀:为 int 和 double 各生成一份代码,二进制里就有两份。区别在控制手段——Rust 可以用 trait 对象(dyn)显式放弃单态化换取体积,粒度更可控;C++ 传统上只能接受,或者靠「类型擦除」的手工技巧。
trait 与 concept
初看 Rust 的 trait 和 C++20 的 concept 都是「对类型的约束」,很容易混为一谈。细看职责完全不同:
- concept 是编译期的约束检查器:它本身不产生代码、也不包含实现,只回答「这个类型合不合格」——主要价值是让模板报错不再像天书;
- trait 是行为的抽象与实现载体:既定义接口,又可以携带默认实现,还提供两种多态路径——静态分发(
impl Trait,编译期单态化)与动态分发(dyn Trait,运行时虚表)。
再往底层看,「合格」的判定方式也不同:
- concept 是结构化判定:类型只要恰好具备要求的表达式,无需任何声明就自动合格,相当于「鸭子类型」的可检查升级版;
- trait 是名义判定:必须显式写出
impl Trait for Type(derive不过是替你生成这行impl),哪怕方法一应俱全,少了这行声明依旧不合格。
一个传播很广的说法是「微软的 proxy 框架利用 concept 实现了基于行为约束的多态」。这个说法对了一半:
- concept 在 proxy 里确实负责定义「门面」——声明这个多态对象需要支持哪些操作,让类型擦除变得可描述、类型安全;
- 但真正实现多态的核心手段是类型擦除(内部持有指针 + 维护虚表),概念约束只是让这套机制有了优雅的语法外衣。
换句话说,proxy 是用「concept 的语法 + 类型擦除的技术」在 C++ 里做出了 dyn Trait 级别的东西——一种基于值语义的运行时多态,不需要继承体系,也没有 std::function 那种局限性。与其说它是 concept 的应用,不如说是 C++ 向 Rust 设计的一次致敬式追赶。
标记 trait
Rust 有一类特殊的空 trait——不包含任何方法,纯粹作为编译期标签使用,称为标记 trait(marker trait)。最经典的例子是 Send 与 Sync:
Send:类型的所有权可以安全地转移到其它线程;Sync:类型的共享引用可以安全地在多个线程间共享。
一个类型没实现 Send,就根本编译不过「发送到别的线程」的代码——线程安全的规则直接用类型系统强制执行。
C++ 这边对应的是 tag dispatching(标签分发):定义空 struct 作为标签(如迭代器类别 random_access_iterator_tag),配合 traits 类模板萃取某个类型的标签,再通过函数重载让编译器自动挑实现。
两者思想高度一致——用编译期信息区分类型、选择实现,零运行时开销——但目标侧重不同:
| 维度 | Rust 标记 trait | C++ tag dispatching |
|---|---|---|
| 基本单元 | 空 trait | 空 struct + traits 类模板 |
| 核心机制 | trait bound | 模板特化 + 函数重载 |
| 主要目标 | 类型安全保证(如线程安全) | 算法优化(如迭代器分发) |
| 语言地位 | 类型系统核心特性 | 高级模板惯用法 |
标记 trait 的另一个缩影是 Deref 的关联类型。impl<T> Deref for MyBox<T> 里必须写 type Target = T;,它表面上是「填空」,实质是履行 Deref trait 预先定义的契约——trait 源码里早就写好了 type Target: ?Sized 这个「坑位」,deref() 的返回类型直接绑定 &Self::Target。不填或填错,契约不闭环,编译报错。
这个设计还牵出一个有意思的类型系统话题:为什么 Deref 用关联类型,而 From 用泛型参数?因为 Rust 的原则是「一个类型针对某个 trait 只能有一种核心行为」——MyBox<i32> 解引用后必须且只能是 i32;而 From 允许一个类型有多种来源(i32 可以同时实现 From<u8>、From<u16>)。关联类型约束唯一性,泛型参数许可多态性,各司其职。
编译期计算
有一段时间我有个印象:Rust 的编译期计算远不如 C++——后者有 constexpr、consteval,能把大量函数逻辑搬到编译期。深入了解后发现,Rust 的 const fn 早已不是「只能算算简单常量」的水平。
它的心智模型很直白:你写下普通逻辑,编译器替你执行——只要在 const 上下文(常量定义、数组长度、const 泛型参数)中调用,计算就发生在编译期,结果直接烙进二进制。
更有意思的是它和类型系统的联动:
const fn is_even(x: u32) -> bool {
x % 2 == 0
}
fn process<const FLAG: bool>() {
if FLAG { println!("Even mode"); } else { println!("Odd mode"); }
}
process::<{ is_even(4) }>(); // 编译期求出 true,运行时分支被整个剪掉
const fn 的计算结果可以直接作为 const 泛型参数参与类型定义——这种「编译期计算 + 类型系统」的组合拳,是 C++ 模板元编程苦苦追求的效果,在 Rust 里由语言原生语法完成。
| 特性 | C++ constexpr |
Rust const fn |
|---|---|---|
| 求值时机 | 编译期或运行时,取决于参数是否常量 | 编译期或运行时,取决于调用位置 |
| 成熟度 | 历史悠久、能力成熟 | 起步晚、演进快,循环/分支/可变引用陆续解锁 |
| 与类型系统结合 | 通过模板参数间接实现 | const 泛型直接联动 |
现阶段的差距依然存在:const fn 禁止堆分配(不能在编译期造 Vec)、禁止外部副作用(I/O、随机数),这让「编译期构建复杂数据结构」暂时受限——但这两条限制都在持续放宽,大量标准库函数也已被标记为 const。
至于性能收益,一个直观的案例是初始化 1000 元素的编译期表格:初始化耗时从 1.27ms 降到 0,启动时间下降约 70%。
宏系统
C++ 的模板元编程,有一段出了名的「歪打正着」史:模板最初只是为了写通用容器和算法,结果人们发现特化 + 递归实例化居然图灵完备,于是编译期阶乘、素数判断、甚至编译期光线追踪都冒了出来——这套能力是「意外发现」出来的,代价是语法晦涩、报错冗长,成为货真价实的黑魔法。
Rust 走的是另一条路:宏从设计之初就是语言的一部分,直接打通编译流程。
先分清两个阵营——声明宏与过程宏。我在对比时得出一个类比,乍看有些勉强,但仔细验证后确认它确实准确:
- 声明宏(
macro_rules!)像 C 语言宏:核心机制都是「按规则做代码替换」,只是 Rust 把无脑的字符替换升级成了基于 Token 流的模式匹配,并附带卫生性(Hygiene)——宏内部变量不会污染外部作用域,C 宏那些括号优先级、变量捕获的经典坑被系统性消灭; - 过程宏像 C++ 模板:核心能力都是「编译期代码生成与计算」。区别在实现路径:
两者对「代码膨胀」的处理因此不同:
- C++ 模板为每个类型实例化一份机器码,深度递归的元编程可能生成海量代码,只能靠链接器 COMDAT 折叠缓解;
- 过程宏的输出是语法转换后的 Rust 代码,代码复用交给泛型系统处理,膨胀更可控。
三种过程宏——派生宏、属性宏、函数式宏——看起来形态迥异(贴标签、修饰代码、函数式调用),其实本质是同一个东西:
| 宏类型 | 编译器传入 | 主要用途 |
|---|---|---|
| 函数式宏 | 括号内的 token | 生成任意代码片段 |
| 派生宏 | 整个结构体/枚举定义 | 自动生成 trait 实现,如 #[derive(Debug)] |
| 属性宏 | 属性参数 + 被修饰代码块 | 替换或包裹原有代码,如 #[tokio::main] |
三者都是「编译期 Rust 函数」,签名统一为 TokenStream -> TokenStream,区分形态纯粹是为了开发者体验——总比满屏 my_derive_macro!(struct User { ... }) 要优雅得多。
至此,Rust 的拆分策略已经清晰:C++ 模板一身二职,Rust 让宏系统承接「代码生成」(对应 TMP 能力),让泛型 + trait 承接「约束与复用」(对应模板 + concepts)。再叠加 cargo expand 这类展开工具和精准的报错定位,元编程从「调戏编译器」变成了可调试、可测试的正规工程。
用一句话总结两种风格:C++ 模板是在利用编译器的既有特性做计算,Rust 宏是在扩展编译器能力生成代码——前者更野,后者更稳。
资源管理
构造函数
C++ 的构造函数体系是最让人心累的部分之一。默认构造、拷贝构造、移动构造、拷贝赋值、移动赋值——五法则之所以难缠,在于它们非正交:写一个移动构造函数,编译器可能悄悄删掉拷贝构造函数;写一个析构函数,移动操作的默认生成规则又变了。写代码时还得在脑子里模拟编译器的规则引擎——「我写了析构,移动构造还会自动生成吗?」「我写了拷贝构造,移动赋值会被删除吗?」
Rust 里没有「构造函数」这个特殊概念,初始化就是普通的关联函数 fn new(...) -> Self,命名靠约定(new、with_capacity、from_*),没有任何隐式行为。这一点和 Objective-C 的 alloc/init/dealloc 很像——动作与意图一一对应,简单、确定。
| 维度 | C++ | Rust |
|---|---|---|
| 初始化 | 默认/带参构造、初始化列表、explicit |
普通函数 fn new() -> Self,无特殊语法 |
| 拷贝语义 | 拷贝构造函数(未定义则隐式生成浅拷贝) | 默认移动;需要拷贝就显式实现 Copy/Clone |
| 移动语义 | 移动构造涉及资源窃取、源对象置空 | 默认行为(位拷贝 + 所有权转移),零代码 |
| 析构 | ~T(),异常安全需仔细处理 |
Drop trait,离开作用域自动调用 |
| 规则 | 五法则:改一个可能影响另外四个 | 零法则:通常什么都不用写 |
关于移动语义还有两个细节让我很舒服:
- Rust 的「移动」是位拷贝加编译器层面的原地址失效,不存在 C++ 里
std::move之后源对象到底处于什么状态的争议;配合 RVO,按值返回永远是高效的默认表达——而在 C++ 里返回大对象,要反复确认std::move会不会反而阻碍 RVO; - C++ 社区还在为历史包袱打补丁:
- 「可重定位语义」(relocatable types)就是 C++26 正在引入的概念——为了允许对象在内存中搬迁而不调用移动构造(比如
vector扩容),编译器必须分析类型是否「可平凡重定位」,虚函数指针、引用成员一掺和就变得极其复杂; - Rust 从第一天就假设所有类型可以安全地按位移动:引用安全由借用检查器在编译期兜底,对象在内存里随便搬,更新栈上的指针即可,不需要任何运行时元数据检查。
- 「可重定位语义」(relocatable types)就是 C++26 正在引入的概念——为了允许对象在内存中搬迁而不调用移动构造(比如
用这个领域的经典比喻收尾:
- C++ 像一架仪表盘密布、手动阀门遍地的旧式飞机,性能极强,但你得时刻提防某个阀门被系统悄悄关掉;
- Rust 像电传操纵的现代飞机,用严格的规则阻止你做出危险动作。
对于厌倦了五法则博弈的开发者,「显式优于隐式」本身就是一种解脱。
智能指针
最初对比智能指针时我有个困惑:Box、Rc、Arc、Cell、RefCell 看起来都是「封装」,凭什么一个体系里塞这么多概念,它们不打架吗?
后来自己总结出一句话,把整个体系理顺了:
Box/Rc/Arc 属于内存分配层——负责数据放在哪、谁负责释放,引用计数本质上就是为了确定释放时机;Cell/RefCell/Mutex/RwLock 属于访问控制层——负责共享条件下怎么安全地读写。
这两层是正交的,可以自由拼装:
| 需求 | 内存分配层 | 访问控制层 | 组合形态 |
|---|---|---|---|
| 单线程、共享且需要修改 | Rc |
RefCell |
Rc<RefCell<T>> |
| 多线程、共享且需要修改 | Arc |
Mutex / RwLock |
Arc<Mutex<T>> |
| 单线程、简单共享修改 | Rc |
Cell(适合 Copy 类型) |
Rc<Cell<T>> |
| 仅堆分配、独占所有权 | Box |
不需要 | Box<T> |
命名也很形象:
Box是纸箱,负责把东西搬到堆上;Cell是只能整体复制进出的保险柜;RefCell是带登记簿的保险柜——borrow()/borrow_mut()时在运行时可查借用计数,违规直接 panic。
内部可变性还有一个绕不开的 C++ 对应物:mutable。两者解决的是同一个矛盾——逻辑不可变与物理不可变的冲突,比如 const 方法里需要更新的缓存计数器。
但实现底线完全不同:
- C++ 的
mutable是君子协定,编译器开个后门,剩下靠程序员自觉,忘了加锁就是数据竞争; - Rust 是一套安保系统,修改必须走安检通道(
get/set、borrow、lock),违规要么编译不过,要么运行时 panic。
RefCell 的运行时开销就是这套安检的成本:一次借用计数器的读写。
它是用「运行时 panic」换「编程灵活性」的工程折中——这也解释了 Rust 为什么要给检查时机分档:能编译期解决的,绝不放到运行时。
顺带一提 Mutex<T> 与 std::mutex 的本质区别:
- C++ 的锁和数据是分离的,忘记加锁直接访问数据完全合法;
- Rust 把锁和数据绑定在一起——不
.lock()就拿不到T的访问权,「忘记加锁」在类型层面不可能发生。
Arc 为何会变成「手动 GC」
面对生命周期错误,有一种常见的「暴力解法」:把所有东西塞进 Arc,编译立刻就通过了。这和 C++ 里用 shared_ptr 暴力解决悬垂指针是同一类反模式——生命周期的设计问题被封装掩盖,代价是原子操作开销和满屏的 .clone()。
更有意思的是,曾经一位 Android 背景的同事私下说「Rust 很像 Java」,我猜他就是把 Arc 当 GC 用了。
| 特性 | Java GC | Rust Arc |
|---|---|---|
| 触发时机 | 不确定,由 JVM 决定何时扫描回收 | 确定,引用计数归零的瞬间同步释放 |
| 性能开销 | 平时极低;GC 时可能有 STW 停顿 | 每次克隆/销毁都有原子操作开销,无法避免 |
| 循环引用 | GC 能处理 | 无法处理,会内存泄漏(必须用 Weak 打破) |
| 心智模型 | 「我不关心它何时死,反正有人管」 | 「我必须精确记账还有谁在引用」 |
Arc 的正确用途只有一个:多线程环境下的所有权共享。单线程共享用 Rc 就够,省掉原子操作;能用借用解决的场景,&T 才是零成本答案。把 Arc 当 GC,等于在不需要的场景为「确定的释放时机」支付原子操作的隐性税——高频 clone 下还会强制 CPU 缓存行同步,性能比 GC 更糟,同时把清晰的借用关系搅成了运行时账本。
自引用与 Pin
Pin 解决的是 Rust 里少数需要「钉住地址」的场景:自引用结构体的内存地址一旦变化,内部指针立刻悬垂;async/await 生成的状态机正是这种结构,所以 Future::poll 的签名要求 Pin<&mut Self>,以保证跨 .await 挂起点内部引用始终有效。
这里我纠正过一个理解偏差:以为 Pin 是 trait,需要手写 impl 为类型实现。
实际上 Pin 是结构体(包装器),Unpin 才是 trait——而且它是标记 trait,由编译器根据字段自动推导:只要字段全是 Unpin,类型自动 Unpin,被 Pin 包装也能安全取出。想让类型「绝不移动」,就在结构体里加一个 PhantomPinned 字段,破坏 Unpin 的自动实现,Pin 随即对它开启严格模式——用类型系统在编译期锁死内存地址,一行显式逻辑都不用写。
PhantomData(标记体系的一员)则是零大小的「幽灵字段」,运行时零开销,只负责向编译器传递信息,四个典型用途:
Drop检查:手写裸指针容器时「假装拥有T」,让编译器知道析构职责归属;- 生命周期绑定:
PhantomData<&'a T>显式声明结构体与'a的关系,让借用检查器正确工作; - 类型标记:
struct UserId<T>(u64, PhantomData<T>),不同T的 ID 不可混用,零成本类型安全; Send/Sync控制:PhantomData<*const T>手动破坏线程安全属性的错误自动推导。
循环引用
双向链表和带环图是「两条赛道」理论的实战:A 引用 B、B 引用 A 的循环,正是引用计数和借用检查共同面对的最难场景。
标准库 LinkedList 走的是硬核模式:Box<Node<T>> 分配节点 + 裸指针 *mut + unsafe 手动维护指向,把危险关在笼子里,对外暴露绝对安全的 API。
日常开发则用 Rc/Arc + RefCell/Mutex 组合,关键是一个方向用强引用、另一个方向用 Weak 弱引用——否则 A、B 互相强引用,计数永不归零,内存泄漏:
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>, // 后继:强引用
prev: Option<Weak<RefCell<Node>>>, // 前驱:弱引用,打破循环
}
图结构更进一步,主流方案有两条:
- 索引化(
Vec<Node>+usize下标,如petgraph)——完全避开借用检查,内存连续、缓存友好、易序列化; - Arena 模式(如
typed_arena)——节点从统一内存池分配,生命周期绑定同一上下文,互相引用只存真实引用&'a Node,连Rc的运行时开销都省了。
| 结构 | 方案 | 核心组合 | 适用场景 |
|---|---|---|---|
| 双向链表 | 标准库底层 | 裸指针 + unsafe |
极致性能的底层库 |
| 链表/带环图 | 常规安全实现 | Rc/Arc + RefCell/Mutex + Weak |
日常业务开发 |
| 高性能图 | 索引化 | Vec<T> + usize |
大规模图计算 |
| 复杂自引用 | Arena | 内存池 + &'a T |
编译器、AST 等系统 |
结论适用于大多数场景:业务开发别造轮子,petgraph 和标准库足够;想体验所有权系统的精髓,手写一遍 Rc<RefCell<T>> + Weak 的带环结构是最好的练习。
资源管理这一章对比下来,两种语言的分歧可以浓缩成一句话:C++ 把正确性押在「程序员记得住规则」上,Rust 把它押在「编译器验证得了契约」上。构造函数、智能指针、标记类型,都是这个判断在不同层面的投影。
抽象与控制流
闭包
写 C++ Lambda 时,捕获列表是绕不开的仪式:[=] 按值、[&] 按引用,选错了要么悬垂引用、要么意外深拷贝。
对比之下,Rust 闭包「不用声明」捕获方式,是因为编译器按使用方式自动推断:
- 只读变量 → 不可变借用
&T,闭包实现Fn; - 修改变量 → 可变借用
&mut T,实现FnMut; - 消耗变量 → 所有权转移,实现
FnOnce。
这套优先级与 trait 体系一一对应,也解释了 Rust 为什么不默认拷贝:对 String、Vec 这类非 Copy 类型,「拷贝」要么不成立,要么是昂贵的深拷贝——C++ 里 [=] 捕获大对象触发深拷贝,正是常见的高频性能杀手。
唯一需要显式关键字的是 move:当闭包要跨越作用域(比如交给 std::thread::spawn),借用链必须切断,move 明确宣告「所有权归闭包了」。不写就编译报错,而不是运行时悬垂——这是编译器强制的安全边界。
返回闭包时还有个常见误解:Box<dyn Fn(i32) -> i32> 看起来是「必须堆内存」的标准写法,其实不是。闭包是编译器生成的匿名结构体,大小未知,而函数返回值必须 Sized;dyn Fn 负责类型擦除,Box 把未知大小搬到堆上、在栈上只留固定 8 字节的指针——两个限制合起来才凑出这个写法。如果函数各分支只返回同一种闭包,impl Fn 就够,全程栈上、还能内联。这道选择题,本质上还是「单态化 vs 虚表」的延续。
迭代器
Rust 的迭代器是语言的核心抽象:任何实现 Iterator trait 的类型都能链式组合,.filter().map().collect() 惰性求值、零成本——整条链会被编译器优化成与手写循环相当甚至更好的机器码。
C++ 的传统迭代器模型则被吐槽多年:
- 算法要传
begin/end迭代器对、容易不匹配; - 多步组合要临时容器。
所以「C++ 的 range 算是为数不多的亮点,各种算法跟 STL 打通」这个判断值得展开说:Ranges 的高明之处不是抛弃迭代器,而是把「范围」升格为一等公民、以 View 实现惰性求值——vec | views::filter | views::transform 声明式管道、没有中间容器,还结合 concepts 做编译期约束。对老的迭代器模型来说,这确实是一次漂亮的翻身仗。
不过「充分利用 SIMD」这个预期要打折:向量化的前提是数据连续布局,而 views::filter 恰恰引入间接访问、破坏连续性,反而可能阻碍自动向量化;能不能向量化,更多取决于数据结构和编译器设置(-O3、-march=native),不是 Ranges 自带的魔法。Rust 一侧同样依赖 LLVM 自动向量化,而且因为别名规则更严格,编译器证明内存布局的阻力更小;需要显式控制时有正在标准化的 std::simd。
真正的杀手锏在并行:C++ 的 std::execution::par 仍在完善,Rust 的 Rayon 只需把 .iter() 换成 .par_iter(),整条链自动多核并行,而且无数据竞争——由编译器保证。
| 特性 | C++ Ranges | Rust Iterator |
|---|---|---|
| 组合语法 | 管道符 | |
链式点号 . |
| 核心抽象 | Range + View 两层 | 一切皆迭代器 |
| 惰性求值 | 是(View) | 是(核心特性) |
| 并行处理 | std::execution::par(较新) |
Rayon .par_iter()(成熟) |
动态多态
Rust 实现传统 OOP 的多态,主力是 trait + dyn:Vec<Box<dyn Draw>> 这样的异构集合,运行时通过虚表分发。
和 C++ 的虚函数机制相比,最本质的差异是虚表指针的位置:
- C++:vptr 嵌在对象内部,每个带虚函数的对象都多 8 字节,vtable 与类继承体系深度绑定;
- Rust:对象保持纯净,vptr 放在胖指针里——
&dyn Trait是 16 字节(数据指针 + 虚表指针),vtable 由编译器为「具体类型 × trait」组合生成,不依赖继承。
外置带来一个 C++ 没有的优势:同一个对象可以同时拥有 &dyn Draw 和 &dyn Debug 两个胖指针,各自携带指向不同 vtable 的指针,而对象内存布局一成不变。
约束当然也有:
dyn Trait是运行时才知道大小的类型(DST),必须经由指针使用;- 受「对象安全」限制——返回
Self、带泛型参数的方法无法进入 vtable,编译期直接拦截。
没有继承的世界
从 trait + dyn 追到底层会发现:Rust 根本没有传统 OOP 的类继承。
C++ 一系列因继承而生的复杂问题与补丁,在这里集体消失:
- 菱形继承:不存在。Rust 拆分数据与行为——数据复用靠组合(结构体嵌套),行为复用靠 trait(只定义行为、不含数据),不存在「两份基类数据副本」的冗余与歧义;
this指针偏移:不存在。- 多重继承下父类指针转换需要编译器偷偷做地址加减;
- Rust 的胖指针永远指向对象起始位置,不需要偏移魔法;
- 空基类优化与
[[no_unique_address]]:不需要。- C++ 规定对象必须有唯一地址,空类强制占 1 字节,于是先有 EBO、后有
[[no_unique_address]],一路打补丁; - Rust 原生支持零大小类型(ZST),空结构体就是 0 字节,作为字段不占空间、也引发不了对齐填充,需要地址时才由编译器分配逻辑标识——语言层面的原生特性,而非特例豁免。
- C++ 规定对象必须有唯一地址,空类强制占 1 字节,于是先有 EBO、后有
一句话总结这个层面的分歧:C++ 的对象模型是「继承驱动的补丁堆」,Rust 是「行为驱动的正交拆分」。凡是 C++ 需要靠 EBO、[[no_unique_address]]、this 指针偏移来缝补的地方,Rust 在语言设计里都没有对应的概念——不是解决了问题,而是不复存在。
运行时安全网
编译期防线之外,语言还要面对只在运行时才暴露的三类问题:数据竞争、错误传播、进程崩溃。这三种场景正好可以看清 C++ 与 Rust 各自「兜底策略」的材质。
数据竞争
C++ 的治理是一套「组合拳」:
- Clang 线程安全注解(
GUARDED_BY、REQUIRES、ACQUIRE/RELEASE)把「该变量由这把锁保护」写进类型,编译期静态检查; - 运行时靠 TSan 动态捕捉;
- 再叠加静态分析工具、压力测试与模糊测试。
但所有防线都建立在开发者主动参与之上——注解要手写,TSan 只在测试时开启,工具没覆盖的竞争留到生产环境才引爆。
Rust 把主力放在编译期:借用检查器强制「同一时刻要么多个不可变引用、要么一个可变引用」,编译通过即逻辑上无竞争;RefCell、Mutex 等内部可变性类型才是降级到运行时检查的特殊场景。
Send/Sync 两个标记 trait 则把所有类型放进四个象限:
| 类型 | Send |
Sync |
说明 | 例子 |
|---|---|---|---|---|
| 能传能共享 | 是 | 是 | 所有权可移交,引用可多线程共享 | i32、String、Arc<T>、Mutex<T> |
| 能传不能共享 | 是 | 否 | 可移交所有权,但不能被多线程同时引用 | RefCell<T>、Cell<T>(内部可变性非原子) |
| 不能传能共享 | 否 | 是 | 不能移交,但共享引用安全 | MutexGuard(锁守卫) |
| 都不能 | 否 | 否 | 只能留在本线程内使用 | Rc<T>、*mut T |
于是治理思路的对比可以归纳成一句话:C++ 是「事后检测 + 人工规范」,Rust 是「事前预防 + 编译强制」。
从锁到消息
数据竞争再往上走一层,就是架构级的话题。最原始的方案——多个线程直接对共享资源加读写锁——问题在于锁的复杂度是「业务级」的:哪些是读、哪些是写、读写之间怎么互斥,全要开发者自己维护,稍有差池就是死锁或竞争。
改成「单一所有者 + 请求队列」后,复杂度发生质变:资源由一个所有者独占维护(天然无竞争),使用者通过 FIFO 队列发送请求、订阅回调——这正是 Go「不要通过共享内存来通信,而要通过通信来共享内存」的哲学,一种 Actor 模型变体。
锁的复杂度降级为「工具锁」:只存在于队列内部,业务代码完全无锁。再进一步是 Disruptor 式的无锁环形队列——用 CAS 和内存屏障连队列锁都省掉。
这套模型与 Rust 是天然契合的:mpsc::channel 传数据靠的就是所有权转移,tx.send 把值 move 进通道后,发送方再也无法触碰;move 闭包把变量所有权搬进新线程(等价于 C++ 的 [x = std::move(x)] lambda),再配合 Send/Sync 的编译期检查,「通过通信共享内存」不只是架构约定,而是被类型系统强制的纪律。
C++ 里同样的架构要依赖程序员自觉——数据放进队列后「记得」不要再访问。「记得」这两个字,就是两种语言的差距。
错误处理
Rust 的 ? 本质是 match 的语法糖:Ok 解包继续,Err 立即从当前函数返回,再借 From trait 自动完成错误类型转换(io::Error 转自定义 AppError,只需实现 From)。函数签名里的 Result<T, E> 是显式的——调用者无法假装错误不存在。
C++ 的 throw/try-catch 则是「非局部跳转」:错误作为事件沿调用栈寻找 catch,函数签名默认不体现(除非 noexcept)。
这套机制的问题清单相当长:
.eh_frame元数据带来二进制开销;- 编译器因「不确定是否抛出」而保守优化;
- 栈展开成本高;
- 跨越非 RAII 边界容易泄漏;
- 还拖累移动语义——如果移动构造不能保证
noexcept,会退化为拷贝。
| 特性 | Rust ? / Result |
C++ throw / try-catch |
|---|---|---|
| 可见性 | 显式,函数签名必须声明 | 隐式,签名通常不体现 |
| 性能开销 | 零开销(条件判断 + 返回值) | 快乐路径快,展开慢;二进制增大约 10%~15% |
| 传播方式 | 线性,像数据流 | 跳跃,像传送门 |
| 错误组合 | From 汇聚多种错误为一个枚举 |
异常类继承树 |
现代语言的趋势也印证了这一判断:Go 的 if err != nil、TypeScript 社区的 Result 模式,都在回归「错误即值」。
C++ 自己也引入 C++23 的 std::expected<T, E>,几乎就是 Result 的翻版——它的两难在于历史包袱:
- 旧代码依赖异常;
- 构造函数没有返回值只能抛异常;
- 异常又无法跨越 C ABI 边界。
panic 与 C++ 异常
Rust 的 panic 与 C++ 异常存在高度机制对应:
- 默认策略
unwind≈ C++ 常规异常栈展开:沿调用栈回溯、执行局部变量Drop,资源安全释放; panic = "abort"≈ 调用std::terminate():直接终止,不展开、不清理。
同源的根源有两层:
- 展开机制与 ABI 强相关:ELF/PE/Mach-O 平台上要靠
.eh_frame/DWARF(Windows 用 SEH)回溯调用栈,Rust 为了与 C/C++ 互操作复用了这套系统级机制; - 编译器后端同源:rustc 与 Clang 共用 LLVM 的异常处理代码生成模块,因此开销几乎一致(切到
abort能省回约 5%~15% 体积)。
分野在「边界纪律」上。C++ 的异常跨越 FFI 边界是「最佳实践层面的禁忌」;Rust 则是编译器强制——extern "C" 函数里 panic 逃逸会直接熔断,想优雅跨越必须显式 catch_unwind 拦截,或声明 extern "C-unwind" 允许展开,像一道海关安检。
关闭开关的对比也很有意思:
- C++ 的
-fno-exceptions是全局暴力开关,关闭后错误处理只能退回错误码; - Rust 的
panic = "abort"只改变「致命错误」的处理方式,Result体系不依赖栈展开、照常工作。
一个是在错误处理模型上妥协,一个只是纯粹的性能/体积优化。
崩溃监控
跨平台 crash 监控绕不过 Unix signal:
SIGSEGV回调里,std::mutex、条件变量甚至内存分配都是禁区——POSIX 只允许调用「异步信号安全」函数,C++ 大部分标准库组件不在白名单;- handler 可以插在任意指令边界,打断主线程的堆操作会导致二次破坏;
- 跨线程信号加栈展开没有 ABI 支持,属于彻底的 UB。
C++ 迟迟不给 signal 做现代化抽象,不是疏漏,而是信号机制的本质与 C++ 抽象理念根本冲突:RAII 依赖确定性,而 signal 是「任意时刻的打断」。社区共识是把它降级为「通知机制」——handler 里只改 atomic 标志或用 self-pipe trick 写管道,完整逻辑交给事件循环接管。
Rust 给出的是分层答案:
- 硬件级崩溃(
SIGSEGV/SIGILL):面对同样的物理定律,同样需要unsafe+ 原子标志/管道,handler 里不能分配、不能锁; - 优雅退出(
SIGINT/SIGTERM):享受现代抽象——Tokio 把信号转成异步Stream直接.await;rustix用强类型枚举和Result替代裸整数宏; - 语言级 panic:
std::panic::set_hook注册自定义 hook,处于正常执行上下文,可以用全部标准库收集堆栈、写日志。
「不是只能 unsafe + C pipe,而是把危险限制在真正需要它的最小范围」——这句话几乎可以概括 Rust 在系统边界上的一贯态度:物理定律不粉饰,只在物理定律要求的地方暴露 unsafe。
这三种运行时场景最终呈现的,是两套材质不同的安全网:C++ 的网由工具与纪律编织,Rust 的网由类型系统与分层策略编织。
质量保障
写完的代码靠什么兜底?这一章从静态分析工具一路走到形式化验证,最后停在一个让我反复咀嚼的结论上。
静态分析
C++ 的静态分析生态是分层互补的:
- Infer(Meta 开源):基于抽象释义与分离逻辑做过程间分析——能跨函数甚至跨文件追踪指针生命周期,误报率控制得好,背后有 POPL 级别的论文支撑;
- clang-tidy:基于 LLVM 的完整 AST,擅长编码规范、性能优化与现代 C++ 实践;
- Cppcheck:轻量、无需编译、扫描快。
所以「Infer 是升级版 clang-tidy」的说法并不成立——它们更像是各有所长的互补品,工业界的用法是组合拳:
- Cppcheck 日常扫描;
- clang-tidy 统一风格;
- Infer 定期做深度内存安全体检。
Rust 生态里,clippy 承担 lint 与反模式检查,MIRAI 基于符号执行做深度分析——后者的定位更接近 Infer;但论「严谨」,还要看 Creusot 这一类工具。
形式化验证
Creusot 走的是演绎验证路线:用 #[requires](前置条件)、#[ensures](后置条件)把契约写进代码,工具将 Rust 代码翻译成 Why3ML 中间语言,再交给 Z3、Alt-Ergo 等 SMT 求解器生成并求解数学命题——证明通过,就相当于拿到「这个函数永远满足契约」的数学保证。
它的挂接方式很巧妙:不魔改 rustc,而是通过 rustc_driver 接口「搭便车」——用同名包装器替换编译器驱动,在 HIR 阶段提取契约、在 MIR 阶段提取执行逻辑,翻译成数学命题交给求解器;证明失败就像编译报错一样直接中止。
初看 #[requires]/#[ensures] 很像 gtest 断言,但这个类比恰好点破了本质差异:gtest 是「考试」——运行时跑几个用例,测过 n=1、n=2 不代表 n=9999 成立;Creusot 是「数学归纳法」——不跑数据,直接对全体输入推公式。一个覆盖你想到的场景,一个框死所有可能。
C++ 一侧的形式化工具则是「商业神器 + 学术组合拳」的格局:
- Polyspace Code Prover 用抽象释义给每行代码染色(绿色证明安全、红色证明必错、橙色无法证明),是航空(DO-178)、汽车(ISO 26262)领域的标配;
- CBMC 把代码转成布尔公式交给 SMT 求解器,还能给出反例路径,常与 Clang Static Analyzer 串联使用。
语言层面的动向则是 C++26 Contracts:pre/post 与 contract_assert 把契约引入语法,与 GTest 结合还有化学反应——EXPECT_DEATH 可用来验证契约违规时的终止行为,未来的模糊测试工具甚至能根据前置条件自动推导边界测试数据。GTest 负责「怎么测」,合约负责「测什么」——C++ 正在朝「契约入类型」的方向补课,只是这条路它走得比 Rust 晚了很多年。
工具链全景
「借用检查器 + clippy + miri + cargo audit 是不是就够了?」——对绝大多数项目,这套组合已是坚固的「黄金标准」;但对高可靠性系统,仍有几处盲区需要补位:
| 防御层级 | 核心工具 | 解决的问题 |
|---|---|---|
| 编译期防线 | 借用检查器、Clippy | 内存安全、数据竞争、代码反模式 |
| 运行时/模拟检测 | Miri | unsafe 代码中的未定义行为 |
| 供应链安全 | Cargo Audit | 第三方依赖的已知漏洞 |
| 逻辑与边界测试 | cargo fuzz | 业务逻辑错误、极端边界崩溃 |
| 质量量化 | cargo tarpaulin | 核心代码的测试覆盖率 |
| 数学级证明 | Creusot / Verus | 核心算法的绝对正确性 |
最容易被忽视的两个盲区:
- 逻辑错误:借用检查器保证「内存不乱跑」,但保证不了「业务逻辑对」——空数组求平均、排序写错、状态流转错误,它都无能为力,需要模糊测试补位;
- 死锁:它能防数据竞争,防不住死锁——A 线程先锁
m1再锁m2、B 线程反过来,编译器拦不住,只能靠parking_lot调试模式的死锁检测这类工具兜底。
把形式化验证装进类型系统
把前面这些线索串起来,有一段总结让我印象极深:
Rust 的 ownership 系统本质上是把 Separation Logic 的一个子集编码进了类型系统——编译器自动做「证明」,程序员不需要写规约。代价是表达力受限(有些安全的代码写不出来,需要
unsafe),好处是零成本、零注解、全自动。所以 Rust 可以看作是形式化验证在工业界的一次成功降维打击——不要求完全证明,只保证内存安全和数据竞争自由,但做到了让普通程序员无感使用。
这个判断把前面所有线索都串起来了:Infer 用分离逻辑去「分析」C++ 代码,而 Rust 的借用检查器本质上在做同一件事——分离逻辑的核心「独占性」,恰好对应 &mut T 的排他规则;区别在于传统形式化工具要求程序员手动写规约,而 Rust 把这套逻辑固化成了类型规则,编译期自动证明,证明失败就直接报错。
Rust 做了一笔极聪明的交易:牺牲一部分表达力(带环结构、复杂指针操作,编译器推不出就写不出来)换取全自动(零注解、零门槛),同时给「编译器证明不了、但程序员确信安全」的场景留了 unsafe 这个受控逃生舱——「这里我接管,用人脑保证安全」。
它不追求「证明一切」,只保证内存安全与数据竞争自由;也正因如此,「让普通程序员在不知不觉中写出高可靠性代码」才成为可能。这解释了为什么云计算、区块链、浏览器引擎等高安全领域在纷纷从 C++ 转向 Rust——不是 Rust 更优雅,而是它的正确性证明是「免费附赠」的。
跨语言调用
跨语言调用是工程里的常态:存量模块是 C++,新模块用 Rust,两边共存于同一个进程,必须能互相调用。而无论哪种语言组合,接口最终都要降维到 C ABI 的形状,再在上面搭胶水。
下面沿着「胶水层怎么搭」看下去:先看 C++ 的传统做法,再看 Rust 生态的现成方案,最后是一套把绑定生成做成流水线的框架。
C++ 的做法
C++ 向其它语言导出接口,第一个障碍是 ABI:它没有跨编译器的稳定 ABI——GCC、Clang、MSVC 之间连 name mangling 规则、虚表布局、异常处理机制都不一致,甚至同一编译器的不同版本之间也不保证兼容。
所以胶水层不能直接暴露 C++ 符号,通行做法是先包一层扁平的 C 接口:extern "C" 函数,参数与返回值全部「降维」到 C ABI 能表达的形状——指针、整数、回调函数指针,再配上一组显式的创建/销毁函数来约定生命周期。
这套做法谈不上优雅:类型信息在边界上丢失(std::vector<std::unique_ptr<Base>> 这样的签名,映射成什么?),内存管理靠手动约定,胶水代码全得手写。但这层手写的 C 胶水是所有跨语言方案的起点,也定义了后续所有自动化的目标——把这层又脏又累的活交给机器。
Rust 调用 C/C++
另一个高频方向,是把现成的 C/C++ 库接进 Rust 项目。这条方向上的答案已经很成熟:
- 官方有
rust-bindgen,从 C 头文件生成 Rust 的 FFI 绑定; - Google 有
autocxx,在它之上进一步处理 C++ 特性(类、重载、模板等的映射)。
两者把「翻译接口」自动化,Rust 侧就能像调用普通 crate 一样调用 C/C++ 库。
但这两个工具覆盖的只是一个方向——「Rust 调别人」。另一个方向,「Kotlin、Swift、Python 怎么调 Rust」,需要一条完全不同的路子。
外部语言调用 Rust
这条路上的主角是 UniFFI(Mozilla 主导)。它不是对 rust-bindgen 的重复造轮子——后者只解决「翻译接口」一步,而 UniFFI 交付的是从接口定义到多语言绑定的完整流水线:
- 统一的接口描述层;
- Rust 侧的脚手架生成(自动产出
extern "C"FFI 函数、类型转换、错误处理,甚至异步Future的状态机代码); - 目标语言侧的绑定生成(按各语言惯用法渲染模板)。
胶水从哪来?C++ 方案要靠 libclang 解析源码 AST、脚本渲染模板;UniFFI 则把「接口发现」内嵌进 Rust 自己的工具链,分三步:
- 元数据生成:接口可以用
.udl文件声明,也可以直接用#[uniffi::export]等过程宏标注;过程宏在编译期拿到被标注函数/结构体的签名信息(参数类型、返回值、方法列表),序列化成自定义中间格式,嵌入最终动态库的特定 section; - 元数据提取:
uniffi-bindgen generate加载编译好的.so/.dylib,读出嵌入的元数据(若走 UDL 路径则直接解析.udl文件),构建出语言无关的 Component Interface——一份描述所有导出函数、对象、枚举、Record 的中间表示(IR); - 代码生成:到这里 bindgen 就是一个纯模板引擎,按目标语言选择模板,把 IR 渲染成 Kotlin/Swift/Python 源码文件。
| 环节 | C++ 方案 | Rust UniFFI |
|---|---|---|
| 接口发现 | 外部工具(libclang)解析源码 AST | 过程宏在编译期提取,嵌入二进制 |
| 中间表示 | 脚本层自行构建 | Component Interface(内置) |
| 代码生成 | 脚本 + 模板 | uniffi-bindgen 内置模板引擎 |
| 外部依赖 | libclang + Python/脚本环境 | 零外部依赖,纯 Rust 工具链 |
根因在于编译期能力:
- C++ 只能借助外部工具「看」代码——宏只是文本替换,模板虽图灵完备却无法「反射」自身结构,C++26 静态反射尚未落地;
- Rust 的过程宏天然运行在编译期,能拿到
TokenStream做结构化分析——宏本身就是那个「分析器」。
这也是 UniFFI「零配置」的来源:全程内嵌在编译流程里,不需要额外的解析器或脚本环境。
社区基于这套 IR 又长出了 React Native、Kotlin Multiplatform、Dart、C#、Go 等绑定生成器——第三方方案能蓬勃生长,恰恰证明中间表示设计得足够通用:接入新语言只需要写一个新模板,Rust 侧的接口提取与 FFI 脚手架完全复用。
异步的归属
异步是跨语言调用里最容易想当然的一环:Rust 的 async 函数被导出到外部语言后,谁来驱动它背后的 Future?直觉容易走偏——以为框架会像 Flutter 的 MethodChannel 那样在底层维护一套调度系统,自动排队、调度、把结果推回来。
UniFFI 的答案恰好相反:它「借用」外部运行时的调度能力,自己不启动任何 Rust 运行时(如 Tokio),只提供一组标准 FFI 接口,让外部语言(Kotlin 协程、Swift GCD、Python asyncio)的运行时来驱动 Rust 的 Future。
整条链路只有四个动作:
- 启动:外部调用 Rust 函数,Rust 返回一个
RustFuture句柄(本质是指针/ID),此时什么都不执行; - 轮询:外部在自己事件循环里调用
rust_future_poll,并传入一个回调。Rust 尝试推进Future——Ready就立即调用回调通知;Pending就把回调存下来当作Waker返回,等Future真正就绪时再触发它; - 完成:外部收到回调后调用
rust_future_complete取走最终结果; - 清理:调用
rust_future_free释放资源。
所以 UniFFI 在异步里扮演的是状态容器而非调度器:持有句柄、管理 Waker 的注册与触发、维护 Pending/Ready/Cancelled 状态机、提供 poll/complete/free 三个操作口——但不做任何调度决策,不决定何时 poll、在哪个线程 poll,也不维护事件循环。管数据不管调度,管状态不管时机。
打个比方:Flutter 是自动挡,你只管 await;UniFFI 是手动挡,油门(polling)和方向盘(调度器)都在外部语言手里,它只提供仪表盘(查询接口)和点火开关(回调接口)。
这个设计顺带澄清了一个常被混淆的概念:异步不等于后台线程或协程。异步的本质是「我不阻塞当前执行流,完事了通知你」,至于通知之后谁去执行、在哪个线程执行,那是调度策略的事,与异步本身无关。
GCD 的优雅正在于此——把两个正交维度拆得干净:sync/async 决定当前线程等不等(回调方式),serial/concurrent 决定任务怎么排队消费(调度方式),二者自由组合。很多框架偏偏把两件事搅在一起——一提 async 就默认开线程池,一提串行就只能同步阻塞。
UniFFI 沿用了同样的分层思路:只管「完成后的回调通知」,回调拿去串行排队还是丢进并发池,完全由外部决定;由此也带出一条纪律——Rust 层最好只做纯计算,避免依赖特定运行时的原语(如 tokio::spawn),否则会与外部调度器打架。
为什么 C++ 没有对等物
回到绑定生成这条线,一个自然推论是:C++ 理论上也能做,而且成本可能更低——clang/LLVM 的元数据提取能力是现成的(它们本来就是 C++ 写的),差的只是最后一步代码生成。
乍看之下,有一组「C++ 为什么做不成」的解释颇具说服力:
- 类型系统太复杂:模板特化、SFINAE、多重继承、隐式转换,映射到跨语言统一类型系统工作量巨大;
- ABI 太碎:胶水不能直接暴露 C++ 符号,必须先包一层 C 桥,多一道成本;
- 所有权语义难推断:裸指针、智能指针、引用混用,AST 看不出跨边界时该用哪种管理策略。
但这组解释经不起推敲:Rust 同样有一批无法直接跨 FFI 表达的类型与 feature——Pin、impl Trait、async 语法糖,哪个能裸穿边界?而 C 桥本就是 C++ 导出接口的成熟套路,不是绑定生成才有的额外负担。两边在边界上遇到的问题本质相同——都得做类型「降维」——不存在谁天然更简单。
真正的差距不在技术能力,而在生态与动力:
- Rust 社区有跨语言绑定的刚需(WASM、嵌入式、移动端),所以有人愿意长期投入,把 UniFFI 打磨到「开箱即用」;
- C++ 这边各家大厂倾向于内部造轮子——djinni 早已停止维护,cpp2 仍在探索——始终没有一个统一的项目被推到成熟。
技术能力从来不是瓶颈,工程投入和社区共识才是。
C ABI 并没有被绕过
我起初的理解是:UniFFI 摒弃了传统跨语言导出 C 接口的套路,用中间语言描述元数据,自己解决了跨语言 ABI 问题。
这个理解需要修正——它没有绕过 C ABI,只是把「手写 C 接口」这一步自动化了:
- 底层传输:自动生成的 FFI 函数签名全部是 C 兼容的(指针、整数、回调函数指针),外部语言通过标准的
dlopen/System.loadLibrary加载调用; - 元数据的作用:中间表示描述的不是 ABI 本身,而是类型语义——「这个不透明指针其实是
Arc<MyStruct>」「这个u32在 Kotlin 侧应映射为UInt」。
分工可以这样记:C ABI 负责「怎么传」(传输协议),中间表示负责「传的是什么」(类型语义)。就像 HTTP 与 OpenAPI/Swagger 的关系——HTTP 只管字节怎么传,Swagger 描述每个接口传什么字段、什么类型;UniFFI 就是那个读 Swagger 定义、自动生成双端代码与 SDK 的工具,只不过定义用 UDL 或过程宏来写。
放到整篇文章的视角里,这也补上了最后一块拼图:前文 panic 一节提到,extern "C" 是两种语言交汇的海关;而在跨语言调用的全景里,C ABI 实打实是共同地基——无论手写还是自动生成,Rust 与外部世界的每一句话都在这块地基上说。Rust 在这件事上的优势不在魔法,而在它的类型与所有权语义足够规整,让语言无关的契约好设计、好实现——再加上社区有人真的去做了。
两种语言最真实的关系,不发生在对比文章里,而发生在同一个进程的 C ABI 边界上:那里没有对立,只有分工。
尾声
回望这批笔记的起点,动机其实很朴素:从 C++ 转向 Rust,相通的地方太多,与其按部就班刷语法,不如顺着「为什么这样设计」一路追问下去。如今这一路走完,可以给出一个分层的总结:
对应关系:
C++ 的痛点几乎都能在 Rust 找到镜像解:
- UB 对应
unsafe围栏与 Miri; - 指针别名与悬垂引用对应借用检查;
- 宏/模板报错混乱对应过程宏与 trait 的分工;
shared_ptr滥用对应Arc滥用;- 虚表指针内嵌于对象对应外置于胖指针;
- 多继承的坑对应数据靠组合、行为靠 trait;
- 异常对应 panic +
Result; - signal handler 的禁区对应分层策略——
unsafe只在硬件级崩溃层暴露。
这一层解决「有什么」,但停在对应关系里,容易变成「Rust 真香」的表面结论。
代价意识:
每一个安全保证背后都有明确的账单:
- 移动语义换来显式的
Clone; - 闭包自动捕获推断换来
move的书写; RefCell换来运行时计数与 panic 风险;Arc换来原子开销;- 异步换来
Pin<&mut Self>签名与PhantomPinned幽灵字段的负担; unsafe换来「用人脑保证安全」的责任。
Rust 的诚实之处在于,这些账单都被控制在可解释、可预期的范围内。
范式差异:
- C++ 信任程序员、允许下注,再拿工具(Sanitizer、TSan、Infer、Polyspace)兜底;
- Rust 不信任程序员、先证明再放行,再用
unsafe与工具链(Miri、clippy、Creusot)补表达力与严谨度。
两条路线在过去十年其实在互相靠近:
- C++ 的补丁史(
noexcept、EBO、[[no_unique_address]]、std::expected、Contracts、Ranges)是在往「可验证性」走; - Rust 的
unsafe、FFI 与渐进式工具链是在往「实用主义」走。
它们各自被历史约束塑造,也各自承担代价。
所以「重新审视 Rust」的结论并不是「谁替代谁」,而是:Rust 用一组正交的机制——所有权、借用、trait、标记类型、分层 unsafe——把 C++ 里依赖约定和纪律维持的正确性,变成了类型系统的定理。穿过 C++ 的荆棘之后再看 Rust,看清的其实是 C++ 半个世纪的工程智慧(RAII、零成本抽象、编译期计算)如何被另一条路径重新演绎——而这条路径最大的不同,是它让正确性不再依赖「记得」。
如果一定要用一句话收尾:
- C++ 是一把需要大师驾驭的剑,锋利、自由,代价是每一剑都责任自负;
- Rust 是一副会纠正握姿的护具,安全、确定,代价是有些动作它不允许你做。
两者没有胜负——你要去哪里,以及你愿意为「记得」付出多少,才是选择的依据。