移动语义与 RAII
自动关联目录:移动语义与 RAII
C++ 里"资源该什么时候释放"这件事,答案是:靠作用域结束时的析构函数。这就是 RAII。移动语义则是让"资源的转移"变得便宜。
一句话定位:std::move 不搬任何东西,它只是把左值转成右值引用,让重载决议选中移动构造。真正的搬运发生在移动构造函数里。
RAII
class FileHandle
{
FILE* F = nullptr;
public:
explicit FileHandle(const char* Path) : F(fopen(Path, "rb")) { }
~FileHandle() { if (F) fclose(F); }
FileHandle(const FileHandle&) = delete; // 禁止拷贝
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& O) noexcept : F(O.F) { O.F = nullptr; }
FileHandle& operator=(FileHandle&& O) noexcept
{
if (this != &O) { if (F) fclose(F); F = O.F; O.F = nullptr; }
return *this;
}
};
void Read()
{
FileHandle H("a.txt"); // 离开作用域自动 fclose,即使抛异常
}RAII 的价值在异常路径:手写 close() 在提前 return、抛异常、中途 continue 时都会漏。析构函数不会漏。
| 资源 | RAII 封装 |
|---|---|
| 内存 | std::unique_ptr / shared_ptr |
| 文件 | 上面的 FileHandle / std::fstream |
| 锁 | std::lock_guard / std::unique_lock / std::scoped_lock |
| 线程 | jthread(C++20 自动 join) |
| GPU / 引擎句柄 | 自己封装(如 UE 的 TUniquePtr + 自定义 deleter) |
判断标准:任何一个"必须成对调用"的 API(open/close、lock/unlock、new/delete、acquire/release),都该包成 RAII。
值类别
| 类别 | 例子 | 能绑定到 |
|---|---|---|
| lvalue | 有名字的变量 | T& |
| xvalue(将亡值) | std::move(x) 的结果、返回右值引用的函数调用 | T&& |
| prvalue | 字面量、返回非引用类型的函数调用 | T&& / const T& |
std::string a = "hi";
std::string b = a; // 拷贝(a 是 lvalue)
std::string c = std::move(a); // 移动(转成 xvalue)
std::string d = MakeString(); // 可能直接构造,无拷贝(RVO)记住三条:
std::move只是类型转换,不搬运- 被 move 过的对象处于"有效但未指定状态"——只能赋新值或析构,不要再读它的内容
- 函数返回局部对象时不要写
std::move,那会阻止 RVO(返回值优化)
// 错误:阻止了 RVO,反而多一次移动
std::string Make() { std::string s; return std::move(s); }
// 正确:让编译器做 RVO
std::string Make() { std::string s; return s; }Rule of 0 / 3 / 5
| 规则 | 含义 |
|---|---|
| Rule of 0 | 优先让类里全是值成员(vector、string、unique_ptr),一个特殊成员函数都不写 |
| Rule of 3 | 需要手写析构 / 拷贝构造 / 拷贝赋值,通常三个都要写 |
| Rule of 5 | 再加上移动构造与移动赋值 |
Rule of 0 是首选。写法是:把资源交给已经是 RAII 的成员管理,自己就不必管了。
// Rule of 0:什么都不用写
class Texture
{
std::vector<uint8> Data; // 自己管内存
std::string Name; // 自己管字符串
};完美转发
template<typename... Args>
std::unique_ptr<T> MakeThing(Args&&... args)
{
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}| 要点 | 说明 |
|---|---|
Args&& 是转发引用 | 只在模板推导时成立 |
std::forward<T> | 保持原值类别(左值仍左值,右值仍右值) |
与 std::move 的区别 | move 无条件转右值;forward 有条件保持 |
不用 std::forward 而直接 std::move(args)...,会导致左值参数也被移动——调用方发现自己的变量被掏空了。
移动构造该 noexcept
Foo(Foo&&) noexcept = default; // 必须标为什么重要:std::vector 扩容搬移时,如果元素的移动构造不是 noexcept,标准库会退回用拷贝构造(为了保证异常安全)。一个漏标的 noexcept 能让扩容慢一个数量级。
| 场景 | 影响 |
|---|---|
vector 扩容 | 不 noexcept → 走拷贝 |
vector::reserve | 同上 |
| 排序算法 | 元素搬移 |
同样地,析构函数默认就是 noexcept,不要在析构里抛异常。
常见误解
| 误解 | 事实 |
|---|---|
std::move 会移动 | 只是类型转换 |
返回局部变量要加 move | 反而阻止 RVO |
| 移动一定比拷贝快 | 对 POD(int、struct{int,int})移动就是拷贝 |
| 移动后对象为空 | 只保证"有效但未指定",不要假设为空 |
| 有移动构造就不用管拷贝 | 两者独立,拷贝可能仍被意外调用 |
unique_ptr 不能直接赋值 | 可以,但只能移动赋值 |
与 UE 的对照
| C++ | UE |
|---|---|
std::unique_ptr | TUniquePtr |
std::shared_ptr | TSharedPtr(不要用于 UObject,见 GC) |
| RAII 管理 UObject | 用 TStrongObjectPtr 或 UPROPERTY |
| 值语义 USTRUCT | 引擎侧按值复制,注意成员是否可拷贝 |
UE 里 UObject 不适用 RAII:它的生命周期归 GC 管,手动 delete 是错的。
常见坑
| 坑 | 说明 |
|---|---|
移动构造漏 noexcept | 容器扩容退化成拷贝 |
返回局部变量加 std::move | 阻止 RVO |
| 用了被 move 的对象 | 值未定义 |
| 手写资源管理而不用 RAII | 异常路径泄漏 |
模板里用 move 代替 forward | 左值参数被掏空 |
拷贝了 unique_ptr | 编译错误,应为 move |
| 在析构里抛异常 | 未定义行为(通常是 terminate) |
| 浅拷贝了裸指针 | Rule of 3 违反,双重释放 |
许可协议:CC BY
作者:Davids
本文链接:https://hustjjd.github.io/fddca970.html
更新于:2026年10月10日