0%

C++高阶知识:深入分析移动构造函数及其原理

移动构造函数是C++11中新增加的一种构造函数,其作用是提高程序性能。今天我们就细扒一下它的工作原理,看看它是怎么提高性能的。

移动构造函数的由来

在讲解移动构造函数之间,我们先来了解一下在没有移动构造函数之前哪里有性能瓶颈吧。我们来举个例子:

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
#include <iostream>
#include <vector>
#include <string.h>

class A {
public:
A(){
std::cout << "A construct..." << std::endl;
ptr_ = new int(100);
}

A(const A & a){
std::cout << "A copy construct ..." << std::endl;
ptr_ = new int();
memcpy(ptr_, a.ptr_, sizeof(int));
}

~A(){
std::cout << "A deconstruct ..." << std::endl;
if(ptr_){
delete ptr_;
}
}

A& operator=(const A & a) {
std::cout << " A operator= ...." << std::endl;
return *this;
}

int * getVal(){
return ptr_;
}
private:
int *ptr_;
};

int main(int argc, char *argv[]){
std::vector<A> vec;
vec.push_back(A());
}

//clang++ -g -o testmove test_move.cpp

上面这段代码很简单对吧,就是定义了一个普通的类A。在main函数中创建一个vector,然后用A类创建一个对象,并把它放入到vector中。这样的程序在C++中是很常见,但就是这样很常见的代码确有非常大的性能问题。为什么呢?因为在将A对象放入vector时,在vector内部又创建了一个A对象,并调用了其拷贝构造函数进行了深拷贝。

我们看一下上面代码运行的结果就一目了然了,其结果如下:

1
2
3
4
A construct...          //main中创建的A对象
A copy construct ... //vector内部创建的A对象
A deconstruct ... //vector内部创建的A对象被析构
A deconstruct ... //main中创建的A对象析构

上面的运行结果印证了我们之前的讨论,在vector内部确实又创建了一个A对象。如果在A对象中分配的是一个比较大的空间,且vector中要存放大量的A对象时(如 100000个),就会不断的做分配/释放堆空间的操作,这会造成多在的性能消耗呀!

有什么办法可以解决这个问题呢?这就要用到我们今天要讲的移动构造函数了。

移动构造函数的使用

从C++11开始,类中不光可以有构造函数、拷贝构造函数,还增加了一种新的构造函数即移动构造函数。移动构造函数起什么作用呢?就像它的名子一样,它可以实现指针的移动,即可以将一个对象中的指针成员转移给另一个对象。指针成员转移后,原对象中的指针成员一般要被设置为NULL,防止其再被使用。

还是以我们上面的代码为例,如果我们有了移动构造函数,那么在将A对象push到vector时,vector内部虽然还是会再分A对象,但在进行数据的拷贝时就不是深拷贝了,而变成了浅拷贝,这样就大大提高了程序的执行效率。

如何为A增加移动构造函数呢?我们来看一下代码:

1
2
3
4
5
6
7
8
9
10
11
class A {
public:
...

A(A && a){
std::cout << "A move construct ..." << std::endl;
ptr_ = a.ptr_;
a.ptr_ = nullptr;
}
...
};

在 A 类中增加上面代码即可,上面的代码看起来与普通构造函数好像没什么两样,但你细心观察可以发现该构造函数的参数是 A && a。咦!&&这在以前还真没见过对吧。它表示的是C++中的右值,也就是只有创建A对象时传入的是右值才会执行该构造函数。

对于右值后面我们还会做详细介绍,现在我们只要知道要想让这个函数起作用,就必须传给它一个右值就可以了。如么问题来了,我们这个例子中如何传递给它一个右值呢?这就要用到 std::move 函数了。

std::move可以将任何一值变成右值,所以我们不管3721,在创建A对象时直接调用std::move”造”个右值给它就好了。于是我们修改main代码如下:

1
2
3
4
int main(int argc, char *argv[]){
std::vector<A> vec;
vec.push_back(std::move(A()));
}

经这样修后,我们运行一下程序看现在它的结果是什么样子吧。结果如下:

1
2
3
4
A construct...          //main中创建A对象
A move construct ... //vector内部通过移动构造函数创建A对象,减少了对堆空间的频繁操作
A deconstruct ... //释放vector中的A对象
A deconstruct ... //释放main中创建的A对象

从上面的结果我们可以看出我们新增加的移动构造函数确实被调用了,这样就大大减了频繁对堆空间的分配/释放操作,从而提高了程序的执行效率。这里需要注意的是,在移动构造函数操作之后原A对象的指针地址已经指向NULL了,因此此时就不能再通过其访问之前的堆空间了。

C++的左值与右值

右值是C++从C继承来的概念,最初是指=号右边的值。但现在C++中的右值已经与它最初的概念完全不一样了。在C++中右值指的的临时值或常量,更准确的说法是保存在CPU寄存器中的值为右值,而保存在内存中的值为左值。

可能有很多同学对计算机系统的底层不太了解,我们这里做一个简单的介绍。计算机是由CPU、内存、主板、总线、各种硬件等组成的,这个大家应该都清楚。而CPU又是由逻辑处理器,算术单元、寄存器等组成的。我们的程序运行时并不是直接从内存中取令运行的,因为内存相对于CPU来说太慢了。一般情况下都是先将一部分指令读到CPU的指令寄存器,CPU再从指令寄存器中取指令然后一条一条的执行。对于数据也是一样,先将数据从内存中读到数据寄存器,然后CPU从数据寄存器读数据。以Intel的CPU为例,它就包括了 EAX、EBX、ECX、EDX…多个通用寄存器,这样就可以让CPU更高效的工作。

比如说一个常数5,我们在使用它时不会在内存中为其分配一个空间,而是直接把它放到寄存器中,所以它在C++中就是一个右值。再比如说我们定义了一个变量 a,它在内存中会分配空间,因此它在C++中就是左值。那么a+5是左值还是右值呢?当然是右值对吧,因为a+5的结果存放在寄存器中,它并没有在内存中分配新空间,所以它是右值。

通过上面的描述你就应该对 C++ 中的左值和右值比较清楚了。我们来看个例子吧:

1
2
3
4
5
6
7
8
9
#include<iostream>

int main(int argc, char *argv[]){

int && a = 5; // 正确,5会被直接存放在寄存器中,所以它是右值
int b = 10;
int && c = b; // 错误,b在内存中有空间,所以是右值;右值不能赋值给左值
int && d = b + 5; // 正确,虽然 b 在内存中,但 b+5 的结果放在寄存器中,它没有在内存中分配空间,因此是右值
}

在C++中使用&&表示右值引用,在上面的例子中,我首先将常数5赋值给右值引用a,因为常数5是右值,所以这条语句可以编译成功;紧接着我定义了变量b,因为它是左值,所以当将b赋值给右直引用c时,编译器会报错;最后一行将b+5赋值给右值引用d,由于b+5不会在内存中占用空间所以这也是右值,因此最后一句编译也没有任何问题。

接下来我们看一个有意思的情况,代码如下:

1
2
3
...
int && e = a;
..

这种情况是否是合法的呢?实际上当你这么做的时候编译器会报错,因为a是左值而e必须接收右值。那有没有办法将一个左值转成右值呢?这个问题我们前面其实已经回答过了,通过std::move就可以解决这个问题。我们来看一个例子:

1
2
3
...
int && e = std::move(a);
...

之前我们直接将a赋值给e是肯定不行的,但上面的操作编译器就不会报错了,因为通过std::move可以将一个左值转成右值。但这里有一点需要特别注意:e虽然接收的必须是右值,但它本身是左值。换句话说e是一种特殊的变量,它是只能接收右值的变量。我们再从左值的本质来看,e也是占内存空间的,所以它肯定是左值。

std::move的实现

上面我们已经看到了std::move的神奇之处,你可能很好奇std::move是如何做到的呢?实际上std::move就是一个类型转换器,将左值转换成右值而以。我们来看一下它的实现吧!

1
2
3
4
5
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_case<typename remove_reference<T>::type&&>(t);
}

std::move的实现还是挺简单的就这么几行代码,但要理解这几行代码可不容易。下面我们就来对它做下详细分析。

通用引用

首先我们来看一下move的输入参数,move的输入参数类型称为通用引用类型。什么是通用引用呢?就是它既可以接收左值也可以接收右值。我们来看一下例子:

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

template<typename T>
void f(T&& param){
std::cout << "the value is "<< param << std::endl;
}

int main(int argc, char *argv[]){

int a = 123;
auto && b = 5; //通用引用,可以接收右值

int && c = a; //错误,右值引用,不能接收左值

auto && d = a; //通用引用,可以接收左值

const auto && e = a; //错误,加了const就不再是通用引用了

func(a); //通用引用,可以接收左值
func(10); //通用引用,可以接收右值
}

在上面代码中有两种类型的通用引用: 一种是auto,另一种是通过模板定义的T&&。实际上auto就是模板中的T,它们是等价的。下面我们就对这段代码做下详细解读。

代码中的 a 是个左值,因为它在内存中会分配空间,这应该没什么异义;b 是通过引用。为什么呢?因为通用引用有两个条件:一,必须是T&&的形式,由于auto等价于T,所以auto && 符合这个要求;二,T类型要可以推导,也就是说它必须是个模板,而auto是模板的一种变型,因此b是通用引用。通用引用即可以接收左值,也可以接收右值,所以b=5是正确的;c不是通用引用,因为它不符合T&&的形式。所经第三行代码是错误的,右值引用只能接收右值;d是通用引用,所以给它赋值a是正确的;e不是通用引用,它多了一个const已不符合T&& 的形式,所以给它左值肯定会出错;最后两个函数调用的形参符合 T&&,又因是模板可以进行类型推导,所以是通用引用,因此给它传左值和右值它都能正确接收。

模板的类型推导

通用引用好强大呀!它既可以接收左值又可以接收右值,它是如何做到的呢?这就要讲讲模板的类型推导了。

模板的类型推导规则还是蛮复杂的,这里我们只简要说明一下,有兴趣的同学可以查一下C++11的规范。我们还是举个具体的例子吧:

1
2
3
4
template <typename T>
void f(ParamType param);

f(expr);

上面这个例子是函数模板的通用例子,其中T是根据f函数的参数推到出来的,而ParamType则是根据 T 推导出来的。T与ParamType有可能相等,也可能不等,因为ParamType是可以加修饰的。我们看下面的例子:

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
template <typename T>
void f(T param);

template <typename T>
void func(T& param);

template <typename T>
void function(T&& param);

int main(int argc, char *argv[]) {

int x = 10; // x是int
int & rr = x; // rr是 int &
const int cx = x; // cx是const int
const int& rx = x; // rx是const int &
int *pp = &x; // pp是int *

//下面是传值的模板,由于传入参数的值不影响原值,所以参数类型退化为原始类型
f(x); // T是int
f(cx); // T是int
f(rx); // T是int
f(rr); // T是int
f(pp); // T是int*,指针比较特殊,直接使用

//下面是传引用模板, 如果输入参数类型有引用,则去掉引用;如果没有引用,则输入参数类型就是T的类型
func(x); // T为int
func(cx); // T为const int
func(rx); // T为const int
func(rr); // T为int
func(pp); // T是int*,指针比较特殊,直接使用

//下面是通用引用模板,与引用模板规则一致
function(x); // T为int&
function(5); // T为int
}

上面代码中可以将类型推导分成两大类:其中类型不是引用也不是指针的模板为一类; 引用和指针模板为另一类。

对于第一类其推导时根据的原则是,函数参数传值不影响原值,所以无论你实际传入的参数是普通变量、常量还是引用,它最终都退化为不带任何修修饰的原始类型。如上面的例子中,const int &类型传进去后,退化为int型了。

第二类为模板类型为引用(包括左值引用和右值引用)或指针模板。这一类在类型推导时根据的原则是去除对等数量的引用符号,其它关键字照般。还是我们上面的例子,func(x)中x的类型为 int&,它与T&放在一起可以知道T为int。另一个例子function(x),其中x为int&它与T&& 放在一起可知T为int&

根据推导原则,我们可以知道通用引用最终的结果是什么了,左值与通用引用放在一推导出来的T仍为左值,而右值与通用引用放在一起推导出来的T仍然为右值。

move 的返回类型

实际上上面通过模板推导出的T与move的返回类型息息相关的,要讲明白这一点我们先要把move的返回类型弄明白。下面我们就来讨论一下move的返回类型:

1
typename remove_reference<T>::type&&

move的返回类型非常奇特,我们在开发时很少会这样写,它表示的是什么意思呢?

这就要提到C++的另外一个知识点,即类型成员。你应该知道C++的类成员有成员函数、成员变量、静态成员三种类型,但从C++11之后又增加了一种成员称为类型成员。类型成员与静态成员一样,它们都属于类而不属于对象,访问它时也与访问静态成员一样用::访问。

了解了这点,我们再看move的返类型是不是也不难理解了呢?它表达的意思是返回remove_reference类的type类型成员。而该类是一个模板类,所以在它前面要加typename关键字。

remove_reference看着很陌生,接下来我们再分析一下remove_reference类,看它又起什么作用吧。其实,通过它的名子你应该也能猜个大概了,就是通过模板去除引用。我们来看一下它的实现吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
struct remove_reference{
typedef T type; //定义T的类型别名为type
};

template <typename T>
struct remove_reference<T&> //左值引用
{
typedef T type;
}

template <typename T>
struct remove_reference<T&&> //右值引用
{
typedef T type;
}

上面的代码就是remove_reference类的代码,在C++中struct与class基本是相同的,不同点是class默认成员是private,而struct默认是public,所以使用struct代码会写的更简洁一些。

通过上面的代码我们可以知道,经过remove_reference处理后,T的引用被剔除了。假设前面我们通过move的类型自动推导得到T为int&&,那么再次经过模板推导remove_reference的type成员,这样就可以得出type的类型为int了。

remove_reference利用模板的自动推导获取到了实参去引用后的类型。现在我们再回过来看move函数的时候是不是就一目了解了呢?之前无法理解的5行代码现然变成了这样:

1
2
3
4
5
6
7
8
int && move(int&& && t){
return static_case<int&&>(t);
}

//或
int && move(int& && t){
return static_case<int&&>(t);
}

经上面转换后,我们看这个代码就清晰多了,从中我们可以看到move实际上就是做了一个类型的强制转换。如果你是左值引用就强制转换成右值引用。

引用折叠

上面的代码我们看起来是简单了很多,但其参数int& &&int && &&还是让人觉得很别扭。因为C++编译器根本就不支持这两种类型。咦!这是怎么回事儿呢?

到这里我们就要讲到最后一个知识点引用折叠了。在C++中根本就不存 int& &&int && &&这样的语法,但在编译器内部是能将它们识别出来的。换句话说,编译器内部能识别这种格式,但它没有给我们提供相应的接口(语法)。

实际上,当编译器遇到这类形式的时候它会使用引用折叠技术,将它们变成我们熟悉的格式。其规则如下:

  • int & & 折叠为 int&
  • int & && 折叠为 int&
  • int && & 折叠为 int&
  • int && && 折叠为 int &&

总结一句话就是左值引用总是折叠为左值引用,右值引用总是折叠为右值引用。

经过这一系列的操作之后,对于一个具体的参数类型int & a,std::move就变成了下面的样子:

1
2
3
int && move(int& t){
return static_case<int&&>(t);
}

这一下我们就清楚它在做什么事儿了哈!

小结

以上就是C++高阶知识移动构造函数及其原理的分析。在本文中我首先向你介绍了拷贝构造函数在某些场景下会引起程序性能严重下降,然后讲解了如何使用移动构造函数和std::move函数改善性能。在文章的最后,我带你深入剖析了std::move是如何实现的,最终我们发现它原来就是实现了一个自适应类型的强制类型转换的功能。

参考

细说智能指针
重学C/C++中的const
聊聊C++中的类型转换

欢迎关注我的其它发布渠道