3. 標頭檔 (Header Files)
通常每一個 .cc
檔都有一個對應的 .h
檔。也有一些常見例外,如單元測試程式碼和內部只有 main()
函式的 .cc
檔。
正確使用標頭檔可令程式碼在可讀性、文件大小和編譯性能上大為改觀。
下面的規則將引導你避開使用標頭檔時的各種陷阱。
3.1. 自我完整 (Self-contained) 的標頭檔
小訣竅
標頭檔應該能夠自我完整(不需要先引入其他的標頭檔就能通過編譯),以 .h
結尾。用來插入其他檔案的文件,應以 .inc
結尾,同時應儘量少用。
所有標頭檔都必須自我完整。使用者和重構工具不需要為了使用一個標頭檔而引入額外更多的標頭檔。特別是,一個標頭檔應該要有 #define 保護 (#define Guard)、且引入所有需要的其它標頭檔。
儘可能把模板和行內函式的宣告和定義放在同一支檔案中。在每個使用到這些模板、行內函式的 .cc
檔中,都必須要引入它們的定義,否則在某些編譯組態下會發生連結錯誤。如果宣告和定義放在不同的檔案中的話,那麼引入前者應該要自動將後者引入。不要將這些定義放在分開的引入標頭檔 (-inl.h
) 中;這樣的做法在過去很常見,但現在已經不被允許了。
有個例外:如果某個模板為所有相關模板參數顯式具現化,或本身就是類別的一個私有詳細實作,那麼它就只能定義在具現化該模板的 .cc
檔裡。
在少數的情況下,有些要被引入的檔案並不自我完整。這些檔案通常會在不太尋常的地方(例如檔案的中間)被引入。這些檔案也許不會有 #define 保護 (#define Guard),也可能不會引入它們所需要的其他檔案。這些檔案的附檔名應為 .inc
。儘量少用這樣的檔案,可能的話,還是使用自我完整的標頭檔。
3.2. #define 保護 (#define Guard)
小訣竅
所有標頭檔都應該使用 #define
防止標頭檔被多次引入。建議的命名格式為 <PROJECT>_<PATH>_<FILE>_H_
為保證唯一性,#define
的名稱中應包含標頭檔在專案中的完整路徑。例如:專案 foo 中的標頭檔 foo/src/bar/baz.h
可按如下方式保護:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
3.3. 前置宣告 (Forward Declaration)
小訣竅
避免使用前置宣告,直接引入需要的標頭檔即可。
定義:
前置宣告是不提供與之關連的定義下,宣告一個類別、函式或是模板。
優點:
由於
#include
會強制編譯器開啟更多的檔案與處理更多的輸入,利用前置宣告減少#include
可以減少編譯時間。越多的
#include
代表程式碼更可能因為相依的標頭檔更動而被重新編譯,使用前置宣告可以節省不必要的重新編譯。
缺點:
前置宣告可能隱藏掉與標頭檔間的相依關係,導致當標頭檔改變時,相依的程式碼沒有被重新編譯。
前置宣告可能在函式庫進行可向下相容的 API 改動時發生編譯錯誤。例如函式庫開發者放寬了某個參數類型、替模板增加預設參數或更改命名空間等等。
前置宣告來自
std::
命名空間的 symbols 會導致未定義行為 (undefined behavior)。難以抉擇是要使用前置宣告或是引入完整的標頭檔。使用前置宣告替換掉
#include
有可能意外地修改了程式碼的意圖:// b.h: struct B {}; struct D : B {}; // good_user.cc: #include "b.h" void f(B*); void f(void*); void test(D* x) { f(x); } // calls f(B*)若
#include
被替換成 B 和 D 的前置宣告,test()
會呼叫到f(void*)
。使用前置宣告多個 symbols 可能比直接引入標頭檔更冗長。
為了使用前置宣告而修改程式碼(例如:使用指標成員而不是物件成員)可能會導致程式運作較為緩慢或是更加的複雜。
結論:
在任何狀況下避免使用前置宣告。
當在標頭檔內使用到函式宣告時,總是引入對應的標頭檔。
當使用類別模板時,建議引入對應的標頭檔。
至於什麼時候引入標頭檔,參見 #include 的路徑及順序。
3.4. 行內函式 (Inline Functions)
小訣竅
只有當函式非常的短,例如只有 10 行甚至更少的時候,才將其定義為行內函式。
定義:
當函式被宣告為行內函式之後,代表你允許編譯器將其展開在該函式被呼叫的位置,而不是原來的函式呼叫機制進行。
優點:
當函式主體比較小的時候,行內該函式可以產生更有效率目標程式碼 (object code)。對於存取函式 (accessors)、賦值函式 (mutators) 以及其它函式體比較短或對性能要求較高的函式,可以依據需求將其轉為行內函式。
缺點:
濫用行內函式反而會導致程式變慢。行內展開可能使目標程式碼變大或變小,這取決於行內函式主體的大小。一個非常短小的存取函式被行內展開通常會減少目標程式碼的大小,但展開一個相當大的函式將非常顯著地增加目標程式碼大小。現代的處理器 (CPU) 具備有指令快取 (instruction cache),小巧的程式碼往往執行時間較短。
結論:
一個較為合理的經驗準則是,不要將超過 10 行的函式寫成行內函式。謹慎對待解構式。解構式的執行時間往往比表面看起來更長,因為還需要呼叫隱式成員和父類別的解構式!
另一個實用的經驗準則:若是函式內有迴圈或是
switch
語句的話,行內展開常會得不償失(除非在大多數情況下,這些迴圈或switch
不會被執行)。要注意的是,既使函式即使宣告為行內,也不一定會被編譯器展開。例如虛擬函式 (virtual) 和遞迴函式 (recursive) 就不會被正常展開。通常,遞迴函式不應該宣告成行內函式。將虛擬函式寫成行內的主要原因是想把它的定義和類別定義放在一起,可能是為了方便,也可能是當作文件描述其行為。例如存取函式或賦值函式就常這麼做。
3.5. #include
的路徑及順序
小訣竅
使用以下標準的標頭檔引入順序可增強可讀性,同時避免隱藏的相依性:相關標頭檔 > C 函式庫 > C++ 函式庫 > 其他函式庫的 .h > 專案內的 .h。
專案內的標頭檔應按照專案目錄樹結構排列,避免使用 UNIX 特殊的目錄捷徑 .
(當前目錄) 或 ..
(上層目錄)。例如:
google-awesome-project/src/base/logging.h
應該按如下方式引入:
#include "base/logging.h"
另一個例子是,若 dir/foo.cc
或 dir/foo_test.cc
的主要作用是實作或測試 dir2/foo2.h
的功能,foo.cc
中引入標頭檔的次序應如下:
dir2/foo2.h
空一行
C 系統檔案
C++ 系統檔案
空一行
其他函式庫的
.h
檔專案內的
.h
檔
注意不要有連續的空白行。
使用這種排序方式,若是 dir2/foo2.h
忽略了任何需要的標頭檔,在編譯 dir/foo.cc
或 dir/foo_test.cc
就會因發生錯誤而停下來。因此這個規則可以確保這些功能的開發者可以在第一時間就發現錯誤,而不會波及維護其他部份的無辜程式員。
dir/foo.cc
和 dir2/foo2.h
通常位於同一目錄下(如 base/basictypes_test.cc
和 base/basictypes.h
),但也可以放在不同目錄下。
C 的相容性標頭檔(例如 stddef.h
)基本上都能換成 C++ 所提供的對應版本(例如 cstddef
) 。想用哪種都可以,但請儘量和現有的程式碼保持一致。
標頭檔的順序在依照類別分類後,同類別的引入順序則應該依照按字母順序排列。若現有程式碼不是按照這個規則,應該在有空閒的時間將其修正。
你所需要的 symbols 被哪些標頭檔所定義,你就應該引入那些標頭檔,但在少數使用 前置宣告 的情況除外。例如你要用到 bar.h
中的某個 symbol,哪怕你所引入的 foo.h
已經引入了 bar.h
,你也應顯示的引入 bar.h
,除非 foo.h
有明確說明它會向你提供 bar.h
中的 symbol。不過,.cc
檔中所對應的標頭檔引入的其他標頭檔,就不需要在 .cc
檔中重複引入了。例如 foo.cc
不用再次引入 foo.h
已經引入的標頭檔。
舉例來說,google-awesome-project/src/foo/internal/fooserver.cc
的引入順序如下:
#include "foo/public/fooserver.h"
#include <sys/types.h>
#include <unistd.h>
#include <vector>
#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"
例外:
有時,系統專屬(system-specific)的程式碼需要依據條件被引入。這種情況下,這些部份可以放到其它的
#includes
之後。當然,儘量讓你的系統專屬程式碼小且集中,例如:#include "foo/public/fooserver.h" #include "base/port.h" // For LANG_CXX11. #ifdef LANG_CXX11 #include <initializer_list> #endif // LANG_CXX11