← 返回 Atlas
语言

C++ 移动语义 vs Rust 所有权

2002 年 9 月 10 日, Howard Hinnant 向 C++ 委员会提交 N1377: C++98 的 std::auto_ptr 拷贝构造会悄悄偷走所有权, 谁把它塞进 std::vector, 一次排序就双重释放。Rust 的起点传闻是一座温哥华电梯, Graydon Hoare 受够 C/C++ 内存错误, 2013 年干脆移除 GC。同样回答『这块内存归谁』, 两个语言给出近乎相反的答案。

C++ · Rust · 所有权

同一道题, 两条修法: 提案驱动与编译器后置

C++ 走的是委员会提案路线。2002 年 Hinnant 的 N1377 动机直指 std::vector: 拷贝要新开 buffer, 移动只需偷三根指针; 2004 年 N1690 确立核心论证——从右值移动安全, 因为没人持有右值的引用; 2006 年 N2118 把右值引用从『move 的载体』扩成完整语言特性, 直到 2011 年 GCC 4.6 首发、同年 8 月 C++11 定稿。Rust 相反: 2006 年 Hoare 在 Mozilla 私人项目起步, 所有权系统 2010 年成形, 2013 年移除 GC, 2015 年 1.0 才稳定交付。一个先有标准后有编译器, 一个先定语义再谈稳定。

移动之后, 源对象是幽灵还是禁地

C++11 之后你 move 走一个对象, 源对象进入 valid but unspecified: 标准只保证它还能析构、还能赋值。vector 通常变空, 短 string 走 SSO 却把 15 字节 buffer 逐字节拷走, list/map 又常变空——没有『一定为空』的保证。Rust 把同一场景变成编译错误: let s2 = s; println!("{}", s); 直接 E0382。一个允许幽灵在运行时游荡, 一个在编译期立下禁地。补丁打到 2019 年 GCC 9, 用 -Wpessimizing-move 警告 return std::move(local) 这类反模式。

std::move 其实什么都没干

std::move 的完整实现就是一行: static_cast::type&&>(t)。它不搬任何东西, 只是把对象标记成『可被 move』, 真正的搬运发生在 move 构造函数被选中时。坑也随之而来: 标准库扩容用 move_if_noexcept, 你的 move 没标 noexcept, vector 宁可退化成拷贝; 类型不可拷贝就编译失败。模板里的 T&& 也不是右值引用而是转发引用, 得用 std::forward 保真。Rust 没有 move 关键字: 赋值、传参、返回默认 move, 只有 Copy 例外——i32 赋值后两个都活着, String 赋值后旧的消失。

扩容那一刻, 标准库替你做了决定

同一个扩容问题, 两个标准库给出相反解法。C++ 的 vector 因 move 可能抛异常, 扩容走 move_if_noexcept, 正确性靠人写 noexcept 去喂; Rust 的 Vec 扩容直接 move, 因为类型系统保证 move 是一次不抛的位拷贝。但所有权并不万能: 2017 年 Firefox 57 把 16 万行 C++ 的 CSS 引擎重写成 8.5 万行 Rust, 两次 C++ 并行化失败后靠 rayon 成功, 69 个安全 bug 里 51 个在 Rust 下不可能发生——团队却误复现了已修复的 :visited 漏洞。所有权杀内存 bug, 逻辑 bug 仍要靠测试。

实战示例

同一段 push_back 代码, C++ 里 move 没标 noexcept, 触发扩容时打印的是 copy 而非 move——标准库宁可拷贝也不赌你的 move 不抛。Rust 里 data.push(4) 放在借用最后一次使用之后, 1.63 前编译不过——NLL 让借用活到最后一次使用。

子关键词

std::move 与 std::forward 一个转右值, 一个保转发
std::move 是 static_cast 到右值引用, 不搬任何东西, 真正搬走的是 move 构造函数。模板 T&& 是转发引用, 传给下一层要用 std::forward 保真, 否则 lvalue 被 std::move 掉。
move_if_noexcept 扩容陷阱 没标 noexcept 就静默拷贝
vector 扩容用 move_if_noexcept: 你的 move 若可能抛异常, 它宁可拷贝, 因为拷贝失败还能回滚。类型不可拷贝就直接编译错误。修正就一句话: 析构不抛, move 标 noexcept。
NLL 非词法生命周期 借用活到最后一次使用
RFC 2094 由 Niko Matsakis 主导, 用 MIR 控制流图算借用存活范围, 原来 match 分支等四类合法代码都被误拒; Rust 1.63(2022) 全量稳定, 残余交给 Polonius。
Copy 与 Clone 的边界 位拷贝与深拷贝之分
Copy 是编译期位拷贝, 赋值即拷贝, i32 传参后两个变量都活着; String 不是 Copy, 赋值即 move, 旧变量成禁地。忘写 Copy 导致自定义 struct 传参被静默 move, 是最常见的新人报错。
Rc/RefCell 的坑 单线程、成环泄漏、运行时报错
Rc 是单线程引用计数, 多线程必须 Arc; Rc 成环会泄漏, Rust 没有 GC 兜底。RefCell 违反借用规则是运行时 panic(BorrowMutError), 不是编译错——借用检查器管不到动态借用。
Stylo 与所有权边界 内存 bug 归零, 逻辑 bug 照旧
Firefox 57 用 Rust 重写 CSS 引擎, 16 万行 C++ 换 8.5 万行, 并行化靠 rayon 成功。69 个安全 bug 中 51 个不可能发生, 团队误复现了已修复的历史漏洞——逻辑 bug 靠测试。

衍生角度

设计模式: move-only 类型

C++ 靠显式删除拷贝构造得到 unique_ptr, Rust 靠类型系统默认得到 String/Box——同一个『不可拷贝、只可转移』概念, 一个靠禁止, 一个靠默认。

概念拆解: 值类别 vs Copy

C++ 有 lvalue/prvalue/xvalue, xvalue 是『将死之物』; Rust 只有 Copy/move。同一转移, C++ 要人写 std::move, Rust 是默认。

风格架构: 值传递加 move

Dave Abrahams 的 pass-by-value 让 Chromium 用值传参加 move 换少分配, 漏标 noexcept 就退化拷贝; Rust 隐式 move 让同一风格成默认。

原理分析: 借用与 elision 的对照

C++ 靠 NRVO/elision 让 return 大对象不拷贝; Rust 返回即 move。NLL 让借用活到最后一次使用, 对应 C++ 用警告兜误用 std::move。

实际用法与坑

  • 自定义类型 move 构造/赋值标 noexcept, 否则 vector 扩容静默退化拷贝。
  • 别 return std::move(local), 它阻止 NRVO 且被 GCC 9 警告。
  • 值类别速查: 具名=lvalue, std::move 包=xvalue, 字面量=prvalue。
  • 判 Copy: 成员全 Copy 且无 Drop, 否则传参静默 move, 报 E0382。
  • NLL 后借用到最后一次使用, 用 data.push 在 last use 后能否编译验证直觉。

下一步动手做

先关优化编译 C++ 示例, 数清拷贝/移动打印, 再补 noexcept 看扩容变化。然后装 Rust, 复现 E0382 与 NLL 前后对照。最后自答: unique_ptr 的拷贝为何被删, String 的赋值为何不报错。

相关书籍

Effective Modern C++

Item 23: std::move 是 cast, Item 29: noexcept 扩容坑。

Programming Rust

第 4 章讲三规则, 第 15 章覆盖 Rc 成环泄漏, 从设计意图而非语法出发。

关联关键词