c++:類拷貝控制 - 拷貝建構函式 & 拷貝賦值運算子
一、拷貝控制
當定義一個類時,我們可以顯式或隱式地指定此型別的物件拷貝、移動、賦值和銷燬時做什麼。
一個類可以通過定義五種特殊的成員函式來控制這些操作,包括:++拷貝建構函式++、++拷貝賦值函式++、++移動建構函式++、++移動複製函式++和++解構函式++。我們稱這些操作為拷貝控制操作。
- 拷貝建構函式和移動建構函式定義了當用同類型的另一個物件初始化本物件時做什麼。
- 拷貝賦值運算子和移動賦值運算子定義了將一個物件賦予同類型的另一個物件時做什麼。
- 解構函式定義了當此型別物件銷燬時做什麼。
class Foo {
Foo()
Foo(const Foo&); // 拷貝建構函式
Foo(const Foo&&); // 移動建構函式
Foo& operator=(const Foo&); // 拷貝賦值運算子
Foo& operator=(const Foo&&); // 移動賦值運算子
~Foo(); // 解構函式
...
}
如果一個類沒有定義所有這個拷貝控制成員,編譯器會自動為它定義缺失的操作。
本篇主要介紹最基本的《拷貝建構函式》和《拷貝賦值運算子》及其使用過程中需要注意的地方。
二、拷貝建構函式
2.1、什麼是拷貝建構函式?
==定義:如果一個建構函式的第一個引數是自身類型別的引用,且任何額外引數都有預設值,則此建構函式是拷貝建構函式。==
class Foo {
public:
Foo(); // 預設建構函式
Foo(const Foo&) // 拷貝建構函式
}
拷貝建構函式用同類型的另一個物件初始化本物件,完成從物件之間的 複製過程;
2.2、合成拷貝建構函式
如果我們沒有為一個類定義拷貝建構函式,編譯器會為我們預設定義一個。
通常稱預設的拷貝建構函式稱為:合成拷貝建構函式。也可以按我們的習慣稱之為預設拷貝建構函式。
合成的拷貝建構函式會從給定物件中依次將每個非 static 成員拷貝到正在建立的物件中。
每個成員的型別決定了它如何拷貝:
1. 對類型別的成員,會使用其拷貝建構函式來拷貝
2. 其他內建型別成員直接拷貝
以一個例子進行說明:
class Foo {
public:
Foo(); // 預設建構函式
Foo(const Foo&) // 拷貝建構函式
private:
std::string str;
int num;
}
// 與 Foo 的合成拷貝函式等價:
Foo::Foo(const Foo &foo) {
this->str = foo.str; // 使用 string 的拷貝建構函式
this->num = foo.num; // 直接拷貝 foo.num
}
–> 一定需要重寫拷貝建構函式的情況
成員型別的拷貝需要特別強調一點:預設的合成建構函式對於指標型別,使用的是位拷貝!
位拷貝拷貝地址,值拷貝拷貝內容
試想一下,當成員的型別包含指標的情況:
class Foo {
public:
Foo(); // 預設建構函式
Foo(const Foo&) // 拷貝建構函式
char *data;
}
定義 Foo 物件 A 與 B,此時 A.data 與 B.data 分別指向一段記憶體區域,進行如下賦值操作:
Foo A;
A.data = "hello";
Foo B(A); // 拷貝建構函式
B.data = "world"; // B.data = A.data = "world"
Foo B(A) 呼叫預設拷貝建構函式,對於指標型別,編譯器會預設進行位拷貝(也就是淺拷貝),拷貝指標的地址 —— B.data = A.data,這樣 A.data 與 B.data 就指向了同一塊記憶體區域,因此 A.data 的內容也變成 world。
這樣可能導致的問題:
1. A.data 和 B.data 指向同一塊區域,任何一方改變都會影響另一方。
2. 當物件析構時,B.data 被釋放兩次
因此,當類成員包含指標型別,一定要重寫拷貝建構函式或者拷貝賦值函式,對指標型別自定義實現值拷貝:
Foo::Foo(const Foo &foo) {
data = new char(sizeof(foo.data));
memcpy(data, B.data, sizeof(B.data));
}
2.3、拷貝建構函式的呼叫時機
(1) 物件以值傳遞的方式傳入函式引數
class Foo
{
public:
Foo(int a);
Foo(const Foo&);
~Foo();
int a;
};
// 建構函式
Foo::Foo(int a)
{
this->a = a;
std::cout << "-> create" << std::endl;
}
// 解構函式
Foo::~Foo()
{
std::cout << "-> delete" << std::endl;
}
// 拷貝建構函式
Foo::Foo(const Foo &foo)
{
this->a = foo.a;
std::cout << "-> copy" << std::endl;
}
void g_fun(Foo foo)
{
}
int main()
{
Foo foo(1);
g_fun(foo);
return 0;
}
輸出如下:
-> create
-> copy
-> delete
當呼叫 g_fun 函式,編譯器會偷偷做比較重要的幾個步驟:
1. foo 物件傳入函式時,產生一個臨時變數 C
2. 呼叫拷貝建構函式使用 foo 物件初始化 C
3. 等 g_fun 方法執行完成後,析構掉 C
(2) 物件以值傳遞的方式從函式返回
class Foo
{
public:
Foo(int a);
Foo(const Foo&);
~Foo();
int a;
};
// 建構函式
Foo::Foo(int a)
{
this->a = a;
std::cout << "-> create" << std::endl;
}
// 解構函式
Foo::~Foo()
{
std::cout << "-> delete" << std::endl;
}
// 拷貝建構函式
Foo::Foo(const Foo &foo)
{
this->a = foo.a;
std::cout << "-> copy" << std::endl;
}
foo g_fun()
{
Foo foo(1);
return foo;
}
int main()
{
g_fun();
return 0;
}
當 g_Fun() 函式執行到 return 時,會產生以下幾個重要步驟:
1. 生成臨時變數 C
2. 呼叫拷貝建構函式使用 foo 物件初始化 C
3. 函式執行到最後析構區域性變數 foo
4. 函式呼叫結束析構臨時變數 C
(3) 物件需要通過另外一個物件進行初始化
Foo foo(1); // 直接初始化
Foo foo1(foo); // 拷貝建構函式
Foo foo2 = foo; // 拷貝建構函式
(4) 標準庫容器
特別的,c++ 標準庫容器會對它們所分配的物件進行拷貝初始化,如:對容器類呼叫其 insert 或 push 成員時,對其元素進行拷貝初始化。而用 emplace 成員建立的函式都是進行直接初始化。
容器類應儘可能使用 emplace
容器類呼叫 insert 或 push 成員插入元素,會涉及到兩次建構函式的呼叫,一是初始化物件時,二是插入時觸發拷貝構造。這樣會造成不必要的資源浪費。
c++11 標準中引入了 emplace,如 vector 容器的 emplace、emplace_back,類似於 insert,但是由於直接初始化,只需構造一次就可以了。
2.4 阻止拷貝建構函式發生
大多數類應該定義拷貝建構函式合拷貝賦值運算子,但對於某些類,這些操作沒有合理的意義。在此情況下必須採取某種機制阻止拷貝或賦值。如,iostream 類阻止了拷貝,以避免多個物件寫入或讀取相同的 IO 緩衝。
在 c++11 新標準下,可以通過將拷貝建構函式和拷貝賦值運算子定義為 delete 來阻止拷貝。如:
class Foo
{
public:
Foo(int a);
Foo(const Foo&) = delete;
Foo& operator=(const Foo&) = delete;
};
= delete 通知編譯器該控制成員是刪除的成員,我們不希望定義這些成員。
解構函式不能是刪除的成員,我們不能刪除解構函式
三、拷貝賦值運算子
區分 拷貝建構函式 & 拷貝賦值運算子
拷貝建構函式和賦值運算子的行為比較相似,都是將一個物件的值複製給另一個物件;但是其結果卻有些不同,拷貝建構函式使用傳入物件的值生成一個新的物件的例項,而賦值運算子是將物件的值複製給一個已經存在的例項。
這種區別從兩者的名字也可以很輕易的分辨出來,拷貝建構函式也是一種建構函式,那麼它的功能就是建立一個新的物件例項;賦值運算子是執行某種運算,將一個物件的值複製給另一個物件(已經存在的)。
呼叫的是拷貝建構函式還是賦值運算子,主要是看是否有新的物件例項產生。==如果產生了新的物件例項,那呼叫的就是拷貝建構函式;如果沒有,那就是對已有的物件賦值,呼叫的是賦值運算子。==
Foo foo(10);
Foo foo1(20);
foo = foo1; // 拷貝賦值運算子
Foo foo2(foo); // 拷貝建構函式
過載賦值運算子
拷貝賦值運算子基於過載運算子,本質上也是函式,其名字由 operator 關鍵字後接要定義的運算子的符號組成。
為了與內建型別的賦值保持一致,賦值運算子通常返回一個指向其左側運算物件的引用。
Foo& operator=(const Foo&);
合成拷貝賦值運算子
與預設的 合成建構函式 一樣,如果一個類未定義自己的拷貝賦值運算子,編譯器會預設生成一個 合成拷貝賦值運算子。
合成拷貝賦值運算子會將右側物件的每個非 static 成員賦予左側運算物件的對應成員,這一工作是通過成員型別的拷貝賦值運算子完成的。對於陣列型別成員,逐個賦值陣列元素。
// 與 Foo 的拷貝賦值運算子等價:
Foo& Foo::operator=(const Foo &foo) {
this->str = foo.str; // 使用 string::operator=
this->num = foo.num; // 直接內建的 int 賦值
}