
前言:
很多事不深入以為自己懂了,但真正用到專案上,才發現了問題。曾以為自己寫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:標頭檔中適合放置介面的宣告,不適合放置實現。
說明:標頭檔是
-
內部使用的函式(相當於類的私有方法)宣告不應放在標頭檔中。
-
內部使用的巨集、列舉、結構定義不應放入標頭檔中。
-
變數定義不應放在標頭檔中,應放在.c檔案中。
-
變數的宣告盡量不要放在標頭檔中,亦即盡量不要使用全域變數作為介面 。變數是模組或單元的內部實現細節,不應透過在標頭檔中宣告的方式直接暴露給外部,應透過函式介面的方式進行對外暴露。
延伸閱讀材料:《 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中存在函式呼叫關係:
1 2 3 4 5 6 7 8 9 | void foo() { bar(); } void bar() { Do something; } |
必須在foo之前宣告bar,否則會導致編譯錯誤。
這一類的函式宣告,應當在.c的頭部宣告,並宣告為static的,如下:
1 2 3 4 5 6 7 8 9 10 11 | 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,應按如下方式保護:
1 2 3 4 5 6 7 | #ifndef VOS_INCLUDE_TIMER_TIMER_H #define VOS_INCLUDE_TIMER_TIMER_H ... #endif |
也可以使用如下簡單方式保護:
1 2 3 4 5 6 7 | #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,將會得到
1 2 3 4 5 | extern "C" { void foo(int); void b(); } |
按照a.h作者的本意,函式foo是一個C++自由函式,其連結規範為」C++」。但在b.h中,由於#include 「a.h」被放到了extern 「C」 { }的內部,函式foo的連結規範被不正確地更改了。
範例:錯誤的使用方式:
1 2 3 4 5 | extern "C" { #include "xxx.h" ... } |
正確的使用方式:
1 2 3 4 5 6 | #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出現,而且不易搜尋。
作者:奶蓋紅茶
原文連結:
https://blog.csdn.net/fengcq126/article/details/103016917?spm=1001.2014.3001.5501

1.盤點國內RISC-V內核MCU廠商!
2.嵌入式行業經濟發展和歐盟工業發展的晴雨表,你關注了么?
3.一個技術員工的離職成本,到底有多高?
4.新世界,新方式:2021年國際嵌入式展及大會走向數值化
5.扒一扒國產Linux作業系統架構是怎麼設計的?
6.人工智慧入門:基於Linux與Python的神經網路

免責宣告:本文系網路轉載,版權歸原作者所有。如涉及作品版權問題,請與我們聯繫,我們將根據您提供的版權證明材料確認版權並支付稿酬或者刪除內容。