移动语义与 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)

记住三条:

  1. std::move 只是类型转换,不搬运
  2. 被 move 过的对象处于"有效但未指定状态"——只能赋新值或析构,不要再读它的内容
  3. 函数返回局部对象时不要写 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_ptrTUniquePtr
std::shared_ptrTSharedPtr(不要用于 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 违反,双重释放