C++類的特殊成員-預設/拷貝/移動建構函式
首先學習這章,需要對動態記憶體分配有一定的理解。
類的特殊成員函式有六個,如下:
接下來讓我們逐一分析:
1 預設建構函式
預設建構函式相信大家都不陌生了,只有當沒有宣告建構函式或者物件在宣告的時候沒有任何初始化引數就會呼叫預設建構函式。
class Example {
public:
int total;
void accumulate (int x) { total += x; }
};
編譯器假定Example有一個預設建構函式。因此,類的物件可以不使用任何引數簡單地宣告。
Example ex;
但是,只要類的建構函式被使用任何引數顯式地宣告,編譯器就不會隱式地呼叫預設建構函式,也就是不再允許類物件的宣告不使用引數。例如,下面的類:
class Example2 {
public:
int total;
Example2(int value):total(value) { };
void accumulate (int x) { total += x; };
};
這兒,我們聲明瞭一個帶有int形的建構函式。因此,下面的物件宣告是正確的:
Example2 ex (100); // ok: calls constructor
但是,下面的宣告就是不正確的:
Example2 ex; // 不正確: 沒有預設建構函式
因此,類能夠使用帶有一個引數的顯式建構函式取代預設建構函式。因此,如果類物件需要使用無參宣告,類中必須有正確的預設建構函式。例如:
// 類和預設建構函式
#include <iostream>
#include <string>
using namespace std;
class Example3 {
string data;
public:
Example3 (const string& str) : data(str) {}
Example3() {}
const string& content() const {return data;}
};
int main () {
Example3 foo;
Example3 bar ("Example" );
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
這裡,Example3有一個預設建構函式,它具有空的函式體。
Example3() {}
上面的Example3就是預設建構函式,當class宣告中沒有其它建構函式時就會呼叫這個預設建構函式。但是,在上面的例子中還有其它建構函式:
Example3 (const string& str);
如果在類中顯式聲明瞭任何建構函式,預設建構函式就不會自動提供。
2 解構函式
什麼是解構函式?
解構函式一般與建構函式成對出現,主要用於類物件在宣告週期結束時釋放物件所佔記憶體。為了做到這點,解構函式舊出現了。
看下面的例子:
// destructors
#include <iostream>
#include <string>
using namespace std;
class Example4 {
string* ptr;
public:
// constructors:
Example4() : ptr(new string) {}
Example4 (const string& str) : ptr(new string(str)) {}
// destructor:
~Example4 () {delete ptr;}
// access content:
const string& content() const {return *ptr;}
};
int main () {
Example4 foo;
Example4 bar ("Example");
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
在例4中,為一個字串分配了儲存空間,這個儲存空間就會被解構函式在後面釋放。
3 複製建構函式
當用一個已初始化過了的自定義類型別物件去初始化另一個新構造的物件的時候,拷貝建構函式就會被自動呼叫。也就是說,當類的物件需要拷貝時,拷貝建構函式將會被呼叫。以下情況都會呼叫拷貝建構函式:
(1)一個物件以值傳遞的方式傳入函式體
(2)一個物件以值傳遞的方式從函式返回
(3)一個物件需要通過另外一個物件進行初始化。
如果在類中沒有顯式地宣告一個拷貝建構函式,那麼,編譯器將會自動生成一個預設的拷貝建構函式,該建構函式完成物件之間的位拷貝。位拷貝又稱淺拷貝,後面將進行說明。
自定義拷貝建構函式是一種良好的程式設計風格,它可以阻止編譯器形成預設的拷貝建構函式,提高原始碼效率。
淺拷貝和深拷貝
在某些狀況下,類內成員變數需要動態開闢堆記憶體,如果實行位拷貝,也就是把物件裡的值完全複製給另一個物件,如A=B。這時,如果B中有一個成員變數指標已經申請了記憶體,那A中的那個成員變數也指向同一塊記憶體。這就出現了問題:當B把記憶體釋放了(如:析構),這時A內的指標就是野指標了,出現執行錯誤。
深拷貝和淺拷貝可以簡單理解為:如果一個類擁有資源,當這個類的物件發生複製過程的時候,資源重新分配,這個過程就是深拷貝,反之,沒有重新分配資源,就是淺拷貝。下面舉個深拷貝的例子。
// 拷貝建構函式: 深複製
#include <iostream>
#include <string>
using namespace std;
class Example5 {
string* ptr;
public:
Example5(const string& str):ptr(new string(str)){}
~Example5() {delete ptr;}
// 拷貝建構函式:
Example5(const Example5& x):ptr(new string(x.content())) {}
// 訪問類的字串:
const string& content() const {return *ptr;}
};
int main () {
Example5 foo ("Example");
Example5 bar = foo;
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
上面的程式碼中,拷貝建構函式的引數列表中,new一個新的字串儲存位置用來儲存複製過來的舊類物件的字串內容。通過這樣的操作,兩個物件就具有內容相同,但是儲存位置不同的字串。
來總結一下關於 深拷貝與淺拷貝需要知道的基本概念和知識:
(1)什麼時候用到拷貝函式?
a.一個物件以值傳遞的方式傳入函式體;
b.一個物件以值傳遞的方式從函式返回;
c.一個物件需要通過另外一個物件進行初始化。
如果在類中沒有顯式地宣告一個拷貝建構函式,那麼,編譯器將會自動生成一個預設的拷貝建構函式,該建構函式完成物件之間的位拷貝。位拷貝又稱淺拷貝。
(2)是否應該自定義拷貝函式?
(3)什麼叫深拷貝?什麼是淺拷貝?兩者異同?
自定義拷貝建構函式是一種良好的程式設計風格,它可以阻止編譯器形成預設的拷貝建構函式,提高原始碼效率。
深拷貝:如果一個類擁有資源,當這個類的物件發生複製過程的時候,資源重新分配,這個過程就是深拷貝,反之,沒有重新分配資源,就是淺拷貝。
(4)深拷貝好還是淺拷貝好?
如果實行位拷貝,也就是把物件裡的值完全複製給另一個物件,如A=B。這時,如果B中有一個成員變數指標已經申請了記憶體,那A中的那個成員變數也指向同一塊記憶體。這就出現了問題:當B把記憶體釋放了(如:析構),這時A內的指標就是野指標了,出現執行錯誤。
4 拷貝賦值操作
物件不僅僅在構造階段的初始化時被拷貝,它們也可以被任何賦值操作拷貝。看看它們的不同:
MyClass foo;
MyClass bar(foo); // 物件初始化: 呼叫拷貝建構函式
MyClass baz = foo; // 物件初始化: 呼叫拷貝建構函式
foo = bar; // 物件已經被初始化,呼叫拷貝賦值
在上面的例子中,MyClass baz = foo;雖然使用了等號“=“但是這並不是賦值操作(儘管它看起來像是):物件的宣告不是賦值操作,它僅僅是呼叫單引數建構函式的另一種語法。
但是對foo這個物件的操作就是賦值操作了,這裡沒有物件的宣告,僅僅是對一個已經存在的物件進行賦值操作。
物件的拷貝複製操作是操作符“=“的一種過載形式。返回值是*this指標的引用(儘管這裡沒有要求)。語法如下:
MyClass& operator= (const MyClass&);
拷貝賦值操作符是一種特殊的函式,如果一個類沒有使用者定義的拷貝或者移動賦值(或者移動建構函式)就會隱含的宣告拷貝賦值操作符。
同拷貝賦值操作一樣,執行的也是淺拷貝。這種操作,不僅僅會有刪除物件兩次,還會造成記憶體洩漏的風險。這些問題可以通過拷貝賦值先前的物件且執行深拷貝來避免,看下面的例子:
Example5& operator= (const Example5& x) {
delete ptr; // 刪除現在指向的字串
// 為新字串分配儲存空間,並拷貝
ptr = new string (x.content());
return *this;
}
更好的方式就是,基於字串成員不是constant,它可以重新利用相同的字串物件:
Example5& operator= (const Example5& x) {
*ptr = x.content();// 指向同一個字串物件
return *this;
}
5 轉移建構函式
與拷貝類似,移動也使用一個物件的值設定另一個物件的值。但是,又與拷貝不同的是,移動實現的是物件值真實的轉移(源物件到目的物件):源物件將丟失其內容,其內容將被目的物件佔有。移動操作的發生的時候,是當移動值的物件是未命名的物件的時候。
這裡未命名的物件就是那些臨時變數,甚至都不會有名稱。典型的未命名物件就是函式的返回值或者型別轉換的物件。
使用臨時物件的值初始化另一個物件值,不會要求對物件的複製:因為臨時物件不會有其它使用,因而,它的值可以被移動到目的物件。做到這些,就要使用移動建構函式和移動賦值:
當使用一個臨時變數對物件進行構造初始化的時候,呼叫移動建構函式。類似的,使用未命名的變數的值賦給一個物件時,呼叫移動賦值操作。
MyClass fn(); // 函式返回一個 MyClass 物件
MyClass foo; // 預設建構函式
MyClass bar = foo; // 拷貝建構函式
MyClass baz = fn(); // 移動建構函式
foo = bar; // 拷貝賦值
baz = MyClass(); // 移動賦值
fn的返回值和MyClass構造的值都是臨時變數。在這些例子裡,沒有需要做拷貝,因為臨時變數的生命週期很短,能夠被其它物件獲取,這種操作是更有效的。
下面是移動建構函式和移動賦值的語法,它們的返回型別都是class類自身。
MyClass (MyClass&&); // move-constructor
MyClass& operator= (MyClass&&); // move-assignment
移動操作的概念對物件管理它們使用的儲存空間很有用的,諸如物件使用new和delete分配記憶體的時候。在這類物件中,拷貝和移動是不同的操作:從A拷貝到B意味著,B分配了新記憶體,A的整個內容被拷貝到為B分配的新記憶體上。
而從A移動到B意味著分配給A的記憶體轉移給了B,沒有分配新的記憶體,它僅僅包含簡單地拷貝指標。
看下面的例子:
// 移動建構函式和賦值
#include <iostream>
#include <string>
using namespace std;
class Example6 {
string* ptr;
public:
Example6 (const string& str) : ptr(new string(str)) {}
~Example6 () {delete ptr;}
// 移動建構函式,引數x不能是const Pointer&& x,
// 因為要改變x的成員資料的值;
// C++98不支援,C++0x(C++11)支援
Example6 (Example6&& x) : ptr(x.ptr)
{
x.ptr = nullptr;
}
// move assignment
Example6& operator= (Example6&& x)
{
delete ptr;
ptr = x.ptr;
x.ptr=nullptr;
return *this;
}
// access content:
const string& content() const {return *ptr;}
// addition:
Example6 operator+(const Example6& rhs)
{
return Example6(content()+rhs.content());
}
};
int main () {
Example6 foo("Exam"); // 建構函式
Example6 bar = Example6("ple"); // 移動建構函式
foo = foo + bar; // 移動賦值
cout << "foo's content: " << foo.content() << '\n';
return 0;
}
執行結果:
foo's content: Example
編譯器早已能用返回值優化的方式優化大多數上形式上呼叫移動構造的情況。最顯著的是,當一個函式返回值被用來初始化一個物件時。在這些情況下,移動建構函式事實上不會被呼叫。
注意,即使返回值引用能夠用作任何一個函式引數的型別,對於實際使用也沒有什麼用,除了移動建構函式。返回值引用是十分危險的,不必要的使用可能會成為錯誤的源頭,而且十分難追蹤。
總結:使用場景:
如果第二個物件是在複製或賦值結束後被銷燬的臨時物件,則呼叫移動建構函式和移動賦值運算子,這樣的好處是避免深度複製,提高效率。
6 隱含成員的總結
下面是6種隱含成員出現的時機以及預設定義的總結
注意,在同一個類中並不是沒有的特殊成員都會被隱含定義。這主要是為相容C結構體和更早版本的C++,以及一些廢棄的類,而作的妥協。幸運的是,每個類可以顯式地選擇哪個成員使用它們預設的定義存在,或使用關鍵字default和delete進行選擇。語法如下:
function_declaration = default;
function_declaration = delete;
// 預設和刪除隱含成員
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
Rectangle (int x, int y) : width(x), height(y) {}
Rectangle() = default;
Rectangle (const Rectangle& other) = delete;
int area() {return width*height;}
};
int main () {
Rectangle foo;
Rectangle bar (10,20);
cout << "bar's area: " << bar.area() << '\n';
return 0;
}
上面的程式碼,Rectangle類既能夠使用兩個int型的引數,也能夠預設建構函式構建物件。但是沒有拷貝建構函式,因為它已經被delete。因此,對於上面例子中的物件,下面的宣告將是不正確的。
Rectangle baz (foo);
但是,可以通過定義它的拷貝建構函式顯式地使上面的語句合法:
Rectangle::Rectangle(const Rectangle& other)=default;
它在本質上等於下面的語句:
Rectangle::Rectangle (const Rectangle& other) : width(other.width), height(other.height) {}
注意,關鍵字default定義的成員函式不等於預設建構函式(例如,在這兒,預設建構函式意味著沒有引數),但是等於如果沒有被刪除的隱含定義的建構函式。
通常,為了未來的相容性,類將會顯式地定義一個拷貝/移動建構函式,或者拷貝/移動賦值操作,而不是兩個都定義。對於其它特殊成員函式,不想顯式定義的使用delete或default指定,這種做法就會被鼓勵。