Visual Studio 2005把泛型編程的類型參數模型引入了微軟.NET框架組件。C++/CLI支持兩種類型參數機制--通用語言運行時(CLR)泛型和C++模板。本文將介紹兩者之間的一些區別--特別是參數列表和類型約束模型之間的區別。
參數列表又回來了 參數列表與函數的信號(signature)類似:它標明了參數的數量和每個參數的類型,并把給每個參數關聯一個唯一的標識符,這樣在模板定義的內部,每個參數就可以被唯一地引用。
參數在模板或泛型的定義中起占位符(placeholder)的作用。用戶通過提供綁定到參數的實際值來建立對象實例。參數化類型的實例化并非簡單的文本替代(宏擴展機制就是使用文本替代的)。相反地,它把實際的用戶值綁定到定義中的相關的形式參數上。
在泛型中,每個參數都表現為Object類型或衍生自Object的類型。在本文后面你可以看到,這約束了你可能執行的操作類型或通過類型參數聲明的對象。你可以通過提供更加明確的約束來調整這些約束關系。這些明確的約束引用那些衍生出實際類型參數的基類或接口集合。
模板除了支持類型參數之外,還支持表達式和模板參數。此外,模板還支持默認的參數值。這些都是按照位置而不是名稱來分解的。在兩種機制之下,類型參數都是與類或類型名稱關鍵字一起引入的。
參數列表的額外的模板功能 模板作為類型參數的補充,允許兩種類型的參數:非類型(non-type)參數和模板參數。我們將分別簡短地介紹一下。
非類型參數受常數表達式的約束。我們應該立即想到它是數值型或字符串常量。例如,如果選擇提供固定大小的堆棧,你就可能同時指定一個非類型的大小參數和
元素類型參數,這樣就可以同時按照元素類別和大小來劃分堆棧實例的類別。例如,你可以在代碼1中看到帶有非類型參數的固定大小的堆棧。
代碼1:帶有非類型固定大小的堆棧
template <class elemType, int size> public ref class tStack { array<elemType> ^m_stack; int top;
public: tStack() : top( 0 ) { m_stack = gcnew array<elemType>( size ); } }; |
此外,如果模板類設計者可以為每個參數指定默認值,使用起來就可能方便多了。例如,把緩沖區的默認大小設置為1KB就是很好的。在模板機制下,可以給參數提供默認值,如下所示:
// 帶有默認值的模板聲明 template <class elemType, int size = 1024> public ref class FixedSizeStack {}; |
用戶可以通過提供明確的第二個值來重載默認大小值:
// 最多128個字符串實例的堆棧 FixedSizeState<String^, 128> ^tbs = gcnew FixedSizeStack<String^, 128>; |
否則,由于沒有提供第二個參數,它使用了相關的默認值,如下所示:
// 最多1024個字符串實例的堆棧 FixedSizeStack<String^> ^tbs = gcnew FixedSizeStack<String^>; |
使用默認的參數值是標準模板庫(STL)的一個基本的設計特征。例如,下面的聲明就來自ISO-C++標準:
// ISO-C++名字空間std中的默認類型參數值示例 { template <class T, class Container = deque<T> > class queue;
template <class T, class Allocator = allocator<T> > class vector; // ... } |
你可以提供默認的元素類型,如下所示:
// 帶有默認的元素類型的模板聲明 template <class elemType=String^, int size=1024> public ref class tStack {}; |
從設計的角度來說很難證明它的正確性,因為一般來說容器不會集中在在單個默認類型上。
指針也可以作為非類型參數,因為對象或函數的地址在編譯時就已知了,因此是一個常量表達式。例如,你可能希望為堆棧類提供第三個參數,這個參數指明遇到
特定條件的時候使用的回調處理程序。明智地使用typedef可以大幅度簡化那些表面上看起來很復雜的聲明,如下所示:
typedef void (*handler)( ... array<Object^>^ ); template <class elemType, int size, handler cback > public ref class tStack {}; |
當然,你可以為處理程序提供默認值--在這個例子中,是一個已有的方法的地址。例如,下面的緩沖區聲明就提供了大小和處理程序:
void defaultHandler( ... array<Object^>^ ){ ... }
template < class elemType, int size = 1024, handler cback = &defaultHandler > public ref class tStack {}; |
由于默認值的位置次序優先于命名次序,因此如果不提供明確的大小值(即使這個大小與默認值是重復的),就無法提供重載的處理程序的。下面就是可能用到的修改堆棧的方法:
void demonstration() { // 默認的大小和處理程序 tStack<String^> ^ts1 = nullptr; // 默認的處理程序 tStack<String^, 128> ^ts2 = gcnew tStack<String^, 128>; // 重載所有的三個參數 tStack<String^, 512, &yourHandler> ^ts3; }
|
模板支持的第二種額外的參數就是template模板參數--也就是這個模板參數本身表現為一個模板。例如:
// template模板參數 template <template <class T> class arena, class arenaType> class Editor { arena<arenaType> m_arena; // ... }; |
Editor模板類列出了兩個模板參數arena和arenaType。ArenaType是一個模板類型參數;你可以傳遞整型、字符串型、自定義類型
等等。Arena是一個template模板參數。帶有單個模板類型參數的任何模板類都可以綁定到arena。m_arena是一個綁定到
arenaType模板類型參數的模板類實例。例如:
// 模板緩沖區類
template <class elemType>
public ref class tBuffer {};
void f()
{
Editor<tBuffer,String^> ^textEditor;
Editor<tBuffer,char> ^blitEditor;
// ...
}
類型參數約束 如果你把參數化類型簡單地作為存儲和檢索元素的容器,那么你可以略過這一部分了。當你需要調用某個類型參數(例如在比較兩個對象,查看它們相等或者其中一個小于另一個的時候,或者通過類型參數調用方法名稱或嵌套類型的時候)上的操作的時候,才會考慮約束的問題。例如:
template <class T> ref class Demonstration { int method() { typename T::A *aObj; // ... } }; |
這段代碼成功地聲明了aObj,它同時還約束了能夠成功地綁定到你的類模板的類型參數。例如,如果你編寫下面的代碼,aObj的聲明就是非法的(在這種特定的情況下),編譯器會報錯誤信息:
int demoMethod() { Demonstration<int> ^demi = gcnew Demonstration<int>( 1024 ); return dm->method(); } |
當然,其特定的約束是,這個類型參數必須包含一個叫做A的類型的嵌套聲明。如果它的名字叫做B、C或Z都沒有關系。更普通的約束是類型參數必須表示一個
類,否則就不允許使用T::范圍操作符。我使用int類型參數同時違反了這兩條約束。例如,Visual C++編譯器會生成下面的錯誤信息:
error C2825: 'T': must be a class or namespace when followed by '::' |
C++模板機制受到的一條批評意見是:缺乏用于描述這種類型約束的形式語法(請注意,在參數化類型的原始設計圖紙中,Bjarne
Stroustrup論述了曾經考慮過提供顯式約束語法,但是他對這種語法不太滿意,并選擇了在那個時候不提供這種機制)。也就是說,在一般情況下,用戶
在閱讀源代碼或相關的文檔,或者編譯自己的代碼并閱讀隨后的編譯器錯誤消息的時候,才能意識到模板有隱含約束。
如果你必須提供一個與模板不匹配的類型參數該怎么辦呢?一方面,我們能做的事情很少。你編寫的任何類都有一定的假設,這些假設表現為某些使用方面的約束。很難設計出適合每種情況的類;設計出適合每種情況和每種可能的類型參數的模板類更加困難。
另一方面,存在大量的模板特性為用戶提供了"迂回"空間。例如,類模板成員函數不會綁定到類型參數,直到在代碼中使用該函數為止(這個時候才綁定)。因此,如果你使用模板類的時候,沒有使用那些使類型參數失效的方法,就不會遇到問題。
如果這樣也不可行,那么還可以提供該方法的一個專門的版本,讓它與你的類型參數關聯。在這種情況下,你需要提供Demonstration<
int>::方法的一個專用的實例,或者,更為普遍的情況是,在提供整數類型參數的時候,提供整個模板類的專門的實現方式。
一般來說,當你提到參數化類型可以支持多種類型的時候,你一般談到的是參數化的被動使用--也就是說,主要是類型的存儲和檢索,而不是積極地操作(處理)它。
作為模板的設計人員,你必須知道自己的實現對類型參數的隱含約束條件,并且努力去確保這些條件不是多余的。例如,要求類型參數提供等于和小于操作是合理
的;但是要求它支持小于或等于或XOR位運算符就不太合理了。你可以通過把這些操作分解到不同的接口中,或者要求額外的、表示函數、委托或函數對象的參數
來放松對操作符的依賴性。例如,代碼2顯示了一個本地C++程序員使用內建的等于操作符實現的搜索方法。
代碼2:不利于模板的搜索實現
template <class elemType, int size=1024> ref class Container { array<elemType> ^m_buf; int next;
public: bool search( elemType et ) { for each ( elemType e in m_buf ) if ( et == e ) return true; return false; }
Container() { m_buf = gcnew array<elemType>(size); next = 0; }
void add( elemType et ) { if ( next >= size ) throw gcnew Exception; m_buf[ next++ ] = et; }
elemType get( int ix ) { if ( ix < next ) return m_buf[ ix ]; throw gcnew Exception; } // ... }; |
在這個搜索函數中沒有任何錯誤。但是,它不太利于使用模板,因為類型參數與等于操作符緊密耦合了。更為靈活的方案是提供第二個搜索方法,允許用戶傳遞一
個對象來進行比較操作。你可以使用函數成員模板來實現這個功能。函數成員模板提供了一個額外的類型參數。請看一看代碼3。
代碼3:使用模板
template <class elemType, int size=1024> ref class Container { // 其它的都相同 ... // 這是一個函數成員模板... // 它可以同時引用包含的類參數和自有參數...
template <class Comparer> bool search( elemType et, Comparer comp ) { for each ( elemType e in m_buf ) if ( comp( et, e ) ) return true; return false; } // ... }; |
現在用戶可以選擇使用哪一個方法來搜索內容了:緊密耦合的等于操作符搜索效率較高,但是不適合于所有類型;較靈活的成員模板搜索要求傳遞用于比較的類型。
哪些對象適用這種比較目的?函數對象就是普通的用于這種目的的C++設計模式。例如,下面就是一個比較兩個字符串是否相等的函數對象:
class EqualGuy { public: bool operator()( String^ s1, String^ s2 ) { return s1->CompareTo( s2 ) == 0; } }; |
代碼4中的代碼顯示了你如何調用這兩個版本的搜索成員函數模板和傳統的版本。
代碼4:兩個搜索函數
int main() { Container<String^> ^sxc = gcnew Container<String^>; sxc->add( "Pooh" ); sxc->add( "Piglet" );
// 成員模板搜索 ... if ( sxc->search( "Pooh", EqualGuy() ) ) Console::WriteLine( "found" ); else Console::WriteLine( "not found" );
// 傳統的等于搜索 ... if ( sxc->search( "Pooh" ) ) Console::WriteLine( "found" ); else Console::WriteLine( "not found" ); } |
一旦有了模板的概念,你就會發現使用模板幾乎沒有什么事情不是實現。至少感覺是這樣的。
泛型約束 與模板不同,泛型定義支持形式約束語法,這些語法用于描述可以合法地綁定的類型參數。在我詳細介紹約束功能之前,我們簡短地考慮一下為什么泛型選擇了提供約束功能,而模板選擇了不提供這個功能。我相信,最主要的原因是兩種機制的綁定時間之間差異。
模板在編譯的過程中綁定,因此無效的類型會讓程序停止編譯。用戶必須立即解決這個問題或者把它重新處理成非模板編程方案。執行程序的完整性不存在風險。
另一方面,泛型在運行時綁定,在這個時候才發現用戶指定的類型無效就已經太遲了。因此通用語言結構(CLI)需要一些靜態(也就是編譯時)機制來確保在運行時只會綁定有效的類型。與泛型相關的約束列表是編譯時過濾器,也就是說,如果違反的時候,會阻止程序的建立。
我們來看一個例子。圖5顯示了用泛型實現的容器類。它的搜索方法假設類型參數衍生自Icomparable,因此它實現了該接口的CompareTo方
法的一個實例。請注意,容器的大小是在構造函數中由用戶提供的,而不是作為第二個、非類型參數提供的。你應該記得泛型不支持非類型參數的。
代碼5:作為泛型實現的容器
generic <class elemType> public ref class Container { array<elemType> ^m_buf; int next; int size; public: bool search( elemType et ) { for each ( elemType e in m_buf ) if ( et->CompareTo( e )) return true; return false; }
Container( int sz ) { m_buf = gcnew array<elemType>(size = sz); next = 0; }
// add() 和 get() 是相同的 ...
}; |
該泛型類的實現在編譯的時候失敗了,遇到了如下所示的致命的編譯診斷信息:
error C2039: 'CompareTo' : is not a member of 'System::Object' |
你也許有點糊涂了,這是怎么回事?沒有人認為它是System::Object的成員啊。但是,在這種情況下你就錯了。在默認情況下,泛型參數執行最嚴
格的可能的約束:它把自己的所有類型約束為Object類型。這個約束條件是對的,因為只允許CLI類型綁定到泛型上,當然,所有的CLI類型都多多少少
地衍生自Object。因此在默認情況下,作為泛型的作者,你的操作非常安全,但是可以使用的操作也是有限的。
你可能會想,好吧,我減小靈活性,避免編譯器錯誤,用等于操作符代替CompareTo方法,但是它卻引起了更嚴重的錯誤:
error C2676: binary '==' : 'elemType' does not define this operator or a conversion to a type acceptable to the predefined operator |
同樣,發生的情況是,每個類型參數開始的時候都被Object的四個公共的方法包圍著:ToString、GetType、GetHashCode和
Equals。其效果是,這種在單獨的類型參數上列出約束條件的工作表現了對初始的強硬約束條件的逐步放松。換句話說,作為泛型的作者,你的任務是按照泛
型約束列表的約定,采用可以驗證的方式來擴展那些允許的操作。我們來看看如何實現這樣的事務。
我們用約束子句來引用約束列表,使用非
保留字"where"實現。它被放置在參數列表和類型聲明之間。實際的約束包含一個或多個接口類型和/或一個類類型的名稱。這些約束顯示了參數類型希望實
現的或者衍生出類型參數的基類。每種類型的公共操作集合都被添加到可用的操作中,供類型參數使用。因此,為了讓你的elemType參數調用
CompareTo,你必須添加與Icomparable接口關聯的約束子句,如下所示:
generic <class elemType> where elemType : IComparable public ref class Container { // 類的主體沒有改變 ... }; |
這個約束子句擴展了允許elemType實例調用的操作集合,它是隱含的Object約束和顯式的Icomparable約束的公共操作的結合體。該泛
型定義現在可以編譯和使用了。當你指定一個實際的類型參數的時候(如下面的代碼所示),編譯器將驗證實際的類型參數是否與將要綁定的類型參數的約束相匹
配:
int main() { // 正確的:String和int實現了IComparable Container<String^> ^sc; Container<int> ^ic;
//錯誤的:StringBuilder沒有實現IComparable Container<StringBuilder^> ^sbc; } |
編譯器會提示某些違反了規則的信息,例如sbc的定義。但是泛型的實際的綁定和構造已經由運行時完成了。
接著,它會同時驗證泛型在定義點(編譯器處理你的實現的時候)和構造點(編譯器根據相關的約束條件檢查類型參數的時候)是否違反了約束。無論在那個點失敗都會出現編譯時錯誤。
約束子句可以每個類型參數包含一個條目。條目的次序不一定跟參數列表的次序相同。某個參數的多個約束需要使用逗號分開。約束在與每個參數相關的列表中必須唯一,但是可以出現在多個約束列表中。例如:
generic <class T1, class T2, class T3> where T1 : IComparable, ICloneable, Image where T2 : IComparable, ICloneable, Image where T3 : ISerializable, CompositeImage public ref class Compositor { // ... }; |
在上面的例子中,出現了三個約束子句,同時指定了接口類型和一個類類型(在每個列表的末尾)。這些約束是有額外的意義的,即類型參數必須符合所有列出的
約束,而不是符合它的某個子集。我的同事Jon
Wray指出,由于你是作為泛型的作者來擴展操作集合的,因此如果放松了約束條件,那么該泛型的用戶在選擇類型參數的時候就得增加更多的約束。
T1、T2和T3子句可以按照其它的次序放置。但是,不允許跨越兩個或多個子句指定某個類型參數的約束列表。例如,下面的代碼就會出現違反語法錯誤:
generic <class T1, class T2, class T3> // 錯誤的:同一個參數不允許有兩個條目 where T1 : IComparable, ICloneable where T1 : Image public ref class Compositor { // ... }; |
類約束類型必須是未密封的(unsealed)參考類(數值類和密封類都是不允許的,因為它們不允許繼承)。有四個System名字空間類是禁止出現在
約束子句中的,它們分別是:System::Array、System::Delegate、
System::Enum和System::ValueType。由于CLI只支持單繼承(single
inheritance),約束子句只支持一個類類型的包含。約束類型至少要像泛型或函數那樣容易訪問。例如,你不能聲明一個公共泛型并列出一個或多個內
部可視的約束。
任何類型參數都可以綁定到一個約束類型。下面是一個簡單的例子:
generic <class T1, class T2> where T1 : IComparable<T1> where T2 : IComparable<T2> public ref class Compositor { // ... }; |
約束是不能繼承的。例如,如果我從Compositor繼承得到下面的類,Compositor的T1和T2上的Icomparable約束不會應用在 BlackWhite_Compositor類的同名參數上:
generic <class T1, class T2> public ref class BlackWhite_Compositor : Compositor { // ... }; |
當這些參數與基類一起使用的時候,這就有幾分設計方面的便利了。為了保證Compositor的完整性,BlackWhite_Compositor必須把Compositor約束傳播給那些傳遞到Compositor子對象的所有參數。例如,正確的聲明如下所示:
generic <class T1, class T2> where T1 : IComparable<T1> where T2 : IComparable<T2> public ref class BlackWhite_Compositor : Compositor { // ... }; |
包裝 你已經看到了,在C++/CLI下面,你可以選擇CLR泛型或C++模板。現在你所擁有的知識已經可以根據特定的需求作出明智的選擇了。在兩種機制下,超越元素的存儲和檢索功能的參數化類型都包含了每種類型參數必須支持操作的假設。
使用模板的時候,這些假設都是隱含的。這給模板的作者帶來了很大的好處,他們對于能夠實現什么樣的功能有很大的自由度。但是,這對于模板的使用者是不利
的,他們經常面對某些可能的類型參數上的沒有正式文檔記載的約束集合。違反這些約束集合就會導致編譯時錯誤,因此它對于運行時的完整性不是威脅,模板類的
使用可以阻止失敗出現。這種機制的設計偏好傾向于實現者。
使用泛型的時候,這些假設都被明顯化了,并與約束子句中列舉的基本類型
集合相關聯。這對泛型的使用者是有利的,并且保證傳遞給運行時用于類型構造的任何泛型都是正確的。據我看來,它在設計的自由度上有一些約束,并且使某些模
板設計習慣稍微難以受到支持。對這些形式約束的違反,無論使在定義點還是在用戶指定類型參數的時候,都會導致編譯時錯誤。這種機制的設計偏好傾向于消費
者。