C++编码优化之减少冗余拷贝或赋值
C++编码优化之减少冗余拷贝或赋值
临时变量
目前遇到的一些产生临时变量的情况:函数实参、函数返回值、隐式类型转换、多余的拷贝。
1. 函数实参
这点应该比较容易理解,函数参数,如果是实参传递的话,函数体里的修改并不会影响调用时传入的参数的值。那么函数体里操作的对象肯定是函数调用的过程中产生出来的。
那么这种情况我们该怎么办呢?
如果 callee
中确实要修改这个对象,但是 caller
又不想 callee
的修改影响到原来的值,那么这个临时变量就是必须的了,不需要也没办法避免。
如果 callee
中根本没有修改这个对象,或者 callee
中这个参数本身就是 const
型的,那么将实参传递改为引用传递是个不错的选择(如果是基本类型的函数实参,则没有必要改为引用),可以减少一个临时变量而且不会带来任何损失。
另外,推荐一个静态代码检查工具 cppcheck
,这个工具可以提示非基本类型的 const
实参改为引用。
2. 函数返回值(返回对象)
函数返回值的情况比较复杂,因为编译器在这方面做了很多优化,编译器优化到何种程度我也没追根究底研究过。
在没开任何优化选项的时候,gcc
也优化了一些简单的返回对象的情况。
先看一段代码:
1 | A createA(int a) |
抛开所有优化不谈,函数中 createA
应该有一个构造操作(tmp
对象生成)和一个拷贝构造操作(tmp
对象返回时)。
于是有些编译器尝试对函数返回时的拷贝构造进行优化:
1 | A createA(int a) |
第一步可以被优化的拷贝构造就是上面的这种情况,即 RVO(return value optimization)
,这时候只能在函数返回一个未命名变量的时候进行优化。
后来更进一步,可以在函数返回命名变量的时候也进行优化了,这就是 NRVO(named return value optimization)
。
但是这时候,还有一种情况不能优化的情况是:如果 createA
函数内部不同的分支返回不同的对象。
1 | A createA(int a) |
比如上面这段代码,我在 gcc 3.4.5
的情况下测试,发现这种情况是不能优化的。
但是也不排除 gcc
更高的版本或者某些在这方面做得更优秀的编译器已经可以优化这种情况。
3. 隐式类型转换
代码中的一些类型的隐式转换也会产生临时变量,比如:
1 | class A |
在 a1 = 3 执行时会首先调用 A(3)
产生一个临时对象,然后调用operator=(const A& a)
。
这种情况下,我们只要实现一个A::operator=(int)
函数就可以避免这个临时对象的产生了。
当然,这只是一个最简单的例子,不过思路是差不多的。
4. 多余的拷贝
这种情况应该比较少,也比较简单,个人感觉,这种情况主要是疏忽引起的。
是这样一种情况:
有个线程级的结构体thread_data_t *pthread_data
,里面包含请求包的各种数据,在几处使用的使用使用了const A a = pthread_data->getA()
。
getA()
的实现简单来说是返回了thread_data_t
内部的A的成员。
因为在一次请求的处理过程中thread_data_t
内部的 A 的成员不会改变,调用者用const A a
来接收这个对象就表明调用者也不会改变返回的 A 成员。
因此,其实完全可以让getA()
返回A成员的引用,调用者同样用引用来接收:const A & a = pthread_data->getA()
。
这样就完全就避免了一次多余的拷贝。
非临时变量
遇到的一些非临时变量情况有:stl vector
的增长引起拷贝构造、vector
的赋值、std::sort
操作
1. vector的增长
先简单介绍一下vector
的增长机制:每次push_back
时,如果发现原来vector
的空间用完,会把vector
调整到原来的 2 倍( sgi 的实现,visual studio
的实现好像是 1.5 倍)。因为 vector
空间是连续存储的,这里就有一个问题,如果原来 vector
地址后面空余的空间没有被使用,那么vector
继续把后面的地址申请来就可以扩展其空间了。但是如果后面的空间不够了呢?那就要重新申请一个2*current_size
大小的空间,然后把vector
当前,也就是current_size
的内容拷贝到刚申请的那块空间中去,这时就引起了对象的拷贝操作了。
假设vector
初始大小是 0,我们通过push_back
加入了 10 个对象,以sgi
实现的两倍增长为例,再假设每次调整vector
空间的时候都需要调整地址,一共引入了多少次无用的拷贝?
因为vector
空间是1->2->4->8->16
增长的,拷贝的次数一共是四次,每次拷贝对象分别是1、2、4、8
个。所以答案是1+2+4+8=15
。
很容易看出规律,拷贝对象的个数等于最终vector
空间大小减一。
那么如果vector
大小最终会涨到 1000,1W 呢?数据就很可观了。
我接触过好几个服务,最终vector
可能会增长到 10W 左右的。如果vector
要放入 10W 个元素,那么就会开辟131072
的空间,也就是说最多会引入 13W 次的对象拷贝,而这个拷贝操作是无效的、是可以避免的。
其实要避免vector
增长引入的拷贝也很简单,在push_back
之前先调用reserve
申请一个估算的最大空间。
比如我们之前优化的一些服务,预期vector
最大可能会增长到 10W,那么直接调用v.reserve(100000)
就可以了。
当然,这也许会引起一些内存使用的浪费,这就需要使用时注意权衡了。
但如果你的服务是一直运行的,而且这个vecto
r对象也是常驻内存的,个人觉得完全可以reserve
一个最大的空间。因为vector
空间增长之后,就算调用clear
清除所有元素,内存也是不会释放的。除非使用和空vector
交换的方式强制释放它的内存。
2. vector的赋值
遇到过这样一种情况,在一个函数接受一个vector &
作为输入,经过一系列处理得到一个临时的vector
,并在函数返回前将这个临时的vector
赋值给作为参数的vector &
作为返回值。简化一下代码如下:
1 | void cal_result(vector<int> &input_ret) |
这里,我们可以注意到函数返回后 tmp
对象也就消失了,不会被继续使用,所以如果可以的话,我们根本不需要返回 tmp
的拷贝,直接返回 tmp
占用的空间就可以了。
那么怎么可以直接返回 tmp
而不引起拷贝呢?是不是可以这样想,我们把 tmp
这个vector
指向的地址赋值给input_ret
,把tmp
指向的空间和大小设置为 0 就可以了?
那么我们完全可以使用vector
的swap
操作。它只是将两个vector
指向空间等等信息交换了一下,而不会引起元素的拷贝,它的操作是常数级的,和交互对象中元素数目无关。
因此将上述代码改为:
1 | void cal_result(vector<int> &input_ret) |
可以减少tmp
元素的拷贝操作,大大提高了该函数的处理效率。(提高多少,要看tmp
中所有元素拷贝的代价多大)
3. std::sort操作
在为一个模块做性能优化的时候,发现一个vector
的sort
的操作十分消耗性能,占了整个模块消耗CPU 10%
以上。
使用gperftools
的cpu profiler
分析了一下数据,发现sort
操作调用了元素的拷贝构造和赋值函数,这才是消耗性能的原因。
进一步分析,vector
中的元素对象特别庞大,对象中又嵌套了其他对象且嵌套了好几层,因此函数的拷贝和赋值的操作代价会比较大。
而std::sort
采用的是内省排序+插入排序的方式( sgi 的实现),不可避免地会引入对象的交换和移动。(其实不管怎么排序都避免不了交换和移动的吧…)
因此,要优化这句std::sort
操作,还需要减少对象交换或者提高交换的效率上入手。
- 减少对象的交换
我们采用的减少对象交换的方式是:先使用index
的方式进行排序,排序好了之后,把原来的vector
中的对象按照index
排序的结果最终做一次拷贝,拷贝到这个对象排序后应该在的位置。
- 提高交换的效率
如果对象的实现是如下这样的:
1 | class A |
这里为了保持代码简短,省略了部分实现且没考虑一些安全性的校验。
那么在对象交换的时候,其实是没有必要调用拷贝构造函数和赋值函数的(std::swap
的默认实现),直接交换两个对象的_content
和_len
值就好了。如果调用拷贝构造函数和赋值函数的话,不可避免还要引入new、memcpy、strlen、delete
等等操作。
这种情况下,我们完全可以针对 A 的实现,重载全局的swap
操作。这样sort
的过程中就可以调用我们自己实现的高效的swap
了。
如下代码可以重载我们 A 函数的swap
实现:
1 | namespace std |
因为调用堆精度问题和编译优化的问题,有时候也可能分析不到 sort
是因为调用了元素对象的拷贝构造和赋值函数所以才效率比较低。所以发现sort
消耗性能的时候,可以看看是否是因为sort
对象过大造成的,积累一个common sense
吧。