posts

vnn01

blog_how_to_use_move

tags
cpp_move_semantics

什么叫语言级别的资源转移

  • cpp03_no_res_transferring 这个博文谈到 c++03 缺少了资源转移的语言支持,那理想中的应该怎么支持移动语义?下面几个是典型的场景:
    1. 用旧变量构造、赋值新变量是最可能需要资源转移的。
      • 所以移动语义应该和拷贝构造类似,都能由编译器默认生成、用户明确定义、编译器可以在合适的时候自动调用。
      • 引入移动构造、赋值后,标准库选择移动、拷贝语义时,要遵守之前定下的异常规则(如vector拷贝不能抛异常): 如果没有异常,要在函数声明后加上 noexcept 关键字,如果不加,只能回退到拷贝。
    2. 需要有一个类型,右值引用(&&)参数,明确指出这个变量即将会进行资源转移。
    3. 需要一个函数(std::move)将变量转化为右值引用类型,再传递到合适移动操作的函数转移资源。

语法定义 — 移动构造、移动赋值的声明形式

  • 要实现移动语义,我们需要增加2个成员函数
    • 移动构造函数
    • 移动赋值函数, 和拷贝赋值类似,需要注意 自赋值 的情况
class Datas {
public:
        ...
    // 移动构造:直接"偷"走对方的资源
    Datas(Datas&& o) noexcept   // 注意:参数是右值引用 T&&,不是 const T&
        : data(o.data)
        , size(o.size)
    {
        o.data = nullptr;       // 把源对象掏空
        o.size = 0;
    }

    // 移动赋值
    Datas& operator=(Datas&& o) noexcept {
        if (this != &o) {
            delete[] data;      // 1. 先释放自己的旧资源
            data = o.data;      // 2. 偷走对方的资源
            size = o.size;
            o.data = nullptr;   // 3. 把对方掏空
            o.size = 0;
        }
        return *this;
    }
private:
    int* data;
    size_t size;
}
  • 移动拷贝
Datas myData(10000000);
Datas submitData(std::move(myData));  // 调用移动构造
  • 移动赋值
Datas submitData(0);

submitData = std::move(myData);  // 调用移动赋值
// myData 现在是空壳,submitData 持有 1000 万条记录
  • 如果你不写移动构造/移动赋值,编译器会不会帮你生成?答案是: 有条件 。

六个特殊成员函数

C++11 有六个编译器可能自动生成的函数:

  1. 默认构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值运算符
  5. 移动构造函数 (C++11 新增)
  6. 移动赋值运算符 (C++11 新增)

生成规则

移动操作只在 三个条件同时满足 时自动生成:

  • 没有用户声明的拷贝操作
  • 没有用户声明的移动操作
  • 没有用户声明的析构函数

换句话说: 一旦你碰了拷贝或析构,移动就不会自动生成 。

class Widget {
public:
    ~Widget() { /* ... */ }         // 声明了析构函数
    // → 移动构造、移动赋值都不会自动生成
    // → 所有"移动"操作退化为拷贝
};

这个设计是有意为之的——如果你需要自定义析构,说明你在管理某种资源,默认的逐成员移动大概率是错的。编译器选择"不生成"而不是"生成一个可能出错的版本"。

Rule of Five

由此引出一条实践法则:

> 拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数—— 要不一个都不定义,要不五个全定义 。

“全都不定义"适用于成员都是值类型(`int`、`std::string`、`std::vector`)的类——编译器自动生成的版本就是正确的。

“全定义"适用于管理资源的类——你写了析构释放资源,就必须写移动来转移资源,写拷贝来复制资源(或 `= delete` 禁止拷贝)。

移动操作的耦合关系

两个移动操作是耦合的:

class Widget {
public:
    Widget(Widget&&) { /* ... */ }  // 声明了移动构造
    // → 移动赋值不会自动生成!
};

声明一个移动操作,编译器就不会生成另一个。这是合理的——如果你需要自定义移动构造,移动赋值的默认行为大概率也不对。

声明移动后,拷贝被删除

class Widget {
public:
    Widget(Widget&&) { /* ... */ }  // 声明了移动构造
    // → 拷贝构造被隐式删除
};

这也是合理的——能移动的对象通常不应该被意外拷贝(想想 `unique_ptr`)。

右值参数与move

  • 有了移动语义,当我们需要传递一个即将被转移资源的变量时,我们可以使用右值引用 &&
  • 所以,转移资源的接口我们可以定义为 Datas&& d, 这样我们看接口就知道d这个变量的资源在函数内部会被转移
  • 在转移d的状态前,我们可能需要使用d,所以在函数内部d还是一个正常的变量(准确来说是左值变量, 也就是可以取地址)
    • 所以如果直接用d去构造,调用的是拷贝构造,因为我们需要允许在资源d转移之前拷贝d的
  • 我们真正转移状态是靠移动赋值、移动拷贝,而那2个函数都要求参数类型是右值引用,所以我们使用std::move(d)将d再转为右值再传参给移动操作运算符,完成资源的转移
    • 所以实际上 std::move() 并不转移资源(实际上是只类型转化static_cast<Datas&&>),只是再次提醒编译器,我们这时准备要转移资源了,请调用相应的移动操作函数
void archiveToPolice(Datas&& d) {
    std::cout << "存储部门归档 " << d.size << " 条记录\n";

    Datas copiedData(d); //!!!注意这时是调用的拷贝构造

    Datas innerData(std::move(d)); //这才调用移动构造
    // innerData 接管了 d 的资源,离开作用域时由其析构
    ...
}

总结

  • c++11的资源转移机制核心就是移动构造、移动赋值
  • 移动操作自动生成或删除有比较复杂的机制,我推荐用暴力的准则
    • 要么全部不定义
    • 要不全部定义
    • 如果不抛出异常,移动操作要加上noexcept修饰
  • 如果接口需要资源转移,我们要将类型定义为右值引用类型(&&)
  • 在函数内部,资源转移前,这个右值引用类型的变量是左值,直接调用拷贝、赋值是调用的拷贝赋值;只有通过std::move转化为右值时,才能调用相应的移动操作