穿越 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 的区别正是别名假设的具象化:

前提一旦用错,行为即未定义。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:

  • Drop trait:定义「值被销毁时做什么」的接口,由编译器在作用域结束时自动调用;
  • 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 优雅处理),且零长度切片的指针也保证非空、已对齐;
  • 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++ 模板:核心能力都是「编译期代码生成与计算」。区别在实现路径:
    • 模板是用一套内嵌的 DSL(特化、递归、SFINAE)在编译器内部编程;
    • 过程宏则是在编译期运行的、用 Rust 写的普通程序——接收 TokenStream,借 syn 解析成 AST,处理后用 quote 生成代码返回,全程用开发者熟悉的 Rust 逻辑操作。

两者对「代码膨胀」的处理因此不同:

  • 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++ 的对象模型是「继承驱动的补丁堆」,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:不写的析构

拆开看,「析构」其实是两层:

  • Drop trait:你可选实现的用户逻辑,只在需要「插入」时才出现;
  • 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&lt;RefCell&lt;T&gt;&gt;</b><br/>单线程共享 + 可改"]
  c2["<b>Rc&lt;Cell&lt;T&gt;&gt;</b><br/>单线程 + 简单整存整取"]
  c3["<b>Arc&lt;Mutex&lt;T&gt;&gt;</b><br/>多线程共享 + 可改(RwLock 同理)"]
  c4["<b>Box&lt;T&gt;</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,同时挑明非 -unwind ABI 上的展开仍是未定义行为;
  • Rust 1.81 补上最后一刀——非 -unwind ABI(如 "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:提供 TryDrop trait,允许清理逻辑返回 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 生态里:

  • clippy 承担 lint 与反模式检查;
  • MIRAI 基于符号执行做深度分析——后者的定位更接近 Infer;
  • 论「严谨」,还要看 Creusot 这一类工具。

形式化验证

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 的直接原因。

出路与 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 隔离不安全代码 「用人脑保证安全」的责任