C++的源代碼文件分為兩類:頭文件(Header file)和源文件(Source code file)。頭文件用於存放對類型定義、函數聲明、全局變量聲明等實體的聲明,作為對外接口;而源程序文件存放類型的實現、函數體、全局變量定義。對於商業C++程序庫,一般把頭文件隨二進制的庫文件發布,而源代碼保留。
一般情況下頭文件常以.h或.hpp作為擴展名,而實現文件常以.cpp或.cc為擴展名。頭文件一般不直接編譯,一個源文件代表一個“編譯單元”。在在編譯一個源文件時,如果引用的類型、函數或其它實體不在本編譯單元內,可以通過引用頭文件將其它編譯單元內實現的實體引入到本編譯單元。
而從本質上講,這些源代碼文件都是純文本文件,可以使用任何一款文本編譯器進行源代碼的編輯,並沒有本質的區別,這些頭文與實現文件的擴展名只是一種習慣。而C++的標准庫的頭文件則不使用擴展名,例如string、 iostream、cstdio等頭文件。對與源文件也一樣,你完全可以使用.inl或.cplusplus作為文件的擴展名。事實上,在一些C++的項目中.inl被用作源代碼文件的擴展名,保存內聯函數,直接包含在源文件中,如ACE(the Adaptive Communication Environment, http://www.cse.wustl.edu/~schmidt/ACE.html)等。gcc默認支持的C++源文件擴展名有.cc、.cp、.cpp、.cxx、.c++、.CPP、.C(注意后兩項是大寫,在Unix/Linux上的文件名是區分大小寫的)。例如在gcc中你可以這樣編譯一個擴展名為.cplusplus的C++程序:
g++ -x c++ demo.cplusplus
雖然文件名對程序沒有任何影響,但.cpp和.cc這些擴展名是編譯器默認支持的,使用這些擴展名您就不需要手動添加編譯選項支持您使用的擴展名,如gcc中的-x選項。
而實際上,頭文件以什么為擴展名並沒有什么影響,因為沒有人會直接編譯頭文件,因為頭文件里只有聲明而沒有定義,而在實際的編譯過程中,#include預編譯指令用到的頭文件是被直接插入到源代碼文件中再進行編譯的,這與直接將頭文件的內容復制到#include行所在的位置是沒有區別的,這樣就很容易理解#include可以出現在文件的什么位置,顯然放到一個函數體或類的定義里是不合適的。
1.1.1. 定義與聲明有什么不同
一般來講定義要放在源代碼文件中,而聲明要放在頭文件中。具體哪些內容應該放在源代碼文件中,哪些內容應該放在頭文件中,需要清楚地理解,哪些是定義,哪些是聲明。
1.1.1.1. 類的定義與聲明
類的定義是定義了類的完整結構,包括成員函數與成員變量,如例程[2-1]。
// 例程2-1: 類的定義
class Point
{
private:
int x_;
int y_;
public:
Point( int x, int y);
int X( void ) const;
int Y( void ) const;
};
而類的聲明,只說明存在這一種類型,但並不定義它是什么樣的類型,如例程[2-2]。
// 例程2-2: 類的聲明
class Point;
類的說明與實現都可以放在頭文件中,因為上層代碼需要使用Point的類必須知道當前工程已經定義了這個類。但應該使用定義還是聲明呢?使用聲明可以的地方使用定義都是可以的,但是,過多得使用定義會使項目編譯時間加長,減慢編譯速度,細節可參見(@see effective series,item 34)。
還有一種情況是必須使用聲明的,就是當兩個類在定義中出現互相引用的情況時,如例程[2-3]。當然,這種情況出現的情況比較少,多數情況下也可以通過修改設計盡量避免,在不可避免的情況下只能使用這種方式。
// 例程2-3: 類定義的交叉引用
class B;
class A { public : B& GetB( void ) const; }
class B { public: A* CreateA( void ) const; }
類的定義只給出了類包含了哪些數據(成員變量)和接口(成員函數),但並沒有給出實現,程序的實現應該放在原代碼文件中。如例程[2-1]中的Point類定義在Point.hpp頭文件中,相應的源代碼文件Point.cpp的內容如例程[2-4]所示。
// 例程2-4: 成員函數的實現
Point::Point(int x, inty)
:x_(x), y_(y)
{
}
int Point::X( void ) const
{
return x_;
}
int Point::Y( void ) const
{
return y_;
}
當然,類的成員函數的實現也可以放到頭文件中,但編譯時默認會為這些函數加上inline修飾符,當成內聯函數處理。像Point::X和PointY這樣的簡單的讀值函數,比較適合放到頭文件中作為內聯函數,詳見[??inline]一節。
1.1.1.2. 函數的定義與聲明
函數的聲明只說明函數的外部接口,而不包含函數的實現函數體,如例程[2-5]所示。
// 例程2-5: 函數的聲明
int SplitString(vector& fields
, const string& str
, const string& delimiter);
而函數定義則是包含函數聲明和函數體在內的所有部分,如例程[2-6]所示,給出了一個拆分字符串的函數,雖然效率不高,但它的確是一個能工作的函數。
// 例程2-6: 函數的定義
int SplitString(vector& fields
, const string& str
, const string& delimiters)
{
string tmpstr = str;
fields.clear();
string::size_type pos1, pos2;
for(;;) {
pos1 = pos2 = 0;
if((pos1 = tmpstr.find_first_not_of(delimiters, pos2))
== string::npos)
break;
if((pos2 = tmpstr.find_first_of(delimiters, pos1))
!= string::npos){
fields.push_back(tmpstr.substr(pos1, pos2 - pos1));
}else {
fields.push_back(tmpstr.substr(pos1));
break;
}
tmpstr.erase(0, pos2);
}
return fields.size();
}
函數聲明可以放在任何一個調用它的函數之前,而且在調用一個函數之前必須在調用者函數之前定義或聲明被調函數。函數的定義只能有一次,如果調用者與被調用者不在同一編譯單元,只能在調用者之前添加函數的聲明。函數定義只能有一次,函數聲明可以有無限次(理論上),這也是頭文件的作用,將一批函數的聲明放入一個頭文件中,在任何需要這些函數聲明的地方引用該頭文件,以便於維護。
函數聲明之前有一個可選的extern修飾符,表示該函數是在其它編譯單元內定義的,或者在函數庫里。雖然它對於函數的聲明來講不是必須的,但可以在一個源文件中直接聲明其它編譯單元內實現的函數時使用該關鍵詞,從而提高可讀性。假如例程[2-6]中的函數SplitString定義在strutil.cpp文件中定義,而且在strutil.cpp還定義了很多字符串相關的函數,other.cpp只用到了strutil.cpp中SplitString這一個函數。而您為了提高編譯速度, 可以直接在other.cpp中聲明該函數,而不是直接引用頭文件,此時最好使用extern標識,使程序的可讀性更好。
1.1.1.3. 變量的定義與聲明
變量的聲明是帶有extern標識,而且不能初始化;而變量的定義沒有extern標識,可以在定義時初始化,如例程[2-7]所示。
// 例程2-7:變量的定義與聲明
// 聲明
extern int global_int;
extern std::string global_string ;
// 定義
int global_int = 128;
std::string global_string = “global string”;
在形式上,與函數的聲明不同的是,變量的聲明中的extern是必須的,如果沒有extern修飾,編譯器將當作定義。之所以要區分聲明與變量,是在為對於變量定義編譯器需要分配內存空間,而對於變量聲明則不需要分配內存空間。
1.1.1.4. 小結
從理論上講,聲明與定義的區別就是:定義描述了內部內容,而聲明不表露內部內容,只說明對外接口。例如,類的定義包含了內部成員的聲明,而類的聲明不包含任何類的內部細節;函數的定義包含了函數體,而函數聲明只包括函數的簽名;變量的定義可以包含初始化,而變量的聲明不可以包含初始化。
從語法表現上的共同點,聲明可以重復,而定義不可以重復。
聲明與定義的分離看似有些不方便,但是它可以使實現與接口分離,而且頭文件本身就是很好的接口說明文檔,具有較好的自描述性,加上現在較智能的集成開發環境(IDE),比起閱讀其它類型的文檔更方便。C#在3.0中也加入了“部分方法(Partial method)”的概念,其作用與頭文件基本相似,這也說明了頭文件的優點。
從工程上講,頭文件的文件名應該與對應的源文件名相同便於維護,如果頭文件中包含了多個源文件中的定義或聲明,則應該按源文件分組布局頭文件中的代碼,並且通過注釋注明每組所在的源文件。當一個工程的文件較多時應該將源文件與頭文件分開目錄存放,一般頭文件存放在include或inc目錄下,而源文件存放在source或src目錄下,根據經驗,一個工程的文件數超過30個時應該將源文件與頭文件分開存放,當文件較少時直接放到同一目錄即可。
1.1.2. 頭文件中為什么有#ifndef/#define/#endif預編譯指令
雖然函數、變量的聲明都可以重復,所以同一個聲明出現多次也不會影響程序的運行,但它會增加編譯時間,所以重復引用頭文件會使浪費編譯時間;而且,當頭文件中包含類的定義、模板定義、枚舉定義等一些定義時,這些定義是不可以重復的,必須通過一定措施防止重復引用,這就是經常在頭文件中看到的#ifndef/#define/#endif的原因,一般形式如例程[2-8] 所示。
// 例程[2-8]
#ifndef HEADERFILE_H
#define HEADERFILE_H
// place defines and declarations here
#endif
一些編譯器還支持一些編譯器指令防止重復引用,例如Visual C++支持
#pragma once
指令,而且可以避免讀磁盤文件,比#ifndef/endif效率更高。
1.1.3. #include與#include”filepath”有什么區別
在C++中有兩種引用頭文件的形式:
// 形式1
#include
// 形式2
#include “filename”
其實,C++標准中也沒有確定這兩種方式搜索文件filepath的順序,而是由編譯器的實現確定,其區別就是如果編譯器按照第二種形式定義的順序搜索文件filepath失敗或者不支持這種方式時,將其替換為第一種順序再進行搜索。
而實際上,一般來講第一種方式都是先搜索編譯器的系統目錄,而第二種方式則是以被編譯的頭文件所在目錄為當前目錄進行搜索,如果搜索失敗再在系統頭文件里搜索。這兩種方式從本質上講沒有什么區別,但當我們自己的程序文件與系統頭文件重名時,用后者就會先搜到我們的頭文件而不是系統的。但無論如何,與系統頭文件重名都不是一個好習慣,一不小心就可能帶來不必要的麻煩,當我們自己編寫程序庫時,最好把它放入一個目錄里,不把這個目錄直接添加到編譯器的頭文件搜索路徑中(如gcc的-I, visual c++的/I選項等,其實在UNIX/Linux平台的編譯器一般都是-I選項),而是添加到上一級目錄,而在我們的源文件中引用該頭文件時就包含該目錄名,這樣不容易造成沖突。
例如,我們創建了一個程序庫叫mylib,其中一個頭文件是strutil.hpp,我們可以創建一個/home/user/project/src/mylib目錄,然后把strutil.hpp放進去,然后把 /home/user/project/src添加到編譯選項里:
gcc -I/home/user/project/src
這樣,在我們的源程序中可以這樣引用strutil.hpp文件:
#include “mylib/strutil.hpp”
通過顯示的目錄名引用頭文件就不容易產生沖突,不容易使我們自己的頭文件與系統頭文件產生混淆。
當然,從代碼邏輯上我們還有另外一種解決沖突的方案,那就是命名空間,詳見第[?]節。
1.1.4. #include 與#include有什么區別
這兩個的區別是比較明顯的,因為它們引用的不是同一個頭文件,但其作用是不明顯的,在功能上並沒有任何區別。不帶擴展名,以字母c為前綴的一系列頭文件只是C++將對應的C語言標准頭文件引入到了std命名空間中,將標准庫統一置入std命名空間中,另外如cstdlib、cmath等。
如果引用了后者,則需要在使用標准函數庫時使用
using namespace std;
以引入std命名空間,或顯示通過域作用符調用標准庫函數,如
std::printf(“hello from noock”);
建議在C++項目中,特別是大中型項目中使用后者,盡可能避免標識符的沖突。