在 C++ 多檔案程式設計中,一個完整的 C++ 專案可以包含 2 類檔案,即 .h 檔案和 .cpp 檔案。通常情況下,.h 檔案稱為 C++ 標頭檔案,.cpp 檔案稱為 C++ 原始檔。
通過 《用g++命令執行C++多檔案專案》一節的學習我們知道,同屬一個 C++ 專案中的所有程式碼檔案是分別進行編譯的,只需要在編譯成目標檔案後再與其它目標檔案做一次連結即可。例如,在 a.cpp 原始檔中定義有一個全域性函數 a(),而在檔案 b.cpp 中需要呼叫這個函數。即便如此,處於編譯階段的 a.cpp 和 b.cpp 並不需要知道對方的存在,它們各自是獨立編譯的是,只要最後將編譯得到的目標檔案進行連結,整個程式就可以執行。
那麼,整個過程是如何實現的呢?從寫程式的角度來理解,當檔案 b.cpp 中需要呼叫 a() 函數時,只需要先宣告一下該函數即可。這是因為,編譯器在編譯 b.cpp 時會生成一個符號表,類似 a() 這樣看不到定義的符號就會被存放在這個表中。在連結階段,編譯器就會在別的目標檔案中去尋找這個符號的定義,一旦找到了,程式也就可以 順利地生成了(反之則出現連結錯誤)。
注意,這裡提到了兩個概念,即“宣告”和“定義”。所謂定義,指的是就是將某個符號完整的描述清楚,它是變數還是函數,變數型別以及變數值是多少,函數的引數有哪些以及返回值是什麼等等;而“宣告”的作用僅是告訴編譯器該符號的存在,至於該符號的具體的含義,只有等連結的時候才能知道。
也就是說,定義的時候需要遵循 C++ 語法規則完整地描繪一個符號,而宣告的時候只需要給出該符號的原型即可。值得一提的是在 C++ 專案中,一個符號允許被宣告多次,但只能被定義一次。理由很簡單,如果一個符號出現多種定義,編譯器該採用哪一個呢?
基於宣告和定義的不同,才有了 C++ 多檔案程式設計的出現。試想如果有一個很常用的函數 f(),其會被程式中的很多 .cpp 檔案呼叫,那麼我們只需要在一個檔案中定義此函數,然後在需要呼叫的這些檔案中宣告這個函數就可以了。
那麼問題來了,一個函數還好對付,宣告起來也就一句話,如果有好幾百個函數(比如是一大堆的數學函數),該怎麼辦呢?一種簡單的方法就是將它們的宣告全部放在一個檔案中,當需要時直接從檔案中拷貝。這種方式固然可行,但還是太麻煩,而且還顯得很笨拙,於是標頭檔案便可以發揮它的作用了。
所謂的標頭檔案,其實它的內容跟 .cpp 檔案中的內容是一樣的,都是 C++ 的原始碼,唯一的區別在於標頭檔案不用被編譯。我們把所有的函數宣告全部放進一個標頭檔案中,當某一個 .cpp 原始檔需要時,可以通過 #include 宏命令直接將標頭檔案中的所有內容引入到 .cpp 檔案中。這樣,當 .cpp 檔案被編譯之前(也就是預處理階段),使用 #include 引入的 .h 檔案就會替換成該檔案中的所有宣告。
以《用g++命令執行C++多檔案專案》一節中的 C++ 專案為例,擁有 student.h、student.cpp 和 main.cpp 這 3 個檔案,其中 student.cpp 和 main.cpp 檔案中用 #include 引入了 student.h 檔案。在此基礎上,文章中用 g++ 命令分別對 student.cpp 和 main.cpp 進行了預處理操作,並分別生成了 student.i 和 main.i 檔案。
如下展示了 main.i 檔案中的內容:
class Student {
public:
const char *name;
int age;
float score;
void say();
};
int main() {
Student *pStu = new Student;
pStu->name = "小明";
pStu->age = 15;
pStu->score = 92.5f;
pStu->say();
delete pStu;
return 0;
}
顯然和之前的 main.cpp 檔案相比,抹去了用 #include 引入 student.h 檔案,而是將 student.h 檔案中所有的內容都拷貝了過來。
#include 是一個來自 C 語言的宏命令,作用於程式執行的預處理階段,其功能是將它後面所寫檔案中的內容,完完整整、一字不差地拷貝到當前檔案中。
C++標頭檔案內應該寫什麼
通過上面的講解讀者應該知道,.h 標頭檔案的作用就是被其它的 .cpp 包含進去,其本身並不參與編譯,但實際上它們的內容會在多個 .cpp 檔案中得到編譯。
通過“符號的定義只能有一次”的規則,我們可以很容易地得出,標頭檔案中應該只放變數和函數的宣告,而不能放它們的定義。因為一個標頭檔案的內容實際上是會被引入到多個不同的 .cpp 檔案中的,並且它們都會被編譯。換句話說,如果在標頭檔案中放了定義,就等同於在多個 .cpp 檔案中出現對同一個符號(變數或函數)的定義,縱然這些定義的內容相同,編譯器也不認可這種做法(報“重定義”錯誤)。
所以讀者一定要謹記,.h 標頭檔案中只能存放變數或者函數的宣告,而不要放定義。例如:
extern int a;
void f();
這些都是宣告。反之:
int a;
void f() {}
這些都是定義,如果存放在 .h 檔案中,一旦該檔案被 2 個以上的 .cpp 檔案引入,編譯器就會立馬報錯。
凡事都有例外,以上 3 種情況也屬於定義的範疇,但它們應該放在 .h 檔案中:
1) 標頭檔案中可以定義 const 物件
要知道,全域性的 const 物件預設是沒有 extern 宣告的,所以它只在當前檔案中有效。把這樣的物件寫進標頭檔案中,即使它被包含到其他多個 .cpp 檔案中,這個物件也都只在包含它的那個檔案中有效,對其他檔案來說是不可見的,所以便不會導致多重定義。
與此同時,由於這些 .cpp 檔案中的 const 物件都是從一個標頭檔案中包含進去的,也就保證了這些 .cpp 檔案中的 const 物件的值是相同的,可謂一舉兩得。
同理,static 物件的定義也可以放進標頭檔案。
2) 標頭檔案中可以定義行內函式
行內函式(用 inline 修飾的函數)是需要編譯器在編譯階段根據其定義將它內聯展開的(類似宏展開),而並非像普通函數那樣先宣告再連結。這就意味著,編譯器必須在編譯時就找到行內函式的完整定義。
顯然,把行內函式的定義放進一個標頭檔案中是非常明智的做法。
有關 C++ 行內函式,讀者可回顧《C++ inline行內函式詳解》一節。
3) 標頭檔案中可以定義類
因為在程式中建立一個類的物件時,編譯器只有在這個類的定義完全可見的情況下,才能知道這個類的物件應該如何布局,所以,關於類的定義的要求,跟行內函式是基本一樣的,即把類的定義放進標頭檔案,在使用到這個類的.cpp檔案中去包含這個標頭檔案。
值得一提的是,類的內部通常包含成員變數和成員函數,成員變數是要等到具體的物件被建立時才會被定義(分配空間),但成員函數卻是需要在一開始就被定義的,這也就是類的實現。通常的做法是將類的定義放在標頭檔案中,而把成員函數的實現程式碼放在一個 .cpp 檔案中。
還有另一種辦法,就是直接成員函數的實現程式碼寫到類定義的內部。在 C++ 的類中,如果成員函數直接定義在類體的內部,則編譯器會將其視為行內函式。所以把函數成員的定義寫進類體內,一起放進標頭檔案中,也是合法的。
注意,如果把成員函數的定義寫在定義類的標頭檔案中,而沒有寫進類內部,這是不合法的。這種情況下,此成員函數不是行內函式,一旦標頭檔案被兩個或兩個以上的 .cpp 檔案包含,就可能會出現重定義的錯誤。
有效避免標頭檔案被重複引入
在 C++ 多檔案程式設計中,如果 .h 標頭檔案中只包含宣告語句的話,即便被同一個 .cpp 檔案引入多次也沒有問題,因為宣告語句是可以重複的,且重複次數不受限制。然而,剛剛討論到的 3 種特殊情況也是標頭檔案很常用的一個用處。如果一個標頭檔案中出現了上面 3 種情況中的任何一種,且被同一個 .cpp 檔案引入多次,就會發生重定義錯誤。
在 C++ 多檔案程式設計中,為了有效避免“因多次引入標頭檔案發生重定義”的問題,C++ 提供了 3 種處理機制,其中最常用的一種方式就是借助條件編譯 #ifndef/#define/#endif,初學者一定要學會至少一種方式。
有關 C++ 中防止標頭檔案被重複引入的 3 種方式,讀者可回顧《C++防止標頭檔案被重複引入的3種方法》一節。