C語言標頭檔案組織與包含原則

clover_toeic發表於2018-08-28

  說明

  本文假定讀者已具備基本的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則通過預處理命令模擬類似行為:

 #ifndef  _PRJ_DIR_FILE_H  //必須確保header guard巨集名永不重名
 #define  _PRJ_DIR_FILE_H
 
 //<標頭檔案內容>
 
 #endif

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

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

  缺點則是:

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

  9) C++中要引用C函式時,函式所在標頭檔案內應包含extern "C"[3]。

  //.h檔案頭部
  #ifdef  __cplusplus
  extern "C" {
  #endif
  
  //<函式宣告>
 
  //.h檔案尾部
  #ifdef  __cplusplus
 }
 #endif

  被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,如此可減少依賴性方面的問題。示例如下:

 struct T_MeInfoMap;  //前置宣告
 struct T_OmciMsg;    //前置宣告
 
 typedef FUNC_STATUS (*OmciChkFunc)(struct T_MeInfoMap *ptMeInfo, struct T_OmciMsg *ptMsg, struct T_OmciMsg *ptAckMsg);
 
 //OMCI實體資訊
 typedef struct{
     INT16U wMeClass;               //實體類別
     OMCI_ATTR_INFO *pMeAttrInfo;   //實體所定義的屬性資訊指標
     INT8U  ucAttrNum;              //實體所定義的屬性數目
     INT16U wTotalAttrLen;          //實體所有屬性所佔的總位元組數,初始化為0,動態計算
     INT8U  *pszDbName;             //實體存庫時的資料表名稱,建議不要超過DB_NAME_LEN(32)
     INT16U wMaxRecNum;             //實體存庫時支援的最大記錄數目
     OmciChkFunc fnCheck;           //Omci校驗函式指標
     BOOL   bDbCreated;             //實體資料表是否已建立
 }OMCI_ME_INFO_MAP;

  如上,在OmciChkFunc函式的實現原始檔內包含T_MeInfoMap和T_OmciMsg所在標頭檔案即可。

  另舉一例如下:

 typedef TBL_SET_MODE (*OperTypeFunc)(INT8U *pTblEntry);
 typedef INT8U (*CmpRecFunc)(VOID *pvCmpData, VOID *pvRecData); //為避免標頭檔案交叉引用,與CompareRecFunc異名同構
 
 //表屬性資訊
 typedef struct{
     INT16U wMaxEntryNum;         //表屬性最大表項數目(實體記錄數目wMaxRecNum * wMaxEntryNum <= MAX_RECORD_NUM)
     OperTypeFunc fnGetOperType;  //操作型別函式指標。根據表項資料或外界需求(只讀表)解析當前表項操作型別
     TBL_KEY_INFO tCmpKeyInfo;    //檢索表屬性子表記錄時的匹配關鍵字資訊(TBL_KEY_INFO)
     CmpRecFunc   fnCmpAddKey;    //增加表項時需要檢測的關鍵字匹配函式指標
     CmpRecFunc   fnCmpDelKey;    //刪除表項時需要檢測的關鍵字匹配函式指標
     INT16U wTblEntrySize;        //表屬性表項位元組數,由外部動態賦值
 }TBL_ATTR_INFO;

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

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

  9)避免包含重量級的平臺標頭檔案,如windows.h或d3d9.h等。若僅使用該標頭檔案少量函式,可extern函式到原始檔內。如下:

 /**********************************************************************************************
                       外部函式宣告 (當外部介面未提供標頭檔案或標頭檔案過於複雜時) 
 **********************************************************************************************/
 //因宣告所在標頭檔案引用混亂,此處僅extern函式宣告。
 extern INT32S DBShmCliInit(VOID); //#include "db_shm_mgr.h"
 extern INT32S cmLockInit(VOID);   //#include "common_cmapi.h"

  若還使用該標頭檔案某些型別和巨集定義,可建立適配性原始檔。在該原始檔內包含平臺標頭檔案,封裝新的介面並將其宣告在同名標頭檔案內,其他原始檔將通過適配標頭檔案間接訪問平臺介面。如下:

 /*****************************************************************************************
 * 檔名稱: Omci_Send_Msg.c
 * 內容摘要: OMCI訊息轉發介面
 * 其它說明: 該標頭檔案封裝SEND介面,以避免其他原始檔包含支撐api和pid公共標頭檔案導致引用混亂。
  *****************************************************************************************/
 
 #include "Omci_Common.h"
 #include "Omci_Send_Msg.h"
 #include "oss_api.h" 
 
 /**********************************************************************************************
                                          函式實現區
 **********************************************************************************************/
 
 //向自身程式傳送非同步訊息
 INT32U OmciAsynSendSelf(INT16U wEvent, VOID *pvMsg, INT16U wMsgLen)
 {
     PID dwSelfPid = 0;
     SELF(&dwSelfPid);
     return ASEND(wEvent, pvMsg, wMsgLen, dwSelfPid);
 }

  10)對於函式庫(包括標準庫和自定義的公共巨集及介面)的標頭檔案,可將其加入到一個通用標頭檔案中。需要控制該標頭檔案的體積(主要是該標頭檔案所包含的所有標頭檔案內容大小),並確保所有原始檔首先包含該通用標頭檔案。示例如下:

 #ifndef  _OMCI_COMMON_H
 #define  _OMCI_COMMON_H
 
 /*******************************************************************************************
 * 說明:
 * 本檔案僅應包含與具體通訊協議無關的通用資料型別及巨集定義。
 * 為簡化標頭檔案包含且不失可移植性,本檔案內可包含少量C庫通用標頭檔案。
 * 因本檔案內定義基本資料型別別名,故.c檔案中應將本標頭檔案置於包含列表頂端,
 * 否則編譯時可能產生型別未定義錯誤。
 *******************************************************************************************/
 
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/time.h>
 #include <limits.h>
 
 #include "Omci_Byte.h"
 
 //<Other Contents...>

  注意,示例標頭檔案內包含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優化。如下:

 #pragma once
 #ifndef    _PRJ_DIR_FILE_H
 #define   _PRJ_DIR_FILE_H
 
 //<標頭檔案內容>
 
 #endif

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

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

  【注3】extern "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;)。示例如下:

 typedef BOOL (*func)(const DefStruct *ptStrt);
 
 typedef struct DefStruct_t{
     int i;
     func f;
 }DefStruct;

  如上在DefStruct中使用回撥函式func宣告,這樣交叉引用必然編譯報錯。進行前向宣告即可:

 typedef struct DefStruct_t DefStruct;
 typedef BOOL (*func)(const DefStruct *ptStrt);
 
 struct DefStruct_t{
     int i;
     func f;
 };

  注意,在前向宣告和具體定義之間涉及識別符號(變數、結構、函式等)實現細節的使用都是非法的。若函式被前向宣告但未被呼叫,則編譯和執行正常;若前向宣告函式被呼叫但未被定義,則編譯正常但連結報錯(undefined reference)。將具體定義放在原始檔中可部分避免該問題。

相關文章