天天看點

模闆的聲明和實作為何要放在頭檔案中?

如何組織編寫模闆程式 

發表日期: 1/21/2003 12:28:58 PM 

發表人: Nemanja Trifunovic

前言

常遇到詢問使用模闆到底是否容易的問題,我的回答是:“模闆的使用是容易的,但組織編寫卻不容易”。看看我們幾乎每天都能遇到的模闆類吧,如STL, ATL, WTL, 以及Boost的模闆類,都能體會到這樣的滋味:接口簡單,操作複雜。

我在5年前開始使用模闆,那時我看到了MFC的容器類。直到去年我還沒有必要自己編寫模闆類。可是在我需要自己編寫模闆類時,我首先遇到的事實卻是“傳統”程式設計方法(在*.h檔案聲明,在*.cpp檔案中定義)不能用于模闆。于是我花費一些時間來了解問題所在及其解決方法。

本文對象是那些熟悉模闆但還沒有很多編寫模闆經驗的程式員。本文隻涉及模闆類,未涉及模闆函數。但論述的原則對于二者是一樣的。

問題的産生

通過下例來說明問題。例如在array.h檔案中有模闆類array:

// array.h

template <typename T, int SIZE>

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

#i nclude "array.h"

int main(void)

array<int, 50> 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        

      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

template<typename T, int SIZE> T& array<T, SIZE>::operator [](int i)

    {

    return data_[i];

    }

template<typename T, int SIZE> const T& array<T, SIZE>::get_elem(int i) const

template<typename T, int SIZE> void array<T, SIZE>::set_elem(int i, const T& value)

    data_[i] = value;

template<typename T, int SIZE> array<T, SIZE>::operator T*()

    return data_;

編譯時會出現3個錯誤。問題出來了:

  為什麼錯誤都出現在第一個地方?

  為什麼隻有3個連結出錯?array.cpp中有4個成員函數。

要回答上面的問題,就要深入了解模闆的執行個體化過程。

模闆執行個體化

程式員在使用模闆類時最常犯的錯誤是将模闆類視為某種資料類型。所謂類型參量化(parameterized types)這樣的術語導緻了這種誤解。模闆當然不是資料類型,模闆就是模闆,恰如其名:

  編譯器使用模闆,通過更換模闆參數來建立資料類型。這個過程就是模闆執行個體化(Instantiation)。

  從模闆類建立得到的類型稱之為特例(specialization)。 

  模闆執行個體化取決于編譯器能夠找到可用代碼來建立特例(稱之為執行個體化要素,

  point of instantiation)。

  要建立特例,編譯器不但要看到模闆的聲明,還要看到模闆的定義。

  模闆執行個體化過程是遲鈍的,即隻能用函數的定義來實作執行個體化。

再回頭看上面的例子,可以知道array是一個模闆,array<int, 50>是一個模闆執行個體 - 一個類型。從array建立array<int, 50>的過程就是執行個體化過程。執行個體化要素展現在main.cpp檔案中。如果按照傳統方式,編譯器在array.h檔案中看到了模闆的聲明,但沒有模闆的定義,這樣編譯器就不能建立類型array<int, 50>。但這時并不出錯,因為編譯器認為模闆定義在其它檔案中,就把問題留給連結程式處理。

現在,編譯array.cpp時會發生什麼問題呢?編譯器可以解析模闆定義并檢查文法,但不能生成成員函數的代碼。它無法生成代碼,因為要生成代碼,需要知道模闆參數,即需要一個類型,而不是模闆本身。

這樣,連結程式在main.cpp 或 array.cpp中都找不到array<int, 50>的定義,于是報出無定義成員的錯誤。

至此,我們回答了第一個問題。但還有第二個問題,在array.cpp中有4個成員函數,連結器為什麼隻報了3個錯誤?回答是:執行個體化的惰性導緻這種現象。在main.cpp中還沒有用上operator[],編譯器還沒有執行個體化它的定義。

解決方法

認識了問題,就能夠解決問題:

  在執行個體化要素中讓編譯器看到模闆定義。

  用另外的檔案來顯式地執行個體化類型,這樣連結器就能看到該類型。

  使用export關鍵字。

前二種方法通常稱為包含模式,第三種方法則稱為分離模式。

第一種方法意味着在使用模闆的轉換檔案中不但要包含模闆聲明檔案,還要包含模闆定義檔案。在上例中,就是第一個示例,在array.h中用行内函數定義了所有的成員函數。或者在main.cpp檔案中也包含進array.cpp檔案。這樣編譯器就能看到模闆的聲明和定義,并由此生成array<int, 50>執行個體。這樣做的缺點是編譯檔案會變得很大,顯然要降低編譯和連結速度。

第二種方法,通過顯式的模闆執行個體化得到類型。最好将所有的顯式執行個體化過程安放在另外的檔案中。在本例中,可以建立一個新檔案templateinstantiations.cpp:

// templateinstantiations.cpp                

#i nclude "array.cpp"

template class array <int, 50>; // 顯式執行個體化

array<int, 50>類型不是在main.cpp中産生,而是在templateinstantiations.cpp中産生。這樣連結器就能夠找到它的定義。用這種方法,不會産生巨大的頭檔案,加快編譯速度。而且頭檔案本身也顯得更加“幹淨”和更具有可讀性。但這個方法不能得到惰性執行個體化的好處,即它将顯式地生成所有的成員函數。另外還要維護templateinstantiations.cpp檔案。

第三種方法是在模闆定義中使用export關鍵字,剩下的事就讓編譯器去自行處理了。當我在

Stroustrup的書中讀到export時,感到非常興奮。但很快就發現VC 6.0不支援它,後來又發現根本沒有編譯器能夠支援這個關鍵字(第一個支援它的編譯器要在2002年底才問世)。自那以後,我閱讀了不少關于export的文章,了解到它幾乎不能解決用包含模式能夠解決的問題。欲知更多的export關鍵字,建議讀讀Herb Sutter撰寫的文章。

結論

要開發模闆庫,就要知道模闆類不是所謂的"原始類型",要用其它的程式設計思路。本文目的不是要吓唬那些想進行模闆程式設計的程式員。恰恰相反,是要提醒他們避免犯下開始模闆程式設計時都會出現的錯誤。

//////////////////////////////

甚至是在定義非内聯函數時,模闆的頭檔案中也會放置所有的聲明和定義。這似乎違背了通常的頭檔案規則:“不要在配置設定存儲空間前放置任何東西”,這條規則是為了防止在連接配接時的多重定義錯誤。但模闆定義很特殊。由template<...>處理的任何東西都意味着編譯器在當時不為它配置設定存儲空間,它一直出于等待狀态直到被一個模闆執行個體告知。在編譯器和連接配接器的某一處,有一機制能去掉模闆的多重定義,是以為了容易使用,幾乎總是在頭檔案中放置全部的模闆聲明和定義。

為什麼C++編譯器不能支援對模闆的分離式編譯 

劉未鵬(pongba) /文

首先,C++标準中提到,一個編譯單元[translation unit]是指一個.cpp檔案以及它所include的所有.h檔案,.h檔案裡的代碼将會被擴充到包含它的.cpp檔案裡,然後編譯器編譯該.cpp檔案為一個.obj檔案,後者擁有PE[Portable Executable,即windows可執行檔案]檔案格式,并且本身包含的就已經是二進制碼,但是,不一定能夠執行,因為并不保證其中一定有main函數。當編譯器将一個工程裡的所有.cpp檔案以分離的方式編譯完畢後,再由連接配接器(linker)進行連接配接成為一個.exe檔案。 

舉個例子: 

//---------------test.h-------------------// 

void f();//這裡聲明一個函數f 

//---------------test.cpp--------------// 

#i nclude”test.h” 

void f() 

…//do something 

} //這裡實作出test.h中聲明的f函數 

//---------------main.cpp--------------// 

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 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) 

{} 

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----------------// 

class A 

public: 

void f(); //這裡隻是個聲明 

}; 

//---------------test.cpp-------------// 

void A<T>::f() //模闆的實作,但注意:不是具現 

//---------------main.cpp---------------// 

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中就找不到一行模闆具現體的二進制代碼,于是連接配接器也黔

/////////////////////////////////

 C++模闆代碼的組織方式 ——包含模式(Inclusion Model)     選擇自 sam1111 的 Blog  

關鍵字   Template Inclusion Model 

出處   C++ Template: The Complete Guide

說明:本文譯自《C++ Template: The Complete Guide》一書的第6章中的部分内容。最近看到C++論壇上常有關于模闆的包含模式的文章,聯想到自己初學模闆時,也為類似的問題困惑過,是以翻譯此文,希望對初學者有所幫助。

模闆代碼有幾種不同的組織方式,本文介紹其中最流行的一種方式:包含模式。

連結錯誤

大多數C/C++程式員向下面這樣組織他們的非模闆代碼:

         ·類和其他類型全部放在頭檔案中,這些頭檔案具有.hpp(或者.H, .h, .hh, .hxx)擴充名。

         ·對于全局變量和(非内聯)函數,隻有聲明放在頭檔案中,而定義放在點C檔案中,這些檔案具有.cpp(或者.C, .c, .cc, .cxx)擴充名。

這種組織方式工作的很好:它使得在程式設計時可以友善地通路所需的類型定義,并且避免了來自連結器的“變量或函數重複定義”的錯誤。

由于以上組織方式約定的影響,模闆程式設計新手往往會犯一個同樣的錯誤。下面這一小段程式反映了這種錯誤。就像對待“普通代碼”那樣,我們在頭檔案中定義模闆:

// basics/myfirst.hpp 

#ifndef MYFIRST_HPP 

#define MYFIRST_HPP 

// declaration of template

template <typename T>

void print_typeof (T const&);

#endif // MYFIRST_HPP

print_typeof()聲明了一個簡單的輔助函數用來列印一些類型資訊。函數的定義放在點C檔案中:

// basics/myfirst.cpp

#i nclude <iostream>

#i nclude <typeinfo>

#i nclude "myfirst.hpp" 

// implementation/definition of template

template <typename T> 

void print_typeof (T const& x) 

    std::cout << typeid(x).name() << std::endl;

這個例子使用typeid操作符來列印一個字元串,這個字元串描述了傳入的參數的類型資訊。

最後,我們在另外一個點C檔案中使用我們的模闆,在這個檔案中模闆聲明被#i nclude:

// basics/myfirstmain.cpp 

// use of the template

    double ice = 3.0; 

    print_typeof(ice);  // call function template for type double

大部分C++編譯器(Compiler)很可能會接受這個程式,沒有任何問題,但是連結器(Linker)大概會報告一個錯誤,指出缺少函數print_typeof()的定義。

這個錯誤的原因在于,模闆函數print_typeof()的定義還沒有被具現化(instantiate)。為了具現化一個模闆,編譯器必須知道哪一個定義應該被具現化,以及使用什麼樣的模闆參數來具現化。不幸的是,在前面的例子中,這兩組資訊存在于分開編譯的不同檔案中。是以,當我們的編譯器看到對print_typeof()的調用,但是沒有看到此函數為double類型具現化的定義時,它隻是假設這樣的定義在别處提供,并且建立一個那個定義的引用(連結器使用此引用解析)。另一方面,當編譯器處理myfirst.cpp時,該檔案并沒有任何訓示表明它必須為它所包含的特殊參數具現化模闆定義。

頭檔案中的模闆

解決上面這個問題的通用解法是,采用與我們使用宏或者内聯函數相同的方法:我們将模闆的定義包含進聲明模闆的頭檔案中。對于我們的例子,我們可以通過将#i nclude "myfirst.cpp"添加到myfirst.hpp檔案尾部,或者在每一個使用我們的模闆的點C檔案中包含myfirst.cpp檔案,來達到目的。當然,還有第三種方法,就是删掉myfirst.cpp檔案,并重寫myfirst.hpp檔案,使它包含所有的模闆聲明與定義:

// basics/myfirst2.hpp

#i nclude <iostream> 

#i nclude <typeinfo> 

// declaration of template 

void print_typeof (T const&); 

// implementation/definition of template 

這種組織模闆代碼的方式就稱作包含模式。經過這樣的調整,你會發現我們的程式已經能夠正确編譯、連結、執行了。

從這個方法中我們可以得到一些觀察結果。最值得注意的一點是,這個方法在相當程度上增加了包含myfirst.hpp的開銷。在這個例子中,這種開銷并不是由模闆定義自身的尺寸引起的,而是由這樣一個事實引起的,即我們必須包含我們的模闆用到的頭檔案,在這個例子中是<iostream>和<typeinfo>。你會發現這最終導緻了成千上萬行的代碼,因為諸如<iostream>這樣的頭檔案也包含了和我們類似的模闆定義。

這在實踐中确實是一個問題,因為它增加了編譯器在編譯一個實際程式時所需的時間。我們是以會在以後的章節中驗證其他一些可能的方法來解決這個問題。但無論如何,現實世界中的程式花一小時來編譯連結已經是快的了(我們曾經遇到過花費數天時間來從源碼編譯的程式)。

抛開編譯時間不談,我們強烈建議如果可能盡量按照包含模式組織模闆代碼。

另一個觀察結果是,非内聯模闆函數與内聯函數和宏的最重要的不同在于:它并不會在調用端展開。相反,當模闆函數被具現化時,會産生此函數的一個新的拷貝。由于這是一個自動的過程,編譯器也許會在不同的檔案中産生兩個相同的拷貝,進而引起連結器報告一個錯誤。理論上,我們并不關心這一點:這是編譯器設計者應當關心的事情。實際上,大多數時候一切都運轉正常,我們根本就不用處理這種狀況。然而,對于那些需要建立自己的庫的大型項目,這個問題偶爾會顯現出來。

最後,需要指出的是,在我們的例子中,應用于普通模闆函數的方法同樣适用于模闆類的成員函數和靜态資料成員,以及模闆成員函數。

網絡上志同道合,我們一起學習網絡安全,一起進步,QQ群:694839022

繼續閱讀