文章目錄
條款 05:瞭解 C++ 默默編寫並調用哪些函數
Know what functions C++ silently writes and calls.
C++ 中,空類並不是空的。
如果你沒有指定構造函數,編譯器會自動生成 default 構造函數,如果沒有指定 copy 構造函數、copy 賦值操作符和析構函數,編譯器也會自動生成空的版本。這幾個自動生成的函數是 public 和 inline 的,析構函數是非 virtual 的(除非該空類的基類聲明瞭 virtual 的析構函數)。
不過有個前提,只有這些函數被調用時,編譯器纔會創建。
自動生成的 copy 構造函數和 copy 賦值運算符,是簡單的將類的成員全部拷貝賦值。
如果某個未指定 copy 構造函數或 copy 賦值運算符的類內存在引用成員對象或常量成員對象,需要執行類對象的 copy 操作時,編譯器會拒絕編譯。因爲自動生成的 copy 構造函數或 copy 賦值運算符無法處理對引用成員對象和常量成員對象的賦值操作。
示例代碼如下:
template<class T>
class DOG {
public:
DOG(std::string& name, const T& value);
// 這裏我們只聲明構造函數,不聲明 copy 賦值運算符函數
private:
std::string& name; // 引用成員對象
const T value; // 常量成員對象
};
std::string newDog("Persephone");
std::string oldDog("Satch");
DOG<int> p(newDog, 2); // 作者當時養的狗狗
DOG<int> s(oldDog, 36); // 作者之前養的已經去世的狗狗
p = s; // 編譯器對這條代碼無能爲力
如果某個類的基類將 copy 構造函數 和 copy 賦值運算符函數聲明爲 private,那麼該類中也不會由編譯器自動生成這兩個函數,編譯器認爲它沒辦法處理 copy 操作時,調用基類 copy 方法的操作。
原文建議
- 編譯器可以暗自爲 class 創建 default 構造函數、copy 構造函數、copy 賦值運算符和析構函數。
條款 06:若不想使用編譯器自動生成的函數,就該明確拒絕
Explicitly disallow the use of compiler-generated functions you do not want.
上個條款中我們知道,如果我們不聲明 copy 構造函數和 copy 賦值運算符函數,編譯器會在需要的時候自動聲明。但如果我們不希望這個類的對象有拷貝操作,如何禁止這種事情發生?
話題 1:主動聲明這些函數,並放到 private 中
雖然這樣,對象沒法拷貝了,但類內成員函數和友元函數還是可以訪問,而我們通常不會去實現這些 copy 構造函數和 copy 賦值運算符函數,從而導致鏈接錯誤。
C++ iostream 庫中的函數就是通過這種辦法避免拷貝操作。
話題 2:將這些函數放到基類的 private 中
專門做一個基類,來隱藏 copy 構造函數和 copy 賦值運算符函數。代碼如下:
class Uncopyable {
protected:
Uncopyable() {};
~Uncopyable() {};
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
};
class HFS : private Uncopyable { // 將想要隱藏拷貝函數的類繼承自 Uncopyable
...
};
當派生類想要做拷貝操作時,因爲會調用到基類的 copy 構造函數或 copy 賦值運算符函數,從而被編譯器拒絕。
和話題 1 中相比,將問題提前在編譯器中暴露。
缺點是可能會導致多重繼承。
Boost 庫中提供了這樣一個函數:noncopyable。
原文建議
- 爲禁止編譯器自動生成函數的機制,可將對應成員函數聲明爲 private 並不實現。
- Uncopyable 是另一種可行的做法。
條款 07: 爲多態基類聲明 virtual 析構函數
Declare destructors virtual in polymorphic base classes.
當子類對象經基類指針刪除,同時基類中的析構函數是 non-virtual 的,那麼很可能最後子類部分的內容不會被析構掉,導致資源泄漏。
virtual 函數也可以修飾其他成員函數,如果一個基類裏有 virtual 修飾的成員函數,那麼它也必當有一個 virtual 的析構函數。
話題 1:並不是始終需要將析構函數修飾爲 virtual
如果一個類沒有任何 virtual 的成員函數,那麼它理應被認爲它不被設計爲一個會被繼承的類,這時,不應該將析構函數修飾爲 virtual。原因是,帶有 virtual 之後,編譯器會自動生成虛函數表(virtual table),對應聲明的對象將帶有虛函數表指針(virtual table pointer),這將會佔據額外的內存空間。
需要注意,如果你繼承的基類沒有 virtual 的析構函數,使用它作爲基類是很危險的。所有 STL 容器都是不應該被繼承的(C++ 沒有禁止繼承的操作,比如 Java 的 final class,所以這裏是個小坑)。
總結一下,並不是所有類都是爲多態設計的,多態的設計裏,析構函數修飾爲 virtual,其他情況下,如 STL 等,並不是爲多態而設計的,所以它們的析構函數是 non-virtual 的。
話題 2: 純虛析構函數需要提供定義
我們知道,如果一個成員函數被修飾爲 pure virtual 的,表示所在的這個類是一個抽象類,抽象類不能定義對象。
但是,必須爲純虛析構函數提供一份定義:
class AW {
public:
virtual ~AW () = 0;
}
AW::~AW() {} // 空的定義
原因是,派生類對象被析構時,會首先調用基類的析構函數,即使它是 pure virtual 的也同理,所以爲了避免鏈接錯誤,還是要給一個定義。
原書建議
- 帶多態性質的基類應該聲明 virtual 的析構函數。如果類內包含任何 virtual 的成員函數,那它就應該使用 virtual 的析構函數。
- 類的設計不一定是要繼承的,或並不是要用於多態的,這時不應該聲明 virtual 析構函數。
條款 08: 別讓異常逃離析構函數 (重要)
Prevent exceptions from leaving destructors.
析構函數中拋出異常,會讓析構動作停止,這可能會導致部分資源不會被成功析構。儘量在析構函數中解決所有異常。
可以在析構函數中用 try 塊捕獲異常,並 abort 程序或者忽略異常,都不是特別好的辦法,前者會導致程序非正常結束,後者會導致不可預見的問題。
如果某個操作可能在失敗時拋出異常,而又必須處理該異常,那這個操作就必須放在析構函數以外的其他函數中。
原書建議
- 析構函數絕對不要吐出異常。如果一個被析構函數調用的函數可能拋出異常,析構函數應該捕捉任何異常,然後吞下他們(不傳播)或結束程序。
- 如果客戶需要對某個操作函數運行期間拋出的異常做出反應,那麼 class 應該提供一個普通的函數(非析構函數)來執行這個操作。
條款 09: 絕不在構造和析構過程中調用 virtual 函數
Never call virtual functions during construction or destruction.
派生類在構造時,會先調用基類的構造函數,而此時,派生類還不存在,如果基類構造函數中調用了 virtual 函數,那實際上調用的是基類的 virtual 函數,而不是派生類的版本,而一旦基類中的 virtual 函數是 pure virtual 的,就出錯了。但如果其他情況下沒出錯,程序會出現更詭異的問題。
析構函數也是同理。
有些編譯器會針對這種情況做警告,但有時它和鏈接器都無能爲力。比如:
class Base {
public:
Base() { init(); } // init 是一個普通成員函數
virtual void log() const; // log 是一個 virtual 成員函數
private:
void init() {
log(); // 裏邊調用了 virtual 函數
}
void log() {
...; // 也有一份實現
}
};
class Derive {
public:
virtual void log() const;
};
這個程序,我們編譯時是正常的,編譯器檢查不到 Derive() -> Base() -> Base::init() -> Base::log() 這條調用鏈,但實際運行時你會發現,基類的虛函數被錯誤的調用了。
唯一的解決辦法就是,不要在構造函數和析構函數中調用 virtual 成員函數,如果非得調用,那就把這個 virtual 成員函數改成 non-virtual 的。
原書建議
- 在構造和析構期間不要調用 virtual 函數,因爲這類調用從不下降至 derived class (比起當前執行構造函數和析構函數的那一層)。
條款 10: 令 operator= 返回一個 reference to *this
Have assignment operators return a reference to *this.
因爲賦值支持連鎖操作,比如: a = b = c;
,所以重載賦值運算符必須返回一個指向操作數左側對象的指針,也就是 *this。
這不只適用於標準賦值運算符,也適用於任何賦值相關的運算符重載,比如 +=。
內置類型和標準程序庫都滿足這種規範。
原書建議
- 令賦值(assignment)操作符返回一個 reference to *this。
條款 11: 在 operator= 中處理 “自我賦值” (重要)
Handle assigment to self in operaotr=.
同一個對象自己等於自己一般不會有人這麼寫,但有些時候不一定能看出來,比如:
a[i] = a[j]; // i == j
*px = *py; // px == py
有些時候,你要自己實現 operator=,如下:
class B {...};
class Widget {
...
private:
B* pb;
};
Widget&
Widget::operator=(const Widget& rhs)
{
delete pb; // pb 是 Wdiget 類中的一個指針對象
pb = new B(*rhs.pb);
return *this;
}
這個代碼中,當operator= 的左值和右值是同一個對象時,就出錯了,返回的對象中 pb 指向了一段已經被銷燬的內存。
話題 1:在 operator= 中,做證同測試
也就是開頭加一段:if (this == &rhs) return *this;
。判斷如果是同一個對象,就直接返回。
但仍然可能有問題,比如 new B 時出異常了,導致 pb 賦值失敗,那返回的對象中 pb 指向的位置也是被銷燬的。
話題 2:使用臨時對象
先將 pb 賦給臨時對象,再 new B,然後把臨時對象 delete 掉。
這是一個行得通的辦法。
Widget&
Widget::operator=(const Widget& rhs)
{
B* temp = pb;
pb = new B(*rhs.pb);
delete temp;
return *this;
}
話題 3: 更好的辦法,copy and swap 技術
使用交換技術代替賦值。
class Widget {
...
void swap(Widget& rhs); // 用於交換 rhs 和 *this 的數據
...
};
Widget&
Widget:: operator=(const Widget& rhs)
{
Widget temp(rhs); // 仍然需要臨時變量
swap(temp);
return *this;
}
Widget::operator=(Widget rhs) // 另一種變體,傳值方式會複製一份副本
{
swap(rhs); // ths 本身是副本,直接交換
return *this;
}
原書建議
- 確保當對象自我賦值時 operator= 有良好行爲。其中技術包括比較“來源對象”和“目標對象”的地址、精心周到的語句順序、以及 copy-and-swap。
- 確定任何函數如果操作一個以上的對象,而其中多個對象是同一個對象時,其行爲仍然正確。
條款 12: 複製對象時勿忘其每一個成分
Copy all parts of an object.
Copy 函數包括 copy 構造函數和 copy 賦值運算符,這兩個函數不定義的話,編譯器會生成默認版本,我們需要確保我們自己定義的 copy 函數一切正常。
如果你先寫完了構造函數和 copy 函數,之後又新增了成員變量,那麼你需要自己留意在所有這些構造函數和 copy 函數中添加這個新的成員變量的操作,編譯器不會提醒你。一旦你忘記了,你的初始化或賦值操作就是不完整的。
如果是一個派生類中的 copy 函數,除了處理好派生類內的成員對象的 copy 操作,還要負責基類中對象的 copy 操作,也就是在初始化列表中完成對基類的 copy 操作。如下代碼:
class PC : public Base {
public:
...
PC(const PC& rhs);
PC& operator=(const PC& rhs);
private:
int p;
};
PC::PC(const PC& rhs)
: Base(rhs), // 注意這裏,調用基類的 copy 構造函數
p(rhs.p)
{
...
}
PC&
PC::operator=(const PC& rhs)
{
...
Base::operator=(rhs); // 注意這裏,手動調用基類的 copy 賦值運算符函數
p = rhs.p;
return *this;
}
另外,如果兩個 copying 函數中的內容基本一致,比如上面代碼 … 部分的內容很多且一致,不要想着用一個 copying 函數調用另一個。應該另外寫一個成員函數,在包裝這些共同的代碼。
原書建議
- Copying 函數應該確保複製 “對象內的所有成員變量” 及 “所有 base class 成分”。
- 不要嘗試以某個 copying 函數實現另一個 copying 函數。應該將共同機能放進第三個函數中,並由兩個 copying 函數共同調用。