C++性能优化之二:右值引用

来龙去脉

在我项目里,经常会出现这样一段代码:

1
2
#define _C_S(x) String(x)
String str = _C_S("hello world");

这个代码的运行机制实际上是这样的:

1
2
3
String tmp("hello world");
String str = tmp;
tmp.~String();

构造函数生成临时的tmp对象(申请内存块A存放”hello world”),然后通过复制构造函数,将tmp内存里的内容复制到str对象(str申请内存块B,接受从内存块A复制过来的字符串),然后tmp对象脱离作用域调用析构函数(第二行代码结束,释放内存块A)。仔细分析下,发现有冗余的内存申请和释放,这里实际上存在两次内存申请,和一次内存释放,那是不是有办法做到,只申请一次内存就完成上述代码。

答案是有的,我们只需要把tmp对象的内存“移动”到str中即可,这就是C++11的右值引用。(由于我们项目C++代码的基础容器都是自己维护的,并没有使用stl,因此会缺失很多新特性,如C++11的右值引用)

左值右值的定义

首先说明右值引用之前,先解释下C++里对于右值和左值的定义

当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。

概念有点抽象,举几个例子来看看

1
2
3
int a = 52; //a是左值
int b = a + c; //b是左值,a+c是左值
string c = string("hello") //c是左值,string("hello")是右值

从上面代码可以看出,其实左值和右值的根本区别在于能否获取内存地址,左值有自己的变量名和地址,而右值是函数返回的或者运算符计算得出的临时对象,出了作用域就会被析构。

右值引用的应用

那么引入右值引用的目的是什么呢?很简单,合理规划临时对象的内存使用。

如果没有右值引用,像使用string这种有指针成员变量的临时对象,去给左值做构造或者赋值时,就会存在多余的内存申请和释放,如果该指针指向的内存块很大,那么这种频繁的临时对象内存的申请和释放很容易导致内存碎片和内存尖峰,进而影响性能。

下面代码是以一个简单的字符串String类为例,实现了String的复制构造函数和赋值运算符的右值引用版本,来说明右值引用的作用。

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class String
{
public:
//构造函数
String();
String(const char* str);
//复制构造函数
String(const String& str);
//复制构造函数-右值引用
String(String&& str);
//赋值运算符函数
String& operator=(const String& str);
//赋值运算符函数-右值引用
String& operator=(String&& str);
//析构函数
virtual ~String();
//字符串反转
String reverse();
void show(){cout << _pdata <<endl;};
private:
size_t _len;
char* _pdata;
};
String::String()
{
_len = 0;
_pdata = nullptr;
}
String::String(const char* str)
{
_len = strlen(str);
_pdata = new char[_len + 1];
if (_pdata != nullptr)
{
memcpy(_pdata, str, _len);
_pdata[_len] = '\0';
}
}
String::String(const String& str)
{
_len = str._len;
_pdata = new char[_len + 1];
if (_pdata != nullptr)
{
memcpy(_pdata, str._pdata, _len);
_pdata[_len] = '\0';
}
}
String::String(String&& str)
{
_len = str._len;
_pdata = str._pdata;
str._len = 0;
str._pdata = nullptr;
}
String& String::operator=(String&& str)
{
if (_pdata)
{
delete [] _pdata;
_pdata = nullptr;
_len = 0;
}
_pdata = str._pdata;
_len = str._len;
str._len = 0;
str._pdata = nullptr;
return *this;
}
String& String::operator=(const String& str)
{
if (&str != this)
{
if (_len > str._len)
{
memset(_pdata, 0, _len);
_len = 0;
memcpy(_pdata, str._pdata, str._len);
_len = str._len;
_pdata[_len] = '\0';
}
else if (_len == str._len)
{
memcpy(_pdata, str._pdata, str._len);
}
else
{
delete [] _pdata;
_pdata = nullptr;
_len = str._len;
_pdata = new char[_len + 1];
if (_pdata != nullptr)
{
memcpy(_pdata, str._pdata, _len);
_pdata[_len] = '\0';
}
}
}
return *this;
}
String::~String()
{
if (_pdata != nullptr)
{
delete [] _pdata;
_pdata = nullptr;
_len = 0;
}
}
String String::reverse()
{
String ret = _pdata;
int sidx = 0;
int eidx = (int)(ret._len - 1);
while (sidx <= eidx) {
char tmp = ret._pdata[sidx];
ret._pdata[sidx] = ret._pdata[eidx];
ret._pdata[eidx] = tmp;
sidx++;
eidx--;
}
return ret; //调用移动函数-右值引用
}
int main()
{
String str = String("098"); //调用移动函数-右值引用
String str2("110");
str2 = String("098"); //调用移动函数-右值引用
//str2Reverse的地址和reverse函数中ret变量的地址是一致的
String str2Reverse = str2.reverse(); //调用移动函数-右值引用
return 0;
}

注意到,右值引用版本的复制构造和赋值运算符函数,将临时对象的内存“移动”到了左值,从而避免了临时对象的内存浪费,提高了运行效率。

总结

将该特性移植到我们项目工程代码后,内存申请和调用频次大幅减少,虚存和cpu都有小幅下降,其实不仅仅是拷贝构造和赋值运算符存在临时对象,所有其他用到这两个函数的String成员函数都会涉及到该类问题,我们比如字符串截取函数Mid,Left,Right等,都会返回临时的String对象,使用右值引用后,临时对象内存申请释放存在浪费的问题也就得到解决。C++11中有很多好的特性,但是使用起来也会有点门槛,还是推荐在项目实践的过程中,慢慢学习和理解这些特性。