說明本文假定讀者已具備基本的C編譯知識,。 如非特殊說明,,文中“源文件”指 * .c文件,,“頭文件”指 *.h文件,,“引用”指包含頭文件,。 一,、頭文件作用C語言里,每個源文件是一個模塊,,頭文件為使用該模塊的用戶提供接口,。接口指一個功能模塊暴露給其他模塊用以訪問具體功能的方法。 使用源文件實現(xiàn)模塊的功能,,使用頭文件暴露單元的接口,。用戶只需包含相應的頭文件就可使用該頭文件中暴露的接口。 通過頭文件包含的方法將程序中的各功能模塊聯(lián)系起來有利于模塊化程序設計: 1)通過頭文件調用庫功能,。在很多場合,,源代碼不便(或不準)向用戶公布,只要向用戶提供頭文件和二進制庫即可,。用戶只需按照頭文件中的接口聲明來調用庫功能,,而不必關心接口如何實現(xiàn)。編譯器會從庫中提取相應的代碼,。 2)頭文件能加強類型安全檢查,。若某個接口的實現(xiàn)或使用方式與頭文件中的聲明不一致,編譯器就會指出錯誤,。這一簡單的規(guī)則能大大減輕程序員調試,、改錯的負擔。 在預處理階段,,編譯器將源文件包含的頭文件內容復制到包含語句(#include)處,。在源文件編譯時,連同被包含進來的頭文件內容一起編譯,,生成目標文件(.obj),。 如果所包含的頭文件非常龐大,則會嚴重降低編譯速度(使用GCC的-E選項可獲得并查看最終預處理完的文件),。因此,,在源文件中應僅包含必需的頭文件,且盡量不要在頭文件中包含其它頭文件,。 二,、 頭文件組織原則源文件中實現(xiàn)變量、函數(shù)的定義,,并指定鏈接范圍,。頭文件中書寫外部需要使用的全局變量、函數(shù)聲明及數(shù)據(jù)類型和宏的定義,。 建議組織頭文件內容時遵循以下原則: 1)頭文件劃分原則:類型定義,、宏定義盡量與函數(shù)聲明相分離,分別位于不同的頭文件中,。內部函數(shù)聲明頭文件與外部函數(shù)聲明頭文件相分離,,內部類型定義頭文件與外部類型定義頭文件相分離,。 注意,類型和宏定義有時無法分拆為不同文件,,比如結構體內數(shù)組成員的元素個數(shù)用常量宏表示時,。因此僅分離類型宏定義與函數(shù)聲明,且分別置于*.th和*.fh文件(并非強制要求),。 2)頭文件的語義層次化原則:頭文件需要有語義層次,。不同語義層次的類型定義不要放在一個頭文件中,不同層次的函數(shù)聲明不要放在一個頭文件中,。 3)頭文件的語義相關性原則:同一頭文件中出現(xiàn)的類型定義,、函數(shù)聲明應該是語義相關的、有內部邏輯關系的,,避免將無關的定義和聲明放在一個頭文件中,。 4)頭文件名應盡量與實現(xiàn)功能的源文件相同,即module.c和module.h,。但源文件不一定要包含其同名的頭文件,。 5)頭文件中不應包含本地數(shù)據(jù),以降低模塊間耦合度,。 即只有源文件自己使用的類型,、宏定義和變量、函數(shù)聲明,,不應出現(xiàn)在頭文件里,。作用域限于單文件的私有變量和函數(shù)應聲明為static,以防止外部調用,。將私有類型置于源文件中,,會提高聚合度,并減少不必要的格式外漏,。 6)頭文件內不允許定義變量和函數(shù),,只能有宏、類型(typedef/struct/union/enum等)及變量和函數(shù)的聲明,。特殊情況下可extern基本類型的全局變量,,源文件通過包含該頭文件訪問全局變量。但頭文件內不應extern自定義類型(如結構體)的全局變量,,否則將迫使本不需要訪問該變量的源文件包含自定義類型所在頭文件[1],。 7)說明性頭文件不需要有對應的源文件。此類頭文件內大多包含大量概念性宏定義或枚舉類型定義,,不包含任何其他類型定義和變量或函數(shù)聲明。此類頭文件也不應包含任何其他頭文件,。 8)使用#pragma once或header guard(亦稱include guard或macro guard)避免頭文件重復包含,。#pragma once是一種非標準但已被現(xiàn)代編譯器廣泛支持的技巧,,它明確告知預處理器“不要重復包含當前頭文件”。而header guard則通過預處理命令模擬類似行為: #ifndef _PRJ_DIR_FILE_H //必須確保header guard宏名永不重名 使用#pragma once相比header guard具有兩個優(yōu)點:
被extern 'C'修飾的變量和函數(shù)將按照C語言方式編譯和連接,否則編譯器將無法找到C函數(shù)定義,,從而導致鏈接失敗,。 10)頭文件內要有面向用戶的充足注釋,從應用角度描述接口暴露的內容,。 三,、 頭文件包含原則在實際編程中,常常因頭文件包含不當而引發(fā)編譯時報告符號未定義的錯誤或重復定義的警告,。要消除符號未定義的編譯錯誤,,只需在引用符號(變量、函數(shù),、數(shù)據(jù)類型及宏等)前確保它已被聲明或定義[4],。要消除重復定義的警告,則需合理設計頭文件包含順序和層次,。 建議包含頭文件時遵循以下原則: 1)源文件內的頭文件包含順序應從最特殊到一般,,如: #include '通用頭文件' //內部可能定義本模塊數(shù)據(jù)類型別名 優(yōu)點是每個頭文件必須include需要的關聯(lián)頭文件,否則會報錯,。同時,,源文件同名頭文件置于包含列表前端便于檢查該頭文件是否自完備,以及類型或函數(shù)聲明是否與標準庫沖突,。 2)減少頭文件的嵌套和交叉引用,,頭文件僅包含其真正需要顯式包含的頭文件。 例如,頭文件A中出現(xiàn)的類型定義在頭文件B中,,則頭文件A應包含頭文件B,,除此以外的其他頭文件不允許包含。 頭文件的嵌套和交叉引用會使程序組織結構和文件組織變得混亂,,同時造成潛在的錯誤,。大型工程中,原有頭文件可能會被多個其他(源或頭)文件包含,,在原有頭文件中添加新的頭文件往往牽一發(fā)而動全身,。若頭文件中類型定義需要其他頭文件時,可將其提出來單獨形成一個全局頭文件,。 3)頭文件應包含哪些頭文件僅取決于自身,,而非包含該頭文件的源文件。 例如,,編譯源文件時需要用到頭文件B,,且源文件已包含頭文件A,而索性將頭文件B包含在頭文件A中,,這是錯誤的做法,。 4)盡量保證用戶使用此頭文件時,無需手動包含其他前提頭文件,,即此頭文件內已包含前提頭文件,。 例如,面積相關操作的頭文件Area.h內已包含關于點操作的頭文件Point.h,,則用戶包含Area.h后無需再手動包含Point.h,。這樣用戶就不必了解頭文件的內在依賴關系。 5)頭文件應是自完備的,,即在任一源文件中包含任一頭文件而不會產(chǎn)生編譯錯誤,。 6)源文件中包含的頭文件盡量不要有順序依賴。 7)盡量在源文件中包含頭文件,,而非在頭文件中,。且源文件僅包含所需的頭文件。 8)頭文件中若能前置聲明(亦稱前向聲明[5]),,就不要包含另一頭文件,。僅當前置聲明不能滿足或過于麻煩時才使用include,如此可減少依賴性方面的問題,。示例如下:
如上,,在OmciChkFunc函數(shù)的實現(xiàn)源文件內包含T_MeInfoMap和T_OmciMsg所在頭文件即可。 另舉一例如下: typedef TBL_SET_MODE (*OperTypeFunc)(INT8U *pTblEntry); 如上,,CompareRecFunc函數(shù)原型由其他頭文件提供,,此處為避免頭文件交叉引用定義其異名同構原型CmpRecFunc。 在不會引起歧義的前提下,,頭文件內盡可能使用VOID指針代替非基本類型的值變量或指針,,以避免再包含類型定義所在的頭文件。但這將影響代碼可讀性并降低程序執(zhí)行效率,,應權衡利弊,。 9)避免包含重量級的平臺頭文件,如windows.h或d3d9.h等,。若僅使用該頭文件少量函數(shù),,可extern函數(shù)到源文件內。如下:
若還使用該頭文件某些類型和宏定義,可創(chuàng)建適配性源文件,。在該源文件內包含平臺頭文件,,封裝新的接口并將其聲明在同名頭文件內,其他源文件將通過適配頭文件間接訪問平臺接口,。如下: /***************************************************************************************** 10)對于函數(shù)庫(包括標準庫和自定義的公共宏及接口)的頭文件,,可將其加入到一個通用頭文件中,。需要控制該頭文件的體積(主要是該頭文件所包含的所有頭文件內容大小),并確保所有源文件首先包含該通用頭文件,。示例如下:
注意,,示例頭文件內包含C庫文件雖能簡化包含,但卻與規(guī)則1沖突,。也可另外增加包含庫文件列表的通用頭文件,。 11)若不確定類型、宏定義或函數(shù)聲明所在頭文件具體路徑,可在源文件中再次定義或聲明,,編譯器會以redefined警告或conflicting錯誤給出類型,、宏定義或函數(shù)聲明所在頭文件路徑。 四,、代碼文件組織原則建議C語言項目中代碼文件組織遵循以下原則: 1)使用層次化和模塊化的軟件開發(fā)模型,。每個模塊只能使用所在層和下一層模塊提供的接口。 2)每個模塊的文件(可能多個)保存在一個獨立文件夾中,。 模塊文件較多時可采用子目錄的方式,,物理上隔離不同層次的文件。子目錄下源文件和頭文件應分開存放,,如分別置入include和source目錄,。 3)用于模塊裁減的條件編譯宏保存在一個獨立文件中,便于軟件裁減,。 4)硬件相關代碼和操作系統(tǒng)相關代碼與工程代碼相對獨立保存,,以便于軟件移植。 5)按相同功能或相關性組織源文件和頭文件,。同一文件內的聚合度要高,,不同文件中的耦合度要低。 在對既有工程做單元測試時,,耦合度低的文件布局非常便于搭建環(huán)境,。 6)聲明和定義分開,使用頭文件暴露模塊需要提供給外部的類型,、宏,、變量和函數(shù)。盡量做到模塊對外部透明,,用戶在使用模塊功能時無需了解具體的實現(xiàn),。 7)作為對外接口的頭文件一經(jīng)發(fā)布,應保持穩(wěn)定,。修改時一定要慎重,。 8)文件夾和文件命名要能夠反映出模塊的功能。 9)正式版本和測試版本使用統(tǒng)一文件,,使用宏控制是否產(chǎn)生測試輸出,。 10)必要的注釋不可缺少。 五,、 注解「【注1】全局變量的使用原則」 1)若全局變量僅在單個源文件中訪問,,則可將該變量改為該文件內的靜態(tài)全局變量; 2)若全局變量僅由單個函數(shù)訪問,,則可將該變量改為該函數(shù)內的靜態(tài)局部變量,; 3)盡量不要使用extern聲明全局變量,,最好提供函數(shù)訪問這些變量。直接暴露全局變量是不安全的,,外部用戶未必完全理解這些變量的含義,。 4)設計和調用訪問動態(tài)全局變量、靜態(tài)全局變量,、靜態(tài)局部變量的函數(shù)時,,需要考慮重入問題。 「【注2】#pragma once的可移植性」 #ifndef由C/C++語言標準支持,,不受編譯器任何限制,;而#pragma once僅由編譯器提供保證,,存在可移植性等問題,。 某些gcc編譯器版本(如3.2.3)會報告“warning: #pragma once is obsolete”的警告,而其他較老版本的編譯器可能會報錯,。但隨著gcc 3.4的發(fā)布,,#pragma once中的一些問題(主要與符號鏈接和硬鏈接有關)得以解決,#pragma once命令也標記為“未廢棄”,。 還有種寫法同時使用#pragma once和header guard編寫“可移植性”代碼,,以利用編譯器可能支持的#pragma once優(yōu)化。如下: #pragma once 該法似乎兼有兩者的優(yōu)點,。但既然使用#ifndef就有宏名重名的風險,,也無法避免不支持#pragma once的編譯器告警或報錯,故混用兩種方法似乎不能帶來更多的好處,,反倒讓不熟悉的人感到困惑,。 注意,如果使用header guard,,理論上可在代碼任何地方判斷當前是否已經(jīng)包含某個頭文件,。但應避免通過該判斷來改變后續(xù)代碼的邏輯走向! 這種做法將使程序依賴于頭文件的包含順序,,極不可取,。若需要實現(xiàn)“若當前包含HeaderA.h,才加入StructB結構”,,可對StructB結構創(chuàng)建HeaderB.h頭文件,,在HeaderA.h中包含HeaderB.h。 「【注3】extern 'C'」 C++語言在編譯時為實現(xiàn)函數(shù)重載,,會結合函數(shù)名,、參數(shù)數(shù)目及類型信息而生成一個中間函數(shù)名。 例如,,C++中函數(shù)void foo(int x, float y)編譯后在符號庫中生成的名字為_foo_int_float(不同編譯器可能生成不同函數(shù)名,,但均采用相同機制,,生成的新名字稱為”mangled name”);而該函數(shù)被C編譯器編譯后在符號庫中的名字為_foo,。 C語言中不支持extern 'C'聲明,,在.c文件中包含extern 'C'時會出現(xiàn)編譯語法錯誤。 當然編譯器也可以為其他語言提供鏈接說明,。例如:extern 'FORTRAN',、extern 'Ada'等。 「【注4】聲明(declaration)與定義(definition)」 全局變量或函數(shù)可(在多個編譯單元中)有多處聲明,,但只允許定義一次,。全局變量定義時分配空間并賦初始值(如果有);函數(shù)定義時提供函數(shù)體內容,。
在多個源文件中共享變量或函數(shù)時,,需確保定義和聲明的一致性。通常在某個相關的源文件中定義,,然后在頭文件中進行外部聲明,。需要使用時包含相應的頭文件即可。定義變量的源文件也應包含該頭文件,,以便編譯器檢查定義和聲明的一致性,。 該規(guī)則可提供高度的可移植性:它與ANSI/ISO C標準一致,同時也兼顧大多數(shù)ANSI前的編譯器和鏈接器,。(Unix編譯器和鏈接器常使用允許多重定義的“通用模式”,,只要保證最多對一處定義進行初始化即可。 該方式被ANSI C標準稱為一種“通用擴展”),。某些很老的系統(tǒng)可能要求顯式初始化以區(qū)別定義和外部聲明,。 通用擴展在《深入理解計算機系統(tǒng)》中解釋為:多重定義的符號只允許最多一個強符號。函數(shù)和定義時已初始化的全局變量是強符號,;未初始化的全局變量是弱符號,。Unix鏈接器使用以下規(guī)則來處理多重定義的符號: 規(guī)則一:不允許有多個強符號。在被多個源文件包含的頭文件內定義的全局變量會被定義多次(預處理階段會將頭文件內容展開在源文件中),,若在定義時顯式地賦值(初始化),,則會違反此規(guī)則。 規(guī)則二:若存在一個強符號和多個弱符號,,則選擇強符號,。 規(guī)則三:若存在多個弱符號,則從這些弱符號中任選一個,。 當不同文件內定義同名(即便類型和含義不同)的全局變量時,,該變量共享同一塊內存(地址相同)。若變量定義時均初始化,,則會產(chǎn)生重定義(multiple definition)的鏈接錯誤,;若某處變量定義時未初始化,,則無鏈接錯誤,僅在因類型不同而大小不同時可能產(chǎn)生符號大小變化(size of symbol `XXX' changed)的編譯警告,。 在最壞情況下,,編譯鏈接正常,但不同文件對同名全局變量讀寫時相互影響,,引發(fā)非常詭異的問題,。這種風險在使用無法接觸源碼的第三方庫時尤為突出。 因此,,應盡量避免使用全局變量,。若確有必要,應采用靜態(tài)全局變量(無強弱之分,,且不會和其他全局符號產(chǎn)生沖突),,并封裝訪問函數(shù)供外部文件調用。 「【注5】前向聲明(forward declaration)」 結構體類型S在聲明之后定義之前是一個不完全類型(incomplete type),,即已知S是一個類型,,但不知道包含哪些成員。 不完全類型只能用于定義指向該類型的指針,,或聲明使用該類型作為形參指針類型或返回指針類型的函數(shù)。指針類型對編譯器而言大小固定(如32位機上為四字節(jié)),,不會出現(xiàn)編譯錯誤,。 假設先后定義兩個結構A和B,且兩個結構需要互相引用,。在定義A時B還沒有定義,,則要引用B就需要前向聲明結構B(struct B;)。示例如下: typedef BOOL (*func)(const DefStruct *ptStrt); 如上在DefStruct中使用回調函數(shù)func聲明,,這樣交叉引用必然編譯報錯,。進行前向聲明即可:
注意,在前向聲明和具體定義之間涉及標識符(變量,、結構,、函數(shù)等)實現(xiàn)細節(jié)的使用都是非法的。若函數(shù)被前向聲明但未被調用,,則編譯和運行正常,;若前向聲明函數(shù)被調用但未被定義,則編譯正常但鏈接報錯(undefined reference),。將具體定義放在源文件中可部分避免該問題,。 |
|