c++程式碼優化總結

linlinlinxi007發表於2010-01-28

一. 優化之前
在進行優化之前,我們首先應該做的是發現我們程式碼的瓶頸(bottleneck)在哪裡。然而當你做這件事情的時候切忌從一個debug-version進行推斷,因為debug-version中包含了許多額外的程式碼。一個debug-version可執行體要比release-version大出40%。那些額外的程式碼都是用來支援除錯的,比如說符號的查詢。大多數實現都為debug-version和release-version提供了不同的operator new以及庫函式。而且,一個release-version的執行體可能已經通過多種途徑進行了優化,包括不必要的臨時物件的消除,迴圈展開,把物件移入暫存器,內聯等等。
另外,我們要把除錯和優化區分開來,它們是在完成不同的任務。 debug-version 是用來追捕bugs以及檢查程式是否有邏輯上的問題。release-version則是用來做一些效能上的調整以及進行優化。
下面就讓我們來看看有哪些程式碼優化技術吧:

二. 宣告的放置
程式中變數和物件的宣告放在什麼位置將會對效能產生顯著影響。同樣,對postfix和prefix運算子的選擇也會影響效能。這一部分我們集中討論四個問題:初始化v.s 賦值,在程式確實要使用的地方放置宣告,建構函式的初始化列表,prefix v.s postfix運算子。
(1) 請使用初始化而不是賦值
在C語言中只允許在一個函式體的開頭進行變數的宣告,然而在C++中宣告可以出現在程式的任何位置。這樣做的目的是希望把物件的宣告拖延到確實要使用它的時候再進行。這樣做可以有兩個好處:1. 確保了物件在它被使用前不會被程式的其他部分惡意修改。如果物件在開頭就被宣告然而卻在20行以後才被使用的話,就不能做這樣的保證。2. 使我們有機會通過用初始化取代賦值來達到效能的提升,從前宣告只能放在開頭,然而往往開始的時候我們還沒有獲得我們想要的值,因此初始化所帶來的好處就無法被應用。但是現在我們可以在我們獲得了想要的值的時候直接進行初始化,從而省去了一步。注意,或許對於基本型別來說,初始化和賦值之間可能不會有什麼差異,但是對於使用者定義的型別來說,二者就會帶來顯著的不同,因為賦值會多進行一次函式呼叫----operator =。因此當我們在賦值和初始化之間進行選擇的話,初始化應該是我們的首選。
(2) 把宣告放在合適的位置上
在一些場合,通過移動宣告到合適的位置所帶來的效能提升應該引起我們足夠的重視。例如:
bool is_C_Needed();
void use()
{
C c1;
if (is_C_Needed() == false)
{
return; //c1 was not needed
}
//use c1 here
return;
}
上面這段程式碼中物件c1即使在有可能不使用它的情況下也會被建立,這樣我們就會為它付出不必要的花費,有可能你會說一個物件c1能浪費多少時間,但是如果是這種情況呢:C c1[1000];我想就不是說浪費就浪費了。但是我們可以通過移動宣告c1的位置來改變這種情況:
void use()
{
if (is_C_Needed() == false)
{
return; //c1 was not needed
}
C c1; //moved from the block's beginning
//use c1 here
return;
}
怎麼樣,程式的效能是不是已經得到很大的改善了呢?因此請仔細分析你的程式碼,把宣告放在合適的位置上,它所帶來的好處是你難以想象的。
(3) 初始化列表
我們都知道,初始化列表一般是用來初始化const或者reference資料成員。但是由於他自身的性質,我們可以通過使用初始化列表來實現效能的提升。我們先來看一段程式:
class Person
{
private:
C c_1;
C c_2;
public:
Person(const C& c1, const C& c2 ): c_1(c1), c_2(c2) {}
};
當然建構函式我們也可以這樣寫:
Person::Person(const C& c1, const C& c2)
{
c_1 = c1;
c_2 = c2;
}
那麼究竟二者會帶來什麼樣的效能差異呢,要想搞清楚這個問題,我們首先要搞清楚二者是如何執行的,先來看初始化列表:資料成員的宣告操作都是在建構函式執行之前就完成了,在建構函式中往往完成的只是賦值操作,然而初始化列表直接是在資料成員宣告的時候就進行了初始化,因此它只執行了一次copy constructor。再來看在建構函式中賦值的情況:首先,在建構函式執行前會通過default constructor建立資料成員,然後在建構函式中通過operator =進行賦值。因此它就比初始化列表多進行了一次函式呼叫。效能差異就出來了。但是請注意,如果你的資料成員都是基本型別的話,那麼為了程式的可讀性就不要使用初始化列表了,因為編譯器對兩者產生的彙編程式碼是相同的。
(4) postfix VS prefix 運算子
prefix運算子++和—比它的postfix版本效率更高,因為當postfix運算子被使用的時候,會需要一個臨時物件來儲存改變以前的值。對於基本型別,編譯器會消除這一份額外的拷貝,但是對於使用者定義型別,這似乎是不可能的。因此請你儘可能使用prefix運算子。

三. 行內函數
行內函數既能夠去除函式呼叫所帶來的效率負擔又能夠保留一般函式的優點。然而,行內函數並不是萬能藥,在一些情況下,它甚至能夠降低程式的效能。因此在使用的時候應該慎重。
1.我們先來看看行內函數給我們帶來的好處:從一個使用者的角度來看,行內函數看起來和普通函式一樣,它可以有引數和返回值,也可以有自己的作用域,然而它卻不會引入一般函式呼叫所帶來的負擔。另外,它可以比巨集更安全更容易除錯。
當然有一點應該意識到,inline specifier僅僅是對編譯器的建議,編譯器有權利忽略這個建議。那麼編譯器是如何決定函式內聯與否呢?一般情況下關鍵性因素包括函式體的大小,是否有區域性物件被宣告,函式的複雜性等等。
2.那麼如果一個函式被宣告為inline但是卻沒有被內聯將會發生什麼呢?理論上,當編譯器拒絕內聯一個函式的時候,那個函式會像普通函式一樣被對待,但是還會出現一些其他的問題。例如下面這段程式碼:
// filename Time.h
#include<ctime>
#include<iostream>
using namespace std;
class Time
{
public:
inline void Show() { for (int i = 0; i<10; i++) cout<<time(0)<<endl;}
};
因為成員函式Time::Show()包括一個區域性變數和一個for迴圈,所以編譯器一般拒絕inline,並且把它當作一個普通的成員函式。但是這個包含類宣告的標頭檔案會被單獨的#include進各個獨立的編譯單元中:
// filename f1.cpp
#include "Time.hj"
void f1()
{
Time t1;
t1.Show();
}

// filename f2.cpp
#include "Time.h"
void f2()
{
Time t2;
t2.Show();
}
結果編譯器為這個程式生成了兩個相同成員函式的拷貝:
void f1();
void f2();
int main()
{
f1();
f2();
return 0;
}
當程式被連結的時候,linker將會面對兩個相同的Time::Show()拷貝,於是函式重定義的連線錯誤發生。但是老一些的C++實現對付這種情況的辦法是通過把一個un-inlined函式當作static來處理。因此每一份函式拷貝僅僅在自己的編譯單元中可見,這樣連結錯誤就解決了,但是在程式中卻會留下多份函式拷貝。在這種情況下,程式的效能不但沒有提升,反而增加了編譯和連結時間以及最終可執行體的大小。
但是幸運的是,新的C++標準中關於un-inlined函式的說法已經改變。一個符合標準C++實現應該只生成一份函式拷貝。然而,要想所有的編譯器都支援這一點可能還需要很長時間。
另外關於行內函數還有兩個更令人頭疼的問題。第一個問題是該如何進行維護。一個函式開始的時候可能以內聯的形式出現,但是隨著系統的擴充套件,函式體可能要求新增額外的功能,結果行內函數就變得不太可能,因此需要把inline specifier去除以及把函式體放到一個單獨的原始檔中。另一個問題是當行內函數被應用在程式碼庫的時候產生。當行內函數改變的時候,使用者必須重新編譯他們的程式碼以反映這種改變。然而對於一個非行內函數,使用者僅僅需要重新連結就可以了。
這裡想要說的是,行內函數並不是一個增強效能的靈丹妙藥。只有當函式非常短小的時候它才能得到我們想要的效果,但是如果函式並不是很短而且在很多地方都被呼叫的話,那麼將會使得可執行體的體積增大。最令人煩惱的還是當編譯器拒絕內聯的時候。在老的實現中,結果很不盡人意,雖然在新的實現中有很大的改善,但是仍然還是不那麼完善的。一些編譯器能夠足夠的聰明來指出哪些函式可以內聯哪些不能,但是,大多數編譯器就不那麼聰明瞭,因此這就需要我們的經驗來判斷。如果行內函數不能增強行能,就避免使用它!


四. 優化你的記憶體使用
通常優化都有幾個方面:更快的執行速度,有效的系統資源使用,更小的記憶體使用。一般情況下,程式碼優化都是試圖在以上各個方面進行改善。重新放置宣告技術被證明是消除多餘物件的建立和銷燬,這樣既減小了程式的大小又加快了執行速度。然而其他的優化技術都是基於一個方面------更快的速度或者是更小的記憶體使用。有時,這些目標是互斥的,壓縮了記憶體的使用往往卻減慢了程式碼速度,快速的程式碼卻又需要更多的記憶體支援。下面總結兩種在記憶體使用上的優化方法:
1. Bit Fields
在C/C++中都可以存取和訪問資料的最小組成單元:bit。因為bit並不是C/C++基本的存取單元,所以這裡是通過犧牲執行速度來減少記憶體和輔助儲存器的空間的使用。注意:一些硬體結構可能提供了特殊的處理器指令來存取bit,因此bit fields是否影響程式的速度取決於具體平臺。
在我們的現實生活中,一個資料的許多位都被浪費了,因為某些應用根本就不會有那麼大的資料範圍。也許你會說,bit是如此之小,通過它就能減小儲存空間的使用嗎?的確,在資料量很小的情況下不會看出什麼效果,但是在資料量驚人的情況下,它所節省的空間還是能夠讓我們的眼睛為之一亮的。也許你又會說,現在記憶體和硬碟越來越便宜,何苦要費半天勁,這省不了幾個錢。但是還有另外一個原因一定會使你信服,那就是數字資訊傳輸。一個分散式資料庫都會在不同的地點有多份拷貝。那麼數百萬的紀錄傳輸就會顯得十分昂貴。Ok,現在我們就來看看該如何做吧,首先看下面這段程式碼:
struct BillingRec
{
long cust_id;
long timestamp;
enum CallType
{
toll_free,
local,
regional,
long_distance,
international,
cellular
} type;
enum CallTariff
{
off_peak,
medium_rate,
peak_time
} tariff;
};
上面這個結構體在32位的機器上將會佔用16位元組,你會發現其中有許多位都被浪費了,尤其是那兩個enum型,浪費更是嚴重,所以請看下面做出的改進:
struct BillingRec
{
int cust_id: 24; // 23 bits + 1 sign bit
int timestamp: 24;
enum CallType
{//...
};
enum CallTariff
{//...
};
unsigned call: 3;
unsigned tariff: 2;
};
現在一個資料從16位元組縮減到了8位元組,減少了一半,怎麼樣,效果還是顯著的吧:)
2. Unions
Unions通過把兩個或更多的資料成員放置在相同地址的記憶體中來減少記憶體浪費,這就要求在任何時間只能有一個資料成員有效。Union 可以有成員函式,包括建構函式和解構函式,但是它不能有虛擬函式。C++支援anonymous unions。anonymous union是一個未命名型別的未命名物件。例如:
union { long n; void * p}; // anonymous
n = 1000L; // members are directly accessed
p = 0; // n is now also 0
不像命名的union,它不能有成員函式以及非public的資料成員。
那麼unions什麼時候是有用的呢?下面這個類從資料庫中獲取一個人的資訊。關鍵字既可以是一個特有的ID或者人名,但是二者卻不能同時有效:
class PersonalDetails
{
private:
char * name;
long ID;
//...
public:
PersonalDetails(const char *nm); //key is of type char * used
PersonalDetails(long id) : ID(id) {} //numeric key used
};
上面這段程式碼中就會造成記憶體的浪費,因為在一個時間只能有一個關鍵字有效。anonymous union可以在這裡使用來減少記憶體的使用,例如:
class PersonalDetails
{
private:
union //anonymous
{
char * name;
long ID;
};
public:
PersonalDetails(const char *nm);
PersonalDetails(long id) : ID(id) {/**/} // direct access to a member
//...
};
通過使用union,PersonalDetails類的大小被減半。但是這裡要說明的是,節省4 個位元組記憶體並不值得引入union所帶來的麻煩,除非這個類作為數百萬資料庫記錄的型別或者紀錄在一條很慢的通訊線路傳輸。值得注意的是unions並不引入任何執行期負擔,所以這裡不會有什麼速度上的損失。anonymous union的優點就是它的成員可以被直接訪問。
五. 速度優化
在一些對速度要求非常苛刻的應用系統中,每一個CPU週期都是要爭取的。這個部分展現了一些簡單方法來進行速度優化。
1. 使用類來包裹長的引數列表
一個函式呼叫的負擔將會隨著引數列表的增長而增加。執行時系統不得不建立堆疊來儲存引數值;通常,當引數很多的時候,這樣一個操作就會花費很長的時間。
把引數列表包裹進一個單獨的類中並且通過引用進行傳遞,這樣將會節省很多的時間。當然,如果函式本身就很長,那麼建立堆疊的時間就可以忽略了,因此也就沒有必要這樣做。然而,對於那些執行時間很短而且經常被呼叫的函式來說,包裹一個長的引數列表在物件中並且通過引用傳遞將會提高效能。
2. 暫存器變數
register specifier被用來告訴編譯器一個物件將被會非常多的使用,可以把它放入暫存器中。例如:
void f()
{
int *p = new int[3000000];
register int *p2 = p; //store the address in a register
for (register int j = 0; j<3000000; j++)
{
*p2++ = 0;
}
//...use p
delete [] p;
}
迴圈計數是應用暫存器變數的最好的候選者。當它們沒有被存入一個暫存器中,大部分的迴圈時間都被用在了從記憶體中取出變數和給變數賦新值上。如果把它存入一個暫存器中的話,將會大大減少這種負擔。需要注意的是,register specifier僅僅是對編譯器的一個建議。就好比行內函數一樣,編譯器可以拒絕把一個物件儲存到暫存器中。另外,現代的編譯器都會通過把變數放入暫存器中來優化迴圈計數。Register storage specifier並不僅僅侷限在基本型別上,它能夠被應用於任何型別的物件。如果物件太大而不能裝進暫存器的話,編譯器仍然能夠把它放入一個高速儲存器中,例如cache。
用register storage specifier宣告函式型參將會是建議編譯器把實參存入暫存器中而不是堆疊中。例如:

void f(register int j, register Date d);

3. 把那些保持不變的物件宣告為const
通過把物件宣告為const,編譯器就可以利用這個宣告把這樣一個物件放入暫存器中。
4. Virtual function的執行期負擔
當呼叫一個virtual function,如果編譯器能夠解決呼叫的靜態化,將不會引入額外的負擔。另外,一個非常短的虛擬函式可以被內聯處理。在下面這個例子中,一個聰明的編譯器能夠做到靜態呼叫虛擬函式:
#include <iostream>
using namespace std;
class V
{
public:
virtual void show() const { cout<<"I'm V"<<endl; }
};
class W : public V
{
public:
void show() const { cout<<"I'm W"<<endl; }
};
void f(V & v, V *pV)
{
v.show();
pV->show();
}
void g()
{
V v;
f(v, &v);
}
int main()
{
g();
return 0;
}
如果整個程式出現在一個單獨的編譯單元中,編譯器能夠對main()中的g()進行內聯替換。並且在g()中f()的呼叫也能夠被內聯處理。因為傳給f()的引數的動態型別能夠在編譯期被知曉,因此編譯器能夠把對虛擬函式的呼叫靜態化。但是不能保證每個編譯器都這樣做。然而,一些編譯器確實能夠利用在編譯期獲得引數的動態型別從而使得函式的呼叫在編譯期間就確定了下來,避免了動態繫結的負擔。
5. Function objects VS function pointers
用function objects取代function pointers的好處不僅僅侷限在能夠泛化和簡單的維護性上。而且編譯器能夠對function object的函式呼叫進行內聯處理,從而進一步的增強了效能
六. 最後的求助
迄今為止為大家展示的優化技術並沒有在設計以及程式碼的可讀性上做出妥協。事實上,它們中的一些還提高了軟體的穩固性和可維護性。但是在一些對時間和記憶體有嚴格限制的軟體開發中,上面的技術可能還不夠;有可能還需要一些會影響軟體的可移植性和擴充套件性的技術。但是這些技術只能在所有其他的優化技術都被應用但是還不符合要求的情況下使用。
1. 關閉RTTI和異常處理支援
當你匯入純C程式碼給C++編譯器的時候,你可能會發現有一些效能上的損失。這並不是語言或者編譯器的錯誤,而是編譯器作出的一些調整。如果你想獲得和C編譯器同樣的效能,那麼請關閉編譯器對RTTI以及異常處理的支援。為什麼會這樣呢?因為為了支援RTTI和異常處理,C++編譯器會插入額外的程式碼。這樣就增加了可執行體的大小,從而使得效率有所下降。當應用純C程式碼的時候,那些額外的程式碼是不需要的,所以你可以通過關閉來避免它。
2. 內聯彙編
對時間要求苛刻的部分可以用本地彙編來重寫。結果可能是速度上的顯著提高。然而,這個方法不能想當然的就去實施,因為它將使得將來的修改非常的困難。維護程式碼的程式設計師可能對彙編並不瞭解。如果想要把軟體執行於其他平臺也需要重寫彙編程式碼部分。另外,開發和測試彙編程式碼是一件辛苦的工作,它將花費更長的時間。
3. 直接和作業系統進行互動
API函式可以使你直接與作業系統進行互動。有時,直接執行一個系統命令可能會快許多。出於這個目的,你可以使用標準函式system()。例如,在一個dos/windows系統下,你可以這樣顯示當前目錄下的檔案:
#include <cstdlib>
using namespace std;
int main()
{
system("dir"); //execute the "dir" command
}
注意:這裡是在速度和可移植性以及可擴充套件性之間做出的折衷。

相關文章