cpp11-移动语义详解

今天在业务代码里看到了move的使用,竟然感到一点震惊,因为代码里大多数都还是用的cpp98的标准在写,cpp11的特性其实用的很少,所以特地重新复习了一下cpp11中move的设计,尝试更加深入理解移动语义的原理和作用,顺便把一些思考沉淀下来。

一、rule of three/five

rule of three是自从C++98标准问世以来, 大家总结的一条最佳实践. 这个实践其实很简单, 用一句话就能说明白:

析构函数, 拷贝构造函数, =操作符重载应当同时出现, 或同时不出现

那么, 这背后的缘由是什么呢? 这里就来说道说道.

C++中, 所有变量都是值类型变量, 这意味着在C++代码中, 隐式的拷贝是非常常见的, 最常见的一个隐式拷贝就是参数传递: 非引用类型的参数传递时, 实质上发生的是一次拷贝, 首先我们要明白, 所谓的发生了一次拷贝, 所谓的拷贝, 到底是指什么.

我们从一段短的代码片断开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class person
{
std::string name;
int age;

public:

person(const std::string& name, int age) : name(name), age(age)
{
}
};

int main()
{
person a("Bjarne Stroustrup", 60); // Line 1: 这里显然是调用了构造函数
person b(a); // Line 2: 这里发生了什么?
b = a; // Line 3: 这里又发生了什么?
}

上面是一个简单的类, 仅实现了一个构造函数.

到底什么是拷贝的本质? 在上面代码片断中, Line 1显然不是拷贝, 这是一个非常显然的初始化, 它调用的也很显然是我们定义的唯一的那个构造函数: person(const std::string& name, int age). Line 2和Line 3呢?

Line 2: 也是一个初始化: 初始化了对象b. 它调用的是类person拷贝构造函数.
Line 3: 是一个赋值操作. 它调用的是person=操作符重载

但问题是, 在Line 2中, 我们并没有定义某个构造函数符合person b(a)的调用. 在Line 3中, 我们也并没有实现=操作符的重载. 但上面那段代码, 是可以被正常编译执行的. 所以, 谁在背后搞鬼?

答案是编译器, 编译器在背后给你偷偷实现了拷贝构造函数(person(const person & p))与=操作符重载(person& operator =(const person & p)). 根据C++98的标准:

  1. 拷贝构造函数(copy constructor), =操作符(copy assignment operator), 析构函数(destructor)特殊的成员函数(special member functions
  2. 当用户没有显式的声明特殊的成员函数的时候, 编译器应当隐式的声明它们.
  3. 当用户没有显式的声明特殊的成员函数(显然也并没有实现它们)的时候, 如果代码中使用了这些特殊的成员函数, 编译器应当为被使用到的特殊的成员函数提供一个默认的实现

并且, 根据C++98标准, 编译器提供的默认实现遵循下面的准则:

  1. 拷贝构造函数的默认实现, 是对所有类成员的拷贝. 所谓拷贝, 就是对类成员拷贝构造函数的调用.
  2. =操作符重载的默认实现, 是对所有类成员的=调用.
  3. 析构函数默认情况下什么也不做

也就是说, 编译器为person类偷偷实现的拷贝构造函数=操作符大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 拷贝构造函数
person(const person& that) : name(that.name), age(that.age)
{
}

// =操作符
person& operator=(const person& that)
{
name = that.name;
age = that.age;
return *this;
}

// 析构函数
~person()
{
}

问题来了: 我们需要在什么情况下显式的声明且实现特殊的成员函数呢? 答案是: 当你的类管理资源的时候, 即类的对象持有一个外部资源的时候. 这通常也意味着:

  1. 资源是在对象构造的时候, 交给对象的. 换句话说, 对象是在构造函数被调用的时候获取到资源的
  2. 资源是在对象析构的时候被释放的.

为了形象的说明管理资源的类与普通的POD类之间的区别, 我们把时光倒退到C++98之前, 那时没有什么标准库, 也没有什么std::string, C++仅是C的一个超集, 在那个旧时光, person类可能会被写成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class person
{
char* name;
int age;

public:

// 构造函数获取到了一个资源: 即是C风格的字符串
// 本例中, 是将资源数据拷贝一份, 对象以持有资源的副本: 存储在动态分配的内存中
// 对象所持有的资源, 即是动态分配的这段内存(资源的副本)
person(const char* the_name, int the_age)
{
name = new char[strlen(the_name) + 1];
strcpy(name, the_name);
age = the_age;
}

// 析构的时候需要释放资源, 在本例中, 就是要释放资源副本占用的内存
~person()
{
delete[] name;
}
};

这种上古风格的代码, 其实直到今天都还在有人这样写, 并且在将这种残缺的类套进std::vector, 并且调用push_back后发出痛苦的嚎叫: “MMP为什么代码一跑起来一大堆内存错误?”, 就像下面这样:

1
2
3
4
5
6
7
int main(void ) {
std::vector<person> vec;

vec.push_back(person("allen", 27));

return 0;
}

这是因为: 你并没有提供拷贝构造函数, 所以编译器给你实现了一个. 你调用vec.push_back(person("allen", 27))的时候, 调用了编译器的默认实现版本. 编译器的默认实现版本仅是简单的复制了值, 意味着同一段内存被两个对象同时持有着. 然后这两个对象在析构的时候都会去试图delete[]同一段内存, 所以就炸了.

这就是为什么, 如果你写了析构函数的话, 就应当再写复制构造函数=操作符重载, 它的逻辑是这样的:

  1. 你自行实现了析构函数, 说明这个类并不是简单的POD类, 它有一些资源需要在析构的时候进行释放, 或者是内存, 或者是其它句柄
  2. 为了避免上面示例中的资源重复释放问题, 你需要自行实现对象的拷贝语义, 根据资源是否能被安全的重复释放, 或者资源是否能被安全的多个对象持有多份拷贝, 来决定拷贝的语义
  3. 为了实现拷贝的语义, 你需要自行实现拷贝构造函数=操作符重载

所以一个安全的person类应当实现如下的拷贝构造函数=操作符重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 拷贝构造函数
person(const person& that)
{
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}

// =操作符重载
person& operator=(const person& that)
{
if (this != &that)
{
delete[] name;
// 这其实是一个很危险的写法, 但如何正确的写一个=操作符重载并不属于本节所要讨论的范畴
// 所以暂时先可以凑合这样写着
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}
return *this;
}

注意上面的=操作符重载的实现是很不安全的, 但如何正确的写一个=操作符重载并不是本节所要讨论的内容(下一节”copy-and-swap idiom”中再进行讨论). 这里只要明白为什么析构函数, 拷贝构造函数, =操作符重载应当同生共死就行了.

某些场合中, 对象所持有的资源是不允许被拷贝的, 比如文件句柄或互斥量. 在这种场合, 与其费尽心机的去研究如何让多个对象同时持有同一个资源, 不如直接禁止这种行为: 禁止对象的拷贝与赋值. 要实现这种功能, 在C++03标准之前, 有一个很简单的方式, 即是把拷贝构造函数=操作符在类内声明为private, 并且不去实现它们, 如下:

1
2
3
4
private:

person(const person& that);
person& operator=(const person& that);

在C++11标准下, 你可以使用delete关键字显式的说明, 不允许拷贝操作, 如下:

1
2
person(const person& that) = delete;
person& operator=(const person& that) = delete;

所以, 至此, 就基本说明白了为什么rule of three是大家自从C++98以来, 总结出来的一个最佳实践. 比较遗憾的是, 在语言层面, C++并没有强制要求所有程序员都必须这样写, 不然不给编译通过. 所以说呀, C++这门语言还真是危险呢.

而自C++11以来, 类内的特殊的成员函数由三个, 扩充到了五个. 由于移动语义的引入, 拷贝构造函数=操作符重载都可以有其右值引用参数版本以支持移动语义, 所以rule of three就自然而然的被扩充成了rule of five, 下面是例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class person
{
std::string name;
int age;

public:
person(const std::string& name, int age); // 构造函数

person(const person &) = default; // 拷贝构造函数
person(person &&) noexcept = default; // 拷贝构造函数: 右值引用版. 也被称为移动构造函数
person& operator=(const person &) = default; // =操作符重载
person& operator=(person &&) noexcept = default; // =操作符重载: 右值引用版
~person() noexcept = default; // 析构函数
};

二、copy-and-swap idiom

rule of three/five小节, 我们已经讨论了, 任何一个管理资源的类, 都应当实现拷贝构造函数, 析构函数=操作符重载. 这三者中, 实现拷贝构造函数析构函数的目的是很显而易见的, 但=操作符重载的实现目的, 以及实现手段在很长一段时间内都是有争论的, 人们在实践中发现, 要实现一个完善的=操作符重载, 其实并不像表面上想象的那么简单, 那么, 到底应当如何去写一个完美的=操作符重载呢? 这其中又有哪些坑呢? 这一节我们将进行深入讨论.

简单来说, copy-and-swap就是一种实现=操作符重载的最佳实践, 它主要解决(或者说避免了)两个坑:

  1. 避免了重复代码的书写
  2. 提供了强异常安全的保证

逻辑上来讲, copy-and-swap在内部复用了拷贝构造函数去拷贝资源, 然后将拷贝好的资源副本, 通过一个swap函数(注意, 这不是标准库的std::swap模板函数), 将旧资源与拷贝好的资源副本进行交换. 然后复用析构函数将旧资源进行析构. 最终仅保留拷贝后的资源副本.

上面这段话你看起来可能很头晕, 这不要紧, 后面会进行详细说明.

copy-and-swap套路的核心有三:

  1. 一个实现良好的拷贝构造函数
  2. 一个实现良好的析构函数
  3. 一个实现良好的swap函数.

所谓的swap函数, 是指这样的函数:

  1. 不抛异常
  2. 交换两个对象的所有成员
  3. 不使用std::swap去实现这个swap函数. 因为std::swap内部调用了=操作符, 而我们要写的这个swap函数, 正是为了实现=操作符重载

上面说了那么多, 可能看的你脑壳越来越痛, 不要紧, 现在我们用代码来阐述. 比如下面这个dump_array类, 内部持有堆区的资源(也就是一个通过new分配的数组), 我们先给它把拷贝构造函数析构函数实现掉.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <algorithm>    // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
// 构造函数
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}

// 拷贝构造函数
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
std::copy(other.mArray, other.mArray + mSize, mArray);
}

// 析构函数
~dumb_array()
{
delete [] mArray;
}

private:
std::size_t mSize;
int* mArray;
};

我们先来看一个失败的=操作符重载实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)

mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}

return *this;
}

表面上看这个实现好像也没什么大问题, 但实际上它有三个缺陷:

  1. (1)处, 需要首先判断=操作符左右是不是同一个对象. 这个逻辑上来讲其实没什么问题. 但实际应用中, =左右两边都是同一个对象的可能性非常低, 几乎为0. 但这种判断你又不得不做, 你做了就是拖慢了代码运行速度. 但坦白讲这并不是一个什么大问题.

  2. (2)处, 先是把旧资源释放掉, 然后再在(3)处进行新资源内存的再申请与数据拷贝. 但如果第(3)步, 申请内存的时候抛异常失败了呢? 整个就垮掉了.一个改进的实现是先申请内存与数据拷贝, 成功了再做旧资源的释放, 如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    dumb_array& operator=(const dumb_array& other)
    {
    if (this != &other)
    {
    std::size_t newSize = other.mSize;
    int* newArray = newSize ? new int[newSize]() : nullptr;
    std::copy(other.mArray, other.mArray + newSize, newArray);

    delete [] mArray;
    mSize = newSize;
    mArray = newArray;
    }

    return *this;
    }
  3. 整个=重载的实现, 几乎就是抄了拷贝构造函数中的代码(虽然在本例中不是很明显: 因为拷贝构造函数中使用了成员初始化列表).

看到这里你可能觉得我在吹毛求疵, 但你稍微想一下, 如果我们要管理的资源的非常复杂的初始化步骤的话, 上面的写法其实就很恶心了. 首先是异常安全的保证就需要非常小心, 其次就是抄代码的情况就会非常明显: 同样的逻辑, 你要在拷贝构造函数=操作符重载里, 写两遍!

那么一个正确的实现应当怎么写呢? 我们上面说过, copy-and-swap套路能规避掉上面的三个缺陷, 但在继续讨论之前, 我们首先要实现一个swap函数. 这个swap函数是如此的重要与核心, 我甚至愿意为此, 将所谓的rule of three改名叫成rule of three and a half, 其中的a half就是指这个swap函数. 多说无益, 我们来看swap的实现, 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class dumb_array
{
public:
// ...

// 首先, 这是一个函数, 只是声明与实现都放在了类定义中, 而不是一个成员函数
// 其次, 这个函数不抛异常
friend void swap(dumb_array& first, dumb_array& second)
{
// 通过这条指令, 在找不到合适的swap函数时, 去调用std::swap
using std::swap;

// 由于两个成员都是基础类型, 它们没有自己的 swap 函数
// 所以这里调用的是两次 std::swap
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}

// ...
};

这个swap的实现初看起来很平平无奇, 其目的也十分显而易见(交换两个对象中的所有成员), 但实际上, 上面这个写法里也是有一些门道的, 限于篇幅关系, 这里不会掰开揉碎细细讲, 你最好仔细琢磨一下这个swap的写法, 比如:

  1. 为什么它非要写成friend void swap, 而不是写成一个普通函数
  2. 里面那句using std::swap有什么玄机? 想一想, 如果dumb_array的成员变量不是基础类型, 而是一个类类型, 并且这个类类型也完整的实现了rule of three and a half, 会发生什么?

总之, 现在先不关心swap实现上的一些细节, 仅仅只需要关注它的功能即可: 它是一个函数, 它能完成两个dumb_array对象的交换, 而所谓的交换, 是交换两个对象的成员的值.

在此基础上, 我们的=操作符重载可以实现成下面这样:

1
2
3
4
5
6
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)

return *this;
}

是的, 就这是么简洁. 你没有看错, 就是这么有魔力! 那么, 为什么说它规避了我们先前提到的三个缺陷呢? 它又是如何规避的呢?

首先再回顾一下, 我们实现=操作符重载的逻辑思路:

  1. fuck = shit的内部, 我们先将shit拷贝一份, 称其为shit2好了
  2. 然后使用一个swap函数, 将fuckshit2进行交换: 即交换两个对象的所有成员变量的值. 这样就达到了”把shit的值赋给fuck“的目的
  3. 第三步, 在=操作符实现的内部退栈的时候, shit2会自动由于退栈而被析构.

整个过程没有风险, 没有异常, 很是流畅.

这里有几个点也需要额外说明一下:

  1. 首先, 这个=操作符重载的实现, 其参数是值类型, 而不是const dumb_array & other. 这里面是有门道的, 如果采用引用类型, 如下所示:

    1
    2
    3
    4
    5
    6
    7
    dumb_array& operator=(const dumb_array& other)
    {
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
    }

    从功能上看, 和先前的值引用版本没什么区别. 但内在上, 你实质上放弃了一个”让编译器自动优化代码”的契机. 这个细节展开来说也比较复杂, 具体缘由在这里 有详细解释, 但总结起来就是: 在C++中, 普通的拷贝操作(调用拷贝构造函数), 比起在函数传参时, 编译器在背后执行的拷贝操作(虽然从表面看它也是在调用拷贝构造函数), 效率要低, 并且还低得多!

  2. 使用值传递来自动使编译器在背后调用拷贝构造函数(实质上编译器会做一些优化, 但你可以这样理解), 保证了只要执行流程进入到了=操作符的内部, 数据拷贝就已经完成了. 这暗地里还复用了拷贝构造函数的代码. 所以, 代码重复的问题解决了.

  3. 并且由于这样的写法, 只要函数调用这个动作被成功发起了, 就代表着数据拷贝已经成功: 这意味着拷贝过程中发生的内存分配等其它高危操作已经完成, 如果有异常, 应当在函数调用之前被扔出来, 而一旦代码执行进调用内部, 就不可能再抛异常了. 这解决了异常安全的问题

  4. 我们也规避了用以检查=左右两边是否为同一个对象的逻辑. 虽然如果这种情况发生, 这种写法会导致一次额外的数据拷贝与析构, 但这也是可以接受的, 毕竟, 如果出现了这种情况, 你应当反思的是为什么出现了自己 = 自己这种奇怪的逻辑, 而不是去苛责自己 = 自己执行的不够快.

至此, 就是copy-and-swap套路的所有内容.

那么, 在C++11中, 事情发生了任何变化了吗? 我们在rule of three/five这一小节说过, 由于C++11引入了右值引用移动语义, 所以threefive: 你要新增一个移动构造函数, 与右值引用版的=操作符重载. 但实质上, 使用copy-and-swap套路的话, 你并不需要为=操作符再写一个右值引用版本的重载, 你只需要像下面这样, 添加一个移动构造函数就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class dumb_array
{
public:
// ...

// 移动构造函数
dumb_array(dumb_array&& other)
: dumb_array() // 调用默认构造函数, 这在本例中不是必须的.
{
swap(*this, other);
}

// ...
};

关于为什么不需要再写一个右值引用版的=操作符重载, 这个, 你可以先了解一下下一节的内容: 移动语义后, 再来看这里. 总之, 就是, 使用copy-and-swap套路, 在C++11中, 可以将所谓的rule of five变成rule of four and a half, 分别是:

1
2
3
4
5
1.      析构函数
2. 移动构造函数
3. 拷贝构造函数
4. `=`操作符重载
4.5. `swap`函数

三、移动语义

要理解移动语义, 其实用代码说话更容易让人理解. 我们就从下面的代码片断开始: 这是一个非常简单简陋的string的实现(注意它不是标准库中的std::string, 这里仅是我们自己实现的一个非常简陋的字符串类), 它内部使用一个指针成员持有着数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstring>
#include <algorithm>

class string
{
char* data;

public:

string(const char* p)
{
size_t size = strlen(p) + 1;
data = new char[size];
memcpy(data, p, size);
}

由于我们在这个简陋的实现里选择使用指针来管理数据, 即是作为类的设计者, 我们需要手动管理具体数据占用的内存的分配与释放, 所以按C++03标准的最佳实践, 我们应当遵循rule of three. 即是: 析构函数, 拷贝构造函数, =操作符的重载三者必须同时在场. 我们先在这里把析构函数拷贝构造函数补上, 关于=的重载, 后面一点再谈

1
2
3
4
5
6
7
8
9
10
11
~string()
{
delete[] data;
}

string(const string& that)
{
size_t size = strlen(that.data) + 1;
data = new char[size];
memcpy(data, that.data, size);
}

拷贝构造函数实现了拷贝的语义, 参数const string & thatconst引用, 这代表着它可以指向C++03标准中的右值, 即是一个表达式的值的最终类型是为上面这个简陋的string, 都可以作为拷贝构造函数的参数使用. 所以, 在假定我们还实现了类似于标准库中std::string+的重载的话, 我们可以以如下三种方式调用拷贝构造函数:

1
2
3
4
5
...
// x和y是两个string类型的变量
string a(x); // Line 1
string b(x + y); // Line 2, 这里假设我们实现了+的重载, 使得表达式 x + y 的类型也是 string
string c(some_function_returning_a_string()); // Line 3

现在就到了理解移动语义的关键点:

注意在第一行, 我们使用x作为参数去调用拷贝构造函数初始化a, 拷贝构造函数内部实现了深拷贝: 即完整的把x底层持有的数据拷贝了一份. 这没有任何毛病, 因为这就是我们想要的, 完成初始化a之后, ax分别持有两份数据, 后续再对x做一些数据修改的操作, 不会影响到a, 反之亦然. x显然也是C++03标准中的左值.

而第二行和第三行的参数, 无论是x + y还是some_function_returning_a_string(), 显然都不能算是C++03中的左值, 显然它们都是右值. 因为这两个表达式的运算结果虽然确实是一个string的实例, 但没有一个变量名去持有这些实例, 这些实例都是临时性的实例: 阅后即焚. 即在这个表达式之后, 你没有任何办法再去访问先前表达式指代的那个string实例. 按照C++03的规范, 这种临时量占用的内存在下一个分号之后就可以被扔掉了(更精确一点的说: 在整个包含着这个右值的表达式单元执行完毕之后. 再精确一点: 编译器的实现是不确定的, 你应当假定在表达式执行完毕后这个对象就被析构了, 但编译器多数情况下只会在遇到下个}的时候才析构这种临时对象).

这就给了我们一个灵感: 既然在下个分号之后, 再也无法访问x + ysome_function_returning_a_string()这两个表达式指向的临时string对象, 意味着我们可以在下个分号之前(换句话说, 在初始化bc的过程中: 在拷贝构造函数中), 随意蹂躏这两个临时量! 反正蹂躏完了也不会产生任何额外副作用.

基于这种思路, C++11标准中引入了一种新的机制叫右值引用, 右值引用一般用于函数重载(的参数列表)中, 它的目的是探测调用者传入的参数是否是C++03中的临时量. 一旦探测到调用者传入的是一个临时量的话, 重载调用机制就会匹配到有右值引用参数的重载中. 在这种函数内部, 你通过右值引用可以去访问这个临时量, 并在内部随意蹂躏这个临时量.

说起来有一点绕, 我们直接使用右值引用这个机制去写一个拷贝构造函数的重载, 如下所示:

1
2
3
4
5
string(string&& that)   // string&& is an rvalue reference to a string
{
data = that.data;
that.data = nullptr;
}

在向string的内部添加了这个拷贝构造函数后, string类内部目前就有了两个拷贝构造函数: string(const string& that)string(string&& that). 我们再回到上面的a, b, c三个初始化语句上. 这时, 由于x是一个左值, 所以a的初始化会匹配至string(const string& that). 而由于x + ysome_function_returning_a_string()是两个显然的临时量右值, 所以对于bc的初始化, 就会匹配到string(string&& that).

那么string(string&& that)内部到底做了什么事情呢? 看上面的代码就很显然, 它并没有像string(const string& that)那样去真正的拷贝一份数据, 而仅仅是把临时量内部持有的数据偷了过来, 用读书人的说法, 就叫移动.

这里需要注意, 在string(string&& that)执行结束之后, 临时量x + ysome_function_returning_a_string()还是会和C++03一样, 阅后即焚. 这两个临时对象依然会被析构. 临时量始终都是临时量, 从C++03到C++11, 这个行为没有变化. 只不过, 在析构之前, 我们已经通过string(string&& that)把它内部的数据偷掉了! 真正这两个临时量被析构的时候, 执行的只不过是delete nullptr罢了.

恭喜你, 到目前为止, 理解了C++11中移动语义的基本概念.

现在, 在进一步讨论之前, 让我们先把string类的=操作符重载再补上. 根据C++03的最佳实践之copy and swap idiom, 一个行为正确异常安全的=操作符重载应当被实现成下面这样:

1
2
3
4
5
6
    string& operator=(string that)
{
std::swap(data, that.data);
return *this;
}
};

看到上面这个代码你是不是准备问我, “右值引用哪去了? “. 我的回答是: “这里并不需要右值引用”, 至于为什么, 我们再来看下面三行代码:

1
2
3
4
5
// x, y, a, b, c 均是string类型的变量

a = x; // Line 4
b = x + y; // Line 5
c = some_function_returning_a_string(); // Line 6

我们先来分析第四行(Line 4).

  1. 由于string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x先去初始化that, 你可以理解为string that(x)这种. 由于x是一个左值. 所以that的初始化使用的是string(const string& that)这个构造函数: 即thatx的一个完整副本, 深度拷贝了x的数据
  2. 在执行std::swap(data, that.data)的过程中, a持有的数据与that持有的数据相互交换. 至此, a持有的数据其实就是x数据的一个完整副本.
  3. return *this执行之后, that由于函数退栈, 被析构. that中持有的数据(其实是原a持有的数据)被析构函数安全释放

总结起来: a = x内部, 将x的数据完整的复制了一份给a, 再把a原持有的数据安全析构掉了.

我们再来分析第五行(Line 5)

  1. 由于string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x + y先去初始化that, 你可以理解为string that(x)这种. 由于x + y是一个临时量右值, 所以that的初始化使用的是string(string&& that)这个构造函数, 在这个构造函数内部, that偷掉了x + y内部持有的数据, 并没有发生数据拷贝.
  2. 在执行std::swap(data, that.data)的过程中, b持有的数据与that持有的数据相互交换. 至此, x + y原持有的数据经过二次转手, 来到了b的手上. 而b原持有的数据, 则交换给了that
  3. return *this执行之后, that由于函数退栈, 被析构. that中持有的数据(其实是原b持有的数据)被析构函数安全释放

总结起来: b = x + y内部, 经过两次转手, 将x + y持有的数据转交给了b, 而b原持有的数据被完全的析构掉了.

第六行和第五行类似.

至此, 你可算是基本明白了C++11中的移动语义. 现在, 请回头再看copy-and-swap小节的末尾, 你就会明白, 为什么`copy-and-swap + rule of three + C++11 == rule of four and a half了。

Author

simonisacoder

Posted on

2020-04-15

Licensed under

Comments