1. 程式人生 > >為什麽C語言會有頭文件

為什麽C語言會有頭文件

處理 基本 包含 有一個 般的 算法 源代碼 for 匯編

前段時間一個剛轉到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文件並沒有編譯,選生成一定會報錯)
技術分享圖片

點擊編譯以後它會在項目的源碼目錄下生成一個與對應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不用。


為什麽C語言會有頭文件