1. 程式人生 > >為什麼C語言會有標頭檔案

為什麼C語言會有標頭檔案

前段時間一個剛轉到C語言的同事問我,為什麼C會多一個頭檔案,而不是像Java和Python那樣所有的程式碼都在原始檔中。我當時回答的是C是靜態語言很多東西都是需要事先定義的,所以按照慣例我們是將所有的定義都放在標頭檔案中的。事後我再仔細想想,這個答案並不不能很好的說明這個問題。所以我在這將關於這個問題的相關內容寫下來,希望給大家一點提示,也算是一個總結

include語句的本質

要回答這個問題,首先需要知道C語言程式碼組織問題,也就是我比較喜歡說的多檔案,這個不光C語言有,幾乎所有的程式語言都有,比如Python中使用import來匯入新的模組,而C中我們可以簡單的將include等效為import。那麼問題來了,import後面的模組名稱一般是相關類和物件的的的宣告和實現模組,而include後面只能跟一個頭檔案,只有宣告。其實這個認識是錯誤的,C語言並沒有規定include只能包含標頭檔案,include的本質是一個預處理指令它主要的工作是將它後面的相關檔案整個拷貝並替換這個include語句,比如下面一個例子

//add.cpp
int add(int x, int y)
{
    return x + y;
}

//main.cpp
#include "add.cpp"

int main()
{
    int x = add(1, 2);
    return 0;
}

在這個例子中我們在add.cpp檔案中先定義一個add函式,然後在main檔案中先包含這個原始碼檔案,然後在main函式中直接呼叫add函式,專案的目錄結構如下:

目錄結構
在這裡給大家說一個技巧,在VS中右擊專案—>選擇屬性——>C++——>命令列,在編輯框中填入 /P,然後開啟對應的檔案點選編譯(這裡不能選生成,由於/P選項只會進行預處理並編譯這一個檔案,其餘.cpp檔案並沒有編譯,選生成一定會報錯)
開啟/P選項


點選編譯以後它會在專案的原始碼目錄下生成一個與對應cpp同名的.i檔案,這個檔案是預處理之後生成的原始檔。這個技巧對於除錯檢查和理解巨集定義的程式碼十分重要,我們看到預處理之後的程式碼如下:

int add(int x, int y)
{
    return x + y;
}

int main()
{
    int x = add(1, 2);
    return 0;
}

這段程式碼中我把註釋給刪掉了,註釋表示後面的程式碼段都是來自於哪個檔案的,從程式碼檔案來看,include被替換掉了,正是用add.cpp檔案中的程式碼替換了,去掉之前新增的/P引數,再次點選編譯,發現它報錯了,報的是add函式重複定義。因為編譯add.cpp時生成的add.obj中有函式add的定義,而在main檔案中又有add函式的定義。我們將程式碼做簡單的改變就可以解決這個問題,最終的程式碼如下:

//add.cpp
int add(int x, int y);
#ifndef __ADD_H__
int add(int x, int y)
{
    return x + y;
}

#endif // __ADD_H__

//main.cpp
#define __ADD_H__
#include "add.cpp"

int main()
{
    int x = add(1, 2);
    return 0;
}

在這段程式碼中加了一個巨集定義,如果沒有定義這個巨集則包含add的實現程式碼,否則不包含。然後在main檔案中定義這個巨集,表示在main中不包含它的實現,但是不管怎麼樣都需要在add.cpp中加上add函式的定義,否則在呼叫add函式時會報add函式未定義的變數或者函式

上述寫法的窘境

上面只引入一個檔案,我們來試試引入兩個, 在這個專案中新增一個mul檔案來編寫一個乘法的函式

#define __ADD_H__
#include "add.cpp"
int mul(int x, int y);
#ifndef __MUL_H__
int mul(int x, int y)
{
    int res = 0;
    for(int i =0; i < y; i++)
    {
        res = add(res, x);
    }

    return res;
}

#endif

上面的乘法函式利用之前的add函式,乘法是多次累加的結果,在上面的程式碼中由於要使用add函式,所以先包含add.cpp檔案,並定義巨集保證沒有重複定義,然後再寫對應的演算法。最後在main中引用這個函式

#define __ADD_H__
#define __MUL_H__
#include "add.cpp"
#include "mul.cpp"

int main()
{
    int x = add(1, 2);
    x = mul(x, 2);
    return 0;
}

注意這裡對應巨集定義和include的順序,稍有不慎就可能會報錯,一般都是報重複定義的錯誤,如果報錯還請使用之前介紹的/P選項來排錯
到這裡是不是覺得這麼寫很麻煩?其實我在準備這些例子的時候也是這樣,很多時候沒有注意相關程式碼的順序導致報錯,而針對重複定義的報錯很難排查。而這還僅僅只引入了兩個檔案,一般的專案中幾時上百個檔案那就更麻煩了

標頭檔案的誕生

從上面的兩個例子來看,其實我們只需要包含對應的宣告,不需要也不能包含它的實現。很自然的就想到專門編寫一個檔案來包含所有的定義,這樣要使用對應的函式或者變數的時候直接包含這個檔案就可以了,這個就是我們所說的標頭檔案了。至於為什麼叫做標頭檔案,這只是一個約定俗成的叫法,而以.h來命名也只是一個約定而已,我們經常看到C++的開源專案中將標頭檔案以.hpp命名。這個真的只是一個約定而已,我們也看到了上面的例子都包含的是cpp檔案,它也能編譯過。
其實針對所有的變數、類、函式可以都在統一的標頭檔案中宣告,但是這麼做又帶來一個問題,如果我要看它的實現怎麼辦,那麼多個檔案我不可能一個個的找吧。所以這裡又有一條約定,每個模組都放在統一的cpp檔案中而該檔案中相關內容的宣告則放到與之同名的標頭檔案中

其實我覺得這個原則在所有靜態的、需要區分宣告和實現的語言應該是都適用的,像我知道的組合語言,特別是win32 的巨集彙編,它也有一個頭檔案的思想。

C語言編譯過程

在上面我基本上回答了為什麼需要一個頭檔案,但是本質的問題還是沒有解決,為什麼像Python這類動態語言也有對應模組、多檔案,但是它不需要像C那樣要先宣告才能使用?
要回答這個問題需要了解一點C/C++的編譯過程。
C/C++編譯的時候先掃描整個檔案有沒有語法錯誤,然後將C語句轉化為彙編,當碰到不認識的變數、類、函式、物件的命名時,首先查詢它有沒有宣告,如果沒有宣告直接報錯,如果有,則根據對應的定義空出一定的儲存空間並進行相關的指令轉化:比如給變數賦值時會轉化為mov指令並將、呼叫函式時會使用call指令。這樣就解釋了為什麼在宣告時指定變數型別,如果編譯器不知道型別就不知道該用什麼指令來替換C程式碼。同時會將對應的變數名作為符號保留。然後在符號表(這個符號表時每個程式碼檔案都有一個)中填入該檔案中定義的相關內容的符號以及它所在的首地址。最終如果未發生錯誤就生成了一個對應的.obj檔案,這就是編譯的基本過程。
編譯完成之後進行連結,首先掃描所有的obj檔案,先查詢main函式,然後根據main函式中程式碼的執行流程來一一組織程式碼結構,當碰到之前保留的符號時,去所有的obj中的符號表中根據變數符號查詢對應的地址,當它發現找到多個地址的時候就會報重複定義的錯誤。如果未找到對應的符號就會報函式或者變數已經宣告但是未定義。找到之後會將之前obj中的符號替換為地址,比如將 mov eax num 替換成 mov eax, 0x00ff7310這樣的指令。最終生成一個PE檔案。
根據上面的編譯過程來看,它事先會掃描檔案中所有的變數定義,所以必須讓編譯器知道這個變數是什麼。而Python是邊解釋邊執行,所以事先不需要宣告,只要執行到該處能找到定義即可。它們這點區別就解釋了為什麼C/C++需要宣告而Python不用。