爲什麼很多人禁用拷貝(複製)構造函數

關於C++的拷貝構造函數,很多的建議是直接禁用。爲什麼大家會這麼建議呢?沒有拷貝構 造函數會有什麼限制呢?如何禁用拷貝構造呢?這篇文章對這些問題做一個簡單的總結。

這裏討論的問題以拷貝構造函數爲例子,但是通常賦值操作符是通過拷貝構造函數來實現 的( copy-and-swap 技術,詳見《Exceptional C++》一書),所以這裏討論也適用於賦 值操作符,通常來說禁用拷貝構造函數的同時也會禁用賦值操作符。

爲什麼禁用拷貝構造函數

關於拷貝構造函數的禁用原因,我目前瞭解的主要是兩個原因。第一是淺拷貝問題,第二 個則是基類拷貝問題。

淺拷貝問題

編譯器默認生成的構造函數,是memberwise拷貝^1,也就是逐個拷貝成員變量,對於 下面這個類的定義^2

 
class Widget {
 public:
    Widget(const std::string &name) : name_(name), buf_(new char[10]) {}
    ~Widget() { delete buf_; }

 private:
    std::string name_;
    char *buf_;
};

默認生成的拷貝構造函數,會直接拷貝buf_的值,導致兩個Widget對象指向同一個緩 衝區,這會導致析構的時候兩次刪除同一片區域的問題(這個問題又叫雙殺問題)。

解決這個問題的方式有很多:

  1. 自己編寫拷貝構造函數,然後在拷貝構造函數中創建新的buf_,不過拷貝構造函數的 編寫需要考慮異常安全的問題,所以編寫起來有一定的難度。

  2. 使用 shared_ptr 這樣的智能指針,讓所有的 Widget 對象共享一片 buf_,並 讓 shared_ptr 的引用計數機制幫你智能的處理刪除問題。

  3. 禁用拷貝構造函數和賦值操作符。如果你根本沒有打算讓Widget支持拷貝,你完全可 以直接禁用這兩操作,這樣一來,前面提到的這些問題就都不是問題了。

基類拷貝構造問題

如果我們不去自己編寫拷貝構造函數,編譯器默認生成的版本會自動調用基類的拷貝構造 函數完成基類的拷貝:

 
class Base {
 public:
    Base() { cout << "Base Default Constructor" << endl; }
    Base(const Base &) { cout << "Base Copy Constructor" << endl; }
};

class Drived : public Base {
 public:
    Drived() { cout << "Drived Default Constructor" << endl; }
};

int main(void) {
    Drived d1;
    Drived d2(d1);
}

上面這段代碼的輸出如下:

 
Base Default Constructor
Drived Default Constructor

Base Copy Constructor  // 自動調用了基類的拷貝構造函數

但是如果我們出於某種原因編寫了,自己編寫了拷貝構造函數(比如因爲上文中提到的淺 拷貝問題),編譯器不會幫我們安插基類的拷貝構造函數,它只會在必要的時候幫我們安 插基類的默認構造函數:

 
class Base {
 public:
    Base() { cout << "Base Default Constructor" << endl; }
    Base(const Base &) { cout << "Base Copy Constructor" << endl; }
};

class Drived : public Base {
 public:
    Drived() { cout << "Drived Default Constructor" << endl; }
    Drived(const Drived& d) {
    	cout << "Drived Copy Constructor" << endl;
    }
};

int main(void) {
    Drived d1;
    Drived d2(d1);
}

上面這段代碼的輸出如下:

1
2
3
4
5
Base Default Constructor
Drived Default Constructor

Base Default Constructor // 調用了基類的默認構造函數
Drived Copy Constructor

這當然不是我們想要看到的結果,爲了能夠得到正確的結果,我們需要自己手動調用基類 的對應版本拷貝基類對象。

 
Drived(const Drived& d) : Base(d) {
    cout << "Drived Copy Constructor" << endl;
}

這本來不是什麼問題,只不過有些人編寫拷貝構造函數的時候會忘記這一點,所以導致基 類子對象沒有正常複製,造成很難察覺的BUG。所以爲了一勞永逸的解決這些蛋疼的問題, 乾脆就直接禁用拷貝構造和賦值操作符。

沒有拷貝構造的限制

在C++11之前對象必須有正常的拷貝語義才能放入容器中,禁用拷貝構造的對象無法直接放 入容器中,當然你可以使用指針來規避這一點,但是你又落入了自己管理指針的困境之中 (或許使用智能指針可以緩解這一問題)。

C++11中存在移動語義,你可以通過移動而不是拷貝把數據放入容器中。

拷貝構造函數的另一個應用在於設計模式中的原型模式,在C++中沒有拷貝構造函數,這 個模式實現可能比較困難。

如何禁用拷貝構造

  1. 如果你的編譯器支持 C++11,直接使用 delete

  2. 否則你可以把拷貝構造函數和賦值操作符聲明成private同時不提供實現。

  3. 你可以通過一個基類來封裝第二步,因爲默認生成的拷貝構造函數會自動調用基類的拷 貝構造函數,如果基類的拷貝構造函數是 private,那麼它無法訪問,也就無法正常 生成拷貝構造函數。

     
    class NonCopyable {
    protected:
        ~NonCopyable() {}  // 關於爲什麼聲明成爲 protected,參考
        		       // 《Exceptional C++ Style》
    private:
        NonCopyable(const NonCopyable&);
    }
    
    class Widget : private NonCopyable { // 關於爲什麼使用 private 繼承
    				     // 參考《Effective C++》第三版
    }
    
    Widget widget(Widget()); // 錯誤
    

上不會生成memberwise的拷貝構造函數,詳細內容可以參考《深度探索C++對象模型》一 書

 

禁用拷貝
禁用原因主要是兩個:

1. 淺拷貝問題,也就是上面提到的二次析構。
2. 自定義了基類和派生類的拷貝構造函數,但派生類對象拷貝時,調用了派生類的拷貝,沒有調用自定義的基類拷貝而是調用默認的基類拷貝。這樣可能造成不安全,比如出現二次析構問題時,因爲不會調用我們自定義的基類深拷貝,還是默認的淺拷貝。

Effective C++條款6規定,如果不想用編譯器自動生成的函數,就應該明確拒絕。方法一般有三種:
1. C++11對函數聲明加delete關鍵字:Base(const Base& obj) = delete;,不必有函數體,這時再調用拷貝構造會報錯嘗試引用已刪除的函數。
2. 最簡單的方法是將拷貝構造函數聲明爲private
3. 條款6給出了更好的處理方法:創建一個基類,聲明拷貝構造函數,但訪問權限是private,使用的類都繼承自這個基類。默認拷貝構造函數會自動調用基類的拷貝構造函數,而基類的拷貝構造函數是private,那麼它無法訪問,也就無法正常生成拷貝構造函數。

Qt就是這樣做的,QObject定義中有這樣一段,三條都利用了:

 

第一種方法:最簡單的方法是將拷貝構造函數聲明爲private

private:
    Q_DISABLE_COPY(QMainWindow)

#define Q_DISABLE_COPY(Class) \
    Class(const Class &) Q_DECL_EQ_DELETE;\
    Class &operator=(const Class &) Q_DECL_EQ_DELETE;

類的不可拷貝特性是可以繼承的,例如凡是繼承自QObject的類都不能使用拷貝構造函數和賦值運算符。
 

(2)第二種方法 繼承一個uncopyable類
C++的編譯在鏈接之前,如果我們能在編譯期解決這個問題,會節省不少的時間,要想在編譯期解決問題,就需要人爲製造一些bug。我們聲明一個專門阻止拷貝的基類uncopyable。

class uncopyable{
protected:
    uncopyable(){}
    ~uncopyable(){}
private:
    uncopyable(const uncopyable&);
    uncopyable& operator=(const uncopyable&);
}

接下來,我們的類只要繼承uncopyable,如果要發生拷貝,編譯器都會嘗試調用基類的拷貝構造函數或者賦值運算符,但是因爲這兩者是私有的,會出現編譯錯誤。
 

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