C++頭文件設(shè)計(jì)

軟件設(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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 一、溫故而知新 1. 內(nèi)存不夠怎么辦 內(nèi)存簡(jiǎn)單分配策略的問(wèn)題地址空間不隔離內(nèi)存使用效率低程序運(yùn)行的地址不確定 關(guān)于...
    SeanCST閱讀 8,107評(píng)論 0 27
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒(méi)有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對(duì)...
    cosWriter閱讀 11,621評(píng)論 1 32
  • ORA-00001: 違反唯一約束條件 (.) 錯(cuò)誤說(shuō)明:當(dāng)在唯一索引所對(duì)應(yīng)的列上鍵入重復(fù)值時(shí),會(huì)觸發(fā)此異常。 O...
    我想起個(gè)好名字閱讀 5,919評(píng)論 0 9
  • 幾種語(yǔ)言的特性 匯編程序:將匯編語(yǔ)言源程序翻譯成目標(biāo)程序編譯程序:將高級(jí)語(yǔ)言源程序翻譯成目標(biāo)程序解釋程序:將高級(jí)語(yǔ)...
    囊螢映雪的螢閱讀 3,056評(píng)論 1 5
  • 1.淺淺的海灣: 彼時(shí)讀余光中先生的《鄉(xiāng)愁》,還是少年心氣,還未識(shí)得愁的滋味; 現(xiàn)在讀到“鄉(xiāng)愁是一灣淺淺的海灣,我...
    吳此人z閱讀 2,382評(píng)論 1 3

友情鏈接更多精彩內(nèi)容