程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> Effective Modern C++ 條款17 理解特殊成員函數的生成

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

編輯:關於C++

在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 values;
};

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

不過在之後,它要在創建對象和析構對象時記錄日志,這很有用,然後添加這些功能也是很容易的:
class StringTable {
public:
StringTable()
{makeLogEntry("Creating StringTable Object"); } // 新添加

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

這看起來合情合理,但是聲明了析構函數有個很大的副作用:阻止移動操作的生成。但是類的拷貝操作不受影響,因此這代碼依舊可編譯、可運行、可通過測試,這包括移動語義的測試,盡管這個類不再可移動,但是請求移動它依舊可以編譯和運行。在本條款有講到(移動內部使用std::move),這樣的請求會進行拷貝操作,意味著代碼“移動”StringTable對象實際上只是拷貝它,即拷貝內在的std::map對象。拷貝std::map對象可能會比移動它慢一個數量級,在類中添加析構函數這個小小的動作竟然會導致嚴重的性能問題!用“=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 // 可以用任何對象構造Widget
Widget(const T& rhs);

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

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

總結

需要記住的4點:

特殊成員函數是編譯器可自動生成的函數:默認構造函數,析構函數,拷貝操作,移動操作。 移動操作只有在那些沒有顯式聲明移動操作、拷貝操作、析構函數的類中生成。 拷貝構造只有在那些沒有顯式聲明拷貝構造的類中生成,如果類中聲明了移動操作它就會被刪除。拷貝復制操作符只有在那些沒有顯式聲明拷貝操作運算符的類中生成,如果類中聲明了移動操作它會被刪除。在顯式聲明析構函數的類中生成拷貝操作是被反對的。 成員函數模板從來不會抑制特殊成員函數的生成。
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved