C++編程自學寶典:你所不知道的軟體項目的目錄結構和文件結構

異步社區 發佈 2020-05-25T08:57:53+00:00

1.4 C++項目結構簡介C++項目中可以包含幾千個文件,並且管理這些文件甚至可以成為一個單獨的工作任務。當構建項目時,如果應該編譯某個文件,那麼選擇哪種工具編譯它?文件應該按照什麼順序編譯?這些編譯器生成的輸出結果又是什麼?編譯後的文件應該如何組織到一起構造可執行文件?

1.4 C++項目結構簡介

C++項目中可以包含幾千個文件,並且管理這些文件甚至可以成為一個單獨的工作任務。當構建項目時,如果應該編譯某個文件,那麼選擇哪種工具編譯它?文件應該按照什麼順序編譯?這些編譯器生成的輸出結果又是什麼?編譯後的文件應該如何組織到一起構造可執行文件?

編譯器工具還擁有大量的選項,比如調試信息、優化類型、為多種語言特性提供支持以及處理器特性。編譯器選項的不同組合將會用於不同場景(比如版本構建和版本調試)。如果用戶是在命令行上執行編譯任務的,那麼務必確保選擇了正確的選項,並在編譯所有原始碼的過程中始終應用它們

文件和編譯器選項的管理可以變得很複雜。這也是用戶應該使用一款構建工具處理即將上線的產品代碼的原因。與Visual Studio一起安裝的構建工具有: MSBuild和nmake兩款。當用戶在Visual Studio環境下構建一個Visual C++項目時,將使用MSBuild ,並且會把編譯規則存放在一個XML文件中。用戶甚至可以在命令行中調用MSBuild ,將XML項目文件傳遞給它。 mmake 是Microsoft在多個編譯器之間維護程序多個版本的實用性工具。在本章中,讀者將學習如何充分利用mmake 的實用性編寫一個簡單的makefile文件。

在介紹項目管理的基礎知識之前,我們必須先了解用戶通常會在C++項目中找到哪些文件以及編譯器會如何處理這些文件。

1.4.1 編譯器

C++是一門高級程序語言,旨在為用戶提供豐富的語言特性,以及為用戶和其他開發人員提供良好的可讀性。計算機的處理器執行底層代碼,並且這也是編譯器將C++代碼轉化成處理器的機器碼的主要目的。單個編譯器也許可以兼容多種處理器,如果代碼是符合C++規範的,那麼它們還可以被其他編譯器編譯,以便兼容其他處理器。

不過,編譯器的功能遠不止於此。如第4章所述, C++允許用戶將代碼分割成若干函數,這些函數可以接收參數並返回一個值,因此編譯器可以配置內存來傳遞這些數據。此外,函數可以聲明只在函數內部使用的變量(第5章將介紹更多細節) ,並且它將只在函數被調用時才存在。編譯器配置的內存稱為棧幀( stackframe ),編譯器中包含如何創建棧幀的選項,比如Microsof的編譯器選項 /Gd 、/Gr和/Gz決定了函數參數被推送到堆棧上的次序,以及調用方函數或被調用函數在調用結束時是否應該從堆棧中移除這些參數。當我們編寫的代碼需要和其他人共享時,這些選項將非常重要(不過基於本書的目的,應該會使用默認的堆棧結構)。這只是冰山一角,不過編譯器選項為用戶提供的強大功能和靈活性應該會讓讀者印象深刻。

編譯器編譯C++代碼,如果遇到代碼中的錯誤,將向用戶發送編譯器錯誤提示信息。它是對代碼的語法檢查。這對於確保用戶從語法角度編寫完美的C++代碼非常重要,不過這仍然可能是在做無用功。編譯器的語法檢查對於檢查代碼來說非常重要,不過用戶應該總是使用其他方法檢查代碼。比如下列代碼聲明了一個整數類型變量並為它賦值:

int i=1/0:

編譯器將向用戶提示C2124 錯誤: divide or mod by zero (除數不能為0)。不過,下列代碼將使用額外的變量執行相同的操作,但是編譯器不會報錯:

int j =0;
inti=1/ j;

當編譯器提示出現錯誤時將停止編譯。這意味兩件事:首先,你將無法得到編譯輸出結果,因此將不會在一個可執行文件中找到該錯誤;其次,如果原始碼中存在其他錯誤,我們只有在修復當前錯誤重新編譯代碼時才會發現它。如果你希望對代碼執行語法檢查並退出編譯,可以使用/zs選項開關。

編譯器還會生成警告信息。一個警告意味著代碼將被編譯,但是代碼中的某個問題可能會對生成的可執行文件產生潛在的不良影響。Microsof編譯器將警告分為4個級別:級別1是最嚴重的(應該立刻解決),級別4是信息性的。警告通常用於向用戶聲明被編譯的語言特性可以正常運行,不過它需要的某個特定編譯器選項,開發者並沒有使用

在開發代碼期間,我們將會經常忽略警告信息,因為這可能是在測試某些語言特性。

不過,當開發的代碼準備上線發布時,你最好對警告信息多加留意。默認情況下, Microsof編譯器將顯示1級警告信息,你可以使用/選項和一個數字來聲明希望看到的警告信息級別(比如, /M2表示用戶希望看到2級警告以及1級警告)。在正式上線的產品代碼中,你可能會使用/wx選項,這是告知編譯器將警告信息也當作錯誤來看待,我們必須修復所有問題,以便能夠順利編譯代碼。你還可以使用pragma編譯器( pragma 的概念將稍後介紹) ,並且編譯器的選項還可以忽略特定警告信息。

1.4.2 連結代碼

編譯器將生成一個輸出。對於C++代碼來說,這將是對象代碼,不過你可能還會得到一些其他的編譯器輸出,比如被編譯的資源文件。對於它們自身來說,這些文件無法被執行,尤其是作業系統需要設置特定的結構時。一個C++項目將始終包含兩個階段:先將原始碼編譯成一個或者多個對象文件,然後將這些對象文件連結到一個可執行程序中。這意味著C++編譯器將提供另外一種工具,即連結器。

連結器也有決定它如何工作並指定輸出和輸入的選項供用戶選擇,並且它還會向我們發出錯誤和警告信息。與編譯囂類似, Microsof的連結器也有一個選項nx ,它可以將預覽版程序中的警告信息當作錯誤來處理。

1.4.3源文件

在最基本的層面,一個C++項目將只包含一個文件,即C++原始碼文件。該文件一般是以cpp或者cxx後綴結尾的。

1.一個簡單示例

一個最簡單的C++程序如下:

#include <iostream>
//程序的入口點
int main()
{
std;scout <s "Hello, world!n":
}

第一點需要注意的是,以1/開頭的行是注釋。編譯器將忽略直到該行末尾的所有文本。如果你希望使用多行注釋,則注釋的每行都必須以//開頭。你還可以使用C語言風格的注釋。一個C語言風格的注釋是以1"開頭、以./結尾的,這兩個標識符之間的內容就是一個注釋,包括換行符。

C語言風格的注釋是一種對部分代碼進行快速說明解釋的方式。

大括號1}表示一個代碼塊。在這種情況下, C++代碼就是函數main 。我們可以根據基本的格式判斷這是一個函數,首先,它聲明了返回值類型,然後具有一對括號的函數名,括號中常用於聲明傳遞給該函數的參數(和它們的類型)。在這個示例中,函數名是main ,括號內是空的,說明該函數沒有參數。函數名之前的標識符( int )表示該函數將返回一個整數。

C+中約定名為main 的函數是可執行文件的入口,即在命令行中調用該可執行程序時,該函數將是項目代碼中首個被調用的函數。

main 函數隻包含一行代碼:這個單條語句是以std開頭,然後以一個分號(;)作為結尾的。C++中空格的使用非常靈活,與之相關的詳情將在下一章介紹。不過,有一點讀者必須特別留意,那就是在使用文本字符串時(比如本文中使用的) ,每個語句都是用分號分隔的。語句末尾缺少分號是編譯器錯誤的常見來源。一個額外的分號只表示一個空語句,因此對於新手來說,項目代碼中分號太少的問題比分號過多更致命。

示例中的單個語句會在控制台上列印輸出字符串" He110, world! "(以及一個換行符)。我們知道這是一個字符串,因為它是用雙引號標記包裹起來的( ." )。該語句的含義是使用運算符<<將上述字符串添加到流對象std:scout中。該對象名中的std表示一個命名空間,實際上代表一組包含類似目的的代碼集合,或者來自單個供應源。在這種情況下, std表示cout流對象是C++標準庫的一部分。雙冒號::是域解析運算符,並表示你希望訪問的 cout 對象是在sta命名空間下聲明的。你還可以定義屬於自己的命名空間,並且在一個大型項目中用戶應該定義自己的命名空間,因為它允許我們使用可能已經存在於其他命名空間的名稱進行變量定義,並且這種語法使我們可以消除標識符的歧義。

對象cout是ostream類的一個實例,並且在main 函數被調用之前已經創建。<<表示一個名為運算符<<的函數被調用,並傳遞了相關的字符串(它是一個字符型數組)。該函數會將字符串中的每個字符列印輸出到控制台上,直到遇到一個NML字符。

這是一個演示C++靈活性的示例,即被稱為運算符重載的特性。運算符<<經常會與整數一起使用,它被用於將某個整數向左移動指定數目的位置; x<<y將返回一個將x向左移動y位後的值,實際上返回的值是x乘以2y後的值。不過,在上述代碼中,代替整數x的是流對象std::cout ,並且代替左移索引的是一個字符串。很明顯,運算符<<在C++中的定義並未生效。當運算符<<出現在一個ostream 對象的左邊時,C++規範已經高效地對它進行了重新定義。此外,代碼中的運算符<<將在控制台上列印輸出一個字符串,因此它會接收位於右邊的一個字符串。C++標準庫中還定義了其他的<<運算符,使得用戶可以將其他類型的數據列印輸出到控制台。它們的調用方式都是一樣的,編譯器會根據使用的參數類型來決定使用哪個函數。

如前文所述, std:cout 對象已經作為 ostream 類的一個實例被創建,但是沒有告知用戶這是如何發生的。這將引出我們對這個簡單源碼文件沒有解釋的最後一個部分:以#include開頭的第一行代碼。這裡#會高效地向編譯器聲明某種類型的信息。

可供發送的信息有多種(比如 #define 、#ifdef 、#pragma ,本書後續的內容將會涉及它們)。在這種情況下, #include 告知編譯器在此處將特定文件的內容拷貝到該原始碼文件中,實際上這意味著上述文件的內容也將被編譯。這種特定的文件也叫頭文件,並且在文件管理和通過庫復用代碼方面很重要。

文件<iostream)是標準庫的一部分,可以在C++編譯器附帶的include目錄下找到。尖括號( <> )表示編譯器應該到用於存儲頭文件的標準目錄中查找相關內容,不過我們可以通過雙引號("" )提供頭文件的絕對路徑(或者當前文件的相對路徑)。C++標準庫按照慣例不使用文件的擴展名。你在命名自己的頭文件時,最好使用h (或者hpp ,但很少使用hxx )作為文件的擴展名。C運行時庫(也可以在C++代碼中運行)中對它的頭文件也會使用h作為其擴展名。

2.創建源文件

首先在「開始"菜單中找到Visual Studio 2017文件夾,然後單擊"Developer Command Prompt for

vS2017"項。這個操作將會啟動一個Windows命令提示符並為Visual C++ 2017配置環境變量。不過遺憾的是,它還會將命令行程序停留在Program Files目錄下的Visual Studio文件中。如果你希望進行程序開發工作,將會希望將命令行程序從該文件夾移動到其他文件夾中,以便在創建和刪除文件時不會對上述目錄下的文件造成不良影響。在執行此操作之前,請轉到Visual C++目錄下,並列出其中文件:

C:\Program Files\Microsoft Visual studio\2017\community>cd
%VCToolsInstallDir%
C:\Program Files\Microsoft Visual
Studio\2017\Community'yC\Tools\MSVC\14.0.10.2517>dir

因為安裝程序將把C++文件放在一個包含當前版本編譯器的文件夾中,所以為了確保系統採用了最新版本的程序(目前的版本號是14.0.10.2517) ,通過環境變量VCToolsInstal1oir 要比聲明特定的版本安全得多。

有幾件事是需要留意的。首先是C++項目文件中的 bin、 include和1ib 目錄,關於這3個文件夾的用途如表1-1所列。

本章後續的內容還會涉及這些文件夾。

另外要指出的是位於文件夾VCAuxillary\Build下的vevarsall.bat文件。當我們在"開始"菜單上單擊"Developer Command Prompt for VS2017"項時,這個批處理文件將被執行。如果希望在一個現有的命令提示符中編譯C++代碼,那麼可以通過運行這個批處理文件進行設置。該批處理文件中3個最重要的操作是設置環境變量PATH ,以便其中包含bin文件的路徑,然後將環境變量INCLUDE和LIB分別指向 include和1ib文件夾

現在導航到根目錄下,新建一個名為 Beginning_c++ 的文件夾,並導航到該目錄下。接下來為本章創建一個名為Chapter_01 的文件夾。現在你可以切換到Visual Studio。如果該程序還未啟動,則可以從「開始"菜單中啟動。

在Visual Studio中,單擊"文件"菜單,然後單擊"新建」按鈕,之後彈出一個新的對話框,在左邊的樹形視圖中單擊Visual C++項目。在該面板中間你可以看到C++ File (.cpp)和Header File (.h)兩個選項以及打開文件夾時的C++屬性項,如圖1-7所示。

前兩種文件類型主要用於C++項目;第三種類型將創建一個JSON文件輔助Visual Studio實現代碼自動補全功能(幫助我們輸入代碼) ,本書將不會使用這個選項。

單擊這些選項中的第一項,然後單擊"Open"按鈕。該操作將創建一個名為Sourcel.cpp的空白文件,為了將它以simple.cpp的形式另存到本章項目文件夾下,可以通過單擊"File"按鈕,然後選擇"Save Sourcel.cppAs"項,導航到上述新建的項目文件目錄下,在單擊"Save"按鈕之前,在文件名輸入框中將之重命名為simple.cpp。

現在我們可以在該空白文件中輸入簡單程序的代碼,代碼內容如下:

#include <iostream>
int main()
{
std::cout << "Hello, world!n";
}

當完成上述代碼的輸入後,可以通過單擊"File"菜單,單擊其中的"Save simple.cpp"項保存該文件。接下來我們就可以編譯代碼了。

3,編譯代碼

轉到命令行提示符下,然後輸入命令c1 /2 。因為環境變量PATH配置引用了 bin文件夾的路徑,你將看到編譯器的幫助頁面。可以通過按下"回車"鍵對這些幫助信息進行滾動瀏覽,直到返回命令提示符。其中大多數選項的用途超出了本書的範圍,但是我們將討論表1-2中所列的編譯器開關選項。

對於某些選項需要注意,在開關和選項之間需要包含空格,有些選項則不能有空格,而對於其他選項空格是可選的。一般來說,如果文件或者文件夾的名稱中包含空格,那麼最好使用雙引號將它們引起來。在使用一個開關之前,我們最好查看相關的幫助文件,了解它們是如何處理空格的。

在命令行中,輸入c1 siple.cpp 命令,你將發現編譯器發出的警告信息C4530和4577 。這是因為C++標準庫會使用異常機制,但是用戶沒有為編譯器聲明應該為異常機制提供必需的代碼。可以通過開關/EHsc 解決這個問題。在命令行中,輸入命令 c1 /EHsc simple.cpp 。如果輸入正確無誤,則將看到如下結果

C:Beginning C++iChanter Olsdl /EHsc simple.cpp
Microsoft (R) CIC+ Optimizing Compiler Version 19.00.25017 for x86
Copyright (C) Microsoft Corporation. All rights reserver
simple.cpp
Microsoft (R) Incremental Linker Version 14.10.25017.0
Copyright (C) Microsoft Corporation. All rights reserved.
/out:simple.ese
simple.obj

默認情況下,編譯器會將原始碼文件編譯成一個對象文件,然後將該文件傳遞給連結器,並將之連結為一個與C++源文件同名的命令行可執行文件,不過其文件擴展名為.exe 。上述信息行指出

/out:simple.exe 是由連結鵑生成的, /out 是一個連結器選項。

列出文件夾中的內容,你將會發現3個文件:源碼文件 simple.cpp ;編譯器生成的對象文件

simple.obj;可執行文件simple.exe ,即連結器將對象文件和相應的運行時庫連結之後生成的可執行文件。你可以通過在命令行中輸入simple來運行這個可執行文件:

C:Beginning _C++/Chanter_ 01>simple
Hello. World!

4·在命令行和可執行文件之間傳遞參數

如前所述,讀者發現main函數會返回一個值,默認情況下該值是0。當應用程式執行完畢後,可以向命令行返回一個錯誤代碼。這使得你可以在批處理文件和腳本中使用可執行程序,並且可以在腳本中使用上述返回值控制程序流。一般來說,當運行一個可執行程序時,可以在命令行上傳遞相關參數,這將對可執行程序的行為產生影響。

通過在命令行上輸入simple命令來運行這個簡單的應用程式。在Windows中,錯誤代碼是通過偽環境變量 ERRORLEVEL 獲取的,因此可以通過ECHO命令獲得這個值

C:Beginning_C++\Chapter_01>simple
Hello.World!
C:Beginning_C++\Chapter_01>ECHO %ERRORLEVEL%
o

為了演示上述值是通過該應用程式返回的,可以將main 函數的返回值修改為一個大於0的值(本示例中是99,並且予以加粗表示

int main()
{
std::cout << "Hello, worId!n";
return 99;
}

編譯上述代碼並運行它,然後列印輸出與前文類似的錯誤代碼。你將看到現在輸出的錯誤代碼是99這是一種非常基礎的交流機制:它只允許傳遞整數值,腳本調用代碼時必須知道每個整數值代碼的具體含義。

我們更有可能將參數傳遞給應用程式,這些參數將通過main函數的形式參數進行傳遞。將main函教替換成如下形式:

int main(int argc, char *argv[])
{
std::cout << "there are "<< argc<< "parameters"<<
std::end1;
for (int i =0; i < argc; ++i)
{
std::cout << argv[i]<<std;:end1;
}
}

當我們編寫可以從命令行接收參數值的main 函數時,按照約定它會包含兩個參數。

第一個參數通常稱為argc 。它是一個整數,並表明了傳遞給應用程式的參數格式。這個參數非常重要。因為我們將通過一個數組訪問內存,該參數將對所訪問的內存做一定限制。如果訪問內存時超出了此限制,那麼將會遇到麻煩。最好的情況是訪問未初始化的內存,最糟糕的情況是出現訪問衝突。非常重要的點是,每當訪問內存時,都要了解可以訪問的內存數量,並確保在其限制範圍之內。

第二個參數通常稱為 argy ,它是一個指向內存中C字符串的指針數組。第4章將詳細介紹指針數組第9章將詳細介紹字符串,因此我們在這裡不對它們進行深入討論。

方括號(1)表示參數是一個數組,並且數組中每個成員的類型是char * 。*表示數組的每個元素是指向內存的指針。一般來說,這將被解析為一個指向單個給定類型元素的指針,不過字符串比較特別:char 表示內存中的指針指向的是以NUL字符(結尾的0個或者多個字符。字符串的長度是根據字符數目到NL字符的總數得出的。

上述代碼中的第三行表示在控制台上列印輸出傳遞給應用程式字符的長度。在這個示例中,我們將使用流sta:iend1 替代轉義換行符(n)來添加一個新行。有不少運算符可供選擇,與之有關的詳情將在第6章深入介紹。std::end1 運算符將把新行添加到輸出流中,然後對流中的內容進行刷新。

該行表示C++允許將輸出運算符<s連結到一起並添加到流中,該行也向用戶表明 輸出運算符被重載了,不同類型的參數對應的運算符版本也各不相同(有3種情況:一種是接收整數(用於argv參數) ,另一種是接收字符串參數,還有一種是接收運算符作為參數) ,不過這些運算符的語法調用幾乎是一樣的。

最後,用於列印輸出argy數組中每個字符串的代碼塊如下:

for (int i =0; i < argc; ++i)
{
std::cout <<argy[i] << std::end1;
}

for語句表示該代碼塊在變量1的值小於argc的值之前會一直被調用,每次循環疊代成功後變量1的值自動加1 (在它前面使用自增運算符)。數組中的元素是通過方括號進行訪問的( [] )。傳遞的值是數組中的索引。

需要注意的是,變量i的起始值是0,因此訪問第一個元素是通過argv[0]進行的,因為for循環完成後,變量i中包含的是argc的值,這意味著訪問數組中最後一個元素是通過argv[argc-1]實現的。數組的一種典型用法是:第一個索引是0,如果數組中包含n個元素,那麼最後一個元素的索引就是n-1。

如前文所述,編譯並運行這些代碼,並且不提供任何參數:

C:Beginning C++Chapter_01>simple
there are 1 parameters
simple

注意,即使你沒有提供任何參數,程序本身也會認為你提供了一個參數,即可執行程序的名稱。事實上,它不僅是程序名稱,而且是命令行中調用可執行程序的命令。在這種情況下,輸入simple命令(沒有擴展名) ,會返回文件simple的值並將其作為參數列印輸出到控制台上。再試一次,不過這次使用文件全名simple.exe 。現在你將會發現第一個參數是simple.exe 。

嘗試使用一些實際的參數調用該代碼。在命令行上輸入命令simple test parameters;

C:Beeinning_C++\Chapter_01>simple test parameters
there are 3 parameters
simple
test parameters

這次程序執行結果表明存在3個參數,並且使用空格對它們進行了分隔。如果你希望在單個參數中使用空格,那麼可以將整個字符串放到雙引號中:

C:Beginning_C++\Chapter_01>simple "test parameters"
there are 2 parameters
simple
test parameters

請記住, argy是一個字符串的指針數組,因此如果你希望在命令行中傳遞一個數字類型的參數,則必須通過argv對它的字符串進行類型轉換。

1.4.4預處理器和標識符

C+編譯器編譯源文件需要經過幾個步驟。顧名思義,編譯器的預處理器位於這個過程的開始部分。預處理器會對頭文件進行定位並將它們插入到源文件中。它還會替換宏和定義的常量。

1,定義常量

通過預處理器定義常量主要有兩種方式:通過編譯器開關和編寫代碼。為了了解它的運行機制,我們將修改main 函數以便列印輸出常量的值,其中比較重要的兩行代碼予以加粗顯示:

#include <iostream>
#define NUMBER 4
int main()
{
std::cout << NUMBER<< std:sendl:
}

以#define開頭的代碼行是一條預處理器指令,它表示代碼文本中任意標記為MUMBER 的符號都應該被替換成4,它是一個文本搜索和替換,但是只會替換整個符號(因此如果文件中包含一個名為 NUMBER9的符號,則其中的MUMBER部分將被替換)。預處理器完成它的工作之後,編譯器將看到如下內容:

int main()
{
std::cout<<4<<std::endl:
}

編譯原始代碼並運行它們,將發現該程序會把4列印輸出到控制台。

預處理器的文本搜索和替換功能可能會導致一些奇怪的結果,比如修改main 函數,在其中聲明一個名為MUMBER 的變量,如下列代碼所示:

int main()
{
int NUMBER=99;
std::cout<<NUMBER << std::end1;
}

現在編譯代碼,你將發現編譯器報告了一個錯誤

C: Beginning_C++\Chapter_01>cl /EHhe simple.cpp
Microsoft (R) C/C++ Optimizine Compiler Version 19.00,25017 for x86
Copyright (C) Microsoft Corporation. All rights reserved.
simple.cpp
simple.cpp(7): error C2143: syntax error; missing ';' before 'constant'
simple.cop(7): erorr C2106'=': left operand must be l-value

這表示第7行代碼中存在一個錯誤,這是聲明變量新增的代碼行。不過,由於預處理器執行了搜索和替換工作,編譯器看到的代碼將如下列內容所示:

int 4 =99

這在C++程序中是錯誤的!

在所輸入的代碼中,很明顯導致該問題的原因是你在相同文件中擁有一個該標識符的#define偽指令。在實際開發過程中,我們將引用若干頭文件,這些文件可能會引用其自身,因此錯誤的#define偽指令可能會在多個文件中被重複定義。同樣,常量標識符可能會與引用的頭文件中的變量重名,並可能會被預處理器替換。

使用#define定義全局常量並不是一種好的解決方案, C++中有更好的方法,與之有關的詳情將在第3章深入介紹。

如果你認為預處理器替換標識符過程中可能存在問題,那麼可以通過檢查經過預處理器處理後傳遞給編譯器的源文件來確認自己的判斷。為此,在編譯時需要搭配開關/EP一起使用。這將中斷實際的編譯過程,並將預處理器的執行結果輸出到命令行窗口中。需要注意的是,這將生成大量的文本,因此最好將輸出結果另存為一個文件,然後使用Visual Studio編輯器查看該文件。

為預處理器提供所需的值的方式是通過編譯器開關傳遞它們。編輯上述代碼並將以#define開頭的代碼行刪除。像往常一樣對代碼進行編譯( c1 /EHsc simple.cpp )並運行它,然後確保列印輸出到控制台上的值是99 ,即分配給變量的值。現在再次通過下列命令對代碼進行編譯:

cl/EHsc simple.cpp /DNUMBER=4

注意,開關/D與標識符之間沒有空格。這會告知預處理器使用4替換每個NUMBER符號,並且這會導致與前文所述相同的錯誤,這表明預處理器正嘗試使用你提供的值替換相關符號。

Visual C+這類工具和mmake 項目將提供一種機制通過C編譯器定義符號。開關/0隻能用來定義一個符號,如果你希望定義其他符號,將需要提供與之相關的/0開關。

你現在應該理解為什麼C++會擁有這樣一個看上去只會引起歧義的古怪特性。一旦明白了預處理器的工作機制,那麼符號定義功能將非常有用。

2.宏

宏是預處理器符號非常有用的特性之一。一個宏可以包含參數,並且預處理器將確保使用宏的參數搜索和替換宏中的符號。

編輯main函數如下列代碼所示:

#include <iostream>
#define MESSAGE (c, v)
for(int i =1; i<c; ++i) std::cout << v[i] << std::endl;
int main(int argc, char *argv[])
{
MESSAGE (argc, argv);
std::cout << "invoked with "<<argy[0] << std::endl;
}

注意,在宏定義中,反斜槓(\)表示行連接符,因此我們可以定義包含多行的宏。通過一個或者多個參數編譯和運行這些代碼,然後確保 MESSAGE能夠列印輸出命令行參數。

3.標識符

我們可以定義一個不包含值的標識符,並且預處理器可以被告知測試驗證某個標識符是否被定義。最常見的應用場景是編譯調試版本的不同代碼,而不是發布版程序。

編輯上述代碼並添加加粗顯示的代碼行:

#ifdefDEBUG
#define MESSAGE(c, v)
for(int i=1; i<c; ++i) std::cout << v[i] << std::endl;
#els
#define MESSAGE
#endit

第一行代碼告知預處理器去查找 DEUG標識符。如果該標識符已經定義(不管其值是什麼) ,則第一MESSAGE 宏定義將被使用。如果該標識符未定義(一個預覽版構建) ,則MESSAGE標識符將被定義,不過它不執行任何操作,本質上來說,就是將代碼中出現的包含兩個參數的 MESSAGE 刪除。

編譯上述代碼並通過一個或者多個參數運行該程序,比如下列內容:

C:\Beginnine C++\Chapter _01>simple test parameters
invoked with simple

這表示代碼已經在不包含DEBUG定義的情況下被編譯,因此MESSAGE的定義將不會執行任何操作。現在再次編譯代碼,不過這次使用 /ODEBUG開關來定義 DEUG標識符。再次運行該程序之後,用戶將發現命令行參數被列印輸出到控制台上:

C:\Beginning C++\Chapter _01>simple test parameters
test parameters
invoked with simple

上述代碼使用了宏,不過我們可以通過條件編譯在任意C+代碼中使用標識符。這種標識符的使用方式允許編寫靈活的代碼,並且可以通過在編譯器命令行中定義一個標識符來選擇將要被編譯的代碼。此外,編譯器自身也將定義一些標識符,比如DATE將包含當前日期、TIME將包含當前時間、FILE將包含當前文件名。

4. pragma指令

與標識符和條件編譯有關的是編譯器指令#pragma once 。 pragma是專屬於編譯器的指令,不同編譯器支持的pragma也不盡相同。Visual C++定義的#pragma once指令是為了解決多個頭文件重複引用相同頭文件的問題。該問題可能導致相同元素被重複定義一次以上,並且編譯器會將之標記為錯誤。有兩種方法可以執行此操作,並且<iostream>頭文件下採用了這兩種技術。你可以在Visual C++的 include文件夾下找到該文件。在該文件頂部將看到如下代碼:

//ostream standard header
#pragma once
#ifndef_ IOSTREAM_
#define_ IOSTREAM_

在該文件底部,將看到如下代碼行:

#endif/*_IOSTREAM_*/

首先是條件編譯。該頭文件的名稱首次被引用,標識符 IOSTREAM-還未被定義,所以該標識符會被定義,然後其餘的文件將被引用直到#endif代碼行。上述過程演示了條件編譯時的最佳實踐。對於每個#ifndef ,都有一個 tendif與之對應,並且它們之間包含數百行代碼。當使用#ifdef或者

#ifundef時,為相應的#else 和#endif提供注釋說明信息是比較推薦的做法,這樣做的目的是聲明標識符引用的目標。

如果文件被再次引用,則標識符 _IOSTREAML 將被定義,這樣一來#ifndef和tendif之間的代碼將被忽略。不過,非常重要的一點是,即使已經定義該標識符,頭文件仍然將被載入和處理,因為相關的操作指令是被包含在文件中的。

#pragma once 標識符會對條件編譯執行相同的操作,不過它解決了可能存在的標識符重複定義的問題。如果你將這行代碼添加到了自己的頭文件頂部,將指示預處理器載入和處理該文件一次。預處理器維護著一份已經處理過的文件列表,如果後續的某個頭文件嘗試載入一個已經處理過的文件,則該文件將不會被載入和處理。這種做法可以減少項目預處理過程所需的時間。

在關閉<iostream>文件之前,可以查看該文件的代碼行數。對於版本是v6.50:0009的<iostream>它包含55行代碼。這是一個小型文件,不過它引用的<istream>文件有( 1157行) ,引用的<ostream)文件有( 1036行) ,引用的<ios)文件有(374行) ,引用的文件有(1630行)。預處理的結果可能意味著你的原始碼文件中將引用數萬行代碼,即使程序只包含一行代碼!

1.4.5依賴項

一個C++項目將生成一個可執行文件或者庫,它們是由連結器根據對象文件構建的。可執行文件或者庫依賴於這些對象文件。一個對象文件是由一個C++原始碼文件(可能包含一個或者多個頭文件)編譯而來的。對象文件依賴於這些C++原始碼文件和頭文件。理解這些依賴關係非常重要,它可以幫助我們了解項目代碼的編譯順序,並且允許我們通過只編譯已更改的文件來加快項目構建的速度。

1.庫

當你在自己的原始碼文件中引用一個文件時,頭文件中的代碼將能夠訪問代碼。我們引用的文件可能包含整個函數或者類的定義(與之有關的詳情將在後續章節介紹) ,不過這將導致出現前面提及的問題:某個函數或者類被重複定義。相反,你可以聲明一個類或者函數原型,這可以指示代碼如何調用函數而不進行實際定義。顯然,代碼將在其他地方定義,這可能是在一個源文件或者庫中,不過這對編譯器來說很有利,因為它只看到了一個定義。

庫就是已經定義的代碼,它經過完全的調試和測試,因此將不需要訪問原始碼。C++標準庫主要是通過頭文件的形式共享的,這有助於調試項目代碼,但是你必須抵制住任何臨時編輯這些代碼的誘惑。其他庫將以已編譯程序庫的形式提供。

編譯程序庫一般有兩種:靜態庫和動態連結庫。如果使用的是靜態庫,那麼編譯器將從靜態庫中拷貝我們所需的代碼,並將它們集成到可執行程序中。如果你使用的是動態連結(共享)庫,那麼連結晶將在程序運行過程中(有可能是在可執行程序被加載後,或者可能被推遲到函數被調用時)添加一些信息,以便將共享庫加載到內存中並訪問其功能特性。

如果你所需的代碼在某個靜態庫或者動態連結庫中,編譯器將需要精確地知道你調用函數的信息,以便確保函數調用時使用正確的參數個數和類型。這也是函數原型的主要用途:它在不提供實際函數體的情況下,為編譯提供了調用函數所需的信息,即函數定義。

本書將不會涉及如何編寫程序庫的細節,因為它是特定於編譯器的,也不會詳細介紹調用程序庫代碼,因為不同作業系統共享代碼的方式也各不相同。一般來說, C++標準庫將以標準頭文件的形式引入項目中。C運行時庫(將為C++標準庫提供一些代碼)將以靜態連結庫的形式引入,不過如果編譯器提供了動態連結版本程序,那麼我們可以通過編譯器選項來使用它。

2.預編譯頭文件

當我們將一個文件引入到原始碼文件中時,預處理器將引入該文件的內容(在執行完所有條件編譯指令之後),並且以遞歸的方式添加所有該文件引用的任意文件。如前所述,最終的結果可能涉及數千行代碼。在程序開發過程中,我們將經常編譯項目代碼,以便對代碼進行測試。每次編譯代碼時,在頭文件中定義的代碼也會被編譯,即使頭文件中的代碼沒有發生任何變化。對於大型項目,這使得編譯過程需要耗費很長時間才能完成。

為了解決這個問題,編譯器通常會提供一個選項,對沒有發生變更的頭文件進行預編譯。預編譯頭文件的創建和使用是特定於編譯器的。比如GNU C++編譯器gcc ,如果編譯的某個頭文件是一個C++原始碼文件(使用/x開關) ,該編譯器會創建一個擴展名為gch的文件。當 gcc編譯原始碼文件需要用到該頭文件時,它會去搜索該gch文件。如果它找到了該預編譯頭文件,將使用它;否則,它會使用對應的頭文件。

在Visual C++中該過程稍微有點複雜,因為必須在編譯器編譯原始碼文件時,告知編譯器去查找某個預編譯頭文件。Visual C++項目的約定是提供一個名為stdafx.cpp 的源文件,其中包含一行引用stdafx.n文件的代碼。你可以在stdafx.n文件中引用所有性能穩定的頭文件。然後可以通過編譯stdafx.cpp文件來創建一個預編譯頭文件,同時使用Yc編譯器選項聲明所有性能穩定並且需要被編譯的頭文件都包含在了 stdafx.h文件中。這將創建一個pch文件(一般來說, Visual C++將在項目名稱之後附加相關的名稱),其中包含經過編譯的所有stdafx.h 頭文件中引用的代碼。其他原始碼文件中必須將stdafx.h頭文件作為第一個引用的文件進行引用,不過它們還可以引用其他文件。當編譯原始碼文件時,可以使用/Yu開關聲明性能穩定的頭文件( staafx.h ) ,編譯器將使用預編譯pch文件替代相關的頭文件。

當在瀏覽大型項目文件時,經常會發現其中採用了不少預編譯頭文件。如你所見,它會改變項目的文件結構。本章後續的示例將向讀者演示如何創建和使用預編譯頭文件。

3.項目結構

將項目代碼進行模塊化組織非常重要,這使得我們可以高效地對項目代碼進行維護。第7章將介紹面向對象編程技術,它是一種組織和復用代碼的方式。不過,即使你正在編寫類似C語言的過程式代碼(也就是說,代碼是以線性的方式進行函數調用的) ,仍然可以將它們組織成模塊化的形式,繼而從中獲益。比如,代碼中的某些函數是與操作字符串有關的,其他函數是與文件訪問有關的,那麼我們可以將字符串函數的定義放在某個單獨的源碼文件中,即string.cpp ;與文件函數定義有關的內容放在其他文件中,即

file.cpp 。這樣一來,就可以方便項目文件中的其他模塊調用這些文件,你必須在某個頭文件中聲明這些函數的原型,並在調用這些函數的模塊中引用上述頭文件。

在頭文件和原始碼文件之間包含函數的定義在語言層面並沒有絕對的規則。你可能在string.cpp的函數中引用了一個名為string.n 的頭文件,或者在file.cpp 的函數中引用了一個名為file.h的頭文件。又或者我們可能只有一個utilities.n文件,其中包含上述兩個文件中所有函數的聲明。我們必須遵守的唯一規則是,在編譯時,編譯器必須能夠通過某個頭文件或者函數定義本身,在當前的原始碼文件中訪問函數的定義。

編譯器在原始碼中將不會向前查找,因此如果函數A準備調用函數B,那麼在同一原始碼文件中函數B必須在函數A調用它之前就已經被定義,否則必須存在一個對應的原型聲明。這導致了一個非常典型的約定,即每個包含頭文件的原始碼文件中包含函數的原型聲明,並且該源文件引用上述頭文件。當編寫類時,這一約定變得更加重要。

4,管理依賴項

當通過構建工具構建項目時,將先檢查構建的輸出是否存在,如果不存在,則執行適當的構建操作。構建步驟的輸出的通用術語一般稱為目標,構建步驟的輸入(比如原始碼文件)是目標的依賴項。每個目標的依賴項是用於構建它們的文件。依賴項也可能自身就是某個構建動作的目標,並且擁有它們自己的依賴項

比如,圖1-8展示了一個項目的依賴關係。

在這個項目中,有main.cpp 、file.cpp和file2.cpp 三個原始碼文件。它們引用了相同的頭文件utils.n ,它可以被預編譯(因為有第四個原始碼文件utils.cpp ,它只引用了utils.h 頭文件)。所有原始碼文件都依賴於utils.pch文件,而utils.pch文件又依賴於utils.h文件。原始碼文件

main.cpp 包含main 函數,並調用了存在於file1.cpp和file2.cpp兩個原始碼文件的函數,而且是通過頭文件file1.n和file2.n訪問這些函數的。

在第一次編譯時,構建工具將發現可執行程序依賴於4個對象文件,因此它將根據查找規則來構建每個對象文件。存在3個C++原始碼文件的情況下,這意味著需要編譯原始碼文件,不過因為utils.obj是用於支持預編譯頭文件的,因此構建規則將與其他文件不同。當構建工具生成這些對象文件時,它將使用任意庫代碼將它們連結到一起(這裡未顯示)。

隨後,如果你修改了file2.cpp文件,然後構建該項目,構建工具將發現只有file2.cpp文件被修改,並且因為只有file2.obj文件依賴於file2.cpp文件,需要構建工具做的所有工作就是編譯

file2.cpp文件,然後使用現存的對象文件連結新的file2.obj文件,以便創建可執行程序。如果你修改了頭文件file2.n ,構建工具將發現有兩個文件依賴於該頭文件,即file2.cpp 和main.cpp ,因此構建工具將編譯這兩個原始碼文件,然後使用現有的對象文件連結新生成的對象文件file2.obj和

main.obj ,以便生成可執行文件。但是,如果預編譯的頭文件util.n發生了變化,這意味著所有原始碼文件都必須重新編譯。

對於小型項目來說,依賴關係的管理還比較容易。如你所見,對於單個源文件項目,我們甚至不需要為調用連結器操心,因為編譯器會自動執行。但隨著C++項目規模不斷增大,依賴項的管理會變得越來越複雜,這時諸如Visual C++這樣的開發環境就會變得至關重要。

5. makefile文件

如果你正在維護一個C++項目,則很有可能會遇到makefile文件。它是一個文本文件,其中包含用於構建目標文件的目標、依賴項以及項目構建規則。 makerile是通過make命令進行調用的,其中

Windows平台的工具是nmake 、類Unix平台的工具是make 。一個makefile文件就是一系列與下列內容類似的規則:

targets: dependents
commands

目標是一個文件還是多個文件取決於其依賴項(也可能是多個文件) ,因此如果有一個或者多個依賴項比目標文件中的版本更新(並且目標自上次構建之後已經發生變更) ,那麼將需要再次構建目標文件,這些操作是通過運行相關命令完成的。可能有多個命令,每個命令都是以制表符為前綴處於單個行上。一個目標可能不包含任何依賴項,在這種情況下,這些命令仍然將被調用。

比如,使用上述示例時,可執行文件test.exe的構建規則如下:

test.exe: main.obj filel.obj file2.obj utils.obj
link /out:test. exe main.obj filel.obj file2.obj utils.obj

因為對象文件main.obj 依賴於原始碼文件 main.cpp 、頭文件File1.n和File2.h 、預編譯頭文件utils.pch ,所以該文件的構建規則如下:

main.obj: main.cpp filel,h file2,h utils.pch
C1 /c /Ehsc main. cpp /Yuutils,h

編譯器被調用時使用了/c開關選項,這表明相關的代碼會被編譯成對象文件,但是編譯器將不會調用連結器。 /Yu開關選項和頭文件utils.h搭配使用,是告知編譯器使用預編譯頭文件utils.pch 。其他兩個原始碼文件的構建規則與此類似。

生成預編譯頭文件的構建規則如下:

utils.pch: utils.cpp utils.h
C1 /c /EHsc utils.cpp /Ycutils.h

/YC開關是告知編譯器使用頭文件utils.n 創建一個預編譯頭文件。

實際開發中makefile通常比上述內容更複雜。它們將包含宏,即組目標、依賴項和命令行開關。它們還會包含目標類型的一般規則,而不是這裡描述的具體規則,而且它們還將包含條件測試的內容。如果需要維護或者編寫makefile ,那麼你應該詳細了解構建工具幫助手冊中的所有選項。

本文節選自《C++編程自學寶典》

本書旨在通過全面細緻的內容和代碼示例,帶領讀者更加全方位地認識C++語言。全書內容共計10章,由淺入深地介紹了C++的各項特性,包括C++語法、數據類型、指針、函數、類、面向對象特性、標準庫容器、字符串、診斷和調試等。本書涵蓋了C++11規範及相關的C++標準庫,是全面學習C++編程的合適之選。


關鍵字: