聊聊c++中的移动语义

c++11中引入的移动语义和std::move函数,是一个非常容易让人疑惑的点。正好最近在开发中遇到了几个std::move使用不当造成的bug,网上关于移动语义的资料很多,正好借着这个机会,按照我的理解好好盘一盘c++11里的移动语义到底做了一件什么事儿。

所谓的赋值语句,到底做了一件什么事儿

想聊明白移动语义,很多资料其实上来就在讲左右值和左右值引用,诚然这两个概念在C++11里通常是绑定在一起来理解的,不过我还是想换个思路,从赋值语句开始来聊聊这件事。为了更容易去理解,我们以下的讨论只针对用户自定义的class和struct,不考虑c++中的基础数据类型。

我们首先通过几个case来看一下类对象调用赋值语句会发生什么情况。

case 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class Test {
public:
Test(int value): value_(value) {}
int value() const {return value_;}
private:
int value_;
};

int main() {
Test a(1), b(2);
a = b;
std::cout << t.value() << std::endl;
std::cout << a.value() << std::endl;
return 0;
}

这段代码地输出结果很显然,两行都是b的value值2,代码执行的逻辑是符合我们预期的,在执行a = b的时候,程序将value的值b赋值给了a这个新对象,尽管a与b是两个完全独立的对象,赋值语句将b的值copy给了a。

case 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class Test {
public:
Test(int value): value_(value) { data_ = new int[value]; }
~Test() {delete data_;}
int value() const {return value_;}
int* data_ptr() {return data_;}
private:
int value_;
int* data_;
};

int main() {
Test a(1), b(2);
Test a = b;
std::cout << (uint64_t) a.data_ptr() << std::endl;
std::cout << (uint64_t) t.data_ptr() << std::endl;
return 0;
}

这段代码逻辑也很简单,合理的预期是用户创建Test对象的同时,会申请一块长度为value的内存给这个对象持有。我们试着执行这段函数,首先从打印结果上来看,两行打印出来的对象持有的这块内存地址是相同的,另外不出预料的话,程序会报出一个double free的运行时错误,这显然不符合了我们的设计初衷,a和b共同持有了一块相同的内存,任何一个对象对data_的操作都会影响到另外一个对象中持有的data_。

c++文档在赋值运算符一章里对赋值运算符有这样的定义[^1]:

copy assignment operator replaces the contents of the object a with a copy of the contents of b (b is not modified). For class types, this is a special member function, described in copy assignment operator.

在case1和case2这种情况里,我们没有重载过赋值运算符,a = b其实调用了默认的赋值运算符,a的content会直接repace成b的值。这个逻辑在case1中没有问题,但在case2中这种需要自己管理内存的场景下,指针的值(内存地址)也将作为一个content直接copy到b中,显然不能满足我们的需求。因此为了能让每个对象自己持有内存地址,我们需要重载opertor=,来让c++做一些特定的操作。

case 3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Test {
public:
explicit Test(int size): size_(size) { data_ = new int[size_]; }
~Test() {delete data_;}

int size() const {return size_;}
int* data_ptr() {return data_;}

Test &operator=(const Test &other) {
std::cout << "This is copy= operator" << std::endl;
if (&other != this) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(&other.data_[0], &other.data_[0] + size_, &data_[0]);
}
return *this;
}
private:
int size_;
int* data_;
};

我们将Test类增加一个=操作符号的重载函数,再次执行case 2的main函数,此时输出会变成:

1
2
3
This is copy= operator
94493275451056
94493275451088

好了,我们看到内存地址已经不同了。

通过上面的三个case,我们可以总结出两个关于类对象进行赋值运算相关的特性:

  • c++中针对赋值运算的实现是通过赋值运算符(operator=)函数来实现的
  • c++中提供了默认的赋值运算符(浅拷贝),但是在某些情况下默认赋值运算符满足我们的需求,用户需要自行重载赋值运算符来实现更复杂的功能,例如case 2和case 3中的内存拷贝(深拷贝)

拷贝赋值与移动赋值

其实大部分场景下,通过case 3中类似的操作,即对Test& operator=(const Test& other)进行重载,基本上能满足我们日常的开发需求。

但仔细观察很容易发现,每次调用一次赋值运算符,data_都需要重新申请一块新内存并进行一次内存拷贝,是否所有的场景里都需要申请新的内存呢?

我们看一下下面的例子:

case 4

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
32
33
#include <iostream>
class Test {
public:
explicit Test(int size): size_(size) { data_ = new int[size_]; }
~Test() {
std::cout << "~Test" << std::endl;
delete data_;
}

int size() const {return size_;}
int* data_ptr() {return data_;}

Test &operator=(const Test &other) {
std::cout << "This is copy= operator" << std::endl;
if (&other != this) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(&other.data_[0], &other.data_[0] + size_, &data_[0]);
}
return *this;
}
private:
int size_;
int* data_;
};

int main() {
Test a(1);
a = Test(10);
return 0;
}

上述代码,我们先创建了一个对象a,然后对a通过a = Test(10)进行了一次更新。程序输出如下:

1
2
3
This is copy= operator
~Test
~Test

我们观察输出的结果,这里显然进行了一次内存申请和内存拷贝。仔细思考这个过程,Test(10)首先创建了一个临时对象申请了一块内存,在这条赋值语句执行结束了之后,这个临时的对象生命周期就已经结束了,此时临时对象中的数据也销毁了。进一步思考,总之临时变量在这次运行结束后也会被销毁,我们若能直接把这个临时对象中的资源承接过来转移到a中,似乎就可以减少一次内存申请和拷贝,并且对整个程序来说也是安全的。

于是,关于赋值,貌似出现了两种选择:

  1. 赋值之后原对象需要保留,此时需要将原对象中的值拷贝到新对象(拷贝语义 - 拷贝赋值
  2. 赋值之后原对象可销毁,此时可以将原对象中的资源转移到新对象并等待原对象销毁(移动语义 - 移动赋值

显然,一个operator=的重载已经没办法满足我们的需求,case 3给出了一个典型的拷贝赋值的实现,显然我们还需要一个移动赋值的实现。要实现这个功能,最重要的是要把上述的两种情况合理表达出来(这涉及到区分原对象是否有保留的价值)。这里c++为了更好地描述类似地情况,引入了左值和右值的概念,关于左值右值更详细的描述,可以参考[^2]和[^3],强烈建议好好读一读。在这里,我们只需要清楚,在移动语义下,右值才可以在使用完成后直接销毁,承担资源转移的功能

于是,我们可以声明一个Test &operator=(Test &&other)函数,来专门处理右值。代码如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>

class Test {
public:
explicit Test(int size) : size_(size) { data_ = new int[size_]; }
~Test() {
std::cout << "~Test" << std::endl;
delete[] data_;
}

int size() const { return size_; }
int *data_ptr() { return data_; }

Test &operator=(const Test &other) {
std::cout << "This is copy= operator" << std::endl;
if (&other != this) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(&other.data_[0], &other.data_[0] + size_, &data_[0]);
}
return *this;
}

Test &operator=(Test &&other) {
std::cout << "This is move= operator" << std::endl;
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}

private:
int size_;
int *data_;
};

int main() {
Test a(1);
a = Test(10);
return 0;
}

输出:

1
2
3
This is move= operator
~Test
~Test

运行上述代码,我们可以观察到程序执行了移动赋值,资源在两个对象间进行了转移。

拷贝构造与移动构造

同理,移动语义与拷贝语义的概念,我们也可以拓展到构造函数上,引入拷贝构造与移动构造的概念。例如对于如下代码。

1
2
3
Test a(1);
Test b = a;
Test c = std::move(a);

Test b = a的实现需要依赖Test(Test& test)构造函数实现,类似移动赋值,Test c = std::move(a)也需要来专门识别右值的构造函数Test(Test&& test)来实现,完整代码如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Test {
public:
explicit Test(int size) : size_(size) { data_ = new int[size_]; }

Test(Test& test): size_(0), data_(nullptr){
std::cout << "copy construct" << std::endl;
*this = test;
}

Test(Test&& test): size_(0), data_(nullptr){
std::cout << "move construct" << std::endl;
*this = test;
}

~Test() {
std::cout << "~Test" << std::endl;
delete[] data_;
}

int size() const { return size_; }
int *data_ptr() { return data_; }

Test &operator=(const Test &other) {
std::cout << "This is copy= operator" << std::endl;
if (&other != this) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(&other.data_[0], &other.data_[0] + size_, &data_[0]);
}
return *this;
}

Test &operator=(Test &&other) {
std::cout << "This is move= operator" << std::endl;
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}

private:
int size_;
int *data_;
};

int main() {
Test a(1);
Test b = a;
Test c = std::move(a);
return 0;
}

有兴趣可以自行执行一下上述代码,在这里就不深入讨论了。关于移动构造和拷贝构造更详细的资料可以查看[^4]、[^5]、[^6]和[^7]。

总结

好了,到这里我们可以简单总结一下了:

  • 移动语义的主要目的就是为了性能优化,是为了将一个即将释放的目标对象中的资源移动到新对象里,以便以最小的代价构建新对象
  • 移动语义的相关操作中,被移动的对象在移动后资源已经被移动走,该对象应该被释放或生命周期结束自动释放,不应有任何后续的操作
  • c++中的自定义的类原生不支持移动语义,而是通过重载=运算符和构造函数来实现的,其核心的原理是在移动语义中,被移动的对象必须是右值,因此可以通过重载参数为右值引用的=运算符和构造函数,代码在编译过程中就可以根据类型推断来判断释放用户要进行移动操作,具体如何移动是根据用户的实现来进行的。
  • 对于一个类来说,移动语义相关的函数并不是必须的,在设计类时若没有显式重载移动语义相关的函数,默认是会直接按照拷贝语义相关的操作来进行的

回过头说一嘴std::move

到此,我们回过头来再看一下std::move这个操作。在很多移动语义相关的逻辑里我们都能看到std::move的身影。很多文档中都说std::move实际上没有移动任何东西,只是将左值转换为右值。基于上述的一系列讨论,我们似乎更容易理解std::move存在的意义。在代码里,我们可能有很多需要将左值进行移动的操作,移动语义又是通过右值引用作为参数来推断使用哪个构造或者赋值函数的。在这种情况下,std::move将左值类型转换成了右值,进而触发了移动语义相关的构造或者赋值函数。

因此,std::move相当于只进行了类型转换,真正移动相关的操作还是通过类内部的移动赋值和移动构造函数来实现的。再进一步,假如某一个类自己本身没重载移动赋值和移动构造函数,尽管在一些场景下调用了std::move,相关操作还是会被默认的拷贝函数承接,不会触发任何移动相关的操作。

[^1]: Assignment operators - cppreference.com

[^2]: Value categories - cppreference.com

[^3]: Lvalues 和 Rvalues (C++)

[^4]: Move constructors - cppreference.com

[^5]: Copy constructors - cppreference.com

[^6]: 复制构造函数和复制赋值运算符 (C++)

[^7]: 移动构造函数和移动赋值运算符 (C++)