C++語言特性(4)---臨時對象

從"構造函數和析構函數"中已經知道,對象的創建與銷燬對程序的性能影響很大。尤其當該對象的類處於一個複雜繼承體系的末端,或者該對象包含很多成員變量對象(包括其所有父類對象,即直接或者間接父類的所有成員變量對象)時,對程序性能影響尤其顯著。因此作爲一個對性能敏感的開發人員,應該儘量避免創建不必要的對象,以及隨後的銷燬。這裏"避免創建不必要的對象",不僅僅意味着在編程時,主要減少顯式出現在源碼中的對象創建。還有在編譯過程中,編譯器在某些特殊情況下生成的開發人員看不見的隱式的對象。這些對象的創建並不出現在源碼級別,而是由編譯器在編譯過程中"悄悄"創建(往往爲了某些特殊操作),並在適當時銷燬,這些就是所謂的"臨時對象"。需要注意的是,臨時對象與通常意義上的臨時變量是完全不同的兩個概念,比如下面的代碼:

 
void  swap(int  *px,  int  *py) 
{
    
int temp;                  ①

    temp 
= *px;
    
*px = *py;
    
*py = temp;
}

習慣稱①句中的temp爲臨時變量,其目的是爲了暫時存放指針px指向的int型值。但是它並不是這裏要考察的"臨時對象",不僅僅是因爲一般開發人員不習慣稱一個內建類型的變量爲"對象"(所以不算臨時"對象")。而且因爲temp出現在了源碼中,這裏考察的臨時對象並不會出現在源碼中。

到底什麼纔是臨時對象?它們在什麼時候產生?其生命週期有什麼特徵?在回答這些問題之前,首先來看下面這段代碼:

#include <iostream>
#include 
<cstring>

using namespace std;

class Matrix
{
public:
        Matrix(
double d = 1.0
        { 
            cout 
<< "Matrix::Matrix()" << endl; 

            
for(int i = 0; i < 10; i++)
                
for(int j = 0; j < 10; j++)
                    m[i][j] 
= d;
        }

        Matrix(
const Matrix& mt)
        {
            cout 
<< "Matrix::Matrix(const Matrix&)" << endl;
            memcpy(
this&mt, sizeof(Matrix));
        }
        
        Matrix
& operator=(const Matrix& mt)
        {
            
if(this == &mt)
                
return *this;

            cout 
<< "Matrix::operator=(const Matrix&)" << endl;
            memcpy(
this&mt, sizeof(Matrix));
        
            
return *this;
        }

        friend 
const Matrix operator+(const Matrix&const Matrix&);
        
//...

private:
        
double m[10][10];
};

const Matrix operator+(const Matrix& arg1, const Matrix& arg2)
{
        Matrix sum;                                    ①
        
for(int i = 0; i < 10; i++)
            
for(int j = 0; j < 10; j++)
                sum.m[i][j] 
= arg1.m[i][j] + arg2.m[i][j];

        
return sum;                                    ②
}

int main()
{
        Matrix a(
2.0),  b(3.0),  c;                    ③
        c 
= a + b;                                     ④

        
return 0;
}

分析代碼,③處生成3個Matrix對象a,b,c,調用3次Matrix構造函數。④處調用operator+(const Matrix&, const Matrix&)執行到①處時生成臨時變量(注意此處的sum並不是"臨時對象"),調用一次Matrix構造函數。④處c = a + b最後將a + b的結果賦值給c,調用的是賦值操作,而不會生成新的Matrix對象,因此從源碼分析,此段代碼共生成4個Matrix對象。

但是輸出結果:

Matrix::Matrix()                               ①
Matrix::Matrix()                               ②
Matrix::Matrix()                               ③
Matrix::Matrix()                               ④
Matrix::Matrix(
const Matrix&)              ⑤
Matrix::
operator=(const Matrix&)           ⑥

①、②、③3處輸出分別對應對象a、b和c的構造,④處輸出對應的是operator+(const Matrix&, const Matrix&)中sum的構造,⑥處輸出對應的是c = a + b句中最後用a + b的結果向c賦值,那麼⑤處輸出對應哪個對象?

答案是在這段代碼中,編譯器生成了一個"臨時對象"。

a + b實際上是執行operator+(const Matrix& arg1, const Matrix& arg2),重載的操作符本質上是一個函數,這裏a和b就是此函數的兩個變量。此函數返回一個Matrix變量,然後進一步將此變量通過 Matrix::operator=(const Matrix& mt)對c進行賦值。因爲a + b返回時,其中的sum已經結束了其生命週期。即在operator+(const Matrix& arg1, const Matrix& arg2)結束時被銷燬,那麼其返回的Matrix對象需要在調用a + b函數(這裏是main()函數)的棧中開闢空間用來存放此返回值。這個臨時的Matrix對象是在a + b返回時通過Matrix拷貝構造函數構造,即⑤處的輸出。

既然如上所述,創建和銷燬對象經常會成爲一個程序的性能瓶頸所在,那麼有必要對臨時對象產生的原因進行深入探究,並在不損害程序功能的前提下儘可能地規避它。

臨時對象在C++語言中的特徵是未出現在源代碼中,從堆棧中產生的未命名對象。這裏需要特別注意的是,臨時對象並不出現在源代碼中。即開發人員並沒有聲明要使用它們,沒有爲其聲明變量。它們由編譯器根據情況產生,而且開發人員往往都不會意識到它們的產生。

產生臨時對象一般來說有如下兩種場合。

(1)當實際調用函數時傳入的參數與函數定義中聲明的變量類型不匹配。

(2)當函數返回一個對象時(這種情形下也有例外,下面會講到)。

另外,也有很多開發人員認爲當函數傳入參數爲對象,並且實際調用時因爲函數體內的該對象實際上並不是傳入的對象,而是該傳入對象的一份拷貝,所以認爲這時函數體內的那個拷貝的對象也應該是一個臨時對象。但是嚴格說來,這個拷貝對象並不符合"未出現在源代碼中"這一特徵。當然只要能知道並意識到對象參數的工作原理及背後隱含的性能特徵,並能在編寫代碼時儘量規避之,那麼也就沒有必要在字面上較真了,畢竟最終目的是寫出正確和高效的程序。

因爲類型不匹配而生成臨時對象的情況,可以通過下面這段程序來認識:

class Rational
{
public:
        Rational (
int a = 0int b = 1 ) : m(a), n(b) {}  ①
  
private:
        
int m;
        
int n; 
};

...
void foo()
{
    Rational r;
    r 
= 100;                                              ②
    ...
}

當執行②處代碼時,因爲Rational類並沒有重載operator=(int i),所以此處編譯器會合成一個operator=(const Rational& r)。並且執行逐位拷貝(bitwise copy)形式的賦值操作,但是右邊的一個整型常量100並不是一個Rational對象,初看此處無法通過編譯。但是,需要注意的一點是C++編譯器在判定這種語句不能成功編譯前,總是儘可能地查找合適的轉換路徑,以滿足編譯的需要。這裏,編譯器發現Rational類有一個如①處所示的 Rational(int a=0, int b=1)型的構造函數。因爲此構造函數可以接受0、1或2個整數作爲參數,這時編譯器會"貼心"地首先將②式右邊的100通過調用 Rational::Rational(100, 1)生成一個臨時對象,然後用編譯器合成的逐位拷貝形式的賦值符對r對象進行賦值。②處語句執行後,r對象內部的m爲100,n爲1。

從上面例子中,可以看到C++編譯器爲了成功編譯某些語句,往往會在私底下"悄悄"地生成很多從源代碼中不易察覺的輔助函數,甚至對象。比如上段代碼中,編譯器生成的賦值操作符、類型轉換,以及類型轉換的中間結果,即一個臨時對象。

很多時候,這種編譯器提供的自動類型轉換確實提高了程序的可讀性,也在一定程度上簡化了程序的編寫,從而提高了開發速度。但是類型轉換意味着臨時對象的產生,對象的創建和銷燬意味着性能的下降,類型轉換還意味着編譯器還需要生成額外的代碼等。因此在設計階段,預計到不需要編譯器提供這種自動類型轉換的便利時,可以明確阻止這種自動類型轉換的發生,即阻止因此而引起臨時對象的產生。這種明確阻止就是通過對類的構造函數增加"explicit"聲明,如上例中的代碼,可以通過如下聲明來阻止:

class Rational
{
public:
        
explicit Rational (int a = 0int b = 1 ) : m(a), n(b) {}  ①
  
private:
        
int m;
        
int n; 
};

...
void foo()
{
    Rational r;
    r 
= 100;                                                        ②
    ...
}

此段代碼編譯時在②處報一個錯誤,即"binary '=' : no operator defined which takes a right-hand operand of type 'const int' (or there is no acceptable conversion)",這個錯誤說明編譯器無法將100轉換爲一個Rational對象。編譯器合成的賦值運算符只接受Rational對象,而不能接受整型。編譯器要想能成功編譯②處語句,要麼提供一個重載的"="運算符,該運算符接受整型作爲參數;要麼能夠將整型轉換爲一個Rational對象,然後進一步利用編譯器合成的賦值運算符。要想將整型轉換爲一個Rational對象,一個辦法就是提供能只傳遞一個整型作爲參數的Rational構造函數(不一定非要求該構造函數只有一個整型參數,因爲考慮到默認值的原因。如上面的例子,Rational的構造函數接受兩個整型參數。但是因爲都有默認值,因此調用該構造函數可以有3種方式,即無參、一個參數和兩個參數),這樣編譯器就可以用該整型數作爲參數調用該構造函數生成一個Rational對象(臨時對象)。

但是上面沒有重載以整型爲參數的"="操作符,雖然提供了一個能只傳入一個整型作爲參數的構造函數,但是用"explicit"限制了此構造函數。因爲explicit的含義是開發人員只能顯式地根據這個構造函數的定義調用,而不允許編譯器利用其來進行隱式的類型轉換。這樣編譯器無辦法利用它來將100轉換爲一個臨時的Rational對象,②處語句也無法編譯。

上面提到,可以通過重載以整型爲參數的"="操作符使②處成功編譯的目的,看這種方法:

class Rational
{
public:
    
explicit Rational (int a = 0int b = 1 ) : m(a), n(b) {}       ①
      Rational
& operator=(int a) {m=a; n=1return *this; }           ③

private:
    
int m;
    
int n; 
};

...
void foo()
{
    Rational r;
    r 
= 100;                                                        ②
    ...
}

如③處所示,重載了"="操作符。這樣當編譯②處時,編譯器發現右邊是一個整型數,它首先尋找是否有與之匹配的重載的"="操作符。找到③處的聲明,及定義。這樣它利用③處來調用展開②處爲r.Rational::operator=(100),順利通過編譯。

需要指出的是,重載"="操作符後達到了程序想要的效果,即程序的可讀性及代碼編寫的方便性。同時還有一個更重要的效果(對性能敏感的程序而言),即成功避免了一個臨時對象的產生。因爲"="操作符的實現,僅僅是修改了被調用對象的內部成員對象,整個過程中都不需要產生臨時對象。但是重載"="操作符也增加了設計類Rational的成本,如果一個類可能會支持多種其他類型對它的轉換,則需要進行多次重載,這無疑會使得這個類變得十分臃腫。同樣,如果一個大型程序有很多這樣的類,那麼因爲代碼臃腫引起的維護難度也相應會增加。

因此在設計階段,在兼顧程序的可讀性、代碼編寫時的方便性、性能,以及程序大小和可維護性時,需要仔細分析和斟酌。尤其要對每個類在該應用程序實際運行時的調用次數及是否在性能關鍵路徑上等情況進行預估和試驗,然後做到合理的折衷和權衡。

如前所述,還有一種情形往往導致臨時對象的產生,即當一個函數返回的是某個非內建類型的對象時。這時因爲返回結果(一個對象)必須要有一個地方存放。所以編譯器會從調用該函數的函數棧楨中開闢空間,並用返回值作爲參數調用該對象所屬類型的拷貝構造函數在此空間中生成該對象。在被調用函數結束並返回後,可以繼續利用此對象(返回值),如:

#include <iostream>

using namespace std;

class Rational
{

        friend 
const Rational operator+(const Rational& a, const Rational& b);

public:
        Rational (
int a = 0int b = 1 ) : m(a), n(b) 
        {
            cout 
<< "Rational::Rational(int,int)" << endl;
        }
        Rational (
const Rational& r) : m(r.m), n(r.n) 
        {
            cout 
<< "Rational::Rational(const Rational& r)" << endl;
        }
        Rational
& operator=(const Rational& r) 
        {
            
if(this == &r)
                
return(*this);

            m
=r.m; 
            n
=r.n; 

            cout 
<< "Rational::operator=(const Rational& r)" << endl;

            
return *this
        }

private:
        
int m;
        
int n; 
};

const Rational operator+(const Rational& a, const Rational& b)
{
        cout 
<< "operator+() begin" << endl;
        Rational temp;
        temp.m 
= a.m + b.m;
        temp.n 
= a.n + b.n;
        cout 
<< "operator+() end" << endl;
        
return temp;                               ②
}

int main()
{
        Rational r, a(
10,10), b(5,8);
        r 
= a + b;                                 ①

        
return 0;
}
執行①的處語句時,相當於在main函數中調用operator+(const Rational& a, const Rational& b)函數。在main函數棧中會開闢一塊Rational對象大小的空間。在operator+(const Rational& a, const Rational& b)函數的②處,函數返回被銷燬的temp對象爲參數調用拷貝構造函數在main函數棧中開闢的空間中生成一個Rational對象,然後在r=a+b 的"="部分執行賦值運算符操作,輸出如下:
Rational::Rational(int,int)
Rational::Rational(
int,int)
Rational::Rational(
int,int)
operator+() begin
Rational::Rational(
int,int)
operator+() end
Rational::Rational(
const Rational& r)
Rational::
operator=(const Rational& r)
但r在之前的默認構造後並沒有用到,此時可以將其生成延遲,如下所示:
#include <iostream>

using namespace std;

class Rational
{

        friend 
const Rational operator+(const Rational& a, const Rational& b);

public:
        Rational (
int a = 0int b = 1 ) : m(a), n(b) 
        {
            cout 
<< "Rational::Rational(int,int)" << endl;
        }
        Rational (
const Rational& r) : m(r.m), n(r.n) 
        {
            cout 
<< "Rational::Rational(const Rational& r)" << endl;
        }
        Rational
& operator=(const Rational& r) 
        {
            
if(this == &r)
                
return(*this);

            m
=r.m; 
            n
=r.n; 

            cout 
<< "Rational::operator=(const Rational& r)" << endl;

            
return *this
        }

private:
        
int m;
        
int n; 
};

const Rational operator+(const Rational& a, const Rational& b)
{
        cout 
<< "operator+() begin" << endl;
        Rational temp;
        temp.m 
= a.m + b.m;
        temp.n 
= a.n + b.n;
        cout 
<< "operator+() end" << endl;
        
return temp;                         ②
}

int main()
{
        Rational a(
10,10), b(5,8);
        Rational r 
= a + b;                  ①

        
return 0;
}
這時輸出爲:
Rational::Rational(int,int)
Rational::Rational(
int,int)
operator+() begin
Rational::Rational(
int,int)
operator+() end
Rational::Rational(
const Rational& r)

已經發現,經過簡單改寫,這段程序竟然減少了一次構造函數和一次賦值操作。爲什麼?原來改寫後,在執行①處時的行爲發生了很大的變化。編譯器對"="的解釋不再是賦值運算符,而是對象r的初始化。在取得a+b的結果值時,也不再需要在main函數棧楨中另外開闢空間。而是直接使用爲r對象預留的空間,即編譯器在執行②處時直接使用temp作爲參數調用了Rational的拷貝構造函數對r對象進行初始化。這樣,也消除了臨時對象的生成,以及原本發生在①處的賦值運算。

通過這個簡單的優化,已經消除了一個臨時對象的生成,也減少了一次函數調用(賦值操作符本質上也是一個函數)。這裏已經得到一個啓示,即對非內建類型的對象,儘量將對象延遲到已經確切知道其有效狀態時。這樣可以減少臨時對象的生成,如上面所示,應寫爲:

Rational r = a + b。
而不是:
Rational r;

= a + b;

當然這裏有一個前提,即在r = a + b調用之前未用到r,因此不必生成。再進一步,已經看到在operator+(const Rational& a, const Rational& b)實現中用到了一個局部對象temp,改寫如下:

#include <iostream>

using namespace std;

class Rational
{

        friend 
const Rational operator+(const Rational& a, const Rational& b);

public:
        Rational (
int a = 0int b = 1 ) : m(a), n(b) 
        {
            cout 
<< "Rational::Rational(int,int)" << endl;
        }
        Rational (
const Rational& r) : m(r.m), n(r.n) 
        {
            cout 
<< "Rational::Rational(const Rational& r)" << endl;
        }
        Rational
& operator=(const Rational& r) 
        {
            
if(this == &r)
                
return(*this);

            m
=r.m; 
            n
=r.n; 

            cout 
<< "Rational::operator=(const Rational& r)" << endl;

            
return *this
        }

private:
        
int m;
        
int n; 
};

const Rational operator+(const Rational& a, const Rational& b)
{
        cout 
<< "operator+() begin" << endl;
        
return Rational(a.m + b.m, a.n + b.n);  ②
}

int main()
{
        Rational a(
10,10), b(5,8);
        Rational r 
= a + b;                        ①

        
return 0;
}
這時輸出如下:
Rational::Rational(int,int)
Rational::Rational(
int,int)
operator+() begin
Rational::Rational(
int,int)

如上,確實消除了temp。這時編譯器在進入operator+(const Rational& a, const Rational& b)時看到①處是一個初始化,而不是賦值。所以編譯器傳入參數時,也傳入了在main函數棧楨中爲對象r預留的空間地址。當執行到②處時,實際上這個構造函數就是在r對象所處的空間內進行的,即構造了r對象,這樣省去了用來臨時計算和存放結果的temp對象。

需要注意的是,這個做法需要與前一個優化配合纔有效。即a+b的結果用來初始化一個對象,而不是對一個已經存在的對象進行賦值操作,如果①處是:

= a + b;

那麼operator+(const Rational& a, const Rational& b)的實現中雖然沒有用到temp對象,但是仍然會在調用函數(這裏是main函數)的棧楨中生成一個臨時對象用來存放計算結果,然後利用這個臨時對象對 r對象進行賦值操作。

對於operator+(const Rational& a, const Rational& b)函數,常常看到有如下調用習慣:

    Rational a, b;

    a 
= a + b;
    
這種寫法也經常會用下面這種寫法代替:
Rational a, b;        

+= b;    

這兩種寫法除了個人習慣之外,在性能方面有無區別?回答是有區別。而且有時還會很大,視對象大小而定。因此設計某類時,如果需要重載 operator+,最好也重載operator+=,並且考慮到維護性,operator+用operator+=來實現。這樣如果這個操作符的語義有所改變需要修改時,只需要修改一處即可。

對Rational類來說,一般operator+=的實現如下:

Rational& operator+=(const Rational& rhs)
{
    m 
+= rhs.m;
    n 
+= rhs.n;
    
return (*this);
}
這裏可以看到,與operator+不同,operator+=並沒有產生臨時變量,operator+則只有在返回值被用來初始化一個對象,而不是對一個已經生成的對象進行賦值時纔不產生臨時對象。而且往往返回值被用來賦值的情況並不少見,甚至比初始化的情況還要多。因此使用operator+=不產生臨時對象,性能會比operator+要好,爲此儘量使用語句:
+= b;
而避免使用:
= a + b;
相應地,也應考慮到程序的代碼可維護性(易於修改,因爲不小心的修改會導致不一致等)。即儘量利用operator+=來實現operator+,如下:
const Rational operator+(const Rational& a, const Rational& b)
{
        
return Rational(a) += b;
}

同理,這個規律可以擴展到-=、*=和/=等。

操作符中還有兩個比較特殊的,即++和--。它們都可以前置或者後置,比如i++和++i。二者的語義是有區別的,前者先將其值返回,然後其值增1;後者則是先將值增1,再返回其值。但當不需要用到其值,即單獨使用時,比如:


二者的語義則是一樣的,都是將原值增1。但是對於一個非內建類型,在重載這兩個操作符後,單獨使用在性能方面是否有差別?來考察它們的實現。仍以Rational類作爲例子,假設++的語義爲對分子(即m)增1,分母不變(暫且不考慮這種語義是否符合實際情況),那麼兩個實現如下:

    
    
const Rational& operator++()    //prefix
    {
        
++m;
        
return (*this);
    }

    
const Rational operator++(int)  //postfix
    {
        Rational tmp(
*this);          ①
        
++(*this);
        
return tmp;
    }
    

可以看到,因爲考慮到後置++的語義,所以在實現中必須首先保留其原來的值。爲此需要一個局部變量,如①處所示。然後值增1後,將保存其原值的局部變量作爲返回值返回。相比較而言,前置++的實現不會需要這樣一個局部變量。而且不僅如此,前置的++只需要將自身返回即可,因此只需返回一個引用;後置++需要返回一個對象。已經知道,函數返回值爲一個對象時,往往意味着需要生成一個臨時對象用來存放返回值。因此如果調用後置++,意味着需要多生成兩個對象,分別是函數內部的局部變量和存放返回值的臨時變量。

有鑑於此,對於非內建類型,在保證程序語義正確的前提下應該多用:

而避免使用:

同樣的規律也適用於前置--和後置--(與=/+=相同的理由,考慮到維護性,儘量用前置++來實現後置++)。

至此,已經考察了臨時對象的含義、產生臨時對象的各種場合,以及一些避免臨時對象產生的方法。最後來查看臨時對象的生命週期。在C++規範中定義一個臨時對象的生命週期爲從創建時開始,到包含創建它的最長語句執行完畢,比如:

string a, b;
const char* str;

if( strlen( str = (a + b).c_str() ) > 5)  ①
{
        printf(
"%s ", str);                 ②
        …
}

在①處,首先創建一個臨時對象存放a+b的值。然後從這個臨時string對象中通過c_str()函數得到其字符串內容,賦給str。如果str的長度大於5,就會進入if內部,執行②處語句。問題是,這時的str還合法否?

答案是否定的,因爲存放a+b值的臨時對象的生命在包含其創建的最長語句結束後也相應結束了,這裏是①處語句。當執行到②處時,該臨時對象已經不存在,指向它內部字符串內容的str指向的是一段已經被回收的內存。這時的結果是無法預測的,但肯定不是所期望的。

但這條規範也有一個特例,當用一個臨時對象來初始化一個常量引用時,該臨時對象的生命會持續到與綁定到其上的常量引用銷燬時,如:

string a, b;

if( …)    
{
        
const string& c = a + b         ①
        cout 
<< c << endl;           ②
        …    
}

這時c這個常量string引用在①處綁定在存放a+b結果的臨時對象後,可以繼續在其使用域(scope)內正常使用,如在②處語句中那樣。這是因爲c是一個常量引用,因爲被它綁定。所以存放a+b的臨時對象並不會在①處語句執行後銷燬,而是保持與c一樣的生命週期。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章