土法炼钢兴趣小组的算法知识备份

Rust 所有权:C++ RAII 本来想成为的样子

目录

每个 C++ 程序员都会说一句话:“我用 RAII 管资源,不会有泄漏。”

这句话在 2005 年是对的。那时候替代方案是手写 new/delete,RAII 是巨大进步。但今天再说这句话,就像开手动挡的人说”我不需要自动变速箱,我换挡从不失误”——你可能确实不会失误,但你为什么要承担这个风险?

C++ 在 1984 年发明了 RAII(Stroustrup 在《The C++ Programming Language》中首次阐述)。40 年后,Rust 展示了 RAII 本来应该成为的样子。

区别不在于概念——两者都是”让编译器管资源”。区别在于执行力度:C++ 的 RAII 是编程约定(convention),编译器不管你遵不遵守。Rust 的所有权是编译器法律(law),违反就编译不过。

以下是 C++ RAII 做不到的五件事,以及 Rust 如何把每一个漏洞堵死。

RAII vs Ownership 保护范围对比

一、Use-After-Move:僵尸对象

C++11 引入了移动语义,本意是避免昂贵的深拷贝。但标准委员会做了一个关键决定:移动之后,源对象处于”valid but unspecified”状态。翻译成人话就是——你可以继续使用一个已经被掏空的对象,编译器不会拦你

std::vector<int> v = {1, 2, 3};
auto v2 = std::move(v);
v.push_back(4);   // 合法!但 v 现在是什么状态?
std::cout << v.size();  // 可能输出 1,可能输出别的

这段代码没有 warning,没有 error,通过所有编译器。v 在 move 之后变成了一个僵尸——它还活着(可以调用方法),但灵魂已经没了(内部数据已转移)。

更隐蔽的场景:

void process(std::unique_ptr<Connection> conn) {
    send_to_pool(std::move(conn));
    conn->close();  // 合法!但 conn 是 nullptr,运行时崩溃
}

unique_ptr 被 move 后变成 nullptr,对它解引用是未定义行为。编译器不说一个字。

这不是极端案例。Google 在 Abseil 文档中承认,use-after-move 是他们 C++ 代码库中的高频 bug 类别。Clang-Tidy 专门有一个 bugprone-use-after-move 检查项——但它只是 lint,不是语言保证。

Rust 的选择截然不同:move 之后,源变量不再存在

let v = vec![1, 2, 3];
let v2 = v;       // v 的所有权转移给 v2
v.push(4);         // 编译错误:borrow of moved value: `v`

不是运行时检查,不是 lint 建议——是编译错误。v 在 move 之后从作用域中消失,就像从未声明过。

为什么 C++ 不能做到这一点?答案是向后兼容。移动语义在 C++11 才引入,大量已有代码依赖”对象在任何状态下都可以调用方法”的假设。如果 move 之后禁止使用源对象,几百万行代码会编译不过。C++ 选择了兼容性,Rust 选择了正确性。

这个选择的代价:C++ 每一个 std::move 都是一个潜在的 bug 注入点,需要人脑追踪每个对象的”灵魂是否还在”。Rust 把这个追踪工作交给了编译器。

Use-After-Move 对比:C++ 僵尸对象 vs Rust 编译错误

二、悬垂引用:RAII 管不住的指针

RAII 解决了”谁来释放资源”的问题,但没有解决”引用还指向那块资源吗”的问题。

C++ 的一个经典陷阱:

std::string_view get_name() {
    std::string name = "hello";
    return std::string_view(name);  // 悬垂!name 在返回时析构
}

std::string 的 RAII 完美地释放了内存。问题是 string_view 还指着那块已释放的内存。RAII 管了资源的生命周期,没管引用的生命周期。

这不是理论上的风险。C++17 引入 string_view 后,悬垂引用 bug 的数量显著上升,因为 string_view 看起来像 string 但语义完全不同——它不持有数据。

更常见的场景:

std::vector<int> vec = {1, 2, 3};
auto& ref = vec[0];     // ref 指向 vec 内部
vec.push_back(4);        // 可能触发 realloc,ref 悬垂
std::cout << ref;        // 未定义行为

每个 C++ 程序员都被这种迭代器失效咬过。标准库文档里写满了”Invalidates iterators and references if reallocation occurs”——但编译器不检查这一条。

Rust 的 borrow checker 在编译期追踪每个引用的生命周期:

fn get_name() -> &str {
    let name = String::from("hello");
    &name  // 编译错误:cannot return reference to local variable
}
let mut vec = vec![1, 2, 3];
let ref_first = &vec[0];   // 不可变借用
vec.push(4);                 // 编译错误:cannot borrow `vec` as mutable
                             // because it is also borrowed as immutable
println!("{}", ref_first);

Rust 的规则很简单:持有不可变引用时,不能修改数据源。这条规则从根本上杜绝了迭代器失效问题——如果你持有一个引用,底层数据保证不会被改变(也就不会被 realloc)。

这不是纸上谈兵。我在用 Rust 重写 LSM-Tree 存储引擎时真实遇到了这个问题。C 版本的 SSTable BlockBuilder 里,add() 函数先读 last_key 计算前缀共享长度,再往 data 缓冲区写入编码后的 key-value,最后更新 last_key。C 代码一气呵成,没有问题。Rust 直译后直接编译不过:

// error[E0502]: cannot borrow `self` as mutable
//              because it is also borrowed as immutable
fn add(&mut self, key: &[u8], value: &[u8]) {
    let shared = common_prefix(&self.last_key, key); // 不可变借用 self
    self.data.extend_from_slice(...);                 // 可变借用 self -- 冲突!
}

borrow checker 发现 common_prefix 持有 self.last_key 的不可变引用,而 self.data.extend_from_slice 需要 self 的可变引用。这在 C 里没问题(last_keydata 是不同的字段),但 Rust 的借用粒度是整个 self。解决方案是先把 shared 算成一个纯数值(usize),不保持任何借用,再做写操作。borrow checker 迫使我将”读旧数据”和”写新数据”在时间线上严格分开——这恰好避免了 C 中一种隐蔽的 bug:如果 extend_from_slice 触发了缓冲区 realloc,而 last_key 恰好指向同一块内存区域,C 代码会悄悄产生悬垂读。

C++ 也试图解决这个问题。Herb Sutter 的 Lifetime Profile 提案(P1179)旨在给 C++ 加上类似的静态分析。但这个提案从 2018 年提出到现在都没有进入标准。原因之一是 C++ 的类型系统没有生命周期的概念,要在不改变语言语义的前提下做静态分析,难度极大。

Rust 从第一天就把生命周期(lifetime)作为类型系统的一部分。代价是学习曲线陡峭——'a 标注是 Rust 新手最大的困惑来源。但这个代价换来了编译期保证没有悬垂引用,这是 C++ 到今天都做不到的事情。


三、共享可变:数据竞争的温床

auto ptr = std::make_shared<std::vector<int>>();

// 线程 1
ptr->push_back(1);

// 线程 2
ptr->push_back(2);
// data race!shared_ptr 的引用计数是原子的,但数据不是

shared_ptr 解决了”谁来 delete”的问题——最后一个 shared_ptr 析构时自动释放。RAII 在这里工作正常。但 RAII 管析构,不管并发访问

shared_ptr 给了你共享所有权(shared ownership),但没给你同步机制。你得自己加 mutex,自己保证线程安全。忘了加?编译器不说一个字。运行时在某个 CPU 核心上、某个时间窗口内触发竞态,排查三天起步。

C++ Core Guidelines(C.131)写得很清楚:“Don’t share mutable data without synchronization。” 但 Guidelines 是建议,编译器不强制。

Rust 的类型系统把并发安全编码到了类型层面:

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

let data = Arc::new(Mutex::new(vec![]));

let d1 = Arc::clone(&data);
std::thread::spawn(move || {
    d1.lock().unwrap().push(1);  // 必须先 lock()
});

let d2 = Arc::clone(&data);
std::thread::spawn(move || {
    d2.lock().unwrap().push(2);  // 必须先 lock()
});

如果你尝试不加 Mutex 就跨线程修改数据:

let data = Arc::new(vec![]);
let d1 = Arc::clone(&data);
std::thread::spawn(move || {
    d1.push(1);  // 编译错误:cannot borrow as mutable
});

编译器直接拦住。背后的机制是两个 marker trait: - Send:一个类型是否可以安全地跨线程转移所有权 - Sync:一个类型是否可以安全地被多线程共享引用

这两个 trait 由编译器自动推导。Rc(非线程安全的引用计数)不实现 Send,所以你连把它发到另一个线程都做不到:

let rc = Rc::new(42);
std::thread::spawn(move || {
    println!("{}", rc);  // 编译错误:Rc<i32> cannot be sent between threads safely
});

我在无锁代码的打脸文里讨论过 C++ 无锁编程中 memory_order 被滥用的问题。Rust 的方案不是让程序员选择更安全的 memory order,而是从类型层面杜绝”忘记同步”这件事。你想要共享可变?编译器强制你加锁或用原子类型。


四、异常 vs panic:半构造对象

class Connection {
    std::unique_ptr<Socket> sock;
    std::unique_ptr<Buffer> buf;
public:
    Connection()
        : sock(std::make_unique<Socket>(open_socket()))
        , buf(std::make_unique<Buffer>(alloc_buffer()))  // 如果这里抛异常?
    {
        // sock 已构造,buf 没有。析构函数不会被调用(因为对象没有完全构造)。
        // 但 sock 的 unique_ptr 析构器会被调用——C++ 保证已构造的成员自动析构。
    }
};

上面的例子看起来没问题——unique_ptr 保护了 sock 的析构。但如果你用的是裸指针呢?

class LegacyConnection {
    Socket* sock;
    Buffer* buf;
public:
    LegacyConnection()
        : sock(new Socket(open_socket()))
        , buf(new Buffer(alloc_buffer()))  // 异常!sock 泄漏
    {}
    ~LegacyConnection() { delete sock; delete buf; }
};

构造函数抛异常时,析构函数不会被调用(因为对象从未完全构造),sock 直接泄漏。

C++ 的异常安全有三个级别:基本保证(basic guarantee)、强保证(strong guarantee)、无异常保证(nothrow guarantee)。但关键问题是——编译器不告诉你一个函数提供哪个级别noexcept 说明符是程序员的承诺,不是编译器的验证。你可以在 noexcept 函数里抛异常,后果是直接 std::terminate()

写异常安全的 C++ 代码极其困难。Herb Sutter 在 GotW(Guru of the Week)系列中花了大量篇幅讲异常安全,核心信息是:如果你觉得你的异常安全代码是对的,多半是错的。

Rust 的方案:没有可恢复异常机制

fn connect() -> Result<Connection, Error> {
    let sock = Socket::open()?;    // 失败就提前返回 Err
    let buf = Buffer::alloc()?;    // 失败就提前返回 Err,sock 自动 drop
    Ok(Connection { sock, buf })
}

? 操作符让错误传播像异常一样简洁,但它是显式的——每个可能失败的调用点都清楚地标记了 ?。没有”不知道这个函数会不会抛异常”的问题。

更关键的是析构保证:

C++ 需要程序员记住”构造函数抛异常时析构函数不被调用”这个规则,并据此设计每一个类的异常安全策略。Rust 把这个心智负担的大部分转移给了编译器。


五、逃生舱门的代价

总结 C++ RAII 的五个逃生舱门:

  1. 你可以不用 RAII – 裸 new/delete 仍然合法,任何函数都可能返回裸指针
  2. 你可以从 smart pointer 里拿裸指针.get().release() 随时可用
  3. 你可以使用被 move 的僵尸对象 – 编译器不阻止
  4. 你可以创建悬垂引用string_viewspan、迭代器失效,编译器不检查
  5. 你可以共享可变状态而不加锁shared_ptr 不保护数据并发安全

这五扇门都是默认打开的。写安全的 C++ 需要你主动关门——用 smart pointer、用 clang-tidy、用 code review、用编码规范。但门始终在那里,任何团队成员的任何一次疏忽都可能推开它。

Rust 也有逃生舱门:unsafe。但设计哲学完全相反:

unsafe {
    let ptr = &v as *const Vec<i32> as *mut Vec<i32>;
    (*ptr).push(42);  // 通过裸指针绕过 borrow checker,未定义行为
}

关键区别:

我在无锁代码的打脸文里审计了多个 GitHub 高星无锁库。在 Rust 生态中(crossbeam, parking_lot 等),unsafe 的使用集中在少数经过高度审计的底层模块中,外部 API 全部是 safe 的。这正是 unsafe 的正确用法——在小范围内承担风险,对外提供安全保证。

C++ 是”default unsafe, opt-in safe”。Rust 是”default safe, opt-in unsafe”。

这不是措辞差异,而是根本的设计哲学分歧。前者要求每个人时刻保持警惕,后者要求少数人在少数地方承担风险。哪个模型更适合大规模工程协作,不言自明。

逃生舱门对比:C++ 5扇全开 vs Rust 1扇锁死

六、C++ 的反击:RAII 赢在哪里

公平起见。C++ RAII 在几个维度上仍有真实优势,不是 Rust 能简单替代的。

生态成熟度。C++ 有 40 年的库积累。几乎每一个 C 库都有 RAII 封装——我在Libevent 的 C++ 封装中展示过,用 unique_ptr + custom deleter 可以很优雅地包装 C 接口。Rust 的生态在快速增长,但在嵌入式、游戏引擎、HPC 等领域,C++ 库的丰富程度仍然碾压。

自定义分配器。C++ 的 placement new + allocator-aware container 允许极其精细的内存控制(arena allocator、pool allocator)。Rust 的 allocator API 到今天仍在 nightly,稳定版只有全局分配器。对于需要控制每一次内存分配的场景(游戏引擎、嵌入式实时系统),C++ 的灵活性明显更高。

编译速度。这是真实的痛点。Rust 的 borrow checker + monomorphization 让编译时间显著长于等价的 C++ 代码。在大型项目中,Rust 的增量编译有所改善,但 clean build 仍然让人难以忍受。C++ 的编译也不快(template instantiation),但有 precompiled header 和成熟的分布式编译方案(distcc、icecream)。

C 互操作。C++ 调 C 是零成本——同一个链接器、同一个 ABI(extern "C")。Rust 调 C 需要 unsafe FFI,且每个 C 类型都需要手动绑定或用 bindgen 生成。虽然可行,但摩擦明显更大。

模板元编程。C++ 的 SFINAE、Concepts(C++20)、constexpr if 提供的编译期计算能力极强。Rust 的 trait system + const generics 正在追赶,但在类型级计算的灵活性上仍有差距。

但要注意:这些优势与资源安全正交。C++ 赢在生态和灵活性,Rust 赢在正确性保证。选择哪个取决于你的工程约束——但不要把”C++ 生态更丰富”当作”C++ RAII 足够安全”的论据。这是两个独立的维度。


结语

RAII 是 C++ 对”谁负责释放资源”这个问题的回答。所有权是 Rust 对同一问题的回答。两者的核心思想一脉相承:让编译器在作用域结束时自动释放资源,不靠人记。

区别在于执行边界。C++ 的 RAII 管住了析构,但管不住引用、管不住并发、管不住 move 后的僵尸、管不住半构造。Rust 的所有权模型把这些全管了——代价是更严格的编译器和更陡的学习曲线。

标题说”C++ RAII 本来想成为的样子”,不是贬低 RAII。恰恰相反,RAII 是计算机科学中最伟大的资源管理发明之一。Rust 的贡献是站在 C++ 40 年经验的肩膀上,把 RAII 的理念推到了逻辑终点——如果编译器能管,为什么还要靠人?


如果你想看 Rust 所有权在实战中的表现,可以读LSM-Tree 存储引擎的 Rust 重写,那里有 5 个”编译器不让我过”的真实故事。

如果你想看 C++ RAII 在实战中的优雅用法,可以读Libevent 的 C++ 现代化封装,那里展示了 unique_ptr + custom deleter 的正确打开方式。

如果你对 Rust 和 C++ 在并发场景下的表现感兴趣,无锁代码的打脸文审计了两个语言生态中的无锁库实现。

如果你想从更宏观的角度理解 Rust 在内存管理谱系中的位置,GC 横向对比把 Java、Go、Python、Rust 四种方案放在一起比较。


By .