在C++世界中,除了需要使用構造函數直接創建一個新的對象之外,有時還需要根據已經存在的某個對象創建它的一個副本,就像那只叫做多利的羊一樣,我們希望根據一只羊創建出來另外一只一模一樣的羊。例如:
// 調用構造函數創建一個新對象shMother Sheep shMother; // 對shMother進行一些操作… // 利用shMother對象創建一個一模一樣的新對象shDolly作為其副本 Sheep shDolly(shMother);
在這裡,首先創建了一個Sheep類的新對象shMother,然後對它進行了一些操作改變其成員變量等,接著用這個對象作為Sheep類的構造函數的參數,創建一個與shMother對象一模一樣的副本shDolly。我們將這種可以接受某個對象作為參數並創建一個新對象作為其副本的構造函數稱為拷貝構造函數。拷貝構造函數實際上是構造函數的表親,在語法格式上,兩者基本相似,兩者擁有相同的函數名,只是拷貝構造函數的參數是這個類對象的引用,而它所創建得到的對象,是對那個作為參數的對象的一個拷貝,是它的一個副本。跟構造函數相似,默認情況下,如果一個類沒有顯式地定義其拷貝構造函數,編譯器會為其創建一個默認的拷貝構造函數,以內存拷貝的方式將舊有對象內存空間中的數據拷貝到新對象的內存空間,以此來完成新對象的創建。因為我們上面的Sheep類沒有定義拷貝構造函數,上面代碼中shDolly這個對象的創建就是通過這種默認的拷貝構造函數的方式完成的。
使用default和delete關鍵字控制類的默認行為
為了提高開發效率,對於類中必需的某些特殊函數,比如構造函數、析構函數、賦值操作符等,如果我們沒有在類當中顯式地定義這些特殊函數,編譯器就會為我們生成這些函數的默認版本。雖然這種機制可以為我們節省很多編寫特殊函數的時間,但是在某些特殊情況下,比如我們不希望對象被復制,就不需要編譯器生成的默認拷貝構造函數。這時候這種機制反倒成了畫蛇添足,多此一舉了。
為了取消編譯器的這種默認行為,我們可以使用delete關鍵字來禁用某一個默認的特殊函數。比如,在默認情況下,即使我們沒在類當中定義拷貝構造函數,編譯器也會為我們生成默認的拷貝構造函數進而可以通過它完成對象的拷貝復制。而有的時候,我們不希望某個對象被復制,那就需要用delete禁用類當中的拷貝構造函數和賦值操作符,防止編譯器為其生成默認函數:
class Sheep { // ... // 禁用類的默認賦值操作符 Sheep& operator = (const Sheep&) = delete; // 禁用類的默認拷貝構造函數 Sheep(const Sheep&) = delete; };
現在,Sheep類就沒有默認的賦值操作符和拷貝構造函數的實現了。如果這時還想對對象進行復制,就會導致編譯錯誤,從而達到禁止對象被復制的目的。例如:
// 錯誤的對象復制行為 Sheep shDolly(shMother); // 錯誤:Sheep類的拷貝構造函數被禁用 Sheep shDolly = shMother; // 錯誤:Sheep類的賦值操作符被禁用
與delete關鍵字禁用默認函數相反地,我們也可以使用default關鍵字,顯式地表明我們希望使用編譯器為這些特殊函數生成的默認版本。還是上面的例子,如果我們希望對象可以以默認方式被復制:
class Sheep { // ... // 使用默認的賦值操作符和拷貝構造函數 Sheep& operator = (const Sheep&) = default; Sheep(const Sheep&) = default; };
顯式地使用default關鍵字來表明使用類的默認行為,對於編譯器來說顯然是多余的,因為即使我們不說明,它也會那麼干。但是對於代碼的閱讀者而言,使用default關鍵字顯式地表明使用特殊函數的默認版本,則意味著我們已經考慮過,這些特殊函數的默認版本已經滿足我們的要求,無需自己另外定義。而將默認的操作留給編譯器去實現,不僅可以節省時間提高效率,更重要的是,減少錯誤發生的機會,並且通常會產生更好的目標代碼。
在大多數情況下,默認版本的拷貝構造函數已經能夠滿足我們拷貝復制對象的需要了,我們無需顯式地定義拷貝構造函數。但在某些特殊情況下,特別是類當中有指針類型的成員變量的時候,以拷貝內存方式實現的默認拷貝構造函數只能復制指針成員變量的值,而不能復制指針所指向的內容,這樣,新舊兩個對象中不同的兩個指針卻指向了相同的內容,這顯然是不合理的。默認的拷貝構造函數無法正確地完成這類對象的拷貝。在這種情況下,就需要自己定義類的拷貝構造函數,以自定義的方式完成像指針成員變量這樣的需要特殊處理的內容的拷貝工作。例如,有一個Computer類,它有一個指針類型的成員變量m_pKeyboard,指向的是一個獨立的Keboard對象,這時就需要定義Compuer類的拷貝構造函數來完成特殊的復制工作:
// 鍵盤類,因為結構簡單,我們使用struct來定義 struct Keyboard { // 鍵盤的型 string m_strModel; }; // 定義了拷貝構造函數的電腦類 class Computer { public: // 默認構造函數 Computer() : m_pKeyboard(nullptr),m_strModel("") {} // 拷貝構造函數,參數是const修飾的Computer類的引用 Computer(const Computer& com) : m_strModel(com.m_strModel) // 直接使用初始化屬性列表完成對象類型成員變量m_strModel的復制 { // 創建新對象,完成指針類型成員變量m_pKeyboard的復制 // 獲得已有對象com的指針類型成員變量m_pKeyboard Keyboard* pOldKeyboard = com.GetKeyboard(); // 以pOldKeyboard所指向的Keyboard對象為藍本, // 創建一個新的Keyboard對象,並讓m_Keyboard指向這個對象 if( nullptr != pOldKeyboard ) // 這裡Keyboard對象的復制使用的是Keyboard類的默認拷貝構造函數 m_pKeyboard = new Keyboard(*(pOldKeyboard)); else m_pKeyboard = nullptr; // 如果沒有鍵盤 } // 析構函數, // 對於對象類型的成員變量m_strModel,會被自動銷毀,無需在析構函數中進行處理 // 對於指針類型的成員變量m_pKeyboard,則需要在析構函數中主動銷毀 ~Computer() { delete m_pKeyboard; m_pKeyboard = nullptr; } // 成員函數,設置或獲得鍵盤對象指針 void SetKeyboard(Keyboard* pKeyboard) { m_pKeyboard = pKeyboard; } Keyboard* GetKeyboard() const { return m_pKeyboard; } private: // 指針類型的成員變量 Keyboard* m_pKeyboard; // 對象類型的成員變量 string m_strModel; };
在這段代碼中,我們為Computer類創建了一個自定義的拷貝構造函數。在這個拷貝構造函數中,對於對象類型的成員變量m_strModel,我們直接使用初始化屬性列表就完成了成員變量的拷貝。而對於指針類型成員變量m_pKeyboard而言,它的拷貝並不是拷貝這個指針的值本身,而應該拷貝的是這個指針所指向的對象。所以,對於指針類型的成員變量,並不能直接采用內存拷貝的形式完成拷貝,那樣只是拷貝了指針的值,而指針所指向的內容並沒有得到拷貝。要完成指針類型成員變量的拷貝,首先應該獲得已有對象的指針類型成員變量,進而通過它獲得它所指向的對象,然後再創建一個副本並將新對象中相應的指針類型成員變量指向這個對象,比如我們這裡的“m_pKeyboard = new Keyboard(*(pOldKeyboard));”,這樣才完成了指針類型成員變量的復制。這個我們自己定義的拷貝構造函數不僅能夠拷貝Computer類的對象類型成員變量m_strModel,也能夠正確地完成指針類型成員變量m_pKeyboard的拷貝,最終才能完成對Computer對象的拷貝。例如:
// 引入斷言所在的頭文件 #include <assert.h> //… // 創建一個Computer對象oldcom Computer oldcom; // 創建oldcom的Keyboard對象並修改其屬性 Keyboard keyboard; keyboard.m_strModel = "Microsoft-101"; // 將鍵盤組裝到oldcom上 oldcom.SetKeyboard(&keyboard); // 以oldcom為藍本,利用Computer類的拷貝構造函數創建新對象newcom // 新的newcom對象是oldcom對象的一個副本 Computer newcom(oldcom); // 使用斷言判斷兩個Computer對象是否相同, // 電腦型號應該相同 assert(newcom.GetModel() == oldcom.GetModel()); // 不同的Computer對象應該擁有不同的Keyboard對象 assert( newcom.GetKeyboard() != oldcom.GetKeyboard() ); // 因為是復制,不同的Keyboard對象應該是相同的型號 assert( newcom.GetKeyboard()->m_strModel == oldcom.GetKeyboard()->m_strModel );
在C++中,除了使用拷貝構造函數創建對象的副本作為新的對象之外,在創建一個新對象之後,還常常將一個已有的對象直接賦值給它來完成新對象的初始化。例如:
// 創建一個新的對象 Computer newcom; // 利用一個已有的對象對其進行賦值,完成初始化 newcom = oldcom;
賦值的過程,實際上也是一個拷貝的過程,就是將等號右邊的對象拷貝到等號左邊的對象。跟類的拷貝構造函數相似,如果沒有顯式地為類定義賦值操作符,編譯器也會為其生成一個默認的賦值操作符,以內存拷貝的方式完成對象的賦值操作。因為同樣是以內存拷貝的方式完成對象的復制,所以當類中有指針型成員變量時,也同樣會遇到只能拷貝指針的值而無法拷貝指針所指向的內容的問題。因此,要完成帶有指針型成員變量的類對象的賦值,必須對類的賦值操作符進行自定義,在其中以自定義的方式來完成指針型成員變量的復制。例如,Computer類中含有指針型成員變量m_pKeybard,可以這樣自定義它的賦值操作符來完成其賦值操作。
// 定義了賦值操作符“=”的電腦類 class Computer { public: // 自定義的賦值操作符 Computer& operator = (const Computer& com) { // 判斷是否是自己給自己賦值 // 如果是自賦值,則直接返回對象本身 // 這裡的this指針,是類當中隱含的一個指向自身對象的指針。 if( this == &com ) return *this; // 直接完成對象型成員變量的賦值 m_strModel = com.m_strModel; // 創建舊有對象的指針型成員變量所指對象的副本 // 並將被賦值對象相應的指針型成員變量指向這個副本對象 m_pKeyboard = new Keyboard(*(com.GetKeyboard())); } // … };
在上面的賦值操作符函數中,我們首先判斷這是不是一個自賦值操作。所謂自賦值,就是自己給自己賦值。例如:
// 用newcom給newcom賦值 newcom = newcom;
嚴格意義上說,這種自賦值操作是沒有意義的,應該算是程序員的一個失誤。但作為一個設計良好的賦值操作符,應該可以檢測出這種失誤並給予恰當的處理,將程序從程序員的失誤中解救出來:
// 判斷是否是自賦值操作 // 將this指針與傳遞進來的指向com對象的指針進行比較 // 如果相等,就是自賦值操作,直接返回這個對象本身 if( this == &com) return *this;
在賦值操作符函數中如果檢測到這種自賦值操作,它就直接返回這個對象本身從而避免後面的復制操作。如果不是自賦值操作,對於對象型成員變量,使用“=”操作符直接完成其賦值;而對於指針型成員變量,則采用跟拷貝構造函數相似的方式,通過創建它所指向的對象的副本,並將左側對象的相應指針型成員變量指向這個副本對象來完成其賦值。
另外值得注意的一點是,賦值操作符的返回值類型並不一定是這個類的引用,我們使用void代替也是可以的。例如:
class Computer { public: // 以void作為返回值類型的賦值操作符 void operator = (const Computer& com) { // … } // … };
以上的代碼雖然在語法上正確,也能夠實現單個對象的賦值操作,但是卻無法實現如下形式的連續賦值操作:
Computer oldcom; // Computer newcom1,newcom2; // 連續賦值 newcom1 = newcom2 = oldcom;
連續的賦值操作符是從右向左開始進行賦值的,所以,上面的代碼實際上就是:
newcom1 = (newcom2 = oldcom );
也就是先將oldcom賦值給newcom2(如果返回值類型是void,這一步是可以完成的),然後將“newcom2 = oldcom”的運算結果賦值給newcom1,而如果賦值操作符的返回值類型是void,也就意味著“newcom2 = oldcom”的運算結果是void類型,我們顯然是不能將一個void類型數據賦值給一個Computer對象的。所以,為了實現上面這種形式的連續賦值,我們通常以這個類的引用(Computer&)作為賦值操作符的返回值類型,並在其中返回這個對象本身(“return *this”),已備用做下一步的繼續賦值操作。
初始化列表構造函數
除了我們在上文中介紹的普通構造函數和拷貝構造函數之外,為了讓對象的創建形式更加靈活,C++還提供了一種可以接受一個初始化列表(initializer list)為參數的構造函數,因此這種構造函數也被稱為初始化列表構造函數。初始化列表由一對大括號(“{}”)構造而成,可以包含任意多個相同類型的數據元素。如果我們希望可以通過不定個數的相同類型數據來創建某個對象,比如,一個工資對象管理著不定個數的工資項目,包括基本工資,獎金,提成,補貼等等,有的人只有基本工資,而有的人全都有,為了創建工資對象形式上的統一,我們就希望這些不定個數的工資項目都可以用來創建工資對象。這時,我們就需要通過實現工資類的初始化列表構造函數來完成這一任務:
#include <iostream> #include <vector> #include <initializer_list> // 引入初始化列表所在頭文件 using namespace std; // 工資類 class Salary { public: // 初始化列表構造函數 // 工資數據為int類型,所以其參數類型為initializer_list<int> Salary(initializer_list<int> s) { // 以容器的形式訪問初始化列表 // 獲取其中的工資項目保存到工資類的vector容器 for(int i : s) m_vecSalary.push_back(i); } // .. // 獲取工資總數 int GetTotal() { int nTotal = 0; for(int i : m_vecSalary) nTotal += i; return nTotal; } private: // 保存工資數據的vector容器 vector<int> m_vecSalary; }; int main() { // 陳老師只有基本工資,“{}”表示初始化列表 Salary sChen{2200}; // 王老師既有基本工資還有獎金和補貼 Salary sWang{5000,9500,1003}; // 輸出結果 cout<<"陳老師的工資:"<<sChen.GetTotal()<<endl; cout<<"王老師的工資:"<<sWang.GetTotal()<<endl; return 0; }
從這裡可以看到,雖然陳老師和王老師的工資項目各不相同,但是通過初始化列表構造函數,他們的工資對象都可以以統一的形式創建。而這正是初始化列表的意義所在,它可以讓不同個數的同類型數據以相同的形式作為函數參數。換句話說,如果我們希望某個函數可以接受不定個數的同類型數據為參數,就可以用初始化列表作為參數類型。例如,我們可以為Salary類添加一個AddSalary()函數,用初始化列表作為參數,它就可以向Salary對象添加不定個數的工資項目:
// 以初始化列表為參數的普通函數 void AddSalary(initializer_list<int> s) { for(int i : s) m_vecSalary.push_back(i); } // … // 後來發現是陳老師的獎金和補貼忘了計算了,給他加上 // 這裡的大括號{}就構成初始化列表 sChen.AddSalary({8200,6500});
如果要想對兩個對象進行操作,比如兩個對象相加,最直觀的方式就是像數學式子一樣,用表示相應意義的操作符來連接兩個對象,以此表達對這兩個對象的操作。在本質上,操作符就相當於一個函數,它有自己的參數,可以用來接收操作符所操作的數據;也有自己的函數名,就是操作符號;同時也有返回值,用於返回結果數據。而在使用上,只需要用操作符連接被操作的兩個對象,比函數調用簡單直觀得多,代碼的可讀性更好。所以在表達一些常見的操作時,比如對兩個對象的加減操作,我們往往通過重載這個類的相應意義的操作符來完成。在C++中有許多內置的數據類型,包括int、char、string等,而這些內置的數據類型都有許多已經定義的操作符可以用來表達它們之間的操作。比如,我們可以用“+”操作符來表達兩個對象之間的“加和”操作,用它連接兩個int對象,得到的“加和”操作結果就是這兩個數的和,而用它連接兩個string對象,得到的“加和”操作結果就是將兩個字符串連接到一起。例如:
int a = 3; int b = 4; // 使用加法操作符“+”獲得兩個int類型變量的和 int c = a + b; cout<<a<<" + "<<b<< " = "<<c<<endl; string strSub1("Hello "); string strSub2("C++"); // 使用加法操作符“+”獲得兩個string類型變量的連接結果 string strCombin = strSub1 + strSub2; cout<<strSub1<<"+ "<<strSub2<<" = "<<strCombin<<endl;
這種用操作符來表達對象之間的操作關系的方式,用抽象性的操作符表達了具體的操作過程,從而隱藏了操作過程的具體細節,既直觀又自然,也就更便於使用。對於內置的數據類型,C++已經提供了豐富的操作符供我們選擇使用以完成常見的操作,比如表示數學運算的“+”(加)、“-”(減)、“*”(乘)、“/”(除)。但是對於我們新定義的類而言,其兩個對象之間是不能用這些操作符直接進行操作的。比如,分別定義了Father類和Mother類的兩個對象,我們希望可以用加法操作符“+”連接這兩個對象,進而通過運算得出一個Baby類的對象:
// 分別定義Father類和Mother類的對象 Father father; Mother mother; // 用加法操作符“+”連接兩個對象,運算得到Baby類的對象 Baby baby = father + mother;
以上語句所表達的是一件顯而易見的事情,但是,如果沒有對Father類的加法操作符“+”進行定義,Father類是不知道如何和一個Mother類的對象加起來創建一個Baby類對象的,這樣的語句會出現編譯錯誤,一件顯而易見的事情在C++中卻行不通。但幸運的是,C++允許我們對這些操作符進行重載,讓我們可以對操作符的行為進行自定義。既然是自定義,自然是想干啥就干啥,自然也就可以讓Father類對象加上Mother類對象得到Baby類對象,讓上面的代碼成為可能。
在功能上,重載操作符等同於類的成員函數,兩者並無本質上的差別,可以簡單地將重載操作符看成是一類比較特殊的成員函數。雖然成員函數可以提供跟操作符相同的功能,但是運用操作符可以讓語句更加自然簡潔,也更具可讀性。比如,“a.add(b)”調用函數add()以實現兩個對象a和b相加,但是表達相同意義的“a + b”語句,遠比“a.add(b)”更直觀也更容易讓人理解。
在C++中,聲明重載操作符的語法格式如下:
class 類名 { public: 返回值類型 operator 操作符 (參數列表) { // 操作符的具體運算過程 } };
從這裡可以看到,重載操作符和類的成員函數在本質上雖然相同,但在形式上還是存在一些細微的差別。普通成員函數以標識符(不以數字為首的字符串)作為函數名,而重載操作符以“operator 操作符”作為函數名。其中的“operator”表示這是一個重載的操作符函數,而其後的操作符就是我們要定義的符號。
在使用上,當使用操作符連接兩個對象進行運算時,實際上相當於調用第一個對象的操作符函數,而第二個對象則作為這個操作符函數的參數。例如,使用加法操作符對兩個對象進行運算:
a + b;
這條語句實際上等同於:
a.operator + (b);
“a + b”表示調用的是對象a的操作符“operator +”,而對象b則是這個操作符函數的參數。理解了這些,要想讓“father + mother”得到baby對象,只需要定義Father類的“+”操作符函數(因為father位於操作符之前,所以我們定義father所屬的Father類的操作符),使其可以接受一個Mother類的對象作為參數,並返回一個Baby類的對象就可以了:
// 母親類 class Mother { // 省略具體定義 }; // 孩子類 class Baby { public: // 孩子類的構造函數 Baby(string strName) : m_strName(strName) {} private: // 孩子的名字 string m_strName; }; // 父親類 class Father { public: // 重載操作符“+”,返回值為Baby類型,參數為Mother類型 Baby operator + (const Mother& mom) { // 創建一個Baby對象並返回,省略創建過程… return Baby("MiaoMiao"); } };
在Father類的重載操作符“+”中,它可以接受一個Mother類的對象作為參數,並在其中創建一個Baby類的對象作為操作符的返回值。這樣就完整地表達了一個Father類的對象加上一個Mother類的對象得到一個Baby類的對象的意義,現在就可以方便地使用操作符“+”將Father類的對象和Mother類的對象相加而得到一個Baby類的對象了。需要注意的是,這裡我們只是定義了Father類的“+”操作符,所以在用它計算的時候,只能是Father類的對象放在“+”之前,而如果希望Mother類的對象也可以放在“+”之前,相應地就同樣需要定義Mother類的“+”操作符。