軟件設(shè)計(jì)的目標(biāo)
軟件設(shè)計(jì)就是為了完成如下目標(biāo),其重要程度依次減低。
- 實(shí)現(xiàn)功能
- 易于重用
- 易于理解
- 沒(méi)有冗余
對(duì)于C++從業(yè)者來(lái)說(shuō),頭文件是最能反映其設(shè)計(jì)思想的,其頭文件的設(shè)計(jì)的合理性規(guī)范性及嚴(yán)謹(jǐn)性最能體現(xiàn)從業(yè)者的水平。
編譯鏈接
為了將C/C++代碼轉(zhuǎn)換為可以在硬件上運(yùn)行的程序,需要經(jīng)過(guò)編譯和鏈接。(關(guān)于編譯及鏈接的簡(jiǎn)單介紹: CMake搭建項(xiàng)目工程(1)-C/C++編譯及CMake那些事)。源文件(.c.cpp.cc)文件經(jīng)過(guò)編譯生成目標(biāo)文件(.o),目標(biāo)文件會(huì)提供三張表,未解決符號(hào)表,導(dǎo)出符號(hào)表和地址重定向表。
- 未解決符號(hào)表提供在該編譯單元里引用但不在本編譯單元里實(shí)現(xiàn)的符號(hào)及其出現(xiàn)的地址。(如源文件a.cc中引用b.cc中實(shí)現(xiàn)的函數(shù)
int add(int, int)) - 導(dǎo)出符號(hào)表提供了本編譯單元具有定義,并且提供給其他編譯單元使用的符號(hào)及其地址。(如b.cc函數(shù)
int add(int, int),它暴露在b.h中,可以被其他源文件引用) - 地址重定向表提供了本編譯單元所有對(duì)自身地址的引用的記錄。
在鏈接過(guò)程中,鏈接器首先決定各個(gè)目標(biāo)文件在最終可執(zhí)行文件里的位置。然后訪問(wèn)所有目標(biāo)文件的地址重定向表,對(duì)其中記錄的地址進(jìn)行重定向(即加上該編譯單元實(shí)際在可執(zhí)行文件里的起始地址);然后鏈接器會(huì)遍歷目標(biāo)文件的未解決符號(hào)表,在導(dǎo)出符號(hào)表里查找需要匹配的符號(hào),并在未解決符號(hào)表中所記錄的位置上填寫實(shí)際的地址。
頭文件作用
為什么開篇講述編譯鏈接過(guò)程,那是因?yàn)?code>未解決符號(hào)表和導(dǎo)出符號(hào)表都與頭文件緊密聯(lián)系,實(shí)例化說(shuō)明,有頭文件a.h和其對(duì)應(yīng)的源文件a.cc,
及頭文件b.h和對(duì)應(yīng)的源文件b.cc,a.h包含著a.cc要對(duì)外暴露的接口(函數(shù)或類等)聲明,b.h包含著b.cc要對(duì)外暴露的接口(函數(shù)或類等)聲明,對(duì)于a.cc編譯產(chǎn)生a.o的導(dǎo)出符號(hào)表可以認(rèn)為是a.h里聲明的接口的信息,而未解決符號(hào)表就是a.cc中引用b.h的函數(shù)的信息。鏈接過(guò)程就是在a.o的未解決符號(hào)表在b.o中的導(dǎo)出符號(hào)表找到并建立鏈接的過(guò)程。(鏈接又分為靜態(tài)鏈接和動(dòng)態(tài)鏈接)
頭文件的作用其實(shí)就是對(duì)外暴露接口,它像一個(gè)功能組件對(duì)外提供某些職責(zé)。(在JAVA語(yǔ)言里沒(méi)有頭文件的概念,但是其元素、函數(shù)的可見性也在一定程度上起到了頭文件的作用)
頭文件包含的內(nèi)容
在C++頭文件里應(yīng)該放哪些內(nèi)容呢?在最開始工作的時(shí)候,編寫代碼更多的是為了實(shí)現(xiàn)功能,于是和以前的代碼一樣,把函數(shù)的實(shí)現(xiàn)放在源文件里,把函數(shù)的聲明放在頭文件里,完全沒(méi)有思考頭文件的含義。如果一個(gè)函數(shù)只在自身的源文件中使用,那放在頭文件里其實(shí)就變相的增大了它的可見性,而如果每個(gè)頭文件都是如此的話,那整個(gè)系統(tǒng)的耦合性非常大。在JAVA語(yǔ)言中,其實(shí)也有類似的設(shè)計(jì)理念,JAVA對(duì)可見性不但包含private、protected、public還包括包可見性,這其實(shí)就要求我們寫代碼時(shí),對(duì)每個(gè)變量每個(gè)接口的可見性有很清楚的認(rèn)知,而不是不明所以的變量全部private,接口全部public。正如作者在工作前幾年寫C++代碼時(shí),把所有的類、函數(shù)的聲明都放在頭文件里一樣,這樣的頭文件設(shè)計(jì)無(wú)疑是糟糕的。
struct VS class
struct是C語(yǔ)言引入的關(guān)鍵字,class是C++引入的關(guān)鍵字,兩者都可以包裝類型,但存在以下差異:
- 默認(rèn)可見性,struct的默認(rèn)可見性是public,class的默認(rèn)可見性是private
- 繼承的可見性,struct默認(rèn)是public繼承,而class如果不特別指明默認(rèn)為private繼承
- class可用于定義模板參數(shù)(類似typename),但struct不能
由于在代碼設(shè)計(jì)中需要使用繼承,而class需要顯示的指明是public繼承,存在一定冗余,所以建議仍然使用struct。
我們定義類A、B,希望類C公有繼承A和B,對(duì)比以下代碼,在使用struct時(shí),繼承關(guān)系非常清爽,而如果使用class,則在每個(gè)父類前都需要加上額外的繼承可見性,如果在B前面少加了public,則B默認(rèn)為private,會(huì)引入編譯錯(cuò)誤。
class C : public A, public B{
};
struct C : A, B{
}
C++頭文件設(shè)計(jì)原則
自包含原則
自包含原則是組件不依賴其他組件,能夠以獨(dú)立的方式供外部使用,即:任意一個(gè)頭文件均可獨(dú)立編譯。如果一個(gè)頭文件不滿足自包含原則,即所有包含該頭文件的源文件都需要包含其他的頭文件方可通過(guò)編譯,無(wú)疑增加了依賴的復(fù)雜性。這項(xiàng)原則的檢查非常簡(jiǎn)單,在寫完頭文件后,在源文件里只包含該頭文件,然后編譯如果不報(bào)錯(cuò),則說(shuō)明滿足自包含原則。
當(dāng)然為了滿足自包含原則,而在頭文件中不加判斷的去包含其他的頭文件無(wú)疑是糟糕的做法,這樣任何頭文件的變化都會(huì)導(dǎo)致連鎖的多個(gè)源文件的重新編譯,會(huì)顯著增加編譯時(shí)間。這就引來(lái)下一個(gè)原則。
優(yōu)先使用前置聲明來(lái)減少編譯時(shí)依賴
在頭文件中使用了其他的類或者接口,那需要根據(jù)情況決定是前置聲明還是包含頭文件。對(duì)于頭文件中只是使用指針、引用、返回值、函數(shù)參數(shù)的情況,只需要前置聲明即可,無(wú)需引入頭文件。相反地,如果編譯器需要知道實(shí)體的真正內(nèi)容時(shí),則必須包含頭文件,此依賴也常常稱為強(qiáng)編譯時(shí)依賴。如繼承、宏等都需要包含對(duì)應(yīng)的頭文件。
如下面代碼,由于在該頭文件中Cell、CellMap、NeighbourStateTrans都是指針、引用、返回值、函數(shù)參數(shù)的這些情況,所以無(wú)需引入頭文件,只需要前置聲明即可。這樣Cell、CellMap、NeighbourStateTrans類的變化不會(huì)引起只包含CellTrans.h的源文件的重新編譯,減少了編譯時(shí)間。
struct Cell;
struct CellMap;
struct NeighbourStateTrans;
struct CellTrans{
bool oneRoundCellChange(NeighbourStateTrans* trans) const;
CellMap changeCompelete(Cell c) const;
private:
bool doCellChange() const;
virtual CellMap& getCellMap() const = 0;
};
單一職責(zé)
頭文件職責(zé)不單一,依賴不相關(guān)元素,則會(huì)導(dǎo)致所有包含該頭文件的所有實(shí)現(xiàn)文件都被這些不相關(guān)元素所污染,這也是導(dǎo)致編譯時(shí)間過(guò)長(zhǎng)的主要原因。這也是SOLID原則中的單一職責(zé)SRP(Single Reponsibility Priciple)在頭文件設(shè)計(jì)時(shí)的一個(gè)具體運(yùn)用。頭文件職責(zé)單一,當(dāng)然會(huì)增加頭文件的數(shù)量,但這不是問(wèn)題,每個(gè)功能單一的類或者頭文件,其實(shí)現(xiàn)也會(huì)非常簡(jiǎn)單,其變化方向可控,這樣可以使用利用這些功能單一的類或者接口進(jìn)行組合式設(shè)計(jì)。如果一個(gè)類的職責(zé)過(guò)多,很容易在需求的演變過(guò)程中變?yōu)樯系垲悺?/p>
頭文件盡量不放置實(shí)現(xiàn)
頭文件的作用是對(duì)外暴露接口,如果將實(shí)現(xiàn)代碼也放在頭文件中(模板實(shí)現(xiàn)只能放在頭文件里不再此列),則就將自己的實(shí)現(xiàn)細(xì)節(jié)暴露,而這個(gè)實(shí)現(xiàn)細(xì)節(jié)是一種不穩(wěn)定的因素,如果有一天我的內(nèi)部設(shè)計(jì)或者數(shù)據(jù)結(jié)構(gòu)發(fā)生變化,則會(huì)導(dǎo)致函數(shù)實(shí)現(xiàn)需要重寫,則這樣會(huì)帶來(lái)大量文件的重新編譯,所以盡量在頭文件里不要放置函數(shù)實(shí)現(xiàn)(inline包括在內(nèi))。當(dāng)然需要注意一些特殊的情況,由于創(chuàng)建其實(shí)現(xiàn)文件沒(méi)有必要,可以將實(shí)現(xiàn)放在頭文件中。如virtual析構(gòu)函數(shù)、空的virtual函數(shù)實(shí)現(xiàn)或C++11的default函數(shù)等。
禁止頭文件循環(huán)依賴
頭文件的循環(huán)依賴帶來(lái)的問(wèn)題是牽一發(fā)而動(dòng)全身,任何一個(gè)頭文件的修改都會(huì)引起一大批文件的重新編譯,當(dāng)然出現(xiàn)循環(huán)依賴時(shí),更應(yīng)該去考量自己的設(shè)計(jì)是否合理,為何會(huì)出現(xiàn)如此強(qiáng)的依賴出現(xiàn)。
盡量使用PIMPL設(shè)計(jì)手法
PIMPL:Private Implementation,是使用指針來(lái)隱藏對(duì)象的實(shí)現(xiàn)細(xì)節(jié)。也叫編譯防火墻。它是GOF的Bridge模式的一種應(yīng)用,它的出現(xiàn)也是體現(xiàn)了頭文件即是接口的真諦。它有以下好處:
- 降低模塊的耦合,隱藏了類的實(shí)現(xiàn)
- 降低編譯依賴,提高編譯速度
- 接口與實(shí)現(xiàn)分離,提高接口的穩(wěn)定性
PIMPL的寫法:(在頭文件中只暴露X的接口,X包含一個(gè)XImpl的私有指針,XIMPL在源文件中定義及實(shí)現(xiàn))
class X {
public:
/* ... public functions ... */
private:
class XImpl* pimpl_; // opaque pointer to forward-declared class
};
在實(shí)現(xiàn)層面,有四種操作手法:
- 1.把所有的private數(shù)據(jù)(不包含函數(shù))XImpl
- 2.把所有私有成員(包含函數(shù))放入XImpl;
- 3.把所有的私有成員和保護(hù)成員放入XImpl
- 4.X只聲明公有的接口,將所有的實(shí)現(xiàn)都放入XImpl
最常用的手法為2和4,筆者最喜歡4的方式,但是4的應(yīng)用場(chǎng)景也受限,需要根據(jù)具體設(shè)計(jì)考量。重點(diǎn)闡述一下手法2,這里說(shuō)的把所有的私有成員包括函數(shù)放入XImpl中并不準(zhǔn)確,不能將virtual的功能函數(shù)放入到XImpl的類中,這也很好理解。
信息隱藏原則
在頭文件中定義類時(shí),public, protected, private的準(zhǔn)確區(qū)分可以傳遞設(shè)計(jì)意圖。其中private做為一種實(shí)現(xiàn)細(xì)節(jié)被隱藏起來(lái),為適應(yīng)未來(lái)不明確的變化提供便利。盡量不要將數(shù)據(jù)設(shè)置為public,因?yàn)閿?shù)據(jù)可以認(rèn)為是一種實(shí)現(xiàn)方式,如果隨著需求的變化,數(shù)據(jù)結(jié)構(gòu)可能發(fā)生變化,則對(duì)外暴露的public的數(shù)據(jù)將是災(zāi)難,而應(yīng)該盡可能的將所有實(shí)體設(shè)置為私有。外部的類只會(huì)調(diào)用其public方法,如果只是實(shí)現(xiàn)方式發(fā)生變化,不會(huì)波及其他領(lǐng)域。
頭文件宏的唯一性
每一個(gè)頭文件都應(yīng)該具有獨(dú)一無(wú)二的保護(hù)宏,并保持命名規(guī)則的一致性,切記不要太短,在項(xiàng)目較大時(shí),如果頭文件保護(hù)宏過(guò)短,很容易出現(xiàn)重復(fù)情況,可以采用<PROJECT><MODULE>_<FILE>H形式避免。
頭文件路徑注意大小寫敏感
由于在windows下大小寫是不敏感的,而在linux下,大小寫是敏感的,DEMO和demo是兩個(gè)完全不同的目錄,所以在書寫包含路徑時(shí)大小寫一定寫正確,來(lái)保證代碼的可移植性。筆者一般參考JAVA包的方式,所有的路徑文件夾采用全小寫字母(另外斜杠采用 /)。
利用namespace避免沖突
- 合理使用C++引入的namespace來(lái)避免命名沖突
- 頭文件嚴(yán)禁使用using namespace,污染太大,使用(空間名::xx)方式
- 在實(shí)現(xiàn)文件里使用匿名namespace代替static。
WalkeR-ZG