穿越 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 不发生」为前提把相关路径整个剪掉,于是含 UB 的程序可能 -O0 下正常、-O2 下行为诡异。同一个函数加一行无关语句、换个编译器版本,输出就可能不同。
更完整的 UB 清单与编译器利用 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++ 里,多个指针指向同一块内存(并且都能写)是家常便饭:
编译器无法确定两个指针是否同址,只能假设「可能别名」,寄存器缓存、常量折叠、自动向量化统统告吹。
缓解手段是 restrict / __restrict 这类扩展——由程序员手工承诺「这两个指针绝不重叠」,承诺错了就是 UB。
具体可见前文《现代 C++ 跨平台开发 - 内存篇》的「指针别名限制」一节。
Rust 的借用规则把这枚硬币直接没收了:
- 同一时刻,要么任意多个不可变引用(
&T),要么唯一一个可变引用(&mut T),二者不可兼得; - 引用的有效范围受生命周期约束,不可能指向已释放的内存。
代码里出现一个 &mut data,编译器就能百分之百确定:当前作用域内没有任何别的指针碰得到这块数据——这种「独占性」在 C++ 里拿不到。
拿到这份保证,LLVM 可以放手做 C/C++ 不敢做的优化(重排内存访问、常量提升、自动向量化)——安全性红利反过来变成了性能红利。
而 memcpy 与 memmove 的区别正是别名假设的具象化:
- 前者以「两段内存不重叠」换取性能;
- 后者允许重叠(必要时反向拷贝);
- 示例见前文《现代 C++ 跨平台开发 - 内存篇》的
memmove()一节。
前提一旦用错,行为即未定义。Rust 的安全代码里根本构造不出这种调用——借用检查器不允许你同时持有指向重叠区域的可写引用与可读引用。
一句话收束这段对比:
C++ 里「两个指针没有别名」是程序员写的承诺;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++」。
原则:能靠设计绕开的优先调整设计,确实绕不开的再局部引入。
RefCell 与 Cell、Mutex、RwLock 在工具箱里各占哪一层,见后文「资源管理 - 智能指针」的分层图。
生命周期标注
借用检查里最劝退初学者的,大概就是 '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 明确亲和谁 |
生命周期标注本质上就是「返回值对参数的亲和性声明」。
C++ 的工具兜底
C++ 没有生命周期标注,多引用参数导致的悬垂引用问题,只能靠「工具辅助 + 规范约束」来兜底:
std::weak_ptr:真正意义的「安全网」,使用前.lock()升级为shared_ptr,对象已销毁则返回空——但这是运行时检查,有引用计数开销;- ASan(AddressSanitizer):运行时监控内存访问,抓到悬垂立刻报错——但程序必须真的跑到那段错误代码,测试覆盖率不够的 Bug 依然会溜进生产环境;
- Clang-Tidy / 静态分析:编译前扫出「返回局部变量引用」这类明显问题——可能漏报、可能误报,且换一个编译器(如 MSVC)规则就未必一致;
unique_ptr与值语义:从源头减少堆内存悬垂,但只覆盖独占所有权场景;gsl::not_null、注释契约:靠文档和纪律维护「谁活得比谁久」,编译器完全不检查。
每一条单看都有效,共同问题是三个字——不强制:用不用、配没配、跑没跑全凭自觉,检测率正比于恰好触发了多少测试路径。
Rust 把这些检查放进编译器核心:编译通过,悬垂引用在安全代码里就不可能存在。
Safe 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 把机制交到开发者手上当工具,安全感由语言保证。
定容容器
C++26 补上 std::inplace_vector<T, N>(<inplace_vector>):容量编译期固定为 N,size() 在 0..N 间可变,元素存在对象内部、不触发堆分配,且连续存储(data() / operator[] / 指针偏移照常可用)。
- 对标
std::array:array的 size 恒等于 capacity、构造时全部元素已初始化且要求可默认构造;inplace_vector只初始化实际插入的元素; - 对标
std::vector:不重分配、不扩容,代价是move/swap逐元素搬移(O(N),而vector只搬指针),且二者都会使全部迭代器失效; - 没有
push_front/emplace_front/pop_front/prepend_range。
容量耗尽时分三种:
- 溢出容量的插入:抛
std::bad_alloc,强异常保证(失败无副作用); try_push_back/try_emplace_back/try_append_range:满时返回nullptr,不抛异常;unchecked_*:不做容量检查,越界由调用者负责。
选型:
- 个数永远固定 →
array; - 不确定或可能很大 →
vector; - 上限已知且不大、要避免堆分配 →
inplace_vector(协议解析临时缓冲、回调参数列表、事件队列)。
前身 boost::static_vector 已沿用多年。
Rust 标准库没有等价物([T; N] 大小固定、Vec<T> 总在堆上),对应物都在生态里:
arrayvec::ArrayVec<T, N>:与它几乎一对一,超容量 panic 或由try_push返回Result;smallvec::SmallVec:内联容量内不分配、超了 spill 到堆,已 spill 时move只搬指针;tinyvec:内部零unsafe,代价是元素需实现Default。
枚举与代数数据类型
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 从设计之初就把它当母语:
match只要编译通过,数据状态必然合法;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 的迭代器是语言的核心抽象:任何实现 Iterator trait 的类型都能链式组合,.filter().map().collect() 惰性求值、零成本——整条链会被编译器优化成与手写循环相当甚至更好的机器码。
C++ 的传统迭代器模型则被吐槽多年:
- 算法要传
begin/end迭代器对、容易不匹配; - 多步组合要临时容器。
Ranges 是 C++ 为数不多的亮点:它不抛弃迭代器,而是把「范围」升格为一等公民、用 View 实现惰性求值——vec | views::filter | views::transform 是声明式管道,没有中间容器,再用 concepts 做编译期约束。
「充分利用 SIMD」这个预期要打折:
- 向量化要求数据连续,而
views::filter引入间接访问、破坏连续性,反而可能阻碍自动向量化——能不能向量化更多取决于数据结构与编译选项(-O3、-march=native); - 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 真正和 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++ 传统上只能接受,或者靠「类型擦除」的手工技巧。
再往上是特化——C++ 模板最锋利的部分(全特化、偏特化,可针对具体类型给零成本实现),Rust 稳定版没有对应能力:
specialization(RFC 1210)至今只在 nightly:stable 上写#![feature(specialization)]会直接被拒(E0554: may not be used on the stable release channel);min_specialization也不等于「特化已稳定」——unstable book 的定义是specialization 的一个健全子集,「供标准库使用,直到 specialization 的健全性问题被解决」,同样只在 nightly;- 所以在「稳定语言里能写什么」这个维度上,Rust 目前比不过 C++ 模板特化。这是约束式泛型的代价,不是遗漏。
约束式泛型买到的是确定性:
- 能力先声明后使用,函数体被检查之前就知道
T能干什么; - 不存在「某个模板分支其实从未编译过」——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 的语法 + 类型擦除的技术」做出的 dyn Trait 级东西:值语义的运行时多态,不需要继承体系,也没有 std::function 的局限。
标记 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 | 模板特化 + 函数重载 |
| 主要目标 | 类型安全保证(如线程安全) | 算法优化(如迭代器分发) |
| 语言地位 | 类型系统核心特性 | 高级模板惯用法 |
同一族还有 PhantomData:零大小的「幽灵字段」,运行时零开销,只负责向编译器传递信息。四个典型用途:
Drop检查:手写裸指针容器时「假装拥有T」,让编译器知道析构职责归属;- 生命周期绑定:
PhantomData<&'a T>显式声明结构体与'a的关系,让借用检查器正确工作; - 类型标记:
struct UserId<T>(u64, PhantomData<T>),不同T的 ID 不可混用,零成本类型安全; Send/Sync控制:PhantomData<*const T>手动破坏线程安全属性的错误自动推导。
标记 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。
至于性能收益:把表放进 const 在编译期算完,运行时不需要任何初始化,启动阶段直接省掉这段构造耗时。
宏系统
C++ 的模板元编程是「歪打正着」:模板本为通用容器与算法,人们却发现特化 + 递归实例化图灵完备,于是编译期阶乘、素数判断甚至编译期光线追踪都冒了出来——能力是意外捡到的,代价是语法晦涩、报错冗长。
Rust 的宏从设计之初就是语言的一部分,直接打通编译流程。宏分两类,各对应一个 C++ 概念:
- 声明宏(
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 { ... }) 要优雅得多。
至此拆分策略清晰:
- C++ 模板一身二职;
- Rust 让宏系统承接「代码生成」(对应 TMP),让泛型 + trait 承接「约束与复用」(对应模板 + concepts);
- 再叠加
cargo expand这类展开工具与精准报错,元编程从「调戏编译器」变成可调试、可测试的正规工程。
用一句话总结两种风格:C++ 模板是在利用编译器的既有特性做计算,Rust 宏是在扩展编译器能力生成代码——前者更野,后者更稳。
多态
多态问的是同一个问题:同一段调用,实际跑的是哪个实现。
- C++:答案写在一个个类里,由对象内部的虚表指针找过去;
- Rust:答案写在 trait 里,由编译器在编译期把「类型 × trait」焊成一张表。
两者的分歧,从「虚表指针住在哪」开始。
动态多态
Rust 实现传统 OOP 的多态,主力是 trait + dyn:dyn Draw 表示「某个实现了 Draw 的类型」,具体类型已被擦除——Vec<Box<dyn Draw>> 这样的异构集合因此成立,运行时通过虚表分发。
和 C++ 的虚函数机制相比,最本质的差异是虚表指针的位置:
- C++:虚表指针嵌在对象内部,每个带虚函数的对象都多 8 字节,虚表与类继承体系深度绑定;
- Rust:对象保持纯净,虚表指针放在胖指针里——
&dyn Trait是 16 字节(数据指针 + 虚表指针),虚表由编译器为「具体类型 × trait」组合生成,不依赖继承。
外置带来一个 C++ 没有的优势:同一个对象可以同时拥有 &dyn Draw 和 &dyn Debug 两个胖指针,各自携带指向不同虚表的指针,而对象内存布局一成不变。
位置之外,表里的内容也值得对照:
- C++ 的虚表装着虚函数指针,外加一个指向
type_info的 RTTI 指针——运行时类型信息就挂在表上;多继承还要追加若干this调整跳板; - Rust 的虚表开头固定是
drop、size、align三项,之后才是各方法指针——&dyn Trait胖指针的后 8 字节指向的就是它;表里没有 RTTI。
两种布局对照如下:
flowchart TD
subgraph cpp["C++:虚表绑定在类上(继承体系),指针内嵌在对象里"]
base["Shape(基类)"]
derived["Circle(派生类)"]
base -->|"继承"| derived
cobj["Circle 对象<br/>x / y<br/>虚表指针(内嵌 8 字节)"]
cvt["<b>Circle 虚表</b>(.rodata)<br/>draw() / area()"]
ti["type_info"]
derived -.->|"随类生成"| cvt
cvt -->|"RTTI 指针"| ti
cobj -->|"虚表指针"| cvt
end
classDef vtable stroke-width:2.5px
class cvt vtable
flowchart TD
subgraph rust["Rust:没有继承,虚表由「类型 × trait」编译期组合生成,指针外置"]
rtype["Circle(结构体,无基类)"]
rtrait["Draw(trait,只定义行为)"]
rfp["&dyn Draw(trait 对象引用)<br/>dyn:具体类型被擦除<br/>16 字节胖指针"]
robj["Circle 对象<br/>x / y"]
rvt["<b>Circle × Draw 虚表</b>(.rodata)<br/>drop / size / align<br/>draw()"]
rtype -.->|"编译期绑定"| rvt
rtrait -.->|"编译期绑定"| rvt
rfp -->|"数据指针"| robj
rfp -->|"虚表指针"| rvt
end
classDef vtable stroke-width:2.5px
class rvt vtable
图中 Rust 侧的「Circle × Draw」:Circle 是具体类型,Draw 是只定义行为的 trait(接口)——一个管数据、一个管行为,二者互不隶属。编译器在编译期把这组搭配「焊」成一张专属虚表,&dyn Draw 的后 8 字节携带的正是它。
两种虚表在二进制里的「存在形式」其实相同——都在编译期生成、都放在只读数据段(.rodata)。差异从来不在表本身,而在「指针住在哪、表属于谁」。
这也消掉了一个 C++ 经典陷阱:
- 基类指针
delete派生对象、基类忘了virtual析构,就是未定义行为; Box<dyn Trait>的析构与释放所需信息都在虚表里——没有需要「记得写」的项,默认路径就是正确路径。
虚表槽位布局、主基类与 this thunk、以及「析构为何必须 virtual」的完整推演,见前文虚函数表与多态。
约束当然也有:
- 大小未知:
dyn Trait是运行时才知道大小的类型(DST),必须经由指针使用; - 对象安全:返回
Self、带泛型参数的方法无法进入虚表,编译期直接拦截。
没有继承的世界
从 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 在语言设计里都没有对应的概念——不是解决了问题,而是不复存在。
运行时的类型识别与转换
dyn Trait 解决的是「调用哪个实现」。
反过来,手里握着基类指针或 trait 对象,要找回背后的具体类型——这套能力在 C++ 里叫 RTTI(运行时类型识别),工具是 typeid 与 dynamic_cast:
typeid:对多态对象取「动态类型」——身份从虚表上的 RTTI 指针读出,随取随用;对非多态类型则退化为编译期的静态类型;dynamic_cast:把指针或引用沿继承体系挪动——向下(基类到派生类)、交叉(crosscast,在兄弟基类之间转换)、void*(指回最派生对象);要求源类型多态、RTTI 开启,失败时指针版得到nullptr、引用版抛std::bad_cast。
其中「交叉转换」是多继承独有的操作:不必知道具体类型,只凭目标在继承体系里的位置,运行时就能算出偏移。
实现层全貌——type_info 布局、__dynamic_cast 如何沿继承图遍历、跨模块比较「先比指针、再比名称」的兜底——见前文RTTI。
Rust 没有图,「转换」只有两个方向:
- 向上:有 supertrait 关系的
dyn Sub可以转成dyn Super(Box等指针形式同样适用),对应 C++ 里派生类指针到基类的隐式转换——这一环直到 Rust 1.86(2025 年 4 月)才成为语言原生能力,此前得在子 trait 里手写一个「转父 trait」的方法; - 向下:走
Any与TypeId——普通 trait 对象不携带身份,识别必须经由Any:Any的type_id方法在虚表里占一个槽位,返回具体类型编译期算出的TypeId;downcast_ref返回Option,成败就是一次整数比较;Box<dyn Any>的downcast则返回Result,失败时把原对象原样退回。两条硬限制:目标必须满足'static(带非静态借用的类型进不了Any),且必须写明要认回的具体类型。
两个方向还能接力:trait 以 Any 为父 trait 时(如 trait Draw: Any),dyn Draw 可先向上转到 dyn Any,再执行向下识别。
一句话对照:
- C++ 的动态转换是继承图上的寻路——目标能任选一枝,甚至横向跳跃;
- Rust 的转换只有「认回具体类型」与「向上」两种,遇到交叉场景,等价路径是「先降到具体类型,再升回目标 trait」——一边是继承图的自由度,一边是身份比对的确定性。
对象的创建、传递与销毁
对象的一生分成三段:创建(怎么诞生)、传递(怎么拷贝、怎么移动、能不能改)、销毁(何时释放、如何释放)。下面按这三段逐项对比:构造、析构、拷贝语义、移动语义、返回值优化、可变性、this 指针,最后再回头看 C++ 独有的一层——编译器替你写规则的那套机制。
构造
C++ 的构造函数是一个「不是函数」的存在:没有返回值、不能取地址、名字由类名固定,还要在初始化列表与成员初始化顺序之间小心周旋。
Rust 没有构造函数这回事:初始化就是普通函数(fn new() -> Self)或结构体字面量,命名靠约定(new、with_capacity、from_*),没有隐式行为——与 Objective-C 的 alloc/init/dealloc 同一种路数。
| 维度 | C++ | Rust |
|---|---|---|
| 初始化 | 默认/带参构造、初始化列表、explicit |
普通函数 fn new() -> Self,无特殊语法 |
| 构造失败 | 无返回值,只能抛异常 | 返回 Result<Self, E> / Option<Self> |
构造失败:把错误做成返回值
- C++ 受语言层面的硬约束——构造过程没有返回值可用,失败只能抛异常;
- Rust 因为构造就是普通函数,这个问题根本不存在:
new()返回Self,也可以返回任何东西。
社区因此形成了分层约定:
Result<Self, E>(首选):可能失败就返回Result,调用方从签名即可判断,也能用?无缝向上传播:impl Foo { fn try_new(x: usize) -> Result<Self, Error> { if x >= LIMIT { return Err(Error::TooLarge(x)); } Ok(Self { val: x }) } }Option<Self>(失败原因不言自明):只有「有 / 无」一种语义时用它,比如「值不存在」;失败原因不止一种就必须用Result;panic!(编程错误):仅用于调用方违反了函数不变量这类「绝不该发生」的情况,生产代码里应尽量避免。
命名本身就是文档:new 表示不会失败,try_new(或动词开头的 open、connect、load)表示可能失败。Vec::try_reserve 已稳定,Box::try_new、Rc::try_new 同一约定但仍在 nightly 门后。
和 C++ 对照,三处差异值得记下:
- 错误路径显式化:失败与否写在签名里,不需要查文档或猜;
- 没有「半构造对象」:C++ 构造函数抛异常时,已构造的成员要逆向析构,状态与析构行为都得仔细推演;Rust 要么返回完整的
Ok(Self),要么返回Err——后者根本没有对象产生; - 组合友好:
?直接传播,不需要为构造单独包一层try-catch。
析构
C++ 的析构函数同样特殊:名字固定 ~T()、字段按声明逆序析构、自定义逻辑必须手写析构函数。析构里抛异常是禁忌——析构默认是隐式的 noexcept,真抛出来通常直接 std::terminate。
Rust 把「对象怎么死」拆成两个问题:
所有权系统决定「何时」释放,Drop 决定「如何」释放。字段按声明正序析构,自定义逻辑只在需要时实现 Drop。
析构里 panic 同样是禁忌,执行方式也更硬——展开中再 panic 直接 abort(见后文「异常处理 - 析构中的异常」)。
| 维度 | C++ 析构 | Rust |
|---|---|---|
| 字段析构顺序 | 与声明逆序 | 与声明正序 |
| 自定义逻辑 | 必须手写析构函数 | 只在需要时实现 Drop,否则编译器全包 |
| 抛异常 / panic | 默认 noexcept,抛出即 std::terminate |
正常路径只是一次普通 panic;展开中再 panic 即 abort |
drop glue:不写的析构
拆开看,「析构」其实是两层:
Droptrait:你可选实现的用户逻辑,只在需要「插入」时才出现;- drop glue:编译器必定合成的代码,负责先调用
Drop::drop(如果有),再递归析构每个字段——你写不出它,也绕不过它。
带 drop 的名字有三个,最容易混:Drop trait 是可选的用户逻辑,std::mem::drop 是强制提前释放的工具函数(前文已辨析),drop glue 是编译器必定生成的那层——只有第一个需要你操心。
struct MyResource {
file: File, // 实现了 Drop:关闭文件描述符
buffer: Vec<u8>, // 实现了 Drop:释放堆内存
name: String, // 实现了 Drop:释放堆内存
}
// 不实现 Drop,析构依然完整
MyResource 离开作用域时,drop glue 按声明顺序依次析构三个字段,每个字段各自的析构再逐层递归。只有需要在字段析构之前插入逻辑——比如先 flush 再关文件——才需要手写 Drop。
这也呼应了构造的「去特殊化」:Foo { a: 0, b: 1 } 只是数据聚合,资源获取被下放到 File::open()、Box::new() 这类普通函数。RAII 契约绑定的是作用域而非某个「构造时刻」:变量一诞生所有权就到位,析构路径由编译器保证。
拷贝语义
C++ 的拷贝由两个默认可生成的函数负责:拷贝构造与拷贝赋值——默认行为是逐成员浅拷贝,对裸指针就是指针值的复制,要深拷贝必须自己写。麻烦在于它发生得太自动:传参、返回、auto x = y,随手一处都在拷贝,写漏一个就是浅拷贝事故(三法则)。
Rust 反过来:默认不拷贝。
赋值、传参、返回一律视为移动(源值失效),要复制则必须在调用点显式写出 .clone()(可深拷贝,需实现 Clone)或依赖 Copy(按位复制)。
| 维度 | C++ | Rust |
|---|---|---|
| 默认行为 | 隐式生成浅拷贝(逐成员) | 默认移动,不拷贝 |
| 何时发生 | 传参、返回、初始化……隐式发生 | 只在显式 .clone() 或 Copy 处 |
| 深拷贝 | 自己写拷贝构造 | 实现 Clone |
| 类型检查 | 无——浅拷贝照样编译通过 | Copy 类型的字段必须全都 Copy |
于是得到一条很朴素的体验差异:拷贝在 Rust 代码里看得见,在 C++ 里则未必。
移动语义
C++11 引入移动构造,是为了消除多余深拷贝:把源对象的资源「偷」过来,源对象进入「已移动」状态——而标准只保证它「有效但未指定」,究竟能不能用、用了会怎样,全看类型作者怎么写。配套的坑还有一串:
std::move名字像动作,其实只是个 cast——真正搬东西的是移动构造;- 手写
std::move反而可能阻碍 RVO。
Rust 从第一天就假设所有类型可以安全地按位移动:「移动」=位拷贝 + 原地址失效,源值此后不可再用,且由编译期保证。因此没有 moved-from 状态的争议,按值返回也永远是高效的默认表达。
| 维度 | C++ | Rust |
|---|---|---|
| 怎么移动 | 移动构造(可自定义) | 位拷贝 + 原地址失效,零代码 |
| 触发方式 | std::move(cast)+ 匹配的移动构造 |
赋值、传参、返回本身就是移动 |
| 源对象状态 | 「有效但未指定」,行为由类型作者定义 | 编译期即失效,不可再用 |
| 搬迁优化 | C++26 补「可重定位语义」,资格判定极其复杂 | 默认如此,无额外规则 |
最后一行要解决的问题——允许对象搬迁而不调用移动构造,以及「可平凡重定位」的资格为何变得极其复杂——见前文《现代 C++ 跨平台开发 - 内存篇》的可重定位语义(C++26)。
返回值优化
C++ 的拷贝省略(copy elision / RVO)出发点是性能:构造函数是用户代码,返回大对象要经「构造局部 → 搬到返回值槽位 → 析构局部」两三趟构造。但省略会改变可观察行为——构造函数跑没跑,打日志、计数都看得见——所以标准必须表态:C++17 起 prvalue 返回强制省略(对象直接在调用方存储里构造,拷贝/移动构造连存在都不必),NRVO 仍只是「允许」,编译器不省时就得真移动一次。
Rust 没有构造函数,移动就是位拷贝、不调用任何用户代码,省略在语义上根本不可观察——没有可观察的目标,也就无所谓保证,纯性能问题,全交优化器(debug 构建或优化失败时多一次 memcpy 照样可能发生)。
| 维度 | C++ | Rust |
|---|---|---|
| 省略的出发点 | 性能——省掉返回时的多轮构造 | 性能——省掉多余的 memcpy |
| 为什么要语言表态 | 构造/析构是用户代码,省略可观察 | 移动不调用用户代码,省略不可观察 |
| 保证程度 | prvalue 强制省略;NRVO 仅「允许」,仍靠 QoI | 无保证,全交优化器 |
| 遗留缺口 | 可重定位语义(C++26)的资格判定极其复杂 | 堆分配不保证就地构造(Box::new() 可能经栈中转),地址稳定要 Pin |
可变性与 const
C++ 把「能不能改」写成类型限定符 const,代价是它一路传染:
- 传染性:
const T是类型的一部分,一个参数只读,调用链上的成员函数与返回值就得跟着const——「const 正确性」靠纪律维持,漏一处就编译不过; - 重载爆炸:成员函数的
const是「能否在 const 对象上调用」的限定符,同一段逻辑常要写 const / 非 const 两份; - 两个逃生舱:
const_cast绕过编译器(改真正的const对象即 UB),mutable在const方法里开后门——「只读」这个承诺从此不可信; - 同名的两件事:
constexpr(编译期可求值)与const(只读)常被混为一谈,只有前者是「回到编译期」的那条路。
Rust 把「能不能改」拆成两个更精确的问题,都不放在类型上:
- 绑定能不能改:
let默认不可变,要改就显式let mut——mut属于绑定不属于类型,因此不传染; - 借用能不能改:
&T只读、&mut T独占可写;成员函数不需要const限定符,&self/&mut self一句话说完; - 没有后门:安全代码里不存在
&T → &mut T,「const 对象」这个概念不成立;想在只读接口里改内部状态,必须显式换成Cell/RefCell,把「这里在偷偷改」写在类型上。
最后把 Rust 里几个容易混淆的概念放回原位:
| 概念 | 含义 | 对照 C++ |
|---|---|---|
const 项 |
编译期常量,默认内联、无固定地址 | constexpr 变量 |
const fn |
可在编译期求值的函数(见前文「编译期计算」) | constexpr / consteval 函数 |
static |
全局,有唯一地址、'static 生命周期 |
全局变量——但初始化顺序不再是坑 |
static mut |
可变全局,需要 unsafe;Rust 2024 起连取引用都禁止 |
可变全局变量 |
static mut 那一行值得补一句:C++ 的可变全局之所以危险,一半来自「谁先初始化谁后初始化」的未定义顺序,一半来自它天然是数据竞争源。Rust 把两件事一起劝退:
- 要共享可变,就写
Mutex或原子类型; - 要延迟初始化,就写
OnceLock/LazyLock——顺序问题在类型里显式解决。
this 指针
C++ 的成员函数有一个隐藏参数 this,它的类型由「写在哪张类里」决定——于是「const 与非 const 对象」「左值与右值对象」只能靠重载区分。绕开它有两条路,代价都不小:
- 老老实实写四份:
const/ 非const× 左值 / 右值,四种接收者各写一个重载,重复且容易漏改; - 退回 CRTP:基类模板拿派生类当参数,用编译期技巧绕开「
this类型无法推导」这个缺陷——代价是语法晦涩,基类里访问派生类的名字也不自然。
C++23 的 deducing this 把这件事拉回正轨——显式对象参数让 this 也成为可推导、可完美转发的参数,四份重载收敛成一份:
struct Widget {
int v;
// 一个函数覆盖 const / 非 const、左值 / 右值四种情形
template <typename Self>
auto&& value(this Self&& self) {
return std::forward<Self>(self).v;
}
};
对照 Rust,这件事的落差就出来了:可变性本来就是接收者类型的一部分,压根不存在重载爆炸,也不存在「const 版本忘了同步更新」这类问题——可变性不在函数签名的修饰符里,而在接收者的类型里,由借用检查器统一把关。
&self是不可变借用,&mut self是独占可变借用,self则消耗所有权(移动)——三种接收者从第一天起就是语言的一部分;- 需要「读」和「写」两种能力,就写两个方法,语义一目了然。
C++ 独有的坑
上面每一项,Rust 都能给出一个对应的机制。C++ 这一侧则多出一层编译器替你写规则的机制——你没写,它替你生成,你写了,它可能反而把别的悄悄删掉,五个特殊成员函数因此非正交,写一个可能牵连另一个。
详见前文《现代 C++ 跨平台开发 - 内存篇》的零法则与五法则。
Rust 这一侧没有这种牵连:类型默认只能移动,拷贝要显式 Clone / Copy,写一个也不会牵连另一个。
对于厌倦了五法则博弈的开发者,「显式优于隐式」本身就是一种解脱。
资源管理
对象的创建与销毁管的是「一个对象」的一生,再往上一层,是资源如何持有、如何共享、由谁释放的问题。
智能指针
Box、Rc、Arc、Cell、RefCell 看着都是「封装」,其实分两层,且两层正交、可自由拼装:
- 内存分配层:
Box/Rc/Arc——数据放在哪、谁负责释放;引用计数就是为了确定释放时机; - 访问控制层:
Cell/RefCell/Mutex/RwLock——共享条件下怎么安全读写。
flowchart LR
subgraph alloc["内存分配层:数据放哪、谁负责释放"]
box["Box(独占堆分配)"]
rc["Rc(单线程引用计数)"]
arc["Arc(多线程引用计数)"]
end
subgraph access["访问控制层:共享条件下怎么安全读写"]
cell["Cell(Copy 类型整体进出)"]
refc["RefCell(运行时借用检查)"]
mutex["Mutex / RwLock(跨线程互斥)"]
end
c1["<b>Rc<RefCell<T>></b><br/>单线程共享 + 可改"]
c2["<b>Rc<Cell<T>></b><br/>单线程 + 简单整存整取"]
c3["<b>Arc<Mutex<T>></b><br/>多线程共享 + 可改(RwLock 同理)"]
c4["<b>Box<T></b><br/>只堆分配,不需要访问控制层"]
rc --> c1
refc --> c1
rc --> c2
cell --> c2
arc --> c3
mutex --> c3
box --> c4
classDef combo stroke-width:2.5px
class c1,c2,c3,c4 combo
几个典型组合如下:
| 需求 | 内存分配层 | 访问控制层 | 组合形态 |
|---|---|---|---|
| 单线程、共享且需要修改 | 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:
Unpin是标记 trait,由编译器按字段自动推导——字段全是Unpin,类型就自动Unpin,被Pin包装也能安全取出;- 想「绝不移动」,就在结构体里加一个
PhantomPinned字段,破坏自动实现,Pin随即对它开启严格模式。
效果:用类型系统在编译期锁死地址,一行显式逻辑都不用写。
代价是这一切只能绕道指针:
- 只能包指针:
Pin<P>要求P: Deref,所以「这个类型不可移动」没法像 C++ 删掉移动构造那样写成类型自身的属性,只能换个指针类型来表达——Pin<&mut T>、Pin<Box<T>>、Pin<Arc<T>>; - 构造有门槛:
Pin::new只对Unpin生效,!Unpin的值只能用Box::pin()、栈上的pin!宏,或者unsafe { Pin::new_unchecked() }; - 取出有门槛:
Pin<&mut T>不给&mut T(给了就等于允许移动),除非T: Unpin——这就是「钉住」的全部机制。
PhantomData 属于同一族零成本标记,见前文「泛型与元编程 - 标记 trait」。
循环引用
双向链表和带环图是「两条赛道」理论的实战: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的带环结构。
这一章的三个话题——智能指针的工具箱、Pin 对地址的锁定、Weak 对循环引用的破解——都是同一个判断的投影:C++ 把正确性押在「程序员记得住规则」上,Rust 把它押在「编译器验证得了契约」上。
异步与并发
「并发」这个词底下其实压着两条正交的路:
- 一条是异步——等待时不占着线程;
- 另一条是多线程——真的并行跑起来。
两者的语言机制(闭包捕获、Send / Sync)与底层底座(原子操作、内存序)又互相共用。这一章就按这个顺序展开。
闭包
写 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 虚表」的延续。
递归闭包是这套机制留下的一个明确缺口——闭包是匿名结构体,无法在自身内部引用自己:
let fib = |n: u32| -> u64 {
if n < 2 { n as u64 } else { fib(n - 1) + fib(n - 2) }
// error[E0425]: cannot find function `fib` in this scope
};
C++23 的 deducing this(显式对象参数)让 lambda 直接拿到自己,零额外开销:
auto fib = [](this auto&& self, int n) -> long long {
return n < 2 ? n : self(n - 1) + self(n - 2); // fib(10) == 55
};
它取代的是 C++11/14 的老绕法——把 lambda 自己当参数传:
auto fib = [](auto&& self, int n) -> long long {
return n < 2 ? n : self(self, n - 1) + self(self, n - 2);
};
fib(fib, 10); // 每次调用都要多传一个 self
stable Rust 只有两条绕路,都不便宜。绕法一:用 Rc 打破自引用,代价是每层调用一次 borrow + 一次 clone + 一次 dyn 动态分发,而且这个 Rc 环本身会泄漏:
use std::{cell::RefCell, rc::Rc};
let slot: Rc<RefCell<Option<Rc<dyn Fn(u32) -> u64>>>> = Rc::new(RefCell::new(None));
let me = Rc::clone(&slot);
*slot.borrow_mut() = Some(Rc::new(move |n| {
if n < 2 { n as u64 } else {
let f = me.borrow().clone().unwrap(); // 每层:borrow + clone + dyn
f(n - 1) + f(n - 2)
}
}));
绕法二:放弃闭包,改写成显式类型——零开销,但它已经不是闭包了。stable 上连给自定义类型 impl Fn 都做不到,extern "rust-call" 属实验 ABI,直接报 E0658:
struct Fib;
impl Fib {
fn call(&self, n: u32) -> u64 {
if n < 2 { n as u64 } else { self.call(n - 1) + self.call(n - 2) }
}
}
Fib.call(10);
结论:稳定 Rust 里「闭包递归不是零开销」是事实,不是写法问题——这正是约束式泛型在表达力上付的那笔账。
异步:把等待编译成状态机
异步的本质可以用一句话说清:不阻塞当前执行流,完事了通知你。至于通知之后谁去推进、在哪个线程推进,那是调度策略的事——所以异步不等于多线程,两者是正交的。这一层分工在后文「跨语言调用 - 异步的归属」里会被用到极致。
Rust 的 async 语法正是这句话的忠实实现:async fn 不会立刻执行函数体,而是返回一个实现了 Future 的值。
async fn fetch() -> String {
"data".to_string() // 函数体在此刻并不执行
}
async fn caller() {
let fut = fetch(); // 到这一行为止,什么都没发生
let s = fut.await; // 现在才开始推进
}
两条性质决定了它的形态:
- 惰性:
Future不poll就不推进——这也是「Rust 必须配执行器」的根本原因,语言只提供「可被推进的东西」,不提供「谁来推进」; - 编译期状态机:每个
.await是一个挂起点,整个函数体被编译器展开成一个状态机(每个挂起点对应一个状态,局部变量按需搬进状态里)。它的大小在编译期确定,默认不涉及堆分配,也没有虚调用——只有递归的异步函数、或需要dyn Future时才用Box::pin把它搬到堆上。
前文留下的两个伏笔,到这里也对上了:
- 为什么
Future::poll的签名是Pin<&mut Self>——状态机持有跨.await的局部变量引用,是自引用结构,地址不能变(见前文「资源管理 - 自引用与 Pin」); - 为什么
#[tokio::main]会出现在「宏系统」的属性宏示例里——它的作用正是把async fn main包成一个同步入口,并在里面启动执行器。
和 C++20 协程对照,差别不在语法,而在「谁提供什么」:
| 维度 | Rust | C++20 协程 |
|---|---|---|
| 语法入口 | async fn / async {} / .await |
co_await / co_yield / co_return |
| 返回类型 | 统一为 Future trait |
由自定义的 promise_type 决定,语言不管 |
| 使用门槛 | 会写 async / await 即可 |
要实现 promise_type,再加上 await_ready / await_suspend / await_resume 一整套可等待协议 |
| 内存分配 | 状态机编译期定大小,默认不分配 | 由 promise_type::operator new 决定,可以省略 |
| 执行器 | 语言不管,生态提供(Tokio 等) | 语言不管,标准一度也没有——std::execution 要到 C++26 才纳入,且 std::future 至今不能 co_await |
一句话概括这个分工:C++ 把协程做成一套需要自己组装的零件,Rust 把它做成一门语法加一个生态。
更高层的方案其实试过:std::async(C++11)就是「丢一个任务出去、拿一个 std::future 回来」,但卡在三处设计保留上,没能成为主流:
- 启动策略不确定:默认的
std::launch::async | std::launch::deferred由实现自行选择,同一段代码是「立刻开线程」还是「调用get()时才同步执行」,你说了不算; - 没有 continuation:
std::future不提供then(),「完成了接着做下一件事」只能阻塞在get()上,或者自己拿std::promise手工接线,链式组合无从谈起; - 一次性且析构语义分裂:
get()只能调用一次,调用后valid()失效;而且同一个类型的析构行为取决于它从哪来——std::async策略下产生的那份会阻塞等待任务结束,从std::promise得到的那份则不会。
于是 C++20 补了协程语法,却没同时补 Future 与执行器,std::execution(sender/receiver)要到 C++26 才纳入标准——在此之前,标准只给零件,不给装配图。
不过 Rust 侧也有账单,这是 C++ 老兵最容易踩的三个坑:
- 异步会传染:
async fn只能在async上下文里.await,于是「是不是异步」一路传到调用链顶端;- 同步代码想进来只能
block_on,而在 runtime 里再block_on会直接 panic。 - 这一点 C++20 协程同样逃不掉,算共同成本;
- 异步里不能阻塞:
std::fs::read、std::thread::sleep这类同步调用会把执行器线程整个堵住,后面排队的任务一起遭殃;- 要么换异步版本,要么交给
spawn_blocking丢进阻塞线程池;
Send不是免费的:tokio::spawn要求Future: Send + 'static,而只要异步块里跨.await持有一个Rc<T>,整个Future就不再是Send——编译期会被拦下,但报错往往指向一个你没想到的局部变量。- 解法是换
Arc,或者退回单线程运行时与LocalSet。 - 这是 Rust 多出来的一层传染:
Send会沿着整条 futures 链往上要求。
线程
异步之外,还有最朴素的一条并发路径:直接开线程。
两边的入场形式看起来差不多——C++ 的 std::thread 配 lambda,Rust 的 std::thread::spawn 配闭包(并且要求闭包与返回值满足 Send + 'static)——但归宿规则正好相反:
| 维度 | C++ | Rust |
|---|---|---|
| 创建 | std::thread t(f) |
let h = std::thread::spawn(f) |
| 句柄被丢弃时 | std::terminate:析构前若既没 join 也没 detach,直接终止进程 |
静默 detach:JoinHandle 一丢,线程继续跑到结束 |
| 取回返回值 | 要自己搭 promise / 共享变量 |
h.join() 直接拿到 Result<T, _> |
| 取消 | C++20 std::jthread + std::stop_token,协作式取消,析构时自动 request_stop() 再 join() |
标准库没有等价的取消 API |
这张表里藏着两个方向相反的坑:
- C++ 逼你明确「谁来收尾」:忘了
join就是一瞬间的进程终止,这是新手的经典翻车点; - Rust 允许你撒手不管:丢掉
JoinHandle不会报错,但那个线程会一直跑到结束,而只有join()才能拿到的失败结果也随之无从观察——「能丢掉」不等于「该丢掉」。
而「取消」这一项,C++ 反而走到了前面:std::jthread 的 stop_token 给出语言层面的协作式取消协议,Rust 标准库的 JoinHandle 至今没有 cancel(),要停下来只能自己想办法:
- 用
AtomicBool或 channel 自己约定一个「该收工了」的信号; - 或者依赖框架提供的机制:Tokio 的
select!、tokio::task::AbortHandle,以及tokio_util::sync::CancellationToken(结构化取消)。
Rust 这一侧还有一件 C++ 完全对不上的工具——作用域线程:
let data = vec![1, 2, 3];
std::thread::scope(|s| {
s.spawn(|| println!("{:?}", &data)); // 借引用即可,不必 move
s.spawn(|| println!("{:?}", &data));
}); // 作用域结束前,派生的线程全部自动 join
scope 保证「作用域内派生的线程一定在作用域结束前被 join」,所以这些线程可以借用外部数据,既不用 move,也不必裹 Arc。这恰好是前文「Arc 为何会变成手动 GC」的一味解药:很多时候你需要的并不是共享所有权,只是一个「借用到任务结束为止」的保证——代价是你必须等它们全部收工。
C++ 想表达同样的意图,只能靠程序员自己保证「join 一定发生在数据销毁之前」,编译器完全不检查。Rust 这边则是两条泾渭分明的路,选错任一条都在编译期被拦下:
- 交出所有权(
move):把数据搬进线程,此后谁也不能再碰; - 借用(
scope):把线程关进作用域,借用到全部收工为止。
三个「起任务」的入口别混用:
| 入口 | 起的是什么 | 适用 |
|---|---|---|
std::thread::spawn |
真正的 OS 线程 | CPU 密集、阻塞任务 |
tokio::spawn |
异步任务,跑在 worker 线程池上(不是 OS 线程) | IO 密集的异步逻辑 |
tokio::task::spawn_blocking |
阻塞线程池里的一个任务 | 同步 IO、第三方阻塞库 |
异步调度
异步的价值在于用协作式调度换掉线程切换与锁的开销,current_thread 上多任务交替推进,代价是任何一处阻塞都会拖垮整条流水线。语言只给 Future、怎么推进由运行时决定,而 current_thread / multi_thread 是 Runtime 的两种调度模式,spawn_blocking 是任务提交 API,与两者正交:
| 维度 | current_thread |
multi_thread(默认) |
|---|---|---|
| 线程数 | 1 | worker 数默认取 TOKIO_WORKER_THREADS,否则 available_parallelism();另配独立阻塞线程池 |
| 并行能力 | 只有并发,没有并行 | 可利用多核 |
| 调度开销 | 极低(无锁) | 较低(有窃取与同步) |
| 一处阻塞 | 整条事件循环停摆 | 只卡住当前 worker,但阻塞任务一多,线程池仍会被耗尽 |
| 适用场景 | CLI、测试、WASM、低 QPS 服务 | Web 服务、微服务、高并发网关 |
multi_thread 本质是 M:N 调度器(大量 Task 映射到少量 OS 线程):
- 两级队列:每个 worker 一个本地队列(容量默认 256、无锁,owner 从队首取),加一个全局注入队列(MPMC,收外部提交与本地溢出)。取任务顺序是:本地 → 全局 → 窃取别人;
- 工作窃取:本地空了就从对端队首批量取约一半(
n - n/2); - LIFO slot:每个 worker 另有单元素槽位,存放「下一个要 poll 的任务」,不可被其他 worker 窃取——刚被唤醒的热任务因此优先执行;官方只说这可能降低整体吞吐;
- 任务预算(coop):任务预算初始 128,库里的 yield point 逐次扣减,扣光时相关 API 返回
Pending让出;
Runtime 的组件关系:
flowchart TD
GQ["Global / 注入队列(MPMC)<br/>外部提交 / 本地溢出"]
subgraph S["Scheduler:worker 线程 + 本地队列 + LIFO slot"]
W0["Worker 0"]
W1["Worker 1"]
W0 <-->|"work stealing:取约一半"| W1
end
subgraph D["Driver"]
IO["IO Driver:mio → epoll / kqueue / IOCP"]
TM["Time Driver:分层时间轮(6 层 × 64 槽,1 ms 分辨率)"]
RQ["Ready Queue"]
IO --> RQ
TM --> RQ
end
BP["Blocking Thread Pool:spawn_blocking"]
GQ --> W0
GQ --> W1
RQ -->|"唤醒的 Task"| W0
RQ -->|"唤醒的 Task"| W1
- IO Driver 隔着 mio:Tokio 不直接调
epoll/kqueue/IOCP,而是经 mio 的统一Poll接口落到各平台——这与 Node.js 的 libuv、Python asyncio 的 selector 是同一个位置。Rust 标准库只有同步的std::net,不封装事件驱动接口; - 一轮 turn 的动作:
poll()等事件(默认边缘触发,应用层必须一次读完可用数据)→ 按事件Token找到注册项、更新就绪状态 → 调Waker::wake()把任务推进 Ready Queue → 处理到期 timer。I/O 与定时器由 Driver 统一驱动,构成「IO + 时间」双源(park 超时具体怎么算属实现细节,随版本变化); - io_uring 目前仍是实验路径(
tokio-uring),默认后端不变。
阻塞与卸载:
spawn_blocking把阻塞工作丢进独立的阻塞线程池:异步任务在.await处挂起、不占 worker,闭包在池里跑完再唤醒任务。池上限默认 512(max_blocking_threads可调),空闲线程约 10 秒回收(thread_keep_alive);提交队列没有背压,任务堆太多会一直涨;- 它的边界要记清:能拿返回值;已经启动的阻塞任务无法取消,只能等它结束;池满则排队;即使在
current_thread下也会另起线程,不会把单线程运行时变成纯单线程; - 短时阻塞可用
block_in_place(仅multi_thread),长时间不.await的循环要主动插yield_now().await。
| 场景 | 做法 |
|---|---|
| 网络、数据库、消息队列 | 异步 API + tokio::spawn |
| 同步文件 IO、同步第三方库 | spawn_blocking |
| CPU 密集计算 | spawn_blocking 或 rayon(数据并行) |
| 短时阻塞、不想换线程 | multi_thread 下 block_in_place |
长时间不 .await 的循环 |
插 yield_now().await |
- 容器里建议显式设
worker_threads(n):默认值走available_parallelism(),它会考虑 CPU 亲和性与 cgroup 配额,但在沙箱、VM 里可能测不准。
调度还有另一半——任务起来之后,怎么等、怎么取消、怎么批量管:
select!:多个 future 同时轮询,谁先就绪就执行谁,其余分支被 drop(取消)。所有分支在同一个任务内并发,不是并行。适合超时控制、取消信号、多路消息接收;- 代价是取消安全要自己保证:分支被 drop 时若只做了一半(写了一半、拿了锁),就是副作用;
JoinSet:管理动态数量的已spawn任务。任务独立调度(multi_thread下真并行),join_next()按完成顺序返回结果;JoinSet被 drop 时未完成的任务自动 abort,不会泄漏。
选型:
| 工具 | 语义 | 场景 |
|---|---|---|
join! / try_join! |
等全部完成(try_join! 遇错短路) |
少量固定 future,零 spawn 开销 |
select! |
等任意一个完成,其余取消 | 超时、取消、多路选择 |
JoinSet |
管理动态数量的已 spawn 任务 | 批量并发、按完成顺序处理 |
futures::future::join_all |
等同质 future 集合全部完成 | 同质批量等待 |
futures::stream::StreamExt::buffer_unordered |
有界并发度的流式处理 | 大量任务需要限流 |
tokio::time::timeout语义上等价于select!+sleep;实现是专门的Timeout组合子(内部持一个Sleep)。
两种语言异步调度的对照,收到这里:
- Rust
async/await与 C++20 协程都只是语义层(无栈协程 / 状态机),标准都不绑定调度器;差别在标准化程度——Rust 的Future/Waker/Context/Pin已是稳定抽象,C++ 的std::execution(sender/receiver)是 C++26 才纳入的任务图与调度抽象,不等于一个现成运行时; - 按 C++ 生态类比,Tokio 更像 asio + 线程池 + epoll/kqueue reactor + 调度器打包在一起,而不是单独的
std::executor; - 本节参数以 Tokio 1.53 为准,调度器与 Driver 的内部实现随版本变化。
数据竞争
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 是「事前预防 + 编译强制」。
从锁到消息
数据竞争再往上走一层是架构级话题。最原始的方案是多线程直接对共享资源加读写锁,问题在于锁的复杂度是「业务级」的:哪些读、哪些写、怎么互斥全要自己维护,稍有差池就是死锁或竞争。
改成「单一所有者 + 请求队列」后复杂度质变:
- 资源由所有者独占维护(天然无竞争),使用者通过队列发请求、订阅回调——即 Go 那句「不要通过共享内存来通信,而要通过通信来共享内存」,一种 Actor 变体;
- 锁随之降级为「工具锁」,只存在于队列内部,业务代码无锁;
- 再进一步是
Disruptor式的无锁环形队列,用 CAS + 内存屏障连队列锁都省掉。
这套模型与 Rust 是天然契合的:
mpsc::channel传数据靠的就是所有权转移,tx.send把值move进通道后,发送方再也无法触碰;move闭包把变量所有权搬进新线程(等价于 C++ 的[x = std::move(x)]lambda),再配合Send/Sync的编译期检查,「通过通信共享内存」不只是架构约定,而是被类型系统强制的纪律。
C++ 里同样的架构只能依赖程序员自觉——数据放进队列后「记得」不要再访问。
原子操作与内存序
前面说的无锁环形队列,靠的就是 CAS 与内存屏障——而这些手段在语言层面的对应物,恰好是 C++ 与 Rust 对应最整齐的一层:原子操作与内存序。C++ 的 std::atomic 与 memory_order 枚举,逐项对得上 Rust 的 std::sync::atomic 与 Ordering:
C++ memory_order |
Rust Ordering |
语义 |
|---|---|---|
relaxed |
Relaxed |
只保证原子性,不约束重排 |
acquire |
Acquire |
读端:之后的读写不能上移到它之前 |
release |
Release |
写端:之前的读写不能下移到它之后 |
acq_rel |
AcqRel |
读-改-写操作(如 fetch_add)同时具备两者 |
seq_cst |
SeqCst |
全局单一次序,最严格 |
差别只在两处用法细节:
- C++ 的原子操作缺省即
seq_cst(最严格),Rust 则要求每次调用都显式写出Ordering——「用哪种语义」被强制留在每个调用点上; - Rust 的原子类型(如
AtomicUsize)不在安全接口上暴露内部值的普通引用(&T/&mut T),读写只能走原子 API——C++ 里「用普通指针绕过原子保护」防不胜防,Rust 里编译期就不成立,想绕过必须显式写unsafe。
volatile 的对应关系则不是关键字对关键字:Rust 没有 volatile 关键字,它被降级为指针上的两个方法——read_volatile() 与 write_volatile()。语义边界也因此更清晰:volatile 只保证「每次访问真实发生、不被优化掉或合并」,面向硬件寄存器、MMIO 这类有副作用的内存,不提供原子性,也不建立线程间同步。
C++ 里拿 volatile 当同步原语是经典误用——Rust 干脆不给它关键字地位,从源头杜绝这种混淆。
同一套内存模型,两种表达方式:C++ 提供缺省值、把其余选项留给文档,Rust 没有缺省可言——每个选择都必须落在调用点上。
硬件层为何需要这套秩序——Store Buffer、Invalidate Queue 与缓存一致性协议——以及 volatile 在 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 |
|---|---|---|
| 可见性 | 显式,函数签名必须声明 | 隐式,签名通常不体现 |
| 性能开销 | 零开销(条件判断 + 返回值) | 快乐路径快,展开慢;异常元数据(.eh_frame / LSDA)与 landing pad 带来额外体积与展开成本 |
| 传播方式 | 线性,像数据流 | 跳跃,像传送门 |
| 错误组合 | From 汇聚多种错误为一个枚举 |
异常类继承树 |
现代语言的趋势也印证了这一判断:Go 的 if err != nil、TypeScript 社区的 Result 模式,都在回归「错误即值」。
C++ 自己也引入 C++23 的 std::expected<T, E>,几乎就是 Result 的翻版——它为「错误即值」补上了 C++ 一直缺的那块:
- 相比
errno这类错误码:类型安全,无法被悄悄忽略; - 相比
std::optional:能携带具体的错误类型,而不是只有「有 / 无」; - 相比层层
if-else:and_then/or_else这类组合子让错误传播保持线性; - 正常路径不涉及栈展开,性能接近直接返回结构体。
社区里有种说法:「有了 std::expected,终于不用异常了」——这个判断经不起推敲,两个理由几乎立刻浮现:
- 构造函数场景无解:构造过程没有返回值可用,失败只能抛异常,这是语言层面的硬约束,
std::expected救不了——Rust 在这里走的是另一条路,见前文「构造失败」一节; - 其他场景本来就能用返回值:错误码、成功标志一直是可行方案。
std::expected的价值是让这条路更好走、更安全,而不是发明了新机制。
还有两个容易被忽略的细节:
std::expected不捕获异常:函数内部调用std::stoi、std::filesystem这类会抛的 API 时,异常照样穿透出去,要转成unexpected必须自己try-catch——异常生态密集的地方,它反而增加适配成本;- 委员会的态度是「协作而非对立」:异常适合「真正意外、需跨层级传播、依赖 RAII 保障资源」的场景,
std::expected适合可预期的高频失败;Google、LLVM 禁用异常各有工程背景(存量代码库、体积与实时约束),不代表异常本身被淘汰。
它的两难于是落在历史包袱上:
- 旧代码依赖异常;
- 异常又无法跨越 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后.eh_frame与 landing pad 一并消失,二进制随之变小,幅度取决于代码里需要展开的路径有多少(官方没有给出通用数字,值得在自己产物上实测)。
分野在「边界纪律」上。
- C++ 的异常跨越 FFI 边界是「最佳实践层面的禁忌」;
- Rust 则是编译器强制——
extern "C"函数里 panic 逃逸会直接熔断,想优雅跨越必须显式catch_unwind拦截,或声明extern "C-unwind"允许展开,像一道海关安检。
两条出口各长这样:
// use std::panic::{catch_unwind, AssertUnwindSafe};
// 出口一:在边界内侧接住,转成错误码
pub extern "C" fn parse(state: *mut i32) -> i32 {
let state = unsafe { &mut *state };
match catch_unwind(AssertUnwindSafe(|| do_parse(state))) {
Ok(code) => code,
Err(_) => -1,
}
}
AssertUnwindSafe 不是装饰:捕获 &mut 的闭包默认不满足 UnwindSafe——编译器原话是「may not be safely transferred across an unwind boundary」。这个包装等于签字确认:展开会留下什么状态,我认账。
// 出口二:声明允许展开穿过边界
pub extern "C-unwind" fn trampoline() { panic!("boom") }
pub extern "C" fn outer() -> i32 {
match catch_unwind(|| trampoline()) { // 接住它的仍必须是 Rust
Ok(()) => 0,
Err(_) => -1,
}
}
C-unwind 只保证「展开穿过边界」是定义良好的行为,不保证对面接得住:按 RFC,异常必须由它出身的那门语言捕获——Rust 的 panic 能穿过中间的 C++ 帧(需带 -fexceptions 编译),最终仍要由 Rust 的 catch_unwind 收住。
对「Rust 被外部宿主调用」的形态来说,panic 只有三种收场方式:
| 策略 | 行为 | 代价 |
|---|---|---|
panic = "abort" |
panic! 当场终止:一条信息、不展开、无中间态 |
catch_unwind 失效;panic 路径上的 Drop 不再执行(正常作用域结束的析构不受影响) |
默认 unwind + 在边界内侧 catch_unwind |
panic 在 Rust 一侧被拦下,转成错误码或状态返回宿主 | 需要一套统包所有入口的机制,漏掉一个就退化成下一行 |
默认 unwind + 什么都不做 |
先照常展开,沿途 Drop 跑掉一半,到边界再触发一次 panic in a function that cannot unwind,然后 abort |
展开开销、半途的清理状态,宿主依然接不住 |
最后一行既是默认值,也是三者中最糟的一个——而它的行为刚被明确下来:
- Rust 1.71 落地
C-unwind系列 ABI,同时挑明非-unwindABI 上的展开仍是未定义行为; - Rust 1.81 补上最后一刀——非
-unwindABI(如"C")遇到逃逸的 panic 直接 abort,把一个长期存在的健全性(soundness)问题彻底关掉。
上表容易读出一个错觉:把三行当成「三档成本」,于是第一行成了「最省事的第二行」。其实第一行与第三行的结局相同——宿主进程都保不住,区别只在第三行会先白白展开一半。
而「跑掉一半的 Drop 不算挽回」这个判断虽然成立,却把重点引偏了:
三行里只有第二行能让进程活下来。
换句话说,第一、三行对宿主都等于崩溃,唯有在边界内侧拦下 panic,宿主才拿得回一个可处置的返回值。所以第二行与第三行之争,争的不是「省下多少 Drop」,而是要不要给出「调用可能失败,但进程不死」这条保证。
而第一行也不该被看作这条路上的一个取舍项——它与第二行要达成的效果互斥:panic = "abort" 恰恰让 catch_unwind 失效,一旦把 abort 写死,第二行就再也无法实现。这也意味着,这个开关决定的是库对外承诺的形状,而不只是体积优化:
- 承诺「panic 一律转成错误码返回宿主」,abort 就不能开;
- 接受「panic 即进程终止」,abort 可以用——此时
catch_unwind已不会生效,写了也只会造成「已接住」的错觉。
需要澄清的是,第二行的代价并不是「每个入口靠自觉去记得」。它完全可以被机械化:一个宏统包住所有 extern "C" 入口,再配一条把 panic 消息送出边界的通道,剩下要人工维护的只是一条「新入口也用这个宏」的规范——而它比「记得别漏写 catch_unwind」容易检查得多。这套守卫落到代码里是什么形状,见后文「跨语言调用 - Rust/C 跨语言边界的 panic 最佳实践」一节。
和 C++ 的 -fno-exceptions 对比,两个开关拆的不是同一样东西:
- C++ 关掉的是错误处理模型本身——异常没了,
Result式的返回路径也没补上,只能退回错误码; - Rust 关掉的是 unwind 机制:
catch_unwind失效、panic 路径上的Drop不再执行、子线程 panic 会连带整个进程终止;但Result/?是纯值传递,不过栈展开,错误处理路径原样保留。
析构中的异常
「析构不能抛异常」是 C++ 和 Objective-C 共同的老规矩:
- C++ 里析构函数默认是隐式的
noexcept,真抛出来通常直接std::terminate; - OC 的
dealloc连报错渠道都没有。
Rust 的规矩更窄,也更硬——正常路径上 Drop 里 panic 只是一次普通 panic(exit 101,还能被 catch_unwind 接住),但在展开过程中再 panic,进程直接 abort。
原因还是那套展开机制:panic 沿栈回溯时要执行沿途每个局部变量的 Drop,如果某个 Drop 自己又 panic,运行时就同时面对两个 panic——只能终止进程。官方文档的措辞同样限定在展开中:Drop 在线程已经 panic 时 panic,进程会 abort。
三者的「强制力」差距因此相当明显:
| 维度 | C++ 析构 | OC dealloc |
Rust Drop |
|---|---|---|---|
| 能否抛异常 / panic | 能,但展开中再抛即 std::terminate |
能,但危险 | 能;展开中再 panic 才强制 abort |
| 语言约束 | 无,靠 noexcept 等约定与自觉 |
无,靠自觉 | 无语言约束,展开中再 panic 由运行时强制终止 |
| 最佳实践 | 只做释放,并标记 noexcept |
只做释放 | 只做「尽力而为」的清理,别 panic |
- C++ 与 OC 是「你可以抛,后果自负」;
- Rust 把最危险的那条路——展开中再 panic——从建议升级成了运行时强制。
那「清理真的会失败」怎么办?社区形成了三条共识:
- 提供显式的
close()/destroy():标准库File就是这个路子——Drop会关闭描述符但忽略关闭时的 I/O 错误,要拿到错误必须显式调用sync_all()。同理,需要报告失败的清理动作统一写成fn close(mut self) -> io::Result<()>,把「尽力而为的兜底」与「必须被处理的收尾」分成两条路; - 用
std::thread::panicking()分流:判断当前是否已经处在展开中——是则只记录日志(操作系统会帮忙收尾),否则才 panic 暴露问题; - 交给
try_drop这类 crate:提供TryDroptrait,允许清理逻辑返回Result,再由统一策略决定是打日志、替换还是忽略。
社区一直有人呼吁给标准库加一个 FallibleDrop,但始终没有落地,原因是哲学上的:Drop 的语义是隐式、自动、必定执行,而错误处理要求显式、强制、可被传播——两者放在一起,必然有一方要妥协。
所以 Rust 的选择是把「清理必须成功」这件事显式化:真需要,就提供 close() 让调用方负责,Drop 只当最后一道「尽力而为」的安全网。
崩溃监控
跨平台 crash 监控绕不过 Unix signal:
SIGSEGV回调里,std::mutex、条件变量甚至内存分配都是禁区——POSIX 只允许调用「异步信号安全」函数,C++ 大部分标准库组件不在白名单;- handler 可以插在任意指令边界,打断主线程的堆操作会导致二次破坏;
- 想靠栈展开从 handler 里逃逸,没有任何 ABI 支持,属于彻底的 UB。
C++ 迟迟不给 signal 做现代化抽象,不是疏漏:RAII 依赖确定性,而 signal 是「任意时刻的打断」,二者根本冲突。社区共识是把它降级为「通知机制」:
flowchart TD
sig["信号到达(SIGSEGV / SIGINT …)<br/>可能打断任意一条指令"]
subgraph safe["handler:异步信号安全区"]
hnd["只做两件事:<br/>置 atomic 标志 / self-pipe 写管道"]
ban["禁区:锁、内存分配、<br/>大部分标准库"]
hnd -.->|"不得触碰"| ban
end
pipe["管道 / 标志<br/>仅传递「有事发生」"]
subgraph normal["正常上下文:事件循环"]
el["读取通知后接手完整逻辑:<br/>收集堆栈、写日志、上报、退出"]
end
sig --> hnd
hnd --> pipe
pipe --> el
Rust 给出的是分层答案:
- 硬件级崩溃(
SIGSEGV/SIGILL):面对同样的物理定律,同样需要unsafe+ 原子标志/管道,handler 里不能分配、不能锁; - 优雅退出(
SIGINT/SIGTERM):享受现代抽象——Tokio 把信号转成异步Stream直接.await;rustix用强类型枚举和Result替代裸整数宏; - 语言级 panic:
std::panic::set_hook注册自定义 hook,处于正常执行上下文,可以用全部标准库收集堆栈、写日志。
一句话概括 Rust 在系统边界上的态度:不是只能 unsafe + C pipe,而是把危险限制在真正需要它的最小范围——C++ 的安全网由工具与纪律编织,Rust 的安全网由类型系统与分层策略编织。
质量保障
写完的代码靠什么兜底:静态分析、形式化验证,以及一个值得咀嚼的结论。
静态分析
C++ 的静态分析生态是分层互补的:
- Infer(Meta 开源):基于抽象释义与分离逻辑做过程间分析——能跨函数甚至跨文件追踪指针生命周期,误报率控制得好,背后有 POPL 级别的论文支撑;
- clang-tidy:基于 LLVM 的完整 AST,擅长编码规范、性能优化与现代 C++ 实践;
- Cppcheck:轻量、无需编译、扫描快。
所以「Infer 是升级版 clang-tidy」的说法并不成立——它们更像是各有所长的互补品,工业界的用法是组合拳:
- Cppcheck 日常扫描;
- clang-tidy 统一风格;
- Infer 定期做深度内存安全体检。
Rust 生态里:
形式化验证
Creusot 走的是演绎验证路线:用 #[requires](前置条件)、#[ensures](后置条件)把契约写进代码,工具将 Rust 代码翻译成 Why3ML 中间语言,再交给 Z3、Alt-Ergo 等 SMT 求解器生成并求解数学命题——证明通过,就相当于拿到「这个函数永远满足契约」的数学保证。
它的挂接方式很取巧:不魔改 rustc,而是用同名包装器替换编译器驱动(经 rustc_driver 接口),HIR 阶段提取契约、MIR 阶段提取执行逻辑,翻译成数学命题交给求解器,证明失败就像编译报错一样中止。
整条验证流水线如下:
flowchart TD
src["Rust 源码<br/>#[requires] 前置 / #[ensures] 后置"]
drv["同名包装器替换 rustc 驱动<br/>(经 rustc_driver 接口「搭便车」)"]
hir["HIR 阶段:提取契约"]
mir["MIR 阶段:提取执行逻辑"]
why3["翻译为 Why3ML 中间语言<br/>生成数学命题"]
smt["Z3 / Alt-Ergo 等 SMT 求解器<br/>求解命题"]
ok["证明通过<br/>拿到「永远满足契约」的数学保证"]
err["证明失败<br/>像编译报错一样直接中止"]
src --> drv
drv --> hir
drv --> mir
hir --> why3
mir --> why3
why3 --> smt
smt --> ok
smt --> err
初看 #[requires]/#[ensures] 很像 gtest 断言,但这个类比恰好点破了本质差异:
- gtest 是「考试」——运行时跑几个用例,测过 n=1、n=2 不代表 n=9999 成立;
- Creusot 是「数学归纳法」——不跑数据,直接对全体输入推公式。
一个覆盖你想到的场景,一个框死所有可能。
C++ 一侧的形式化工具则是「商业神器 + 学术组合拳」的格局:
- Polyspace Code Prover 用抽象释义给每行代码染色(绿色证明安全、红色证明必错、橙色无法证明),是航空、汽车领域的标配;
- 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 的所有权系统本质上是把分离逻辑的一个子集编码进了类型系统——编译器自动做「证明」,程序员不需要写规约。代价是表达力受限(有些安全的代码写不出来,需要
unsafe),好处是零成本、零注解、全自动。所以 Rust 可以看作是形式化验证在工业界的一次成功降维打击——不要求完全证明,只保证内存安全和数据竞争自由,但做到了让普通程序员无感使用。
Infer 用分离逻辑去「分析」C++ 代码,而 Rust 的借用检查器本质上在做同一件事——分离逻辑的核心「独占性」,恰好对应 &mut T 的排他规则。区别在于传统形式化工具要求程序员手动写规约,而 Rust 把这套逻辑固化成了类型规则,编译期自动证明,证明失败就直接报错。
这笔交易的代价在表达力:带环结构、复杂指针操作,编译器推不出就写不出来,补偿是给这类场景留了 unsafe 这个受控逃生舱——「这里我接管,用人脑保证安全」。
它不追求「证明一切」,只保证内存安全与数据竞争自由,也正因如此,「让普通程序员在不知不觉中写出高可靠性代码」才成为可能。
这解释了为什么云计算、区块链、浏览器引擎等高安全领域在纷纷从 C++ 转向 Rust——不是 Rust 更优雅,而是它的正确性证明是「免费附赠」的。
跨模块边界
现代软件的一个进程,往往由多个独立编译、独立发布的模块拼成:.dll、.so、.dylib 之间隔着一道常被忽视的边界——ABI。C++ 的许多默认前提,跨过它就集体失效。
C++ 跨模块的坑
最具代表性的是这几类问题:
dynamic_cast失效:同一个类在不同模块中会各自生成一份type_info(符号未合并时),而dynamic_cast依赖的正是type_info的比较——地址对不上,匹配就失败;- 单例不再「单」:头文件里的
static单例在两个动态库里就是两个对象——Windows 每个 DLL 有独立数据段,Linux 靠符号插入通常能合并,但-fvisibility=hidden或dlopen不带RTLD_GLOBAL时会退化为各持一份; - 跨堆释放:模块链接的运行时库(CRT)版本不同,堆也彼此独立——A 模块
new、B 模块delete,直接损坏堆元数据;STL 容器跨模块传递是它的延伸,allocator 绑定到错误的堆,容器内部布局还可能随 STL 版本漂移; - 异常跨模块传播:unwind 信息与异常类型不兼容时,轻则捕获不到,重则直接
std::terminate; - 虚表与模板:编译选项或版本不一致会导致虚表布局错位;模板实例在每个模块中各生成一份,其
static成员随之「分身」; - 初始化顺序:跨编译单元的全局对象构造顺序本就未定义,跨模块进一步不可控——而析构顺序反转,就是 use-after-free。
把这串清单压成一句:根因都是跨模块的符号、数据与资源隔离,而 C++ 有太多东西默认「全进程唯一」。防御手段只有两条:
- 统一编译环境——编译器版本、运行时库、编译选项全部对齐;
- 边界降维——跨模块只暴露 C 风格接口或纯虚接口,C++ 对象、异常与模板实例不跨界。
一个反直觉的细节:dynamic_cast 比较的确实是 type_info:
- Itanium C++ ABI 把它规约为
__dynamic_cast,实参是两个type_info指针; - libstdc++ 的相等判断是「先比指针、不同再比名称」(2000 年起名称比较为默认兜底),MSVC 则直接比名称字符串。
所以「跨模块失败=type_info 地址不同」并不完整:名称比较只解决「认出类型」,解决不了继承图数据不可达、指针偏移失配与 ABI 不一致。
Rust 的跨模块问题
Rust 没有继承也没有 dynamic_cast,但在模块边界前的处境并不特殊,只是问题换了一副形状:
- 类型身份换成了编译期哈希:
- 最接近
dynamic_cast的是Any::downcast,底层靠TypeId——编译期算出的整数哈希,同一次编译内全进程唯一、零运行时开销; - 但这份稳定跨不出编译单元:官方文档明确哈希值可能随版本变化,两个模块若由不同版本的工具链编译,同一个类型的
TypeId就可能不同,downcast随之失败。 - 分布式与插件场景的常见对策,是用过程宏生成写死的稳定 ID 来替代
TypeId;
- 最接近
- trait 对象依赖创建侧的虚表:
dyn Trait的虚表是「具体类型 × trait」的扁平表,不携带类型身份——转换关系(含 supertrait 向上)都由编译期固化,跨独立编译的模块重新解释 trait 对象没有依据;
- 动态库没有稳定 ABI:
- Rust 生态默认把一切静态编进单个二进制,平时根本碰不到这些边界;一旦引入动态加载(插件、
dylib),宿主与插件就必须锁在同一版本的 rustc 上——Rust 至今没有稳定的动态库 ABI,这正是各类方案最终都要退回 C ABI 的直接原因。
- Rust 生态默认把一切静态编进单个二进制,平时根本碰不到这些边界;一旦引入动态加载(插件、
出路与 C++ 殊途同归——把边界降维成稳定的 ABI 形状:跨模块接口用 #[repr(C)] 结构体加函数指针表(手动虚表),生态里还有 abi_stable 这类 crate,为跨版本、跨动态库的 trait 对象提供受检的稳定 ABI。
所以「跨模块」面前,两种语言并不对立:C++ 的导出宏、统一 CRT 是在缝补边界,Rust 的 #[repr(C)] 与稳定 ABI 封装是在给边界定型——一个修补既有对象模型的裂缝,一个索性不带对象模型过境。底线相同:ABI 是边界上的通用语言,跨模块协作的前提,是双方对「对象的字节形状」先有共识。
编译与链接配置
C++ 那边一条 -ffunction-sections -fdata-sections -Wl,--gc-sections 的活,Rust 拆成四层:profile、LTO、链接器回收、符号可见性。
profile 先解决大部分问题:
[profile.release]
opt-level = "z" # 体积优先(官方提示常比 "s" 更大,需实测);性能优先改 3
lto = true # 跨 crate 死代码消除 + 内联(thin 编译快;大项目里 fat 未必更快)
codegen-units = 1 # 全局优化更充分,编译更慢
panic = "abort" # 去掉展开所需的 landing pad 与清理路径(.eh_frame 通常仍保留)
strip = true
与 GCC / Clang 参数的对应:
| GCC / Clang / 链接器 | Rust 侧 |
|---|---|
-ffunction-sections / -fdata-sections |
默认已开启(每个函数、每份数据各占一节);要关掉用 -Zfunction-sections=n(nightly,一个开关同时管代码节与数据节,没有独立的 data-sections 开关) |
-Wl,--gc-sections |
-Clink-args=-Wl,--gc-sections(开了 LTO 后收益变小,但对 C/C++ 静态库与 FFI 产物仍有用) |
-fvisibility=hidden |
-Zdefault-visibility=hidden(nightly,unstable book 的口径就是「roughly equivalent to Clang 的 -fvisibility=hidden」,且只对 shared object 生效) |
| 更强的跨 crate 死代码消除 | lto = "thin" / "fat" |
pub不等于导出:Rust 默认不把符号放进动态符号表,pub只管 crate 内可见性;要对外的是 FFI 导出项,必须显式写#[unsafe(no_mangle)]或#[export_name];- strip 的粒度:
strip = "debuginfo"只去掉调试信息、保留符号表(崩溃符号化还要用它);strip = true(Cargo 文档明确等价于"symbols")连符号表一起去——ELF 上动态库必需的.dynsym由链接器保留,所以 cdylib 的导出符号不受影响(这条是 ELF 语义,rustc 只是透传--strip-all;官方另提醒剥符号会让崩溃/剖析符号化失效); - 链接器层还能再调:
-Clink-arg=-fuse-ld=mold换链接器、-Clink-args=-Wl,--build-id=none去掉 ELF build-id、--target x86_64-unknown-linux-musl静态链接 musl(该目标是默认静态的例外)。注意link-args不是”原样”透传:cc-driver 目标上 rustc 会把它包成-Wl,…,跨平台时先用cargo build -vv看最终命令行。
按目标的推荐组合:
| 目标 | 配置 |
|---|---|
| 最小体积 | opt-level="z" + lto=true + codegen-units=1 + panic="abort" + strip=true |
| 最高性能 | opt-level=3 + lto="fat" + codegen-units=1 |
| cdylib / FFI | panic="abort" + strip="debuginfo"(留符号表便于排查)+ 显式 #[unsafe(no_mangle)] 导出 |
panic = "abort"有跨 crate 同策略约束:crate 图里任一个用 abort,最终产物就必须同样用 abort;tests / bench、build script 与 proc-macro 会忽略该设置——cdylib / FFI 产物要特别留意这条。
动态加载与符号导出
- Rust 标准库没有
dlopen/dlsym,只提供 FFI 原语(extern "C"、#[link]、#[unsafe(no_mangle)]、*const c_void、core::ffi/std::ffi),加载器要自己接(std::os::raw已过时,官方指向core::ffi); - 常规做法是用
libloading:它把 Linux/macOS 的dlopen/dlsym与 Windows 的LoadLibrary/GetProcAddress统一成一套接口,并用Symbol<'lib, T>把符号生命周期绑到Library上(库一卸载,符号即失效),错误走Result而非dlerror():
use libloading::{Library, Symbol};
let lib = Library::new("./libmy.so")?;
let add: Symbol<unsafe extern "C" fn(i32, i32) -> i32> =
unsafe { lib.get(b"add")? };
let r = unsafe { add(1, 2) };
- 想把符号批量加载进结构体做插件式 API,可以用
dlopen2;要纯 Rust 的 ELF 加载器(no_std目标、运行时符号替换,提供dlsym_next/dlsym_default),看dlopen-rs; - 也可以直接调
libc::dlopen/libc::dlsym,但那基本是 C 的直译:unsafe面积更大,dlerror()、生命周期、线程安全都得自己管,一般只在写底层加载器时才这么干; unsafe省不掉:函数签名、ABI、生命周期都是运行时才知道的,编译期无从验证。能做的只是把unsafe收到调用那一行,外面用Result、生命周期与封装结构兜住。
跨语言调用
跨语言调用是工程里的常态:存量模块是 C++,新模块用 Rust,两边共存于同一个进程,必须能互相调用。而无论哪种语言组合,接口最终都要降维到 C ABI 的形状,再在上面搭胶水。
胶水层怎么搭,从 C++ 的传统做法说起。
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 源码文件。
flowchart TD
src["Rust 源码<br/>#[uniffi::export] 过程宏标注"]
udl["接口声明文件 .udl<br/>(另一种声明方式)"]
lib["编译产物:动态库<br/>元数据嵌入专用 section"]
bg["uniffi-bindgen generate<br/>读出元数据,构建 Component Interface(IR)"]
bind["模板渲染<br/>Kotlin / Swift / Python 绑定源码"]
src -->|"编译期提取签名,序列化嵌入"| lib
lib -->|"加载动态库"| bg
udl -.->|"跳过动态库,直接解析"| bg
bg -->|"套用目标语言模板"| bind
| 环节 | C++ 方案 | Rust UniFFI |
|---|---|---|
| 接口发现 | 外部工具(libclang)解析源码 AST | 过程宏在编译期提取,嵌入二进制 |
| 中间表示 | 脚本层自行构建 | Component Interface(内置) |
| 代码生成 | 脚本 + 模板 | uniffi-bindgen 内置模板引擎 |
| 外部依赖 | libclang + Python/脚本环境 | 接口提取与脚手架生成零外部依赖;目标语言侧另有依赖(Kotlin 需 JNA 与 kotlinx-coroutines) |
根因在于编译期能力:
- C++ 只能借助外部工具「看」代码——宏只是文本替换,模板虽图灵完备却无法「反射」自身结构,C++26 静态反射尚未落地;
- Rust 的过程宏天然运行在编译期,能拿到
TokenStream做结构化分析——宏本身就是那个「分析器」。
这也是 UniFFI「零配置」的来源:全程内嵌在编译流程里,不需要额外的解析器或脚本环境。
社区基于这套 IR 又长出了 React Native、Kotlin Multiplatform、Dart、C#、Go 等绑定生成器——第三方方案能蓬勃生长,恰恰证明中间表示设计得足够通用:接入新语言只需要写一个新模板,Rust 侧的接口提取与 FFI 脚手架完全复用。
官方文档对这些第三方绑定有一句提醒:它们往往要求更旧的 UniFFI 版本,部分特性可能缺失甚至完全未实现——生产环境选用之前,先核对版本与特性覆盖。
异步的归属
异步是跨语言调用里最容易想当然的一环: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释放资源。
flowchart TD
start["启动:外部调用 Rust 导出的 async 函数<br/>拿到 RustFuture 句柄(此时什么都不执行)"]
poll["轮询:外部事件循环调用 rust_future_poll<br/>并把一个回调交给 Rust"]
q{"Rust 推进的结果"}
notify["回调触发:通知外部「就绪了」"]
pend["Pending:回调存为 Waker<br/>等待 Future 真正就绪"]
done["完成:外部收到回调后<br/>调用 rust_future_complete 取走结果"]
free["清理:rust_future_free 释放资源"]
start --> poll
poll --> q
q -->|"Ready"| notify
q -->|"Pending"| pend
pend -->|"就绪时触发 Waker"| notify
notify --> done
done --> free
所以 UniFFI 在异步里扮演的是状态容器而非调度器:持有句柄、管理 Waker 的注册与触发、维护 Pending/Ready/Cancelled 状态机、提供 poll/complete/free 三个操作口——但不做任何调度决策,不决定何时 poll、在哪个线程 poll,也不维护事件循环。管数据不管调度,管状态不管时机。
这也印证了前文的判断:异步不等于后台线程或协程——异步只管「完事了通知你」,谁去执行、在哪个线程执行,是调度策略的事。
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 兼容的(指针、整数、回调函数指针)——这一层没有新魔法,只是被自动化了;
- 加载方式因语言而异:
- Swift:直接链接并调用符号;
- Python / Ruby:分别用 ctypes 与 Fiddle,本质都是
dlopen; - Kotlin:用的是 JNA 而不是 JNI(官方文档原话「UniFFI relies on JNA for the ability to call native methods」);生成的
UniffiLib通过Native.register(...)绑定这些extern "C"符号,且已从接口代理换成 direct mapping,不再每次调用都走反射;
- 元数据的作用:中间表示描述的不是 ABI 本身,而是类型语义——「这个不透明指针其实是
Arc<MyStruct>」「这个u32在 Kotlin 侧应映射为UInt」。
「免胶水」的边界要划清:
- 下行(宿主调 Rust):由 UniFFI 生成的 FFI 函数承担,不用手写;
- 上行(Rust 回调 Kotlin/Java):仍要手写 JNI——官方文档给出的正是
#[unsafe(export_name = "Java_com_pkg_Class")]配System.loadLibrary加external fun的写法; - 代价的根源:Rust 线程回调 Java 时若不先
AttachCurrentThread,JNA 每次调用都要替你把线程 attach/detach 一遍,文档自己称之为「a heavy operation」; - 实现细节:回调用的是
com.sun.jna.Callback,这条路径远比下行调用昂贵。
分工可以这样记:
- C ABI 负责「怎么传」(传输协议),中间表示负责「传的是什么」(类型语义);
- HTTP 与 OpenAPI/Swagger 的关系可作类比:HTTP 只管字节怎么传,Swagger 描述每个接口传什么字段、什么类型;
- UniFFI 就是那个读 Swagger 定义、自动生成双端代码与 SDK 的工具,只不过定义用 UDL 或过程宏来写。
还有一个适用面的问题要交代:
- 官方目标语言:Kotlin、Swift、Python(外加已进入维护模式的 Ruby)——都是自带运行时、能承接回调与调度的宿主语言;
- C 头文件:它不生成,所以 C/C++ 宿主无法直接消费;
- 对本章开头场景的结论:那个「存量 C++ + 新 Rust 模块同进程」的通用场景,UniFFI 帮不上忙,那里依然是手写扁平 C 层。
把这条边界落到「谁来写胶水」上,可以整理成一张矩阵:
| 宿主 | 手写扁平 C 层 | UniFFI |
|---|---|---|
| C / C++ | 唯一选择 | 不适用(不产出 C 头文件) |
| Kotlin / Swift / Python | 可行,但手工量最大 | 推荐,下行免胶水 |
| Kotlin 的 Rust → Java 回调 | 必须手写 | 仍要手写 JNI |
| C# / Go / Dart / React Native | 可行 | 有第三方绑定,版本落后于主线 |
Rust/C 跨语言边界的 panic 最佳实践
上面这些方案各有适用范围,但「存量 C++ + 新 Rust 模块同进程」这个通用场景,最终仍然落回手写扁平 C 层。用 DynRS 做个样本——一个以 staticlib 形式嵌进宿主进程的库。
对外契约的形状先定死:每个导出的 extern "C" 函数都返回一个状态码,结果全部经由 out 参数回传。前面「枚举与代数数据类型」一节说过「让非法状态无法表示」,到了 C ABI 上就得换一种表达——C 的类型系统里没有 Result,于是状态与数据分居两处,状态是返回值,数据是出参。
接着是最容易被漏掉的一条纪律:panic 不能穿过边界。DynRS 把这件事收进一个宏:
#[macro_export]
macro_rules! ffi_return {
($($body:tt)*) => {
match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(
move || -> _ { $($body)* },
)) {
Ok(value) => value,
Err(payload) => {
$crate::report_panic(&payload);
<_ as $crate::FfiPanicValue>::FFI_PANIC_VALUE
}
}
};
}
于是每个入口只剩「写业务逻辑」这一件事:
#[unsafe(no_mangle)]
pub extern "C" fn ngenrs_kv_read_int(
store: *mut c_void,
key: *const c_char,
out: *mut i64,
) -> DynrsStatus {
ffi_return! {
// ……读参数、调 core、写 out 参数
DynrsStatus::Ok
}
}
三处设计值得记下:
AssertUnwindSafe在这里不是偷懒:闭包move走了捕获,而 panic 之后&mut局部变量留下的中间状态本来就无人使用——宿主拿到的是错误码,不会再把那个句柄当有效值用。这与「panic 与 C++ 异常」一节说的「签字认账」是同一笔交易,只是这里由宏统一签字;- 返回值的放弃值由类型自己申报:
FfiPanicValue这个关联常量把「这个返回类型 panic 时该给什么」交给类型自己声明(指针归null、状态码归Panicked),宏不必知道调用方返回的是什么——「让非法状态无法表示」的思路在此又补了一刀; - panic 消息要另开一条通道:返回给宿主的错误码只能说「panic 了」,说不清为什么。所以库里另外登记一个回调,
ffi_return!捕获到 panic 之后把消息交给它(没人登记就退回stderr)——守卫负责捕获,通道负责解释,两件事分开。这条通道只传消息;要拿位置与堆栈信息,走的是「崩溃监控」一节那条路。
有个细节容易踩:投递消息的那段代码自己也要包在守卫里。它是在 catch_unwind 已经完成展开、恢复到正常控制流之后才执行的——此刻若再 panic,就没有第二道网了,它会直接逃出 extern "C" 函数。所以 DynRS 连登记通道的那个函数本身也套了 ffi_return!:用来兜底 panic 的代码,自己不能是 panic 的来源。
这套东西也顺带说明了另一件事:「每个 FFI 入口都要记得包一层」并不像听起来那么靠自觉。有了宏,人工只维护一条「新入口也用这个宏」的规范,而漏写宏在代码审查里是看得见的——catch_unwind 散落在各处则会隐蔽得多。
这也补上最后一块拼图:前文 panic 一节提到,extern "C" 是两种语言交汇的海关,而在跨语言调用的全景里,C ABI 则是共同地基——无论手写还是自动生成,Rust 与外部世界的每一句话都在这块地基上说。
Rust 的优势不在魔法,而在类型与所有权语义足够规整,让语言无关的契约好设计、好实现——再加上社区有人真的去做了。
两种语言最真实的关系,不发生在对比文章里,而发生在同一个进程的 C ABI 边界上:那里没有对立,只有分工。
尾声
范式差异:两种语言的出发点正好相反。
- C++:信任程序员、允许下注,再拿工具(Sanitizer、TSan、Infer、Polyspace)兜底;
- Rust:不信任程序员、先证明再放行,再用
unsafe与工具链(Miri、clippy、Creusot)补表达力与严谨度。
代价意识:每个安全保证背后都有账单。
| 特性 | 代价 |
|---|---|
| 移动语义(赋值、传参、返回默认按位移动) | 源值一移动即失效(C++ 的 moved-from 至少还能用),复制必须显式 Clone |
| 借用检查(同一时刻只能一写或全读) | 表达力受限——链表、带环结构得绕道 Rc<RefCell> 或 unsafe |
| 约束式泛型(能力先声明、后使用) | 稳定版没有特化/偏特化——specialization(RFC 1210)与 min_specialization 都还在 nightly,C++ 那种「针对具体类型换一份零成本实现」写不出来 |
| 生命周期标注(引用有效期写进签名) | 多个引用参数 + 返回引用时,必须手写 'a 指明谁与返回值绑定 |
Rc / Arc 共享所有权 |
不可互通,选错一次就是全链路重构Arc 无条件付原子开销 |
Cell / RefCell 内部可变性 |
要写进类型,并沿调用点传染 都不是 Sync,跨线程必须改换 Mutex / RwLock借用检查降级到运行时—— RefCell 的计数开销与违规 panic |
| 闭包(自动捕获推断、匿名类型不可自引用) | - 捕获方式不必手写,但跨作用域必须 move- 递归闭包不是零开销:要么 Rc<RefCell<dyn Fn>>(每层 borrow + clone + 动态分发,引用环还会泄漏),要么改写成显式类型 |
Pin 钉住地址(自引用结构) |
「不可移动」不能直接写成类型属性,只能借指针表达——Pin<Box<T>> / Pin<&mut T>,还得靠 PhantomPinned 破坏 Unpin |
async / await 编译期状态机 |
状态机是自引用结构,轮询必须靠 Pin<&mut Self>Send 还沿调用链传染 |
unsafe 隔离不安全代码 |
「用人脑保证安全」的责任 |