程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> c++應用程序文件的編譯過程

c++應用程序文件的編譯過程

編輯:C++入門知識

這裡講下C++文件的編譯過程及其中模板的編譯過程;

一:一般的C++應用程序的編譯過程。
    一般說來,C++應用程序的編譯過程分為三個階段。模板也是一樣的。

在cpp文件中展開include文件。
將每個cpp文件編譯為一個對應的obj文件。
連接obj文件成為一個exe文件(或者其它的庫文件)。
下面分別描述這幾個階段。
1.include文件的展開。
    include文件的展開是一個很簡單的過程,只是將include文件包含的代碼拷貝到包含該文件的cpp文件(或者其它頭文件)中。被展開的cpp文件就成了一個獨立的編譯單元。在一些文章中我看到將.h文件和.cpp文件一起看作一個編譯單元,我覺得這樣的理解有問題。至於原因,看看下面的幾個注意點就可以了。
    1):沒有被任何的其它cpp文件或者頭文件包含的.h文件將不會被編譯。也不會最終成為應用程序的一部分。先看一個簡單的例子:
1 ==========test.h文件==========
2 // 注意,後面沒有分號。也就是說,如果編譯的話這裡將產生錯誤。
3 void foo()在你的應用程序中添加一個test.h文件,如上面所示。但是,不要在任何的其它文件中include該文件。編譯C++工程後你會發現,並沒有報告上面的代碼錯誤。這說明.h文件本身不是一個編譯單元。只有通過include語句最終包括到了一個.cpp文件中後才會成為一個編譯單元。

    2):存在一種可能性,即一個cpp文件直接的或者間接的包括了多次同一個.h文件。下面就是這樣的一種情況:

 1 // ===========test.h============
 2 // 定義一個變量
 3 int i;
 4
 5 // ===========test1.h===========
 6 // 包含了test.h文件
 7 #include "test.h"
 8
 9 // ===========main.cpp=========
10 // 這裡同時包含了test.h和test1.h,
11 // 也就是說同時定義了兩個變量i。
12 // 將發生編譯錯誤。
13 #include "stdafx.h"
14 #include "test.h"
15 #include "test1.h"
16
17 void foo();
18 void foo();
19
20 int _tmain(int argc, _TCHAR* argv[])
21 {
22     return 0;
23 }
上面的代碼展開後就相當於同時在main.cpp中定義了兩個變量i。因此將發生編譯錯誤。解決辦法是使用#ifndef或者#pragma once宏,使得test.h只能在main.cpp中被包含一次。關於#ifndef和#pragma once請參考這裡。

    3):還要注意一點的是,include文件是按照定義順序被展開到cpp文件中的。關於這個,請看下面的示例。

 1 // ===========test.h============
 2 // 聲明一個函數。注意後面沒有分號。
 3 void foo()
 4
 5 // ===========test1.h===========
 6 // 僅寫了一個分號。
 7 ;
 8
 9 // ===========main.cpp=========
10 // 注意,這裡按照test.h和test1.h的順序包含了頭文件。
11 #include "stdafx.h"
12 #include "test.h"
13 #include "test1.h"
14
15 int _tmain(int argc, _TCHAR* argv[])
16 {
17     return 0;
18 }
如果單獨看上面的代碼中,test.h後面需要一個分號才能編譯通過。而test1.h中定義的分號剛好能夠補上test.h後面差的那個分號。因此,安這樣的順序定義在main.cpp中後都能正常的編譯通過。雖然在實際項目中並不推薦這樣做,但這個例子能夠說明很多關於文件包含的內容。
有的人也許看見了,上面的示例中雖然聲明了一個函數,但沒有實現且仍然能通過編譯。這就是下面cpp文件編譯時的內容了。

2.CPP文件的編譯和鏈接。
大家都知道,C++的編譯實際上分為編譯和鏈接兩個階段,由於這兩個階段聯系緊密。因此放在一起來說明。在編譯的時候,編譯器會為每個cpp文件生成一個obj文件。obj文件擁有PE[Portable Executable,即windows可執行文件]文件格式,並且本身包含的就已經是二進制碼,但是,不一定能夠執行,因為並不保證其中一定有main函數。當所有的cpp文件都編譯好了之後將會根據需要,將obj文件鏈接成為一個exe文件(或者其它形式的庫)。看下面的代碼:

 1 // ============test.h===============
 2 // 聲明一個函數。
 3 void foo();
 4
 5 // ============test.cpp=============
 6 #include "stdafx.h"
 7 #include <iostream>
 8 #include "test.h"
 9
10 // 實現test.h中定義的函數。
11 void foo()
12 {
13     std::cout<<"foo function in test has been called."<<std::endl;
14 }
15
16 // ============main.cpp============
17 #include "stdafx.h"
18 #include "test.h"
19
20 int _tmain(int argc, _TCHAR* argv[])
21 {
22     foo();
23
24     return 0;
25 }
注意到22行對foo函數進行了調用。上面的代碼的實際操作過程是編譯器首先為每個cpp文件生成了一個obj,這裡是test.obj和main.obj(還有一個stdafx.obj,這是由於使用了VS編輯器)。但這裡有個問題,雖然test.h對main.cpp是可見的(main.cpp包含了test.h),但是test.cpp對main.cpp並不可見,那麼main.cpp是如何找到foo函數的實現的呢?實際上,在單獨編譯main.cpp文件的時候編譯器並不先去關注foo函數是否已經實現,或者在哪裡實現。它只是把它看作一個外部的鏈接類型,認為foo函數的實現應該在另外的一個obj文件中。在22行調用foo的時候,編譯器僅僅使用了一個地址跳轉,即jump 0x23423之類的東西。但是由於並不知道foo具體存在於哪個地方,因此只是在jump後面填入了一個假的地址(具體應該是什麼還請高手指教)。然後就繼續編譯下面的代碼。當所有的cpp文件都執行完了之後就進入鏈接階段。由於.obj和.exe的格式都是一樣的,在這樣的文件中有一個符號導入表和符號導出表[import table和export table]其中將所有符號和它們的地址關聯起來。這樣連接器只要在test.obj的符號導出表中尋找符號foo[當然C++對foo作了mapping]的 地址就行了,然後作一些偏移量處理後[因為是將兩個.obj文件合並,當然地址會有一定的偏移,這個連接器清楚]寫入main.obj中的符號導入表中foo所占有的那一項。這樣foo就能被成功的執行了。

簡要的說來,編譯main.cpp時,編譯器不知道f的實現,所有當碰到對它的調用時只是給出一個指示,指示連接器應該為它尋找f的實現體。這也就是說main.obj中沒有關於f的任何一行二進制代碼。編譯test.cpp時,編譯器找到了f的實現。於是乎foo的實現[二進制代碼]出現在test.obj裡。連接時,連接器在test.obj中找到foo的實現代碼[二進制]的地址[通過符號導出表]。然後將main.obj中懸而未決的jump XXX地址改成foo實際的地址。

現在做個假設,foo()的實現並不真正存在會怎麼樣?先看下面的代碼:

 1 #include "stdafx.h"
 2 //#include "test.h"
 3
 4 void foo();
 5
 6 int _tmain(int argc, _TCHAR* argv[])
 7 {
 8     foo();
 9
10     return 0;
11 }
注意上面的代碼,我們把#include "test.h"注釋掉了,重新聲明了一個foo函數。當然也可以直接使用test.h中的函數聲明。上面的代碼由於沒有函數實現。按照我們上面的分析,編譯器在發現foo()的調用的時候並不會報告錯誤,而是期待連接器會在其它的obj文件中找到foo的實現。但是,連接器最終還是沒有找到。於是會報告一個鏈接錯誤。
LINK : 沒有找到 E:\CPP\CPPTemplate\Debug\CPPTemplate.exe 或上一個增量鏈接沒有生成它;

再看下面的一個例子:

 1 #include "stdafx.h"
 2 //#include "test.h"
 3
 4 void foo();
 5
 6 int _tmain(int argc, _TCHAR* argv[])
 7 {
 8     // foo();
 9
10     return 0;
11 }
這裡只有foo的聲明,我們把原來的foo的調用也去掉了。上面的代碼能編譯通過。原因就是由於沒有調用foo函數,main.cpp沒有真正的去找foo的實現(main.obj內部或者main.obj外部),編譯器也就不會在意foo是不是已經實現了。


二:模板的編譯過程。
    在明白了C++程序的編譯過程後再來看模板的編譯過程。大家知道,模板需要被模板參數實例化成為一個具體的類或者函數才能使用。但是,類模板成員函數的調用且有一個很重要的特征,那就是成員函數只有在被調用的時候才會被初始化。正是由於這個特征,使得類模板的代碼不能按照常規的C++類一樣來組織。先看下面的代碼:

 1 // =========testTemplate.h=============
 2 template<typename T>
 3 class MyClass{
 4 public:
 5     void printValue(T value);
 6 };
 7
 8 // =========testTemplate.cpp===========
 9 #include "stdafx.h"
10 #include "testTemplate.h"
11
12 template<typename T>
13 void MyClass<T>::printValue(T value)
14 {
15     //
16 }
下面是main.cpp的文件內容:

 1 #include <iostream>
 2 #include "testTemplate.h"
 3
 4 int main()
 5 {
 6     // 1:實例化一個類模板。
 7     // MyClass<int> myClass;
 8
 9     // 2:調用類模板的成員函數。
10     // myClass.printValue(2);
11    
12     std::cout << "Hello world!" << std::endl;
13     return 0;
14 }

注意到注釋掉的兩句代碼。我們將會按步驟說明模板的編譯過程。
1):我們將testTemplate.cpp文件從工程中拿掉,即刪除testTemplate.cpp的定義。然後直接編譯上面的文件,能編譯通過。這說明編譯器在展開testTemplate.h後編譯main.cpp文件的時候並沒有去檢查模板類的實現。它只是記住了有這樣的一個模板聲明。由於沒有調用模板的成員函數,編譯器鏈接階段也不會在別的obj文件中去查找類模板的實現代碼。因此上面的代碼沒有問題。

2):把main.cpp文件中,第7行的注釋符號去掉。即加入類模板的實例化代碼。在編譯工程,會發現也能夠編譯通過。回想一下這個過程,testTemplate.h被展開,也就是說main.cpp在編譯是就能找到MyClass<T>的聲明。那麼,在編譯第7行的時候就能正常的實例化一個類模板出來。這裡注意:類模板的成員函數只有在調用的時候才會被實例化。因此,由於沒有對類模板成員函數的調用,編譯器也就不會去查找類模板的實現代碼。所以,上面的函數能編譯通過。

3):把上面第10行的代碼注釋符號去掉。即加入對類模板成員函數的調用。這個時候再編譯,會提示一個鏈接錯誤。找不到printValue的實現。道理和上面只有函數的聲明,沒有函數的實現是一樣的。即,編譯器在編譯main.cpp第10行的時候發現了對myClass.PrintValue的調用,這時它在當前文件內部找不到具體的實現,因此會做一個標記,等待鏈接器在其他的obj文件中去查找函數實現。同樣,連接器也找不到一個包括MyClass<T>::PrintValue聲明的obj文件。因此報告鏈接錯誤。

4):既然是由於找不到testTemplate.cpp文件,那麼我們就將testTemplate.cpp文件包含在工程中。再次編譯,在VS中會提示一個鏈接錯誤,說找不到外部類型_thiscall MyClass<int>::PrintValue(int)。也許你會覺得很奇怪,我們已經將testTemplate.cpp文件包含在了工程中了阿。先考慮一個問題,我們說過模板的編譯實際上是一個實例化的過程,它並不編譯產生二進制代碼。另外,模板成員函數也只有在被調用的時候才會初始化。在testTemplate.cpp文件中,由於包含了testTemplate.h頭文件,因此這是一個獨立的可以編譯的類模板。但是,編譯器在編譯這個testTemplate.cpp文件的時候由於沒有任何成員函數被調用,因此並沒有實例化PrintValue成員。也許你會說我們在main.cpp中調用了PrintValue函數。但是要知道testTemplate.cpp和main.cpp是兩個獨立的編譯單元,他們相互間並不知道對方的行為。因此,testTemplate.cpp在編譯的時候實際上還是只編譯了testTemplate.h中的內容,即再次聲明了模板,並沒有實例化PrintValue成員。所以,當main.cpp發現需要PrintValue成員,並在testTemplate.obj中去查找的時候就會找不到目標函數。從而發出一個鏈接錯誤。

5):由此可見,模板代碼不能按照常規的C/C++代碼來組織。必須得保證使用模板的函數在編譯的時候就能找到模板代碼,從而實例化模板。在網上有很多關於這方面的文章。主要將模板編譯分為包含編譯和分離編譯。其實,不管是包含編譯還是分離編譯,都是為了一個目標:使得實例化模板的時候就能找到相應的模板實現代碼。大家可以參照這篇文章。


最後,作一個小總結。C++應用程序的編譯一般要經歷展開頭文件->編譯cpp文件->鏈接三個階段。在編譯的時候如果需要外部類型,編譯器會做一個標記,留待連接器來處理。連接器如果找不到需要的外部類型就會發生鏈接錯誤。對於模板,單獨的模板代碼是不能被正確編譯的,需要一個實例化器產生一個模板實例後才能編譯。因此,不能寄希望於連接器來鏈接模板的成員函數,必須保證在實例化模板的地方模板代碼是可見的。

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved