Effective C++ 之《構造/析構/賦值運算》

條款05:瞭解C++默默編寫並調用了哪些函數

  考慮如下一個類:

class Empty{};

  這個類其實等價於:

class Empty {
public:
	Empty();
	Empty(const Empty& other);
	~Empty();
	
	Empty& operator=(const Empty& other);
}

  也就是說,當我們編寫一個空類時,編譯器會在我們調用這些函數時,自動爲我們創建出來。那這些函數對應我們平時做的哪些操作呢:

{
	Empty a1;		//調用Empty(),默認構造函數
	Empty a2(a1);	//調用Empty(const Empty& other),拷貝構造函數
	a2 = a1;  		//調用Empty& operator=(const Empty& other),賦值函數
}					//作用域結束,調用~Empty(),析構函數

  所以在寫一個類的時候,如果沒有自定義這幾個函數的話,編譯器會自動爲我們創建,這些默認的函數也有自己默認的操作內容。

  1. 當類中有const或引用的成員變量時,編譯器拒絕自動生成賦值函數
  2. 如果父類中的賦值函數被聲明爲private,那麼編譯器拒絕爲子類生成一個賦值函數。

總結

編譯器可以暗自爲class創建默認構造函數、拷貝構造函數、賦值函數、以及析構函數。

條款06:若不想使用編譯器自動生成的函數,就該明確拒絕

  在項目開發中,我們會經常編寫一些複雜的類、或者單例,或者某些類從業務邏輯上是不允許被拷貝和賦值的。那麼這個時候,如果不去明確拒絕,那麼正如條款05所描述,編譯器會自動爲我們生成這些函數。所以如果不想這樣做的話,我們需要明確拒絕。

  那麼如何明確拒絕呢,一般有兩種方法:

  1. 類中聲明拷貝構造函數和賦值函數爲private,且不實現它們.
class HomeForSale {
public:
	...
private:
	HomeForSale(const HomeForSale&);
	HomeForSale& operator=(const HomeForSale&);
}

  如上在頭文件中僅僅聲明它即可,不用去實現。主要原因如下:

  1. 使用private修飾爲了防止外部進行調用
  2. 不去實現,是爲了防止在內部的成員函數或者friend函數內調用。
  1. 如果這種類過多,那麼需要對每一個類做這樣的操作,這樣會比較麻煩。有一種方式是編寫一個父類,讓擁有這種屬性的類繼承父類,從而達到它們的實例不允許拷貝的效果。
class Uncopyable {
protected:
	Uncopyable();			//不可實例化
	~Uncopyable();		
private:
	Uncopyable(const Uncopyable&);
	Uncopyable& operator=(const Uncopyable&);	
}

  這樣我們只需要HomeForSale 類繼承Uncopyable即可:

class HomeForSale : public Uncopyable {
	...
}

  這種方式比較常用,boost中有noncopyable提供了該功能:

#ifndef BOOST_NONCOPYABLE_HPP_INCLUDED  
#define BOOST_NONCOPYABLE_HPP_INCLUDED  

namespace boost {  

//  Private copy constructor and copy assignment ensure classes derived from  
//  class noncopyable cannot be copied.  

//  Contributed by Dave Abrahams  

namespace noncopyable_  // protection from unintended ADL  
{  
  class noncopyable  
  {  
   protected:  
      noncopyable() {}  
      ~noncopyable() {}  
   private:  // emphasize the following members are private  
      noncopyable( const noncopyable& );  
      const noncopyable& operator=( const noncopyable& );  
  };  
}  

typedef noncopyable_::noncopyable noncopyable;  

} // namespace boost  

#endif  // BOOST_NONCOPYABLE_HPP_INCLUDED  

總結

爲駁回編譯器自動提供的功能,可以將相應的成員函數聲明爲private並且不予以實現。使用像Uncopyable這樣的父類也是一種做法。

條款07:爲多態基類聲明virtual析構函數

1. 帶多態性質的父類,應該聲明一個virtual析構函數

   看如下的一個例子:

class A {
	A();
	~A();
};

class B : public A {
	B();
	~B();
}

A *a = new B();
delete a;

  如上類B是類A的派生類,在實例化的時候使用A類型的指針指向B生成的對象,那麼在析構的時候會出現什麼情況呢。

  答案是類B的析構函數沒有被調用。這是爲什麼呢?這是因爲C++明確指出,當派生類對象經由一個父類指針刪除,而該父類帶着一個非虛析構函數,其結果是未定義的。也就是派生類對象析構函數未被調用。

  從另外一個角度思考也是合理的,程序在調用析構函數的時候,此時該指針是A類的,但實際指向B的實例。那麼它首先會調用覆蓋析構函數的派生類B的析構函數,但是A的析構函數未聲明爲虛函數,那麼就不存在覆蓋它的析構函數了,所以派生類B的析構函數未能執行。

  如果B的析構函數未被執行,那就意味這,對象未全部銷燬。如果B類中申請了一些其他類的實例,那麼顯然的,這會出現內存泄漏的問題。

2. 類的設計目的不作爲父類使用,不該聲明virtual函數

  如果一個類的設計目的不是用來作爲基類的,那麼我們最好不應該聲明虛函數(包括虛析構函數)。

  這是因爲當我們聲明虛函數的時候,申請出來的對象中含有一個vptr,它指向了一個虛函數表(vpbl),它存儲了類中每一個虛函數的函數指針地址。所以在我們每申請一次這個對象就會多出一個佔4字節(32位)的vptr。這無疑是增加了內存的開銷。

3. 不要繼承一個沒有聲明虛析構函數的類

  在實際開發中,我們可能會寫出這樣的代碼:

class MyString : public std::string {
}

  這樣的寫法是危險的,因爲std::string類並沒有聲明自己析構函數爲virtual。如果發生1所描述的情況,那麼就會出現問題。

4. 純虛函數

  如果你的基類的虛函數本身什麼都不做,可以將其聲明爲純虛函數:

class AWOV {
public:
	virtual ~AWOV() = 0;
}

  它的好處在於,我們知道先構造的後析構,後構造的先析構。那麼編譯器在先析構派生類的時候,如果發現派生類沒有定義虛析構函數,那麼鏈接器就會發出錯誤信息。這有助於我們提前知道自己所編寫的代碼的問題所在。

總結

  1. polymorphic(帶多態性質的)base classes 應該聲明一個virtual析構函數,如果class帶有任何virtual函數,它就應該擁有一個virtual析構函數。
  2. Classes的設計目的如果不是作爲base classes使用,或不是爲了具備多態性(polymorphically),就不該聲明virtual析構函數。

條款08:別讓異常逃離析構函數

  考慮如下代碼:

class Widget {
public:
~Widget() {    // 假定這個析構函數可能會吐出異常
};
void doSomething() {
	std::vector<Widget> v;
}

   這裏如果在析構函數中對vector中的第一個元素析構時,拋出異常,那麼就會導致程序結束執行或者出現不明確行爲。
  對於這種情況,一般情況採用如下方式:

class DBConn {
public:
	void close() {
		db.close();
		closed = true;
	}
	~DBConn() {
		if(!closed) {
			try {
				db.close();
			}
			catch {...} {}
		}
	}
private:
	DBConnection db;
	bool		 closed;
};

  如上代碼所示,它是一個在析構函數中關閉數據庫鏈接的操作,爲了防止db.close()函數在析構函數中發生異常,可以定義一個外部接口,供用戶自己去關閉數據庫連接,將異常的情況交給用戶進行處理。

  但是如果用戶忘記調用close,在析構函數中爲了保險期間,需要try catch將異常吞掉,防止出現程序異常退出的情況。

  這種雙保險時解決異常逃離析構函數的方法。
總結

  1. 析構函數絕對不要吐出異常。如果一個被析構函數調用的函數可能拋出異常,析構函數應該捕捉任何異常,然後吞下它們(不傳播)或結束程序。
  2. 如果客戶需要對某個操作函數運行期間拋出的異常做出反應,那麼 class應該提供一個普通函數(而非在析構函數中)執行該操作。

條款09:絕不在構造和析構過程中調用virtual函數

  且看如下實例:

class Transaction {
public:
	Transaction();
	virtual void LogTransaction() const = 0;
	...
};

Transaction::Transaction() {
	...
	LogTransaction();
}

class BuyTransaction : public Transaction {
public:
	virtual void LogTransaction() const;
};

class SellTransaction : public Transaction {
public:
	virtual void LogTransaction() const;
};

  這是一個股票買進賣出的系統,不同的操作都會記錄自己的日誌信息。在Transaction父類的構造函數中調用了LogTransaction() 虛成員方法。這種情況會出現什麼問題呢。

鏈接器會報錯,無法找到LogTransaction定義的版本。

  我們知道當實例化BuyTransaction對象的時候,程序先調用父類Transaction的構造方法,此時BuyTransaction對象並沒有被初始化,這個時候調用了LogTransaction虛方法並不是BuyTransaction的實現版本。就算是BuyTransaction的實現版本,那麼BuyTransaction的構造方法沒有執行,也就是類中的成員變量未初始化,這個時候BuyTransaction方法中如果使用了未初始化的成員變量,同樣會使程序運行出現問題。

  同樣的在析構函數中,如果在父類調用了虛函數,也會出現問題。因爲父類的析構函數執行順序在派生類之後。如果在父類的析構函數中調用了虛函數,此時,派生類中的成員變量已經變成未定義狀態,這樣同樣會造成程序執行到不可知的方向。

  有時候我們也會這樣做:

class Transaction {
public:
	Transaction() {
		Init();
	}
	virtual void LogTransaction() const = 0;
	...
private:
	void Init() {
		LogTransaction();
	}
};

  這種情況和上面的一樣會有問題,因爲其本質上還是在構造函數中出現了對虛函數的調用。

  所以我們在coding的時候一定要注意構造函數中是否有對虛函數的調用,這樣的做法使相當危險的。
  那麼有什麼辦法解決這種問題呢。

class Transaction {
public:
	explicit Transaction(const std::string& logInfo);
	void LogTransaction(const std::string& logInfo) const;
	...
};

Transaction::Transaction(const std::string& logInfo) {
	...
	LogTransaction(logInfo);
}

class BuyTransaction : public Transaction {
public:
	BuyTransaction(parameters) 
		: Transaction(createLogString(parameters)){
	}
private:
	static std::string createLogString(parameters);
};
  1. 聲明LogTransaction爲非虛函數。
  2. 將變化的部分作爲LogTransaction的形參傳遞給父類。
  3. 使用static函數讓初始化的實參是已經定義的。

  使用用static方法,同樣是因爲在傳遞參數的時候,該實參如果作爲派生類的成員變量傳遞的話,此成員變量並未被初始化,同樣會出問題。所以使用createLogString靜態方法,確保傳遞的實參是已經被定義的。

  當然以上只是舉個例子,在實際開發中我們可能不會這樣去寫。

總結

在構造和析構期間不要調用virtual函數,因爲這類調用從不下降至derived class(比起當前執行構造函數和析構函數的那層)。

條款10:令operator= 返回一個reference to *this

  該條款說明的是一種大家都遵循的協議,即我們在class中實現operator=、+=、-=、*=等操作符的時候,最好使它的返回類型是一個reference to *this。

class widget {
public:
	widget& operator= (const widget& other) {
		...
		return *this;
	}
}

總結

令賦值(assignment)操作符返回一個reference to *this。

條款11:在operator= 中處理"自我賦值"

  該條款使用最經典的String拷貝面試題來解釋會更好一些。這個在《劍指Offer》中也提到過的面試題。

  關於這個面試題,基本的解法如下:

EMyString& EMyString::operator = (const EMyString& str) {
	if (this == &str) {
		return *this;
	}

	delete[] m_pData;
	m_pData = nullptr;
	m_nLen = str.Len();
	m_pData = new char[m_nLen];
	memcpy(m_pData, str.m_pData, m_nLen);
	return *this;
}

  那麼我們看到的:

	if (this == &str) {
		return *this;
	}

  就是條款中所說的,在operator =中處理自我賦值。因爲如果不處理的話,很可能在後面delete[] m_pData,delete的是自己,這會出現很嚴重的問題。

  所以這個條款在我們的實際開發中要謹記。

  條款中也提到了另一個風險:

	delete[] m_pData;
	m_pData = nullptr;
	m_nLen = str.Len();
	m_pData = new char[m_nLen];

  m_pData = new char[m_nLen];這句可能出現的風險是,當內存不足時,可能申請不到這塊內存。但是此時我們已經將this對象中的數據釋放掉了,此時等於破壞掉了原始的this對象,這就會出現異常安全。所以有更好的實現方式如下:

const EMyString& EMyString::operator=(const EMyString& str) {
	if (this != &str) {
		EMyString strTmp(str);
		char* tempData = strTmp.m_pData;
		strTmp.m_pData = m_pData;				//作用域之後調用析構釋放
		m_pData = tempData;						//tempData是在EMyString構造函數中申請的內存
	}

	return *this;
}

  這裏使用交換的方式,先申請臨時的strTmp實例,然後通過str拷貝構造出來的對象內容與this對象內容進行交換。當出了if作用域後,它會釋放原本this對象中的m_pData。這樣當EMyString拷貝構造函數中如果申請不到內存的話,也不會破壞原來this對象的內容。

總結

  1. 確保當前自我賦值時operator=有良好行爲,其中技術包括比較”來源對象“和”目標對象“的地址、精心周到的語句順序、以及copy-and-swap。
  2. 確定任何函數如果操作一個以上的對象,而其中多個對象是同一個對象時,其行爲仍然正確。

條款12:複製對象時勿忘其每一個成分

  該條款表明的是,在實現拷貝構造函數和賦值函數時,我們需要考慮到類中的每一個成員是否被拷貝了,根據業務邏輯,我們需要使用的時淺拷貝還是深拷貝。

  同時,當該類時子類的時候,需要考慮到父類的成員變量是否被拷貝到。

總結

Copying函數應該確保複製”對象內的所有成員變量“及”所有base class成分“。
不要嘗試以某個copying函數實現另一個copying函數。應該將共同機能放進第三個函數中,並由兩個coping函數共同調用。

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