4 類
4.1 類的設計
類是物件導向設計的基礎,一個好的類應該職責單一,介面清晰、少而完備,類間低耦合、類內高內
聚,並且很好地展現封裝、繼承、多態、模組化等特性。
原則4.1 類職責單一
說明:類應該職責單一。如果一個類的職責過多,往往難以設計、實現、使用、維護。
隨著功能的擴充,類的職責範圍自然也擴大,但職責不應該發散。
用小類代替巨類。小類更易於編寫,測試,使用和維護。用小類體現簡單設計的概念;巨類會削弱封
裝性,巨類往往承擔過多職責,試圖提供“完整”的解決方案,但往往難以真正成功。
如果一個類有10個以上資料成員,類的職責可能過多。
原則4.2 隱藏資訊
說明:封裝是物件導向設計和編程的核心概念之一。隱藏實現的內部資料,減少調用者代碼與具體實
現代碼之間的依賴。
盡量減少全域和共用資料;
禁止成員函數返回成員可寫的引用或者指標;
將資料成員設為私人的(struct除外),並提供相關存取函數;
避免為每個類資料成員提供訪問函數;
運行時多態,將內部實現(衍生類別提供)與對外介面(基類提供)分離。
原則4.3 盡量使類的介面正交、少而完備
說明:應該圍繞一個核心去定義介面、提供服務、與其他類合作,從而易於實現、理解、使用、測試
和維護。介面函數功能正交,盡量避免一個介面功能覆蓋另一個介面功能。介面函數太多,會難以理
解、使用和維護。如果一個類包含20個以上的非私人成員函數,類的介面可能不夠精簡。
規則4.1 模組間對外介面類不要暴露私人和保護成員
說明:對外介面類暴露受保護或者私人成員則破壞了封裝,一旦因為類的設計變更(增加,刪除,修改
內部成員)會導致關聯組件或系統的代碼重新編譯,從而增加系統編譯時間,也產生了二進位相容問題,
導致關聯升級和打補丁。所以除非必要,不要在介面類中暴露私人和保護成員。
有如下幾種做法:
使用純虛類作為介面類,用實作類別完成實現,使用者只看到介面類,這種做法缺點是:
代碼結構相對複雜。
新增介面必須放在原有介面後面,不能改變原有介面的順序。否則,因為虛函數表的原因,
會導致客戶代碼重新編譯。
介面類使用PIMPL模式(只有一個指向實作類別指標的私人資料成員),所有私人成員都封裝在實作類別
中(實作類別可以不暴露為標頭檔,直接放在實現檔案中)。
代碼結構簡單,容易理解。
可以節省虛函數開銷,但是有間接訪問開銷。
修改實現不會導致客戶代碼重新編譯。
class Interface
{
public:
void function();
private:
Implementation* impl_;
};
class Implementation
{
public:
int i;
int j;
};
void Interface:: function ()
{
++impl_->i;
}
規則4.2 避免成員函數返回成員可寫的引用或者指標
說明:破壞了類的封裝性,對象本身不知道的情況下對象的成員被修改。
樣本:不好的例子
class Alarm
{
public:
string& getname(){return name;} //破壞類的封裝性,成員name被暴露
private:
string name;
};
例外:某些情況下確實需要返回可寫引用或者指標的,例如單件模式的一種寫法:
Type& Type::Instance()
{
static Type instance;
return instance;
}
規則4.3 禁止類之間循環相依性
說明:循環相依性會導致系統耦合度大大增加,所以類之間禁止循環相依性。類A依賴類B,類B依賴類A。
出現這種情況需要對類設計進行調整,引入類C:
升級:將關聯業務提到類C,使類C依賴類A和類B,來消除循環相依性
降級:將關聯業務提到類C,使類A和類B都依賴類C,來消除循環相依性。
樣本:類Rectangle和類Window互相依賴
class Rectangle
{
public:
Rectangle(int x1, int y1, int x2, int y2);
Rectangle(const Window& w);
};
class Window
{
public:
Window(int xCenter, int yCenter, int width, int height);
Window(const Rectangle& r);
};
可以增加類BoxUtil做為轉換,不用產生相互依賴
class BoxUtil
{
public:
static Rectangle toRectangle(const Window& w);
static Window toWindow(const Rectangle& r);
};
建議4.1 將資料成員設為私人的(struct除外),並提供相關存取函數
說明:資訊隱藏是良好設計的關鍵,應該將所有資料成員設為私人,精確的控製成員變數的讀寫,對
外屏蔽內部實現。否則意味類的部分狀態可能無法控制、無法預測,原因是:
非private成員破壞了類的封裝性,導致類本身不知道其資料成員何時被修改;
任何對類的修改都會延伸影響到使用該類的代碼。
將資料成員私人化,必要時提供相關存取函數,如定義變數foo_及存取子foo()、賦值操作符
set_foo()。 存取函數一般內聯在標頭檔中定義成內嵌函式。如果外部沒有需求,私人資料成員可以
不提供存取函數,以達到隱藏和保護的目的。不要通過存取函數來訪問私人資料成員的地址(見規則
4.2)。
建議4.2 使用PIMPL模式,確保私人成員真正不可見
說明:C++將私人成員成員指定為不可訪問,但還是可見的,可以通過PIMPL慣用法使私人成員在當前
類的範圍中不可見。PIMPL主要是通過前置聲明,達到介面與實現的分離的效果,降低編譯時間,降低
耦合。
樣本:
class Map
{
private:
struct Impl;
shared_ptr<Impl> pimpl_;
};
4.2 構造、賦值和析構
規則4.4 包含成員變數的類,須定義建構函式或者預設建構函式
說明:如果類有成員變數,沒有定義建構函式,又沒有定義預設建構函式,編譯器將自動產生一個構
造函數,但編譯器產生的建構函式並不會對成員變數進行初始化,對象狀態處於一種不確定性。
例外:如果這個類是從另一個類繼承下來,且沒有增加成員變數,則不用提供預設建構函式
樣本:如下代碼沒有建構函式,私人資料成員無法初始化:
class CMessage
{
public:
void ProcessOutMsg()
{
//…
}
private:
unsigned int msgid;
unsigned int msglen;
unsigned char *msgbuffer;
};
CMessage msg; //msg成員變數沒有初始化
msg.ProcessOutMsg(); //後續使用存在隱患
//因此,有必要定義預設建構函式,如下:
class CMessage
{
public:
CMessage ():
msgid(0),
msglen (0),
msgbuffer (NULL)
{
}
//...
};
規則4.5 為避免隱式轉換,將單參數建構函式聲明為explicit
說明:單參數建構函式如果沒有用explict聲明,則會成為隱式轉換函式。
樣本:
class Foo
{
public:
explicit Foo(const string &name):m_name(name)
{
}
private:
string m_name;
};
ProcessFoo("zhangsan"); //函數調用時,編譯器報錯,因為顯式禁止隱式轉換
定義了Foo::Foo(string &name),當形參是Foo對象實參為字串時,建構函式Foo::Foo(string &name)
被調用並將該字串轉換成一個Foo臨時對象傳給調用函數,可能導致非預期的隱式轉換。解決辦法:
在建構函式前加上explicit限制隱式轉換。
規則4.6 包含資源管理的類應自訂拷貝建構函式、賦值操作符和解構函式
說明:如果使用者不定義,編譯器預設會產生拷貝建構函式、賦值操作符和解構函式。自動產生的拷貝
建構函式、賦值操作符只是將所有來源物件的成員簡單賦值給目的對象,即淺拷貝(shallow copy);自
動產生解構函式是空的。這對於包含資源管理的類來說是不夠的:比如從堆中申請的資源,淺拷貝會
使得來源物件和目的對象的成員指向同一記憶體,會導致資源重複釋放。空的解構函式不會釋放已申請內
存。
如果不需要拷貝建構函式和賦值操作符,可以聲明為private屬性,讓它們失效。
樣本:如果結構或對象中包含指標,定義自己的拷貝建構函式和賦值操作符以避免野指標。
class GIDArr
{
public:
GIDArr()
{
iNum = 0;
pGid = NULL;
}
~GIDArr()
{
if (pGid)
{
delete [] pGid;
}
}
private:
int iNum;
char *pGid;
GIDArr(const GIDArr& rhs);
GIDArr& operator = (const GIDArr& rhs) ;
}GIDArr;
規則4.7 讓operator=返回*this的引用
說明:符合連續賦值的常見用法和習慣。
樣本:
String& String::operator=(const String& rhs)
{
//...
return *this; //返回左邊的對象
}
string w, x, y, z;
w = x = y = z = "Hello";
規則4.8 在operator=中檢查給自己賦值的情況
說明:自己給自己賦值和普通賦值有很多不同,若不防範會出問題。
樣本:
class String
{
public:
String(const char *value);
~String();
String& operator=(const String& rhs);
private:
char *data;
};
//自賦值,合法
String a;
a=a;
//不好的例子:忽略了給自己賦值的情況,導致訪問野指標
String& String::operator=(const String& rhs)
{
delete [] data; //刪除data
//分配新記憶體,將rhs的值拷貝給它
data = new char[strlen(rhs.data) + 1]; //rhs.data已經刪除,變成野指標
strcpy(data, rhs.data);
return *this;
}
//好的例子:檢查給自己賦值的情況
String& String::operator=(const String& rhs)
{
if(this != &rhs)
{
delete [] data;
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}
return *this;
}
規則4.9 在拷貝建構函式、賦值操作符中對所有資料成員賦值
說明:確保建構函式、賦值操作符的對象完整性,避免初始化不完全。
規則4.10 通過基類指標來執行刪除操作時,基類的解構函式設為公有且虛擬
說明:只有基類解構函式是虛擬,才能保證衍生類別的解構函式被調用。
樣本:基類定義中無虛解構函式導致的記憶體流失。
//如下平台定義了基類A,完成擷取版本號碼的功能。
class A
{
public:
virtual std::string getVersion()=0;
};
//產品衍生類別B,實現其具體功能,其定義如下:
class B:public A
{
public:
B()
{
cout<<"B()"<<endl;
m_int = new int [100];
}
~B()
{
cout<<"~B()"<<endl;
delete [] m_int;
}
std::string getVersion(){ return std::string("hello!");}
private:
int *m_int;
};
//類比該介面的調用代碼如下:
int main(int argc, char* args[])
{
A *p = new B();
delete p;
return 0;
}
衍生類別B雖然在解構函式中進行了資源清理,但不幸的是該衍生類別解構函式永遠不會被調用。由於基類
A沒有定義解構函式,更沒有定義虛解構函式,當對象被銷毀時,只會調用系統預設的解構函式,故導
致記憶體流失。
規則4.11 避免在建構函式和解構函式中調用虛函數
說明:在建構函式和解構函式中調用虛函數,會導致未定義的行為。
在C++中,一個基類一次只構造一個完整的對象。
樣本:類BaseA是基類,DeriveB是衍生類別
class BaseA //基類BaseA
{
public:
BaseA();
virtual void log() const=0; //不同的衍生類別調用不同的記錄檔
};
BaseA::BaseA() //基類建構函式
{
log(); //調用虛函數log
}
class DeriveB:public BaseA //衍生類別
{
public:
virtual void log() const;
};
當執行如下語句:
DeriveB B;
會先執行DeriveB的建構函式,但首先調用BaseA的建構函式,由於BaseA的建構函式調用虛函數log,
此時log還是基類的版本,只有基類構造完成後,才會完成衍生類別的構造,從而導致未定義的行為。
同樣的道理也適用於解構函式。
建議4.3 拷貝建構函式和賦值操作符的參數定義成const參考型別
說明:拷貝建構函式和賦值操作符不可以改變它所引用的對象。
建議4.4 在解構函式中集中釋放資源
說明:使用解構函式來集中處理資源清理工作。如果在解構函式之前,資源被釋放 (如release函數),
請將資源設定為NULL,以保證解構函式不會重複釋放。
4.3 繼承
繼承是物件導向語言的一個基本特性。理解各種繼承的含義: “public繼承”意味"是...一個",純虛
函數只繼承介面,一般的虛函數繼承介面並提供預設實現,非虛函數繼承介面和實現但不允許修改。
繼承的層次過多導致理解困難;多重繼承會顯著增加代碼的複雜性,還會帶來潛在的混淆。
原則4.4 用組合代替繼承
說明:繼承和組合都可以複用和擴充現有的能力。如果組合能表示類的關係,那麼優先使用組合。
繼承實現比較簡單直觀,但繼承在編譯時間定義,無法在運行時改變;繼承對衍生類別暴露了基類的實現
細節,使衍生類別與基類別結合程度性非常強。一旦基類發生變化,衍生類別隨著變化,而且因為衍生類別無法修
改基類的非虛函數,導致修改基類會影響到各個衍生類別。
而組合的靈活性較高,代碼耦合小,所以優先考慮組合。
但是並非絕對,往往組合和繼承是一起使用的,例如組合的元素是抽象的,通過實現抽象來修改組合
的行為。
繼承在一般情況下有兩類:實現繼承(implementation inheritance)和介面繼承(interface
inheritance),儘可能不要使用實現繼承而考慮用組合替代。
介面繼承:只繼承成員函數的介面(也就是聲明),例如純虛(pure virtual)函數;實現繼承:繼
承成員函數的介面和實現,例如虛函數同時繼承介面和預設實現,又能夠覆寫它們所繼承的實現;非
虛函數繼承介面,強制性繼承實現。
樣本:組合是指一個類型嵌入另一個類型的成員變數,即"有一個" 或 "由...來實現"。例如:
class Address{ ... }; //某人居住之處
class PhoneNumber{ ... }; //某人電話號碼
class Person
{
private:
string name; //組合成員變數
Address address; //同上
PhoneNumber voiceNumber; //同上
PhoneNumber faxNumber; //同上
};
原則4.5 避免使用多重繼承
說明: 相比單繼承,多重實現繼承可重用更多代碼;但多重繼承會顯著增加代碼的複雜性,程式可維
護性差,且父類轉換時容易出錯,所以除非必要,不要使用多重實現繼承,使用組合來代替。
多重繼承中基類都是純介面類,至多隻有一個類含有實現。
規則4.12 使用public繼承而不是protected/private繼承
說明:public繼承與private繼承的區別:
private繼承體現"由...來實現"的關係。編譯器不會把private繼承的衍生類別轉換成基類,也就是
說,私人繼承的基類和衍生類別沒有"是...一個"的關係。
public繼承體現"是...一個"的關係,即類B public繼承於類A,則B的對象就是A的對象,反之則
不然。例如“白馬是馬,但馬不是白馬”。
對繼承而言,努力做到"是...一個"的關係,否則使用組合代替。
private繼承意味"由...來實現",它通常比組合的層級低,與組合的區別:
private繼承可以訪問基類的protected成員,而組合不能。
private繼承可以重新定義基類的虛函數,而組合不能。
盡量用組合代替private繼承,因為private繼承不如組合簡單直觀,且容易和public繼承混淆。
規則4.13 繼承層次不超過4層
說明:當繼承的層數超過4層時,對軟體的可維護性大大降低,可以嘗試用組合替代繼承。
規則4.14 虛函數絕不使用預設參數值
說明:在C++中,虛函數是動態綁定的,但函數的預設參數卻是在編譯時間就靜態繫結的。這意味著你最
終執行的函數是一個定義在衍生類別,但使用了基類中的預設參數值的虛函數。因此只要在基類中定義
預設參數值即可,絕對不要在衍生類別中再定義預設參數值。
樣本:虛函數display預設參數值strShow是由編譯時間刻決定的,而非運行時刻,沒有達到多態的目的:
class Base
{
public:
virtual void display(const std::string& strShow = "I am Base class !")
{
std::cout << strShow << std::endl;
}
virtual ~Base(){}
};
class Derive: public Base
{
public:
virtual void display(const std::string& strShow = "I am Derive class !")
{
std::cout << strShow << std::endl;
}
virtual ~Derive(){}
};
int main()
{
Base* pBase = new Derive();
Derive* pDerive = new Derive();
pBase->display(); //程式輸出結果: I am base class !而期望輸出:I am Deriveclass !
pDerive->display();//程式輸出結果: I am Derive class !
delete pBase;
delete pDerive;
return 0;
};
規則4.15 絕不重新定義繼承而來的非虛函數
說明:因為非虛函數無法實現動態綁定,只有虛函數才能實現動態綁定:只要操作基類的指標,即可
獲得正確的結果。
樣本:pB->mf()和pD->mf()兩者行為不同。
class B
{
public:
void mf();
//...
};
class D:public B
{
public:
void mf();
//...
};
D x; //x is an object of type D
B *pB = &x; //get pointer to x
D *pD = &x; //get pointer to x
pB->mf(); //calls B::mf
pD->mf(); //calls D::mf
建議4.5 避免衍生類別中定義與基類同名但參數類型不同的函數
說明:參數類型不同的函數實際是不同的函數。
樣本:如下三個類,類之間繼承關係如下:類Derive2繼承類Derive1,類Derive1繼承類Base。
三個類之中均實現了FOO函數,定義如下:
Base類:virtual long FOO(const A , const B , const C)=0;
Derive1類:long FOO(const A , const B , const C);
Derive2類:long FOO(const A , B , const C);
代碼中存在如下的調用:
Base* baseptr = new Derive2();
baseptr -> FOO(A,B,C);
代碼原意是期望通過如上的代碼調用Derive2::FOO函數,但是由於Derive2::FOO與Base::FOO的
參數類型不一致,Derive2::FOO對Base類來說不可見,導致實際啟動並執行時候,調用到
Derive1::FOO,出現調用錯誤。使得代碼邏輯異常。
解決方案:確保衍生類別Derive2::FOO定義和Base::FOO一致。
建議4.6 衍生類別重定義的虛函數也要聲明virtual關鍵字
說明:當重定義派生的虛函數時,在衍生類別中明確聲明其為virtual。如果遺漏virtual聲明,閱讀者
需要檢索類的所有祖先以確定該函數是否為虛函數。
4.4 重載
C++的重載功能使得同名函數可以有多種實現方法,以簡化介面的設計和使用。但是,要合理運用防止帶
來二義性以及潛在問題。保持重載操作符的自然語義,不要盲目創新。
原則4.6 盡量不重載操作符,保持重載操作符的自然語義
說明:重載操作符要有充分理由,而且不要改變操作符原有語義,例如不要使用 ‘+’操作符來做減運算。
操作符重載令代碼更加直觀,但也有一些不足:
混淆直覺,誤以為該操作和內建類型一樣是高效能的,忽略了效能降低的可能;
問題定位時不夠直觀,按函數名尋找比按操作符顯然更方便。
重載操作符如果行為定義不直觀(例如將‘+’操作符來做減運算),會讓代碼產生混淆。
賦值操作符的重載引入的隱式轉換會隱藏很深的bug。可以定義類似Equals()、CopyFrom()等函數來
替代=,==操作符。
規則4.16 僅在輸入參數類型不同、功能相同時重載函數
說明:使用重載,導致在特定調用處很難確定到底調用的是哪個函數;當衍生類別只重載函數的部分變
量,會對繼承語義產生困惑,造成不必要的費解。
如果函數的功能不同,考慮讓函數名包含參數資訊,例如,使用AppendName()、AppendID()而不是
Append()。
建議4.7 使用重載以避免隱式類型轉換
說明:隱式轉換常常建立臨時變數;如果提供類型精確匹配的重載函數,不會導致轉換。
樣本:
class String
{
//…
String( const char* text ); //允許隱式轉換
};
bool operator==( const String&, const String& );
//…代碼中某處…
if( someString == "Hello" ) {... }
上述例子中編譯器進行隱式轉換,好像someString == String( "Hello")一樣,形成浪費,因為並不需
要拷貝字元。使用操作符重載即可消除這種隱式轉換:
bool operator==( const String& lhs, const String& rhs ); //#1
bool operator==( const String& lhs, const char* rhs ); //#2
bool operator==( const char* lhs, const String& rhs ); //#3
建議4.8 C/C++混用時,避免重載介面函數
說明:目前很多產品採用C模組與C++模組混合的方式,在這個情況下,應該避免模組之間介面函數的
重載。比如:傳遞給用C語言實現的組件的函數指標。
stPdiamLiCallBackFun.pfCreateConn = PDIAM_COM_CreatConnect;
stPdiamLiCallBackFun.pfDeleteConn = PDIAM_COM_DeleteConnect;
stPdiamLiCallBackFun.pfSendMsg = PDIAM_COM_SendData;
stPdiamLiCallBackFun.pfRematchConn = PDIAM_COM_RematchConn;
stPdiamLiCallBackFun.pfSuCloseAcceptSocket = PDIAM_COM_CloseAcceptSocket;
//註冊系統底層通訊函數
ulRet = DiamRegLiFunc(&stPdiamLiCallBackFun);
if (DIAM_OK != ulRet)
{
return PDIAM_ERR_FAILED_STACK_INIT;
}
上面的代碼中,由於組件通過C語言實現,如果重載PDIAM_COM_CreatConnect等函數,將會導致該組件
無法初始化。