標頭檔 (Header Files)

一般來說,每一個 .cc 檔都應該要有一個對應的 .h 檔。 不過也有一些常見的例外,像是單元測試 (Unit Test) 跟一些只包含著 main() 的小型 .cc 檔就不需要有。

正確地使用標頭檔可以對可讀性、程式碼大小與效能帶來巨大的影響。

以下這些原則會引領你克服標頭檔中各式各樣的陷阱。

自給自足標頭檔 (Self-contained Headers)

標頭檔應該要自給自足 (self-contained),而且副檔名必須是 .h。 專門用來插入文件的非標頭檔,則應該要使用 .inc 作為副檔名,並且應該盡少使用。

所有標頭檔都應該要自給自足。 換句話說,使用者或者重構工具 (Refractoring Tool) 並不需要依賴任何額外的條件才能夠插入標頭檔。 更精確地說,標頭檔應該要包含 標頭檔保護,而且應該要自己插入所有需要的其他標頭檔。

建議將模板與行內函式的定義放在同樣的檔案作為宣告。 所有使用到這些東西的 .cc 檔都應該要載入這些結構,不然在某些建置設定下會造成程式無法連結。 如果將定義與宣告分別在不同檔案,載入宣告的話也應該要能夠同時載入其定義。 不要將這些定義移至額外的 -inl.h 檔中。 這種作法在過去很常見,但是從現在開始不允許這麼做。

有一個例外是,函式模板的顯式實體化 (explicitly instantiated) 或者該模板是一個類別的私有成員的話,可以只定義在實體化該模板的 .cc 檔中。

在某些極少數的狀況下,標頭檔可以不用是自給自足的。 這些特殊的標頭檔通常是用來載入程式碼到做一些不尋常的位置,例如載入到另一個檔案的中間。 他們可以不使用 標頭檔保護,而且可能沒有載入他們所需的檔案。 這種類型的檔案應該使用 .inc 作為副檔名。 盡量別使用這種檔案,可能的話還是盡量用自給自足的標頭檔。

#define 保護 (The #define Guard)

所有的標頭檔應該要包含 #define 保護,以防止多重載入。 其名稱的格式為 <專案名稱>_<路徑>_<檔名>_H_

為了保證名稱的獨特性,應該要遵照該檔案在專案中的完整路徑來定義。 例如,一個在專案 foo 之中 foo/src/bar/baz.h 位置下的檔案,其保護應該要這樣寫:

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_

...

#endif  // FOO_BAR_BAZ_H_

前向宣告 (Forward Declarations)

盡可能地避免使用前向宣告。 只要 #include 你需要的標頭檔就好。

定義

類別、函式與模板的前向宣告指的就是在沒有定義其內容的情況下,預先宣告名稱的程式碼。

優點

  • 前向宣告可以節省編譯時間。 #include 會使得編譯器處理時必須要開啟更多檔案而且處理更多資料。
  • 前向宣告可以避免不必要的重新編譯。 #include 有可能造成你的標頭檔有點修改,其他相關的程式碼就得需要被重新編譯。

缺點

  • 前向宣告可以用來隱藏某些依賴關係,可能會造成使用者的程式碼在標頭檔修改後略過了必要的重新編譯過程。
  • 一個前向宣告可能會因為其函式庫內部的修改而損壞。 函式與模板的前向宣告會造成撰寫標頭檔的人無法更改 API。 舉例來說,增加函式接受的參數,給模板增加一個預設數值,或者轉移到一個新的名稱空間。
  • 前向宣告 std:: 名稱空間內的符號時常產生一些未定義的行為。
  • 有時候很難界定到底是否該在程式碼中使用前向宣告,或者全部使用 #include。 有時候替換掉 #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 替換成 BD 的前向宣告的話,test() 就會呼叫 f(void*)

  • 從一個標頭檔前向宣告多個符號比單純地 #include 更難在發生錯誤時除錯。
  • 為了使用前向宣告而重構程式碼(像是把把物件成員換成指標),可能會造成程式變慢或者更加複雜

抉擇

  • 盡量避免前向宣告其他專案中的實體
  • 當使用一個宣告在標頭檔內的函式時,總是 #include 那個標頭檔
  • 當你要使用類別模板時,盡量使用 #include

請參考「#include 時的名稱與順序」來參考何時應該插入標頭檔

譯註

老實說我也是第一次看到「前向宣告」這個詞,因此我花了點時間研究這到底是什麼東西。 我稍微閱讀了維基百科上的資料之後,在此寫下我對這個這個詞的理解。

事實上,前向宣告這個詞簡單來說就是「在定義之前先宣告」。 相信應該不少人有宣告過函式吧?像是:

int sum(int, int);

這裏你可以看到我們並沒有寫說 sum 這個函式代表著什麼意思,只說他接受兩個整數參數,回傳一個整數值,而實際上 sum 的內容則定義在別的地方。 這個目的就是在告訴編譯器:「有個叫做 sum 的函式存在,等下使用時,請把 sum 這個符號當成一個接受兩個整數參數,回傳一個整數的函式處理。」

其實這就是一個「前向宣告」,也就是預先告知這個東西的存在,然後再另外定義內容。

前向宣告除了函式之外,也可以用在類別與模板上。 假設現在其他檔案中定義了 class A,而你的檔案需要用到它,你可以單純地宣告:

class A;

這樣就可以不用使用 #include 來載入相關的標頭檔。 但是這個使用有個限制,就是程式碼中使用到 class A 的地方,都只能使用指標或參考,而不能直接使用像是 A a 這樣的變數。 使用類別的前向宣告要注意的事情相對比較多,所以上面的建議才會提到大部份的情況還是直接 #include 比較好。

行內函式 (Inline Functions)

只有在函式程式碼少於或等於 10 行時才將它宣告為行內函式

定義

透過宣告函式為行內函式,可以讓編譯器直接在呼叫該函式的地方展開函式,而不是遵照一般的函式呼叫機制編譯。

優點

將函式宣告為行內函式可以產生更有效率的目的碼 (object code),因為行內函式比起一般的函式小很多。 你可以盡量將存取函式 (accessor) 、修改函式 (mutator) 以及一些極短但對效能有巨大影響的函式行內化。

缺點

過度使用行內函式可能會造成程式變慢。 依照函式長度的不同,行內化可能會增加或減少程式碼的大小。 行內化一個很小的存取函式通常可以減少程式碼的長度,不過行內化一個很大的函式可能會巨幅地增加長度。 現在的處理器因為指令快取 (instruction cache) 的關係,處理較短的程式碼通常會更快。

抉擇

一個適當的規則是不要將 10 行以上的函式行內化。 其中要特別注意解構函式 (destructors),解構函式常常比你所看到的還要長。 因為解構函式還會隱性地另外呼叫成員以及基底類別 (base class) 的解構函式。

另一個有用的規則:一般來說將一個有迴圈或者 switch 的函式行內化對效能並沒有幫助 (除非大多數的情況下這個迴圈或 switch 都不會被執行到)。

要特別注意的是,就算將一個函式宣告為行內函式,編譯器也不一定會照做。 例如:虛擬函式 (virtual function) 或遞迴函式 (recursive function) 常常不會被行內化。 因為遞迴函式ㄧ般來說不該是行內函式。 至於將虛擬函式寫成行內函式的理由,通常只是為了要方便將函式的定義放在類別內而已 (例如類別的存取函式或修改函式)。

譯註

這邊提供一個行內函式的範例,inline 關鍵字是將函式行內化的關鍵:

inline int sum(int a, int b) {
    return a + b;
}

#include 時的名稱與順序

利用右列順序 #include 標頭檔,避免隱藏的依賴關係:直接相關的標頭檔、C 函式庫、C++ 函式庫、其他函式庫 .h 檔、你的專案的 .h 檔。

所有標頭檔的路徑應該都要以專案的程式碼目錄為起點,並且不要使用 UNIX 資料夾簡稱,像是 . (現在) 跟 .. (上一個目錄)。 舉例來說,google-awesome-project/src/base/logging.h 檔案應該要被這樣載入:

#include "base/logging.h"

假設現在有 dir/foo.ccdir/foo_test.cc 檔案,其目標在於實作或測試 dir2/foo2.h 檔內的東西,那麼 #include 的順序應該這樣寫:

  1. dir2/foo2.h
  2. C 系統檔
  3. C++ 系統檔
  4. 其他函式庫 .h
  5. 你的專案的 .h

依照這個順序,如果 dir2/foo2.h 遺漏了任何必要的 #include,那麼在建置 dir/foo.ccdir/foo_test.cc 的時候就會中斷。 因此這確保了建置會先在這些檔案中斷,而不是在其他無辜的地方發生。

dir/foo.ccdir2/foo2.h 通常會放在同一個資料夾下,像是 base/basictypes_test.ccbase/basictypes.h,但是有時候也有可能會分開放。

每個區塊中的檔案應該要依照字母順序排列。 要注意一些比較老的專案中可能沒有遵照這個規則,這些都應該要等方便的時候修改過來。

你應該要 #include 所有包含你使用到任何符號的標頭檔 (除非你使用了前向宣告)。 如果你使用了 bar.h 中的符號,別期待你 #includefoo.h 之後,foo.h 裡面會包含著 bar.h,此時你應該也要 #include bar.h,除非 foo.h 有明顯地展現出它提供了 bar.h 中的符號。 另外,已經在相關的標頭檔中 #include 過的東西,可以不用在 cc 檔中 #include (像是 foo.cc 可以依賴在 foo.h 上)。

舉個範例,google-awesome-project/src/foo/internal/fooserver.cc 檔中的 #include 可能長這樣:

#include "foo/server/fooserver.h"

#include <sys/types.h>
#include <unistd.h>
#include <hash_map>
#include <vector>

#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/server/bar.h"

特例

有時候,只能在某些系統中使用的程式碼需要有條件地 #include,這種就可以程式碼就可以放在所有 #include 之後。 當然,盡可能地讓這種程式碼越少且影響範圍越小越好。 例子:

#include "foo/public/fooserver.h"

#include "base/port.h"  // For LANG_CXX11.

#ifdef LANG_CXX11
#include <initializer_list>
#endif  // LANG_CXX11

results matching ""

    No results matching ""