條款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(),析構函數
所以在寫一個類的時候,如果沒有自定義這幾個函數的話,編譯器會自動爲我們創建,這些默認的函數也有自己默認的操作內容。
- 當類中有const或引用的成員變量時,編譯器拒絕自動生成賦值函數
- 如果父類中的賦值函數被聲明爲private,那麼編譯器拒絕爲子類生成一個賦值函數。
總結
編譯器可以暗自爲class創建默認構造函數、拷貝構造函數、賦值函數、以及析構函數。
條款06:若不想使用編譯器自動生成的函數,就該明確拒絕
在項目開發中,我們會經常編寫一些複雜的類、或者單例,或者某些類從業務邏輯上是不允許被拷貝和賦值的。那麼這個時候,如果不去明確拒絕,那麼正如條款05所描述,編譯器會自動爲我們生成這些函數。所以如果不想這樣做的話,我們需要明確拒絕。
那麼如何明確拒絕呢,一般有兩種方法:
- 類中聲明拷貝構造函數和賦值函數爲private,且不實現它們.
class HomeForSale {
public:
...
private:
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
}
如上在頭文件中僅僅聲明它即可,不用去實現。主要原因如下:
- 使用private修飾爲了防止外部進行調用
- 不去實現,是爲了防止在內部的成員函數或者friend函數內調用。
- 如果這種類過多,那麼需要對每一個類做這樣的操作,這樣會比較麻煩。有一種方式是編寫一個父類,讓擁有這種屬性的類繼承父類,從而達到它們的實例不允許拷貝的效果。
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;
}
它的好處在於,我們知道先構造的後析構,後構造的先析構。那麼編譯器在先析構派生類的時候,如果發現派生類沒有定義虛析構函數,那麼鏈接器就會發出錯誤信息。這有助於我們提前知道自己所編寫的代碼的問題所在。
總結
- polymorphic(帶多態性質的)base classes 應該聲明一個virtual析構函數,如果class帶有任何virtual函數,它就應該擁有一個virtual析構函數。
- 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將異常吞掉,防止出現程序異常退出的情況。
這種雙保險時解決異常逃離析構函數的方法。
總結
- 析構函數絕對不要吐出異常。如果一個被析構函數調用的函數可能拋出異常,析構函數應該捕捉任何異常,然後吞下它們(不傳播)或結束程序。
- 如果客戶需要對某個操作函數運行期間拋出的異常做出反應,那麼 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);
};
- 聲明LogTransaction爲非虛函數。
- 將變化的部分作爲LogTransaction的形參傳遞給父類。
- 使用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對象的內容。
總結
- 確保當前自我賦值時operator=有良好行爲,其中技術包括比較”來源對象“和”目標對象“的地址、精心周到的語句順序、以及copy-and-swap。
- 確定任何函數如果操作一個以上的對象,而其中多個對象是同一個對象時,其行爲仍然正確。
條款12:複製對象時勿忘其每一個成分
該條款表明的是,在實現拷貝構造函數和賦值函數時,我們需要考慮到類中的每一個成員是否被拷貝了,根據業務邏輯,我們需要使用的時淺拷貝還是深拷貝。
同時,當該類時子類的時候,需要考慮到父類的成員變量是否被拷貝到。
總結
Copying函數應該確保複製”對象內的所有成員變量“及”所有base class成分“。
不要嘗試以某個copying函數實現另一個copying函數。應該將共同機能放進第三個函數中,並由兩個coping函數共同調用。