前文談了代碼和庫的符號隱藏手段惋啃。在C/C++中霎槐,無論我們?nèi)绾螌Ψ栠M(jìn)行隱藏,最后該暴露給客戶的API還是要聲明到頭文件中發(fā)布給別人使用青伤。如何設(shè)計和管理好頭文件督怜,決定了我們更大范圍內(nèi)的依賴治理水平。
首先談?wù)勵^文件設(shè)計狠角。這里一個重要前提是要理解:頭文件首先是提供給別人使用的亮蛔。
很多C/C++程序員習(xí)慣了一個實現(xiàn)文件對應(yīng)一個頭文件,因此總下意識的覺得頭文件先是給自己用的擎厢,所以無論什么聲明(宏、常量辣吃、類型动遭、函數(shù))都一股腦先聲明到自己的頭文件中。
這是個很糟糕的做法神得!因為客戶使用你API的標(biāo)準(zhǔn)做法就是包含你的頭文件厘惦,上述做法的頭文件會將大量實現(xiàn)細(xì)節(jié)暴露給所有客戶,增加了彼此的耦合哩簿,造成無謂的依賴和構(gòu)建負(fù)擔(dān)宵蕉。
所以,首先要明白頭文件是提供給別人使用的节榜,否則把所有符號都聲明在自己的實現(xiàn)文件里豈不是更簡單羡玛。因此,頭文件設(shè)計要站在客戶的角度去思考:1)怎么讓別人用著方便宗苍?即遵循自滿足原則稼稿;2)怎么減少別人不必要的依賴薄榛?即遵循最小公開原則。
下面我們看看一個具體的C的頭文件executor_api.h:
// executor_api.h
#ifndef H867A653E_0C66_4A68_80C4_B0F253647F7F
#define H867A653E_0C66_4A68_80C4_B0F253647F7F
#include "executor/keywords.h"
#include "executor/command_type.h"
#ifdef __cplusplus
extern "C" {
#endif
struct Executor;
MOD_PUBLIC struct Executor* executor_clone(const struct Executor* e);
MOD_PUBLIC void executor_exec(struct Executor* e, CommandType cmd);
#ifdef __cplusplus
}
#endif
#endif
上面是一個標(biāo)準(zhǔn)的C的頭文件让歼。首先為了保證每個頭文件在同一個編譯單元中只展開一次敞恋,頭文件的內(nèi)容必須處于Include Guard中,也即熟悉的#ifndef ... #define ... #endif
中谋右。
Include Guard中的宏需要全局唯一硬猫,一般使用路徑名和文件名的大寫加下劃線。但這種做法有個問題是改执,當(dāng)文件重命名后經(jīng)常忘記改對應(yīng)的宏啸蜜,久而久之就會不小心出現(xiàn)沖突。
在有的地方你會看到使用#pragma once
來作為Include Guard天梧,不過這不是標(biāo)準(zhǔn)盔性,存在兼容性問題。
在本例中我們?nèi)耘f是采用Include Guard的標(biāo)準(zhǔn)做法呢岗,只是宏采用IDE自動生成的UUID冕香,這樣既能保證全局唯一,也不會和文件名產(chǎn)生重復(fù)后豫。沒必要糾結(jié)這個宏的可讀性悉尾,因為它只是給編譯器看的,不是給程序員看的挫酿。
接下來為了自滿足性构眯,executor_api.h頭文件中include了它依賴的其它頭文件。本例中是"keywords.h"和 "command_type.h"早龟,它們分別定義了后面會用到的宏MOD_PUBLIC
和枚舉CommandType
惫霸。
再往下是如下語句塊:
#ifdef __cplusplus
extern "C" {
#endif
//...
#ifdef __cplusplus
}
#endif
這個語句塊表達(dá)了:如果該頭文件被C++的程序所使用的話,就將中間的所有符號聲明和定義包含在extern "C" { }
語句塊中間(因為C++的編譯器中有__cplusplus
的定義葱弟,而C編譯器下沒有)壹店。
extern "C" { }
指示大括號中的所有函數(shù)符號不要經(jīng)過C++名稱粉碎(name mangling)過程,全部按照C語言的標(biāo)準(zhǔn)進(jìn)行符號鏈接芝加。這樣就可以保證C++程序能正確鏈接到C語言的函數(shù)實現(xiàn)硅卢。
注意這里對extern "C" { }
用途的解釋,它和extern
的含義是完全不同的藏杖。extern "C" { }
完全是為了讓C語言的API也能被C++程序所使用将塑,擴(kuò)大C語言庫可被復(fù)用的范圍。
另外注意仔細(xì)看上例蝌麸,extern "C" { }
是放在所有的#include
語句下面的点寥,也就是說: extern "C" { }
中間不要包含#include
語句。我們希望每個頭文件自己聲明自己需要放置在extern "C" { }
中的符號来吩,不要為別的頭文件代勞开财,否則可能出現(xiàn)某些匪夷所思的編譯或鏈接錯誤(原因解釋起來稍微有些復(fù)雜汉柒,記住這個原則就好了)。
如果可以保證C程序永遠(yuǎn)不會被C++程序調(diào)動责鳍,C的頭文件中也可以不用加這個語句塊碾褂。遺憾的是這個保證經(jīng)常被打破,比如當(dāng)前主流的C程序的單元測試框架大多是C++寫的历葛,因此當(dāng)你要對所寫的C程序做單元測試的時候正塌,就必須把頭文件交給C++程序使用。所以恤溶,如果沒有特殊的原因乓诽,建議對所有的C語言頭文件加上上述語句塊,以保證其能在更大范圍內(nèi)使用咒程。
我們繼續(xù)看上例中的頭文件鸠天,接下來的是一句前置聲明struct Executor
。
前置聲明是解除頭文件依賴的好方法帐姻,一般函數(shù)的參數(shù)稠集、返回值、以及結(jié)構(gòu)體中的指針和引用類型等都只用前置聲明即可饥瓷,無需包含頭文件剥纷。而枚舉、宏以及需要知道內(nèi)存布局或大小的類型定義呢铆,則需要顯示包含頭文件晦鞋。
在上例中,CommandType
由于是枚舉所以必須包含頭文件"command_type.h"棺克,而struct Executor
在后面的函數(shù)聲明中僅當(dāng)做參數(shù)和返回值悠垛,而且都是使用其指針類型,因此只用前置聲明而無需包含定義其結(jié)構(gòu)體的頭文件娜谊。
示例的頭文件的最后是對外API executor_clone
和executor_exec
的函數(shù)的聲明确买,這里還進(jìn)一步使用了我們之前介紹過的MOD_PUBLIC
進(jìn)行API的顯示導(dǎo)出。
上述這些基本是一個標(biāo)準(zhǔn)的C語言頭文件的全貌因俐。
前面我們說了,頭文件首先是給別人用的周偎,但是為了避免重復(fù)聲明抹剩,自己也可以包含自己對外發(fā)布的頭文件。
如本例蓉坎,為了避免Executor
的實現(xiàn)文件重復(fù)聲明MOD_PUBLIC struct Executor* executor_clone(const struct Executor* e)
和MOD_PUBLIC void executor_exec(struct Executor* e, CommandType cmd)
澳眷,所以executor.c也包含了executor_api.h。
// executor.c
#include "executor/executor_api.h"
struct Executor {
// ...
};
struct Executor* executor_clone(const struct Executor* e) {
// ...
}
void executor_exec(struct Executor* e, CommandType cmd) {
// ...
}
如果需要把某些符號通過頭文件共享給內(nèi)部其它實現(xiàn)文件蛉艾,但是又不需要把這類頭文件公布出去钳踊。這時建議把頭文件分開衷敌,明確分成對外頭文件和私有頭文件。自己可以同時包含對外的和私有的頭文件拓瞪,但對外只發(fā)布公開頭文件缴罗。
假設(shè)本例中,Executor
的結(jié)構(gòu)體定義需要向內(nèi)部公開祭埂,但是外部并不需要看到面氓。這時可以新創(chuàng)一個內(nèi)部頭文件executor.h包含struct Executor
的定義,但對外仍然只發(fā)布executor_api.h蛆橡。這時executor.c可以同時包含executor_api.h和executor.h舌界,而外部客戶只能包含executor_api.h,無法訪問到executor.h泰演。
除了按內(nèi)外部用途將頭文件分開呻拌,有的時候當(dāng)滿足 1)庫的使用方明確且有限;2)庫的使用方對庫頭文件中符號依賴存在明顯差異睦焕;這時為了避免庫的不同用戶因為依賴相同的頭文件而互相影響(例如庫按照一個使用方的要求修改了頭文件中的某個函數(shù)聲明藐握,卻導(dǎo)致并不依賴該函數(shù)的其它使用方都要重新編譯),這時可以按照“接口隔離原則”复亏,把對外頭文件按照不同用戶進(jìn)一步分開趾娃。一般集中式的大項目中劃分的內(nèi)部模塊會容易滿足上述條件,而開源代碼由于并不能假設(shè)自己的用戶所以一般不這么做缔御。
OK抬闷,接下來我們遇到的問題是,當(dāng)按照內(nèi)外部用途拆分開的頭文件越來越多耕突,在目錄結(jié)構(gòu)上要如何進(jìn)行有效的規(guī)劃和管理呢笤成?
繼續(xù)用上面的例子示例,當(dāng)前社區(qū)對于單個庫目錄的主流布局如下:
executor
│
│ README.md
│ CMakeLists.txt
│ ...
│
└───include
│ │
│ └───executor
│ │ keywords.h
│ │ command_type.h
│ │ executor_api.h
│ │ ...
│
└───src
│ │ executor.h
│ │ executor.c
│ │ ...
│ │ CMakeLists.txt
│
└───tests
│ │ executor_stub.h
│ │ executor_stub.cpp
│ │ executor_test.cpp
│ │ ...
│ │ CMakeLists.txt
│
└───benchmarks
│ │ performance_test.cpp
│ │ ...
│ │ CMakeLists.txt
│
└───examples
│ │ example.cpp
│ │ ...
│ │ CMakeLists.txt
│
└───docs
│ │ quickstart.md
│ │ apis.md
│ │ ...
在這個目錄布局中眷茁,首先會將所有對外發(fā)布的頭文件都放在"include/<module_name>"目錄下炕泳,這樣方便發(fā)布的時候直接把include下的所有頭文件一次導(dǎo)出。
這里在include目錄和實際的頭文件中間增加一層以模塊名命名的目錄(如include/executor)上祈,是為了無論自己還是發(fā)布后給別人用培遵,都希望對外頭文件的包含路徑能明確的從模塊名開始(make中-I統(tǒng)一指定到每個模塊的include目錄),這樣方便一眼看出頭文件是哪個模塊的API登刺。
例如上例中無論是內(nèi)部還是外部使用executor_api.h籽腕,都希望寫作#include "executor/executor_api.h"
,這樣一眼看去便知當(dāng)前依賴的是executor模塊的API纸俭。
在上面的目錄布局中皇耗,所有的實現(xiàn)文件都放在src目錄下,內(nèi)部頭文件也放在src目錄下揍很,和自己的實現(xiàn)文件放在一起郎楼。
其它常見的頂級目錄還有:
tests目錄下是庫的功能測試用例以及供測試代碼使用的樁文件万伤,還有測試單獨(dú)使用的頭文件;
benchmarks目錄下是性能測試用例呜袁,或者其它非功能性測試用例敌买;
examples目錄下是庫的示例代碼,用于幫助客戶理解庫的功能以及API的常見用法傅寡;另外這里的代碼示例也用于文檔中的代碼引用放妈;
docs目錄下是庫的使用手冊或者API接口文檔等;
無論是include/executor目錄荐操,還是src芜抒、tests、benchmarks托启、examples目錄宅倒,需要的時候都可以在內(nèi)部繼續(xù)劃分子目錄。
再稍微看看構(gòu)建屯耸。庫頂層的CMake文件用于對構(gòu)建做整體控制拐迁,指定構(gòu)建src目錄,以及選擇是否構(gòu)建tests疗绣、benchmarks和examples线召。
src、tests多矮、benchmarks和examples下有自己更具體的CMake文件用于控制內(nèi)部的構(gòu)建細(xì)節(jié)缓淹。由于對外頭文件和內(nèi)部頭文件的分離,所以構(gòu)建腳本的編寫也變得容易塔逃。關(guān)于構(gòu)建的話題讯壶,我們后面會詳細(xì)的講述,這里先略過湾盗。
上述目錄結(jié)構(gòu)是C/C++社區(qū)主流的一種布局規(guī)范伏蚊。社區(qū)中還有其它的一些布局格式,但是經(jīng)過對比并不比這個布局清晰及使用范圍大格粪。另外這個布局與其它和C/C++語言相似的現(xiàn)代化語言的標(biāo)準(zhǔn)布局是趨于一致的(如RUST)躏吊。
我們推薦在實踐中盡量遵循上述目錄布局規(guī)范。即使在一個集中式的大項目中帐萎,也請保持其中每個模塊的目錄布局符合上述規(guī)范比伏,即內(nèi)外部頭文件分離,同時每個模塊自己維護(hù)和管理自己的頭文件吓肋。
切忌不要把所有模塊的對外頭文件都集中放到一個大目錄下凳怨,這樣會讓每個模塊的頭文件和實現(xiàn)離得過遠(yuǎn)瑰艘,還容易導(dǎo)致把所有模塊的公開頭文件一下子全部暴露給每個模塊從而引起各種依賴混亂問題是鬼。這個話題我們在后面談依賴管理時還會再聊肤舞。
至此,我們總結(jié)下對頭文件設(shè)計和管理的一些建議:
1)明白頭文件首先是提供給別人使用的均蜜,頭文件設(shè)計要遵循自滿足原則和最小公開原則李剖;
2)遵循頭文件的設(shè)計規(guī)范,本文提到了Include Guard囤耳,extern "C"和前置聲明等使用時的一些最佳實踐篙顺;
3)將對外頭文件和對內(nèi)頭文件分開;在滿足一定條件(庫的使用方明確充择、有限德玫,且對庫接口的依賴存在明顯差異)時,可以進(jìn)一步按照接口隔離原則將對外頭文件對不同用戶分開椎麦;
4)對頭文件的目錄管理盡量遵循主流的社區(qū)規(guī)范宰僧;避免將所有模塊或者庫的對外頭文件集中放置到一起然后暴露給所有用戶;