表頭檔要不要?拿速度來換!

本文要提出的內容,是一個非常基礎的程式觀念:表頭檔 (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++ 的真實世界與開發環境中,還是請自行維持良好的程式寫作紀律吧。

所以做為程式設計者,請從現在開始就養成良好的習慣,不要只是想著讓所有事情變得簡單,否則到最後,你將會擁有一個包含所有其他檔案的巨大表頭檔,以及許多不可預期的悲慘後果!

7 thoughts on “表頭檔要不要?拿速度來換!

  1. Nice post! 我想請問有人喜歡把#include寫在.cpp有人喜歡寫在.h中,這兩種方式會有任何差別嗎?有沒有哪種方式會比較好?

    半路:
    就像文章中所提到的內容一樣,
    盡量減少在 .h 中 #include 其他 .h 的情形會比較好囉。

  2. 我一直覺得header file的機制很不方便,每次寫/改一個class就必需重覆兩次(宣告+定義),有時候沒處理好會出現讓人想摔鍵盤的的錯誤(像是字打錯造成undefined external reference) :(

    希望未來C++09能提供更方便的支援機制。

    當然作為一個1980年出現的語言,這樣的要求是有點過份啦._.

    半路:
    多摔幾次鍵盤以後就會學起來了。(茶) XD

    C++ 會使用宣告檔案與定義檔案分離的方式,
    自然有他的時代背景因素存在,在這點上大概很難做出什麼變革。

    我之前一開始在用 C# 時覺得很不習慣,怎麼會把宣告和定義寫在一起?
    後來用久了,反而不習慣使用 C++ 的方法了。 Orz

  3. 看過這篇文章後,我也寫了一篇自己關於 inline function 在標頭檔的一些看法和你交流。

    http://miloyip.seezone.net/?p=47

    P.S. 還想問一下,你每次寫的文章都這麼詳盡,要花多少時間呢? 我寫一篇可能已經要一至三小時了。寫下去要很有恆心呢。

    半路:
    你的這個 inline function 切換方法實在是很棒很實用~
    真是太感謝你的回應了。 :D

    我寫得也不算很詳盡,只是希望文章內容能夠寫得淺顯易懂些,
    有時候一開始也沒打算寫這麼長,不知不覺就寫一堆了。 XD
    時間上,我的寫文功力還很差,每篇至少都會花費三、四個小時吧。
    不過我覺得文章長短不是問題,還是要有好內容才是重點,
    你的這篇回應文章,就給了我全新實用的啟發。

    能夠相互交流想法與心得,真是爽快無得比啊~ ^_^

  4. 我們以前的作法是:
    1. 在每個行include都加上heaer guard. 也就是每一行include前都會有#ifndef _XXX_H
    2. 在每個header file都要#define _XXX_H
    這樣不僅可以降低依存性, 也能使compile time大幅提升(檔案夠多夠大的話), 可以試看看.

  5. @dejob:
    你好,

    其實 header guard 的作法,只能保護 header files 不會被 include 兩次以上而已,並不能降低檔案之間的依存性喔。我認為只要是使用 C++ 語言,就應該謹慎地思考實體檔案以及物件類別之間的依存性,才能夠確實地減少編譯程式碼所需的時間。

  6. 半路您好:
    不小心看到這篇文章,我對於以減少相依性,提高模組化為目標(增加程式碼的Re Use程式),讓有心有戚戚焉,尤其是這三段:
    如果 object references 或 object pointers 可以完成任務,就不要使用 objects。
    如果能夠,盡量以 class 的宣告取代 class 的定義。
    不要在表頭檔中 #include 其他表頭檔,除非你的表頭檔不這樣就無法編譯。
    目前沒辦法解決的就是物件的繼承,我的做法是避免include子物件的head檔,儘量以多型的運作方式去使用parent指標來解決。真正要New物件的地方集中在一個factory來做。

  7. @夢癡:
    以多型的方式盡可能減少不必要的表頭檔參照,確實是個解決方法。

    另外,我想這也是為什麼在《Design Patterns》中,作者論述「Favor composition over inheritance」的好理由之一吧。

Leave a Reply