TA的每日心情 | 开心 2014-6-18 08:29 |
---|
签到天数: 14 天 [LV.3]偶尔看看II
滴水大师
 
- 积分
- 2345
|
被误解的C++——汉尼拔
by 莫华枫
公元前216年8月2日,意大利东部平原,一个叫做坎尼的地方,两支大军摆开阵势,准备决一死战。一方是由保罗斯和瓦罗两位执政官率领的罗马人,另一方则是伟大的军事天才汉尼拔*巴卡率领的迦太基军队及其同盟。罗马人超过8万,而迦太基仅有4万余人。然而到了傍晚,罗马人被彻底击败,7万人被杀,仅有少数得以逃脱。这就是著名的坎尼会战。经此一役,(外加先前进行的特利比亚和特拉西梅诺湖会战),罗马人元气大伤,成年公民损失达五分之一。部分城邦背叛罗马,西西里也发生起义。罗马已经到了摇摇欲坠的地步。
汉尼拔的这些胜利,完全得益于先前的一次异乎寻常的远征。公元前218年,汉尼拔率领军队,从新迦太基城(西班牙)出发,翻越比利牛斯山,进入南高卢地域。在他面前有两条路可走,翻越阿尔俾斯山,或者沿海岸进入意大利。但是,当时罗马人已在沿海地区部署了两支部队,准备拦截汉尼拔。而且,罗马人的海军优势,使得他们可以在任何时候将一支部队登陆在他的背后。而翻越阿尔俾斯山,则是一条及其艰险的道路,更何况是在冬天。
汉尼拔选择了阿尔俾斯山。他甩开了罗马人,从小圣贝纳德和日内瓦山之间越过阿尔俾斯山,进入意大利境内。此时,罗马人便失去了战略纵深,一把尖刀已经深深地插入他们的腹内...
C++的发展史上,也有着如同汉尼拔翻越阿尔俾斯山远征。一切还得从C with Class时代说起。
Bjarne曾经反复强调,他创建C++为的是将Simular的抽象能力同C的性能结合起来。于是,在C语言的基础上,诞生了一种拥有类、继承、重载等等面向对象机制的语言。在这个阶段,C++提供了两个方面的抽象能力。一种是数据抽象,也就是将数据所要表达的含义通过类型以及依附于类型上的成员表述。另一种则是多态,一种最原始的多态(重载)。
数据抽象,通过被称为“抽象数据类型(ADT)”的技术实现。ADT的一种方案,就是类。类所提供的封装性将一个数据实体的外在特征,或者说语义的表述形式,同具体的实现,比如数据存储形式,分离。这样所增加的中间层将数据的使用者同数据的实现者隔离,使得他们使用共同的约定语义工作,不再相互了解彼此的细节,从而使得两者得以解耦。
多态则是更加基础更加重要的一种特性。多态使得我们得以用同一种符号实现某种确定的语义。多态的精髓在于:以一种形式表达一种语义。在此之前,我们往往被迫使用不同的符号来代表同一种抽象语义,为的是适应强类型系统所施加的约束。比如:
//代码#1
int add_int(int lhs, int rhs);
float add_float(float lhs, float rhs);
很显然,这两个函数表达的语义分别是“把两个int类型值加在一起”和“把两个float类型值加在一起”。这两个语义抽象起来都表达了一个意思:加。
我们在做算术题的时候是不会管被计算的数字是整数还是实数。同样,如果能够在编程的时候,不考虑算术操作对象的类型,只需关心谁和谁进行什么操作,那么会方便得多。当C++引入重载后,这种愿望便得以实现:
//代码#2
int add(int lhs, int rhs);
float add(float lhs, float rhs);
重载使得我们只需关心“加”这个语义,至于什么类型和什么类型相加,则由编译器根据操作数的类型自动解析。
从某种意义上说,重载是被长期忽视,但却极为重要的一个语言特性。在多数介绍OOP的书籍中,重载往往被作为OOP的附属品,放在一些不起眼的地方。它的多态本质也被动多态的人造光环所设遮蔽。然而,重载的重要作用却在实践中潜移默化地体现出来。重载差不多可以看作语言迈入现代抽象体系的第一步。它的实际效用甚至要超过被广为关注的OOP,而不会像OOP那样在获得抽象的同时,伴随着不小的副作用。
随着虚函数的引入,C++开始具备了颇具争议的动多态技术。虚函数是一种依附于类(OOP的类型基础)的多态技术。其技术基础是后期绑定(late-binding)。当一个类D继承自类B时,它有两种方法覆盖(override)B上的某个函数:
//代码#3
class B
{
public:
void fun1();
virtual void fun2();
};
class D:public B
{
public:
void fun1();
void fun2();
};
当继承类D中声明了同基类B中成员函数相同函数名、相同签名的成员函数,那么基类的成员函数将被覆盖。对于基类的非虚成员函数,继承类会直接将其遮蔽。对于类型D的使用者,fun1代表了D中所赋予的语义。而类型D的实例,可以隐式地转换成类型B的引用b,此时调用b的fun1,则执行的是类B的fun1 定义,而非类D的fun1,尽管此时b实际指向一个D的实例。
但是,如果继承类覆盖了基类的虚函数,那么将得到相反的结果:当调用引用b的fun2,实际上却是调用了D的fun2定义。这表明,覆盖一个虚函数,将会在继承类和基类之间的所有层次上执行覆盖。这种彻底的、全方位的覆盖行为,使得我们可以在继承类上修饰或扩展基类的功能或行为。这便是OOP扩展机制的基础。而这种技术被称为动多态,意思是基类引用所表达的语义并非取决于基类本身,而是来源于它所指向的实际对象,因此它是“多态”的。因为一个引用所指向的对象可以在运行时变换,所以它是“动”的。
随着动多态而来的一个“副产品”,却事实上成为了OOP的核心和支柱。虚函数的“动多态”特性将我们引向一个极端的情况:一个都是虚函数的类。更重要的,这个类上的虚函数都没有实现,每个虚函数都未曾指向一个实实在在的函数体。当然,这样的类是无法直接使用的。有趣的是,这种被称为“抽象基类”的类,迫使我们继承它,并“替它”实现那些没有实现的虚函数。这样,对于一个抽象基类的引用,多态地拥有了继承类的行为。而反过来,抽象基类实际上起到了强迫继承类实现某些特定功能的作用。因此,抽象基类扮演了接口的角色。接口具有两重作用:一、约束继承类(实现者)迫使其实现预定的成员函数(功能和行为);二、描述了继承类必定拥有的成员函数(功能和行为)。这两种作用促使接口成为了OOP设计体系的支柱。
C++在这方面的进步,使其成为一个真正意义上具备现代抽象能力的语言。然而,这种进步并非“翻越阿尔俾斯山”。充其量也只能算作“翻越比利牛斯山”。对于C++而言,真正艰苦的远征才刚开始,那令人生畏的“阿尔俾斯山”仍在遥远的前方。
同汉尼拔一样,当C++一脚迈入“现代抽象语言俱乐部”后,便面临两种选择。或者在原有基础上修修补补,成为一种OOP语言;或者继续前进,翻越那座险峻的山峰。C++的汉尼拔——Bjarne Stroustrup——选择了后者。
从D&E的描述中我们可以看到,在C++的原始设计中就已经考虑“类型参数”的问题。但直到90年代初,才真正意义上地实现了模板。然而,模板只是第一步。诸如Ada等语言中都有类似的机制(泛型,generic),但并未对当时的编程技术产生根本性的影响。
关键性的成果来源于Alex Stepanov的贡献。Stepanov在后来被称为stl的算法-容器库上所做的努力,使得一种新兴的编程技术——泛型编程(Generic Programming,GP)——进入了人们的视野。stl的产生对C++的模板机制产生了极其重要的影响,促使了模板特化的诞生。模板特化表面上是模板的辅助特性,但是实际上它却是比“类型参数”更加本质的机能。
假设我们有一组函数执行比较两个对象大小的操作:
//代码#4
int compare(int lhs, int rhs);
int compare(float lhs, float rhs);
int compare(string lhs, string rhs);
重载使得我们可以仅用compare一个函数名执行不同类型的比较操作。但是这些函数具有一样的实现代码。模板的引入,使得我们可以消除这种重复代码:
//代码#5
template<typename T> int compare(T lhs, T rhs) {
if(lhs==rhs)
return 0;
if(lhs>rhs)
return 1;
if(lhs<rhs)
return -1;
}
这样一个模板可以应用于任何类型,不但用一个符号表达了一个语义,而且用一个实现代替了诸多重复代码。这便是GP的基本作用。
接下来的变化,可以算作真正意义上的“登山”了。
如果有两个指针,分别指向两个相同类型的对象。此时如果我们采用上述compare函数模板,那么将无法得到所需的结果。因为此时比较的是两个指针的值,而不是所指向的对象本身。为了应付这种特殊情况,我们需要对compare做“特别处理”:
//代码#6
template<typename T> int compare(T* lhs, T* rhs) {
if(*lhs==*rhs)
return 0;
if(*lhs>*rhs)
return 1;
if(*lhs<*rhs)
return -1;
}
这个“特殊版本”的compare,对于任何类型的指针作出响应。如果调用时的实参是一个指针,那么这个“指针版”的compare将会得到优先匹配。如果我们将compare改成下面的实现,那么就会出现非常有趣的行为:
//代码#7
template<typename T>
struct comp_impl
{
int operator()(T lhs, T rhs) {
if(lhs==rhs)
return 0;
if(lhs>rhs)
return 1;
if(lhs<rhs)
return -1;
}
};
template<typename T>
struct comp_impl<T*>
{
int operator()(T* lhs, T* rhs) {
comp_impl<T>()(*lhs, *rhs);
}
};
template<typename T> int compare(T* lhs, T* rhs) {
comp_impl<T>()(*lhs, *rhs);
}
当我们将指针的指针作为实参,调用compare时,神奇的事情发生了:
//代码#8
double **x, **y;
compare(x, y);
compare居然成功地剥离了两个指针,并且正确地比较了两个对象的值。这个戏法充分利用了类模板的局部特化和特化解析规则。根据规则,越是特化的模板,越是优先匹配。T*版的comp_impl比T版的更加“特化”,会得到优先匹配。那么当一个指针的指针实例化comp_impl,则会匹配T*版的 comp_impl,因为指针的指针,也是指针。T*版通过局部特化机制,剥离掉一级指针,然后用所得的类型实例化comp_impl。指针的指针剥离掉一级指针,那么还是一个指针,又会匹配T*版。T*版又会剥离掉一级指针,剩下的就是真正可以比较的类型——double。此时,double已无法与 T*版本匹配,只能匹配基础模板,执行真正的比较操作。
这种奇妙的手法是蕴含在模板特化中一些更加本质的机制的结果。这种意外获得的“模板衍生产品”可以算作一种编译时计算的能力,后来被一些“好事者”发展成独立的“模板元编程”(Template Meta Programming,TMP)。
尽管TMP新奇而又奥妙,但终究只是一种辅助技术,用来弥补C++的一些缺陷、做一些扩展,“捡个漏”什么的。不过它为我们带来了两点重要的启示:一、我们有可能通过语言本身的一些机制,进行元编程;二、元编程在一定程度上可以同通用语言一起使用。这些启示对编程语言的发展有很好的指导意义。
模板及特化规则是C++ GP的核心所在。这些语言特性的强大能力并非凭空而来。实际上有一只“幕后大手”在冥冥之中操纵着一切。
假设有一个类型系统,包含n个类型:t1,...,tn,那么这些类型构成了一个集合T={t1,...,tn}。在当我们运用重载技术时,实际上构造了一组类型的tuple到函数实现的映射:<ti1,ti2,ti3,...> ->fj()。编译器在重载解析的时候,就是按照这组映射寻找匹配的函数版本。当我们编写了形如代码#5的模板,那么就相当于构建了映射:<T, T,...> ->f0()。
而代码#6,以及代码#7中的T*版模板,实际上是构造了一个<Tp>->fp()的映射。这里Tp是T的一个子集:Tp={t'|t'=ti*, ti∈T}。换句话说,特化使泛型体系细化了。利用模板特化技术,我们已经能够(笨拙地)分辨浮点数、整数、内置类型、内置数组、类、枚举等等类型。具备为类型划分的能力,也就是构造不同的类型子集的能力。
现在,我们便可以构造一个“泛型体系”:G={T} U T U Tp U Ta U Ti U Tf U Tc ...。其中,Tp是所有指针类型,Ta是数组,Ti是整数,Tf是浮点数,Tc是类等等。但是如果我们按照泛化程度,把G中的元素排列开:{T, Tp, Ta, Ti,...,t1,...,tn}。我们会发现这中间存在一些“断层”。这些断层位于T和Tp等之间,以及Tp等与ti等之间等等。这表明在C++98/03中,抽象体系不够完整,存在缺陷。
|
|