模塊的封裝(四)——頭文件的作用

原文地址:https://www.amobbs.com/thread-5675247-1-1.html

認真說起來,頭文件(Header File)是個短命的傢伙——就整個編譯過程來說,它的壽命是最短的。
爲什麼這麼說呢?關於頭文件的話題,討論起來那可是“孩子沒娘,說來話長了”,既然是閒聊、你也不是等着這篇文章救命,那就不妨從頭開始說起——先假設讀者們都是不瞭解編譯基本過程的初學者。
一個編譯(Compilation)過程通常至少分爲三個階段:預編譯(Precompiling)、編譯(Make)和鏈接(Linking)。他們就像一個流水線一環套一環——前一工序的輸出是後一工序的輸入。這本沒有什麼稀奇的,但對於程序員來說,這個過程中有幾個基本常識是需要記住的:

  1. C語言編譯的基本單位(Compilation Unit)是 C源文件 (而並沒有頭文件);
  2. 同一個工程中,不同C源文件的編譯是彼此獨立的(毫不相干的);
  3. 頭文件在預編譯階段就已經合併到對應的C源文件中了,和所有的宏以及條件編譯一樣,到了編譯階段,所有的頭文件、宏都是不存在的,已經被替換爲對應的內容和常量了。

理解這三點,基本上已經可以解決很多我們日常編碼過程中存在的很多疑問,比如:

  • Q1:爲什麼不能C語言頭文件裏面定義變量或者函數的實體?

  • Q2:爲什麼有的時候宏的先後順序並不那麼重要?

  • Q3:爲什麼可以在源代碼的任意位置(另起一行後)定義宏,甚至是include別的頭文件?
    推薦大家基於前面的三個事實自己思考。

    頭文件裏可以放什麼呢?這是個值得討論的問題:

  • 各類宏

  • 函數的聲明(也就是 extern xxxxx)

  • 全局變量的聲明(也就是 extern xxxx)
    然而,值得說明的是,這裏有一個編碼規則值得你去遵守:頭文件裏堅決不要放全局變量有關的任何東西(硬要加,也必須是const類型的,比如各類接口)。

  • 類型定義(typedef, struct, union 之類的)

  • static 的變量實體和函數實體。
    這個可以有,爲啥呢?因爲即便多個c源文件包含同一個頭文件導致同樣的函數和變量實體存在多份,但static 的另外一個名字 “private” 可以保證每一份變量和函數實體都是彼此獨立的,都是每個c源代碼的私人財產——你可以有,我也可以有。“哎?你也有啊,真巧哎,我也有……”

  • inline 的函數
    這個和static是一個道理。

頭文件裏面不能放函數的實體,想必原因大部分人都知道了,這裏就不再贅述。但頭文件裏不放(非const)的全局變量的聲明,這怎麼玩?這裏需要說明一下,頭文件裏不是不能放(非const)的全局變量聲明,而是我提供了一個人爲的規定(規範),建議不要放任何(非const)的全局變量到頭文件裏,具體原因和解決方案,我們在別的帖子裏再討論(其實有人討論過,大約就是,如何避免使用全局變量)——是的,避免使用(非const)的全局變量是可以做到的——這裏也不再贅述。說了這麼多廢話,我們真正要討論的內容還沒有開始:
如何建立頭文件的使用規則,使其即靈活、使用方便,又靈活且便於擴展(模塊化)——符合面向接口開發的要求,方便我們 建立黑盒子?
簡而言之,如何讓頭文件的使用不再頭疼;永遠告別循環包含;方便代碼的移植?

首先,思考一個簡單的問題?爲什麼我們要用頭文件?答案其實很簡單,因爲每個.c文件都是獨立編譯的,因此需要在源代碼級別傳遞一些信息,類似一羣人在嘮嗑:

源代碼A:              我定義了一個函數,你們哥幾個要用麼?
源代碼B和源代碼C: 我們要用啊,函數原型(prototype)什麼樣子啊?
源代碼A:               你們不用費腦經記(抄下來),我都寫好了,放在一個頭文件裏了,你們直接include就可以了。
源代碼B和源代碼C: 這個敢情方便。那你頭文件放哪裏了?
源代碼A:               有兩種方式,要麼你直接到我這裏來拿(指定路徑);要麼你找編譯器問(編譯器指定搜索路徑)。
源代碼D:               你們整這麼麻煩做什麼?你直接告訴我原型,我抄下來,不就不用問這個問那個,還包含文件什麼的,真麻煩。
源代碼A:               D啊,你老想耍小聰明,萬一我更新了你不知道怎麼辦?我有義務告訴你麼?並沒有。
源代碼B和源代碼C: 是啊,是啊,A以後估計要外包了,不在這裏了,到時候有變化,都記錄在頭文件裏,你本地放一個,沒法及時同步的。
源代碼D:              我不聽!我不聽!我不聽……

是不是很有畫面感?拋開捂着耳朵的D,我們回到討論的話題——既然頭文件是用來交換信息的,那麼如果把所有的信息都放在一起,大家需要的時候各取所需,豈不美哉?——基於這種思想,幾乎所有人都見過把所有變量、函數、宏、類型定義都放到一個叫做system.h的頭文件裏的做法。你有這麼做過麼?不要不好意思,幾乎所有人都這麼做過——因爲實在太方便了,世界大同,挺好,直到你嘗試和別人一起合作開發系統,並試圖在不同項目間複用一些代碼的時候:

“何首烏藤和木蓮藤纏絡着”……對於這種情況,我們叫做耦合。“是要找個時間來理一理了”,你對自己說,然後長嘆了一口氣,發現這句話其實很早之前就說過了。想到還有更奇葩的循環包涵的問題,你不得不感嘆,頭文件真的是個頭疼的東西——要不我們還是不用了吧?直接抄下來貌似更簡單啊——源程序D癡癡的笑了。

那麼,如何解決這個問題呢?其實,從實踐經驗來看,頭文件的用途分爲兩大類:

站在C源文件的視角上:

從外部向C源文件內部 輸入配置信息——我們把這類頭文件叫做配置頭文件(Configuration Header File)。需要強調的是,信息的流動方向是 從外向內,所以又可以簡單的理解爲輸入性的頭文件(Header File for information input)。常見的app_cfg.h 就是典型的配置頭文件。

從 C源文件內部向外 輸出接口信息(全局函數、類型,宏定義等信息)——我們把這類頭文件叫做接口頭文件(Interface Header File)。需要強調的是,信息的流動方向是 從內向外,所以又可以簡單的理解爲輸出性的頭文件(Header File for information output)。常見的, spi.husart.h, device.h, stdint.h 就是典型的接口頭文件。

輸入和輸出兩個不同的職能如果被放在同一個頭文件裏,就有極大的風險產生循環包含或者交叉引用(兩個相反方向的箭頭產生閉合的圓圈)。
system.h實際上就是一個混淆信息流動方向的例子。這就是本質上依賴system.h的工程 模塊不好拆分的原因。一般來說,爲了“降低”循環包含的風險,同時又爲了尊重常見模塊封裝的習慣,我們會人爲的規定:

  • 模塊內部的各類文件“允許”包含模塊的接口頭文件;
  • 模塊內部的各類文件“應該”包含模塊自己的配置頭文件;
  • 除極少數情況外,系統中所有的配置頭文件都“應該避免”包含任何街口頭文件。

簡單的來說,這三條規則就是允許兩個信息流單向的進行混合:也就是,配置頭文件的信息可以單向的流向接口頭文件;但反過來卻絕對禁止,這就從源頭上極大的降低了發生“循環包含”的概率。但即便如此,還有另外一類問題單純依靠拆分頭文件是不能解決的,這就是頭文件的“交叉引用”問題。

模塊的封裝(一):C語言類的封裝
模塊的封裝(二):C語言類的繼承和派生
模塊的封裝(三):無傷大雅的形式主義

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章