C 語言頭文件 “細節”

很多事不深入以爲自己懂了,但真正用到項目上,才發現了問題。曾以爲自己寫 C 語言已經輕車熟路了,特別是對軟件文件的工程管理上,因爲心裏對自己的代碼編寫風格還是有自信的。(畢竟剛畢業時老大對我最初的訓練就是編碼格式的規範化處理)

曾以爲,一個. c 文件對應一個. h 文件,.c 文件只包含它自身的. h 文件就好,若. c 文件中用到其他文件中的內容, 則. h 文件把用到的頭文件包含進來就可以了。

自己貌似一直秉承這個理念在進行代碼編寫(好可怕)。工程文件數量小時,這種理念貌似看不出問題,但隨着工程文件數量越來越多,我發現自己這種思路有了弊端:頭文件互相包含,導致編譯時自以爲有些宏變量聲明瞭,它就能起作用,但實際測試發現這種方式編碼後,有些聲明的宏沒能起到作用。

經過領導及同事的指正,自己才明白原有的代碼編寫習慣不正確。應該秉承. c 文件對應的. h 文件只包含頭文件裏用到的其它文件的頭文件, 任何非必須的. h 文件不要包含;而. c 文件裏面要包含用到的所有. h 文件。這樣寫即使存在. c 文件內頭文件重複包含也不傷大雅。

語言描述有時太抽象,還是符號舉例說明下:假如有兩個. c 文件分別爲 A.c 和 B.c,自然它們都有各自的 A.h 和 B.h 文件。

原有的思路:

A.c 裏面只有一個 #include "A.h", 而 A.h 所包含的就是一大堆如 B.h,C.h,D.h..... 文件,因爲 A.c 文件裏面要用到 B.h,C.h,D.h 裏面的內容。如圖一所示。

新思路:

A.h 裏面只包含 A.h 所寫內容要用到的. h 文件, 很多時候 A.h 裏面無需任何. h 文件. 而在 A.c 文件內就要寫成  #include "B.h"  #include "C.h"   #include "D.h"。而且兩個文件的. c 文件在頭文件包含上可以互相包含。如圖二所示。

項目中遇到的這個頭文件包含問題導致我重新搜索資料進行該問題的深入瞭解,故下文是通過網絡資源的搜查及加上自己對它的理解,進行了相關內容的整理,希望對感興趣的小夥伴有所幫助。

背景

對於 C 語言來說,頭文件的設計體現了大部分的系統設計。不合理的頭文件佈局是編譯時間過長的根因,不合理的頭文件實際上不合理的設計。

依賴

特指編譯依賴。若 x.h 包含了 y.h,則稱作 x 依賴 y。依賴關係會進行傳導,如 x.h 包含 y.h,而 y.h 又包含了 z.h,則 x 通過 y 依賴了 z。依賴將導致編譯時間的上升。

雖然依賴是不可避免的,也是必須的,但是不良的設計會導致整個系統的依賴關係無比複雜,使得任意一個文件的修改都要重新編譯整個系統,導致編譯時間巨幅上升。

在一個設計良好的系統中, 修改一個文件,只需要重新編譯數個,甚至是一個文件。

某產品曾經做過一個實驗,把所有函數的實現通過工具註釋掉,其編譯時間只減少了不到 10%,究其原因,在於 A 包含 B, B 包含 C, C 包含 D,最終幾乎每一個源文件都包含了項目組所有的頭文件,從而導致絕大部分編譯時間都花在解析頭文件上。

某產品更有一個 “優秀實踐”,用於將. c 文件通過工具合併成一個比較大的. c 文件,從而大幅度提高編譯效率。

其根本原因還是在於通過合併. c 文件減少了頭文件解析次數。但是,這樣的 “優秀實踐” 是對合理劃分. c 文件的一種破壞。

大部分產品修改一處代碼,都得需要編譯整個工程,對於 TDD 之類的實踐,要求對於模塊級別的編譯時間控制在秒級,即使使用分佈式編譯也難以實現,最終仍然需要合理的劃分頭文件、以及頭文件之間的包含關係, 從根本上降低編譯時間。

《google C++ Style Guide》 1.2 頭文件依賴 章節也給出了類似的闡述:若包含了頭文件 aa.h,則就引入了新的依賴:一旦 aa.h 被修改,任何直接和間接包含 aa.h 代碼都會被重新編譯。如果 aa.h 又包含了其他頭文件如 bb.h,那麼 bb.h 的任何改變都將導致所有包含了 aa.h 的代碼被重新編譯。

在敏捷開發方式下,代碼會被頻繁構建,漫長的編譯時間將極大的阻礙頻繁構建。因此,我們傾向於減少包含頭文件,尤其是在頭文件中包含頭文件,以控制改動代碼後的編譯時間。

合理的頭文件劃分體現了系統設計的思想,但是從編程規範的角度看,仍然有一些通用的方法,用來合理規劃頭文件。本章節介紹的一些方法,對於合理規劃頭文件會有一定的幫助。

原則 1:頭文件中適合放置接口的聲明,不適合放置實現。

說明:頭文件是模塊( Module)單元( Unit)的對外接口。頭文件中應放置對外部的聲明,如對外提供的函數聲明、宏定義、類型定義等。

延伸閱讀材料:《 C 語言接口與實現》

原則 2:頭文件應當職責單一。

說明:頭文件過於複雜,依賴過於複雜是導致編譯時間過長的主要原因。很多現有代碼中頭文件過大,職責過多, 再加上循環依賴的問題,可能導致爲了在. c 中使用一個宏,而包含十幾個頭文件。

某個頭文件不但定義了基本數據類型 WORD,還包含了 stdio.h syslib.h 等等不常用的頭文件。

如果工程中有 10000 個源文件,而其中 100 個源文件使用了 stdio.h 的 printf,由於上述頭文件的職責過於龐大,而 WORD 又是每一個文件必須包含的,從而導致 stdio.h/syslib.h 等可能被不必要的展開了 9900 次,大大增加了工程的編譯時間。

原則 3:頭文件應向穩定的方向包含。

說明:頭文件的包含關係是一種依賴,一般來說,應當讓不穩定的模塊依賴穩定的模塊,從而當不穩定的模塊發生變化時,不會影響(編譯)穩定的模塊。

就我們的產品來說,依賴的方向應該是:產品依賴於平臺,平臺依賴於標準庫。某產品線平臺的代碼中已經包含了產品的頭文件,導致平臺無法單獨編譯、發佈和測試, 是一個非常糟糕的反例。

除了不穩定的模塊依賴於穩定的模塊外,更好的方式是兩個模塊共同依賴於接口,這樣任何一個模塊的內部實現更改都不需要重新編譯另外一個模塊。在這裏,我們假設接口本身是最穩定的。

延伸閱讀材料:編者推薦開發人員使用 “依賴倒置” 原則,即由使用者制定接口,服務提供者實現接口,更具體的描述可以參見《 敏捷軟件開發:原則、模式與實踐》 ( Robert C.Martin 著 鄧輝 譯 清華大學出版社 2003 年 9 月) 的第二部分 “敏捷設計” 章節。

規則 1:每一個. c 文件應有一個同名. h 文件,用於聲明需要對外公開的接口。

說明:如果一個. c 文件不需要對外公佈任何接口,則其就不應當存在,除非它是程序的入口,如 main 函數所在的文件。

現有某些產品中,習慣一個. c 文件對應兩個頭文件,一個用於存放對外公開的接口,一個用於存放內部需要用到的定義、聲明等,以控制. c 文件的代碼行數。編者不提倡這種風格。

這種風格的根源在於源文件過大,應首先考慮拆分. c 文件,使之不至於太大。另外,一旦把私有定義、聲明放到獨立的頭文件中,就無法從技術上避免別人 include 之,難以保證這些定義最後真的只是私有的。

本規則反過來並不一定成立。有些特別簡單的頭文件,如命令 ID 定義頭文件,不需要有對應的. c 存在 [a1] 。

示例:對於如下場景,如在一個. c 中存在函數調用關係:

void foo()
{
 bar();
}

void bar()
{
 Do something;
}

必須在 foo 之前聲明 bar,否則會導致編譯錯誤。

這一類的函數聲明,應當在. c 的頭部聲明,並聲明爲 static 的,如下:

static void bar();

void foo()
{
 bar();
}

void bar()
{
 Do something;
}

規則 2:禁止頭文件循環依賴。

說明:頭文件循環依賴,指 a.h 包含 b.h, b.h 包含 c.h, c.h 包含 a.h 之類導致任何一個頭文件修改,都導致所有包含了 a.h/b.h/c.h 的代碼全部重新編譯一遍。

而如果是單向依賴,如 a.h 包含 b.h, b.h 包含 c.h,而 c.h 不包含任何頭文件,則修改 a.h 不會導致包含了 b.h/c.h 的源代碼重新編譯。

規則 3:.c/.h 文件禁止包含用不到的頭文件。

說明:很多系統中頭文件包含關係複雜,開發人員爲了省事起見,可能不會去一一鑽研,直接包含一切想到的頭文件,甚至有些產品乾脆發佈了一個 god.h,其中包含了所有頭文件,然後發佈給各個項目組使用,這種只圖一時省事的做法,導致整個系統的編譯時間進一步惡化,並對後來人的維護造成了巨大的麻煩。

規則 4:頭文件應當自包含

說明:簡單的說,自包含就是任意一個頭文件均可獨立編譯。如果一個頭文件包含某個頭文件,還要包含另外一個頭文件才能工作的話,就會增加交流障礙,給這個頭文件的用戶增添不必要的負擔 [a2] 。

示例:如果 a.h 不是自包含的,需要包含 b.h 才能編譯,會帶來的危害:每個使用 a.h 頭文件的. c 文件,爲了讓引入的 a.h 的內容編譯通過,都要包含額外的頭文件 b.h。額外的頭文件 b.h 必須在 a.h 之前進行包含,這在包含順序上產生了依賴。

注意:該規則需要與 “.c/.h 文件禁止包含用不到的頭文件” 規則一起使用,不能爲了讓 a.h 自包含,而在 a.h 中包含不必要的頭文件。a.h 要剛剛可以自包含,不能在 a.h 中多包含任何滿足自包含之外的其他頭文件。

規則 5:總是編寫內部 #include 保護符( #define 保護)。

說明:多次包含一個頭文件可以通過認真的設計來避免。如果不能做到這一點,就需要採取阻止頭文件內容被包含多於一次的機制。

通常的手段是爲每個文件配置一個宏,當頭文件第一次被包含時就定義這個宏,並在頭文件被再次包含時使用它以排除文件內容。

所有頭文件都應當使用 #define 防止頭文件被多重包含,命名格式爲 FILENAME_H,爲了保證唯一性,更好的命名是 PROJECTNAME_PATH_FILENAME_H。

注:沒有在宏最前面加上 ““,即使用 FILENAME_H 代替_FILENAME_H,是因爲一般以”“和”“開頭的標識符爲系統保留或者標準庫使用,在有些靜態檢查工具中,若全局可見的標識符以”” 開頭會給出告警。

定義包含保護符時,應該遵守如下規則:

1)保護符使用唯一名稱;

2)不要在受保護部分的前後放置代碼或者註釋。

示例:假定 VOS 工程的 timer 模塊的 timer.h,其目錄爲 VOS/include/timer/timer.h, 應按如下方式保護:

#ifndef VOS_INCLUDE_TIMER_TIMER_H

#define VOS_INCLUDE_TIMER_TIMER_H

...

#endif

也可以使用如下簡單方式保護:

#ifndef TIMER_H

#define TIMER_H

..

#endif

例外情況:頭文件的版權聲明部分以及頭文件的整體註釋部分(如闡述此頭文件的開發背景、使用注意事項等)可以放在保護符 (#ifndef XX_H) 前面。

規則 6:禁止在頭文件中定義變量。

說明:在頭文件中定義變量,將會由於頭文件被其他. c 文件包含而導致變量重複定義。

規則 7:只能通過包含頭文件的方式使用其他. c 提供的接口,禁止在. c 中通過 extern 的方式使用外部函數接口、變量 [a3] 。

說明:若 a.c 使用了 b.c 定義的 foo() 函數,則應當在 b.h 中聲明 extern int foo(int input);並在 a.c 中通過 #include 來使用 foo。

禁止通過在 a.c 中直接寫 extern int foo(int input); 來使用 foo,後面這種寫法容易在 foo 改變時可能導致聲明和定義不一致 [a4] 。

規則 8:禁止在 extern “C” 中包含頭文件。

說明:在 extern “C” 中包含頭文件, 會導致 extern “C” 嵌套, Visual Studio 對 extern “C” 嵌套層次有限制,嵌套層次太多會編譯錯誤。

在 extern “C” 中包含頭文件,可能會導致被包含頭文件的原有意圖遭到破壞。例如,存在 a.h 和 b.h 兩個頭文件:

使用 C++ 預處理器展開 b.h,將會得到

extern "C"
{
  void foo(int);
  void b();
}

按照 a.h 作者的本意,函數 foo 是一個 C++ 自由函數,其鏈接規範爲”C++”。但在 b.h 中,由於 #include “a.h” 被放到了 extern “C” { } 的內部,函數 foo 的鏈接規範被不正確地更改了。

示例:錯誤的使用方式:

extern "C"
{
  #include "xxx.h"
  ...
}

正確的使用方式:

#include "xxx.h"

extern "C"
{
  ...
}

建議 1:一個模塊通常包含多個. c 文件,建議放在同一個目錄下,目錄名即爲模塊名。爲方便外部使用者,建議每一個模塊提供一個. h,文件名爲目錄名。

說明:需要注意的是,這個. h 並不是簡單的包含所有內部的. h,它是爲了模塊使用者的方便,對外整體提供的模塊接口。

以 Google test(簡稱 GTest)爲例, GTest 作爲一個整體對外提供 C++ 單元測試框架,其 1.5 版本的 gtest 工程下有 6 個源文件和 12 個頭文件。

但是它對外只提供一個 gtest.h,只要包含 gtest.h 即可使用 GTest 提供的所有對外提供的功能,使用者不必關係 GTest 內部各個文件的關係,即使以後 GTest 的內部實現改變了,比如把一個源文件 c 拆成兩個源文件,使用者也不必關心,甚至如果對外功能不變,連重新編譯都不需要。

對於有些模塊,其內部功能相對鬆散,可能並不一定需要提供這個. h,而是直接提供各個子模塊或者. c 的頭文件。

比如產品普遍使用的 VOS,作爲一個大模塊,其內部有很多子模塊,他們之間的關係相對比較鬆散,就不適合提供一個 vos.h。而 VOS 的子模塊,如 Memory(僅作舉例說明,與實際情況可能有所出入),其內部實現高度內聚,雖然其內部實現可能有多個. c 和. h,但是對外只需要提供一個 Memory.h 聲明接口。

建議 2:如果一個模塊包含多個子模塊,則建議每一個子模塊提供一個對外的. h,文件名爲子模塊名。

說明:降低接口使用者的編寫難度。

建議 3:頭文件不要使用非習慣用法的擴展名,如. inc。

說明:目前很多產品中使用了. inc 作爲頭文件擴展名,這不符合 c 語言的習慣用法。在使用. inc 作爲頭文件擴展名的產品,習慣上用於標識此頭文件爲私有頭文件。

但是從產品的實際代碼來看,這一條並沒有被遵守,一個. inc 文件被多個. c 包含比比皆是。本規範不提倡將私有定義單獨放在頭文件中,具體見 規則 1.1。

除此之外,使用. inc 還導致 source insight、 Visual stduio 等 IDE 工具無法識別其爲頭文件,導致很多功能不可用,如 “跳轉到變量定義處”。

雖然可以通過配置,強迫 IDE 識別. inc 爲頭文件,但是有些軟件無法配置,如 Visual Assist 只能識別. h 而無法通過配置識別. inc。

建議 4:同一產品統一包含頭文件排列方式。

說明:常見的包含頭文件排列方式:功能塊排序、文件名升序、穩定度排序。

以穩定度排序,建議將不穩定的頭文件放在前面,如把產品的頭文件放在平臺的頭文件前面,如下:

相對來說, product.h 修改的較爲頻繁,如果有錯誤, 不必編譯 platform.h 就可以發現 product.h 的錯誤,可以部分減少編譯時間。

[a1] 例如一些屏驅動的地址文件, 一些協議的格式定義文件. 只存在. c 或者. h 即可, 不一定兩者都要有。

[a2] 我對自包含沒有太理解,只是明白在. h 文件裏儘量不包含沒有必要的頭文件, 某些情況下不得已才進行包含其它頭文件的操作。

[a3] 這種做法我寫代碼常用, 但後面應該儘量避免, 而是通過調用頭文件的方式來使用該函數。

[a4] 對,我就遇到過。因爲隨着工程量的增大,後面某個細節調整了 foo 函數, 但其它 extern 調用它的地方沒有及時改正, 而 KEIL 編譯器又沒有報錯, 導致 bug 出現, 而且不易查找。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nouUIQi_7tc8jkS9nQTnxQ