[回到目錄]
白話C++
5.3. C++項目組成
首先我們知道了,寫一個C++程式,可能需要多個源檔案,比如a.cpp、b.cpp。
有沒有可能只用一個源檔案呢?似乎是可以的,比如我們之前寫的“Hello world”經典版等項目,不就只有一個main.cpp嗎。
其實,就算是“Hello world”經典版這樣一個小程式,我們也要支付連結器同志的出場費。因為,我們在代碼中使用了std::cout,std::cout來自於C++標準庫,而C++標準庫,又可能調用了C的標準庫——“標準庫”檔案,其實也是一種“目標檔案”,通常它就是多個“目標檔案”的打包——結論是,就算我們唯寫了一個“main.cpp”,編譯器只編譯出一個“main.o”,但是,仍然需要連結器將“main.o”和一些必要的標準庫檔案進行連結。
5.3.1.專案檔
專案檔並不是必須的,直接使用g++編譯器寫命令列,就可以完成C++源檔案的編譯或連結,但我們剛提過,C++的編譯器是單個單個源檔案編譯的,非常不方便,所以,需要專案檔。
在Unix下,最流行的C++專案檔,稱作“Makefile/製作檔案”。那相當於是一種“批量處理檔案”,並不需要任何IDE。在Windows下,通常每個不同的IDE都會制定自己的專案檔格式。Code::Blocks可以使用Makefile,但預設的,也是更方便的方式是使用Code::Blocks自訂的專案檔,副檔名為:“.cbp”(Code::Blocks Project)。
〖小提示〗:工作空間:管理多重專案
除了提供專案檔以外,Code::Blocks還提供了“工作空間檔案”,用於同時管理多個相關的項目,其副檔名為:“.workspace”。
5.3.2.源檔案、標頭檔
作為一種籠統的說法,我們往往將程式員所寫的一切代碼檔案,都稱為“源檔案”,不過如果具體到“編譯器是單個單個源檔案進行編譯”時,這裡的“源檔案”就僅限於副檔名為“.cpp”或“.c”,或“.cxx”等檔案,而“.hpp”、“.h”、“.hxx”等檔案被稱為“標頭檔”。
從副檔名來區分有些本末倒置。我們剛剛說過,如果要代碼的某處需要用到某個函數,而該函數在代碼當前位置之前還未有定義,我們可以通過“聲明”這個函數長什麼樣子,來騙過編譯器(連結器會幫我們尋找那個函數的真實位置);那麼,假設我們“a.cpp”裡實現了100個函數,而在“b.cpp”和“c.cpp”裡都要全部用上,是否意味著我們要寫上200次“函式宣告”呢?
當然不必,再往前的課程,我們提過:“函式宣告”就像函數的名片,而“標頭檔”就是含有多個聲明的名片夾,因為,我們可以將“a.cpp”中的100個函式宣告,全都寫到一個標頭檔中(通常就叫“a.hpp”),以後在任意需要用到相關函數的源檔案裡,通過:“#include a.hpp”匯入所有全部聲明。
由於“標頭檔”不是編譯單元,所以類似像C++標準庫這樣主要以“標頭檔”形式提供的庫,我們只需要讓編譯可以找到這些標頭檔即可;而對於另外一些,直接以“源檔案”形式的擴充庫,如果我們要在某個項目中使用它,就必須將它所提供“源檔案”,最好先複製一份,然後加入專案檔,參與編譯。
在《準備》章節中,我們安裝了很多擴充庫。我們注意到,幾乎所有擴充庫,都提供一個include子目錄,通常這個目錄之下(可能還會有子目錄),會存在該擴充庫的標頭檔。比如MySQL++庫的include目錄為:E:/cpp_ex_libs/MySQL++/3.0.6/include。
〖課堂作業〗:尋找MySQL的標頭檔
請進入MySQL++中的include目錄,然後尋找“mysql++.h”檔案。
現在,假設以我們要用到MySQL++庫,於是要包含它的一標頭檔,名為“mysql++”,原始碼中是否寫成如下?
#include “E:/cpp_ex_libs/MySQL++/3.0.6/include/mysql++.h”
用絕對路徑不僅麻煩,而且讓原始碼變得很不通用。如果不寫絕對路徑,該如何讓編譯器找到mysql++.h這個檔案呢?
原來,編譯允許我們通過在命令列中,指定參數來告訴它,如果某個標頭檔找不到了,可以上哪裡尋找。對於g++,這個參數是“-I標頭檔路徑”,本例解決方案寫出來類似:
g++.exe –I”E:/cpp_ex_libs/MySQL++/3.0.6/include/” ……
不過,我們並不直接寫命令列,而是通過Code::Blocks調用編譯器,因此,Code::Blocks要求我在專案檔中配置該項目所需要用到的擴充庫的路徑,具體的配置方法,我們將在IDE章節詳談。
5.3.3.使用標頭檔
標頭檔就像“名片”或“名片夾”,通常包含了一些資料聲明、函式宣告、類型定義(典型的如:struct/class)等內容。一個人的名片可以分發給很多人,一個標頭檔通常也要被多個源檔案包含;再者,標頭檔之間還可以相互包含,這就帶來了一種不太好的可能性:相同標頭檔往往會在同一個項目中被重複包含。
生活中我們不會喜歡擁有同一個人的多張重複的名片。對C++來說,重複的包含一個標頭檔,不僅造成編譯速度降低,還會帶來編譯錯誤:資料、函式宣告允許重複,但一個類型不允許重複定義。
#define可以定義一個“宏符號”,並且可以使用 #ifndef來判斷一個“符號”是否已經定義:
#define ABCD //定義一個宏符號:名為ABCD#ifdef ABCD //判斷ABCD是否“已定義” /* 這裡的代碼,僅當ABCD有定義 才會接受編譯,否則被直接略過 */#endif
和本例中的 #ifdef指示符正好相反,#ifndef多出來的字母‘n’為not,它用來判斷指定的符號是否“未定義”。我們可以方便地使用以下方法,來保證一個標頭檔只被實際包含一次。假設當前標頭檔名為:“my_header.h”
001 #ifndef _MY_HEADER_H_002 #define _MY_HEADER_H_003// 所要聲明或定義的內容,放在此處xxx #endif //_MY_HEADER_H_
我們首先假設這是一個項目中第一次包含到“my_eader.h”這個檔案:
先行編譯器在處理“myHeader.h”時,首先就碰上下面這行預先處理指令(通常是第一行):
#ifndef _MY_HEADER_H_
它判斷宏符號“_MY_HEADER_H_”是否“未定義”,因為現在是第一次包含本檔案,所以確實還沒有定義過它,於是002行立即定義這個符號。然後才是本標頭檔的實質內容(從003行,一直到xxx前一行)。
接著,我們假設某一處代碼,再次包含了這個標頭檔,但這回先行編譯器發現符號_MY_HEADER_H_ 已經定義過了,於它直接跳到xxx行(#endif)之後。
竅門顯然在於:我們應該為每一個檔案都取一個唯一的宏符號——通常也稱為:“保護符”。 要讓每一個標頭檔都“掛著”一個唯一的保護符,方法是讓這個符號的名字和標頭檔名字有一個映射關係,習慣上是:將所有字母都改成大寫,再把副檔名之前的‘.’改成‘_’,如果還想再酷一點,可以在前後分別再加一個底線。
〖小提示〗:同名標頭檔怎麼辦?
有時候,位於不同目錄下的兩個標頭檔,確實有可能同名,這時我們需要使用更長一些的保護符名稱。
#include 接受兩種形式的標頭檔指示
#include <library_header.h> #include "my_header.h"
通常我們對標準庫標頭檔,使用角括弧(<>)形式,對當前項目我們自己所寫的標頭檔,使用雙引號("")形式。至於第三方庫,如果我們已經在IDE中配置它的全域路徑,也可以使用角括弧的形式,典型的如wxWidgets或boost庫。
Code::Blocks提供了方便的“檔案嚮導”用於產生標頭檔或源檔案,但本節我們將學習如何“純手工”地項目添加標頭檔及源檔案。
先用嚮導建立一個控制台項目(命名為IncludeDemo1),一開始它只有一個檔案:main.cpp。
點擊主菜單“檔案”-> “建立” ->“空白檔案”(或熱鍵:Ctrl + Shift + N),出現提問框:
“是否將新檔案加入到當前項目(加入項目前,須先儲存)?”
選擇“是”,然後將檔案存為:“my_file.hpp”。由於一個項目預設會有兩個構建目標:Debug和Release版,所以接下來IDE會詢問建立的檔案要加入到哪些構建目標,請選擇全部目標。
再建立一個檔案,儲存為“my_file.cpp”,同樣加入全部目標。現在,專案檔樹如下:
圖 5-4 添加了my_file.cpp/.hpp之後的項目樹
然後,我們將——
- 在my_file.hpp中,定義MyStruct類,聲明my_function函數;
- 在my_file.cpp中,實現MyStruct類,實現my_function函數;
- 在main.cpp中,使用MyStruct類,使用my_function函數。
請分別完成以下代碼,為了方便排除輸入代碼不小心造成的錯誤,請每完成一個檔案的內容之後,就按Ctrl + F9 進行編譯,確實編譯無誤後,再進行下一步。
第一、my_file.hpp中的代碼——聲明、定義:
#ifndef _MY_FILE_HPP_#define _MY_FILE_HPP_//定義一個類struct MyStruct{ MyStruct(); ~MyStruct();};//聲明一個函數:void my_function(int year, int month, int day);#endif //my_file.hpp
第二、my_file.cpp中的代碼——實現
#include "my_file.hpp" //包含自訂的標頭檔#include <iostream> //包含標準庫檔案using namespace std;MyStruct::MyStruct(){ cout << "MyStruct Construct." << endl;}MyStruct::~MyStruct(){ cout << "MyStruct Destruct." << endl;}void my_function(int year, int month, int day){ cout << year << '-' << month << '-' << day << endl;}
第三、main.cpp中的代碼——使用
#include <iostream>#include "my_file.hpp" //引入MyStruct和my_function using namespace std;int main(){ MyStruct myStruct; my_function(1974, 4, 20); return 0;}
5.3.4.庫檔案
C++擴充庫的提代方式,可以是普通源檔案,也可以是已經編譯成目標檔案的“庫檔案”。對於前者,使用起來和我們自己寫的源檔案沒有兩樣,需要加入項目,參加編譯;對於後者,只需要參加連結。連結形式上,又分成兩種方式:靜態連結庫、動態連結程式庫。之前我們在《準備》章節已經介紹過二者的區別。
在“構建期”(也經常籠統地稱為“編譯期”)完成連結。即,當源檔案編譯完成之後,庫檔案和其它中間檔案統一參加連結。因此,庫檔案也被合并到可執行檔(程式)。
靜態連結庫的副檔名,通常是“.lib”或“.a”。
動態連結程式庫的副檔名,通常是“.dll”或“.so”、“.o”。
這類庫採用特定技術,允許在程式運行時,才將庫與程式在記憶體中實現合并。合并所完成的主要任務,是“定位”。比如在主程式k.exe中,需要用到動態庫m.dll中的一個簽名為“void foo()”的函數,自然的,k.exe就需要知道“foo()”函數在m.dll中“地址”。由於程式(包括動態庫)運行時,需要載入到記憶體中,因此這個地址,是一個“記憶體位址”。
如何定位函數(或其它記憶體對象,下面僅以函數為例)在動態庫中的地址,又分為兩種方法:
第一、自動匯入
對於C++編程,當我們寫一個複雜的程式,往往會將程式分成一個主專案(用於產生可執行檔)和好些子項目(用於產生動態庫)。此時常用的方法是由可執行程式在啟動時自動載入所需要的動態庫,並完成定址。
程式如何知道需要載入哪些函數?又如何知道這些函數的地址?這就需要第三種庫出現:“匯入庫/import library”(全稱符號匯入庫)。“匯入庫”儲存了某一動態庫中全部(需要匯出的)函數等符號的位移地址。
位移地址不是“記憶體位址”,它是一種各個函數在DLL檔案中的地址,而當動態庫被載入到記憶體時,整個動態庫有一個起始地址。函數記憶體位址=DLL起始地址+函數位移地址。
通常我們在編譯一個動態庫項目時,除了產生動態庫檔案以外,還會同時產生“匯入庫”。而當我們需要在一個執行檔案使用這個動態庫,並且想採用“自動匯入”的方法,則需要將“匯入庫”以“靜態連結”的方式,加入項目。對於g++,匯入庫通常以“.a”為副檔名。
C++對動態庫自動匯入實現沒有統一標準,因此,這項技術通常無法在不同編譯器之間使用。比如Borland C++ 或Visual C++編譯出來的動態庫,則g++編譯出來的可執行檔無法以自動匯入的方式調用。
第二、手工匯入
手工匯入動態庫中函數等資料,其實是C語言的一項標準。C++因為相容而獲得了這項功能。要使用這項技術,要求必須以C語言的相關標準來匯出一個函數或資料,通常被稱為“C 語言介面”。C語言介面是作業系統暴露其編程介面時的事實標準。
程式在運行時,僅當在需要時,才通過一些特定的語句,將指定動態庫載入到記憶體,再尋找到所需的函數,然後調用這個函數。用完之後,還可以從記憶體中卸載掉這個動態庫。這就是手工匯入動態庫這項技術最大的特色。
C++庫的形式及用法有哪些,是時候重新看一眼了:
圖 5-5 C++庫形式及使用方法
不管採用什麼形式,提供“標頭檔”,以免使用者自己去寫聲明,這是最基本的要求了。對於C++標準庫,及boost中的很多子庫,由於使用了“泛型”技術,所以採用的是“純標頭檔”的形式。
最後,我們一直沒有提到的,但對於庫的使用非常重要的內容是:庫的說明文檔。通常開源的庫都可以在共官方網站上找到說明文檔的連結
[回到目錄]
白話C++