1.1. 原始碼檔案與標頭檔有什麼區別
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++項目中,特別是大中型項目中使用後者,儘可能避免標識符的衝突。