Effective Modern C++ 條款17 理解特殊成員函數的生成

理解特殊成員函數的生成

在C++的官方說法中,有一條是C++願意自己生成特殊成員函數(special member functions)。在C++98,特殊成員函數有四個:默認構造函數,析構函數,拷貝構造函數,拷貝賦值運算符。這四個函數只有當它們被需要時纔會自動生成,也就是一些代碼使用了這些函數,但是用戶類中沒有聲明它們。默認構造函數只有在類中沒有聲明一個構造函數時才生成。(當你聲明瞭帶參的構造函數時,這樣可以防止編譯器創建默認構造。)生成的特殊成員函數是隱式publicinline的,它們不是虛函數,除了一種例外:某個類繼承了析構函數是虛函數的基類,那麼這個類的析構函數是虛函數。

不過你已經知道這些東西了。是的,這已經有很悠久的歷史了:Mesopotamia,the Shang dynasty,,FORTANT,C++98。不過時代變了,C++特殊成員函數產生的規則變了。理解新規則是很重要的,因爲這些東西知道編譯器什麼時候會在類中默默插入函數。

在C++11,特殊成員函數多了兩個:移動構造函數和移動賦值運算符。它們的前面是這樣的:

class Widget {
public:
    ...
    Widget(Widget&& rhs);   // 移動構造函數
                       `
    Widget& operator=(Widget&& rhs);   // 移動賦值運算符
    ..
};

它們的產生和行爲與拷貝相像。移動操作會在需要它們時產生,它們表現的行爲是把類中non-static成員變量“逐一移動”(memberwise move)。那意味着移動構造函數會以rhs類的每一個non-static成員變量作爲參數進行移動構造,移動賦值操作符會以每一個non-static成員變量作爲參數進行移動賦值。移動構造函數還會移動構造基類的部分(如果有的話),移動賦值操作符也移動基類的部分。

現在我想說,對於一個成員變量或者基類的移動操作,不敢保證移動真的會發生。實際上呢,“逐一移動”(memberwise move)更像是請求逐一移動,那些不是能夠移動(move-enable)的類型(即一些不支持移動操作的類型,例如,大部分C++98的類)會藉助它們的拷貝操作進行“移動”。每個逐一“移動”的內部都使用了std::move來移動需要移動的對象,結果呢,通過重載函數決策來決定std::move表示爲拷貝還是移動。條款23會詳解這個過程,在本條款呢,只需簡單地記住:在移動操作中,如果成員變量和基類支持移動操作,那麼就逐一移動它們,否則拷貝它們。

與拷貝操作一樣,如果你聲明瞭移動操作,編譯器就不會幫你生成了。但是呢,它們的規則與拷貝操作又有點區別。

類中的兩個拷貝操作是獨立的:聲明瞭其中一個不會阻止編譯器生成另一個。所以,如果你聲明瞭拷貝構造函數,但沒有聲明拷貝賦值運算符,然後寫代碼的時候需要拷貝賦值運算符,那麼編譯器會爲你生成一個拷貝賦值運算符。同樣地,如果你聲明瞭拷貝移動運算符,沒有聲明拷貝構造函數,然後你的代碼使用到拷貝構造函數,編譯器會爲你生成拷貝構造函數。這在C++98中是正確的,在C++11依然正確。

類中的兩個移動操作不是獨立的。如果你聲明瞭其中一個,那會阻止編譯器生成另一個。這裏的根據是:如果你聲明瞭一個移動構造函數,暗示着你的移動構造函數實現與編譯器產生的默認逐一移動實現不同,那麼如果逐一移動的構造函數是有問題的,那麼逐一移動的賦值運算可能也有問題。所以聲明瞭移動構造函數會阻止移動賦值運算符的生成,聲明移動賦值也會阻止移動構造的生成。

而且,顯式聲明拷貝操作的類不能生成移動操作。正當的理由是:聲明瞭拷貝操作(構造或賦值)暗示着正常的拷貝對象的方法(成員逐一拷貝)是不適合這個類的,然後編譯器認爲如果成員逐一拷貝不適合操作操作,成員逐一移動可能也不會適合移動操作。

反過來說吧。在類中聲明一個移動操作(構造或賦值)會導致拷貝操作無法生成(拷貝操作會被delete,看條款11)。歸根到底,如果成員逐一移動不是對象移動的合適方式,那麼沒有理由相信成員逐一拷貝是拷貝對象的合適方式。聽起來這會破壞C++98的代碼,因爲C++11中使能拷貝操作的條件比C++98要苛刻,但並非如此。C++98沒有移動操作,所以C++98中沒有可移動對象。舊代碼想要擁有用戶聲明的移動操作的唯一辦法就是在C++11中添加它們,即爲了使用移動語義,修改舊的類,那麼這個類必須服從C++11的特殊成員函數生成的規則。

你可能聽過三大法則的指導方針。三大法則規定:如果你聲明瞭拷貝構造、拷貝複製、析構函數中的其中一個,你應該把這三個都聲明。這是從觀察中得到的:需要自定義拷貝構造通常是由於某種資源管理,這幾乎暗示着(1)一個拷貝操作進行的資源管理操作在另一個拷貝操作也需要進行,(2)析構函數也需要參與資源管理(通常是釋放資源)。通常需要管理的資源是內存,這也是爲什麼所有標準庫中涉及資源管理的類(例如STL容器)都聲明瞭“三大”:兩個拷貝操作和一個析構函數。

三大法則的一條法則是:出現用戶聲明的析構函數,暗示着簡單的成員逐一拷貝不適合類的拷貝操作。響應地,表明如果一個類聲明瞭析構函數,拷貝操作不應該自動生成,因爲生成的是不對的。在採用C++98的時候,這條法則不被完全接受,所以在C++98,用戶聲明的析構函數的存在不會影響到編譯器自動生成拷貝操作。這情況在C++11仍然存在,因爲如果改了會破壞大量舊代碼。

三大法則的道理仍然是有效的,不過呢,因爲聲明瞭拷貝操作會阻止移動操作的生成,導致在C++11中,類中出現了用戶聲明的析構函數就不會生成移動操作。(本來應該是聲明瞭析構就不會生成拷貝和移動,但是爲了兼容舊代碼,免除了拷貝。)

所以一個類生成移動操作(當要用時)需要滿足以下3點:

  • 類中沒有聲明拷貝操作。
  • 類中沒有聲明移動操作。
  • 類中沒有聲明析構函數。

在某種意義上,類似的規則可以延伸到拷貝操作,因爲C++11反對在一個聲明瞭拷貝操作或析構函數的類中自動生成拷貝操作。這意味着如果你的代碼中一個聲明瞭析構函數或者某個拷貝操作的類還依賴編譯器生成的拷貝操作,你應該考慮修改這個類來消除依賴。假如編譯器生成的函數是正確的(即你想要的就是逐一拷貝non-static成員變量),你的工作就很簡單啦,因爲C++11的“=default”可以讓你顯示說明:

class Widget {
public:
    ...
    ~Widget();      // 用戶聲明的析構函數
    ...
    Widget(const Widget&) = default;    // 使用默認拷貝構造

    Widget& operator=(const Widget&) = default;  // 使用默認拷貝複製操作
    ...
};

這個方法在多態基類中很有用,即通過派生類來定義接口。多態基類通常有虛析構函數,如果不是這樣,一些操作(例如,派生類對象通過基類指針或引用使用deletetypeid)會導致未定義或者誤導的結果。讓析構函數成爲虛函數的唯一辦法就是把它顯式聲明爲虛函數,通常,默認的實現是正確的,然後“=default”是表達它的好辦法。但是,用戶聲明的析構函數會抑制移動操作的生成,所以如果這個類支持移動,“=defalut”就可以用第二次啦。聲明瞭移動操作就會使拷貝操作無效,所以如果該類是可拷貝的,多用一次“=default”就行:

class Base {
public:
    virtual ~Base() = default;   // 虛析構函數

    Base(Base&&) = default;     // 支持移動操作
    Base& operator(Base&&) = default;

    Base(const Base&) = default;    // 支持拷貝
    Base& operator(const Base&) = default;
    ...
};

事實上,如果你有個類想要用編譯器生成的拷貝操作和移動操作,你可以像這樣聲明它們並使用“=default”定義它們。這好像有點多餘,但這可以讓你避免一些詭異的bug。例如,你有一個表示字符串表的類,即一個通過ID快速查詢字符串的數據結構:

class StringTable {
public:
    StringTable() {}
    ...             // 插入,刪除,查詢函數,但是沒有拷貝/移動/析構函數
private:
    std::map<int, std::string> values;
};

假定這個類沒有聲明拷貝操作,移動操作,析構函數,那麼當需要它們的時候編譯器會自動生成,這好方便呀。

不過在之後,它要在創建對象和析構對象時記錄日誌,這很有用,然後添加這些功能也是很容易的:

class StringTable {
public:
    StringTable()
    { makeLogEntry("Creating StringTable Object"); }  // 新添加

    ~StringTable()
    { makeLogEntry("Destroying StringTable Object"); }  // 新添加
    ...    // 如前
private:
    std::map<int, std::string> values;  // 如前
};

這看起來合情合理,但是聲明瞭析構函數有個很大的副作用:阻止移動操作的生成。但是類的拷貝操作不受影響,因此這代碼依舊可編譯、可運行、可通過測試,這包括移動語義的測試,儘管這個類不再可移動,但是請求移動它依舊可以編譯和運行。在本條款有講到(移動內部使用std::move),這樣的請求會進行拷貝操作,意味着代碼“移動”StringTable對象實際上只是拷貝它,即拷貝內在的std::map<int, std::string>對象。拷貝std::map<int, std::string>對象可能會比移動它慢一個數量級,在類中添加析構函數這個小小的動作竟然會導致嚴重的性能問題!用“=default”顯式定義拷貝和移動操作,這麼問題就不會出現了。

現在呢,忍耐完我沒完沒了的廢話——關於C++11管理拷貝和移動操作的規則,你可能想知道我什麼時候纔會講另外兩個特殊成員函數(默認構造函數,析構函數),嗯,就現在講吧,但只有一句話,因爲這兩個成員函數幾乎沒有發生改變:C++11的規則基本和C++98的規則相同。

因此C++11管理特殊成員函數是這樣的:

  • 默認構造函數:和C++98的規則相同,類中沒有用戶聲明的構造函數纔會生成。
  • 析構函數:本質上C++98的規則相同,唯一的區別就是析構函數默認聲明爲noexcept(看條款14)。C++98的規則是基類的析構函數的虛函數的話,生成的析構函數也是虛函數。
  • 拷貝構造函數:運行期間的行爲和C++98一樣:逐一拷貝構造non-static成員變量。只有在類中缺乏用戶聲明的拷貝構造時纔會生成。如果類中聲明瞭移動操作,拷貝構造會被刪除(delete)。當類中存在用戶聲明的拷貝賦值操作符或析構函數時,反對生成拷貝構造函數。
  • 拷貝賦值運算符:運行期間的行爲和C++98一樣:逐一拷貝複製non-static成員變量。只有在類中缺乏用戶聲明的拷貝賦值運算符時纔會生成。如果類中聲明瞭移動操作,拷貝賦值運算符會被刪除。當類中存在用戶聲明的拷貝構造函數或析構函數時,反對生成拷貝賦值運算符。
  • 移動構造函數和移動賦值運算符:每個都是逐一移動non-static成員變量。只有在類中沒有用戶聲明的拷貝操作、移動操作、析構函數時纔會自動生成。

請注意沒有規則說明成員函數模板會阻止編譯器生成特殊成員函數。這意味着如果Widget是這樣的:

class Widget {
    ...
    template <typename T>  // 可以用任何對象構造Widget
    Widget(const T& rhs);   

    template <typename T>    // 可以把任何對象賦值給Widget
    Widget& operator=(const T& rhs);
    ...`
};

編譯器還是會爲Widget生成拷貝構造和拷貝賦值(假如條件滿足),儘管這些模板可以被實例化來產生拷貝構造和拷貝賦值的簽名(當T是Widget的時候)。你十有八九覺得這僅僅是值得了解的邊緣情況,但我提到它是有原因的,在條款26中我會展示它導致的重大後果。

總結

需要記住的4點:

  • 特殊成員函數是編譯器可自動生成的函數:默認構造函數,析構函數,拷貝操作,移動操作。
  • 移動操作只有在那些沒有顯式聲明移動操作、拷貝操作、析構函數的類中生成。
  • 拷貝構造只有在那些沒有顯式聲明拷貝構造的類中生成,如果類中聲明瞭移動操作它就會被刪除。拷貝複製操作符只有在那些沒有顯式聲明拷貝操作運算符的類中生成,如果類中聲明瞭移動操作它會被刪除。在顯式聲明析構函數的類中生成拷貝操作是被反對的。
  • 成員函數模板從來不會抑制特殊成員函數的生成。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章