C++/CLR泛型与C++模板之间的对比[摘自编程乐园]
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++模板。现在你所拥有的知识已经可以根据特定的需求作出明智的选择了。在两种机制下,超越元素的存储和检索功能的参数化类型都包含了每种类型参数必须支持操作的假设。
使用模板的时候,这些假设都是隐含的。这给模板的作者带来了很大的好处,他们对于能够实现什么样的功能有很大的自由度。但是,这对于模板的使用者是不利的,他们经常面对某些可能的类型参数上的没有正式文档记载的约束集合。违反这些约束集合就会导致编译时错误,因此它对于运行时的完整性不是威胁,模板类的使用可以阻止失败出现。这种机制的设计偏好倾向于实现者。
使用泛型的时候,这些假设都被明显化了,并与约束子句中列举的基本类型集合相关联。这对泛型的使用者是有利的,并且保证传递给运行时用于类型构造的任何泛型都是正确的。据我看来,它在设计的自由度上有一些约束,并且使某些模板设计习惯稍微难以受到支持。对这些形式约束的违反,无论使在定义点还是在用户指定类型参数的时候,都会导致编译时错误。这种机制的设计偏好倾向于消费者。
从设计的角度来说很难证明它的正确性,因为一般来说容器不会集中在在单个默认类型上。
指针也可以作为非类型参数,因为对象或函数的地址在编译时就已知了,因此是一个常量表达式。例如,你可能希望为堆栈类提供第三个参数,这个参数指明遇到特定条件的时候使用的回调处理程序。明智地使用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;
// ...
}