templates(模板)是節省時間和避免代碼重復的極好方法。不必再輸入20個相似的 classes,每一個包含 15 個 member functions(成員函數),你可以輸入一個 class template(類模板),並讓編譯器實例化出你需要的 20 個 specific classes(特定類)和 300 個函數。(class template(類模板)的 member functions(成員函數)只有被使用時才會被隱式實例化,所以只有在每一個函數都被實際使用時,你才會得到全部 300 個member functions(成員函數)。)function templates(函數模板)也有相似的魅力。不必再寫很多函數,你可以寫一個 function templates(函數模板)並讓編譯器做其余的事。這不是很重要的技術嗎?
是的,不錯……有時。如果你不小心,使用 templates(模板)可能導致 code bloat(代碼膨脹):重復的(或幾乎重復的)的代碼,數據,或兩者都有的二進制碼。結果會使源代碼看上去緊湊而整潔,但是目標代碼臃腫而松散。臃腫而松散很少會成為時尚,所以你需要了解如何避免這樣的二進制擴張。
你的主要工具有一個有氣勢的名字 commonality and variability analysis(通用性與可變性分析),但是關於這個想法並沒有什麼有氣勢的東西。即使在你的職業生涯中從來沒有使用過模板,你也應該從始至終做這樣的分析。
當你寫一個函數,而且你意識到這個函數的實現的某些部分和另一個函數的實現本質上是相同的,你會僅僅復制代碼嗎?當然不。你從這兩個函數中分離出通用的代碼,放到第三個函數中,並讓那兩個函數來調用這個新的函數。也就是說,你分析那兩個函數以找出那些通用和變化的構件,你把通用的構件移入一個新的函數,並把變化的構件保留在原函數中。類似地,如果你寫一個 class,而且你意識到這個 class 的某些構件和另一個 class 的構件是相同的,你不要復制那些通用構件。作為替代,你把通用構件移入一個新的 class 中,然後你使用 inheritance(繼承)或 composition(復合)使得原來的 classes 可以訪問這些通用特性。原來的 classes 中不同的構件——變化的構件——仍保留在它們原來的位置。
在寫 templates(模板)時,你要做同樣的分析,而且用同樣的方法避免重復,但這裡有一個技巧。在 non-template code(非模板代碼)中,重復是顯式的:你可以看到兩個函數或兩個類之間存在重復。在 template code(模板代碼)中。重復是隱式的:僅有一份 template(模板)源代碼的拷貝,所以你必須培養自己去判斷在一個 template(模板)被實例化多次後可能發生的重復。
例如,假設你要為固定大小的 square matrices(正方矩陣)寫一個 templates(模板),其中,要支持 matrix inversion(矩陣轉置)。
template<typename T, // template for n x n matrices of
std::size_t n> // objects of type T; see below for info
class SquareMatrix { // on the size_t parameter
public:
...
void invert(); // invert the matrix in place
};
這個 template(模板)取得一個 type parameter(類型參數)T,但是它還有一個類型為 size_t 的參數——一個 non-type parameter(非類型參數)。non-type parameter(非類型參數)比 type parameter(類型參數)更不通用,但是它們是完全合法的,而且,就像在本例中,它們可以非常自然。
現在考慮以下代碼:
SquareMatrix<double, 5> sm1;
...
sm1.invert(); // call SquareMatrix<double, 5>::invert
SquareMatrix<double, 10> sm2;
...
sm2.invert(); // call SquareMatrix<double, 10>::invert
這裡將有兩個 invert 的拷貝被實例化。這兩個函數不是相同的,因為一個作用於 5 x 5 矩陣,而另一個作用於 10 x 10 矩陣,但是除了常數 5 和 10 以外,這兩個函數是相同的。這是一個發生 template-induced code bloat(模板導致的代碼膨脹)的經典方法。
如果你看到兩個函數除了一個版本使用了 5 而另一個使用了 10 之外,對應字符全部相等,你該怎麼做呢?你的直覺讓你創建一個取得一個值作為一個參數的函數版本,然後用 5 或 10 調用這個參數化的函數以代替復制代碼。你的直覺為你提供了很好的方法!以下是一個初步過關的 SquareMatrix 的做法:
template<typename T> // size-independent base class for
class SquareMatrixBase { // square matrices
protected:
...
void invert(std::size_t matrixSize); // invert matrix of the given size
...
};
template< typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
private:
using SquareMatrixBase<T>::invert; // avoid hiding base version of
// invert; see Item 33
public:
...
void invert() { this->invert(n); } // make inline call to base class
}; // version of invert; see below
// for why "this->" is here
就像你能看到的,invert 的參數化版本是在一個 base class(基類)SquareMatrixBase 中的。與 SquareMatrix 一樣,SquareMatrixBase 是一個 template(模板),但與 SquareMatrix 不一樣的是,它參數化的僅僅是矩陣中的對象的類型,而沒有矩陣的大小。因此,所有持有一個給定對象類型的矩陣將共享一個單一的 SquareMatrixBase class。從而,它們共享 invert 在那個 class 中的版本的單一拷貝。
SquareMatrixBase::invert 僅僅是一個計劃用於 derived classes(派生類)以避免代碼重復的方法,所以它是 protected 的而不是 public 的。調用它的額外成本應該為零,因為 derived classes(派生類)的 inverts 使用 inline functions(內聯函數)調用 base class(基類)的版本。(這個 inline 是隱式的——參見《理解inline化的介入和排除》。)這些函數使用了 "this->" 標記,因為就像 Item 43 解釋的,如果不這樣,在 templatized base classes(模板化基類)中的函數名(諸如 SquareMatrixBase<T>)被 derived classes(派生類)隱藏。還要注意 SquareMatrix 和 SquareMatrixBase 之間的繼承關系是 private 的。這准確地反映了 base class(基類)存在的理由僅僅是簡化 derived classes(派生類)的實現的事實,而不是表示 SquareMatrix 和 SquareMatrixBase 之間的一個概念上的 is-a 關系。(關於 private inheritance(私有繼承)的信息,參見 《謹慎使用私有繼承》。)
迄今為止,還不錯,但是有一個棘手的問題我們還沒有提及。SquareMatrixBase::invert 怎樣知道應操作什麼數據?它從它的參數知道矩陣的大小,但是它怎樣知道一個特定矩陣的數據在哪裡呢?大概只有 derived class(派生類)才知道這些。derived class(派生類)如何把這些傳達給 base class(基類)以便於 base class(基類)能夠做這個轉置呢?
一種可能是為 SquareMatrixBase::invert 增加另一個的參數,也許是一個指向存儲矩陣數據的內存塊的開始位置的指針。這樣可以工作,但是十有八九,invert 不是 SquareMatrix 中僅有的能被寫成一種 size-independent(大小無關)的方式並移入 SquareMatrixBase 的函數。如果有幾個這樣的函數,全都需要一種找到持有矩陣內的值的內存的方法。我們可以為它們全都增加一個額外的參數,但是我們一再重復地告訴 SquareMatrixBase 同樣的信息。這看上去不太正常。
一個可替換方案是讓 SquareMatrixBase 存儲一個指向矩陣的值的內存區域的指針。而且一旦它存儲了這個指針,它同樣也可以存儲矩陣大小。最後得到的設計大致就像這樣:
template<typename T>
class SquareMatrixBase {
protected:
SquareMatrixBase(std::size_t n, T *pMem) // store matrix size and a
: size(n), pData(pMem) {} // ptr to matrix values
void setDataPtr(T *ptr) { pData = ptr; } // reassign pData
...
private:
std::size_t size; // size of matrix
T *pData; // pointer to matrix values
};
這樣就是讓 derived classes(派生類)決定如何分配內存。某些實現可能決定直接在 SquareMatrix object 內部存儲矩陣數據:
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix() // send matrix size and
: SquareMatrixBase<T>(n, data) {} // data ptr to base class
...
private:
T data[n*n];
};
這種類型的 objects 不需要 dynamic memory allocation(動態內存分配),但是這些 objects 本身可能會非常大。一個可選方案是將每一個矩陣的數據放到 heap(堆)上:
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix() // set base class data ptr to null,
: SquareMatrixBase<T>(n, 0), // allocate memory for matrix
pData(new T[n*n]) // values, save a ptr to the
{ this->setDataPtr(pData.get()); } // memory, and give a copy of it
... // to the base class
private:
boost::scoped_array<T> pData; // see Item 13 for info on
}; // boost::scoped_array
無論數據存儲在哪裡,從膨脹的觀點來看關鍵的結果在於:現在 SquareMatrix 的許多——也許是全部—— member functions(成員函數)可以簡單地 inline 調用它的 base class versions(基類版本),而這個版本是與其它所有持有相同數據類型的矩陣共享的,而無論它們的大小。與此同時,不同大小的 SquareMatrix objects 是截然不同的類型,所以,例如,即使 SquareMatrix<double, 5> 和 SquareMatrix<double, 10> objects 使用 SquareMatrixBase<double> 中同樣的 member functions(成員函數),也沒有機會將一個 SquareMatrix<double, 5> object 傳送給一個期望一個 SquareMatrix<double, 10> 的函數。很好,不是嗎?
很好,是的,但不是免費的。將矩陣大小硬性固定在其中的 invert 版本很可能比將大小作為一個函數參數傳入或存儲在 object 中的共享版本能產生更好的代碼。例如,在 size-specific(特定大小)的版本中,sizes(大小)將成為 compile-time constants(編譯期常數),因此適用於像 constant propagation 這樣的優化,包括將它們作為 immediate operands(立即操作數)嵌入到生成的指令中。在 size-independent version(大小無關版本)中這是不可能做到的。
另一方面,將唯一的 invert 的版本用於多種矩陣大小縮小了可執行碼的大小,而且還能縮小程序的 working set(工作區)大小以及改善 instruction cache(指令緩存)中的 locality of reference(引用的局部性)。這些能使程序運行得更快,超額償還了失去的針對 invert 的 size-specific versions(特定大小版本)的任何優化。哪一個效果更劃算?唯一的分辨方法就是在你的特定平台和典型數據集上試驗兩種方法並觀察其行為。
另一個效率考慮關系到 objects 的大小。如果你不小心,將函數的 size-independent 版本(大小無關版本)上移到一個 base class(基類)中會增加每一個 object 的整體大小。例如,在我剛才展示的代碼中,即使每一個 derived class(派生類)都已經有了一個取得數據的方法,每一個 SquareMatrix object 都還有一個指向它的數據的指針存在於 SquareMatrixBase class 中,這為每一個 SquareMatrix object 至少增加了一個指針的大小。通過改變設計使這些指針不再必需是有可能的,但是,這又是一樁交易。例如,讓 base class(基類)存儲一個指向矩陣數據的 protected 指針導致封裝性的降低。它也可能導致資源管理復雜化:如果 base class(基類)存儲了一個指向矩陣數據的指針,但是那些數據既可以是動態分配的也可以是物理地存儲於 derived class object(派生類對象)之內的(就像我們看到的),它如何決定這個指針是否應該被刪除?這樣的問題有答案,但是你越想讓它們更加精巧一些,它就會變成更復雜的事情。在某些條件下,少量的代碼重復就像是一種解脫。
本文只討論了由於 non-type template parameters(非類型模板參數)引起的膨脹,但是 type parameters(類型參數)也能導致膨脹。例如,在很多平台上,int 和 long 有相同的二進制表示,所以,可以說,vector<int> 和 vector<long> 的 member functions(成員函數)很可能是相同的——膨脹的恰到好處的解釋。某些連接程序會合並同樣的函數實現,還有一些不會,而這就意味著在一些環境上一些模板在 int 和 long 上都被實例化而能夠引起代碼重復。類似地,在大多數平台上,所有的指針類型有相同的二進制表示,所以持有指針類型的模板(例如,list<int*>,list<const int*>,list<SquareMatrix<long, 3>*> 等)應該通常可以使用每一個 member function(成員函數)的單一的底層實現。典型情況下,這意味著與 strongly typed pointers(強類型指針)(也就是 T* 指針)一起工作的 member functions(成員函數)可以通過讓它們調用與 untyped pointers(無類型指針)(也就是 void* 指針)一起工作的函數來實現。一些標准 C++ 庫的實現對於像 vector,deque 和 list 這樣的模板就是這樣做的。如果你關心起因於你的模板的代碼膨脹,你可能需要用同樣的做法開發模板。
Things to Remember
·templates(模板)產生多個 classes 和多個 functions,所以一些不依賴於 template parameter(模板參數)的模板代碼會引起膨脹。
·non-type template parameters(非類型模板參數)引起的膨脹常常可以通過用 function parameters(函數參數)或 class data members(類數據成員)替換 template parameters(模板參數)而消除。
·type parameters(類型參數)引起的膨脹可以通過讓具有相同的二進制表示的實例化類型共享實現而減少