爲什麼模板函數應該定義在頭文件內

 

參考:

https://www.cnblogs.com/cnsec/p/3789824.html

 

general的編譯鏈接過程:

首先,C++標準中提到,一個編譯單元[translation unit]是指一個.cpp文件以及它所include的所有.h文件,.h文件裏的代碼將會被擴展到包含它的.cpp文件裏,然後編譯器編譯該.cpp文件爲一個.obj文件,但是,不一定能夠執行,因爲並不保證其中一定有main函數。當編譯器將一個工程裏的所有.cpp文件以分離的方式編譯完畢後,再由連接器(linker)進行連接成爲一個可執行文件。

 

舉個例子: 

//---------------test.h-------------------// 
void f();//這裏聲明一個函數f 


//---------------test.cpp--------------// 
#include”test.h” 
void f() 
{ 
    …//do something 
} //這裏實現出test.h中聲明的f函數 


//---------------main.cpp--------------// 
#include”test.h” 
int main() 
{ 
    f(); //調用f,f具有外部連接類型 
} 

        在這個例子中,test. cpp和main.cpp各被編譯成爲不同的.obj文件[姑且命名爲test.obj和main.obj],在main.cpp中,調用了f函數,然而當編譯器編譯main.cpp時,它所僅僅知道的只是main.cpp中所包含的test.h文件中的一個關於void f();的聲明,所以,編譯器將這裏的f看作外部連接類型,即認爲它的函數實現代碼在另一個.obj文件中,本例也就是test.obj,也就是說,main.obj中實際沒有關於f函數的哪怕一行二進制代碼,而這些代碼實際存在於test.cpp所編譯成的test.obj中。在main.obj中對f的調用只會生成一行call指令,像這樣: call f (C++中這個名字當然是經過處理的)

        在編譯時,這個call指令顯然是錯誤的,因爲main.obj中並無一行f的實現代碼。那怎麼辦呢?這就是連接器的任務,連接器負責在其它的.obj中[本例爲test.obj]尋找f的實現代碼,找到以後將call f這個指令的調用地址換成實際的f的函數進入點地址。需要注意的是:連接器實際上將工程裏的.obj“連接”成了一個.exe文件,而它最關鍵的任務就是上面說的,尋找一個外部連接符號在另一個.obj中的地址,然後替換原來的“虛假”地址。 

        這個過程如果說的更深入就是: call f這行指令其實並不是這樣的,它實際上是所謂的stub,也就是一個jmp 0x23423[這個地址可能是任意的,然而關鍵是這個地址上有一行指令來進行真正的call f動作。也就是說,這個.obj文件裏面所有對f的調用都jmp向同一個地址,在後者那兒才真正”call”f。這樣做的好處就是連接器修改地址時只要對後者的call XXX地址作改動就行了。但是,連接器是如何找到f的實際地址的呢[在本例中這處於test.obj中],因爲.obj於.exe的格式都是一樣的,在這樣的文件中有一個符號導入表和符號導出表[import table和export table]其中將所有符號和它們的地址關聯起來。這樣連接器只要在test.obj的符號導出表中尋找符號f[當然C++對f作了mangling]的地址就行了,然後作一些偏移量處理後[因爲是將兩個.obj文件合併,當然地址會有一定的偏移,這個連接器清楚]寫入main.obj中的符號導入表中f所佔有的那一項。 

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

         完成。

 

模板的編譯鏈接過程:

然而,模板函數的代碼其實並不能直接編譯成二進制代碼,其中要有一個“具現化”的過程。舉個例子: 

//----------main.cpp------// 
template<class T> 
void f(T t) {} 

int main() 
{ 
    …//do something 
    f(10); //call f<int> 編譯器在這裏決定給f一個f<int>的具現體 
    …//do other thing 
} 

         也就是說,如果你在main.cpp文件中沒有調用過f,f也就得不到具現,從而main.obj中也就沒有關於f的任意一行二進制代碼!!如果你這樣調用了: 

f(10); //f<int>得以具現化出來 

f(10.0); //f<double>得以具現化出來 

這樣main.obj中也就有了f<int>,f<double>兩個函數的二進制代碼段。以此類推。 

 

然而具現化要求編譯器知道模板的定義,不是嗎? 

看下面的例子:[將模板和它的實現分離] 

//-------------test.h----------------// 
template<class T>
class A 
{ 
  public: 
     void f(); //這裏只是個聲明 
}; 

//---------------test.cpp-------------// 
#include”test.h” 
template<class T> 
void A<T>::f() //模板的實現,但注意:不是具現 
{ 
    …//do something 
} 

//---------------main.cpp---------------// 
#include”test.h” 
int main() 
{ 
    A<int> a; 
    a. f(); //編譯器在這裏並不知道A<int>::f的定義,因爲它不在test.h裏面 
            //於是編譯器只好寄希望於連接器,希望它能夠在其他.obj裏面找到 
            //A<int>::f的實現體,在本例中就是test.obj,然而,後者中真有A<int>::f的 
            //二進制代碼嗎?NO!!!因爲C++標準明確表示,當一個模板不被用到的時 
            //侯它就不該被具現出來,test.cpp中用到了A<int>::f了嗎?沒有!!所以實 
            //際上test.cpp編譯出來的test.obj文件中關於A::f的一行二進制代碼也沒有 
            //於是連接器就傻眼了,只好給出一個連接錯誤 
            //但是,如果在test.cpp中寫一個函數,其中調用A<int>::f,則編譯器會將其
            //具現出來,因爲在這個點上[test.cpp中],編譯器知道模板的定義,所以能夠具現化,
            //於是,test.obj的符號導出表中就有了A<int>::f這個符號的地址,於是連接器就能
            //夠完成任務。 

}

 

關鍵是:在分離式編譯的環境下,編譯器編譯某一個.cpp文件時並不知道另一個.cpp文件的存在,也不會去查找[當遇到未決符號時它會寄希望於連接器]。這種模式在沒有模板的情況下運行良好,但遇到模板時就傻眼了,因爲模板僅在需要的時候纔會具現化出來,所以,當編譯器只看到模板的聲明時,它不能具現化該模板,只能創建一個具有外部連接的符號並期待連接器能夠將符號的地址決議出來。然而當實現該模板的.cpp文件中沒有用到模板的具現體時,編譯器懶得去具現,所以,整個工程的.obj中就找不到一行模板具現體的二進制代碼,於是連接器也黔驢技窮了。

 

總結:

        像上面的這個例子,模板函數的聲明在頭文件內,用到這個模板函數的另一個源文件A中包含了這個頭文件(但是沒有包含模板函數的定義所在的文件)時,編譯器只看到了聲明,這時候並沒有把函數的定義實例化出來,編譯器直接把找到函數定義的工作甩鍋給鏈接器。但是鏈接器拿到的所有.obj文件中都沒有這個模板函數的實例化定義,所以鏈接的時候就找不到。

        如果模板函數的定義也在頭文件內,編譯器在編譯的時候就直接拿着模板實例化出這個模板函數的定義,並把這份定義存放在編譯輸出的.obj文件內。

        所以其實模板的實例化都是在編譯期完成的,如果在編譯的時候,找不到模板函數的定義,就先不在這一次編譯中實例化該模板函數,直接生成一個不含這個模板函數實例化結果的.obj文件,等到與其他.obj文件鏈接的時候再鏈接到一起。

        因此只要讓編譯器能夠在某一次編譯時看到模板函數的定義並將其實例化出來,最後把這些編譯得到的目標文件鏈接在一起,就不會有模板函數鏈接失敗的問題。例如可以:(1)將模板函數的定義直接寫在頭文件內;(2)可以寫在源文件B內,並將這個源文件B包含在源文件A內;(3)在源文件A內想辦法觸發這個模板函數的實例化(例如可以顯式實例化:template void f<int>();)在編譯A時將實例化結果寫入生成的.obj文件內,讓B的.obj文件與A的.obj文件鏈接時能夠找到實例化結果。

        上面的三種方法,前兩種都是在編譯期讓所有用到模板函數的地方直接實例化函數定義,這樣做會使每一個編譯結果都包含實例化結果,導致目標文件較大,鏈接的時候需要把重複的實例化定義去重,編譯鏈接的時間也會長一些。使用第三種方法,可以用一個特定的.cc文件顯示實例化所有會被用到的模板實例,單獨編譯這個文件,最後讓它參與鏈接,用這種方法,不會產生巨大的頭文件,加快編譯速度。而且頭文件本身也顯得更加“乾淨”和更具有可讀性。但這個方法不能得到惰性實例化的好處,即它將顯式地生成所有的成員函數,另外還要維護一個這樣的文件。所以爲了容易使用,幾乎總是在頭文件中放置全部的模板聲明和定義。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章