網上我最喜歡的技術文章是類似某何君所著“CVS快速入門”或者“UML reference card”之類,簡短扼要,可以非常快的領著你進入一個新天地。而對於比較長的文章我通常是將其保存到硬盤上,然後准備著“以後有時間”的時候再看,但它們通常的命運都是“閒坐說玄宗”,直到某一天在整理硬盤時將它們以“不知所雲”入罪,一並刪除。
這篇小文主要是針對剛剛接觸模板概念的讀者,希望能幫助讀者學習模板的使用。為了避免本文也在諸公的硬盤上遭逢厄運,我決定寫的短些。“以後有時間”的時候再補充些內容。
1. 簡介
模板是C++在90年代引進的一個新概念,原本是為了對容器類(container classes)的支持[1],但是現在模板產生的效果已經遠非當初所能想象。
簡單的講,模板就是一種參數化(parameterized)的類或函數,也就是類的形態(成員、方法、布局等)或者函數的形態(參數、返回值等)可以被參數改變。更加神奇的是這裡所說的參數,不光是我們傳統函數中所說的數值形式的參數,還可以是一種類型(實際上稍微有一些了解的人,更多的會注意到使用類型作為參數,而往往忽略使用數值作為參數的情況)。
舉個常用的例子來解釋也許模板就從你腦袋裡的一個模糊的概念變成活生生的代碼了:
在C語言中,如果我們要比較兩個數的大小,常常會定義兩個宏:
#define min(a,b) ((a)>(b)?(b):(a))
#define max(a,b) ((a)>(b)?(a):(b))
這樣你就可以在代碼中:
return min(10, 4);
或者:
return min(5.3, 18.6);
這兩個宏非常好用,但是在C++中,它們並不像在C中那樣受歡迎。宏因為沒有類型檢查以及天生的不安全(例如如果代碼寫為min(a++, b--);則顯然結果非你所願),在C++中被inline函數替代。但是隨著你將min/max改為函數,你立刻就會發現這個函數的局限性 —— 它不能處理你指定的類型以外的其它類型。例如你的min()聲明為:
int min(int a, int b);
則它顯然不能處理float類型的參數,但是原來的宏卻可以很好的工作!你隨後大概會想到函數重載,通過重載不同類型的min()函數,你仍然可以使大部分代碼正常工作。實際上,C++對於這類可以抽象的算法,提供了更好的辦法,就是模板:
template <class T> const T & min(const T & t1, const T & t2) {
return t1>t2?t2:t1;
}
這是一個模板函數的例子。在有了模板之後,你就又自由了,可以像原來在C語言中使用你的min宏一樣來使用這個模板,例如:
return min(10,4);
也可以:
return min(5.3, 18.6)
你發現了麼?你獲得了一個類型安全的、而又可以支持任意類型的min函數,它是否比min宏好呢?
當然上面這個例子只涉及了模板的一個方面,模板的作用遠不只是用來替代宏。實際上,模板是泛化編程(Generic Programming)的基礎。所謂的泛化編程,就是對抽象的算法的編程,泛化是指可以廣泛的適用於不同的數據類型。例如我們上面提到的min算法。
2. 語法
你千萬不要以為我真的要講模板的語法,那太難為我了,我只是要說一下如何聲明一個模板,如何定義一個模板以及常見的語法方面的問題。
template<> 是模板的標志,在<>中,是模板的參數部分。參數可以是類型,也可以是數值。例如:
template<class T, T t>
class Temp{
public:
...
void print() { cout << t << endl; }
private:
T t_;
};
在這個聲明中,第一個參數是一個類型,第二個參數是一個數值。這裡的數值,必須是一個常量。例如針對上面的聲明:
Temp<int, 10> temp; // 合法
int i = 10;
Temp<int, i> temp; // 不合法
const int j = 10;
Temp<int, j> temp; // 合法
參數也可以有默認值:
template<class T, class C=char> ...
默認值的規則與函數的默認值一樣,如果一個參數有默認值,則其後的每個參數都必須有默認值。
參數的名字在整個模板的作用域內有效,類型參數可以作為作用域內變量的類型(例如上例中的T t_),數值型參數可以參與計算,就象使用一個普通常數一樣(例如上例中的cout << t << endl)。
模板有個值得注意的地方,就是它的聲明方式。以前我一直認為模板的方法全部都是隱含為inline的,即使你沒有將其聲明為inline並將函數體放到了類聲明以外。這是模板的聲明方式給我的錯覺,實際上並非如此。我們先來看看它的聲明,一個作為接口出現在頭文件中的模板類,其所有方法也都必須與類聲明出現在一起。用通俗的話來說,就是模板類的函數體也必須出現在頭文件中(當然如果這個模板只被一個C++程序文件使用,它當然也可以放在.cc中,但同樣要求類聲明與函數體必須出現在一起)。這種要求與inline的要求一樣,因此我一度認為它們隱含都是inline的。但是在Thinking In C++[2]中,明確的提到了模板的non-inline function,就讓我不得不改變自己的想法了。看來正確的理解應該是:與普通類一樣,聲明為inline的,或者雖然沒有聲明為inline但是函數體在類聲明中的才是inline函數。
澄清了inline的問題候,我們再回頭來看那些我們寫的包含了模板類的丑陋的頭文件,由於上面提到的語法要求,頭文件中除了類接口之外,到處充斥著實現代碼,對用戶來說,十分的不可讀。為了能像傳統頭文件一樣,讓用戶盡量只看到接口,而不用看到實現方法,一般會將所有的方法實現部分,放在一個後綴為.i或者.inl的文件中,然後在模板類的頭文件中包含這個.i或者.inl文件。例如:
// start of temp.h
template<class T> class Temp{
public:
void print();
};
#include "temp.inl"
// end of temp.h
// start of temp.inl
template<class T> void Temp<T>::print() {
...
}
// end of temp.inl
通過這樣的變通,即滿足了語法的要求,也讓頭文件更加易讀。模板函數也是一樣。
普通的類中,也可以有模板方法,例如:
class A{
public:
template<class T> void print(const T& t) { ...}
void dummy();
};
對於模板方法的要求與模板類的方法一樣,也需要與類聲明出現在一起。而這個類的其它方法,例如dummy(),則沒有這樣的要求。
3. 使用技巧
知道了上面所說的簡單語法後,基本上就可以寫出自己的模板了。但是在使用的時候還是有些技巧。
3.1 語法檢查
對模板的語法檢查有一部分被延遲到使用時刻(類被定義[3],或者函數被調用),而不是像普通的類或者函數在被編譯器讀到的時候就會進行語法檢查。因此,如果一個模板沒有被使用,則即使它包含了語法的錯誤,也會被編譯器忽略,這是語法檢查問題的第一個方面,這不常遇到,因為你寫了一個模板就是為了使用它的,一般不會放在那裡不用。與語法檢查相關的另一個問題是你可以在模板中做一些假設。例如:
template<class T> class Temp{
public:
Temp(const T & t): t_(t) {}
void print() { t.print();}
private:
T t_;
};
在這個模板中,我假設了T這個類型是一個類,並且有一個print()方法(t.print())。我們在簡介中的min模板中其實也作了同樣的假設,即假設T重載了'>'操作符。
因為語法檢查被延遲,編譯器看到這個模板的時候,並不去關心T這個類型是否有print()方法,這些假設在模板被使用的時候才被編譯器檢查。只要定義中給出的類型滿足假設,就可以通過編譯。
之所以說“有一部分”語法檢查被延遲,是因為有些基本的語法還是被編譯器立即檢查的。只有那些與模板參數相關的檢查才會被推遲。如果你沒有寫class結束後的分號,編譯器不會放過你的。
3.2 繼承
模板類可以與普通的類一樣有基類,也同樣可以有派生類。它的基類和派生類既可以是模板類,也可以不是模板類。所有與繼承相關的特點模板類也都具備。但仍然有一些值得注意的地方。
假設有如下類關系:
template<class T> class A{ ... };
|
+-- A<int> aint;
|
+-- A<double> adouble;
則aint和adouble並非A的派生類,甚至可以說根本不存在A這個類,只有A<int>和A<doubl>這兩個類。這兩個類沒有共同的基類,因此不能通過類A來實現多態。如果希望對這兩個類實現多態,正確的類層次應該是:
class Abase {...};
template<class T> class A: public Abase {...};
|
+-- A<int> aint;
|
+-- A<double> adouble;
也就是說,在模板類之上增加一個抽象的基類,注意,這個抽象基類是一個普通類,而非模板。
再來看下面的類關系:
template<int i> class A{...};
|
+-- A<10> a10;
|
+-- A<5> a5;
在這個情況下,模板參數是一個數值,而不是一個類型。盡管如此,a10和a5仍然沒有共同基類。這與用類型作模板參數是一樣的。
3.3 靜態成員
與上面例子類似:
template<class T> class A{ static char a_; };
|
+-- A<int> aint1, aint2;
|
+-- A<double> adouble1, adouble2;
這裡模板A中增加了一個靜態成員,那麼要注意的是,對於aint1和adouble1,它們並沒有一個共同的靜態成員。而aint1與aint2有一個共同的靜態成員(對adouble1和adouble2也一樣)。
這個問題實際上與繼承裡面講到的問題是一回事,關鍵要認識到aint與adouble分別是兩個不同類的實例,而不是一個類的兩個實例。認識到這一點後,很多類似問題都可以想通了。
3.4 模板類的運用
模板與類繼承都可以讓代碼重用,都是對具體問題的抽象過程。但是它們抽象的側重點不同,模板側重於對於算法的抽象,也就是說如果你在解決一個問題的時候,需要固定的step1 step2...,那麼大概就可以抽象為模板。而如果一個問題域中有很多相同的操作,但是這些操作並不能組成一個固定的序列,大概就可以用類繼承來解決問題。以我的水平還不足以在這麼高的層次來清楚的解釋它們的不同,這段話僅供參考吧。
模板類的運用方式,更多情況是直接使用,而不是作為基類。例如人們在使用STL提供的模板時,通常直接使用,而不需要從模板庫中提供的模板再派生自己的類。這不是絕對的,我覺得這也是模板與類繼承之間的以點兒區別,模板雖然也是抽象的東西,但是它往往不需要通過派生來具體化。
在設計模式[4]中,提到了一個模板方法模式,這個模式的核心就是對算法的抽象,也就是對固定操作序列的抽象。雖然不一定要用C++的模板來實現,但是它反映的思想是與C++模板一致的。