Mar 31 2008
表頭檔要不要?拿速度來換!
本文要提出的內容,是一個非常基礎的程式觀念:表頭檔 (Header Files) 與編譯依存關係的課題。雖然對於許多程式設計者來說,這個觀念技巧應該是相當基本而且必備的基礎知識,但在現實世界中,我卻時常看到許多人忽略了這個問題的重要性,因而造成專案開發流程中的重大災難。
來看看實際的範例。例如在專案中有一個 GameObject 類別,用來實作遊戲物件的相關操作行為:
// @ GameObject.h
#include < string >
class GameObject
{
public:
int GetType();
std::string& GetName();
private:
int m_iType;
std::string m_kName;
};
有了基礎的遊戲物件類別之後,自然會陸續建構並且實作出其他的類別。當 GameObject 需要使用或包含其他的類別時,可能會在類別中定義一個該類別的成員變數,以結合 GameObject 類別進行更複雜的操作行為。例如,可能會在 GameObject 類別中,加入一個 ComponentA 類別的物件定義遊戲物件的基本組件:
// @ GameObject.h
#include < string >
#include "ComponentA.h"
class GameObject
{
public:
int GetType();
std::string& GetName();
ComponentA& GetComponentA();
private:
int m_iType;
std::string m_kName;
ComponentA m_kCompA;
};
隨著專案的開發與需求的增加,程式碼的營養越來越好、越長越胖,同時與 GameObject 緊緊相依、難分難捨的類別們也會越來越多。於是,同樣地一一把它們加入 GameObject 的表頭檔宣告中:
// @ GameObject.h
#include < string >
#include < vector >
#include "ComponentA.h"
#include "ComponentB.h"
#include "ComponentC.h"
#include "ComponentD.h"
class GameObject
{
public:
int GetType();
std::string& GetName();
ComponentA& GetComponentA();
private:
int m_iType;
std::string m_kName;
ComponentA m_kCompA;
ComponentB m_kCompB;
ComponentC m_kCompC;
std::vector< ComponentD > m_kCompDList;
};
除了萬能的 GameObject 類別之外,或許在其他的類別,例如 GameEffect 類別中,也會需要使用 ComponentA、ComponentB、ComponentC 與 ComponentD 這些類別。既然在許多地方都會需要使用到這些類別,何不將這些 #include 的表頭檔全部放在一個檔案中?所以聰明的程式設計者想出了一個超級好辦法,就是把所有需要的表頭檔全部丟進同一個表頭檔 GameInclusions.h 中:
// @ GameInclusions.h #include < string > #include < vector > #include "ComponentA.h" #include "ComponentB.h" #include "ComponentC.h" #include "ComponentD.h"
然後在有需要的地方,只要輕鬆簡單地 #include 這個檔案,就可以自由使用檔案中所包含的任何類別:
// @ GameObject.h
#include "GameInclusions.h"
class GameObject
{
public:
int GetType();
std::string& GetName();
ComponentA& GetComponentA();
private:
int m_iType;
std::string m_kName;
ComponentA m_kCompA;
ComponentB m_kCompB;
ComponentC m_kCompC;
std::vector< ComponentD > m_kCompDList;
};
不管是 GameEffect 類別、GameRule 類別、GameFactory 類別還是 GameBalabala 類別,所有需要的類別都可以無差別地使用:
// @ GameEffect.h
#include "GameInclusions.h"
class GameEffect
{
public:
// Member functions
private:
ComponentA m_kCompA;
ComponentC m_kCompC;
};
啊哈!從此再也不需要麻煩地去區分,在這個類別中只使用到 ComponentB 與 ComponentC 類別,而另一個類別只使用到 ComponentD 類別等等許多不同的情況,反正把它們像模組一樣通通包起來,一包就能解決,一次搞定!輕鬆愉快又自然簡單!
可惜的是,程式設計者的日子,從來就沒這麼好過。
在上述的範例中所使用「在表頭檔中 #include 其他表頭檔」的用法,會造成程式碼檔案之間嚴重的編譯依存關係 (Compilation Dependency) 而使專案建置與編譯的速度大幅下降。什麼是編譯依存關係?請看以下這個例子:
// @ Example.cpp #include "GameObject.h" GameObject var;
當你在程式碼檔案中寫出上述的變數宣告敘述句,不論程式碼是位於 .h 表頭檔或是 .cpp 原始檔,就是告訴編譯器要依據 GameObject 類別,產生出一個名為 var 的物件變數。為了要讓編譯器能夠配置物件的記憶體,程式設計者必須要盡告知的義務,通知編譯器在哪個檔案中可以找到 GameObject 的相關資訊;因此,也就是需要使用每個程式設計者都很熟悉的行為:在程式碼檔案中撰寫對應的 #include 語法。
而後每次在 Example.cpp 內容有所變動的時候,編譯器需要對這個檔案進行重新編譯的工作。編譯器首先會編譯 Example.cpp 中所 #include 的檔案,確認 GameObject.h 檔案的內容通過語法檢測並且沒有任何問題,才能夠對我們所需要的 GameObject 物件做出正確的記憶體配置行為,也才會繼續編譯 Example.cpp 檔案。因此,正如每個程式設計者所熟知的基本知識,使用到什麼類別,就要 #include 相對應的表頭檔,這是再自然也不過的事情了!
那麼,在表頭檔中 #include 其他表頭檔會造成什麼問題?按照上述的說法,也就是每次在表頭檔內容有所變動的時候,都需要重新編譯這個檔案中所有 #include 的表頭檔!換言之,在前述的 GameObject 與 Component 類別的例子中,只要在 GameObject.h 中新增或修改任何的程式碼,而需要對 GameObject.h 進行重新編譯時,就會使得編譯器需要先行編譯 ComponentA.h、ComponentB.h、ComponentC.h 以及 ComponentD.h 四個檔案,最後才對 GameObject.h 進行編譯。如果 GameObject.h 只被 GameObject.cpp 所 #include 就不會造成太多的問題;但是如果 GameObject.h 被許多其他的表頭檔與原始檔所 #include,就會在程式專案中造成指數倍增的檔案依存關係!
在這個例子中,只要使用前置宣告 (Forward Declaration) 的方式,並且將原來的實名物件轉換成物件指標的宣告方式,就能夠避免在表頭檔中 #include 其他表頭檔而造成的編譯依存關係。
// @ GameObject.h
#include < string >
#include < vector >
// 前置宣告
class ComponentA;
class ComponentB;
class ComponentC;
class ComponentD;
class GameObject
{
public:
int GetType();
std::string& GetName();
ComponentA& GetComponentA();
private:
int m_iType;
std::string m_kName;
ComponentA* m_pkCompA; // 轉換成 ComponentA 類別的物件指標
ComponentB* m_pkCompB; // 轉換成 ComponentB 類別的物件指標
ComponentC* m_pkCompC; // 轉換成 ComponentC 類別的物件指標
std::vector< ComponentD* > m_kCompDList; // 轉換成 ComponentD 類別的物件指標
};
在《Effective C++》中的第 31 項條款,對於編譯依存關係的問題,刻下每位程式設計者都應該謹記於心的三條戒文:
- 如果 object references 或 object pointers 可以完成任務,就不要使用 objects。
- 如果能夠,盡量以 class 的宣告取代 class 的定義。
- 不要在表頭檔中 #include 其他表頭檔,除非你的表頭檔不這樣就無法編譯。
謹遵這三項條例,才能夠最小化程式專案中的編譯依存關係。在程式檔案中,應該小心謹慎地使用 #include 語法,一個不少,同時也一個都不能多。這是做為一位程式設計者所應該具備的程式撰寫基本素養。
有時候為了減少編譯依存關係,而使用前置宣告的情況會比較不那麼顯而易見;像是我們為了避免 Pass-by-Value 所造成的物件複製問題,所以在成員函式的參數列中會盡量使用 object pointer 或 object reference 的方式傳遞物件。另外,有時候為了解決表頭檔間的循環依存 (Circular Dependencies) 問題,也就是 ComponentA.h 中 #include 了 ComponentB.h,而在 ComponentB.h 中也 #include 了 ComponentA.h 的情況,所以使用前置宣告也是必須的作法。然而,能夠有意識地選擇使用前置宣告的撰寫方法,對於程式設計者來說,仍然是非常重要的觀念與技巧。
在《Effective C++》中,作者也另外提出了一個設計方法,能夠隱藏類別中的實作細節,而達到減少編譯依存關係的效果:
// @ GameObject.h
class GameObjectImp;
class GameObject
{
public:
void SetCompAType(int _iType);
ComponentB* MakeCompB();
void ProcessCompC(ComponentC* _pkCompC);
private:
GameObjectImp* m_pkImp;
};
如程式碼所示,在此新增一個 GameObjectImp 類別,然後在 GameObject 的實作程式碼中,將與其他物件相關的實作細節全權委託給 GameObjectImp 類別處理:
// @ GameObject.cpp
#include "GameObject.h"
#include "GameObjectImp.h"
void GameObject::SetCompAType(int _iType)
{
m_pkImp->SetCompAType(_iType);
}
ComponentB* GameObject::MakeCompB()
{
return m_pkImp->MakeCompB();
}
void GameObject::ProcessCompC(ComponentC* _pkCompC)
{
m_pkImp->ProcessCompC(_pkCompC);
}
使用了上述的設計方法後,在 GameObject.cpp 中僅需要 #include 唯一的 GameObjectImp.h 檔案而完全無須使用其他的表頭檔,能夠大幅消除 GameObject 與其他類別檔案之間的編譯依存關係。然而這個實作方法的缺點,在於需要多產生出一個與原類別相對應的 Imp 類別,並且在每次使用成員函式時,額外增加了一次函式呼叫的成本。所以還是需要視專案的實際情況,決定是否運用這個方法降低編譯依存關係才會比較恰當。
雖然使用前置宣告與物件指標是非常有用的技巧,但是在某些情況中,也會受到限制而無法發揮作用:
- 在 Inline Memeber Functions 中使用其他類別。
- 在 Templated Class 中使用其他類別。
在 C++ 中我們經常會利用行內成員函式 (Inline Member Functions) 來降低函式呼叫的成本,但是如果在函式中呼叫了其他物件的成員函式,編譯器就需要真正獲得該類別函式的宣告,而必須直接 #include 相對應的表頭檔:
// @ GameObject.h
#include < string >
#include < vector >
// 必須使用 #include 而非前置宣告
#include "ComponentA.h"
class GameObject
{
public:
inline void SetCompAType(int _iType)
{
m_pkCompA->SetType(_iType);
}
private:
ComponentA* m_pkCompA;
};
另外一個狀況則是宣告樣版類別 (Templated Class) 時,與行內成員函式的情境相似,由於樣版類別的函式定義,需要撰寫在表頭檔或是 .inl 檔案中,所以在樣版類別的宣告檔案中,同樣也無法使用前置宣告來降低檔案間的編譯依存關係。所以在使用這兩項技術的同時,也等於是無可避免地增加了程式碼檔案間的編譯依存關係。
雖然利用先行編譯表頭檔 (Precompiled Header) 對於專案整體的建置效能能夠有顯著的提升,但是仍然會有力有未逮之處,同時在實際應中也有許多要注意的地方,並不是一股腦地把表頭檔往裡塞就能夠得出良好的編譯效能。關於先行編譯表頭檔的使用,可以參考「The Care and Feeding of Pre-Compiled Headers」這篇很棒的文章。
另外有一些商業軟體工具如 IncrediBuild,能夠在區域網路中利用多台電腦的串連,以分散式運算的方法進行程式專案的編譯工作,進而大幅提昇編譯建置的速度。然而這些工具大多需要付出可觀的授權費用,並不是每間公司或每個專案都能夠承受這樣的支出項目。如果只是因為程式設計者們的不負責任態度或錯誤習慣,而造成專案的編譯速度低落,並不值得以這樣的金錢投資換取較佳的工作效能,應該要解決問題的產生根源才是真正的長久之道。
以我親身經歷過的慘痛經驗來說,利用前置宣告與先行編譯表頭檔,重構整個專案中的程式檔案依存關係,真的能夠達到不輸於使用昂貴輔助工具的效果。唯一的缺點就是程式設計者的「等待專案建置時間」變少了,而原本很充裕的喝咖啡時間、打屁聊天時間,以及發呆晃神的時間也通通變少了許多。所以如果做為一個專案管理者的角色,我應該會要求「在表頭檔中撰寫 #include 語法」這件事,需要經過監督者的蓋章許可才能使用!
在小型的的專案中,使用這些技巧或許看不出太明顯的速度差異,但是當專案規模越來越大,成長到上百甚至上千個表頭檔的程度時,編譯依存性的問題就會開始顯露出可怕的負面作用力,逐漸影響開發流程的工作效率。因此,從一開始就養成並且保持良好的程式寫作習慣是非常重要的事情,因為做為一個程式設計師所做的多數行為,都是沒有經過有意識思考,而是以直覺做出反應與決定的行為。習慣會決定我們多數日常生活的行為,當然也包括工作的行為在內。
關於習慣的重要性,可以參考看看比爾大叔的說法:
Bill Gates says that any programmer who will ever be good is good in the first few years. After that, whether a programmer is good or not is cast in concrete.
如果僅以編譯依存關係與專案編譯速度的角度來看,在 C# 中沒有了表頭檔與原始檔的區別,類別宣告與定義都寫在同一個檔案中,也不需要在檔案之間使用 #include 以參照其他類別的宣告定義,還真的是對於程式設計者的一大福音。然而在 C++ 的真實世界與開發環境中,還是請自行維持良好的程式寫作紀律吧。
所以做為程式設計者,請從現在開始就養成良好的習慣,不要只是想著讓所有事情變得簡單,否則到最後,你將會擁有一個包含所有其他檔案的巨大表頭檔,以及許多不可預期的悲慘後果!

Nice post! 我想請問有人喜歡把#include寫在.cpp有人喜歡寫在.h中,這兩種方式會有任何差別嗎?有沒有哪種方式會比較好?
我一直覺得header file的機制很不方便,每次寫/改一個class就必需重覆兩次(宣告+定義),有時候沒處理好會出現讓人想摔鍵盤的的錯誤(像是字打錯造成undefined external reference) :(
希望未來C++09能提供更方便的支援機制。
–
當然作為一個1980年出現的語言,這樣的要求是有點過份啦._.
看過這篇文章後,我也寫了一篇自己關於 inline function 在標頭檔的一些看法和你交流。
http://miloyip.seezone.net/?p=47
P.S. 還想問一下,你每次寫的文章都這麼詳盡,要花多少時間呢? 我寫一篇可能已經要一至三小時了。寫下去要很有恆心呢。