0%

C++11的std::move

本文介绍了C++11中的move语义,以及右值引用的产生逻辑。

性能问题 (1)

首先,看下面这段代码:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <iostream>
#include <string.h>

class Foo
{
private:
char * data;
public:
virtual ~Foo()
{
std::cout << "~Foo() " << (void*)this << "/" << (void*)data << std::endl;
if(data != NULL)
{
delete []data;
data = NULL;
}
}

Foo(const char * s) : data(NULL)
{
std::cout << "Foo(const char * s) ";

if(s)
{
int len = strlen(s);
data = new char[len+1];
memcpy(data, s, len+1);
}

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo(const Foo & other) : data(NULL)
{
std::cout << "Foo(const Foo & other) " << (void*)&other << "/" << (void*)other.data << "-->";

if(other.data)
{
int len = strlen(other.data);
data = new char[len+1];
memcpy(data, other.data, len+1);
}

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo& operator= (const Foo & other)
{
std::cout << "Foo& operator= (const Foo & other) " << (void*)&other << "/" << (void*)other.data << "==>";

if(this != &other)
{
if(NULL != data)
{
delete []data;
data = NULL;
}

if(NULL != other.data)
{
int len = strlen(other.data);
data = new char[len+1];
memcpy(data, other.data, len+1);
}
}

std::cout << (void*)this << "/" << (void*)data << std::endl;
return *this;
}
};

Foo func1()
{
Foo f1("abc");
return f1;
}

int main()
{
Foo f2 = func1();
return 0;
}

编译(编译的时候,加选项-fno-elide-constructors是为了禁止RVO优化(return value optimization),这样,更容易暴露性能问题):

1
g++ -fno-elide-constructors --std=c++11 Foo1.cpp -o foo1

运行 foo1 输出如下:

1
2
3
4
5
6
Foo(const char * s)    0x7fff8af40050/0x21e8010
Foo(const Foo & other) 0x7fff8af40050/0x21e8010-->0x7fff8af40090/0x21e8030
~Foo() 0x7fff8af40050/0x21e8010
Foo(const Foo & other) 0x7fff8af40090/0x21e8030-->0x7fff8af40080/0x21e8010
~Foo() 0x7fff8af40090/0x21e8030
~Foo() 0x7fff8af40080/0x21e8010

现在来看性能问题:

  • 输出第1行:在函数func1中,构造Foo对象f1(调用构造函数),其data在堆空间的0x21e8010处。
  • 输出第2行:函数func1返回时,把f1拷贝到临时对象(调用拷贝构造函数),其data在堆空间0x21e8030处。
  • 输出第3行:析构f1。
  • 输出第4行:在main函数中,把函数func1返回的临时对象拷贝到f2(调用拷贝构造函数),其data在堆空间0x21e8010处。注意f1.data和f2.data地址相同,但这只是一个巧合而已,它们是不同的字符串:f1被析构后,构造f2分配堆空间,恰好分配到相同的地址。
  • 输出第5行:析构临时对象。
  • 输出第6行:析构f2。

可以看出,为避免浅拷贝问题,Foo对象的每一次拷贝,都需要分配堆空间,若class Foo很复杂,有很多指针成员,拷贝将带来更大的性能损耗。另一方面,f1不会再被访问,而临时对象不能被访问,它们非得拥有一个完整的拷贝吗?我们来尝试优化一下。

优化尝试 (2)

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <iostream>
#include <string.h>

class Foo
{
private:
char * data;
public:
virtual ~Foo()
{
std::cout << "~Foo() " << (void*)this << "/" << (void*)data << std::endl;
if(data != NULL)
{
delete []data;
data = NULL;
}
}

Foo(const char * s) : data(NULL)
{
std::cout << "Foo(const char * s) ";

if(s)
{
int len = strlen(s);
data = new char[len+1];
memcpy(data, s, len+1);
}

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo(Foo & other)
{
std::cout << "Foo(Foo & other) " << (void*)&other << "/" << (void*)other.data << "-->";

data = other.data;
other.data = NULL;

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo& operator= (Foo & other)
{
std::cout << "Foo& operator= (Foo & other) " << (void*)&other << "/" << (void*)other.data << "==>";

if(this != &other)
{
if(NULL != data)
{
delete []data;
data = NULL;
}

data = other.data;
other.data = NULL;
}

std::cout << (void*)this << "/" << (void*)data << std::endl;

return *this;
}
};

Foo func1()
{
Foo f1("abc");
return f1;
}

int main()
{
func1();
return 0;
}

看构拷贝造函数和赋值重载,我们做了3点修改:

  1. 参数由const Foo& other变成了Foo& other。
  2. 本对象直接偷走了other的资源(data成员),而不是重新分配。
  3. 把other.data设置为NULL;这是为了避免析构时,同一个data被两次delete。

来看看效果:
编译:

1
# g++ -fno-elide-constructors --std=c++11 Foo2.cpp -o foo2

运行 foo2 输出如下:

1
2
3
4
Foo(const char * s)    0x7ffe6abae6a0/0x9f3010
Foo(Foo & other) 0x7ffe6abae6a0/0x9f3010-->0x7ffe6abae6d0/0x9f3010
~Foo() 0x7ffe6abae6a0/0
~Foo() 0x7ffe6abae6d0/0x9f3010
  • 输出第1行:在函数func1中,构造Foo对象f1(调用构造函数),其data在堆空间的0x9f3010处。
  • 输出第2行:函数func1返回时,把f1拷贝到临时对象(调用拷贝构造函数)。由于我们的修改,临时对象偷走了f1的data。f1.data变为NULL。
  • 输出第3行:析构f1。注意它的data已经变为0(NULL),无需调用delete。
  • 输出第4行:析构临时对象,delete掉它从f1偷来的data。

基本符合我们的预期,不会再被访问的f1没有必要保留它的资源,所以临时对象偷走它不会带来什么负面影响。但,显然这是一个失败的尝试:

  1. 任何一个Foo的对象,只要它被别人拷贝一次(无论是拷贝构造还是赋值),它就丢失了自己的资源(data变为NULL)。对于不再被访问的对象或者临时对象,这没什么问题,但是对于其他对象,显然不可接受。
  2. 在main函数中,若写 Foo f2 = func1();则编译失败: error: no matching function for call to ‘Foo::Foo(Foo)’。原因是func1返回的是一个右值,不能匹配类型Foo&。所以,编译器试图找签名为Foo::Foo(Foo)的构造函数。找不到导致失败。

C++11的解决方案 (3)

上面的尝试注定是失败的,原因是我们把拷贝构造函数和赋值重载写成那个样子之后,一个Foo对象只要被拷贝一次(拷贝构造或者赋值)就丢失了自己的资源。这对所有的Foo对象都生效, 没有分清哪些对象可以丢失资源,哪些不行。事实上,只要分得清,这个优化的思路还是可行的。而C++11也就是这么做的。
哪些对象可以丢失自己的资源呢,换句话说,哪些对象的资源可以被move到别的对象呢?答案是:右值。左值右值的概念在C语言以及C++11之前一直存在,一个比较严格的区分方式是:若可以进行&运算则为左值,否则为右值。但在C++11中,右值又有了新的意义:它们的资源可被move走,自己剩下一个空壳子所幸的是,这只是理解右值另一个角度而已:左值右值的划分和C++11出现之前是一致的——可以进行&运算的为左值,否则为右值。但是,在C++11中更复杂,出现了五个名词:lvalue,
xvalue, prvalue, glvalue, rvalue,我将在C++11中的值的类型中进行解释,这里我们先按原来的方式找出一些右值:字面量、临时对象(编译器把这两种都叫temporary: 1.函数返回的临时对象; 2.形如Foo(“abc”)的匿名对象)等等。
还有一些左值,可以被强制move:编译器或者程序员知道它们不会再被访问了。

总结一下,可以被move的值:

  • 右值;例如:前文中介绍的临时对象(函数返回时的那个)。
  • 左值,但编译器知道它不会再被访问了,隐式地把它强制为右值;例如:前面代码中,函数func1的内部变量f1。
  • 左值,程序员知道它不会再被访问了,显式地把它强制转换为右值;

有了可被move的对象,C++11通过移动语义来解决前述性能问题:可以为一个类定义移动拷贝构造函数移动赋值重载函数,拿Foo来举例:

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
//移动拷贝构造函数
Foo(Foo && other) noexcept
{
std::cout << "Foo(Foo && other) " << (void*)&other << "/" << (void*)other.data << "-->";

data = other.data;
other.data = NULL;

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

//移动赋值重载函数
Foo& operator= (Foo && other) noexcept
{
std::cout << "Foo& operator= (Foo && other) " << (void*)&other << "/" << (void*)other.data << "==>";

if(this != &other)
{
//也可以实现为: 交换this的data和other的data (总之other被修改了);我们为了演示other丢失资源,所以把other.data设置
//为NULL(并delete this.data);
if(data != NULL)
{
delete []data;
data = NULL;
}

data = other.data;
other.data = NULL;
}

std::cout << (void*)this << "/" << (void*)data << std::endl;

return *this;
}

Foo && 是什么鬼?它就是右值引用,是C++11引入的一个新类型,记住,它是一个新类型。这个类型的值:

  • 可以引用(或者叫绑定)右值。
  • 不能引用(或者叫绑定)左值,但是可以通过强制的办法(std::move)达到。
  • 本身是一个左值(就和之前熟悉的引用&一样),我们可以对它取地址,得到的是被引用的值的地址(就和之前熟悉的引用&一样)。

在移动拷贝构造函数和移动赋值重载函数中,我们按照之前的优化思路,偷走other的资源。好,现在有了两种拷贝构造函数和两种赋值重载函数,只要分清什么时候调用哪个就行了。对应前文,可以调用移动构造或移动赋值重载的情形有3种:

  • 右值;例如:前文中介绍的临时对象(函数返回时的那个)。若它被拷贝时,自动匹配移动拷贝或移动赋值函数;

  • 左值,但编译器知道它不会再被访问了;例如:前面代码中,函数func1的内部变量f1。若它被拷贝时,编译器自动将它强制为右值引用,匹配移动拷贝或移动赋值函数;

  • 左值,程序员知道它不会再被访问了;被拷贝时,程序员自己调用std::move来显式地强制为右值引用,匹配移动拷贝或者移动赋值函数。

    看看我们的新版本:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include <iostream>
#include <string.h>

class Foo
{
private:
char * data;
public:
virtual ~Foo()
{
std::cout << "~Foo() " << (void*)this << "/" << (void*)data << std::endl;
if(data != NULL)
{
delete []data;
data = NULL;
}
}

Foo(const char * s) : data(NULL)
{
std::cout << "Foo(const char * s) ";

if(s)
{
int len = strlen(s);
data = new char[len+1];
memcpy(data, s, len+1);
}

std::cout << (void*)this << "/" << (void*)data << std::endl;

}

Foo(const Foo & other) : data(NULL)
{
std::cout << "Foo(const Foo & other) " << (void*)&other << "/" << (void*)other.data << "-->";

if(other.data)
{
int len = strlen(other.data);
data = new char[len+1];
memcpy(data, other.data, len+1);
}

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo& operator= (const Foo & other)
{
std::cout << "Foo& operator= (const Foo & other) " << (void*)&other << "/" << (void*)other.data << "==>";

if(this != &other)
{
if(NULL != data)
{
delete []data;
data = NULL;
}

if(NULL != other.data)
{
int len = strlen(other.data);
data = new char[len+1];
memcpy(data, other.data, len+1);
}
}

std::cout << (void*)this << "/" << (void*)data << std::endl;

return *this;
}

Foo(Foo && other) noexcept
{
std::cout << "Foo(Foo && other) " << (void*)&other << "/" << (void*)other.data << "-->";

data = other.data;
other.data = NULL;

std::cout << (void*)this << "/" << (void*)data << std::endl;
}

Foo& operator= (Foo && other) noexcept
{
std::cout << "Foo& operator= (Foo && other) " << (void*)&other << "/" << (void*)other.data << "==>";

if(this != &other)
{
//也可以实现为: 交换this的data和other的data (总之other被修改了);我们为了演示other丢失资源,所以把other.data设置
//为NULL(并delete this.data);
if(data != NULL)
{
delete []data;
data = NULL;
}

data = other.data;
other.data = NULL;
}

std::cout << (void*)this << "/" << (void*)data << std::endl;

return *this;
}
};

Foo func1()
{
Foo f1("abc");
return f1;
}

int main()
{
Foo f2 = func1();
Foo f3(f2);
Foo f4(std::move(f3));
return 0;
}

编译:

1
# g++ -fno-elide-constructors --std=c++11 Foo3.cpp -o foo3

运行 foo3,输出如下:

1
2
3
4
5
6
7
8
9
10
Foo(const char * s)    0x7ffdb30fc3f0/0xdc8010
Foo(Foo && other) 0x7ffdb30fc3f0/0xdc8010-->0x7ffdb30fc440/0xdc8010
~Foo() 0x7ffdb30fc3f0/0
Foo(Foo && other) 0x7ffdb30fc440/0xdc8010-->0x7ffdb30fc430/0xdc8010
~Foo() 0x7ffdb30fc440/0
Foo(const Foo & other) 0x7ffdb30fc430/0xdc8010-->0x7ffdb30fc420/0xdc8030
Foo(Foo && other) 0x7ffdb30fc420/0xdc8030-->0x7ffdb30fc410/0xdc8030
~Foo() 0x7ffdb30fc410/0xdc8030
~Foo() 0x7ffdb30fc420/0
~Foo() 0x7ffdb30fc430/0xdc8010
  • 输出第1行:构造f1。
  • 输出第2行:构造临时对象。f1的data被move到临时对象(通过移动拷贝构造函数)。
  • 输出第3行:析构f1。data已经被move,故为0(NULL)。
  • 输出第4行:构造f2。临时对象的data被move到f2(通过移动拷贝构造函数)。
  • 输出第5行:析构临时对象。data已经被move,故为0(NULL)。
  • 输出第6行:构造f3。由于参数是一个左值,我们有没有强制转化为右值引用,所以调用普通的构造函数,重新分配data。这时候,f2和f3都有自己的data。
  • 输出第7行:构造f4。f3是一个左值,但是我们把它强制转化为右值引用,所以调用移动拷贝构造函数。
  • 输出第8行:析构f4。
  • 输出第9行:析构f3。data已经被move,故为0(NULL)。
  • 输出第10行:析构f2。

可见,move语义的引入,解决了上文提出的深拷贝新能问题。

移动构造移动赋值和异常 (4)

当你写移动构造和移动赋值函数的时候,要这样做:

  1. 尽量保证它们不抛出异常。这也是合理的,因为移动构造和移动赋值函数一般不分配内存,仅仅交换指针而已,不会抛异常;
  2. 然后,为移动构造和移动赋值函数加上noexcept修饰符;

如果不这样做,会有一种情况,你希望使用移动语义,但它却不使用;这种情况还比较常见,它就是:当std::vector自动resize的时候,你当然希望拷贝老对象到新分配的空间的时候,使用移动语义,但是它却不使用(为什么?)。

小结 (5)

本文通过示例,讲述了move产生的背后动机,并引入了右值引用的概念。有关右值引用,我将在C++11中的右值引用中介绍。

写的不错,有赏!