C語言頭文件組織與包含原則


說明

     本文假定讀者已具備基本的C編譯知識。

     如非特殊說明,文中“源文件”指*.c文件,“頭文件”指*.h文件,“引用”指包含頭文件。

 

一、頭文件作用

     C語言里,每個源文件是一個模塊,頭文件為使用該模塊的用戶提供接口。接口指一個功能模塊暴露給其他模塊用以訪問具體功能的方法。使用源文件實現模塊的功能,使用頭文件暴露單元的接口。用戶只需包含相應的頭文件就可使用該頭文件中暴露的接口。

     通過頭文件包含的方法將程序中的各功能模塊聯系起來有利於模塊化程序設計:

     1)通過頭文件調用庫功能。在很多場合,源代碼不便(或不准)向用戶公布,只要向用戶提供頭文件和二進制庫即可。用戶只需按照頭文件中的接口聲明來調用庫功能,而不必關心接口如何實現。編譯器會從庫中提取相應的代碼。

     2)頭文件能加強類型安全檢查。若某個接口的實現或使用方式與頭文件中的聲明不一致,編譯器就會指出錯誤。這一簡單的規則能大大減輕程序員調試、改錯的負擔。

     在預處理階段,編譯器將源文件包含的頭文件內容復制到包含語句(#include)處。在源文件編譯時,連同被包含進來的頭文件內容一起編譯,生成目標文件(.obj)。如果所包含的頭文件非常龐大,則會嚴重降低編譯速度(使用GCC的-E選項可獲得並查看最終預處理完的文件)。因此,在源文件中應僅包含必需的頭文件,且盡量不要在頭文件中包含其它頭文件。

 

二、 頭文件組織原則

     源文件中實現變量、函數的定義,並指定鏈接范圍。頭文件中書寫外部需要使用的全局變量、函數聲明及數據類型和宏的定義。

     建議組織頭文件內容時遵循以下原則:

     1)頭文件划分原則:類型定義、宏定義盡量與函數聲明相分離,分別位於不同的頭文件中。內部函數聲明頭文件與外部函數聲明頭文件相分離,內部類型定義頭文件與外部類型定義頭文件相分離。

     注意,類型和宏定義有時無法分拆為不同文件,比如結構體內數組成員的元素個數用常量宏表示時。因此僅分離類型宏定義與函數聲明,且分別置於*.th和*.fh文件(並非強制要求)。

     2)頭文件的語義層次化原則:頭文件需要有語義層次。不同語義層次的類型定義不要放在一個頭文件中,不同層次的函數聲明不要放在一個頭文件中。

     3)頭文件的語義相關性原則:同一頭文件中出現的類型定義、函數聲明應該是語義相關的、有內部邏輯關系的,避免將無關的定義和聲明放在一個頭文件中。

     4)頭文件名應盡量與實現功能的源文件相同,即module.c和module.h。但源文件不一定要包含其同名的頭文件。

     5)頭文件中不應包含本地數據,以降低模塊間耦合度。

     即只有源文件自己使用的類型、宏定義和變量、函數聲明,不應出現在頭文件里。作用域限於單文件的私有變量和函數應聲明為static,以防止外部調用。將私有類型置於源文件中,會提高聚合度,並減少不必要的格式外漏。

     6)頭文件內不允許定義變量和函數,只能有宏、類型(typedef/struct/union/enum等)及變量和函數的聲明。特殊情況下可extern基本類型的全局變量,源文件通過包含該頭文件訪問全局變量。但頭文件內不應extern自定義類型(如結構體)的全局變量,否則將迫使本不需要訪問該變量的源文件包含自定義類型所在頭文件[1]

     7)說明性頭文件不需要有對應的源文件。此類頭文件內大多包含大量概念性宏定義或枚舉類型定義,不包含任何其他類型定義和變量或函數聲明。此類頭文件也不應包含任何其他頭文件。

     8)使用#pragma once或header guard(亦稱include guard或macro guard)避免頭文件重復包含。#pragma once是一種非標准但已被現代編譯器廣泛支持的技巧,它明確告知預處理器“不要重復包含當前頭文件”。而header guard則通過預處理命令模擬類似行為:

1 #ifndef  _PRJ_DIR_FILE_H  //必須確保header guard宏名永不重名
2 #define  _PRJ_DIR_FILE_H
3 
4 //<頭文件內容>
5 
6 #endif
View Code

     使用#pragma once相比header guard具有兩個優點[2]

  • 更快。編譯器不會第二次讀取標記#pragma once的文件,但卻會讀若干遍使用header guard 的文件(尋找#endif);
  • 更簡單。不再需要為每個文件的header guard取名,避免宏名重名引發的“找不到聲明”問題。

     缺點則是:

  • #pragma once保證物理上的同一個文件不會被包含多次,無法對頭文件中的一段代碼作#pragma once聲明。若某個頭文件具有多份拷貝(內容相同的多個文件),pragma不能保證它們不被重復包含。當然,這種重復包含很容易被發現並修正。

     9) C++中要引用C函數時,函數所在頭文件內應包含extern "C"[3]

 1 //.h文件頭部
 2 #ifdef  __cplusplus
 3 extern "C" {
 4 #endif
 5 
 6 //<函數聲明>
 7 
 8 //.h文件尾部
 9 #ifdef  __cplusplus
10 }
11 #endif
View Code

     被extern "C"修飾的變量和函數將按照C語言方式編譯和連接,否則編譯器將無法找到C函數定義,從而導致鏈接失敗。

     10)頭文件內要有面向用戶的充足注釋,從應用角度描述接口暴露的內容。

 

三、 頭文件包含原則

     在實際編程中,常常因頭文件包含不當而引發編譯時報告符號未定義的錯誤或重復定義的警告。要消除符號未定義的編譯錯誤,只需在引用符號(變量、函數、數據類型及宏等)前確保它已被聲明或定義[4]。要消除重復定義的警告,則需合理設計頭文件包含順序和層次。

     建議包含頭文件時遵循以下原則:

     1)源文件內的頭文件包含順序應從最特殊到一般,如:

#include "通用頭文件"  //內部可能定義本模塊數據類型別名

#include "源文件同名頭文件"

#include "本模塊其他頭文件"

#include "自定義工具頭文件"

#include "第三方頭文件"

#include "平台相關頭文件"

#include "C++庫頭文件"

#include "C庫頭文件"

     優點是每個頭文件必須include需要的關聯頭文件,否則會報錯。同時,源文件同名頭文件置於包含列表前端便於檢查該頭文件是否自完備,以及類型或函數聲明是否與標准庫沖突。

     2)減少頭文件的嵌套和交叉引用,頭文件僅包含其真正需要顯式包含的頭文件。

     例如,頭文件A中出現的類型定義在頭文件B中,則頭文件A應包含頭文件B,除此以外的其他頭文件不允許包含。

     頭文件的嵌套和交叉引用會使程序組織結構和文件組織變得混亂,同時造成潛在的錯誤。大型工程中,原有頭文件可能會被多個其他(源或頭)文件包含,在原有頭文件中添加新的頭文件往往牽一發而動全身。若頭文件中類型定義需要其他頭文件時,可將其提出來單獨形成一個全局頭文件。

     3)頭文件應包含哪些頭文件僅取決於自身,而非包含該頭文件的源文件。

     例如,編譯源文件時需要用到頭文件B,且源文件已包含頭文件A,而索性將頭文件B包含在頭文件A中,這是錯誤的做法。

     4)盡量保證用戶使用此頭文件時,無需手動包含其他前提頭文件,即此頭文件內已包含前提頭文件。

     例如,面積相關操作的頭文件Area.h內已包含關於點操作的頭文件Point.h,則用戶包含Area.h后無需再手動包含Point.h。這樣用戶就不必了解頭文件的內在依賴關系。

     5)頭文件應是自完備的,即在任一源文件中包含任一頭文件而不會產生編譯錯誤。

     6)源文件中包含的頭文件盡量不要有順序依賴。

     7)盡量在源文件中包含頭文件,而非在頭文件中。且源文件僅包含所需的頭文件。

     8)頭文件中若能前置聲明(亦稱前向聲明[5]),就不要包含另一頭文件。僅當前置聲明不能滿足或過於麻煩時才使用include,如此可減少依賴性方面的問題。示例如下:

 1 struct T_MeInfoMap;  //前置聲明
 2 struct T_OmciMsg;    //前置聲明
 3 
 4 typedef FUNC_STATUS (*OmciChkFunc)(struct T_MeInfoMap *ptMeInfo, struct T_OmciMsg *ptMsg, struct T_OmciMsg *ptAckMsg);
 5  
 6 
 7 //OMCI實體信息
 8 typedef struct{
 9     INT16U wMeClass;               //實體類別
10     OMCI_ATTR_INFO *pMeAttrInfo;   //實體所定義的屬性信息指針
11     INT8U  ucAttrNum;              //實體所定義的屬性數目
12     INT16U wTotalAttrLen;          //實體所有屬性所占的總字節數,初始化為0,動態計算
13     INT8U  *pszDbName;             //實體存庫時的數據表名稱,建議不要超過DB_NAME_LEN(32)
14     INT16U wMaxRecNum;             //實體存庫時支持的最大記錄數目
15     OmciChkFunc fnCheck;           //Omci校驗函數指針
16     BOOL   bDbCreated;             //實體數據表是否已創建
17 }OMCI_ME_INFO_MAP;
View Code

     如上,在OmciChkFunc函數的實現源文件內包含T_MeInfoMap和T_OmciMsg所在頭文件即可。

     另舉一例如下:

 1 typedef TBL_SET_MODE (*OperTypeFunc)(INT8U *pTblEntry);
 2  
 3 typedef INT8U (*CmpRecFunc)(VOID *pvCmpData, VOID *pvRecData); //為避免頭文件交叉引用,與CompareRecFunc異名同構
 4 
 5 //表屬性信息
 6 typedef struct{
 7     INT16U wMaxEntryNum;         //表屬性最大表項數目(實體記錄數目wMaxRecNum * wMaxEntryNum <= MAX_RECORD_NUM)
 8     OperTypeFunc fnGetOperType;  //操作類型函數指針。根據表項數據或外界需求(只讀表)解析當前表項操作類型
 9     TBL_KEY_INFO tCmpKeyInfo;    //檢索表屬性子表記錄時的匹配關鍵字信息(TBL_KEY_INFO)
10     CmpRecFunc   fnCmpAddKey;    //增加表項時需要檢測的關鍵字匹配函數指針
11     CmpRecFunc   fnCmpDelKey;    //刪除表項時需要檢測的關鍵字匹配函數指針
12     INT16U wTblEntrySize;        //表屬性表項字節數,由外部動態賦值
13 }TBL_ATTR_INFO;
View Code

     如上,CompareRecFunc函數原型由其他頭文件提供,此處為避免頭文件交叉引用定義其異名同構原型CmpRecFunc。

     在不會引起歧義的前提下,頭文件內盡可能使用VOID指針代替非基本類型的值變量或指針,以避免再包含類型定義所在的頭文件。但這將影響代碼可讀性並降低程序執行效率,應權衡利弊。

     9)避免包含重量級的平台頭文件,如windows.h或d3d9.h等。若僅使用該頭文件少量函數,可extern函數到源文件內。如下:

1 /**********************************************************************************************
2                       外部函數聲明 (當外部接口未提供頭文件或頭文件過於復雜時) 
3 **********************************************************************************************/
4 //因聲明所在頭文件引用混亂,此處僅extern函數聲明。
5 extern INT32S DBShmCliInit(VOID); //#include "db_shm_mgr.h"
6 extern INT32S cmLockInit(VOID);   //#include "common_cmapi.h"
View Code

     若還使用該頭文件某些類型和宏定義,可創建適配性源文件。在該源文件內包含平台頭文件,封裝新的接口並將其聲明在同名頭文件內,其他源文件將通過適配頭文件間接訪問平台接口。如下:

 1 /*****************************************************************************************
 2 * 文件名稱: Omci_Send_Msg.c
 3 * 內容摘要: OMCI消息轉發接口
 4 * 其它說明: 該頭文件封裝SEND接口,以避免其他源文件包含支撐api和pid公共頭文件導致引用混亂。
 5  *****************************************************************************************/
 6 
 7 
 8 #include "Omci_Common.h"
 9 #include "Omci_Send_Msg.h"
10 #include "oss_api.h"
11 
12 
13 
14 
15 /**********************************************************************************************
16                                          函數實現區
17 **********************************************************************************************/
18 
19 //向自身進程發送異步消息
20 INT32U OmciAsynSendSelf(INT16U wEvent, VOID *pvMsg, INT16U wMsgLen)
21 {
22     PID dwSelfPid = 0;
23     SELF(&dwSelfPid);
24     return ASEND(wEvent, pvMsg, wMsgLen, dwSelfPid);
25 }
View Code

     10)對於函數庫(包括標准庫和自定義的公共宏及接口)的頭文件,可將其加入到一個通用頭文件中。需要控制該頭文件的體積(主要是該頭文件所包含的所有頭文件內容大小),並確保所有源文件首先包含該通用頭文件。示例如下:

 1 #ifndef  _OMCI_COMMON_H
 2 #define  _OMCI_COMMON_H
 3 
 4 
 5 /*******************************************************************************************
 6 * 說明:
 7 * 本文件僅應包含與具體通信協議無關的通用數據類型及宏定義。
 8 * 為簡化頭文件包含且不失可移植性,本文件內可包含少量C庫通用頭文件。
 9 * 因本文件內定義基本數據類型別名,故.c文件中應將本頭文件置於包含列表頂端,
10 * 否則編譯時可能產生類型未定義錯誤。
11 *******************************************************************************************/
12 
13 
14 #include <stdio.h>
15 #include <stdlib.h>
16 #include <string.h>
17 #include <sys/time.h>
18 #include <limits.h>
19 
20 #include "Omci_Byte.h"
21 
22 
23 //<Other Contents...>
View Code

     注意,示例頭文件內包含C庫文件雖能簡化包含,但卻與規則1沖突。也可另外增加包含庫文件列表的通用頭文件。

     11)若不確定類型、宏定義或函數聲明所在頭文件具體路徑,可在源文件中再次定義或聲明,編譯器會以redefined警告或conflicting錯誤給出類型、宏定義或函數聲明所在頭文件路徑。

 

四、 代碼文件組織原則

     建議C語言項目中代碼文件組織遵循以下原則:

     1)使用層次化和模塊化的軟件開發模型。每個模塊只能使用所在層和下一層模塊提供的接口。

     2)每個模塊的文件(可能多個)保存在一個獨立文件夾中。

     模塊文件較多時可采用子目錄的方式,物理上隔離不同層次的文件。子目錄下源文件和頭文件應分開存放,如分別置入include和source目錄。

     3)用於模塊裁減的條件編譯宏保存在一個獨立文件中,便於軟件裁減。

     4)硬件相關代碼和操作系統相關代碼與工程代碼相對獨立保存,以便於軟件移植。

     5)按相同功能或相關性組織源文件和頭文件。同一文件內的聚合度要高,不同文件中的耦合度要低。

     在對既有工程做單元測試時,耦合度低的文件布局非常便於搭建環境。

     6)聲明和定義分開,使用頭文件暴露模塊需要提供給外部的類型、宏、變量和函數。盡量做到模塊對外部透明,用戶在使用模塊功能時無需了解具體的實現。

     7)作為對外接口的頭文件一經發布,應保持穩定。修改時一定要慎重。 

     8)文件夾和文件命名要能夠反映出模塊的功能。 

     9)正式版本和測試版本使用統一文件,使用宏控制是否產生測試輸出。

     10)必要的注釋不可缺少。

 

五、 注解

     【注1】全局變量的使用原則

  1)若全局變量僅在單個源文件中訪問,則可將該變量改為該文件內的靜態全局變量;

  2)若全局變量僅由單個函數訪問,則可將該變量改為該函數內的靜態局部變量;

     3)盡量不要使用extern聲明全局變量,最好提供函數訪問這些變量。直接暴露全局變量是不安全的,外部用戶未必完全理解這些變量的含義。

     4)設計和調用訪問動態全局變量、靜態全局變量、靜態局部變量的函數時,需要考慮重入問題。

     【注2#pragma once的可移植性

     #ifndef由C/C++語言標准支持,不受編譯器任何限制;而#pragma once僅由編譯器提供保證,存在可移植性等問題。某些gcc編譯器版本(如3.2.3)會報告“warning: #pragma once is obsolete”的警告,而其他較老版本的編譯器可能會報錯。但隨着gcc 3.4的發布,#pragma once中的一些問題(主要與符號鏈接和硬鏈接有關)得以解決,#pragma once命令也標記為“未廢棄”。

     還有種寫法同時使用#pragma once和header guard編寫“可移植性”代碼,以利用編譯器可能支持的#pragma once優化。如下:

1 #pragma once
2 #ifndef    _PRJ_DIR_FILE_H
3 #define   _PRJ_DIR_FILE_H
4 
5 //<頭文件內容>
6 
7 #endif
View Code

     該法似乎兼有兩者的優點。但既然使用#ifndef就有宏名重名的風險,也無法避免不支持#pragma once的編譯器告警或報錯,故混用兩種方法似乎不能帶來更多的好處,反倒讓不熟悉的人感到困惑。

     注意,如果使用header guard,理論上可在代碼任何地方判斷當前是否已經包含某個頭文件。但應避免通過該判斷來改變后續代碼的邏輯走向!這種做法將使程序依賴於頭文件的包含順序,極不可取。若需要實現“若當前包含HeaderA.h,才加入StructB結構”,可對StructB結構創建HeaderB.h頭文件,在HeaderA.h中包含HeaderB.h。

     【注3extern "C"

     C++語言在編譯時為實現函數重載,會結合函數名、參數數目及類型信息而生成一個中間函數名。例如,C++中函數void foo(int x, float y)編譯后在符號庫中生成的名字為_foo_int_float(不同編譯器可能生成不同函數名,但均采用相同機制,生成的新名字稱為”mangled name”);而該函數被C編譯器編譯后在符號庫中的名字為_foo。

     C語言中不支持extern "C"聲明,在.c文件中包含extern "C"時會出現編譯語法錯誤。

     當然編譯器也可以為其他語言提供鏈接說明。例如:extern "FORTRAN"、extern "Ada"等。

     【注4】聲明(declaration)與定義(definition)

     全局變量或函數可(在多個編譯單元中)有多處聲明,但只允許定義一次。全局變量定義時分配空間並賦初始值(如果有);函數定義時提供函數體內容。

聲明:

extern int iGlobal;

extern int func(); 或int func();

定義:

int iGlobal = 0; 或int iGlobal;

int func (){

    return 1;}

     在多個源文件中共享變量或函數時,需確保定義和聲明的一致性。通常在某個相關的源文件中定義,然后在頭文件中進行外部聲明。需要使用時包含相應的頭文件即可。定義變量的源文件也應包含該頭文件,以便編譯器檢查定義和聲明的一致性。

     該規則可提供高度的可移植性:它與ANSI/ISO C標准一致,同時也兼顧大多數ANSI前的編譯器和鏈接器。(Unix編譯器和鏈接器常使用允許多重定義的“通用模式”,只要保證最多對一處定義進行初始化即可。該方式被ANSI C標准稱為一種“通用擴展”)。某些很老的系統可能要求顯式初始化以區別定義和外部聲明。

     通用擴展在《深入理解計算機系統》中解釋為:多重定義的符號只允許最多一個強符號。函數和定義時已初始化的全局變量是強符號;未初始化的全局變量是弱符號。Unix鏈接器使用以下規則來處理多重定義的符號:

     規則一:不允許有多個強符號。在被多個源文件包含的頭文件內定義的全局變量會被定義多次(預處理階段會將頭文件內容展開在源文件中),若在定義時顯式地賦值(初始化),則會違反此規則。

     規則二:若存在一個強符號和多個弱符號,則選擇強符號。

     規則三:若存在多個弱符號,則從這些弱符號中任選一個。

     當不同文件內定義同名(即便類型和含義不同)的全局變量時,該變量共享同一塊內存(地址相同)。若變量定義時均初始化,則會產生重定義(multiple definition)的鏈接錯誤;若某處變量定義時未初始化,則無鏈接錯誤,僅在因類型不同而大小不同時可能產生符號大小變化(size of symbol `XXX' changed)的編譯警告。在最壞情況下,編譯鏈接正常,但不同文件對同名全局變量讀寫時相互影響,引發非常詭異的問題。這種風險在使用無法接觸源碼的第三方庫時尤為突出。

     因此,應盡量避免使用全局變量。若確有必要,應采用靜態全局變量(無強弱之分,且不會和其他全局符號產生沖突),並封裝訪問函數供外部文件調用。

     【注5】前向聲明(forward declaration)

     結構體類型S在聲明之后定義之前是一個不完全類型(incomplete type),即已知S是一個類型,但不知道包含哪些成員。不完全類型只能用於定義指向該類型的指針,或聲明使用該類型作為形參指針類型或返回指針類型的函數。指針類型對編譯器而言大小固定(如32位機上為四字節),不會出現編譯錯誤。

     假設先后定義兩個結構A和B,且兩個結構需要互相引用。在定義A時B還沒有定義,則要引用B就需要前向聲明結構B(struct B;)。示例如下:

1 typedef BOOL (*func)(const DefStruct *ptStrt);
2 
3 typedef struct DefStruct_t{
4     int i;
5     func f;
6 }DefStruct;
Wrong Code

     如上在DefStruct中使用回調函數func聲明,這樣交叉引用必然編譯報錯。進行前向聲明即可:

1 typedef struct DefStruct_t DefStruct;
2 typedef BOOL (*func)(const DefStruct *ptStrt);
3 
4 struct DefStruct_t{
5     int i;
6     func f;
7 };
Correct Code

     注意,在前向聲明和具體定義之間涉及標識符(變量、結構、函數等)實現細節的使用都是非法的。若函數被前向聲明但未被調用,則編譯和運行正常;若前向聲明函數被調用但未被定義,則編譯正常但鏈接報錯(undefined reference)。將具體定義放在源文件中可部分避免該問題。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM