如何从C++迁移代码到Rust?

文章导读
从C++迁移到Rust是我自己做过几轮的事情,坦白说这不是一个“重写即得利”的过程。早期我踩过不少坑,比如把整个模块一次性用Rust重写然后跑不起来,或者迁移后性能反而变差。下面是我在实践中总结的几个关键判断点,希望能帮你少走一些弯路。
📋 目录
  1. 先摸清C++代码的“风险区”再动手
  2. 渐进替换:每个模块留一个FFI垫片
  3. 异常处理:C++ throw 和 Rust Result 的桥接
  4. 内存模型迁移:指针到引用,但别忽略生命周期
  5. 测试验证:从单元到混合场景
  6. 性能基准:release 模式对比,别被 debug 坑
A A

从C++迁移到Rust是我自己做过几轮的事情,坦白说这不是一个“重写即得利”的过程。早期我踩过不少坑,比如把整个模块一次性用Rust重写然后跑不起来,或者迁移后性能反而变差。下面是我在实践中总结的几个关键判断点,希望能帮你少走一些弯路。

在开始迁移前,应优先评估现有C++代码的模块依赖关系与所有权模型。对于内部逻辑独立、无复杂继承或模板元编程的模块,可以优先迁移;反之,若模块深度依赖第三方C++库(如Boost.Python)或频繁使用指针算术与手动内存管理,则建议先封装接口再逐步替换。一个实用的检查方法是:在C++代码中标记所有调用了`malloc`/`free`或`new`/`delete`的函数,这些通常是迁移时需重点重写的风险点。

推荐采用自底向上的渐进式迁移:先从与外部系统交互最少的底层工具函数或数据结构开始,在Rust中实现并保持原有C++接口的ABI兼容性。每替换一个模块后,都应在C++侧保留一个FFI调用垫片,确保老代码能通过`extern "C"`函数直接调用Rust实现。这种做法的关键风险在于严格区分所有权——必须明确规定Rust侧分配的内存由谁释放,避免在C++侧误用`delete`导致未定义行为。

先摸清C++代码的“风险区”再动手

在开始迁移前,应优先评估现有C++代码的模块依赖关系与所有权模型。对于内部逻辑独立、无复杂继承或模板元编程的模块,可以优先迁移;反之,若模块深度依赖第三方C++库(如Boost.Python)或频繁使用指针算术与手动内存管理,则建议先封装接口再逐步替换。一个实用的检查方法是:在C++代码中标记所有调用了malloc/freenew/delete的函数,这些通常是迁移时需重点重写的风险点。

我通常会在项目根目录下跑一个grep,把包含mallocfreenewdelete的行筛出来,然后逐类分析。如果某个源文件里这类调用超过20处,我会先考虑把这个模块的接口封装成C风格的函数,用extern "C"暴露,再去Rust侧逐步实现。这样做的好处是,即使Rust实现还没写完,C++侧的构建和测试也不会中断。

渐进替换:每个模块留一个FFI垫片

推荐采用自底向上的渐进式迁移:先从与外部系统交互最少的底层工具函数或数据结构开始,在Rust中实现并保持原有C++接口的ABI兼容性。每替换一个模块后,都应在C++侧保留一个FFI调用垫片,确保老代码能通过extern "C"函数直接调用Rust实现。这种做法的关键风险在于严格区分所有权——必须明确规定Rust侧分配的内存由谁释放,避免在C++侧误用delete导致未定义行为。

举个例子,假如我要迁移一个字符串处理函数foo(const char* input),我会先在Rust中写一个#[no_mangle] extern "C" fn foo(input: *const c_char) -> *mut c_char,然后在C++里把原来的实现替换成调用这个Rust函数。注意,返回值如果是Rust分配的Box,要保证C++侧用相应的释放函数(比如free_foo_result)来回收,不能直接用free。我一般会在Rust里暴露一个对应的extern "C" fn foo_result_free(p: *mut c_char),并在文档里明确写清楚。

如何从C++迁移代码到Rust?

每替换完一个模块,我都会跑一遍原来C++的单元测试,确保输出一致。如果测试不通过,先查FFI边界的数据对齐和生命周期标注是否正确,而不是急着改业务逻辑。

异常处理:C++ throw 和 Rust Result 的桥接

C++异常机制在Rust中不存在直接对应,迁移时需将可能抛出异常的C++函数封装为返回Result的Rust函数。具体做法是在C++端用catch(...)捕获所有异常,通过setjmp或错误码方式传递至Rust侧。一个常见坑是C++析构函数中的异常——若迁移后的Rust代码通过FFI调用C++对象,必须确保C++析构函数不抛出异常(通常应标记noexcept),否则会导致Rust侧panic或未定义行为。

我处理过的一个实际案例:原来的C++函数int process(Data* d)可能抛出std::runtime_error,我在C++侧把它包装成:

extern "C" int process_c(Data* d, char** err_msg) {
    try {
        return process(d);
    } catch (const std::exception& e) {
        *err_msg = strdup(e.what());
        return -1;
    } catch (...) {
        *err_msg = strdup("unknown error");
        return -1;
    }
}

然后在Rust侧通过std::ffi::CStr读取错误信息,包装成Result。同时我检查了所有涉及C++对象的Rust结构体,确保它们的Drop实现中调用的C++析构函数是noexcept的。如果不确定,我会在C++头文件里手动加上noexcept声明,并review析构函数内部是否有throw。

内存模型迁移:指针到引用,但别忽略生命周期

将C++指针引用迁移至Rust时,最关键的转换动作是将原始指针替换为引用或智能指针类型。例如,C++中常出现的std::unique_ptr应对应Rust的Box,而std::shared_ptr则对应ArcRc。但需注意,若C++代码中频繁使用void*传递不透明句柄,则应在Rust中通过NonNullstd::ffi::c_void配合生命周期标注来保证安全,同时必须在文档中显式声明内存分配器归属。

如何从C++迁移代码到Rust?

我遇到过最头疼的情况是C++里用std::shared_ptr传递所有权,而Foo内部又有std::weak_ptr循环引用。在Rust里改用ArcWeak后,需要仔细处理循环引用导致的无法析构问题。我的做法是先画出所有权图,确认哪些是强引用、哪些是弱引用,然后在Rust代码中用Weak打破循环。记得在upgrade()时始终处理None的情况,不要假设weak_ptr.lock()一定成功。

测试验证:从单元到混合场景

每迁移一组功能后,我不仅会运行原来的C++测试,还会在Rust里单独写#[cfg(test)]模块,用assert_eq!assert!(...)覆盖边界条件。对于FFI调用混合场景,我会写一个集成测试,在Rust中调用C++包装函数,再反过来从C++调用Rust函数,比较中间结果。如果项目中原来有模糊测试或压力测试,我会把它们也迁移为Rust端的#[test],并在cargo test中一并执行。

一个容易忽略的点:线上偶发崩溃。如果Rust侧因为unwrap()slice::from_raw_parts导致panic,整个进程会直接终止。我通常在Rust的FFI入口函数中用std::panic::catch_unwind包裹,将panic信息转换为错误码返回给C++,同时在日志中记录堆栈。这样即使Rust代码有bug,也不至于拖垮整个服务。

性能基准:release 模式对比,别被 debug 坑

迁移完成后不要直接假定Rust更优,而应针对关键路径(如热点循环、I/O操作)建立基准测试。建议在相同硬件、相同编译优化级别(如C++的-O2与Rust的--release)下对比,并记录最小、最大及百分位延迟。注意Rust默认的栈溢出检查(-C overflow-checks=on)在调试模式下会显著影响性能,因此线上对比务必使用release构建。若发现某处性能退化,优先检查是否因过度使用cloneArc导致,必要时可退回到unsafe代码以获取与C++等价的底层控制。

我的一次亲身经历:迁移一个内存拷贝的热点函数后,Rust版本比C++慢了30%(调试模式下甚至慢3倍)。排查发现是因为我用了Vec::from_raw_parts但没有正确设置容量,导致每次写入都触发重新分配。改成Vec::with_capacity并预分配后,性能才回到同一水平。所以不要盲目相信“Rust应该更快”,要亲手跑基准并观察分配统计。

如果回滚需要边界,我会在C++侧保留旧的实现,通过条件编译宏(比如#ifdef USE_RUST)切换,这样一旦线上出问题可以立即切回。回滚后要分析Rust侧的失败原因,通常出在FFI内存管理或生命周期标注上,很少是业务逻辑本身的问题。