C++ STL 分離式編譯

 


爲什麼C++編譯器不能支持對模板的分離式編譯
作者: 劉未鵬(pongba)
轉自: C++的羅浮宮(http://blog.csdn.net/pongba)
首先,一個編譯單元(translation unit)是指一個.cpp文件以及它所#include的所有.h文件,.h文件裏的代碼將會被擴展到包含它的.cpp文 件裏,然後編譯器編譯該.cpp文件爲一個.obj文 件(假定我們的平臺是win32),後者擁有PE(Portable Executable,即windows可執行文件)文件格式,並且本身包 含的就已經是二進制碼,但是不一定能夠執行,因爲並不保證其中一定有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. 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++中這個名字當然是經過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 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;
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中就找不到一行模板實例的二進制代碼,於是連接器也黔驢技窮了。


理解C++編譯器編譯模板類的過程
發佈於:2010-1-13 13:42:51 已被閱讀: 254

常遇到詢問使用模板到底是否容易的問題,我的回答是:“模板的使用是容易的,但組織編寫卻不容易”。看看我們幾乎每天都能遇到的模板類吧,如STL, ATL, WTL, 以及Boost的模板類,都能體會到這樣的滋味:接口簡單,操作複雜。
  本文對象是那些熟悉模板但還沒有很多編寫模板經驗的程序員。本文只涉及模板類,未涉及模板函數。但論述的原則對於二者是一樣的。
  問題的產生
  通過下例來說明問題。例如在array.h文件中有模板類array:
  // array.h
  template
  class array
  {
  T data_[SIZE];
  array (const array& other);
  const array& operator = (const array& other);
  public:
  array(){};
  T& operator[](int i) {return data_[i];}
  const T& get_elem (int i) const {return data_[i];}
  void set_elem(int i, const T& value) {data_[i] = value;}
  operator T*() {return data_;}
  };
  然後在main.cpp文件中的主函數中使用上述模板:
  // main.cpp
  #include "array.h"
  int main(void)
  {
  array intArray;
  intArray.set_elem(0, 2);
  int firstElem = intArray.get_elem(0);
  int* begin = intArray;
  }
  這時編譯和運行都是正常的。程序先創建一個含有50個整數的數組,然後設置數組的第一個元素值爲2,再讀取第一個元素值,最後將指針指向數組起點。
  但如果用傳統編程方式來編寫會發生什麼事呢?我們來看看:
  將array.h文件分裂成爲array.h和array.cpp二個文件(main.cpp保持不變)
  // array.h
  template
  class array
  {
  T data_[SIZE];
  array (const array& other);
  const array& operator = (const array& other);
  public:
  array(){};
  T& operator[](int i);
  const T& get_elem (int i) const;
  void set_elem(int i, const T& value);
  operator T*();
  };
// array.cpp
  #include "array.h"
  template T& array::operator [](int i)
  {
  return data_[i];
  }
  template const T& array::get_elem(int i) const
  {
  return data_[i];
  }
  template void array::set_elem(int i, const T& value)
  {
  data_[i] = value;
  }
  template array::operator T*()
  {
  return data_;
  }
  編譯時會出現3個錯誤。問題出來了:
  爲什麼錯誤都出現在第一個地方?
  爲什麼只有3個鏈接出錯?array.cpp中有4個成員函數。
  要回答上面的問題,就要深入瞭解模板的實例化過程。
  模板實例化
  程序員在使用模板類時最常犯的錯誤是將模板類視爲某種數據類型。所謂類型參量化(parameterized types)這樣的術語導致了這種誤解。模板當然不是數據類型,模板就是模板,恰如其名:
  編譯器使用模板,通過更換模板參數來創建數據類型。這個過程就是模板實例化(Instantiation)。
  從模板類創建得到的類型稱之爲特例(specialization)。
  模板實例化取決於編譯器能夠找到可用代碼來創建特例(稱之爲實例化要素,
  point of instantiation)。
  要創建特例,編譯器不但要看到模板的聲明,還要看到模板的定義。
  模板實例化過程是遲鈍的,即只能用函數的定義來實現實例化。
  再回頭看上面的例子,可以知道array是一個模板,array是一個模板實例 - 一個類型。從array創建array的過程就是實例化過程。實例化要素體現在main.cpp文件中。如果按照傳統方式,編譯器在array.h文件中看到了模板的聲明,但沒有模板的定義,這樣編譯器就不能創建類型array。但這時並不出錯,因爲編譯器認爲模板定義在其它文件中,就把問題留給鏈接程序處理。
  現在,編譯array.cpp時會發生什麼問題呢?編譯器可以解析模板定義並檢查語法,但不能生成成員函數的代碼。它無法生成代碼,因爲要生成代碼,需要知道模板參數,即需要一個類型,而不是模板本身。
  這樣,鏈接程序在main.cpp 或 array.cpp中都找不到array的定義,於是報出無定義成員的錯誤。
  至此,我們回答了第一個問題。但還有第二個問題,在array.cpp中有4個成員函數,鏈接器爲什麼只報了3個錯誤?回答是:實例化的惰性導致這種現象。在main.cpp中還沒有用上operator[],編譯器還沒有實例化它的定義。
  解決方法
  認識了問題,就能夠解決問題:
  在實例化要素中讓編譯器看到模板定義。
  用另外的文件來顯式地實例化類型,這樣鏈接器就能看到該類型。
  使用export關鍵字。
  前二種方法通常稱爲包含模式,第三種方法則稱爲分離模式。
  第一種方法意味着在使用模板的轉換文件中不但要包含模板聲明文件,還要包含模板定義文件。在上例中,就是第一個示例,在array.h中用行內函數定義了所有的成員函數。或者在main.cpp文件中也包含進array.cpp文件。這樣編譯器就能看到模板的聲明和定義,並由此生成array實例。這樣做的缺點是編譯文件會變得很大,顯然要降低編譯和鏈接速度。
  第二種方法,通過顯式的模板實例化得到類型。最好將所有的顯式實例化過程安放在另外的文件中。在本例中,可以創建一個新文件templateinstantiations.cpp:
  // templateinstantiations.cpp
  #include "array.cpp"
  template class array ; // 顯式實例化
  array類型不是在main.cpp中產生,而是在templateinstantiations.cpp中產生。這樣鏈接器就能夠找到它的定義。用這種方法,不會產生巨大的頭文件,加快編譯速度。而且頭文件本身也顯得更加“乾淨”和更具有可讀性。但這個方法不能得到惰性實例化的好處,即它將顯式地生成所有的成員函數。另外還要維護templateinstantiations.cpp文件。
  第三種方法是在模板定義中使用export關鍵字,剩下的事就讓編譯器去自行處理了。當我在
  Stroustrup的書中讀到export時,感到非常興奮。但很快就發現VC 6.0不支持它,後來又發現根本沒有編譯器能夠支持這個關鍵字(第一個支持它的編譯器要在2002年底才問世)。自那以後,我閱讀了不少關於export的文章,瞭解到它幾乎不能解決用包含模式能夠解決的問題。欲知更多的export關鍵字,建議讀讀Herb Sutter撰寫的文章。
  結論
  要開發模板庫,就要知道模板類不是所謂的"原始類型",要用其它的編程思路。本文目的不是要嚇唬那些想進行模板編程的程序員。恰恰相反,是要提醒他們避免犯下開始模板編程時都會出現的錯誤。


發佈了24 篇原創文章 · 獲贊 4 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章