什么是 C++ 的拷貝控制 ?
我們知道在 C++ 當(dāng)中轴总,類類型是一種由用戶自定義的數(shù)據(jù)類型直颅。既然是數(shù)據(jù)類型,我們很自然地會希望在定義上和其他的內(nèi)置類型有著相同的操作怀樟」Τィ回想一下,當(dāng)我們在定義一個內(nèi)置類型變量時往堡,我們需要考慮以下幾種情況:
// 內(nèi)置類型
{
int a = 10; //定義一個 int 變量并初始化
int b = a; //使用已定義好的變量 a 初始化變量 b
int c; //定義一個變量 c 并初始化為默認(rèn)值
c = a; //將 a 的值賦給 c
}// 離開作用域后將局部變量 a械荷、b、c 銷毀
而為了實現(xiàn)這樣的功能虑灰,C++ 為類類型提供了構(gòu)造函數(shù)吨瞎、析構(gòu)函數(shù)、拷貝構(gòu)造函數(shù)穆咐、拷貝賦值運算符颤诀、移動構(gòu)造函數(shù)和移動賦值運算符。其中類的拷貝控制成員包括了析構(gòu)函數(shù)对湃、拷貝構(gòu)造函數(shù)崖叫、拷貝賦值運算符、移動構(gòu)造函數(shù)和移動賦值運算符熟尉,而后兩者則是在 C++ 的新標(biāo)準(zhǔn)中引入的归露,它們?yōu)轭愄峁┝?“剪切” 操作。
析構(gòu)函數(shù)
析構(gòu)函數(shù)的定義方式如下:
class MyClass{
public:
...
//析構(gòu)函數(shù)
~MyClass(){
cout << "MyClass Deconstructor" << endl;
}
...
};
從定義方式可以看到斤儿,析構(gòu)函數(shù)沒有返回值剧包,沒有參數(shù)列表,且函數(shù)名固定為 “~類名”往果。由于沒有參數(shù)列表疆液,因此析構(gòu)函數(shù)不可以重載,一個類中只能有一個析構(gòu)函數(shù)陕贮。
當(dāng)一個對象被銷毀時堕油,會自動調(diào)用其對應(yīng)的析構(gòu)函數(shù)。在析構(gòu)函數(shù)中,首先會執(zhí)行函數(shù)體掉缺,然后按照成員初始化順序進行逆序銷毀卜录。如果對象成員中有其他類的對象,則也會調(diào)用對應(yīng)析構(gòu)函數(shù)進行銷毀操作眶明。這也就意味著析構(gòu)函數(shù)本身并不直接銷毀對象成員艰毒。成員對象是在析構(gòu)函數(shù)之后隱含的析構(gòu)階段銷毀的,而析構(gòu)函數(shù)體是作為成員銷毀步驟的一個前置操作搜囱。
一個對象被銷毀的時機主要有以下幾種:
- 局部對象離開其作用域時會被銷毀
- 存放對象的容器被銷毀丑瞧,容器中的對象都會被銷毀
- 對動態(tài)分配內(nèi)存的對象指針執(zhí)行 delete 操作時,所指向的對象會被銷毀
- 對于臨時對象蜀肘,當(dāng)創(chuàng)建它的完整表達式結(jié)束的時候銷毀
當(dāng)用戶沒有顯式地定義類的析構(gòu)函數(shù)绊汹,那么編譯器會自動為類生成默認(rèn)的析構(gòu)函數(shù)。默認(rèn)的析構(gòu)函數(shù)有兩種可能
- 默認(rèn)析構(gòu)函數(shù)的函數(shù)體為空扮宠,什么都不操作
- 若類的某個成員的析構(gòu)函數(shù)被指明為 = delete 的西乖,則這個類的默認(rèn)析構(gòu)函數(shù)也是 = delete 的。
拷貝構(gòu)造函數(shù)
拷貝構(gòu)造函數(shù)的函數(shù)定義如下
class MyClass{
public:
...
// 拷貝構(gòu)造函數(shù)
MyClass(const MyClass& obj):var(obj.var){
cout << "MyClass Copy Constructor" << endl;
}
...
private:
int var;
};
從定義上來看涵卵,拷貝構(gòu)造函數(shù)的形參是常引用類型浴栽,沒有返回值。其中需要注意的是轿偎,拷貝構(gòu)造函數(shù)的參數(shù)必須是引用類型典鸡,而不能為值類型。如果形參為值類型坏晦,則在調(diào)用拷貝構(gòu)造函數(shù)時萝玷,需要將實參拷貝給形參,則會引發(fā)新一輪拷貝構(gòu)造函數(shù)的調(diào)用昆婿,導(dǎo)致無限遞歸球碉。將引用定義為 const 是因為拷貝構(gòu)造函數(shù)不應(yīng)當(dāng)修改源對象的值,但這并非強制要求仓蛆。當(dāng)用戶沒有顯式定義拷貝構(gòu)造函數(shù)時睁冬,而程序中又使用到了對象的拷貝功能,則編譯器會自動生成默認(rèn)的拷貝構(gòu)造函數(shù)看疙。默認(rèn)的拷貝構(gòu)造函數(shù)只能實現(xiàn)淺拷貝操作豆拨。
由于拷貝構(gòu)造操作常常會被隱式調(diào)用,因此拷貝構(gòu)造函數(shù)通常不聲明為 explicit能庆。例如:
string A("test"); //直接初始化施禾,調(diào)用構(gòu)造函數(shù)
string B(A); //直接初始化,調(diào)用拷貝構(gòu)造函數(shù)
string C = A; //拷貝初始化搁胆,調(diào)用拷貝構(gòu)造函數(shù)
string D = "test"; //拷貝初始化弥搞,調(diào)用拷貝構(gòu)造函數(shù)
string E = string("test"); //拷貝初始化邮绿,調(diào)用拷貝構(gòu)造函數(shù)
由于編譯器優(yōu)化的原因,像 string D = "test" 通常會被優(yōu)化為 string D("test")攀例,進而提高執(zhí)行效率
拷貝賦值運算符
拷貝構(gòu)造運算符的函數(shù)定義如下
class MyClass{
public:
...
// 拷貝構(gòu)造函數(shù)
MyClass& operator=(const MyClass& obj){
cout << "MyClass Copy Assignment" << endl;
if(this != &obj){
auto newp = new string(*obj.p_str);
delete p_str;
p_str = newp;
}
return *this;
}
...
private:
string *p_str;
};
從定義上看船逮,拷貝賦值運算符是一個返回對象的引用,參數(shù)為對象的常引用的運算符函數(shù)粤铭。在實現(xiàn)的過程中傻唾,我們利用 this != obj 來預(yù)防自賦值操作。同時為了保證運算符的實現(xiàn)是異常安全的承耿,我們采用先將右值保存到一個臨時對象中,隨后釋放自身的成員對象伪煤,并完成拷貝操作加袋。同樣的,如果沒有顯式地定義類的拷貝賦值函數(shù)而代碼又使用了拷貝賦值功能抱既,那么編譯器將會自動生成默認(rèn)的拷貝賦值運算符。
在定義拷貝賦值運算符的時候,有三個需要注意的地方:
- 當(dāng)使用一個對象對自身進行賦值時冶共,賦值運算符依然要保證有正確的行為闽烙。
- 一個定義良好的拷貝賦值運算符應(yīng)當(dāng)是異常安全的,即當(dāng)異常發(fā)生時捷泞,能夠使左側(cè)運算對象處于一種有意義的狀態(tài)
- 大多數(shù)的拷貝賦值運算符組合了析構(gòu)函數(shù)和拷貝構(gòu)造函數(shù)的工作足删。
什么情況下,需要程序員手動實現(xiàn)拷貝構(gòu)造函數(shù)和拷貝賦值運算符(三/五法則)
-
當(dāng)類需要實現(xiàn)析構(gòu)函數(shù)時锁右,那么往往也需要實現(xiàn)拷貝構(gòu)造函數(shù)和拷貝賦值運算符失受。而實現(xiàn)了拷貝構(gòu)造函數(shù)和拷貝賦值運算符的類卻不一定要顯式定義析構(gòu)函數(shù)
【注:基類的析構(gòu)函數(shù)是個例外,不遵循該原則】這主要是因為當(dāng)程序需要顯式定義析構(gòu)函數(shù)時咏瑟,往往意味著我們需要手動釋放資源拂到。而使用默認(rèn)拷貝構(gòu)造函數(shù)則會導(dǎo)致“深淺拷貝問題”。
class A{ public: A(string const s = ""):ps(new string(s)){} ~A(){delete ps;} void display(void){ cout << *ps << endl; } private: string *ps; }; int main(void){ A* a = new A("test"); A b(*a); A c; c = *a; delete a; b.display(); //對象 b 試圖訪問已被刪除的對象 a 中的 ps c.display(); //對象 c 試圖訪問已被刪除的對象 a 中的 ps return 0; }
由于沒有顯式定義拷貝構(gòu)造函數(shù)和拷貝賦值運算符码泞,因此編譯器生成默認(rèn)拷貝構(gòu)造函數(shù)和拷貝賦值運算符兄旬,它們僅僅只拷貝了 a.ps 的值,但并沒有拷貝 a.ps 所指向的對象余寥。因此领铐,a.ps, b.ps, c.ps 均指向同一對象,在 a.ps 所指向的對象被釋放后劈狐,b 和 c 又試圖去訪問它罐孝,這種操作的后果是未定義的。
如果定義了拷貝構(gòu)造函數(shù)肥缔,那么通常也要定義拷貝賦值運算符莲兢;反之同理
如果一個類是可拷貝的,那么它應(yīng)該是可移動的。但如果一個類是可移動的改艇,它不一定是可拷貝的收班,例如 unique_ptr 或 IO 類
注:三/五法則并不是指有 3 條或 5 條法則,而是因為在 C++ 的早期標(biāo)準(zhǔn)中只有析構(gòu)函數(shù)谒兄、拷貝構(gòu)造函數(shù)和拷貝賦值運算符摔桦,這三者應(yīng)當(dāng)作為整體考慮,這稱之為 “C++ 三法則”承疲。而 C++ 的新標(biāo)準(zhǔn)引入了移動構(gòu)造函數(shù)和移動賦值運算符邻耕,將三法則擴充為五法則,后統(tǒng)一稱之為 “三/五法則” 燕鸽。詳見《C++ 三法則》
如何阻止對象的拷貝功能
對于某些類對象而言兄世,比如 IO 類或者包含 unique_ptr 成員的類對象,我們不能為其提供拷貝操作啊研。即使我們不去實現(xiàn)拷貝構(gòu)造函數(shù)和拷貝賦值運算符御滩,編譯器也會自動生成默認(rèn)的拷貝構(gòu)造函數(shù)和拷貝賦值運算符。我們先來看看 C++ 的新舊標(biāo)準(zhǔn)當(dāng)中是如何解決這個問題的党远。
在早期的 C++ 標(biāo)準(zhǔn)中削解,用戶可以通過將拷貝構(gòu)造函數(shù)和拷貝賦值運算符的訪問權(quán)限設(shè)置為 private,而且對這兩個函數(shù)只聲明不定義沟娱。由于設(shè)置 private氛驮,因此當(dāng)用戶代碼試圖拷貝類對象時,會產(chǎn)生編譯錯誤济似。若成員函數(shù)或友元函數(shù)試圖拷貝對象柳爽,則會因函數(shù)未定義而引發(fā)鏈接錯誤。
在 C++ 的新標(biāo)準(zhǔn)中引入了 = delete 來表明顯式地禁止使用某個函數(shù)碱屁。具體的用法如下:
struct NoCopy{
NoCopy();
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
~NoCopy() = default;
};
NoCopy::NoCopy() = default;
int main(void){
NoCopy a;
NoCopy b(a); //error: use of deleted function 'NoCopy::NoCopy(const NoCopy&)'
NoCopy c;
c = a; //error: use of deleted function 'NoCopy& NoCopy::operator=(const NoCopy&)'
return 0;
}
顯然磷脯,引入 = delete 使得為了特定類類型提供禁止拷貝功能變得更加簡單。不僅如此娩脾,= delete 還可以修飾普通函數(shù)赵誓,來禁止某些特定的隱式類型轉(zhuǎn)換。例如一個針對 int 類型的 add 函數(shù)柿赊,我們不希望當(dāng)用戶傳遞 double 類型的實參時俩功,會因為隱式類型轉(zhuǎn)換而損失精度,我們可以禁止 add 的重載版本來實現(xiàn)這個功能:
int add(int const a, int const b){
return a + b;
}
int add(double const a, double const b) = delete;
int main(void){
cout << add(3,4) << endl;
cout << add(3.0,4.5) << endl; //error: use of deleted function 'double add(double, double)'|
return 0;
}
= delete 和 = default 的區(qū)別
從前面的描述當(dāng)中碰声,我們可以看到 = delete 和 = default 在用法上的相似性诡蜓,接下來我們來看一看它們二者之間的區(qū)別
理論上所有函數(shù)都可以指定為 = delete,而只有類的特殊成員函數(shù) (構(gòu)造函數(shù)胰挑、析構(gòu)函數(shù)蔓罚、拷貝構(gòu)造函數(shù)和拷貝賦值運算符) 才能指定為 = default
-
= default 可以在類內(nèi)(inline)聲明椿肩,也可以在類外(out of line) 聲明,而= delete 必須在函數(shù)的首次聲明時指定豺谈,這也就意味著 = delete 只能在類內(nèi)聲明
struct A{ A(); A(const A&) = delete; //= delete 必須在函數(shù)首次聲明時指定 A& operator=(const A&) = delete; }; A::A() = default; //= default 可以在類外聲明
注意:理論上所有函數(shù)都可以指定為 delete 的郑象,但通常情況下不能刪除析構(gòu)函數(shù)。如果析構(gòu)函數(shù)被刪除茬末,則無法銷毀此類型對象厂榛。而對于刪除了析構(gòu)函數(shù)的類,或者類成員中包含了刪除析構(gòu)函數(shù)的類的對象丽惭,則編譯器會禁止定義該類型的對象或創(chuàng)建該類型的臨時對象击奶。