返回列表

2026年7月12日 · 约 10 分钟读完

Rust 学习笔记:安全、性能与代价

从 C/C++ 扩展技能的角度,梳理 Rust 的所有权、零成本抽象、Cargo 与学习成本。

Rust 开始出现在更多地方

最近几年,我经常看到 Rust 出现在基础组件和框架的重构消息里。以前大家聊系统编程,默认还是 C/C++;现在 Rust 已经很难绕开了。

截至 2026 年 7 月,Rust 第一次进入了 TIOBE Index 前十,排在第 10 位。排名不能决定技术选型,但这个变化很直观:Rust 已经不只是“看起来很酷的新语言”,而是真的进入了更多团队的候选列表。

我选择学习 Rust,原因很朴素:想给 C/C++ 技能栈补一块拼图。我不太关心它会不会取代 C++。真正吸引我的,是它把不少原本只能靠开发经验规避的问题,直接写进了语言规则和编译器检查里。

第一个绕不过去的坎:所有权

写 Rust 时,最难受的往往不是语法,而是很多在别的语言里顺手就写出来的代码,在 Rust 里会被拒绝。

let name = String::from("Rust");
let moved_name = name;

// println!("{name}"); // 编译不过:name 的所有权已经移动
println!("{moved_name}");

在 C++ 里,我会先看这是拷贝、引用还是 std::move;在 Java、Dart 这类语言里,变量之间大多是对象引用。Rust 则把问题摆在眼前:这个 String 现在归谁?原来的 name 还拿什么去用?

看到这种报错时很容易觉得烦。只是赋个值而已,为什么编译器管这么多?但它管的正是资源到底由谁释放。namemoved_name 不会同时以为自己拥有同一块内存,也就少了一类重复释放和悬空指针的入口。

用 C++ 的 std::unique_ptr 写同一类“所有权转移”也很清楚:

auto name = std::make_unique<std::string>("Rust");
auto moved_name = std::move(name);

std::cout << name->size(); // 能编译;但 name 此时为空,解引用是未定义行为。

这里 C++ 已经把资源所有权交给 moved_name 了,只是类型系统不会阻止我继续解引用 nameunique_ptr 本身是很好的 RAII 工具,问题在于这一步仍要靠开发者记住:move 以后原对象不能再按原来的方式使用。Rust 直接把这条约定变成编译错误。

借用规则更容易让人卡住:同一时刻,要么有多个只读引用,要么只有一个可变引用。

let mut title = String::from("Rust");
let editing = &mut title;

editing.push_str(" Book");
// editing 还在使用时,不能同时再借用 title。

这套规则写业务代码时确实会带来摩擦,特别是数据要跨函数、异步任务或线程流动时。有些实现没法靠“把引用传下去”解决,必须回头重新划分数据的归属和任务边界。

这也是我认可 Rust 的地方。以前写 C/C++,很多资源问题要靠约定、review、ASan、TSan 和测试去抓;Rust 把其中一部分提前到了编译期。代码还没跑,编译器已经在问:这里有没有悬空引用?有没有共享可变状态?这个值离开作用域以后还活着吗?

并发时的差别会更明显。下面这段 C++ 看起来很常见,但两个线程同时写 counter 会产生数据竞争:

int counter = 0;

std::thread first([&] { ++counter; });
std::thread second([&] { ++counter; });

first.join();
second.join();

Rust 不能把一份普通的可变数据直接塞进两个线程。要共享,就得把同步手段写出来:

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let worker_counter = Arc::clone(&counter);

let worker = thread::spawn(move || {
    *worker_counter.lock().unwrap() += 1;
});

*counter.lock().unwrap() += 1;
worker.join().unwrap();

这段 Rust 不是“自动解决并发”。Mutex 仍然可能用错,锁的粒度和死锁也还是工程问题。但 Arc 表达跨线程共享所有权,Mutex 表达可变访问需要互斥;少了其中任何一个,代码就过不了编译。对比靠约定保护共享变量,这种强制性会让并发边界更显眼。

当然,Rust 不是写上就绝对安全。unsafe、FFI、死锁、逻辑错误和资源耗尽都还在。但日常的 safe Rust 代码有这层约束以后,内存问题不再完全依赖开发者“记得小心”。

Rust 编译器在所有权和借用关系进入运行时前拦住冲突

性能不是靠硬写底层代码换来的

Rust 能和 C/C++ 放在一起讨论性能,首先是因为它没有 GC。对象生命周期大多可以在编译期确定,资源离开作用域就释放,不需要像 Java、Go、Dart 那样由运行时扫描和回收。

Swift 和 Objective-C 的 ARC 是另一种做法,通过引用计数管理对象生命周期,工程体验很好,但频繁 retain/release 也有成本。不同语言都有自己的适用场景,我不觉得应该只拿“有没有 GC”给语言排高低;只是 Rust 的资源管理方式,对延迟敏感、内存受限的场景确实很有吸引力。

它还有一个经常被提到的词:零成本抽象。迭代器、闭包、泛型这些东西用起来比手写循环舒服得多,但不代表一定会变慢。Rust 的主流工具链会通过 LLVM 后端做内联、单态化和死代码消除,很多高层写法最后仍能落到接近手写代码的机器码。

例如,下面这段 Rust 写的是过滤、转换再求和:

let total: u64 = values
    .iter()
    .filter(|&&value| value % 2 == 0)
    .map(|&value| u64::from(value) * u64::from(value))
    .sum();

在 C++ 里,很多人会直接写成循环:

std::uint64_t total = 0;

for (const auto value : values) {
    if (value % 2 == 0) {
        const auto number = static_cast<std::uint64_t>(value);
        total += number * number;
    }
}

两种写法表达的计算相同。优化构建下,Rust 的迭代器链通常会被展开和融合,不需要为了避免中间集合或虚调用,先把代码降回手写循环。当然,最终还是应该用 benchmark 和 profile 判断,不该只看语言宣传页。

这点很对我的胃口。想写得清楚时,不必一开始就回到满屏指针和模板;碰到热点时,也还能继续往下看汇编、内存布局和实际的 profile。性能问题还是要测,算法、I/O、锁竞争和数据库往往比语言本身更关键,但 Rust 至少没有先把一个很重的运行时成本放在这里。

Cargo 让我少花了很多时间配环境

比起所有权,Cargo 的好处很容易感受到。

C/C++ 的依赖管理一直是我觉得比较痛苦的部分。CMake、vcpkg 都能用,也在持续进步,但每个项目的组合都不太一样。库是源码、静态库还是动态库?include path 和 link path 怎么配?换到 arm64、x86_64 或 CI 上会不会又出问题?很多时候业务代码还没开始写,环境已经折腾了半天。

Rust 默认就给了一套完整路径:Cargo.toml 管依赖,Cargo.lock 锁版本,创建目录、拉 crate、测试、构建 release 都是同一个入口。

cargo new rust-notes
cd rust-notes
cargo add serde
cargo test
cargo build --release

依赖声明也不需要把包管理和构建脚本拆成两套心智模型:

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }

同一个 C++ 依赖在常见的 vcpkg + CMake 组合里,至少会落到 manifest 和构建脚本两处:

// vcpkg.json
{
  "dependencies": ["fmt"]
}
# CMakeLists.txt
find_package(fmt CONFIG REQUIRED)
target_link_libraries(app PRIVATE fmt::fmt)

这不是说 CMake 或 vcpkg 做不到。它们在真实 C++ 工程里很常见,也能处理复杂场景。只是 Cargo 默认把“依赖是什么、怎么构建、怎么测试”放在同一套工作流中,项目刚起步时少很多选择题。

遇到系统库、交叉编译或原生绑定时,Cargo 也不会变魔法,环境问题还是会出现。但至少一个纯 Rust 项目从零跑起来的体验很顺,不需要先在 CMake、包管理器和 IDE 配置之间来回切换。

对比之下,C++ 的历史包袱更明显。C++26 继续在演进,但真实项目里 C++11、甚至更早的标准依然很常见。模块、ABI、第三方库的编译选项和平台差异,也让大工程集成时需要格外谨慎。Cargo 的统一感在这时候就很突出。

严格的编译器,和真实的学习成本

Rust 编译器很严格,这个吐槽一点都不冤。

改一行代码以后,可能跟着冒出来借用冲突、生命周期不够长、trait 没实现、线程不能安全传递等一串错误。很多时候会陷在“我知道我要什么,为什么它就是不让我写”的状态。所有权和生命周期也不是看一遍概念就能真的理解,只能边写边撞。

但 Rust 的错误信息通常很有用。它会标出哪一次借用还没有结束、哪一个值已经被移动、可以在哪个地方 clone,或者建议把数据改成引用。它不会替我决定架构,但经常能把问题定位到真正冲突的那两三行代码。

并发场景尤其能体会到这个差别。C++ 当然可以把并发代码写对,但数据竞争往往靠开发者自己保证。Rust 会用类型和借用关系限制一部分错误路径:不能安全跨线程的数据,不会让你轻易送进线程。为了通过检查,有时得加锁,有时得换消息传递,有时干脆要把数据结构重写。这些都不是白拿的。

另一个现实成本是编译时间。Rust 编译器要做类型检查、借用检查和优化,大项目的 build 时间并不会轻松。学习曲线和编译速度,是这门语言两笔明确的成本。

Flutter 和 Rust 的组合

Rust 在系统编程、基础设施和性能敏感的核心模块里发展得很快。GUI、跨平台桌面和完整 Web 产品的生态则还要看具体需求,不能因为排行榜进了前十,就把所有项目都翻新一遍。

Flutter 用户界面通过桥接层调用 Rust 核心模块的架构示意图

我比较感兴趣的是 Flutter 和 Rust 的组合。Flutter 继续负责 UI、状态和平台交互;Rust 放在图像处理、音视频、加密、协议,或者其他对性能和资源安全要求更高的 core 里。

例如,图片数据的纯计算可以留在 Rust:

pub fn average_luma(pixels: &[u8]) -> u8 {
    let total: u64 = pixels.iter().map(|&pixel| u64::from(pixel)).sum();
    (total / pixels.len().max(1) as u64) as u8
}

Flutter 侧只需要把它当成一个异步能力调用,UI 不需要知道指针、内存布局或具体算法:

final luma = await imageCore.averageLuma(pixels);
setState(() => previewLuma = luma);

这里的 imageCore 可以是 MethodChannel、FFI,或者某个 bridge 生成的 Dart API;它们是接入细节。核心函数保持纯输入、纯输出,才是 Rust 放在这一层最有价值的地方。

这和我之前做跨平台 native core 时的想法很接近:真正稳定、可独立测试的计算和业务语义沉到核心层;页面、平台生命周期、权限、文件路径这些仍然留在平台层。Rust 可以是 core 的实现方式,但前提是边界真的清楚,不是为了引入 Rust 又多加一层桥。

一些学习建议

Rust 更像是在补一套以前没有被强迫建立的思维模型。

数据属于谁,谁能改,什么时候释放,能不能跨线程,这些问题以后写 C++、Flutter 也同样要面对。Rust 只是没有让我把它们拖到 bug 出现以后再想。

如果想入门,我推荐《Rust 权威指南(第 2 版)》。我读完以后觉得它很适合入门:它不是上来就带着写框架,而是把所有权、借用、生命周期为什么存在讲得比较明白。先把这几块啃下来,再去看异步、并发、FFI 和具体框架,会顺得多。

《Rust 权威指南(第 2 版)》封面

写代码时,官方的 The Rust Programming LanguageThe Cargo Book 也值得常开着。

总结

学完 Rust 的基础内容以后,我最大的感受不是多记住了多少语法,而是很多以前会顺手带过的问题,突然没法含糊过去了。

这个对象最后谁来释放?这份数据到底能不能同时改?把它丢进异步任务或者另一个线程以后,原来的逻辑还成立吗?在 C++ 里,这些问题当然也要考虑,只是很多时候要等到 code review、测试,甚至出了问题以后才会被真正拿出来讨论。Rust 会在你敲下代码的时候就把它们摆出来。

有些时候这确实很烦。一段本来觉得很简单的逻辑,因为借用关系不对,最后要拆数据、加 Arc<Mutex<_>>,或者重新想一遍任务怎么传递。编译慢的时候也会让人怀念改完就能跑的日子。它没有把复杂度变没,只是拒绝把复杂度悄悄留到运行时。

所以我不会把 Rust 当成 C++ 的替代品,也不会因为它热度高就急着在每个项目里引入。C++ 仍然有大量成熟的工程、库和场景;Rust 的 GUI、跨平台和团队生态也都需要结合实际看。

但如果一段代码真的在处理资源、并发或性能敏感的核心逻辑,我愿意多花一点时间让编译器挑刺。至少在上线以前,有人——哪怕这个人是编译器——会逼着我把那些边界想清楚。这也是 Rust 到现在最让我认可的地方。