C++應用程序性能優化(三)——C++語言特性性能分析

C++應用程序性能優化(三)——C++語言特性性能分析

1、C++語言特性性能分析簡介

一般大多數開發人員認爲,彙編語言和C語言比較適合編寫對性能要求很是高的程序,C++語言主要適用於編寫複雜度很是高但性能要求並非很高的程序。由於大多數開發人員認爲,C++語言設計時由於考慮到支持多種編程模式(如面向對象編程和範型編程)以及異常處理等,從而引入了太多新的語言特性。新的語言特性每每使得C++編譯器在編譯程序時插入了不少額外的代碼,會致使最終生成的二進制代碼體積膨脹,並且執行速度降低。
但事實並不是如此,一般一個程序的速度在框架設計完成時大體已經肯定,而並不是由於採用C++語言才致使速度沒有達到預期目標。所以,當一個程序的性能須要提升時,首先須要作的是用性能檢測工具對其運行的時間分佈進行一個準確的測量,找出關鍵路徑和真正的性能瓶頸所在,而後針對性能瓶頸進行分析和優化,而不是主觀地將性能問題歸咎於程序所採用的語言。工程實踐代表,若是框架設計不作修改,即便使用C語言或彙編語言從新改寫,也並不能保證提升整體性能。
所以,遇到性能問題時,首先應檢查和反思程序的整體架構,而後使用性能檢測工具對其實際運行作準確的測量,再針對性能瓶頸進行分析和優化。
但C++語言中確實有一些操做、特性比其它因素更容易成爲程序的性能瓶頸,常見因素以下:
(1)缺頁
缺頁一般意味着要訪問外部存儲,由於外部存儲訪問相對於訪問內存或代碼執行,有數量級的差異。所以,只要有可能,應該儘可能想辦法減小缺頁。
(2)從堆中動態申請和釋放內存
C語言中的malloc/free和C++語言中的new/delete操做時很是耗時的,所以要儘量優先考慮從線程棧中獲取內存。優先考慮棧而減小從動態堆中申請內存,不只由於在堆中分配內存比在棧中要慢不少,並且還與儘可能減小缺頁有關。當程序執行時,當前棧幀空間所在的內存頁確定在物理內存中,所以程序代碼對其中變量的存取不會引發缺頁;若是從堆空間生成對象,只有指向對象的指針在棧上,對象自己則存儲在堆空間中。堆通常不可能都在物理內存中,並且因爲堆分配內存的特性,即便兩個相鄰生成的對象,也頗有可能在堆內存位置上相距很遠。所以,當訪問兩個對象時,雖然分別指向兩個對象的指針都在棧上,但經過兩個指針引用對象時頗有可能會引發兩次缺頁。
(3)複雜對象的建立和銷燬
複雜對象的建立和銷燬會比較耗時,所以對於層次較深的遞歸調用須要重點關注遞歸內部的對象建立。其次,編譯器生成的臨時對象由於在程序的源碼中看不到,更不容易察覺,所以須要重點關注。
(4)函數調用
因爲函數調用有固定的額外開銷,所以當函數體的代碼量相對較少,而且函數被很是頻繁調用時,函數調用時的固定開銷容易成爲沒必要要的開銷。C語言的宏和C++語言的內聯函數都是爲了在保持函數調用的模塊化特徵基礎上消除函數調用的固定額外開銷而引入的。因爲C語言的宏在×××能優點的同時也給開發和調試帶來不便,所以C++語言中推薦使用內聯函數。ios

2、構造函數與析構函數

一、構造函數與析構函數簡介

構造函數和析構函數的特色是當建立對象時自動執行構造函數;當銷燬對象時,析構函數自動被執行。構造函數是一個對象最早被執行的函數,在建立對象時調用,用於初始化對象的初始狀態和取得對象被使用前須要的一些資源,如文件、網絡鏈接等;析構函數是一個對象最後被執行的函數,用於釋放對象擁有的資源。在對象的生命週期內,構造函數和析構函數都只會執行一次。
建立一個對象有兩種方式,一種是從線程運行棧中建立,稱爲局部對象。銷燬局部對象並不須要程序顯示地調用析構函數,而是當程序運行出對象所屬的做用域時自動調用對象的析構函數。
建立對象的另外一種方式是從全局堆中動態分配,一般使用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

值傳遞驗證示例以下:ide

#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%。

3、繼承與虛函數

一、虛函數與動態綁定機制

虛函數是C++語言引入的一個重要特性,提供了動態綁定機制,動態綁定機制使得類繼承的語義變得相對明晰。
(1)基類抽象了通用的數據及操做。對於數據而言,若是數據成員在各個派生類中都須要用到,須要將其聲明在基類中;對於操做而語言,若是操做對於各個派生類都有意義,不管其語義是否會被修改和擴展,須要將其聲明在基類中。
(2)某些操做,對於各個派生類而言,語義徹底保持一致,而無需修改和擴展,則相應操做聲明爲基類的非虛成員函數。各個派生類在聲明爲基類的派生類時,默認繼承非虛成員函數的聲明和實現,若是默認繼承基類的數據成員同樣,而沒必要另外作任何聲明,構成代碼複用。
(3)對於某些操做,雖然對於各個派生類都有意義,但其語義並不相同,則相應的操做應該聲明爲虛成員函數。各個派生類雖然也繼承了虛成員函數的聲明和實現,但語義上應該對虛成員函數的實現進行修改或擴展。若是在實現修改、擴展虛成員函數的過程當中,須要用到額外的派生類獨有的數據時,則將相應的數據聲明爲派生類本身的數據成員。
當更高層次的程序框架(繼承體系的使用者)使用此繼承體系時,處理的是抽象層次的對象集合,對象集合的成員本質是各類派生類對象,但在處理對象集合的對象時,使用的是抽象層次的操做。高層程序框架並不區分相應操做中哪些操做對於派生類是不變的,哪些操做對於派生類是不一樣的,當實際執行到各操做時,運行時系統可以識別哪些操做須要用到動態綁定。從而找到對應派生類的修改或擴展的操做版本。即對繼承體系的使用者而言,繼承體系內部的多樣性是透明的,沒必要關心其繼承細節,處理的是一組對使用者而言總體行爲一致的對象。即便繼承體系內部增長、刪除了某個派生類,或某個派生類的虛函數實現發生了改變,使用者的代碼也沒必要作任何修改,使程序的模塊化程度獲得極大提升,其擴展性、維護性和代碼可讀性也會提升。對於對象繼承體系使用者而言,只看到抽象類型,而沒必要關心具體是哪一種具體類型。

二、虛函數的效率分析

虛函數的動態綁定特性雖然很好,但存在內存空間和時間開銷,每一個支持虛函數的類(基類或派生類)都會有一個包含其全部支持的虛函數的虛函數表的指針。每一個類對象都會隱含一個虛函數表指針(virtual pointer),指向其所屬類的虛函數表。當經過基類的指針或引用調用某個虛函數時,系統須要首先定位指針或引用真正對應的對象所隱含的虛函數指針,而後虛函數指針根據虛函數的名稱對其所指向的虛函數表進行一個偏移定位,再調用偏移定位處的函數指針對應的虛函數,即動態綁定的解析過程。C++規範只須要編譯器可以保證動態綁定的語義,但大多數編譯器都採用上述方法實現虛函數。
(1)每一個支持虛函數的類都有一個虛函數表,虛函數表的大小與類擁有的虛函數的多少成正比。一個程序中,每一個類的虛函數表只有一個,與類對象的數量無關。支持虛函數的類的每一個類對象都有一個指向類的虛函數表的虛函數指針,所以程序運行時虛函數指針引發的內存開銷與生成的類對象數量成正比。
(2)支持虛函數的類生成每一個對象時,在構造函數中會調用編譯器在構造函數內部插入的初始化代碼,來初始化其虛函數指針,使其指向正確的虛函數表。當經過指針或引用調用虛函數時,會根據虛函數指針找到相應類的虛函數表。

三、虛函數與內聯

內聯函數一般能夠提升代碼執行速度,不少普通函數會根據狀況進行內聯化,但虛函數沒法利用內聯化的優點。由於內聯是在編譯階段編譯器將調用內聯函數的位置用內聯函數體替代(內聯展開),但虛函數本質上是運行期行爲。在編譯階段,編譯器沒法知道某處的虛函數調用在真正執行的時後須要調用哪一個具體的實現(即編譯階段沒法肯定其具體綁定),所以,編譯階段編譯器不會對經過指針或引用調用的虛函數進行內聯化。若是須要利用虛函數的動態綁定的設計優點,必須放棄內聯帶來的速度優點。
若是不使用虛函數,能夠經過在抽象基類增長一個類型標識成員用於在運行時識別具體的派生類對象,在派生類對象構造時必須指定具體的類型。繼承體系的使用者調用函數時再也不須要一次間接地根據虛函數表查找虛函數指針的操做,但在調用前仍然須要使用switch語句對其類型進行識別。
所以虛函數的缺點能夠認爲只有兩條,即虛函數表的空間開銷以及沒法利用內聯函數的速度優點。因爲每一個含有虛函數的類在整個程序只有一個虛函數表,所以虛函數表引發的空間開銷時很是小的。因此,能夠認爲虛函數引入的性能缺陷只是沒法利用內聯函數。
一般,非虛函數的常規設計假如須要增長一種新的派生類型,或者刪除一種再也不支持的派生類型,都必須修改繼承體系全部使用者的全部與類型相關的函數調用代碼。對於一個複雜的程序,某個繼承體系的使用者會不少,每次對繼承體系的派生類的修改都會波及使用者。所以,不使用虛函數的常規設計增長了代碼的耦合度,模塊化不強,致使項目的可擴展性、可維護性、代碼可讀性都會下降。面向對象編程的一個重要目的就是增長程序的可擴展性和可維護性,即當程序的業務邏輯發生改變時,對原有程序的修改很是方便,下降由於業務邏輯改變而對代碼修改時出錯的機率。
所以,在性能和其它特性的選擇方面,須要開發人員根據實際狀況進行進行權衡和取捨,若是性能檢驗確認性能瓶頸不是虛函數沒有利用內聯的優點引發,能夠沒必要考慮虛函數對性能的影響。

4、臨時對象

一、臨時對象簡介

對象的建立與銷燬對程序的性能影響很大,尤爲是對象的類處於一個複雜繼承體系的末端,或者對象包含不少成員對象(包括其全部父類對象,即直接或者間接父類的全部成員變量對象)時,對程序性能影響尤爲顯著。所以,做爲一個對性能敏感的程序員,應該儘可能避免建立沒必要要的對象,以及隨後的銷燬。除了減小顯式地建立對象,也要儘可能避免編譯器隱式地建立對象,即臨時對象。

#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結束時結束。

5、內聯函數

一、C++內聯函數簡介

C++語言的設計中,內聯函數的引入徹底是爲了性能的考慮,所以在編寫對性能要求較高的C++程序時,極有必要考量內聯函數的使用。
內聯是將被調用函數的函數體代碼直接地整個插入到函數被調用處,而不是經過call語句進行。C++編譯器在真正進行內聯時,因爲考慮到被內聯函數的傳入參數、本身的局部變量以及返回值的因素,不僅進行簡單的代碼拷貝,還有許多細緻工做。

二、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++內聯機制

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 + FuncSn*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語言宏的區別

C語言宏與C++內聯的區別以下:(1)C++內聯是編譯階段行爲,宏是預處理行爲,宏的替代展開由預處理器負責,宏對於編譯器是不可見的。(2)預處理器不能對宏的參數進行類型檢查,編譯器會對內聯函數的參數進行類型檢查。(3)宏的參數在宏體內出現兩次以上時一般會產生反作用,尤爲是當在宏體內對參數進行自增、自減操做時,內聯不會。(4)宏確定會被展開,inline修飾的函數不必定會被內聯展開。

相關文章
相關標籤/搜索