C++ 編碼優(yōu)化 | 減少冗余拷貝或賦值

置頂/星標(biāo)公眾號(hào)??,硬核文章第一時(shí)間送達(dá)!
鏈接 | http://www.708luo.com/?p=33
臨時(shí)變量
目前遇到的一些產(chǎn)生臨時(shí)變量的情況:函數(shù)實(shí)參、函數(shù)返回值、隱式類型轉(zhuǎn)換、多余的拷貝。
1. 函數(shù)實(shí)參
這點(diǎn)應(yīng)該比較容易理解,函數(shù)參數(shù),如果是實(shí)參傳遞的話,函數(shù)體里的修改并不會(huì)影響調(diào)用時(shí)傳入的參數(shù)的值。那么函數(shù)體里操作的對(duì)象肯定是函數(shù)調(diào)用的過程中產(chǎn)生出來的。
那么這種情況我們?cè)撛趺崔k呢?
如果 callee 中確實(shí)要修改這個(gè)對(duì)象,但是 caller 又不想 callee 的修改影響到原來的值,那么這個(gè)臨時(shí)變量就是必須的了,不需要也沒辦法避免。
如果 callee中根本沒有修改這個(gè)對(duì)象,或者 callee 中這個(gè)參數(shù)本身就是 const 型的,那么將實(shí)參傳遞改為引用傳遞是個(gè)不錯(cuò)的選擇(如果是基本類型的函數(shù)實(shí)參,則沒有必要改為引用),可以減少一個(gè)臨時(shí)變量而且不會(huì)帶來任何損失。
另外,推薦一個(gè)靜態(tài)代碼檢查工具 cppcheck,這個(gè)工具可以提示非基本類型的 const 實(shí)參改為引用。
2. 函數(shù)返回值(返回對(duì)象)
函數(shù)返回值的情況比較復(fù)雜,因?yàn)榫幾g器在這方面做了很多優(yōu)化,編譯器優(yōu)化到何種程度我也沒追根究底研究過。
在沒開任何優(yōu)化選項(xiàng)的時(shí)候,gcc 也優(yōu)化了一些簡(jiǎn)單的返回對(duì)象的情況。
先看一段代碼:
A createA(int a)
{
A tmp;
tmp._a=a;
return tmp;
}
拋開所有優(yōu)化不談,函數(shù)中 createA 應(yīng)該有一個(gè)構(gòu)造操作(tmp 對(duì)象生成)和一個(gè)拷貝構(gòu)造操作(tmp 對(duì)象返回時(shí))。
于是有些編譯器嘗試對(duì)函數(shù)返回時(shí)的拷貝構(gòu)造進(jìn)行優(yōu)化:
A createA(int a)
{
return A(a);
}
第一步可以被優(yōu)化的拷貝構(gòu)造就是上面的這種情況,即 RVO(return value optimization),這時(shí)候只能在函數(shù)返回一個(gè)未命名變量的時(shí)候進(jìn)行優(yōu)化。
后來更進(jìn)一步,可以在函數(shù)返回命名變量的時(shí)候也進(jìn)行優(yōu)化了,這就是 NRVO(named return value optimization)。
但是這時(shí)候,還有一種情況不能優(yōu)化的情況是:如果 createA函數(shù)內(nèi)部不同的分支返回不同的對(duì)象。
A createA(int a)
{
if(a%2==0)
{
A tmp;
tmp._a = 2;
return tmp;
}
else
{
A tmp;
tmp._a = 1;
return tmp;
}
}
比如上面這段代碼,我在 gcc 3.4.5 的情況下測(cè)試,發(fā)現(xiàn)這種情況是不能優(yōu)化的。
但是也不排除 gcc 更高的版本或者某些在這方面做得更優(yōu)秀的編譯器已經(jīng)可以優(yōu)化這種情況。
3. 隱式類型轉(zhuǎn)換
代碼中的一些類型的隱式轉(zhuǎn)換也會(huì)產(chǎn)生臨時(shí)變量,比如:
class A
{
public:
A(int a=0):_a(a)
{
cout<<"constructor"<<endl;
}
A(const A &a)
{
cout<<"copy constructor"<<endl;
this->_a = a._a;
}
A& operator=(const A&a)
{
cout<<"operator="<<endl;
this->_a = a._a;
return *this;
}
int _a;
};
int main()
{
A a1;
a1 = 3;
return 0;
}
在 a1 = 3 執(zhí)行時(shí)會(huì)首先調(diào)用 A(3) 產(chǎn)生一個(gè)臨時(shí)對(duì)象,然后調(diào)用operator=(const A& a)。
這種情況下,我們只要實(shí)現(xiàn)一個(gè)A::operator=(int)函數(shù)就可以避免這個(gè)臨時(shí)對(duì)象的產(chǎn)生了。
當(dāng)然,這只是一個(gè)最簡(jiǎn)單的例子,不過思路是差不多的。
4. 多余的拷貝
這種情況應(yīng)該比較少,也比較簡(jiǎn)單,個(gè)人感覺,這種情況主要是疏忽引起的。
是這樣一種情況:
有個(gè)線程級(jí)的結(jié)構(gòu)體thread_data_t *pthread_data,里面包含請(qǐng)求包的各種數(shù)據(jù),在幾處使用的使用使用了const A a = pthread_data->getA()。
getA()的實(shí)現(xiàn)簡(jiǎn)單來說是返回了thread_data_t內(nèi)部的A的成員。
因?yàn)樵谝淮握?qǐng)求的處理過程中thread_data_t內(nèi)部的 A 的成員不會(huì)改變,調(diào)用者用const A a來接收這個(gè)對(duì)象就表明調(diào)用者也不會(huì)改變返回的 A 成員。
因此,其實(shí)完全可以讓getA()返回A成員的引用,調(diào)用者同樣用引用來接收:const A & a = pthread_data->getA()。
這樣就完全就避免了一次多余的拷貝。
非臨時(shí)變量
遇到的一些非臨時(shí)變量情況有:stl vector 的增長(zhǎng)引起拷貝構(gòu)造、vector 的賦值、std::sort 操作
1. vector的增長(zhǎng)
先簡(jiǎn)單介紹一下vector的增長(zhǎng)機(jī)制:每次push_back時(shí),如果發(fā)現(xiàn)原來vector的空間用完,會(huì)把vector調(diào)整到原來的 2 倍( sgi 的實(shí)現(xiàn),visual studio 的實(shí)現(xiàn)好像是 1.5 倍)。因?yàn)?vector 空間是連續(xù)存儲(chǔ)的,這里就有一個(gè)問題,如果原來 vector 地址后面空余的空間沒有被使用,那么vector繼續(xù)把后面的地址申請(qǐng)來就可以擴(kuò)展其空間了。但是如果后面的空間不夠了呢?那就要重新申請(qǐng)一個(gè)2*current_size大小的空間,然后把vector當(dāng)前,也就是current_size的內(nèi)容拷貝到剛申請(qǐng)的那塊空間中去,這時(shí)就引起了對(duì)象的拷貝操作了。
假設(shè)vector初始大小是 0,我們通過push_back加入了 10 個(gè)對(duì)象,以sgi實(shí)現(xiàn)的兩倍增長(zhǎng)為例,再假設(shè)每次調(diào)整vector空間的時(shí)候都需要調(diào)整地址,一共引入了多少次無用的拷貝?
因?yàn)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: 'Operator Mono', Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(150, 84, 181);">vector空間是1->2->4->8->16增長(zhǎng)的,拷貝的次數(shù)一共是四次,每次拷貝對(duì)象分別是1、2、4、8個(gè)。所以答案是1+2+4+8=15。
很容易看出規(guī)律,拷貝對(duì)象的個(gè)數(shù)等于最終vector空間大小減一。
那么如果vector大小最終會(huì)漲到 1000,1W 呢?數(shù)據(jù)就很可觀了。
我接觸過好幾個(gè)服務(wù),最終vector可能會(huì)增長(zhǎng)到 10W 左右的。如果vector要放入 10W 個(gè)元素,那么就會(huì)開辟131072的空間,也就是說最多會(huì)引入 13W 次的對(duì)象拷貝,而這個(gè)拷貝操作是無效的、是可以避免的。
其實(shí)要避免vector增長(zhǎng)引入的拷貝也很簡(jiǎn)單,在push_back之前先調(diào)用reserve申請(qǐng)一個(gè)估算的最大空間。
比如我們之前優(yōu)化的一些服務(wù),預(yù)期vector最大可能會(huì)增長(zhǎng)到 10W,那么直接調(diào)用v.reserve(100000)就可以了。
當(dāng)然,這也許會(huì)引起一些內(nèi)存使用的浪費(fèi),這就需要使用時(shí)注意權(quán)衡了。
但如果你的服務(wù)是一直運(yùn)行的,而且這個(gè)vector對(duì)象也是常駐內(nèi)存的,個(gè)人覺得完全可以reserve一個(gè)最大的空間。因?yàn)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: 'Operator Mono', Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(150, 84, 181);">vector空間增長(zhǎng)之后,就算調(diào)用clear清除所有元素,內(nèi)存也是不會(huì)釋放的。除非使用和空vector交換的方式強(qiáng)制釋放它的內(nèi)存。
2. vector的賦值
遇到過這樣一種情況,在一個(gè)函數(shù)接受一個(gè)vector &作為輸入,經(jīng)過一系列處理得到一個(gè)臨時(shí)的vector,并在函數(shù)返回前將這個(gè)臨時(shí)的vector賦值給作為參數(shù)的vector &作為返回值。簡(jiǎn)化一下代碼如下:
void cal_result(vector<int> &input_ret)
{
vector<int> tmp;
for(...)
{
... // input_ret will be used
//fill tmp
}
input_ret = tmp;
}
這里,我們可以注意到函數(shù)返回后 tmp 對(duì)象也就消失了,不會(huì)被繼續(xù)使用,所以如果可以的話,我們根本不需要返回 tmp的拷貝,直接返回 tmp 占用的空間就可以了。
那么怎么可以直接返回 tmp 而不引起拷貝呢?是不是可以這樣想,我們把 tmp這個(gè)vector指向的地址賦值給input_ret,把tmp指向的空間和大小設(shè)置為 0 就可以了?
那么我們完全可以使用vector的swap操作。它只是將兩個(gè)vector指向空間等等信息交換了一下,而不會(huì)引起元素的拷貝,它的操作是常數(shù)級(jí)的,和交互對(duì)象中元素?cái)?shù)目無關(guān)。
因此將上述代碼改為:
void cal_result(vector<int> &input_ret)
{
vector<int> tmp;
for(...)
{
... // input_ret will be used
//fill tmp
}
input_ret.swap(tmp);
}
可以減少tmp元素的拷貝操作,大大提高了該函數(shù)的處理效率。(提高多少,要看tmp中所有元素拷貝的代價(jià)多大)
3. std::sort操作
在為一個(gè)模塊做性能優(yōu)化的時(shí)候,發(fā)現(xiàn)一個(gè)vector的sort的操作十分消耗性能,占了整個(gè)模塊消耗CPU 10%以上。
使用gperftools的cpu profiler分析了一下數(shù)據(jù),發(fā)現(xiàn)sort操作調(diào)用了元素的拷貝構(gòu)造和賦值函數(shù),這才是消耗性能的原因。
進(jìn)一步分析,vector中的元素對(duì)象特別龐大,對(duì)象中又嵌套了其他對(duì)象且嵌套了好幾層,因此函數(shù)的拷貝和賦值的操作代價(jià)會(huì)比較大。
而std::sort采用的是內(nèi)省排序+插入排序的方式( sgi 的實(shí)現(xiàn)),不可避免地會(huì)引入對(duì)象的交換和移動(dòng)。(其實(shí)不管怎么排序都避免不了交換和移動(dòng)的吧...)
因此,要優(yōu)化這句std::sort操作,還需要減少對(duì)象交換或者提高交換的效率上入手。
減少對(duì)象的交換
我們采用的減少對(duì)象交換的方式是:先使用index的方式進(jìn)行排序,排序好了之后,把原來的vector中的對(duì)象按照index排序的結(jié)果最終做一次拷貝,拷貝到這個(gè)對(duì)象排序后應(yīng)該在的位置。
提高交換的效率
如果對(duì)象的實(shí)現(xiàn)是如下這樣的:
class A
{
public:
A(const char* src)
{
_len = strlen(src);
_content = new char[_len];
memcpy(_content,src,_len);
}
A(const A &a)
{
*this = a;
}
A& operator=(const A&a)
{
_len = a._len;
_content = new char[_len];
memcpy(_content,src,_len);
}
private:
char *_content;
int _len;
};
這里為了保持代碼簡(jiǎn)短,省略了部分實(shí)現(xiàn)且沒考慮一些安全性的校驗(yàn)。
那么在對(duì)象交換的時(shí)候,其實(shí)是沒有必要調(diào)用拷貝構(gòu)造函數(shù)和賦值函數(shù)的(std::swap的默認(rèn)實(shí)現(xiàn)),直接交換兩個(gè)對(duì)象的_content和_len值就好了。如果調(diào)用拷貝構(gòu)造函數(shù)和賦值函數(shù)的話,不可避免還要引入new、memcpy、strlen、delete等等操作。
這種情況下,我們完全可以針對(duì) A 的實(shí)現(xiàn),重載全局的swap操作。這樣sort的過程中就可以調(diào)用我們自己實(shí)現(xiàn)的高效的swap了。
如下代碼可以重載我們 A 函數(shù)的swap實(shí)現(xiàn):
namespace std
{
template<>
void swap<A>(A &a1,A& a2)
{
cout<<"swap A"<<endl;
int tmp = a1._a;
a1._a = a2._a;
a2._a = tmp;
}
}
因?yàn)檎{(diào)用堆精度問題和編譯優(yōu)化的問題,有時(shí)候也可能分析不到 sort 是因?yàn)檎{(diào)用了元素對(duì)象的拷貝構(gòu)造和賦值函數(shù)所以才效率比較低。所以發(fā)現(xiàn)sort消耗性能的時(shí)候,可以看看是否是因?yàn)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: 'Operator Mono', Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(150, 84, 181);">sort對(duì)象過大造成的,積累一個(gè)common sense吧。




關(guān)注公眾號(hào)「高效程序員」??,一起優(yōu)秀!
