11 - 移动构造函数与移动赋值运算符

本文将简要说说C++的移动构造函数与移动赋值运算符,并实现“移动语义”。

移动语义

对一个体积比较大的类进行大量的拷贝操作是非常消耗性能的,因此 C++11 中加入了“移动语义”的操作。移动语义就是把该对象占据的内存空间的访问权限转移给另一个对象,被移动的对象通常都是临时对象。

拷贝构造/赋值与移动构造/赋值

以Test类的代码为例,对比一下它们:

class Test
{
public:
    // 下边引用这里的代码
private:
    char* str = nullptr;
}

首先是构造函数:

// 拷贝构造函数
Test(const Test& test)
{
    if (test.str)
    {
        str = new char[strlen(test.str) + 1]();
        strcpy_s(str, strlen(test.str) + 1, test.str);
    }
    else
    {
        str = nullptr;
    }
}

// 移动构造函数
Test(Test&& test) noexcept
{
    if (test.str)
    {
        str = std::exchange(test.str, nullptr);
    }
    else
    {
        str = nullptr;
    }
}

可以发现,移动构造函数确实比拷贝构造函数高效得多。中间使用了两个新技术:

  • noexcept关键字:告诉编译器此方法不会抛出任何异常。这对与标准库兼容非常重要,因为如果实现了移动语义,标准库容器会移动存储的对象,且确保不会抛出异常。
  • std::exchange()函数:位于<utility>库,该函数可以用一个新的值替换原来的值,并返回原来的值。

然后是赋值运算符:

// 拷贝赋值
Test& operator=(const Test& test)
{
    // 自引用检查
    if (this = &test)
    {
        return *this;
    }
    
    // 删除str原本内容
    if (str)
    {
        delete[] str;
        str = nullptr;
    }
    
    // 执行拷贝
    if (test.str)
    {
        str = new char[strlen(test.str) + 1]();
        strcyp_s(str, strlen(test.str) + 1, test.str);
    }
    else
    {
        str = nullptr;
    }
    
    return *this;
}

// 移动赋值
Test& operator=(Test&& test) noexcept
{
    // 自引用检查
    if (this = &test)
    {
        return *this;
    }
    
    // 删除str原本内容
    if (str)
    {
        delete[] str;
        str = nullptr;
    }
    
    // 移动语义
    if (test.str)
    {
        str = std::exchange(test.str, nullptr);
    }
    else
    {
        str = nullptr;
    }
    
    return *this;
}

如果对象还以其他对象作为数据成员,则应当使用std::move()函数移动这些对象。例如,假设Test类还有个std::string对象stri,则它的移动语义可以这样写:

stri = std::move(test.stri);

生成默认移动构造函数和赋值运算符

让编译器生成默认移动构造函数和默认赋值运算符主要有两个条件:

  1. 此类没有用户声明的拷贝构造函数、拷贝复制运算符、移动构造函数、移动赋值运算符和析构函数等。
  2. 类的每个非静态成员都可以移动。即所有的基础类型,有移动语义的类/对象。

对于第一点,这是 5规则(Rule of Five)的限制。5规则就是当用户声明了一个或多个特殊成员函数(析构函数、拷贝构造函数、拷贝赋值运算符和移动赋值运算符)时,通常需要声明所有这些函数。可以用(=default/delete)简写。

在现代C++中,5规则应限于自定义资源获取即初始化(Resource Acquisition Is Initialization, RAII)类。RAII类获取资源的所有权,并在正确的时间处理其释放。

其他类应该应用0规则(Rule of Zero),即在设计类时,应当使其不需要上述5个特殊成员函数。避免拥有任何旧式的、动态分配的内存,改用现代结构(标准库容器等)。

注意点

  1. 在进行移动语义后,被转移的对象就不能继续使用了,所以对象移动一般都是对临时对象进行操作(因为临时对象很快就要销毁了)。
  2. 移动语义的右值引用参数不能是const的。
  3. 当从函数中返回一个局部变量或参数时,只需要写return obj;即可,不要使用std::move()。因为前者会让编译器使用返回值优化(RVO)和命名返回值优化(NRVO)技术;而后者不仅没有触发这些优化,还强制使用移动或复制语义。

参考资料

  • 飘零的落花 - 现代C++详解
  • C++20高级编程