C++類的拷貝控制

1,什麼是類的拷貝控制

當我們定義一個類的時候,爲了讓我們定義的類類型像內置類型(char,int,double等)一樣好用,我們通常需要考下面幾件事:

Q1:用這個類的對象去初始化另一個同類型的對象。

Q2:將這個類的對象賦值給另一個同類型的對象。

Q3:讓這個類的對象有生命週期,比如局部對象在代碼部結束的時候,需要銷燬這個對象。

因此C++就定義了5種拷貝控制操作,其中2個移動操作是C++11標準新加入的特性:

拷貝構造函數(copy constructor)

移動構造函數(move constructor)

拷貝賦值運算符(copy-assignment operator)

移動賦值運算符(move-assignment operator)

析構函數 (destructor)

前兩個構造函數發生在Q1時,中間兩個賦值運算符發生在Q2時,而析構函數則負責類對象的銷燬。

但是對初學者來說,既是福音也是災難的是,如果我們沒有在定義的類裏面定義這些控制操作符,編譯器會自動的爲我們合成一個版本。這有時候看起來是好事,但是編譯器不是萬能的,它的行爲在很多時候並不是我們想要的。

所以,在實現拷貝控制操作中,最困難的地方是認識到什麼時候需要定義這些操作。

2,拷貝構造函數

拷貝構造函數是構造函數之一,它的參數是自身類類型的引用,且如果有其他參數,則任何額外的參數都有默認值。

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類型)。

3,拷貝賦值運算符

拷貝賦值運算符是一個對賦值運算符的重載函數,它返回左側運算對象的引用。

class Foo

{

public:

Foo& operator=(const Foo&);

};

與拷貝構造函數一樣,如果沒有給類定義拷貝賦值運算符,編譯器將爲它合成一個。

4,析構函數

析構函數是由波浪線接類名構成,它沒有返回值,也不接受參數。因爲沒有參數,所以它不存在重載函數,也就是說一個類只有一個析構函數。

析構函數做的事情與構造函數相反,那麼我們先回憶一個構造函數都做了哪些事:

1,按成員定義的順序創建每個成員。

2,根據成員初始化列表初始化每個成員。

3,執行構造函數函數體。

而析構函數中不存在類似構造函數中初始化列表的東西來控制成員如何銷燬,析構部分是隱式的。成員如何銷燬依賴於成員自身的類型,如果是類類型則 調用本身的析構函數,如果是內置類型則會自動銷燬。而如果是一個指針,則需要手動的釋放指針指向的空間。與普通指針不同的是,智能指針是一個類,它有自己 的析構函數。

那麼什麼時候會調用析構函數呢?在對象銷燬的時候:

變量在離開其作用域時被銷燬;

當一個對象被銷燬時,其成員被銷燬。

容器被銷燬時,成員被銷燬。

對於動態分配的對象,當對指向它的指針應用delete運算符時被銷燬。

對於臨時對象,當創建它的賽事表達式結束時被銷燬。

值得注意的析構函數是自動運行的。析構函數的函數體並不直接銷燬成員,成員是在析構函數體之後隱含的析構階段中被銷燬的。在整個對象銷燬過程中,析構函數體是作爲成員銷燬步驟之外的另一部分而進行的。

5,定義拷貝控制操作的原則

在第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都指向無效內存

6,使用=default和=delete

我們可以使用=default來顯式地要求編譯器生成合成的版本。合成的函數將隱式地聲明爲內聯的,如果我們不希望合成的成員是內聯的,應該只對成員的類外定義使用=default.

有的時候我們定義的某些類不需要拷貝構造函數和拷貝賦值運算符,比如iostream類就阻止拷貝,以避免多個對象寫入或讀取相同的IO緩衝。

新的標準裏,我們可以在拷貝構造函數和拷貝賦值運算符函數的參數列表後面加上=delete用來指出我們希望將它定義爲刪除的,這樣的函數稱爲刪除函數。

class NoCopy

{

NoCopy() = default; // 使用合成的默認構造函數

NoCopy(const NoCopy&) = delete; // 刪除拷貝

NoCopy& operator=(const NoCopy&) = delete; // 刪除賦值

~NoCopy() = default; // 使用合成的析構函數

};

注意:析構函數不能是刪除的成員,因爲這樣的類是無法銷燬的。

如果一個類有const成員或者有引用成員,則這個類合成拷貝賦值運算符是被定義爲刪除的。

在新的標準出來之前,類是通過將其拷貝構造函數的拷貝賦值運算符聲明爲private來阻止拷貝,而且爲了防止成員被友元或其他成員訪問,會對這些成員函數只聲明,但不定義。
7,右值引用

所謂的右值引用就是必須綁定在右值上的引用,我們可以通過&&來獲得右值引用,右值引用一個很重要的性質是隻能綁定到一個將要銷燬的對象,所以我們可以自由地將一個右值引用的資源"移動"到另一個對象中。

我們可以將一個右值引用綁定到表達式上,但不能將右值引用綁定到一個左值上:

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聲明。

8,移動構造函數和移動賦值運算符

與拷貝一樣,移動操作同樣發生在我們一個類的對象去初始化或賦值同一個類類型的對象時,但是與拷貝不同的是,對象的內容實際上從源對象移動到了目標對象,而源對象丟失了內容。移動操作一般只發生在當這個源對象是一個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

#include

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;

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章