本文介绍了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点修改:
参数由const Foo& other变成了Foo& other。
本对象直接偷走了other的资源(data成员),而不是重新分配。
把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没有必要保留它的资源,所以临时对象偷走它不会带来什么负面影响。但,显然这是一个失败的尝试:
任何一个Foo的对象,只要它被别人拷贝一次(无论是拷贝构造还是赋值),它就丢失了自己的资源(data变为NULL)。对于不再被访问的对象或者临时对象,这没什么问题,但是对于其他对象,显然不可接受。
在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) { 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) { 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) 当你写移动构造和移动赋值函数的时候,要这样做:
尽量保证它们不抛出异常。这也是合理的,因为移动构造和移动赋值函数一般不分配内存,仅仅交换指针而已,不会抛异常;
然后,为移动构造和移动赋值函数加上noexcept
修饰符;
如果不这样做,会有一种情况,你希望使用移动语义,但它却不使用;这种情况还比较常见,它就是:当std::vector
自动resize
的时候,你当然希望拷贝老对象到新分配的空间的时候,使用移动语义,但是它却不使用(为什么?)。
小结 (5) 本文通过示例,讲述了move产生的背后动机,并引入了右值引用的概念。有关右值引用,我将在C++11中的右值引用 中介绍。