1. 程式人生 > >在C++中使用模板出現“無法解析的外部符號”問題

在C++中使用模板出現“無法解析的外部符號”問題

當我們宣告和定義一個模板的時候,必須要讓宣告和定義放在一個檔案裡。否則編譯器會報錯。 這就是為什麼boost的實現檔案的字尾名是hpp了。 這其中的理由是什麼呢?為什麼會這樣? 首先,一個編譯單元translation unit是指一個.cpp檔案以及它所#include的所有.h檔案,.h檔案裡的程式碼將會被擴充套件到包含它的.cpp檔案裡,然後編譯器編譯該.cpp檔案為一個.obj檔案(假定我們的平臺是win32),後者擁有PEPortable Executablewindows可執行檔案檔案格式,並且本身包含的就已經是二進位制碼,但是不一定能夠執行,因為並不保證其中一定有main函式。當編譯器將一個工程裡的所有

.cpp檔案以分離的方式編譯完畢後,再由聯結器linker進行連線成為一個.exe檔案。

舉個例子:
//---------------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. cppmain.cpp各自被編譯成不同的.obj檔案姑且命名為test.objmain.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++中這個名字當然是經過mangling[處理]過的]

在編譯時,這個call指令顯然是錯誤的,因為main.obj中並無一行f的實現程式碼。那怎麼辦呢?這就是聯結器的任務,聯結器負責在其它的.obj中(本例為test.obj尋找f的實現程式碼,找到以後將call f這個指令的呼叫地址換成實際的f的函式進入點地址。需要注意的是:聯結器實際上將工程裡的.obj“連線成了一個.exe檔案,而它最關鍵的任務就是上面說的,尋找一個外部連線符號在另一個.obj中的地址,然後替換原來的虛假地址。

這個過程如果說的更深入就是:

call f這行指令其實並不是這樣的,它實際上是所謂的stub,也就是一個jmp 0xABCDEF這個地址可能是任意的,然而關鍵是這個地址上有一行指令來進行真正的call f動作。也就是說,這個.obj檔案裡面所有對f的呼叫都jmp向同一個地址,在後者那兒才真正”call”f。這樣做的好處就是聯結器修改地址時只要對後者的call XXX地址作改動就行了。但是,聯結器是如何找到f的實際地址的呢在本例中這處於test.obj中),因為.obj.exe的格式是一樣的,在這樣的檔案中有一個符號匯入表和符號匯出表import tableexport 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檔案中沒有呼叫過ff也就得不到例項化,從而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;
f();
// #1
}

---------------------

編譯器在#1處並不知道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中就找不到一行模板例項的二進位制程式碼,於是聯結器也黔驢技窮了。