Effective C++ 學習筆記 第二章:構造、析構、賦值運算

第一章見 Effective C++ 學習筆記 第一章:讓自己習慣 C++

條款 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 函數共同調用。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章