利用pre-compiled headers技術以加速編譯速度

来源:互联网 发布:u8数据库环境 编辑:程序博客网 时间:2024/05/05 05:29
轉貼於http://www.netmag.com.tw/member/article/010307a.htm
利用pre-compiled headers技術以加速編譯速度
--- 以Borland C++ Builder為例 ---

恆逸資訊教育訓練中心首頁

作者: 恆逸資訊 王森

一個程式設計師都有的共同經驗:當程式越寫越大,每次改完程式之後要重新編譯產生執行檔,往往需要很長一段時間,對一個心急如焚的工程師來說,這真是一個夢靨。突而其來的好點子通常就在這漫長的編譯過程中被錯過了。當然,我們可以購買更高檔的硬體使得編譯速度更快。可是硬體升級的速度卻永遠趕不上程式碼增加的速度(這似乎與每次新版Windows出來的時候,我們永遠覺得電腦越來越慢的感覺有異曲同工之妙)。
好幾次向使用Delphi的朋友抱怨編譯時間太長,他們總是笑著勸我改用Delphi,因為Delphi的編譯速度真的很快,而且使用Delphi,幾乎可以在Windows上做到任何開發工具也做得到的事。筆者一直是一個忠誠的C++擁護者(這是情感因素,沒有貶低任何語言的意思),我也因此花了很多時間研究要怎樣才能使BCB所寫的程式可以編譯的更快,讓更多時間拿來除錯或者發揮創意。這篇文章就是我在這方面的研究心得。
在本文中,筆者全部以最新的BCB 5.0做為討論對象。一開始我假設讀者並沒有使用BCB的經驗,所以文章的第一部分,是專門寫給初學者看的。如果您是一個擁有豐富經驗的BCB程式設計師,那麼您可以跳過第一段<前置作業>,直接從第二段<初見pre-compiled headers技術>看起。如果您很有耐心的從頭看起,希望在第一段<前置作業>中筆者一些個人的經驗可以對各位讀者有所幫助。

前置作業

首先,我們先建立一個簡單的console程式,以供往後測試用。 請選擇 File/New 開啟New Item對話盒,在New次頁選擇Console Wizard,如下圖:
按下OK鈕以後,出現如下畫面:
請依照上圖在Use VCL與Console Application的check box上打勾,選完後按下OK鈕,Console Wizard會自動幫我們產生一個程式骨幹,如下圖:
接著請將此程式(Unit1.cpp)以及專案檔(Project1.bpr)一起存檔於一個獨立的新目錄之中,比方我們將他們存放在test目錄中,則第一次儲存後,整個目錄下只有幾檔案,如下圖:
額外一提的是,很多朋友會發現在每次使用BCB編譯完成之後,在專案所在的目錄下會多出很多檔案,通常不知道該刪掉哪一個才好。以我們這個剛開啟的專案來說,當我們按下Project/Make Project1之後,目錄下會產生如下圖內的檔案:

為了將來備份方便,我們可以將一些不必要的檔案刪除掉。下表列出相關資訊:

檔名 功用可否刪除
*.~*
原始碼備份檔案
*.tds
中間檔
*.obj
中間檔
*.res
編譯過的資源檔
*.exe
執行檔

有關原始碼備份檔案的部分,每當我們儲存檔案的時候的時候,只要該檔案原先已存在目錄下,則IDE自動會將原先的檔案改名成*.~*,然後將要儲存的檔案重新寫入硬碟裡頭。比方說Unit1.cpp,如果再存檔,IDE就會把檔名改成Unit.~cpp,然後將最新的Unit1.cpp存回硬碟中。如果各位覺得這個產生備份原始檔的動作有點煩,甚至覺得沒有必要,那麼請選擇Tools/Editor Options開啟下面的對話盒:

把Create backup file這個check box的勾勾拿掉即可,這樣IDE就再也不會幫我們做備份的工作了。
另外,每個cpp檔在編譯過後都會產生一個obj檔,因此當程式越寫越大之後,產生的obj檔會越來越多,到時候備份時要一個個刪除就顯的有點麻煩。在此筆者提供一個建議,就是讓編譯器將編譯過的obj檔統一放到一個目錄下面,到時候我們只要刪掉這個目錄下的所有檔案即可。為了做到這一點,請選擇Project/Option開啟下面的對話盒:

如圖所示,請到Directories/Conditionals次頁裡的Intermediate output裡面填上tmp,意思就是請編譯器今後將編譯後產生的obj檔統一放到專案原始碼所在目錄下的tmp目錄之中。
最後一點要提的是,BCB 5.0內定是把編譯放在背景執行,所以每次我們按下Make或是Build的時候,除非編譯發生問題,否則編譯成功之後,編譯過程對話盒會自動關閉。為了要觀察編譯過程,請選擇Tools/Environment Options開啟如下的對話盒:
請將Background compilation的check box的勾勾拿掉,如此編譯的過程就如同BCB 5.0之前的版本一樣,每次編譯都會在螢幕中間出現編譯過程對話盒,如下所示:

OK,前置作業大功告成,接下來我們要開始做點測試囉!

初見pre-compiled headers技術

過去我們撰寫C/C++程式時,每個檔案都必須利用編譯器指令 #include 引入許多的系統標頭檔才能夠使程式順利編譯,接著經由連結產生執行檔。假如我們的Project(程式專案)存有兩個檔案a.cpp以及b.cpp,當我們在a.cpp裡面用到getch()這個函式,我們就必須在使用a.cpp的開頭處寫著:
 #include < conio.h >
否則編譯器一定告訴我們這個函式沒有定義。同樣地,即使b.cpp 這個檔案和a.cpp同屬一個Project之中,檔案裡面只要有用到getch()這個函式,一樣得在b.cpp的開頭處寫著:
 #include < conio.h >
當編譯器編譯a.cpp的時候,編譯器必須編譯conio.h一次,接著編譯b.cpp的時候,同樣必須重新編譯conio.h一次。因此,一旦a.cpp引入更多和b.cpp相同的標頭檔,也就代表編譯器將浪費許多時間在編譯同樣的標頭檔上。舉例來說,如果我們的Project裡面有十個cpp檔同時引入相同的標頭檔,那麼就代表編譯的時間有9/10都因為被用來編譯相同的標頭檔而浪費掉了。因此BCB引進了pre-compiled headers技術,主要就是為了解決這個會使得編譯器做過多重複的編譯工作而導致編譯過程漫長的問題。
所謂的pre-compiled headers技術,在筆者的記憶中是從BCB 3.0開始引進的概念,其實概念很簡單,就是"預先編譯標頭檔"的意思。以我們拿剛剛提到的例子來說,編譯器第一次編譯a.cpp的時候,就會因為使用pre-compiled headers技術,會先把conio.h的編譯結果先"cache"起來,然後等到待會編譯b.cpp的時候,編譯器會發現conio.h已經先被編譯過了,因此編譯器就直接把剛剛cache起來的conio.h編譯結果直接拿來使用,這樣一來就省掉了了大量的編譯時間,而程式設計師就可以從此向冗長的編譯過程說bye bye。
在BCB之中,pre-compiled headers技術是透過編譯器指令#pragma hdrstop來達成,從BCB的help裡面得知,出現在這個編譯器指令之前的標頭檔即代表告知編譯器要使用pre-compiled headers技術來加速編譯。但是事情並沒有我們想像的那麼單純,所以接下來筆者會花很長的篇幅來探討編譯器指令#pragma hdrstop對編譯效能所帶來的影響。

pre-compiled headers技術對編譯速度的影響 - 1

首先,我們先按照<前置作業>所提到的方式,刪掉所有在編譯過程之中產生的所有檔案(*.exe 、 *.obj 、 *.tds ,請注意 , *.tds必須關掉整個Project之後才能刪除,也請大家先刪除BCB所在目錄的Lib子目錄裡面的*.csm以及*.#??,『?』代表是一個0~9的數字)。
接下來,我們做個簡單的測試,用乾淨的程式原始碼(就是尚未編譯過的原始碼)來做編譯速度的測試,以下的數據以筆者的電腦輸出結果為基準,筆者的電腦配備為: Intel PIII 450 、 勝創PC100 128MB RAM 、華碩P2B主機板、作業系統是Windows 2000 Professional:
■ 程式碼1:
#include <iostream.h>
#include <vcl.h>
#pragma hdrstop
#pragma argsused
int main(int argc, char* argv[])
{

cout << "Hello World" ;
return 0;
}
 ■ 測試結果1:


使用build
編譯次數編譯行數編譯時間
第一次 4192667.90
第二次以後 5106388.48
使用make
編譯次數編譯行數編譯時間
第一次 4192667.73
第二次以後 00.14

程式碼2:
#pragma hdrstop
#include <iostream.h>
#include <vcl.h>


#pragma argsused
int main(int argc, char* argv[])
{

cout << "Hello World" ;
return 0;
}
斜體字部分為與程式碼1不同之處
 ■ 測試結果2:


使用build
編譯次數編譯行數編譯時間
第一次 4192665.60
第二次以後 5106384.42
使用make
編譯次數編譯行數編譯時間
第一次 4192665.86
第二次以後 00.15
提醒讀者一點,空白行也算被編譯器在編譯行數中,所以大家的測試結果在編譯行數上可能會有些微的差距。
從上面的列表可以得到以下結論:
  1. 對這兩組測試程式而言,第一次編譯的時候不管用make或是build,其編譯速度幾乎沒差別。但是第二次以後的編譯,使用make的編譯速度壓倒性的快,而且快很多,原因請讀者參照一些介紹make的相關書籍。在這裡筆者介紹O'Reilly (台灣歐來禮) 出版的Managing Project with make,這本書在台灣歐來禮的網站 www.oreilly.com.tw 似乎有看到會出現中文本的消息。
  2. 2. 把標頭檔放在編譯器指令 #pragma hdrstop之前在第一次編譯所花的時間要比把標頭檔放在編譯器指令 #pragma hdrstop之後要久。咦? 之前不是還提到利用pre-compiled headers技術會加快編譯速度,怎麼經過實驗之後發現竟然編譯速度變慢了呢? 要找尋原因,我們可以使用windows開始功能表裡面的 搜尋/檔案或資料夾 功能,搜尋BCB所在目錄,將搜尋日期限定為您電腦上目前的日期,讀者就可以發現,一旦您把標頭檔放在編譯器指令 #pragma hdrstop之前,在BCB所在目錄下的Lib子目錄就會出現vcl50.#00以及vcl50.csm兩個檔案,而且檔案的size還蠻大的,但是如果把標頭檔放在編譯器指令 #pragma hdrstop之後,這兩個檔案並不會出現(如果讀者在測試的時候先測試 程式碼1 ,再測試 程式碼2 ,那麼這兩個檔案依舊會出現,因為這兩個檔案並不會因為重新打開Project或是重新開啟BCB而被刪除,因此讀者看到的可能是前次編譯所產生的vcl50.#00以及vcl50.csm)。
如此一來,我們可以大膽地推測: 之所以把標頭檔放在編譯器指令 #pragma hdrstop之前在第一次編譯所花的時間會比較長,是因為編譯器花了額外的功夫去產生這兩個檔案。因為編譯後所顯示的編譯時間並非只有單純的編譯時間,還包括了連結目的檔以產生執行檔所耗費的時間。精確的說,應該是『從開始編譯原始碼到產生最後的執行檔總共所花的時間』,所以前面的測試數據會給大家一種『使用pre-compiled headers技術反而會減慢編譯速度』的假象。
讀者在前面所看到測試結果1所得的數據,是筆者每次測試過後,除了刪掉編譯時產生的*.obj 、 *.tds 、 *.exe檔之外,還外加刪掉vcl50.#00以及vcl50.csm兩個檔案所測得。如果在測試程式碼1/使用make的時候,筆者沒有刪除測試程式碼1/使用build的時候所產生vcl50.#00以及vcl50.csm這兩個檔案,則測試結果1的數據會變成:
■ 測試結果3:
使用build
編譯次數編譯行數編譯時間
第一次 201723 13.57
第二次以後 20172313.98
使用make
編譯次數編譯行數編譯時間
第一次 171.87
第二次以後 00.14

是什麼原因導致結果有所差異呢? 因為筆者一開始先使用build指令測試編譯效能,然後關掉Project,刪掉編譯過程中產生的.obj 、 .tds 、 .exe檔,然後重新開啟Project,再使用make指令測試編譯效能,此時,由於之前build時所產生的vcl50.#00以及vcl50.csm這兩個檔案依舊留在硬碟中,所以make時編譯器就直接拿來用啦! 也因此我們看到編譯器只編譯了17行就結束。由此我們更可以證明, vcl50.#00以及vcl50.csm這兩個檔案就是我們所謂的cache檔,而它們的作用就是讓編譯器可以減少編譯的標頭檔數目以加速編譯。
以上所得到的結論告訴我們,如果接下來我們要做pre-compiled headers技術對編譯效能所產生影響之編譯效能評估,應該在第一次編譯的時候使用build指令,第二次以後都使用make指令,這樣才能精確地測出pre-compiled headers技術對編譯效能所帶來的改善,因為從數據中我們可以看出,build指令會讓編譯器從頭到尾重新編譯一次,所以只看build之後產生的結果是沒有意義的。
不過,有時候重頭到尾重新編譯整個系統也是在所難免。比方說我們一旦把程式從debug版本變成release版本,或是把程式從release版本變成debug版本,之後的第一次編譯,即使我們使用make來編譯程式,編譯器所花的時間和使用build來編譯的結果是一樣的,都是重頭到尾重新編譯一次。我們還是可以利用pre-compiled headers技術讓這種從頭到尾的編譯可以更快,在本篇文章的後面會提到。

pre-compiled headers技術對編譯速度的影響 -2



前一段裡面的測試程式只有一個單一的程式原始檔,接著我們來試試如果Project裡面有多個程式原始檔的時候會有何種情形。為了避免情況複雜,我們只測試Project裡頭有兩個程式原始檔的情況。首先,請使用 File/New新增一個Unit:

並將檔案存成Unit2.cpp,此時我們Project之中就多出了兩個檔案,分別是Unit2.h以及Unit2.cpp,他們的內容如下:

■ 程式碼3:
Unit2.h
#ifndef Unit2H
#define Unit2H
void test(void) ;
#endif

Unit2.cpp
#include <vcl.h>
#include <stdio.h>
#pragma hdrstop

#include "Unit2.h"

void test(void)
{
printf("test") ;
}
■ 測試結果4:
編譯次數編譯行數編譯時間
第一次 (build)37556410.03
第二次(make)00.16
我們觀察BCB所在目錄之下的Lib目錄,此時會發現之前的測試只有多兩個檔案,而這次的測試竟然又多了一個檔案,他們分別是: vcl50.csm、vcl50.#00、vcl50.#01。
這樣的測試結果似乎沒有什麼結論,所以我們在第二次編譯之後,第三次編譯之前,把Unit2.cpp的內容修改如下:
■ 程式碼4:
#include <vcl.h>
#include <stdio.h>
#pragma hdrstop

#include "Unit2.h"

void test(void)
{
printf("test") ;
printf("test1") ;
}
 

則測試結果變成:

■ 測試結果5(接測試結果4):


編譯次數編譯行數編譯時間
第三次(make) 301.19

這跟我們預期的結果相同,pre-compiled headers技術完全發揮了縮短編譯時間的功能。
後來筆者在Project多加了幾個Unit來測試,證明BCB除了會產生vcl50.csm之外,他會為每個Unit都產生一個vcl50.#??的檔案(這裡的前提是: 每個Unit所引入的標頭檔彼此都不同,如果相同的話,會有另外一種情況發生),如果我們有三個Unit,他就會在第一次build的時候產生vcl50.#00、vcl50.#01、vcl50.#02,然後加上原本一定會產生的vcl50.csm,總共就會有4個cache檔案,因此我們幾乎可以認定『編譯器會為每一個使用編譯器指令#pragma hdrstop的檔案產生一個cache檔,以加速編譯』這個我們所見的事實。也就是說,如果我們把Unit2.cpp裡頭的編譯器指令#pragma hdrstop拿掉,那麼每次我們修改Unit2.cpp之後所測得的結果應該是:
■ 測試結果6:
編譯次數編譯行數編譯時間
第三次(make) 1738432.69
而不是前面測試結果5的測試結果。因為沒有預先編譯好的cache檔,所以Unit2.cpp在修改程式後,必須從頭到尾從新編譯。
筆者的另外一個測試,是先在Unit2.cpp中使用編譯器指令#pragma hdrstop,然後用make編譯執行檔,讓編譯器幫我們產生Unit2.cpp的cache檔,筆者並沒有刪除任何的vcl50.#??檔案。接著我將Unit2.cpp裡頭的編譯器指令#pragma hdrstop拿掉,雖然每次原始碼的更改都會造成編譯行數多達173843上下,可是在make幾次之後,如果筆者重新將編譯器指令#pragma hdrstop放回Unit2.cpp之中原來的位置(也就是在#include 與#include 之下),則每次修改程式之後編譯的結果會比較接近測試結果5。
在此我們暫且先把編譯器指令#pragma hdrstop之前所有引入的標頭檔檔名所構成的集合稱做『預先編譯標記』,我們大膽假設編譯器會幫我們把這個標記記錄在cache檔裡頭,以方便下次編譯器在編譯其他檔案的時候作為辨識用。
我們根據上面的假設歸納了一個暫時的結論,就是: 編譯器每次在編譯程式原始碼的時候,一般都是重新編譯所有的標頭檔。但是,如果程式原始檔中含有編譯器指令#pragma hdrstop,那麼編譯器就會去尋找Lib目錄底下的vcl50.#??,看看這些檔案是否符合目前的『預先編譯標記』,如果符合,那麼編譯器就直接引用之前編譯後留下的cache,因而省下許多重新編譯標頭檔的時間,如果沒有任何cache檔一個符合標記,那麼編譯器仍然會重頭開始編譯所有的標頭檔,並在編譯後自己產生一個和這個程式原始檔有相同『預先編譯標記』的cache檔,待下次有程式原始檔的預先編譯標記和這個cache檔的預先編譯標記相同時,編譯器就會直接引用這個cache檔。

pre-compiled headers技術的運作方式

在前面一個段落中我們經由推論得到一個假設,接下來我們要證明我們的推論是否正確。因此我們開始修改Unit1.cpp以及Unit2.cpp,如下:
■ 程式碼5:
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop

#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}

Unit2.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop

#include "Unit2.h"
void test(void)
{
printf("test") ;
}
也就是說,我們試著讓兩個檔案有相同的『預先編譯標記』。 這次的測試結果為:
■ 測試結果7:
編譯次數編譯行數編譯時間
第一次(build)2022509.18
本次測試所依據的程式碼和測試結果4所依據的程式碼完全相同,除了編譯器指令#pragma hdrstop之前所引入的標頭檔我們把他改成兩個檔案皆相同。將這個測試結果拿來與測試結果4比較,編譯行數少了快一半。
我們再觀察BCB所在目錄之下的lib目錄,此時會發現之前的測試只有兩個檔案,他們分別是: vcl50.csm、vcl50.#00。 嘿嘿~ 果然如我們所料,編譯器並沒有笨到為每個使用編譯器指令#pragma hdrstop的檔案都各自產生一個cache檔,而是一個Project之中,有多少種『預先編譯標記』,編譯器就產生多少個vcl50.#??的檔案,這樣一來可以有效減少存放這些cache檔所需要花費的空間。
經由上面這個實驗證明了我們之前的假設是正確的。但是,前面有一句話: 『編譯器會為每個使用編譯器指令#pragma hdrstop的檔案產生一個cache檔,以加速編譯』這句話要修正成為『編譯器會為Project之中各種不同的預先編譯標記產生不同的cache檔,以加速編譯』。
至於預先編譯標記是以何種方式來記錄呢?個人猜想只有四種方法較可能:
  1. 利用registry
  2. 編譯器內部的資料結構
  3. 產生預先編譯標記資料庫
  4. 直接紀錄在vcl50.#??
大家應該記得我們在做以上實驗的時候,都可以自己隨心所欲地刪除那些cache 檔,因此,個人認為1、2的做法比較不可能,而3、4是比較可行的方法。筆者自行用工具檢測每次編譯後registry是否有改變,結果發現registry並沒有因此而改變;如果是使用編譯器內部的資料結構,那勢必只能針對每一個Project建構預先編譯資訊,可是後來筆者自己做了實驗後發現,這些預先編譯的cache檔可以"跨Project"使用,也就是說不管是否存在同一個Project之中,這些cache檔是可以讓每個Project共享,編譯器只關心預先編譯標記是否存在。然而當筆者自己使用UltraEdit開啟vcl50.csm以及vcl50.#??的時候,筆者利用UltraEdit的搜尋功能去搜尋這兩個檔案裡面是否有類似 iostream 或是stdio 等字串之後,大致上可以推論出vcl50.csm比較類似的3的做法,而vcl50.#??比較類似4的做法,或許Borland的工程師兩種方法都有用上吧! 由於欠缺更詳盡的資料,所以無法再做更詳盡的剖析,如果有讀者研究出結果,別忘了告訴大家喔!

最後,我們把編譯器在編譯原始碼時,編譯器所採用的編譯邏輯畫成下面這張流程圖。

預先編譯標記是怎麼回事?

前一段我們經由實驗證明了的確存在有預先編譯標記這個抽象的概念,可是預先編譯標記是根據什麼原則所產生的呢? 前面我們做的假設 -- 編譯器指令#pragma hdrstop之前所有引入的標頭檔檔名所構成的集合稱做『預先編譯標記』,真的是如此嗎? 讓我們再做幾個小實驗來證明看看是否是我們假設的那樣。 我們分別修改程式碼5的內容,把編譯器指令#pragma hdrstop之前所引入的標頭檔做下面的調整:

■ 調整一 : 讓標頭檔檔名的大小寫不同
Unit1.cpp
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include <IOstream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
 ■ 測試結果:
編譯次數編譯行數編譯時間
第一次(build)40445112.92
■產生的cache檔:
vcl50.csm、vcl50.#00、vcl50.#01

■ 調整二 : 讓標頭檔排列順序不同
Unit1.cpp
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include <stdio.h>
#include <iostream.h>
#include <vcl.h>
#pragma hdrstop
 ■ 測試結果:
編譯次數編譯行數編譯時間
第一次(build)40445111.12
■產生的cache檔:
vcl50.csm、vcl50.#00、vcl50.#01

■ 調整三 : 讓標頭檔檔名之間有空格
Unit1.cpp
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
 ■ 測試結果:
編譯次數編譯行數編譯時間
第一次(build)2022515.40
■產生的cache檔:
vcl50.csm、vcl50.#00
從上面的三種調整程式碼後所測得的結論,我們可以得到以下結論: 預先編譯標記是由編譯器指令#pragma hdrstop之前所引入的標頭所決定的,而且兩個程式原始檔要構成"預先編譯標記相同"的條件是:
  1. 引入的標頭檔要完全相同
  2. 標頭檔的排列順序要正確
  3. 若有使用編譯器指令#define,其內容跟順序也都要相同(由2導出)
  4. 標頭檔檔名的大小寫也要一致(case-sensitive)
  5. 至於空白列存在與否,對預先編譯標記並不構成影響。
includeall.h
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>

然後把Unit1.cpp與Unit2.cpp各自修改成:

Unit1.cpp
#include "includeall.h"
#pragma hdrstop

#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}

Unit2.cpp
#include "includeall.h"
#pragma hdrstop

#include "Unit2.h"
void test(void)
{
printf("test") ;
}
總而言之,就是除了空白行之外,每個檔案的#pragma hdrstop之前都要長的一模一樣才行,有一點不同就會造成預先編譯標記的不同。 所以我們可以說: 如果要讓pre-compiled heads技術發揮到最極限,則我們應該讓程式中的每一個程式原始檔都引入相同的標頭檔,即使該標頭檔裡面的函式在該程式原始檔之中沒有用到也要引入。喔喔! 接下來一定會有人跟我抱怨: 『那我的Project裡頭有100個程式原始檔,每次我的任何一個程式原始檔裡面新引入了一個標頭檔,那麼我就要修正其他99個檔案,讓每個檔案引入的標頭檔一致,那豈不是累壞了?』。由這個問題之中,我們引出了最佳解法,這個方法同時也可以加速我們在使用build(重頭到尾重新編譯)時,加速編譯速度的方法,就是: 『把所有的 #include指令全部都搬到一個單一的標頭檔裡面,然後Project裡面的每個檔案都直接引入這個單一的標頭檔』,比方說我們可以在Project之中新增一個獨立的標頭檔,叫做includeall.h,這個檔案的內容如又右:

之後,只要任何時候某個程式原始檔需要引入某個標頭檔的時候,就一律修改includeall.h就可以了,這樣一來,就可以讓所有的程式原始檔都保有相同的預先編譯標記,讓pre-compiled headers技術發揮到最極限,而程式設計師也可以省下許多同步更新每個程式原始檔的時間。

當然還有更偷懶的人會問: 『為何不在includeall.h 之中直接把 #pragma hdrstop 寫進去呢?這樣不是我們可以在其他每一個程式原始檔裡面都少打這一行呀?』這個問題問的很好,筆者也是這樣一個懶惰的工程師,不過實際上試過之後,發現這樣子做是有問題的。編譯器的確有使用pre-compiled headers技術,但是,編譯器把所有的程式原始碼都看做有有不同的預先編譯標記,也就是說,如果Project之中有40個檔案都引入 ncludeall.h ,那麼就會產生40個cache,而且大小全部相同,這麼一來不但沒有讓編譯速度加快(因為幾乎每個程式原始檔都是重新徹底編譯),而且還因為要產生那麼多cache檔而浪費了整體時間,也浪費了硬碟空間,所請請讀者千萬不要把 #pragma hdrstop 寫到 includeall.h 裡面。
至於為何會發生這樣的事情呢?終於讓筆者在BCB的on-line help在解說#pragma hdrstop的地方找到下面這行:
"Use this pragma directive only in source files. The pragma has no effect when it is used in a header file."
雖然沒有提到會有反效果,但是上面這行至少說明了把編譯器指令#pragma hdrstop放在標頭檔之中是沒有效果的(就算我們把附檔名從.h改成.cpp也沒有用)。

編譯器指令#pragma hdrstop之前只能放系統標頭檔嗎?

請大家回頭看看在這之前所有被我們用來測試的程式原始檔。拿程式碼5 來說,大家會發現,Unit2.cpp並沒有放在編譯器指令#pragma hdrstop之前,那麼是否代表筆者默認Unit2.cpp不能放在編譯器指令#pragma hdrstop之前呢? 讓我們來做個Project實驗組,程式碼如下:
■ 程式碼6-1:
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#include "Unit2.h"
#pragma hdrstop

#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}

Unit2.h
#ifndef Unit2H
#define Unit2H
void test(void) ;
#endif

Unit2.cpp
#include
#include
#include
#include "Unit2.h"
#pragma hdrstop

void test(void)
{
printf("test") ;
}
然後我們試著編譯看看。接著我們一個檔案都不要刪除,直接把Unit2.h與Unit2.cpp叫出來,把檔案內容改成:
■ 程式碼6-2:
Unit2.h
#ifndef Unit2H
#define Unit2H

void test(void) ;
void test1(void) ;
#endif

Unit2.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#include "Unit2.h"
#pragma hdrstop

void test(void)
{
printf("test") ;
}
void test1(void)
{
printf("test1") ;
}
再重新使用make編譯。
同理,我們也做一組完全相同的Project當作對照組,檔案內容幾乎完全相同,除了我們把#include "Unit2.h"放回編譯器指令#pragma hdrstop之後,我們也做跟上面實驗組Project相同的測試:
■ 程式碼7-1:
Unit1.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include "Unit2.h"
#pragma argsused
int main(int argc, char* argv[])
{
cout << "Hello World" ;
return 0;
}

Unit2.h
#ifndef Unit2H
#define Unit2H
void test(void) ;
#endif

Unit2.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include "Unit2.h"
void test(void)
{
printf("test") ;
}
 ■ 程式碼7-2:
Unit2.h
#ifndef Unit2H
#define Unit2H

void test(void) ;
void test1(void) ;
#endif

Unit2.cpp
#include <iostream.h>
#include <stdio.h>
#include <vcl.h>
#pragma hdrstop
#include "Unit2.h"
void test(void)
{
printf("test") ;
}
void test1(void)
{
printf("test1") ;
}
我們把這兩次測試的結果列在下表:
Unit2.h在#pragma hdrstop之前
編譯次數編譯行數編譯時間
第一次(build)
程式碼6-1
2022458.29
第二次(make)
程式碼6-1
00.14
第三次(make)
程式碼6-2
2022539.16
Unit2.h在#pragma hdrstop之後
編譯次數編譯行數編譯時間
第一次(build)
程式碼7-1
2022588.65
第二次(make)
程式碼7-1
00.16
第三次(make)
程式碼7-2
651.68
這個測試結果代表了什麼涵義呢?
  1. 這個測試結果並非只有系統標頭檔才能放在編譯器指令 #pragma hdrstop 之前,程式設計師自己定義的標頭檔也可以。
  2. 在標頭檔小小的更動會造成整個程式原始檔從頭到尾重新編譯,也使得編譯器重新產生新的cache檔(注意: 編譯器會覆蓋具有相同預先編譯標記的vcl50.#??檔,而非重新產生。其實這樣也無可厚非,否則Lib目錄下就會有好多具有相同預先編譯標記的cache檔,這樣可就糟糕了!)
在程式開發初期,程式標頭檔常常會被修改,可是系統標頭檔卻幾乎沒有人會去動到他們,所以在整個系統的函式介面或資料結構尚未穩定之前,儘量先不要把程式設計師自己定義的標頭檔放到編譯器指令 #pragma hdrstop 之前。因為這麼一來,非但沒有加速程式的編譯速度,反而因為預先編譯標記沒有改變,可是標頭檔內容卻變了,而迫使編譯器每次都要重新編譯這些標頭檔並產生新的cache檔(而且還要先刪掉原先具有相同預先編譯標記的cache檔,使得整體編譯時間更長)。這個問題在我們的測試程式裡並不明顯,可是讀者可以想像,如果今天我們是撰寫GUI程式,我們常常要修改Form上的元件和事件處理函式,每次一修改,勢必動到標頭檔(因為增添/刪除元件,或是新增事件處理函式的時候都會讓該Unit對應的標頭檔改變),如果我們的Project裡頭有好多Form,那事情可就不妙了!! 所以在此筆者的建議是:
在程式設計初期,請先將這些程式設計師自行定義的標頭檔移到編譯器指令#pragma hdrstop之後(IDE所幫我們產生的Unit就是以此為預設情況),等到整個系統之中所有類別、函式介面、資料結構都大致底定的時候,再將這些標頭檔移到編譯器指令#pragma hdrstop之前,這樣效果就會好很多。

讓VCL相關標頭檔也能享受pre-compiled headers的好處

編譯行數
173358 行
編譯時間
4.05 秒
vcl50.csm的大小
7459 KB
Cache檔數目
2 個(vcl50.csm與vcl50.#00)
在這之前,所有的測試範例都是簡單的小程式。對各位讀者來說,雖然上面所得的結論非常有用,但是如果我們要開發的是一般的GUI程式呢? 讓我們來看看一個比較實際的例子。
我們利用 File/New Application重新建造一個GUI的windows應用程式,當我們按下make的時候,我們發現要編譯結果如下:
如果我們在Form上面放許多不同的元件,我們會發現,程式原始檔裡頭永遠都是只有#include ,只有標頭檔裡面才會新引入一些相關的hpp檔。所以如果我們要探究是否可以讓使用VCL標頭檔的程式編譯的速度更快,那我們要痛腦筋的地方就是vcl.h這個檔案囉! 於是我們開啟vcl.h,發現他只引入了vcl0.h,所以我們再打開vcl0.h,嘿嘿~~ 讓我們發現有趣的東西。
在vcl0.h裡面,我們看到幾個條件編譯式,如下:
// Database related headers
//
#if defined(INC_VCLDB_HEADERS)
#include < dbctrls.hpp >
#include < mask.hpp >
#include < db.hpp >
#include < dbtables.hpp >
#endif // INC_VCLDB_HEADERS

#if defined(INC_VCLEXT_HEADERS)
#include < Buttons.hpp >
#include < ChartFX.hpp >
#include < ComCtrls.hpp >
#include < DBCGrids.hpp >
#include < DBGrids.hpp >
#include < DBLookup.hpp >
#include < DdeMan.hpp >
#include < FileCtrl.hpp >
#include < GraphSvr.hpp >
#include < Grids.hpp >
#include < MPlayer.hpp >
#include < Mask.hpp >
#include < Menus.hpp >
#include < OleCtnrs.hpp >
#include < OleCtrls.hpp >
#include < Outline.hpp >
#include < Quickrpt.hpp >
#include < Tabnotbk.hpp >
#include < Tabs.hpp >
#include < VCFImprs.hpp >
#include < VCFrmla1.hpp >
#include < VCSpell3.hpp >
#endif // INC_ALLVCL_HEADERS

#if defined(INC_OLE_HEADERS)
#include < cguid.h >
#include < dir.h >
#include < malloc.h >
#include < objbase.h >
#include < ole2.h >
#include < shellapi.h >
#include < stddef.h >
#include < tchar.h >
#include < urlmon.h >
#include < AxCtrls.hpp >
#include < databkr.hpp >
#include < OleCtnrs.hpp >
#include < OleCtrls.hpp >
#endif

// Using ATLVCL.H
//
#if defined(INC_ATL_HEADERS)
#include < atl/atlvcl.h >
#endif
看到這些條件編譯式,再想到之前我們所提到的預先編譯標記,那麼聰明的讀者想到什麼事呢? 沒錯,如果我們把原始程式檔裡面原本的
#include <vcl.h>
#pragma hdrstop

改成
#define INC_VCLDB_HEADERS
#define INC_VCLEXT_HEADERS
#define INC_OLE_HEADERS
#define INC_ATL_HEADERS

#include
#pragma hdrstop
我們使用build會得到下面結果
編譯行數
811680 行
編譯時間
15.07 秒
vcl50.csm的大小
22483 KB
Cache檔數目
2 個(vcl50.csm與vcl50.#00)
這個實驗結果也就告訴我們:
  1. 如果使用 #define INC_VCLDB_HEADERS 、 #define INC_VCLEXT_HEADERS 、#define INC_OLE_HEADERS 、 #define INC_ATL_HEADERS 可以要求編譯器把一些預設不預先編譯的的標頭檔也一起編譯進來,如此一來幾乎所有利用VCL撰寫windows程式所需的所有標頭檔都會被預先編譯。
  2. 如果沒有用到,那就儘量不要四個define都用上。比方說,如果我們沒有用到資料庫元件,那麼就不要使用 #define INC_VCLDB_HEADERS ;如果沒有用到ATL(Active Template library)相關功能,就不要使用 #define INC_ATL_HEADERS,因為如此一來,只會浪費多餘的時間編譯,同時也因為預先編譯了一些用不到的標頭檔,而使得cache變大,這樣也只是浪費硬碟空間罷了。

結論

綜合前面所有的分析和結論,筆者提供一個較好的解決方案,這個解決方案是修正來自前面段落中所提到『把所有的 #include指令全部都搬到一個單一的標頭檔裡面,然後Project裡面的每個檔案都直接引入這個單一的標頭檔』的概念而來,我們可以把includeall.h這個標頭檔的內容改成
includeall.h
#ifdef USE_VCLDB
#define INC_VCLDB_HEADERS
#endif

#ifdef USE_VCLEXT
#define INC_VCLEXT_HEADERS
#endif

#ifdef USE_OLE
#define INC_OLE_HEADERS
#endif

#ifdef USE_ATL
#define INC_ATL_HEADERS
#endif

#include < vcl.h >

#include < iostream.h >
#include < stdio.h >
#include < …其他要引入的系統標頭檔… >

#ifdef USE_USERDEF
#include "Unit1.h"
#include "Unit2.h"
#include "Unit3.h"
#include "…使用者自訂的標頭檔…"

#endif
這樣一來,以後我們在開發程式的時候,一開始便可以先不用把使用者自訂的標頭檔引入,一旦到了程式開發後期,我們只要使用 Project/Option裡的Directories/Conditionals次頁,在Conditionals的Edit Box裡面填上 USE_USERDEF就可以讓我們自訂的標頭檔都享受到pre-compiled headers技術的好處。同樣地,我們可以在此填入USE_VCLDB、USE_VCLEXT、USE_OLE、USE_ATL等定義,也可以讓VCL內部所有相關的標頭檔充分利用pre-compiled headers技術。如下圖:
其實有了這個對話盒,我們可以不用把includeall.h寫的如此複雜,只要寫成其實有了這個對話盒,我們可以不用把includeall.h寫的如此複雜,只要寫成:
includeall.h
#include < vcl.h >

#include < iostream.h >
#include < stdio.h >
#include < …其他要引入的系統標頭檔… >

#ifdef USE_USERDEF
#include "Unit1.h"
#include "Unit2.h"
#include "Unit3.h"
#include "…使用者自訂的標頭檔…"
#endif
然後直接在Directories/Conditionals次頁裡頭填上INC_VCLDB_HEADERS、INC_VCLEXT_HEADERS、INC_OLE_HEADERS、INC_ATL_HEADERS也是一樣的。
在結論的最後,要提醒各位讀者兩件再筆者撰寫這篇文章時的測試心得:
  1. 請不要把具有樣板(template) 標頭檔放在編譯器指令#pragma hdrstop之前,否則pre-compiled headers技術會失效。個人猜想這跟樣版的具現化(instantiation)有相當的關係。
  2. 如果標頭檔內具有常數定義(如: const int a = 3 ;),則則pre-compiled headers技術也同樣會失效,但是如果是常數宣告(如: const int a ;)就沒有問題。

附註一: 啟動編譯器的pre-compiled headers功能

編譯器是否使用pre-compiled headers技術,可以由兩個地方決定:
  1. Projec t/ Option 的 Compiler次頁:

    如圖所示,預設的編譯器動作就是Cache pre-compiled headers,同時也可以指定cache的檔名。讀者可以按F1以求得更多的訊息,通常我們是不必去更動這個地方的設定。



  2. 命令列指令:

    當我們不使用IDE而直接使用命令列來編譯Project時,可以利用編譯器參數 -H / -Hu / -H- 來控制編譯器對pre-compiled headers的相關行為。

附註二:CB on-line help對#pragma hdrstop的解釋

請將滑鼠移到#pragma hdrstop的hdrstop上並按下F1,可以看到on-line help對這個編譯器指令的相關說明。
原创粉丝点击