天天看點

Big-Five: 析構函數,拷貝構造函數,移動構造函數,拷貝指派操作和移動指派操作

簡介

在C++11裡,類有五個已經為你寫好的特殊函數。它們是析構函數,拷貝構造函數,移動構造函數,拷貝指派操作和移動指派操作函數。這些就是Big-Five。在許多情況下,你可以接受編譯器為五大操作提供的預設函數。但是,有時你不能這樣做。

析構函數(Destructor)

當一個對象離開作用域或者遭到删除時,析構函數被調用。通常,析構函數的唯一職責就是釋放在使用對象期間擷取的任何資源。這包括通過調用delete 來釋放任何與之相應的通過new 操作申請的記憶體,關閉任何打開的檔案等等。預設情況下簡單地對每個資料成員調用析構函數。

拷貝構造函數和移動構造函數(Copy Constructor and Move Constructor)

有兩個特殊構造函數需要構造一個新對象,該對象初始化為同一類型的另一個對象的同一狀态。一個很好的辦法來判斷到底是拷貝構造函數還是移動構造函數就是通過判斷對象是左值還是右值。如果現有的對象是左值(lvalue),那麼是拷貝構造函數,如果現有的對象是右值(rvalue)(例如,即将被摧毀的臨時對象),那麼是移動構造函數。

對于任何對象,例如IntCell 類,一個拷貝構造函數和移動構造函數可以被這樣調用:

  • 一個初始化聲明,例如
IntCell B = C;          // 如果C是左值,那麼是拷貝構造函數;如果C是右值,那麼是移動構造函數
IntCell B { C };        //  如果C是左值,那麼是拷貝構造函數;如果C是右值,那麼是移動構造函數
           

需要注意的是

  • 一個使用按值傳遞(而不是通過& 或者const & )的對象,如前所述,很少這樣做。
  • 一個通過值傳回的對象(而不是通過& 或者const & )。同樣,如果被傳回的對象是一個左值,則調用拷貝構造函數,如果正在傳回的對象是一個右值,則調用一個移動構造函數。

預設情況下,拷貝構造函數的實作,通過依次對其中每個資料成員調用其自身的拷貝構造函數來實作。對于初級的資料成員(例如,整形(int), 浮點型(double)或者指針(pointer)),簡單的指派就可以完成。在IntCell 類中,我們将看到簡單的實作。對于本身就是類對象的資料成員,資料成員适當的調用自身的構造函數來實作。

拷貝指派操作和移動指派操作(operator =)

當 “=”應用在兩個先前已被構造好的對象時,将調用指派運算符(=)。lhs = rhs 旨在将rhs 的狀态指派到lhs 中。如果rhs 是一個左值,這将通過拷貝指派操作完成,如果”rhs” 是一個右值,這将通過移動指派操作完成。預設情況下,通過依次向每個資料成員應用拷貝指派操作符來實作拷貝指派操作符

預設操作

如果我們檢查IntCell類,我們看到預設值是完全可以接受的,是以我們不必做任何事情。 這通常是這樣的。 如果一個類完全由初始資料成員組成,預設值通常是有意義的。 是以,其資料成員為

int

vector<int>

string

vector<string>

的類都可以接受預設值。

主要的問題發生在包含指針作為資料成員的類中。假設類中包含一個指針的資料成員,這個指針指向一個動态配置設定的對象。此時,預設的析構函數不會對這個指針做任何事情(我們必須統購調用delete來删除指針)。此外,拷貝構造函數和拷貝指派運算符都複制指針的值,而不是指向的對象。是以,我們将有兩個類執行個體包含指向同一個對象的指針。 這是所謂的淺拷貝。通常,我們期待的是一個深拷貝(克隆整個對象)。是以,當一個類中包含指針作為資料成員,深層的語義是重要的,我們必須重構析構函數,拷貝構造函數和拷貝指派操作來取消預設由編譯器自帶的預設操作。通常來說,要麼接受預設的五個操作,要麼重新定義五個操作。

對于IntCell ,這些預設的操作為:

~IntCell();                                                    // 析構函數 
IntCell(const IntCell& rhs);                         // 拷貝構造函數
IntCell(IntCell&& rhs);                             // 移動構造函數
IntCell& operator=(const IntCell& rhs);  // 拷貝指派操作
IntCell& operator=(IntCell&& rhs);          // 移動指派操作
           

operator=傳回類型為引用的原因是為了允許鍊式指派,例如a=b=c。雖然看起來傳回值類型應該為const引用,以防止(a=b)=c這種操作。但是在C++中,這種表達式實際上是允許的。 是以,通常使用引用傳回類型(而不是const引用傳回類型),但是從文法上說是不嚴謹的。

當我們定義這些函數時,如果還想使用編譯器自帶的預設函數,我們可以使用

= default

,例如:

~IntCell() { cout << "Invoking destructor" << endl; }  // 析構函數 
IntCell(const IntCell& rhs) = default;                           // 拷貝構造函數
IntCell(IntCell&& rhs) = default;                                   // 移動構造函數
IntCell& operator=(const IntCell& rhs) = default;       // 拷貝指派操作
IntCell& operator=(IntCell&& rhs) = default;               // 移動指派操作
           

或者,我們可以通過

= delete

來禁止預設操縱,例如:

~IntCell() { cout << "Invoking destructor" << endl; }  // 析構函數 
IntCell(const IntCell& rhs) = delete;                            // 拷貝構造函數
IntCell(IntCell&& rhs) = delete;                                    // 移動構造函數
IntCell& operator=(const IntCell& rhs) = delete;        // 拷貝指派操作
IntCell& operator=(IntCell&& rhs) = delete;            // 移動指派操作
           

如果預設操作是有意義的,那麼我們可以使用預設的,如果是沒有意義的,我們需要重寫這些操作。

重寫Big-Five

在大多是時候,這些預設操作都不足以滿足我們的需求,這時候我們需要重寫這些操作。預設函數不工作的最常見情況發生在資料成員是指針類型,并且指針由某個對象成員函數(例如構造函數)配置設定時。例如我們通過動态配置設定一個int 類型的時候,如下,我們定義一個IntCell類:

class IntCell
{
public:
    explicit IntCell(int initialValue = )
    {
        storedValue = new int{initialValue};
    }

    int read() const
    {
        return *storedValue;    
    }

    void write(int x)
    {
        *storedValue = x;   
    }

private:
    int* storedValue;
};
           

當我們定義如上的IntCell類時,使用了預設的操作,其中會有很多問題發生。例如我們如下使用:

int f()
{
    IntCell a{};
    IntCell b = a;
    IntCell c;

    c = a;
    a.write();
    cout << a.read() << endl;           // 4
    cout << b.read() << endl;           // 4
    cout << c.read() << endl;           // 4

    return ;
}
           

此段代碼輸出為3個4,而我們的本意是隻将a改為4,這個問題就是使用了預設的拷貝構造函數(Copy Constructor)和拷貝指派操作(Copy assignment operator=)來拷貝這個

storedValue

指針。是以,

a.storedValue

b.storedValue

c.storedValue

都是指向相同的int 值。這種拷貝就是我們所說的淺拷貝,指針而不是指針被複制。

第二,很容易忽略的是記憶體洩漏。當我們使用完這個指針變量,離開 f() 函數的作用域,我們不再使用指針了,由于預設析構函數不會自動調用

delete

來删除

storedValue

指針,是以存在記憶體洩露的隐患。

綜合這些問題,我們重新定義這5個函數。代碼如下所示。正如我們所見,一旦析構函數被實作,淺複制将導緻一個編譯錯誤:兩個對象的

storedValue

指針指向相同的int 對象,一旦第一個IntCell對象被析構,

storedValue

指針将回收,此時,如果第二個IntCell對象析構,

storedValue

指針被删除兩次,這是将導緻嚴重的錯誤。

這就是為什麼C++11棄用了先前的預設拷貝操作行為,即是析構函數被重寫了。

class IntCell
{
public:
    explicit IntCell(int initialValue = ) 
    {
        storedValue = new int{initialValue};
    }

    ~IntCell()
    {
        delete storedValue;
    }

    IntCell(const IntCell& rhs)
    {
        storedValue = new int{*rhs.storedValue};
    }

    IntCell(IntCell&& rhs) : storedValue(rhs.storedValue)
    {
        rhs.storedValue = nullptr;
    }

    IntCell& operator=(const IntCell& rhs)
    {
        if(this != &rhs) {
            *storedValue = *rhs.storedValue;
        }
        return *this;
    }

    IntCell& operator=(IntCell&& rhs)
    {
        std::swap(storedValue, rhs.storedValue);
        return *this;
    }

    int read() const
    {
        return *storedValue;
    }

    void write(int x)
    {
        *storedValue = x;
    }

private:
    int* storedValue;
};
           

在C++11中,在拷貝指派操作通常使用copy-and-swap慣用法實作。

在第19行和第22行的移動構造函數将資料表示形式從rhs移動到*this; 那麼它将rhs的原始資料(包括指針)設定為有效但易于銷毀的狀态。注意,如果存在非原始資料,則該資料必須在初始化清單中移動。 例如,如果有

vector<string>

項,那麼構造函數将是:

IntCell(IntCell&& rhs)
    : storedValue(rhs.storedValue), 
      items(std::move(rhs.items))       // 通過std::move将rhs的vector<string>項移動到items中
{
    rhs.storedValue = nullptr;  
}
           

在最後,移動指派操作通過成員與成員之間的swap操作進行交換。注意,有時它被實作為與複制指派運算符相同的單一交換對象,但是隻有當交換自身實作為逐個成員交換時才起作用。如果交換被實作為三個移動,那麼我們将有互相的非終止遞歸。

此時再次調用f(),将會輸出

4

2

2