通常大多數開發人員認為,匯編語言和C語言比較適合編寫對性能要求非常高的程序,C++語言主要適用于編寫復雜度非常高但性能要求并不是很高的程序。因為大多數開發人員認為,C++語言設計時因為考慮到支持多種編程模式(如面向對象編程和范型編程)以及異常處理等,從而引入了太多新的語言特性。新的語言特性往往使得C++編譯器在編譯程序時插入了很多額外的代碼,會導致最終生成的二進制代碼體積膨脹,而且執行速度下降。
但事實并非如此,通常一個程序的速度在框架設計完成時大致已經確定,而并非因為采用C++語言才導致速度沒有達到預期目標。因此,當一個程序的性能需要提高時,首先需要做的是用性能檢測工具對其運行的時間分布進行一個準確的測量,找出關鍵路徑和真正的性能瓶頸所在,然后針對性能瓶頸進行分析和優化,而不是主觀地將性能問題歸咎于程序所采用的語言。工程實踐表明,如果框架設計不做修改,即使使用C語言或匯編語言重新改寫,也并不能保證提高總體性能。
因此,遇到性能問題時,首先應檢查和反思程序的總體架構,然后使用性能檢測工具對其實際運行做準確的測量,再針對性能瓶頸進行分析和優化。
但C++語言中確實有一些操作、特性比其它因素更容易成為程序的性能瓶頸,常見因素如下:
(1)缺頁
缺頁通常意味著要訪問外部存儲,因為外部存儲訪問相對于訪問內存或代碼執行,有數量級的差別。因此,只要有可能,應該盡量想辦法減少缺頁。
(2)從堆中動態申請和釋放內存
C語言中的malloc/free和C++語言中的new/delete操作時非常耗時的,因此要盡可能優先考慮從線程棧中獲取內存。優先考慮棧而減少從動態堆中申請內存,不僅因為在堆中分配內存比在棧中要慢很多,而且還與盡量減少缺頁有關。當程序執行時,當前棧幀空間所在的內存頁肯定在物理內存中,因此程序代碼對其中變量的存取不會引起缺頁;如果從堆空間生成對象,只有指向對象的指針在棧上,對象本身則存儲在堆空間中。堆一般不可能都在物理內存中,而且由于堆分配內存的特性,即使兩個相鄰生成的對象,也很有可能在堆內存位置上相距很遠。因此,當訪問兩個對象時,雖然分別指向兩個對象的指針都在棧上,但通過兩個指針引用對象時很有可能會引起兩次缺頁。
(3)復雜對象的創建和銷毀
復雜對象的創建和銷毀會比較耗時,因此對于層次較深的遞歸調用需要重點關注遞歸內部的對象創建。其次,編譯器生成的臨時對象因為在程序的源碼中看不到,更不容易察覺,因此需要重點關注。
(4)函數調用
由于函數調用有固定的額外開銷,因此當函數體的代碼量相對較少,并且函數被非常頻繁調用時,函數調用時的固定開銷容易成為不必要的開銷。C語言的宏和C++語言的內聯函數都是為了在保持函數調用的模塊化特征基礎上消除函數調用的固定額外開銷而引入的。由于C語言的宏在×××能優勢的同時也給開發和調試帶來不便,因此C++語言中推薦使用內聯函數。
前進網站制作公司哪家好,找成都創新互聯公司!從網頁設計、網站建設、微信開發、APP開發、響應式網站等網站項目制作,到程序開發,運營維護。成都創新互聯公司成立與2013年到現在10年的時間,我們擁有了豐富的建站經驗和運維經驗,來保證我們的工作的順利進行。專注于網站建設就選成都創新互聯公司。
構造函數和析構函數的特點是當創建對象時自動執行構造函數;當銷毀對象時,析構函數自動被執行。構造函數是一個對象最先被執行的函數,在創建對象時調用,用于初始化對象的初始狀態和取得對象被使用前需要的一些資源,如文件、網絡連接等;析構函數是一個對象最后被執行的函數,用于釋放對象擁有的資源。在對象的生命周期內,構造函數和析構函數都只會執行一次。
創建一個對象有兩種方式,一種是從線程運行棧中創建,稱為局部對象。銷毀局部對象并不需要程序顯示地調用析構函數,而是當程序運行出對象所屬的作用域時自動調用對象的析構函數。
創建對象的另一種方式是從全局堆中動態分配,通常使用new或malloc分配堆空間。
Obejct* p = new Object();//1
// do something //2
delete p;//3
p = NULL;//4
執行語句1時,指針p所指向對象的內存從全局堆空間中獲得,并將地址賦值給p,p本身是一個局部變量,需要從線程棧中分配,p所指向對象從全局堆中分配內存存放。從全局堆中創建的對象需要顯示調用delete進行銷毀,delete會調用指針p指向對象的析構函數,并將對象所占的全局堆內存空間返回給全局堆。執行語句3后,指針p指向的對象被銷毀,但指針p還存在于棧中,直到程序退出其所在作用域。將p指針所指向對象銷毀后,p指針仍指向被銷毀對象的全局堆空間位置,此時指針p變成一個懸空指針,此時使用指針p是危險的,通常推薦將p賦值NULL。
在Win32平臺,訪問銷毀對象的全局堆空間內存會導致三種情況:
(1)被銷毀對象所在的內存頁沒有任何對象,堆管理器已經將所占堆空間進一步回收給操作系統,此時通過指針訪問會引起訪問違例,即訪問了不合法內存,引起進程崩潰。
(2)被銷毀對象所在的內存頁存在其它對象,并且被銷毀對象曾經占用的全局堆空間被回收后尚未分配給其它對象,此時通過指針p訪問取得的值是無意義的,雖然不會立刻引起進程崩潰,但針對指針p的后續操作行為是不可預測的。
(3)被銷毀對象所在的內存頁存在其它對象,并且被銷毀對象曾經占用的全局堆空間被回收后已經分配給其它對象,此時通過指針p取得的值是其它對象,雖然對指針p的訪問不會引起進程崩潰,但極有可能引起對象狀態的改變。
創建一個對象分為兩個步驟,即首先取得對象所需的內存(從線程?;蛉侄眩?,然后在內存空間上執行構造函數。在構造函數構建對象時,構造函數也分為兩個步驟。第一步執行初始化(通過初始化參數列表),第二步執行構造函數的函數體。
class Derived : public Base
{
public:
Derived(): id(1), name("UnNamed") // 1
{
// do something // 2
}
private:
int id;
string name;
};
語句1中冒號后的代碼即為初始化列表,每個初始化單元都是變量名(值)的模式,不同單元之間使用逗號分隔。構造函數首先根據初始化列表執行初始化,然后執行構造函數的函數體(語句2)。初始化操作的注意事項如下:
(1)構造函數其實是一個遞歸操作,在每層遞歸內部的操作遵循嚴格的次序。遞歸模式會首先執行父類的構造函數(父類的構造函數操作也相應包含執行初始化和執行構造函數函數體兩個部分),父類構造函數返回后構造類自己的成員變量。構造類自己的成員變量時,一是嚴格按照成員變量在類中的聲明順序進行,與成員變量在初始化列表中出現的順序完全無關;二是當有些成員變量或父類對象沒有在初始化列表出現時,仍然在初始化操作中對其進行初始化,內建類型成員變量被賦值給一個初值,父類對象和類成員變量對象被調用其默認構造函數初始化,然后父類的構造函數和子成員變量對象在構造函數執行過程中也遵循上述遞歸操作,直到類的繼承體系中所有父類和父類所含的成員變量都被構造完成,類的初始化操作才完成。
(2)父類對象和一些成員變量沒有出現在初始化列表中時,其仍然會被執行默認構造函數。因此,相應對象所屬類必須提供可以調用的默認構造函數,為此要求相應的類必須顯式提供默認構造函數,要么不能阻止編譯器隱式生成默認構造函數,定義除默認構造函數外的其它類型的構造函數將會阻止編譯器生成默認構造函數。如果編譯器在編譯時,發現沒有可供調用的默認構造函數,并且編譯器也無法生成默認構造函數,則編譯無法通過。
(3)對兩類成員變量,需要強調指出(即常量型和引用型)。由于所有成員變量在執行函數體前已經被構造,即已經擁有初始值,因此,對于常量型和引用型變量必須在初始化列表中正確初始化,而不能將其初始化放在構造函數體內。
(4)初始化列表可能沒有完全列出其子成員或父類對象成員,或者順序與其在類中的聲明順序不同,仍然會保證嚴格被全部并且嚴格按照順序被構建。即程序在進入構造函數體前,類的父類對象和所有子成員變量對象已經被生成和構造。如果在構造函數體內為其執行賦值操作,顯然屬于浪費。如果在構造函數時已經知道如何為類的子成員變量初始化,則應該將初始化信息通過構造函數的初始化列表賦予子成員變量,而不是在構造函數體內進行初始化,因為進入構造函數時,子成員變量已經初始化一次。
析構函數和構造函數一樣,是遞歸的過程,但存在不同。一是析構函數不存在初始化操作部分,析構函數的主要工作就是執行析構函數的函數體;二是析構函數執行的遞歸與構造函數相反,在每一層遞歸中,成員變量對象的析構順序也與構造函數相反。
析構函數只能選擇類的成員變量在類中聲明的順序作為析構的順序參考(正序或逆序)。因為構造函數選擇了正序,而析構函數的工作與構造函數相反,因此析構函數選擇逆序。又因為析構函數只能使用成員變量在類中的聲明順序作為析構順序的依據(正序或逆序),因此構造函數也只能選擇成員變量在類中的聲明順序作為構造的順序依據,而不能采用初始化列表的順序作為順序依據。
如果操作的對象屬于一個復雜繼承體系的末端節點,其析構過程也將十分耗時。
在C++程序中,創建和銷毀對象是影響性能的一個非常突出的操作。首先,如果是從全局堆空間中生成對象,則需要先進行動態內存分配操作,而動態內存的分配與回收是非常耗時的操作,因為涉及到尋找匹配大小的內存塊,找到后可能還需要截斷處理,然后還需要修改維護全局堆內存使用情況信息的鏈表。頻繁的內存操作會嚴重影響性能的下降,使用內存池技術可以減少從全局動態堆空間申請內存的次數,提高程序的總體性能。當取得內存后,如果需要生成的內對象屬于復雜繼承體系的末端類,則構造函數的調用將會引起一連串的遞歸構造操作,在大型復雜系統中,大量的此類對象構造將會消耗CPU操作的主要部分。
由于對象的創建和銷毀會影響性能,在盡量減少自己代碼生成對象的同時,需要關注編譯器在編譯時臨時生成的對象,盡量避免臨時對象的生成。
如果在實現構造函數時,在構造函數體中進行了第二次的賦值操作,也會浪費CPU時間。
減少對象創建和銷毀的常見方法是在聲明中將所有的值傳遞改為常量引用傳遞,如:
int func(Object obj);// 1
int func(const Object& obj);// 2
值傳遞驗證示例如下:
#include <iostream>
using namespace std;
class Object
{
public:
Object(int i = 1)
{
n = i;
cout << "Object(int i = 1): " << endl;
}
Object(const Object& another)
{
n = another.n;
cout << "Object(const Object& another): " << endl;
}
void increase()
{
n++;
}
int value()const
{
return n;
}
~Object()
{
cout << "~Object()" << endl;
}
private:
int n;
};
void func(Object obj)
{
cout << "enter func, before increase(), n = " << obj.value() << endl;
obj.increase();
cout << "enter func, after increase(), n = " << obj.value() << endl;
}
int main()
{
Object a; // 1
cout << "before call func, n = " << a.value() << endl;
func(a); // 2
cout << "after call func, n = " << a.value() << endl;// 3
return 0;
}
// output:
//Object(int i = 1): // 4
//before call func, n = 1
//Object(const Object& another): // 5
//enter func, before increase(), n = 1 // 6
//enter func, after increase(), n = 2 // 7
//~Object() // 8
//after call func, n = 1 // 9
//~Object()
語句4的輸出為語句1處的對象構造,語句5輸出則是語句2處的func(a)函數調用,調用開始時通過拷貝構造函數生成對象a的復制品,緊跟著在函數內檢查n的輸出值輸出語句6,輸出值與func函數外部元對象a的值相同,然后復制品調用increase函數將n值加1,此時復制品的n值為2,并輸出語句7。func函數執行完畢后銷毀復制品,輸出語句8。main函數內繼續執行,打印原對象a的n值為1,輸出語句9。
當函數需要修改傳入參數時,應該用引用傳入參數;當函數不會修改傳入參數時,如果函數聲明中傳入參數為對象,則函數可以達到設計目的,但會生成不必要的復制品對象,從而引入不必要的構造和析構操作,應該使用常量引用傳入參數。
構造函數的重復賦值對性能影響驗證示例如下:
#include <iostream>
#include <time.h>
using namespace std;
class DArray
{
public:
DArray(double v = 1.0)
{
for(int i = 0; i < 1000; i++)
{
d[i] = v + i;
}
}
void init(double v = 1.0)
{
for(int i = 0; i < 1000; i++)
{
d[i] = v + i;
}
}
private:
double d[1000];
};
class Object
{
public:
Object(double v)
{
d.init(v);
}
private:
DArray d;
};
int main()
{
clock_t start, finish;
start = clock();
for(int i = 0; i < 100000; i++)
{
Object obj(2.0 + i);
}
finish = clock();
cout << "Used Time: " << double(finish - start) << "" << endl;
return 0;
}
耗時為600000單位,如果通過初始化列表對成員變量進行初始化,其代碼如下:
#include <iostream>
#include <time.h>
using namespace std;
class DArray
{
public:
DArray(double v = 1.0)
{
for(int i = 0; i < 1000; i++)
{
d[i] = v + i;
}
}
void init(double v = 1.0)
{
for(int i = 0; i < 1000; i++)
{
d[i] = v + i;
}
}
private:
double d[1000];
};
class Object
{
public:
Object(double v): d(v)
{
}
private:
DArray d;
};
int main()
{
clock_t start, finish;
start = clock();
for(int i = 0; i < 100000; i++)
{
Object obj(2.0 + i);
}
finish = clock();
cout << "Used Time: " << double(finish - start) << "" << endl;
return 0;
}
耗時為300000單位,性能提高約50%。
虛函數是C++語言引入的一個重要特性,提供了動態綁定機制,動態綁定機制使得類繼承的語義變得相對明晰。
(1)基類抽象了通用的數據及操作。對于數據而言,如果數據成員在各個派生類中都需要用到,需要將其聲明在基類中;對于操作而語言,如果操作對于各個派生類都有意義,無論其語義是否會被修改和擴展,需要將其聲明在基類中。
(2)某些操作,對于各個派生類而言,語義完全保持一致,而無需修改和擴展,則相應操作聲明為基類的非虛成員函數。各個派生類在聲明為基類的派生類時,默認繼承非虛成員函數的聲明和實現,如果默認繼承基類的數據成員一樣,而不必另外做任何聲明,構成代碼復用。
(3)對于某些操作,雖然對于各個派生類都有意義,但其語義并不相同,則相應的操作應該聲明為虛成員函數。各個派生類雖然也繼承了虛成員函數的聲明和實現,但語義上應該對虛成員函數的實現進行修改或擴展。如果在實現修改、擴展虛成員函數的過程中,需要用到額外的派生類獨有的數據時,則將相應的數據聲明為派生類自己的數據成員。
當更高層次的程序框架(繼承體系的使用者)使用此繼承體系時,處理的是抽象層次的對象集合,對象集合的成員本質是各種派生類對象,但在處理對象集合的對象時,使用的是抽象層次的操作。高層程序框架并不區分相應操作中哪些操作對于派生類是不變的,哪些操作對于派生類是不同的,當實際執行到各操作時,運行時系統能夠識別哪些操作需要用到動態綁定。從而找到對應派生類的修改或擴展的操作版本。即對繼承體系的使用者而言,繼承體系內部的多樣性是透明的,不必關心其繼承細節,處理的是一組對使用者而言整體行為一致的對象。即使繼承體系內部增加、刪除了某個派生類,或某個派生類的虛函數實現發生了改變,使用者的代碼也不必做任何修改,使程序的模塊化程度得到極大提高,其擴展性、維護性和代碼可讀性也會提高。對于對象繼承體系使用者而言,只看到抽象類型,而不必關心具體是哪種具體類型。
虛函數的動態綁定特性雖然很好,但存在內存空間和時間開銷,每個支持虛函數的類(基類或派生類)都會有一個包含其所有支持的虛函數的虛函數表的指針。每個類對象都會隱含一個虛函數表指針(virtual pointer),指向其所屬類的虛函數表。當通過基類的指針或引用調用某個虛函數時,系統需要首先定位指針或引用真正對應的對象所隱含的虛函數指針,然后虛函數指針根據虛函數的名稱對其所指向的虛函數表進行一個偏移定位,再調用偏移定位處的函數指針對應的虛函數,即動態綁定的解析過程。C++規范只需要編譯器能夠保證動態綁定的語義,但大多數編譯器都采用上述方法實現虛函數。
(1)每個支持虛函數的類都有一個虛函數表,虛函數表的大小與類擁有的虛函數的多少成正比。一個程序中,每個類的虛函數表只有一個,與類對象的數量無關。支持虛函數的類的每個類對象都有一個指向類的虛函數表的虛函數指針,因此程序運行時虛函數指針引起的內存開銷與生成的類對象數量成正比。
(2)支持虛函數的類生成每個對象時,在構造函數中會調用編譯器在構造函數內部插入的初始化代碼,來初始化其虛函數指針,使其指向正確的虛函數表。當通過指針或引用調用虛函數時,會根據虛函數指針找到相應類的虛函數表。
內聯函數通??梢蕴岣叽a執行速度,很多普通函數會根據情況進行內聯化,但虛函數無法利用內聯化的優勢。因為內聯是在編譯階段編譯器將調用內聯函數的位置用內聯函數體替代(內聯展開),但虛函數本質上是運行期行為。在編譯階段,編譯器無法知道某處的虛函數調用在真正執行的時后需要調用哪個具體的實現(即編譯階段無法確定其具體綁定),因此,編譯階段編譯器不會對通過指針或引用調用的虛函數進行內聯化。如果需要利用虛函數的動態綁定的設計優勢,必須放棄內聯帶來的速度優勢。
如果不使用虛函數,可以通過在抽象基類增加一個類型標識成員用于在運行時識別具體的派生類對象,在派生類對象構造時必須指定具體的類型。繼承體系的使用者調用函數時不再需要一次間接地根據虛函數表查找虛函數指針的操作,但在調用前仍然需要使用switch語句對其類型進行識別。
因此虛函數的缺點可以認為只有兩條,即虛函數表的空間開銷以及無法利用內聯函數的速度優勢。由于每個含有虛函數的類在整個程序只有一個虛函數表,因此虛函數表引起的空間開銷時非常小的。所以,可以認為虛函數引入的性能缺陷只是無法利用內聯函數。
通常,非虛函數的常規設計假如需要增加一種新的派生類型,或者刪除一種不再支持的派生類型,都必須修改繼承體系所有使用者的所有與類型相關的函數調用代碼。對于一個復雜的程序,某個繼承體系的使用者會很多,每次對繼承體系的派生類的修改都會波及使用者。因此,不使用虛函數的常規設計增加了代碼的耦合度,模塊化不強,導致項目的可擴展性、可維護性、代碼可讀性都會降低。面向對象編程的一個重要目的就是增加程序的可擴展性和可維護性,即當程序的業務邏輯發生改變時,對原有程序的修改非常方便,降低因為業務邏輯改變而對代碼修改時出錯的概率。
因此,在性能和其它特性的選擇方面,需要開發人員根據實際情況進行進行權衡和取舍,如果性能檢驗確認性能瓶頸不是虛函數沒有利用內聯的優勢引起,可以不必考慮虛函數對性能的影響。
對象的創建與銷毀對程序的性能影響很大,尤其是對象的類處于一個復雜繼承體系的末端,或者對象包含很多成員對象(包括其所有父類對象,即直接或者間接父類的所有成員變量對象)時,對程序性能影響尤其顯著。因此,作為一個對性能敏感的程序員,應該盡量避免創建不必要的對象,以及隨后的銷毀。除了減少顯式地創建對象,也要盡量避免編譯器隱式地創建對象,即臨時對象。
#include <iostream>
#include <cstring>
class Matrix
{
public:
Matrix(double d = 1.0)
{
for(int i = 0; i < 10; i++)
{
for(int j = 0; j < 10; j++)
{
m[i][j] = d;
}
}
cout << "Matrix(double d = 1.0)" << endl;
}
Matrix(const Matrix& another)
{
cout << "Matrix(const Matrix& another)" << endl;
memcpy(this, &another, sizeof(another));
}
Matrix& operator=(const Matrix& another)
{
if(this != &another)
{
memcpy(this, &another, sizeof(another));
}
cout << "Matrix& operator=(const Matrix& another)" << endl;
return *this;
}
friend const Matrix operator+(const Matrix& m1, const Matrix& m2);
private:
double m[10][10];
};
const Matrix operator+(const Matrix& m1, const Matrix& m2)
{
Matrix sum; // 1
for(int i = 0; i < 10; i++)
{
for(int j = 0; j < 10; j++)
{
sum.m[i][j] = m1.m[i][j] + m2.m[i][j];
}
}
return sum; // 2
}
int main()
{
Matrix a(2.0), b(3.0), c; // 3
c = a + b; // 4
return 0;
}
由于GCC編譯器默認進行了返回值優化(Return Value Optimization,簡稱RVO),因此需要指定-fno-elide-constructors選項進行編譯:g++ -fno-elide-constructors main.cpp
輸出結果如下:
Matrix(double d = 1.0) // 1
Matrix(double d = 1.0) // 2
Matrix(double d = 1.0) // 3
Matrix(double d = 1.0) // 4
Matrix(const Matrix& another) // 5
Matrix& operator=(const Matrix& another) // 6
分析代碼,語句3生成3個Matrix對象,調用3次構造函數,語句4調用operator+執行到語句1時生成臨時變量sum,調用1次構造函數,語句4調用賦值操作,不會生成新的Matrix對象。輸出5則是因為a+b調用operator+函數時需要返回一個Matrix變量sum,然后進一步通過operator=函數將sum變量賦值給變量c,但a+b返回時,sum變量已經被銷毀,即在operator+函數調用結束時被銷毀,其返回的Matrix變量需要在調用a+b函數的棧中開辟空間來存放,臨時的Matrix對象是在a+b返回時通過Matrix拷貝構造函數構造,即輸出5打印。
如果使用默認GCC編譯選項編譯,GCC編譯器默認會進行返回值優化。
g++ main.cpp
程序輸出如下:
Matrix(double d = 1.0)
Matrix(double d = 1.0)
Matrix(double d = 1.0)
Matrix(double d = 1.0)
Matrix& operator=(const Matrix& another)
臨時對象與臨時變量并不相同。通常,臨時變量是指為了暫時存放某個值的變量,顯式出現在源碼中;臨時對象通常指編譯器隱式生成的對象。
臨時對象在C++語言中的特征是未出現在源代碼中,而是從棧中產生未命名對象,開發人員并沒有聲明要使用臨時對象,由編譯器根據情況產生,通常開發人員不會注意到其產生。
返回值優化(Return Value Optimization,簡稱RVO)是一種優化機制,當函數需要返回一個對象的時候,如果自己創建一個臨時對象用戶返回,那么臨時對象會消耗一個構造函數(Constructor)的調用、一個復制構造函數的調用(Copy Constructor)以及一個析構函數(Destructor)的調用的代價,而如果稍微做一點優化,就可以將成本降低到一個構造函數的代價。
通常,產生臨時對象的場合如下:
(1)當實際調用函數時傳入的參數與函數定義中聲明的變量類型不匹配。
(2)當函數返回一個對象時。
在函數傳遞參數為對象時,實際調用時因為函數體內的對象與實際傳入的對象并不相同,而是傳入對象的拷貝,因此有開發者認為函數體內的拷貝對象也是一個臨時對象,但嚴格來說,函數體內的拷貝對象并不符合未出現在源碼中。
對于類型不匹配生成臨時對象的情況,示例如下:
#include <iostream>
using namespace std;
class Rational
{
public:
Rational(int a = 0, int b = 1): real(a), imag(b) // 1
{
cout << " Rational(int a = 0, int b = 0)" << endl;
}
private:
int real;
int imag;
};
void func()
{
Rational r;
r = 100; // 2
}
int main()
{
func();
return 0;
}
執行語句2時,由于Rational沒有重載operator=(int i),編譯器會合成一個operator=(const Rational& another)函數,并執行逐位拷貝賦值操作,但由于100不是一個Rational對象,但編譯器會盡可能查找合適的轉換路徑,以滿足編譯的需要。編譯器發現存在一個Rational(int a = 0, int b = 1)構造函數,編譯器會將語句2右側的100通過Rational100, 1)生成一個臨時對象,然后用編譯器合成的operator=(const Rational& another)函數進行逐位賦值,語句2執行后,r對象內部的real為100,img為1。
C++編譯器為了成功編譯某些語句會生成很多從源碼中不易察覺的輔助函數,甚至對象。C++編譯器提供的自動類型轉換確實提高了程序的可讀性,簡化了程序編寫,提高了開發效率。但類型轉換意味著臨時對象的產生,對象的創建和銷毀意味著性能的下降,類型轉換還意味著編譯器會生成其它的代碼。因此,如果不需要編譯器提供自動類型轉換,可以使用explicit對類的構造函數進行聲明。
#include <iostream>
using namespace std;
class Rational
{
public:
explicit Rational(int a = 0, int b = 1): real(a), imag(b) // 1
{
cout << " Rational(int a = 0, int b = 0)" << endl;
}
private:
int real;
int imag;
};
void func()
{
Rational r; // 2
r = 100; // 3
}
int main()
{
func();
return 0;
}
此時,進行代碼編譯會報錯:error: no match for ‘operator=’ (operand types are ‘Rational’ and ‘int’)
錯誤信息提示沒有匹配的operator=函數將int和Rational對象進行轉換。C++編譯器默認合成的operator=函數只接受Rational對象,不能接受int類型作為參數。要想代碼編譯能夠通過,方法一是提供一個重載的operator=賦值函數,可以接受整型作為參數;方法二是能夠將整型轉換為Rational對象,然后進一步利用編譯器合成的賦值運算符。將整型轉換為Rational對象,可以提供能只傳遞一個整型作為參數的Rational構造函數,考慮到缺省參數,調用構造函數可能會是無參、一個參數、兩個參數,此時編譯器可以利用整型變量作為參數調用Rational構造函數生成一個臨時對象。由于explicit關鍵字限定了構造函數只能被顯示調用,不允許編譯器運用其進行類型轉換,此時編譯器不能使用構造函數將整型100轉換為Rational對象,所以導致編譯報錯。
通過重載以整型作為參數的operator=函數可以成功編譯,代碼如下:
#include <iostream>
using namespace std;
class Rational
{
public:
explicit Rational(int a = 0, int b = 1): real(a), imag(b) // 1
{
cout << " Rational(int a = 0, int b = 0)" << endl;
}
Rational& operator=(int r)
{
real = r;
imag = 1;
return *this;
}
private:
int real;
int imag;
};
void func()
{
Rational r; // 2
r = 100; // 3
}
int main()
{
func();
return 0;
}
重載operator=函數后,編譯器可以成功將整型數轉換為Rational對象,同時成功避免了臨時對象產生。
當一個函數返回的是非內建類型的對象時,返回結果對象必須在某個地方存放,編譯器會從調用相應函數的棧幀中開辟空間,并用返回值作為參數調用返回值對象所屬類型的拷貝構造函數在所開辟的空間生成對象,在調用函數結束并返回后可以繼續利用臨時對象。
#include <iostream>
#include <string>
using namespace std;
class Rational
{
public:
Rational(int a = 0, int b = 0): real(a), imag(b)
{
cout << " Rational(int a = 0, int b = 0)" << endl;
}
Rational(const Rational& another): real(another.real), imag(another.imag)
{
cout << " Rational(const Rational& another)" << endl;
}
Rational& operator = (const Rational& other)
{
if(this != &other)
{
real = other.real;
imag = other.imag;
}
cout << " Rational& operator = (const Rational& other)" << endl;
return *this;
}
friend const Rational operator+(const Rational& a, const Rational& b);
private:
int real;
int imag;
};
const Rational operator+(const Rational& a, const Rational& b)
{
cout << " operator+ begin" << endl;
Rational c;
c.real = a.real + b.real;
c.imag = a.imag + b.imag;
cout << " operator+ end" << endl;
return c; // 2
}
int main()
{
Rational r, a(10, 10), b(5, 8);
r = a + b;// 1
return 0;
}
執行語句1時,相當于在main函數中調用operator+(const Rational& a, const Rational& b)函數,在main函數的棧中會開辟一塊Rational大小的空間,在operator+(const Rational& a, const Rational& b)函數內部的語句2處,函數返回使用被銷毀的c對象作為參數調用拷貝構造函數在main函數棧中開辟空間生成一個Rational對象。然后使用operator =執行賦值操作。編譯如下:g++ -fno-elide-constructors main.cpp
輸出如下:
Rational(int a = 0, int b = 0)
Rational(int a = 0, int b = 0)
Rational(int a = 0, int b = 0)
operator+ begin
Rational(int a = 0, int b = 0)
operator+ end
Rational(const Rational& another)
Rational& operator = (const Rational& other)
由于r對象在默認構造后并沒有使用,可以延遲生成,代碼如下:
#include <iostream>
#include <string>
using namespace std;
class Rational
{
public:
Rational(int a = 0, int b = 0): real(a), imag(b)
{
cout << " Rational(int a = 0, int b = 0)" << endl;
}
Rational(const Rational& another): real(another.real), imag(another.imag)
{
cout << " Rational(const Rational& another)" << endl;
}
Rational& operator = (const Rational& other)
{
if(this != &other)
{
real = other.real;
imag = other.imag;
}
cout << " Rational& operator = (const Rational& other)" << endl;
return *this;
}
friend const Rational operator+(const Rational& a, const Rational& b);
private:
int real;
int imag;
};
const Rational operator+(const Rational& a, const Rational& b)
{
cout << " operator+ begin" << endl;
Rational c;
c.real = a.real + b.real;
c.imag = a.imag + b.imag;
cout << " operator+ end" << endl;
return c; // 2
}
int main()
{
Rational a(10, 10), b(5, 8);
Rational r = a + b; // 1
return 0;
}
編譯過程如下:
g++ -fno-elide-constructors main.cpp
輸出如下:
Rational(int a = 0, int b = 0)
Rational(int a = 0, int b = 0)
operator+ begin
Rational(int a = 0, int b = 0)
operator+ end
Rational(const Rational& another)
Rational(const Rational& another)
分析代碼,編譯器執行語句1時語義發生了較大變化,編譯器對=的解釋不再是賦值操作符,而是對象r的初始化。在取得a+b的結果時,在main函數棧中開辟空間,使用c對象作為參數調用拷貝構造函數生成一個臨時對象,然后使用臨時對象作為參數調用拷貝構造函數生成r對象。
因此,對于非內建對象,盡量將對象延遲到確切直到其有效狀態時,可以有效減少臨時對象生成。如將Rational r;r = a + b;改寫為Rational r = a + b;
進一步,可以將operator+函數改寫為如下:
const Rational operator+(const Rational& a, const Rational& b)
{
cout << " operator+ begin" << endl;
return Rational(a.real + b.real, a.imag + b.imag); // 2
}
通常,operator+與operator+=需要以其實現,Rational的operator+=實現如下:
Rational operator+=(const Rational& a)
{
real += a.real;
imag = a.imag;
return *this;
}
operator+=沒有產生臨時對象,盡量用operator+=代替operator+操作。考慮到代碼復用性,operator+可以使用operator+=實現,代碼如下:
const Rational operator+(const Rational& a, const Rational& b)
{
cout << " operator+ begin" << endl;
return Rational(a) += b; // 2
}
對于前自增操作符實現如下:
const Rational operator++()
{
++real;
return *this;
}
對于后自增操作如下:
const Rational operator++(int)
{
Rational temp(*this);
++(*this);
return temp;
}
前自增只需要將自身返回,后自增需要返回一個對象,因此需要多生成兩個對象:函數體內的局部變量和臨時對象,因此對于非內建類型,在保證程序語義下盡量使用前自增。
C++規范中定義了臨時對象的生命周期從創建時開始,到包含創建它的最長語句執行完畢。
string a, b;
const char* str;
if(strlen(str = (a + b).c_str()) > 5) // 1
{
printf("%s\n", str);// 2
}
分析代碼,語句1處首先創建一個臨時對象存放a+b的值,然后將臨時對象的內容通過c_str函數得到賦值給str,如果str長度大于5則執行語句2,但臨時對象生命周期在包含其創建的最長語句已經結束,當進入if語句塊時,臨時對象已經被銷毀,執行其內部字符串的str指向的是一段已經回收的內存,結果是無法預測的。但存在一個特例,當用一個臨時對象來初始化一個常量引用時,臨時對象的生命周期會持續到與綁定其上的常用引用銷毀時。示例代碼如下:
string a, b;
if(true)
{
const string& c = a + b; // 1
}
語句1將a+b結果的臨時對象綁定到常量引用c,臨時對象生命周期會持續到c的作用域結束,不會在語句1結束時結束。
C++語言的設計中,內聯函數的引入完全是為了性能的考慮,因此在編寫對性能要求較高的C++程序時,極有必要考量內聯函數的使用。
內聯是將被調用函數的函數體代碼直接地整個插入到函數被調用處,而不是通過call語句進行。C++編譯器在真正進行內聯時,由于考慮到被內聯函數的傳入參數、自己的局部變量以及返回值的因素,不只進行簡單的代碼拷貝,還有許多細致工作。
開發人員可以有兩種方法告訴C++編譯器需要內聯哪些類成員函數,一種是在類的定義體外,一種是在類的定義體內。
(1)在類的定義體外時,需要在類成員函數的定義前加inline關鍵字,顯式地告訴C++編譯器本函數在調用時需要內聯處理。
class Student
{
public:
void setName(const QString& name);
QString getName()const;
void setAge(const int age);
getAge()const;
private:
QString m_name;
int m_age;
};
inline void Student::setName(const QString& name)
{
m_name = name;
}
inline QString Student::getName()const
{
return m_name;
}
inline void Student::setAge(const int age)
{
m_age = age;
}
inline Student::getAge()const
{
return m_age;
}
(2)在類的定義體內且聲明成員函數時,同時提供類成員函數的實現體。此時,inline關鍵字不是必須的。
class Student
{
public:
void setName(const QString& name)
{
m_name = name;
}
inline QString getName()const
{
return m_name;
}
inline void setAge(const int age)
{
m_age = age;
}
inline getAge()const
{
return m_age;
}
private:
QString m_name;
int m_age;
};
(3)普通函數(非類成員函數)需要被內聯時,需要在普通函數的定義前加inline關鍵字,顯式地告訴C++編譯器本函數在調用時需要內聯處理。
inline int add(int a, int b)
{
return a + b;
}
C++是以編譯單元為單位編譯的,通常一個編譯單元基本等同于一個CPP文件。在編譯的預處理階段,預處理器會將#include的各個頭文件(支持遞歸頭文件展開)完整地復制到CPP文件的對應位置處,并進行宏展開等操作。預處理器處理后,編譯才真正開始。一旦C++編譯器開始編譯,C++編譯器將不會意識到其它CPP文件的存在,因此并不會參考其它CPP文件的內容信息。因此,在編譯某個編譯單元時,如果本編譯單元會調用到某個內聯函數,那么內聯函數的函數定義(函數體)必須包含在編譯單元內。因為C++編譯器在使用內聯函數體代碼替換內聯函數調用時,必須知道內聯函數的函數體代碼,并且不能通過參考其它編譯單元信息獲得。
如果多個編譯單元會用到同一個內聯函數,C++規范要求在多個編譯單元中同一個內聯函數的定義必須是完全一致的,即ODR(One Definition Rule)原則??紤]到代碼的可維護性,通常將內聯函數的定義放在一個頭文件中,用到內聯函數的所有編譯單元只需要#include相應的頭文件即可。
#include <iostream>
#include <string>
using namespace std;
class Student
{
public:
void setName(const string& name)
{
m_name = name;
}
inline string getName()const
{
return m_name;
}
inline void setAge(const int age)
{
m_age = age;
}
inline int getAge()const
{
return m_age;
}
private:
string m_name;
int m_age;
};
void Print()
{
Student s;
s.setAge(20);
cout << s.getAge() << endl;
}
int main()
{
Print();
return 0;
}
上述代碼中,在不開啟內聯時調用函數Print的函數時相關的操作如下:
(1)進入Print函數時,從其棧幀中開辟了放置s對象的空間。
(2)進入函數體后,首先在開辟的s對象存儲空間執行Student的默認構造函數構造s對象。
(3)將常數20壓棧,調用s的setAge函數(開辟setAge函數的棧幀,返回時回退銷毀此棧幀).
(4)執行s的getAge函數,并將返回值壓棧.
(5)調用cout操作符操作壓棧的結果,即輸出。
開啟內聯后,Print函數的等效代碼如下:
void Print()
{
Student s;
{
s.m_age = 20;
}
int tmp = s.m_age;
cout << tmp << endl;
}
函數調用時的參數壓棧,棧幀開辟與銷毀等操作不再需要,結合內聯后代碼,編譯器會進一步優化為如下結果:
int main()
{
cout << 20 << endl;
return 0;
}
如果不考慮setAge/getAge函數內聯,對于非內聯函數一般不會在頭文件中定義,因此setAge/getAge函數可能在本編譯單元之外的其它編譯單元定義,Print函數所在的編譯單元會看不到setAge/getAge,不知道函數體的具體代碼信息,不能作出進一步的代碼優化。
因此,函數內聯的優點如下:
(1)減少因為函數調用引起的開銷,主要是參數壓棧、棧幀開辟與回收、寄存器保存與恢復。
(2)內聯后編譯器在處理調用內聯函數的函數時,因為可供分析的代碼更多,因此編譯器能做的優化更深入徹底。
程序的唯一入口main函數肯定不會被內聯化,編譯器合成的默認構造函數、拷貝構造函數、析構函數以及賦值運算符一般都會被內聯化。編譯器并不保證使用inline修飾的函數在編譯時真正被內聯處理,inline只是給編譯器的建議,編譯其完全會根據實際情況對其忽視。
int add(int a, int b)
{
return a + b;
}
void func()
{
...
int c = add(a, b);
...
}
函數調用時相關操作如下:
(1)參數壓棧
參數是a,b;壓棧時通常按照逆序壓棧,因此是b,a;如果參數中有對象,需要先進行拷貝構造。
(2)保存返回地址
即函數調用結束后接著執行的語句的地址。
(3)保存維護add函數棧幀信息的寄存器內容,如SP(對棧指針),FP(棧棧指針)等。具體保存的寄存器與硬件平臺有關。
(4)保存某些通用寄存器的內容。由于某些通用寄存器會被所有函數用到,所以在func函數調用add之前,這些通用寄存器可能已經存儲了對func有用的信息。但這些通用寄存器在進入add函數體內執行時可能會被add函數用到,從而被覆寫。因此,func函數會在調用add函數前保存一份這些通用寄存器的內容,在add函數返回后恢復。
(5)調用add函數。首先通過移動棧指針來分配所有在其內部聲明的局部變量所需的空間,然后執行其函數體內的代碼。
(6)add函數執行完畢,函數返回時,func函數需要進行善后處理,如恢復通用寄存器的值,恢復保存func函數棧幀信息的寄存器的值,通過移動棧指針銷毀add函數的棧幀,將保存的返回地址出棧并賦值給IP寄存器,通過移動棧指針回收傳給add函數的參數所占的空間。
如果函數的傳入參數和返回值都為對象時,會涉及對象的構造與析構,函數調用的開銷會更大。
因為函數調用的準備與善后工作最終都由機器指令完成,假設一個函數之前的準備工作與之后的善后工作的指令所需的空間為SS,執行指令所需的時間為TS,從時間和空間分析內聯的效率如下:
(1)空間效率。通常認為,如果不采用內聯,被調用函數代碼只有一份,在調用位置使用call語句即可。而采用內聯后,被調用函數的代碼在所調用的位置都會有一份拷貝,因此會導致代碼膨脹。
如果函數func的函數體代碼為FuncS,假設func函數在整個程序內被調用n次,不采用內聯時,對func函數的調用只有準備工作與善后工作會增加最后的代碼量開銷,func函數相關的代碼大小為n*SS + FuncS
。采用內聯后,在各個函數調用位置都需要將函數體代碼展開,即func函數的相關代碼大小為n*FuncS
。所以需要比較n*SS + FuncS
與n*FuncS
的大小,如果調用次數n較大,可以簡化為比較SS與FuncS的大小。如果內聯函數自己的函數體代碼量比因為函數調用的準備與善后工作引入的代碼量大,則內聯后程序的代碼量會變大;如果內聯函數自己的函數體代碼量比因為函數調用的準備與善后工作引入的代碼量小,則內聯后程序的代碼量會變??;如果內聯后編譯器因為獲得更多的代碼信息,從而對調用函數的優化更深入徹底,則最終的代碼量會更小。
(2)時間效率。通常,內聯后函數調用都不再需要做函數調用的準備與善后工作,并且由于編譯器可以獲得更多的代碼信息,可以進行深入徹底的代碼優化。內聯后,調用函體內需要執行的代碼是相鄰的,其執行的代碼都在同一個頁面或連續的頁面中。如果沒有內聯,執行到被調用函數時,需要調轉到包含被調用函數的內存頁面中執行,而被調用函數的所屬的頁面極有可能當時不在物理內存中。因此,內聯后可以降低缺頁的概率,減少缺頁次數的效果遠比減少一些代碼量執行的效果要好。即使被調用函數所在頁面也在內存中,但與調用函數在空間上相隔甚遠,可能會引起cache miss,從而降低執行速度。因此,內聯后程序的執行時間會比沒有內聯要少,即程序執行速度會更快。但如果FunS遠大于SS,且n較大,最終程序的大小會比沒有內聯大的多,用來存放代碼的內存頁也會更多,導致執行代碼引起的缺頁也會增多,此時,最終程序的執行時間可能會因為大量的缺頁變得更多,即程序變慢。因此,很多編譯器會對函數體代碼很多的函數拒絕其內聯請求,即忽略inine關鍵字,按照非內聯函數進行編譯。
因此,是否采用內聯時需要根據內聯函數的特征(如函數體代碼量、程序被調用次數等)進行判斷。判斷內聯效果的最終和最有效方法還是對程序執行速度和程序大小進行測量,然后根據測量結果決定是否采用內聯和對哪些函數進行內聯。
調用內聯函數的編譯單元必須具有內聯函數的函數體代碼信息,考慮到ODR規則和代碼可維護性,通常將內聯函數的定義放在頭文件中,每個調用內聯函數的編譯單元通過#include相應頭文件。
在大型軟件中,某個內聯函數因為比較通用,可能會被大多數編譯單元用到,如果對內聯函數進行修改會引起所有用到該內聯函數的編譯單元進行重新編譯。對于大型程序,重新編譯大部分編譯單元會消耗大量的編譯時間,因此,內聯函數最好在開發的后期引入,以避免可能不必要的大量編譯時間浪費。
如果某開發組使用了第三方提供的程序庫,而第三方程序庫中可能包含內聯函數,因此在開發組代碼中使用了第三方庫的內聯函數位置都會將內聯函數體代碼拷貝到函數調用位置。如果第三方庫提供商在下一個版本中修改了某些內聯函數的定義,即使沒有修改任何函數的對外接口,開發組想要使用新版本的第三方庫仍然需要重新編譯。如果程序已經發布,則重新編譯的成本會極高。如果沒有內聯,第三方庫提供商只是修改了函數實現,開發組不必重新編譯即可使用最新的第三方庫版本。
內聯的本質是使用函數體代碼對函數調用進行替換,對于遞歸函數:
int sum(int n)
{
if(n < 2)
{
return 1;
}
else
{
return sum(n - 1) + n;
}
}
如果某個編譯單元內調用了sum函數,如下:
void func()
{
...
int ret = sum(n);
...
}
如果在編譯本編譯單元且調用sum函數時,提供的參數n不能夠知道實際值,則編譯器無法知道對sum函數進行了多少次替換,編譯器會拒絕對遞歸函數sum進行內聯;如果在編譯本編譯單元且調用sum函數時,提供的參數n可以知道實際值,則編譯器可能會根據n的大小來判斷時都對sum函數進行內聯,如果n很大,內聯展開可能會使最終程序的大小變得很大。
內聯函數是編譯階段的行為,虛函數是執行階段行為,因此編譯器一般會拒絕對虛函數進行內聯的請求。虛函數不能被內聯是由于編譯器在編譯時無法知道調用的虛函數到底是哪一個版本,即無法確定虛函數的函數體,但在兩種情況下,編譯器能夠知道虛函數調用的真實版本,因此可以內聯。
一是通過對象而不是指向對象的指針或引用對虛函數進行調用,此時編譯器在編譯器已經知道對象的確切類型,因此會直接調用確切類型的虛函數的實現版本,而不會產生動態綁定行為的代碼。
二是雖然通過對象指針或對象引用調用虛函數,但編譯器在編譯時能夠知道指針或引用指向對象的確切類型,如在產生新對象時做的指針賦值或引用初始化與通過指針或引用調用虛函數處于同一編譯單元,并且指針沒有被改變賦值使其指向到其它不能知道確切類型的對象,此時編譯器也不會產生動態綁定的代碼,而是直接調用確切類型的虛函數實現版本。
inline virtual int x::y(char* a)
{
...
}
void func(char* b)
{
x_base* px = new x();
x ox;
px->y(b);
ox.y(b);
}
C語言宏與C++內聯的區別如下:
(1)C++內聯是編譯階段行為,宏是預處理行為,宏的替代展開由預處理器負責,宏對于編譯器是不可見的。
(2)預處理器不能對宏的參數進行類型檢查,編譯器會對內聯函數的參數進行類型檢查。
(3)宏的參數在宏體內出現兩次以上時通常會產生副作用,尤其是當在宏體內對參數進行自增、自減操作時,內聯不會。
(4)宏肯定會被展開,inline修飾的函數不一定會被內聯展開。
本文題目:C++應用程序性能優化(三)——C++語言特性性能分析
瀏覽路徑:http://vcdvsql.cn/article14/jhisge.html
成都網站建設公司_創新互聯,為您提供手機網站建設、商城網站、網站建設、靜態網站、網站收錄、品牌網站建設
聲明:本網站發布的內容(圖片、視頻和文字)以用戶投稿、用戶轉載內容為主,如果涉及侵權請盡快告知,我們將會在第一時間刪除。文章觀點不代表本網站立場,如需處理請聯系客服。電話:028-86922220;郵箱:631063699@qq.com。內容未經允許不得轉載,或轉載時需注明來源: 創新互聯