當我們定義一個類的時候,為了讓我們定義的類類型像內置類型(char,int,double等)一樣好用,我們通常需要考下面幾件事:
Q1:用這個類的對象去初始化另一個同類型的對象。
Q2:將這個類的對象賦值給另一個同類型的對象。
Q3:讓這個類的對象有生命周期,比如局部對象在代碼部結束的時候,需要銷毀這個對象。
因此C++就定義了5種拷貝控制操作,其中2個移動操作是C++11標准新加入的特性:
拷貝構造函數(copy constructor)
移動構造函數(move constructor)
拷貝賦值運算符(copy-assignment operator)
移動賦值運算符(move-assignment operator)
析構函數 (destructor)
前兩個構造函數發生在Q1時,中間兩個賦值運算符發生在Q2時,而析構函數則負責類對象的銷毀。
但是對初學者來說,既是福音也是災難的是,如果我們沒有在定義的類裡面定義這些控制操作符,編譯器會自動的為我們合成一個版本。這有時候看起來是好事,但是編譯器不是萬能的,它的行為在很多時候並不是我們想要的。
所以,在實現拷貝控制操作中,最困難的地方是認識到什麼時候需要定義這些操作。
拷貝構造函數是構造函數之一,它的參數是自身類類型的引用,且如果有其他參數,則任何額外的參數都有默認值。
class Foo{ public: Foo(); Foo(const Foo&); };
我們從上面代碼中可以注意到幾個問題:
1,我們把形參定義為const類型,雖然我們也可以定義非const的形參,但是這樣做基本上沒有意義的,因為函數的功能只涉及到成員的復制操作。
2,形參是本身類類型的引用,而且必須是引用類型。為什麼呢?
我們知道函數實參與形參之間的值傳遞,是通過拷貝完成的。那麼當我們將該類的對象傳遞給一個函數的形參時,會調用該類的拷貝構造函數,而拷貝構造函數本身也是一個函數,因為是值傳遞而不是引用,在調用它的時候也需要調用類的拷貝構造函數(它自身),這樣無限循環下去,無法完成。
3,拷貝構造函數通過不是explict的。
如果我們沒有定義拷貝構造函數,編譯器會為我們定義一個,這個函數會從給定的對象中依次將每個非static成員拷貝到正在創建的對象中。成員自身的類型決定了它是如何被拷貝的:類類型的成員,會使用其拷貝構造函數來拷貝;內置類型則直接拷貝;數組成員會逐元素地拷貝。
區分直接初始化與拷貝初始化:
string name("name_str"); //直接初始化 string name = string("name_str"); // 拷貝初始化 string name = "name_str"; // 拷貝初始化
直接初始化是要求編譯器使用普通的函數匹配來選擇與我們提供的參數最匹配的構造函數;當我們使用拷貝初始化時,我們要求編譯器將右側運算對象拷貝到正在創建的對象中,如果需要的話還要進行類型轉換(第三行代碼隱藏了一個C風格字符串轉換為string類型)。
拷貝賦值運算符是一個對賦值運算符的重載函數,它返回左側運算對象的引用。
class Foo { public: Foo& operator=(const Foo&); };
與拷貝構造函數一樣,如果沒有給類定義拷貝賦值運算符,編譯器將為它合成一個。
析構函數是由波浪線接類名構成,它沒有返回值,也不接受參數。因為沒有參數,所以它不存在重載函數,也就是說一個類只有一個析構函數。
析構函數做的事情與構造函數相反,那麼我們先回憶一個構造函數都做了哪些事:
1,按成員定義的順序創建每個成員。
2,根據成員初始化列表初始化每個成員。
3,執行構造函數函數體。
而析構函數中不存在類似構造函數中初始化列表的東西來控制成員如何銷毀,析構部分是隱式的。成員如何銷毀依賴於成員自身的類型,如果是類類型則調用本身的析構函數,如果是內置類型則會自動銷毀。而如果是一個指針,則需要手動的釋放指針指向的空間。與普通指針不同的是,智能指針是一個類,它有自己的析構函數。
那麼什麼時候會調用析構函數呢?在對象銷毀的時候:
- 變量在離開其作用域時被銷毀;
- 當一個對象被銷毀時,其成員被銷毀。
- 容器被銷毀時,成員被銷毀。
- 對於動態分配的對象,當對指向它的指針應用delete運算符時被銷毀。
- 對於臨時對象,當創建它的賽事表達式結束時被銷毀。
值得注意的析構函數是自動運行的。析構函數的函數體並不直接銷毀成員,成員是在析構函數體之後隱含的析構階段中被銷毀的。在整個對象銷毀過程中,析構函數體是作為成員銷毀步驟之外的另一部分而進行的。
在第1點裡有提過,在定義類的時候處理拷貝控制最困難的在於什麼時候需要自己定義,什麼時候讓編譯器自己合成。
那麼我們可以有下面2點原則:
如果一個類需要定義析構函數,那麼幾乎可以肯定它也需要一個拷貝構造函數和一個拷貝賦值函數,反過來不一定成立。
如果一個類需要一個拷貝構造函數,幾乎可以肯定它也需要一個拷貝賦值函數,反之亦然。
為什麼析構函數與拷貝構造函數與賦值函數關系這麼緊密呢,或者說為什麼我們在討論拷貝控制(5種)的時候要把析構函數一起放進來呢?
首先,我們思考什麼時候我們一定要自己來定義析構函數,比如:類裡面有動態分配內存。
class HasPtr { public: HasPtr(const string&s = string()) :ps(new string(s), i(0)){} ~HasPtr(){ delete ps; } private: int i; string* ps; };
我們知道如果是編譯器自動合成的析構函數,則不會去delete指針變量的,所以ps指向的內存將無法釋放,所以一個主動定義的析構函數是需要的。那麼如果沒有給這個類定義拷貝構造函數和拷貝賦值函數,將會怎麼樣?
編譯器自動合成的版本,將簡單的拷貝指針成員,這意味著多個HasPtr對象可能指向相同的內存。
HasPtr p("some values"); f(p); // 當f結束時,p.ps指向的內存被釋放 HasPtr q(p);// 現在p和q都指向無效內存
我們可以使用=default來顯式地要求編譯器生成合成的版本。合成的函數將隱式地聲明為內聯的,如果我們不希望合成的成員是內聯的,應該只對成員的類外定義使用=default。
有的時候我們定義的某些類不需要拷貝構造函數和拷貝賦值運算符,比如iostream類就阻止拷貝,以避免多個對象寫入或讀取相同的IO緩沖。
新的標准裡,我們可以在拷貝構造函數和拷貝賦值運算符函數的參數列表後面加上=delete用來指出我們希望將它定義為刪除的,這樣的函數稱為刪除函數。
class NoCopy { NoCopy() = default; // 使用合成的默認構造函數 NoCopy(const NoCopy&) = delete; // 刪除拷貝 NoCopy& operator=(const NoCopy&) = delete; // 刪除賦值 ~NoCopy() = default; // 使用合成的析構函數 };
注意:析構函數不能是刪除的成員,因為這樣的類是無法銷毀的。
如果一個類有const成員或者有引用成員,則這個類合成拷貝賦值運算符是被定義為刪除的。
在新的標准出來之前,類是通過將其拷貝構造函數的拷貝賦值運算符聲明為private來阻止拷貝,而且為了防止成員被友元或其他成員訪問,會對這些成員函數只聲明,但不定義。
所謂的右值引用就是必須綁定在右值上的引用,我們可以通過&&來獲得右值引用,右值引用一個很重要的性質是只能綁定到一個將要銷毀的對象,所以我們可以自由地將一個右值引用的資源“移動”到另一個對象中。
我們可以將一個右值引用綁定到表達式上,但不能將右值引用綁定到一個左值上:
int i = 42; int &r = i; // 正確:r引用i int &&rr = i; // 錯誤:不能將一個右值引用綁定到一個左值上 int &r2 = i * 42; // i*42是一具右值 const int& r3 = i * 42; // 可以將一個const的引用綁定到一個右值上 int && rr2 = i * 42; // 正確:將rr2綁定到乘法結果上
總體來說:左值有持久的狀態,而右值要麼是字面常量,要麼是表達式求值過程中創建的臨時對象。
從而我們得知,關於右值引用:1)所引用的對象將要銷毀;2)該對象沒有其他用戶。
標准庫提供了一個std::move函數,讓我們可以獲得左值上的右值引用:
int &&r3 = std::move(rr1); // rr1是一個變量
move調用告訴編譯器:我們有一個左值,但是我們希望像一個右值一個處理它。在上面的代碼後,要麼銷毀rr1,要麼對rr1進行賦值,否則我們不能使用rr1。
另外一點值得注意的是,我們使用std::move而不是move,即使我們提供了using聲明。
與拷貝一樣,移動操作同樣發生在我們一個類的對象去初始化或賦值同一個類類型的對象時,但是與拷貝不同的是,對象的內容實際上從源對象移動到了目標對象,而源對象丟失了內容。移動操作一般只發生在當這個源對象是一個uname的對象的時候。
一個uname object意思是一個臨時對象,還沒有被賦予一個名字,例如一個返回該類型的函數返回值或者一個類型轉換操作返回的對象。
MyClass fn(); // function returning a MyClass object MyClass foo; // default constructor MyClass bar = foo; // copy constructor MyClass baz = fn(); // move constructor foo = bar; // copy assignment baz = MyClass(); // move assignment
上面的代碼中由fn()返回的對象和由MyClass構造出來的對象都是unnamed,用這樣的對象給MyClass賦值或初始化時,並不需要拷貝,因為源對象只有很短的生命周期。
移動構造函數與移動賦值函數的定義形式上與拷貝操作一樣,只是將拷貝函數的形參的引用換成右值引用。
MyClass (MyClass&&); // move-constructor MyClass& operator= (MyClass&&); // move-assignment
移動操作對那些需要管理存儲空間的類是非常有用的,比如我們下面定義的這個類
// move constructor/assignment #include <iostream> #include <string> using namespace std; class Example6 { string* ptr; public: Example6 (const string& str) : ptr(new string(str)) {} ~Example6 () {delete ptr;} // move constructor 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"); // move-construction foo = foo + bar; // move-assignment cout << "foo's content: " << foo.content() << '\n'; return 0; }