為什麼需要拷貝建構函式
阿新 • • 發佈:2018-12-31
也許很多C++的初學者都知道什麼是建構函式,但是對複製建構函式(copy constructor)卻還很陌生。對於我來說,在寫程式碼的時候能用得上覆制建構函式的機會並不多,不過這並不說明覆制建構函式沒什麼用,其實複製建構函式能解決一些我們常常會忽略的問題。
為了說明覆制建構函式作用,我先說說我們在程式設計時會遇到的一些問題。對於C++中的函式,我們應該很熟悉了,因為平常經常使用;對於類的物件,我們也很熟悉,因為我們也經常寫各種各樣的類,使用各種各樣的物件;對於指標的操作,我們也不陌生吧?嗯,如果你還不瞭解上面三個概念的話,我想這篇文章不太適合你,不過看看也無礙^_^。我們經常使用函式,傳遞過各種各樣的引數給函式,不過把物件(注意是物件,而不是物件的指標或物件的引用)當作引數傳給函式的情況我們應該比較少遇見吧,而且這個物件的建構函式還涉及到一些記憶體分配的操作。嗯,這樣會有什麼問題呢?
把引數傳遞給函式有三種方法,一種是值傳遞,一種是傳地址,還有一種是傳引用。前者與後兩者不同的地方在於:當使用值傳遞的時候,會在函式裡面生成傳遞引數的一個副本,這個副本的內容是按位從原始引數那裡複製過來的,兩者的內容是相同的。當原始引數是一個類的物件時,它也會產生一個物件的副本,不過在這裡要注意。一般物件產生時都會觸發建構函式的執行,但是在產生物件的副本時卻不會這樣,這時執行的是物件的複製建構函式。為什麼會這樣?嗯,一般的建構函式都是會完成一些成員屬性初始化的工作,在物件傳遞給某一函式之前,物件的一些屬性可能已經被改變了,如果在產生物件副本的時候再執行物件的建構函式,那麼這個物件的屬性又再恢復到原始狀態,這並不是我們想要的。所以在產生物件副本的時候,建構函式不會被執行,被執行的是一個預設的建構函式。當函式執行完畢要返回的時候,物件副本會執行解構函式,如果你的解構函式是空的話,就不會發生什麼問題,但一般的解構函式都是要完成一些清理工作,如釋放指標所指向的記憶體空間。這時候問題就可能要出現了。假如你在建構函式裡面為一個指標變數分配了記憶體,在解構函式裡面釋放分配給這個指標所指向的記憶體空間,那麼在把物件傳遞給函式至函式結束返回這一過程會發生什麼事情呢?首先有一個物件的副本產生了,這個副本也有一個指標,它和原始物件的指標是指向同塊記憶體空間的。函式返回時,物件的解構函式被執行了,即釋放了物件副本里面指標所指向的記憶體空間,但是這個記憶體空間對原始物件還是有用的啊,就程式本身而言,這是一個嚴重的錯誤。然而錯誤還沒結束,當原始物件也被銷燬的時候,解構函式再次執行,對同一塊系統動態分配的記憶體空間釋放兩次是一個未知的操作,將會產生嚴重的錯誤。
上面說的就是我們會遇到的問題。解決問題的方法是什麼呢?首先我們想到的是不要以傳值的方式來傳遞引數,我們可以用傳地址或傳引用。沒錯,這樣的確可以避免上面的情況,而且在允許的情況下,傳地址或傳引用是最好的方法,但這並不適合所有的情況,有時我們不希望在函式裡面的一些操作會影響到函式外部的變數 。那要怎麼辦呢?可以利用複製建構函式來解決這一問題。複製建構函式就是在產生物件副本的時候執行的,我們可以定義自己的複製建構函式。在複製建構函式裡面我們申請一個新的記憶體空間來儲存建構函式裡面的那個指標所指向的內容。這樣在執行物件副本的解構函式時,釋放的就是複製建構函式裡面所申請的那個記憶體空間。
除了將物件傳遞給函式時會存在以上問題,還有一種情況也會存在以上問題,就是當函式返回物件時,會產生一個臨時物件,這個臨時物件和物件的副本性質差不多。
拷貝建構函式,經常被稱作X(X&),是一種特殊的建構函式,他由編譯器呼叫來完成一些基於同一類的其他物件的構件及初始化。它的唯一的一個引數(物件的引用)是不可變的(因為是const型的)。這個函式經常用在函式呼叫期間於使用者定義型別的值傳遞及返回。拷貝建構函式要呼叫基類的拷貝建構函式和成員函式。如果可以的話,它將用常量方式呼叫,另外,也可以用非常量方式呼叫。
在C++中,下面三種物件需要拷貝的情況。因此,拷貝建構函式將會被呼叫。
1). 一個物件以值傳遞的方式傳入函式體
2). 一個物件以值傳遞的方式從函式返回
3). 一個物件需要通過另外一個物件進行初始化
以上的情況需要拷貝建構函式的呼叫。如果在前兩種情況不使用拷貝建構函式的時候,就會導致一個指標指向已經被刪除的記憶體空間。對於第三種情況來說,初始化和賦值的不同含義是建構函式呼叫的原因。事實上,拷貝建構函式是由普通建構函式和賦值操作賦共同實現的。描述拷貝建構函式和賦值運算子的異同的參考資料有很多。
拷貝建構函式不可以改變它所引用的物件,其原因如下:當一個物件以傳遞值的方式傳一個函式的時候,拷貝建構函式自動的被呼叫來生成函式中的物件。如果一個物件是被傳入自己的拷貝建構函式,它的拷貝建構函式將會被呼叫來拷貝這個物件這樣複製才可以傳入它自己的拷貝建構函式,這會導致無限迴圈。
除了當物件傳入函式的時候被隱式呼叫以外,拷貝建構函式在物件被函式返回的時候也同樣的被呼叫。換句話說,你從函式返回得到的只是物件的一份拷貝。但是同樣的,拷貝建構函式被正確的呼叫了,你不必擔心。
如果在類中沒有顯式的宣告一個拷貝建構函式,那麼,編譯器會私下裡為你制定一個函式來進行物件之間的位拷貝(bitwise copy) 。這個隱含的拷貝建構函式簡單的關聯了所有的類成員。許多作者都會提及這個預設的拷貝建構函式。注意到這個隱式的拷貝建構函式和顯式宣告的拷貝建構函式的不同在於對於成員的關聯方式。顯式宣告的拷貝建構函式關聯的只是被例項化的類成員的預設建構函式除非另外一個建構函式在類初始化或者在構造列表的時候被呼叫。
拷貝建構函式是程式更加有效率,因為它不用再構造一個物件的時候改變建構函式的引數列表。設計拷貝建構函式是一個良好的風格,即使是編譯系統提供的幫助你申請記憶體預設拷貝建構函式。事實上,預設拷貝建構函式可以應付許多情況。
附另外一篇關於複製建構函式的文章:
對一個簡單變數的初始化方法是用一個常量或變數初始化另一個變數,例如:
int m = 80;
int n = m;
我們已經會用建構函式初始化物件,那麼我們能不能象簡單變數的初始化一樣,直接用一個物件來初始化另一個物件呢?答案是肯定的。我們以前面定義的Point類為例:
Point pt1(15, 25);
Point pt2 = pt1;
後一個語句也可以寫成:
Point pt2( pt1);
它是用pt1初始化pt2,此時,pt2各個成員的值與pt1各個成員的值相同,也就是說,pt1各個成員的值被複制到pt2相應的成員當中。在這個初始化過程當中,實際上呼叫了一個複製建構函式。當我們沒有顯式定義一個複製建構函式時,編譯器會隱式定義一個預設的複製建構函式,它是一個內聯的、公有的成員,它具有下面的原型形式:
Point:: Point (const Point &);
可見,複製建構函式與建構函式的不同之處在於形參,前者的形參是Point物件的引用,其功能是將一個物件的每一個成員複製到另一個物件對應的成員當中。
雖然沒有必要,我們也可以為Point類顯式定義一個複製建構函式:
Point:: Point (const Point &pt)
{
xVal=pt. xVal;
yVal=pt. yVal;
}
如果一個類中有指標成員,使用預設的複製建構函式初始化物件就會出現問題。為了說明存在的問題,我們假定物件A與物件B是相同的類,有一個指標成員,指向物件C。當用物件B初始化物件A時,預設的複製建構函式將B中每一個成員的值複製到A的對應的成員當中,但並沒有複製物件C。也就是說,物件A和物件B中的指標成員均指向物件C,實際上,我們希望物件C也被複制,得到C的物件副本D。否則,當物件A和B銷燬時,會對物件C的記憶體區重複釋放,而導致錯誤。為了使物件C也被複制,就必須顯式定義複製建構函式。 下面我們以string類為例說明,如何定義這個複製建構函式。
例10-11
class String
{
public:
String(); //建構函式
String(const String &s); //複製建構函式
~String(); //解構函式
// 介面函式
void set(char const *data);
char const *get(void);
private:
char *str; //資料成員ptr指向分配的字串
};
String ::String(const String &s)
{
str = new char[strlen(s.str) + 1];
strcpy(str, s.str);
}
我們也常用無名物件初始化另一個物件,例如:
Point pt = Point(10, 20);
類名直接呼叫建構函式就生成了一個無名物件,上式用左邊的無名物件初始化右邊的pt物件。
建構函式被呼叫通常發生在以下三種情況,第一種情況就是我們上面看到的:用一個物件初始化另一個物件時;第二種情況是當物件作函式引數,實參傳給形參時;第三種情況是程式執行過程中建立其它臨時物件時。下面我們再舉一個例子,就第二種情況和第三種情況進行說明:
Point foo(Point pt)
{
…
return pt;
}
void main()
{
Point pt1 = Point(10, 20);
Point pt2;
…
pt2=foo(pt);
…
}
在main函式中呼叫foo函式時,實參pt傳給形參pt,將實參pt複製給形參pt,要呼叫複製建構函式,當函式foo返回時,要建立一個pt的臨時物件,此時也要呼叫複製建構函式。
預設的複製建構函式
在類的定義中,如果沒有顯式定義複製建構函式,C++編譯器會自動地定義一個預設的複製建構函式。下面是使用複製建構函式的一個例子:
例10-12
#include <iostream.h>
#include <string.h>
class withCC
{
public:
withCC(){}
withCC(const withCC&)
{
cout<<"withCC(withCC&)"<<endl;
}
};
class woCC
{
enum{bsz = 100};
char buf[bsz];
public:
woCC(const char* msg = 0)
{
memset(buf, 0, bsz);
if(msg) strncpy(buf, msg, bsz);
}
void print(const char* msg = 0)const
{
if(msg) cout<<msg<<":";
cout<<buf<<endl;
}
};
class composite
{
withCC WITHCC;
woCC WOCC;
public:
composite() : WOCC("composite()"){}
void print(const char* msg = 0)
{
WOCC.print(msg);
}
};
void main()
{
composite c;
c.print("contents of c");
cout<<"calling composite copy-constructor"<<endl;
composite c2 = c;
c2.print("contents of c2");
}
類withCC有一個複製建構函式,類woCC和類composite都沒有顯式定義複製建構函式。如果在類中沒有顯式定義複製建構函式,則編譯器將自動地建立一個預設的建構函式。不過在這種情況下,這個建構函式什麼也不作。
類composite既含有withCC類的成員物件又含有woCC類的成員物件,它使用無參的建構函式建立withCC類的物件WITHCC(注意內嵌的物件WOCC的初始化方法)。
在main()函式中,語句:
composite c2 = c;
通過物件C初始化物件c2,預設的複製建構函式被呼叫。
最好的方法是建立自己的複製建構函式而不要指望編譯器建立,這樣就能保證程式在我們自己的控制之下。