提高程式執行效率的10個簡單方法

cyxlzzs發表於2013-07-03
對於每一個程式設計師來說,程式的執行效率都是一個值得重視,併為之付出努力的問題。但是程式效能的優化也是一門複雜的學問,需要很多的知識,然而並不是每個程式設計師都具備這樣的知識,而且論述如何優化程式提高程式執行效率的書籍也很少。但是這並不等於我們可以忽略程式的執行效率,下面就介紹一下本人積累的一些簡單實用的提高程式執行效率的方法,希望對大家有所幫助。

注:以C/C++程式為例

一、儘量變少值傳遞,多用引用來傳遞引數。
至於其中的原因,相信大家也很清楚,如果引數是int等語言自定義的型別可能能效能的影響還不是很大,但是如果引數是一個類的物件,那麼其效率問題就不言而喻了。例如一個判斷兩個字串是否相等的函式,其宣告如下:
bool Compare(string s1, string s2)
bool Compare(string *s1, string *s2)
bool Compare(string &s1, string &s2)
bool Compare(const string &s1, const string &s2)

其中若使用第一個函式(值傳遞),則在引數傳遞和函式返回時,需要呼叫string的建構函式和解構函式兩次(即共多呼叫了四個函式),而其他的三個函式(指標傳遞和引用傳遞)則不需要呼叫這四個函式。因為指標和引用都不會建立新的物件。如果一個構造一個物件和析構一個物件的開銷是龐大的,這就是會效率造成一定的影響。

然而在很多人的眼中,指標是一個惡夢,使用指標就意味著錯誤,那麼就使用引用吧!它與使用普通值傳遞一樣方便直觀,同時具有指標傳遞的高效和能力。因為引用是一個變數的別名,對其操作等同於對實際物件操作,所以當你確定在你的函式是不會或不需要變數引數的值時,就大膽地在宣告的前面加上一個const吧,就如最後的一個函式宣告一樣。

同時加上一個const還有一個好處,就是可以對常量進行引用,若不加上const修飾符,引用是不能引用常量的。

二、++i和i++引申出的效率問題
看了上面的第一點,你可能覺得,那不就是多呼叫了四個函式而已,你可能對此不屑一顧。那麼來看看下面的例子,應該會讓你大吃一驚。

至於整型變數的前加和後加的區別相信大家也是很清楚的。然而在這裡我想跟大家談的卻是C++類的運算子過載,為了與整形變數的用法一致,在C++中過載運算子++時一般都會把前加和後加都過載。你可能會說,你在程式碼中不會過載++運算子,但是你敢說你沒有使用過類的++運算子過載嗎?迭代器類你總使用過吧!可能到現在你還不是很懂我在說什麼,那麼就先看看下面的例子吧,是本人為連結串列寫的一個內部迭代器。

_SingleList::Iterator& _SingleList::Iterator::operator++()//前加
{
pNote = pNote->pNext;
return *this;
}
_SingleList::Iterator _SingleList::Iterator::operator++(int)//後加
{
Iterator tmp(*this);
pNote = pNote->pNext;
return tmp;
}
從後加的實現方式可以知道,物件利用自己建立一個臨時物件(自己在函式呼叫的一個複製),然後改變自己的狀態,並返回這個臨時物件,而前加的實現方式時,直接改變自己的內部狀態,並返回自己的引用。

從第一點的論述可以知道後加實現時會呼叫複製建構函式,在函式返回時還要呼叫解構函式,而由於前加實現方式直接改變物件的內部狀態,並返回自己的引用,至始至終也沒有建立新的物件,所以也就不會呼叫建構函式和解構函式。

然而更加糟糕的是,迭代器通常是用來遍歷容器的,它大多應用在迴圈中,試想你的連結串列有100個元素,用下面的兩種方式遍歷:
for(_SingleList::Iterator it = list.begin(); it != list.end();++it)
{
//do something
}

for(_SingleList::Iterator it = list.begin(); it != list.end();it++)
{
//do something
}

如果你的習慣不好,寫了第二種形式,那麼很不幸,做同樣的事情,就是因為一個前加和一個後加的區別,你就要呼叫多200個函式,其對效率的影響可就不可忽視了。

三、迴圈引發的討論1(迴圈內定義,還是迴圈外定義物件)
請看下面的兩段程式碼:
程式碼1:
ClassTest CT;
for(int i = 0; i < 100; ++i)
{
CT = a;
//do something
}
程式碼2:
for(int i = 0; i < 100; ++i)
{
ClassTest CT = a;
//do something
}

你會覺得哪段程式碼的執行效率較高呢?程式碼1科學家是程式碼2?其實這種情況下,哪段程式碼的效率更高是不確定的,或者說是由這個類ClassTest本向決定的,分析如下:

對於程式碼1:需要呼叫ClassTest的建構函式1次,賦值操作函式(operator=)100次;對於程式碼2:需要高用(複製)建構函式100次,解構函式100次。

如果呼叫賦值操作函式的開銷比呼叫建構函式和解構函式的總開銷小,則第一種效率高,否則第二種的效率高。

四、迴圈引發的討論2(避免過大的迴圈)
現在請看下面的兩段程式碼,
程式碼1:
for(int i = 0; i < n; ++i)
{
fun1();
fun2();
}

程式碼2:
for(int i = 0; i < n; ++i)
{
fun1();
}
for(int i = 0; i < n; ++i)
{
fun2();
}
注:這裡的fun1()和fun2()是沒有關聯的,即兩段程式碼所產生的結果是一樣的。

以程式碼的層面上來看,似乎是程式碼1的效率更高,因為畢竟程式碼1少了n次的自加運算和判斷,畢竟自加運算和判斷也是需要時間的。但是現實真的是這樣嗎?

這就要看fun1和fun2這兩個函式的規模(或複雜性)了,如果這多個函式的程式碼語句很少,則程式碼1的執行效率高一些,但是若fun1和fun2的語句有很多,規模較大,則程式碼2的執行效率會比程式碼1顯著高得多。可能你不明白這是為什麼,要說是為什麼這要由計算機的硬體說起。

由於CPU只能從記憶體在讀取資料,而CPU的運算速度遠遠大於記憶體,所以為了提高程式的執行速度有效地利用CPU的能力,在記憶體與CPU之間有一個叫Cache的儲存器,它的速度接近CPU。而Cache中的資料是從記憶體中載入而來的,這個過程需要訪問記憶體,速度較慢。

這裡先說說Cache的設計原理,就是時間區域性性和空間區域性性。時間區域性性是指如果一個儲存單元被訪問,則可能該單元會很快被再次訪問,這是因為程式存在著迴圈。空間區域性性是指如果一個儲存單元被訪問,則該單元鄰近的單元也可能很快被訪問,這是因為程式中大部分指令是順序儲存、順序執行的,資料也一般也是以向量、陣列、樹、表等形式簇聚在一起的。

看到這裡你可能已經明白其中的原因了。沒錯,就是這樣!如果fun1和fun2的程式碼量很大,例如都大於Cache的容量,則在程式碼1中,就不能充分利用Cache了(由時間區域性性和空間區域性性可知),因為每迴圈一次,都要把Cache中的內容踢出,重新從記憶體中載入另一個函式的程式碼指令和資料,而程式碼2則更很好地利用了Cache,利用兩個迴圈語句,每個迴圈所用到的資料幾乎都已載入到Cache中,每次迴圈都可從Cache中讀寫資料,訪問記憶體較少,速度較快,理論上來說只需要完全踢出fun1的資料1次即可。

五、區域性變數VS靜態變數
很多人認為區域性變數在使用到時才會在記憶體中分配儲存單元,而靜態變數在程式的一開始便存在於記憶體中,所以使用靜態變數的效率應該比區域性變數高,其實這是一個誤區,使用區域性變數的效率比使用靜態變數要高。

這是因為區域性變數是存在於堆疊中的,對其空間的分配僅僅是修改一次esp暫存器的內容即可(即使定義一組區域性變數也是修改一次)。而區域性變數存在於堆疊中最大的好處是,函式能重複使用記憶體,當一個函式呼叫完畢時,退出程式堆疊,記憶體空間被回收,當新的函式被呼叫時,區域性變數又可以重新使用相同的地址。當一塊資料被反覆讀寫,其資料會留在CPU的一級快取(Cache)中,訪問速度非常快。而靜態變數卻不存在於堆疊中。

可以說靜態變數是低效的。

六、避免使用多重繼承
在C++中,支援多繼承,即一個子類可以有多個父類。書上都會跟我們說,多重繼承的複雜性和使用的困難,並告誡我們不要輕易使用多重繼承。其實多重繼承並不僅僅使程式和程式碼變得更加複雜,還會影響程式的執行效率。

這是因為在C++中每個物件都有一個this指標指向物件本身,而C++中類對成員變數的使用是通過this的地址加偏移量來計算的,而在多重繼承的情況下,這個計算會變數更加複雜,從而降低程式的執行效率。而為了解決二義性,而使用虛基類的多重繼承對效率的影響更為嚴重,因為其繼承關係更加複雜和成員變數所屬的父類關係更加複雜。

七、儘量少使用dynamic_cast
dynamic_cast的作用是進行指標或引用的型別轉換,dynamic_cast的轉換需要目標型別和源物件有一定的關係:繼承關係。 實現從子類到基類的指標轉換,實際上這種轉換是非常低效的,對程式的效能影響也比較大,不可大量使用,而且繼承關係越複雜,層次越深,其轉換時間開銷越大。在程式中應該儘量減少使用。

八、減少除法運算的使用
無論是整數還是浮點數運算,除法都是一件運算速度很慢的指令,在計算機中實現除法是比較複雜的。所以要減少除法運算的次數,下面介紹一些簡單方法來提高效率:
1、通過數學的方法,把除法變為乘法運算,如if(a > b/c),如果a、b、c都是正數,則可寫成if(a*c > b)
2、讓編譯器有優化的餘地,如裡你要做的運算是int型的n/8的話,寫成(unsigned)n/8有利於編譯器的優化。而要讓編譯器有優化的餘地,則除數必須為常數,而這也可以用const修飾一個變數來達到目的。

九、將小粒度函式宣告為行內函數(inline)
正如我們所知,呼叫函式是需要保護現場,為區域性變數分配記憶體,函式結束後還要恢復現場等開銷,而行內函數則是把它的程式碼直接寫到呼叫函式處,所以不需要這些開銷,但會使程式的原始碼長度變大。

所以若是小粒度的函式,如下面的Max函式,由於不需要呼叫普通函式的開銷,所以可以提高程式的效率。
int Max(int a, int b)
{
return a>b?a:b;
}

十、多用直接初始化
與直接初始化對應的是複製初始化,什麼是直接初始化?什麼又是複製初始化?舉個簡單的例子,
ClassTest ct1;
ClassTest ct2(ct1); //直接初始化
ClassTest ct3 = ct1; //複製初始化

那麼直接初始化與複製初始化又有什麼不同呢?直接初始化是直接以一個物件來構造另一個物件,如用ct1來構造ct2,複製初始化是先構造一個物件,再把另一個物件值複製給這個物件,如先構造一個物件ct3,再把ct1中的成員變數的值複製給ct3,從這裡,可以看出直接初始化的效率更高一點,而且使用直接初始化還是一個好處,就是對於不能進行復制操作的物件,如流物件,是不能使用賦值初始化的,只能進行直接初始化。可能我說得不太清楚,那麼下面就引用一下經典吧!

以下是Primer是的原話:
“當用於類型別物件時,初始化的複製形式和直接形式有所不同:直接初始化直接呼叫與實參匹配的建構函式,複製初始化總是呼叫複製建構函式。複製初始化首先使用指定建構函式建立一個臨時物件,然後用複製建構函式將那個臨時物件複製到正在建立的物件”,還有一段這樣說,“通常直接初始化和複製初始化僅在低階別優化上存在差異,然而,對於不支援複製的型別,或者使用非explicit建構函式的時候,它們有本質區別:
ifstream file1("filename")://ok:direct initialization
ifstream file2 = "filename";//error:copy constructor is private
補充:
這裡只是一點點的建議,雖然說了這麼多,但是還是要說一下的就是:要避免不必要的優化,避免不成熟的優化,不成熟的優化的是錯誤的來源,因為編譯器會為你做很多你所不知道的優化。

以後還發現有簡單的提高執行效率的方法,還會繼續補充......
對於每一個程式設計師來說,程式的執行效率都是一個值得重視,併為之付出努力的問題。但是程式效能的優化也是一門複雜的學問,需要很多的知識,然而並不是每個程式設計師都具備這樣的知識,而且論述如何優化程式提高程式執行效率的書籍也很少。但是這並不等於我們可以忽略程式的執行效率,下面就介紹一下本人積累的一些簡單實用的提高程式執行效率的方法,希望對大家有所幫助。

注:以C/C++程式為例

一、儘量變少值傳遞,多用引用來傳遞引數。
至於其中的原因,相信大家也很清楚,如果引數是int等語言自定義的型別可能能效能的影響還不是很大,但是如果引數是一個類的物件,那麼其效率問題就不言而喻了。例如一個判斷兩個字串是否相等的函式,其宣告如下:
bool Compare(string s1, string s2)
bool Compare(string *s1, string *s2)
bool Compare(string &s1, string &s2)
bool Compare(const string &s1, const string &s2)

其中若使用第一個函式(值傳遞),則在引數傳遞和函式返回時,需要呼叫string的建構函式和解構函式兩次(即共多呼叫了四個函式),而其他的三個函式(指標傳遞和引用傳遞)則不需要呼叫這四個函式。因為指標和引用都不會建立新的物件。如果一個構造一個物件和析構一個物件的開銷是龐大的,這就是會效率造成一定的影響。

然而在很多人的眼中,指標是一個惡夢,使用指標就意味著錯誤,那麼就使用引用吧!它與使用普通值傳遞一樣方便直觀,同時具有指標傳遞的高效和能力。因為引用是一個變數的別名,對其操作等同於對實際物件操作,所以當你確定在你的函式是不會或不需要變數引數的值時,就大膽地在宣告的前面加上一個const吧,就如最後的一個函式宣告一樣。

同時加上一個const還有一個好處,就是可以對常量進行引用,若不加上const修飾符,引用是不能引用常量的。

二、++i和i++引申出的效率問題
看了上面的第一點,你可能覺得,那不就是多呼叫了四個函式而已,你可能對此不屑一顧。那麼來看看下面的例子,應該會讓你大吃一驚。

至於整型變數的前加和後加的區別相信大家也是很清楚的。然而在這裡我想跟大家談的卻是C++類的運算子過載,為了與整形變數的用法一致,在C++中過載運算子++時一般都會把前加和後加都過載。你可能會說,你在程式碼中不會過載++運算子,但是你敢說你沒有使用過類的++運算子過載嗎?迭代器類你總使用過吧!可能到現在你還不是很懂我在說什麼,那麼就先看看下面的例子吧,是本人為連結串列寫的一個內部迭代器。

_SingleList::Iterator& _SingleList::Iterator::operator++()//前加
{
pNote = pNote->pNext;
return *this;
}
_SingleList::Iterator _SingleList::Iterator::operator++(int)//後加
{
Iterator tmp(*this);
pNote = pNote->pNext;
return tmp;
}
從後加的實現方式可以知道,物件利用自己建立一個臨時物件(自己在函式呼叫的一個複製),然後改變自己的狀態,並返回這個臨時物件,而前加的實現方式時,直接改變自己的內部狀態,並返回自己的引用。

從第一點的論述可以知道後加實現時會呼叫複製建構函式,在函式返回時還要呼叫解構函式,而由於前加實現方式直接改變物件的內部狀態,並返回自己的引用,至始至終也沒有建立新的物件,所以也就不會呼叫建構函式和解構函式。

然而更加糟糕的是,迭代器通常是用來遍歷容器的,它大多應用在迴圈中,試想你的連結串列有100個元素,用下面的兩種方式遍歷:
for(_SingleList::Iterator it = list.begin(); it != list.end();++it)
{
//do something
}

for(_SingleList::Iterator it = list.begin(); it != list.end();it++)
{
//do something
}

如果你的習慣不好,寫了第二種形式,那麼很不幸,做同樣的事情,就是因為一個前加和一個後加的區別,你就要呼叫多200個函式,其對效率的影響可就不可忽視了。

三、迴圈引發的討論1(迴圈內定義,還是迴圈外定義物件)
請看下面的兩段程式碼:
程式碼1:
ClassTest CT;
for(int i = 0; i < 100; ++i)
{
CT = a;
//do something
}
程式碼2:
for(int i = 0; i < 100; ++i)
{
ClassTest CT = a;
//do something
}

你會覺得哪段程式碼的執行效率較高呢?程式碼1科學家是程式碼2?其實這種情況下,哪段程式碼的效率更高是不確定的,或者說是由這個類ClassTest本向決定的,分析如下:

對於程式碼1:需要呼叫ClassTest的建構函式1次,賦值操作函式(operator=)100次;對於程式碼2:需要高用(複製)建構函式100次,解構函式100次。

如果呼叫賦值操作函式的開銷比呼叫建構函式和解構函式的總開銷小,則第一種效率高,否則第二種的效率高。

四、迴圈引發的討論2(避免過大的迴圈)
現在請看下面的兩段程式碼,
程式碼1:
for(int i = 0; i < n; ++i)
{
fun1();
fun2();
}

程式碼2:
for(int i = 0; i < n; ++i)
{
fun1();
}
for(int i = 0; i < n; ++i)
{
fun2();
}
注:這裡的fun1()和fun2()是沒有關聯的,即兩段程式碼所產生的結果是一樣的。

以程式碼的層面上來看,似乎是程式碼1的效率更高,因為畢竟程式碼1少了n次的自加運算和判斷,畢竟自加運算和判斷也是需要時間的。但是現實真的是這樣嗎?

這就要看fun1和fun2這兩個函式的規模(或複雜性)了,如果這多個函式的程式碼語句很少,則程式碼1的執行效率高一些,但是若fun1和fun2的語句有很多,規模較大,則程式碼2的執行效率會比程式碼1顯著高得多。可能你不明白這是為什麼,要說是為什麼這要由計算機的硬體說起。

由於CPU只能從記憶體在讀取資料,而CPU的運算速度遠遠大於記憶體,所以為了提高程式的執行速度有效地利用CPU的能力,在記憶體與CPU之間有一個叫Cache的儲存器,它的速度接近CPU。而Cache中的資料是從記憶體中載入而來的,這個過程需要訪問記憶體,速度較慢。

這裡先說說Cache的設計原理,就是時間區域性性和空間區域性性。時間區域性性是指如果一個儲存單元被訪問,則可能該單元會很快被再次訪問,這是因為程式存在著迴圈。空間區域性性是指如果一個儲存單元被訪問,則該單元鄰近的單元也可能很快被訪問,這是因為程式中大部分指令是順序儲存、順序執行的,資料也一般也是以向量、陣列、樹、表等形式簇聚在一起的。

看到這裡你可能已經明白其中的原因了。沒錯,就是這樣!如果fun1和fun2的程式碼量很大,例如都大於Cache的容量,則在程式碼1中,就不能充分利用Cache了(由時間區域性性和空間區域性性可知),因為每迴圈一次,都要把Cache中的內容踢出,重新從記憶體中載入另一個函式的程式碼指令和資料,而程式碼2則更很好地利用了Cache,利用兩個迴圈語句,每個迴圈所用到的資料幾乎都已載入到Cache中,每次迴圈都可從Cache中讀寫資料,訪問記憶體較少,速度較快,理論上來說只需要完全踢出fun1的資料1次即可。

五、區域性變數VS靜態變數
很多人認為區域性變數在使用到時才會在記憶體中分配儲存單元,而靜態變數在程式的一開始便存在於記憶體中,所以使用靜態變數的效率應該比區域性變數高,其實這是一個誤區,使用區域性變數的效率比使用靜態變數要高。

這是因為區域性變數是存在於堆疊中的,對其空間的分配僅僅是修改一次esp暫存器的內容即可(即使定義一組區域性變數也是修改一次)。而區域性變數存在於堆疊中最大的好處是,函式能重複使用記憶體,當一個函式呼叫完畢時,退出程式堆疊,記憶體空間被回收,當新的函式被呼叫時,區域性變數又可以重新使用相同的地址。當一塊資料被反覆讀寫,其資料會留在CPU的一級快取(Cache)中,訪問速度非常快。而靜態變數卻不存在於堆疊中。

可以說靜態變數是低效的。

六、避免使用多重繼承
在C++中,支援多繼承,即一個子類可以有多個父類。書上都會跟我們說,多重繼承的複雜性和使用的困難,並告誡我們不要輕易使用多重繼承。其實多重繼承並不僅僅使程式和程式碼變得更加複雜,還會影響程式的執行效率。

這是因為在C++中每個物件都有一個this指標指向物件本身,而C++中類對成員變數的使用是通過this的地址加偏移量來計算的,而在多重繼承的情況下,這個計算會變數更加複雜,從而降低程式的執行效率。而為了解決二義性,而使用虛基類的多重繼承對效率的影響更為嚴重,因為其繼承關係更加複雜和成員變數所屬的父類關係更加複雜。

七、儘量少使用dynamic_cast
dynamic_cast的作用是進行指標或引用的型別轉換,dynamic_cast的轉換需要目標型別和源物件有一定的關係:繼承關係。 實現從子類到基類的指標轉換,實際上這種轉換是非常低效的,對程式的效能影響也比較大,不可大量使用,而且繼承關係越複雜,層次越深,其轉換時間開銷越大。在程式中應該儘量減少使用。

八、減少除法運算的使用
無論是整數還是浮點數運算,除法都是一件運算速度很慢的指令,在計算機中實現除法是比較複雜的。所以要減少除法運算的次數,下面介紹一些簡單方法來提高效率:
1、通過數學的方法,把除法變為乘法運算,如if(a > b/c),如果a、b、c都是正數,則可寫成if(a*c > b)
2、讓編譯器有優化的餘地,如裡你要做的運算是int型的n/8的話,寫成(unsigned)n/8有利於編譯器的優化。而要讓編譯器有優化的餘地,則除數必須為常數,而這也可以用const修飾一個變數來達到目的。

九、將小粒度函式宣告為行內函數(inline)
正如我們所知,呼叫函式是需要保護現場,為區域性變數分配記憶體,函式結束後還要恢復現場等開銷,而行內函數則是把它的程式碼直接寫到呼叫函式處,所以不需要這些開銷,但會使程式的原始碼長度變大。

所以若是小粒度的函式,如下面的Max函式,由於不需要呼叫普通函式的開銷,所以可以提高程式的效率。
int Max(int a, int b)
{
return a>b?a:b;
}

十、多用直接初始化
與直接初始化對應的是複製初始化,什麼是直接初始化?什麼又是複製初始化?舉個簡單的例子,
ClassTest ct1;
ClassTest ct2(ct1); //直接初始化
ClassTest ct3 = ct1; //複製初始化

那麼直接初始化與複製初始化又有什麼不同呢?直接初始化是直接以一個物件來構造另一個物件,如用ct1來構造ct2,複製初始化是先構造一個物件,再把另一個物件值複製給這個物件,如先構造一個物件ct3,再把ct1中的成員變數的值複製給ct3,從這裡,可以看出直接初始化的效率更高一點,而且使用直接初始化還是一個好處,就是對於不能進行復制操作的物件,如流物件,是不能使用賦值初始化的,只能進行直接初始化。可能我說得不太清楚,那麼下面就引用一下經典吧!

以下是Primer是的原話:
“當用於類型別物件時,初始化的複製形式和直接形式有所不同:直接初始化直接呼叫與實參匹配的建構函式,複製初始化總是呼叫複製建構函式。複製初始化首先使用指定建構函式建立一個臨時物件,然後用複製建構函式將那個臨時物件複製到正在建立的物件”,還有一段這樣說,“通常直接初始化和複製初始化僅在低階別優化上存在差異,然而,對於不支援複製的型別,或者使用非explicit建構函式的時候,它們有本質區別:
ifstream file1("filename")://ok:direct initialization
ifstream file2 = "filename";//error:copy constructor is private
補充:
這裡只是一點點的建議,雖然說了這麼多,但是還是要說一下的就是:要避免不必要的優化,避免不成熟的優化,不成熟的優化的是錯誤的來源,因為編譯器會為你做很多你所不知道的優化。

以後還發現有簡單的提高執行效率的方法,還會繼續補充......

相關文章