GP技术的展望——先有鸿钧后有天
莫华枫
自从高级语言出现以来,类型始终是语言的核心。几乎所有语言特性都要以类型作为先决条件。类型犹如天地,先于万物而存在。但是,是否还有什么东西比类型更加原始,更加本质,而先于它存在呢?请往下看。:)
泛型和类型
泛型最简短最直观的描述恐怕应该是:the class of
type。尽管这样的描述不算最完备,但也足以说明问题。早在60年代,泛型的概念便已经出现。最初以“参数化类型”的名义存在。70年代末期发展起来的
恐龙级的Ada(我的意思不是说
Augusta Ada Byron Lovelace伯爵夫人是恐龙,从画像上看,这位程序员的祖师奶长得相当漂亮:)),尚未拥有oop(Ada83),便已经实现了泛型(Generic)。尽管泛型历史悠久,但真正全面地发展起来,还是在90年代初,
天才的
Alexander A. Stepanov创建了stl,促使了“
泛型编程”(
Generic Programming)的确立。
出于简便的目的,我套用一个老掉牙的“通用容器”来解释泛型的概念。(就算我敷衍吧:P,毕竟重头戏在后面,具体的请看前面给出的链接)。假设我在编程时需要一个int类型的栈,于是我做了一个类实现这个栈:
class IntStack {...};
用的很好。过了两天,我又需要一个栈,但是类型变成了double。于是,我再另写一个:
class DoubleStack {...};
不得了,好象是通了马蜂窝,不断地出现了各种类型的栈的需求,有string的,有datetime的,有point的,甚至还有一个Dialog的。每
种类型都得写一个类,而且每次代码几乎一样,只是所涉及的类型不同而已。于是,我就热切地期望出现一种东西,它只是一个代码的框架,实现了stack的所
有功能,只是把类型空着。等哪天我需要了,把新的类型填进去,便得到一个新的stack类。
这便是泛型。
但是,仅仅这些,还不足以成就GP的威名。
我有一个古怪的需求(呵呵,继续敷衍。:)):
做一个模板,内部有一个vector<>成员:
template<typename T> A
{
...
vector<T> m_x;
};
可是,如果类型实参是int类型的话,就得用set<>。为了使用的方便,模板名还得是A。于是,我们就得使用下面的技巧:
template<> A<int>
{
...
set<T> m_x;
};
这叫特化(specialization),相当于告诉编译器如果类型实参是int,用后面那个。否则,用前面的。特化实际上就是根据类型实参由编译器执行模板的选择。换句话说,特化是一种编译期分派技术。
这里还有另一个更古怪需求:如果类型实参是指针的话,就用list<>。这就得用到另一种特化了:
template<typename T> A<T*>
{
...
list<T> m_x;
}
这是局部特化(partial specialization),而前面的那种叫做显式特化(explicit specialization),也叫全特化。局部特化则是根据类型实参的特征(或者分类)执行的模板选择。
最后,还有一个最古怪的需求:如果类型实参拥有形如void func(int a)成员函数的类型,那么就使用deque。这个...,有点难。现有的C++编译器,是无法满足这个要求的。不过希望还是有的,在未来的新版C++09中,我们便可以解决这个问题。
Concept和类型
concept是GP发展必然结果。正如前面所提到的需求,我们有时候会需要编译器能够鉴识出类型的某些特征,比如拥有特定的成员等等,然后执行某种操作。下面是一个最常用的例子:
swap()是一个非常有用的函数模板,它可以交换两个对象的内容,这是
swap手法的基础。swap()的基本定义差不多是这样:
template<typename T> swap(T& lhs, T& rhs) {
T tmp(lhs);
lhs=rhs;
rhs=tmp;
}
但是,如果需要交换的对象是容器之类的大型对象,那么这个swap()的性能会很差。因为它执行了三次复制,这往往是O(n)的。标准容器都提供了一个
swap成员函数,通过交换容器内指向数据缓冲的指针,获得O(1)的性能。因此,swap()成员是首选使用的。但是,这就需要程序员识别对象是否存在
swap成员,然后加以调用。如果swap()函数能够自动识别对象是否存在swap成员,那么就可以方便很多。如果有swap成员,就调用成员,否则,
就是用上述通过中间变量交换的版本。
这就需要用到concept技术了:
template<Swappable T> void swap(T& lhs, T& rhs) {
lhs.swap(rhs);
}
这里,Swappable是一个concept:
concept Swappable<typename T> {
void T::swap(T&);
}
于是,如果遇到拥有swap成员函数的对象,正好符合Swappable concept,编译器可以使用第二个版本,在O(1)复杂度内完成交换。否则,便使用前一个版本:
vector a, b;
... //初始化a和b
swap(a,b); //使用后一个版本
int c=10, d=23;
swap(c, d); //使用前一个版本
这里的swap()也是运用了特化,所不同的是在concept的指导下进行的。这样的特化有时也被称作concept based overload。
从上面的例子中可以看到,原先的特化,无论是全特化,还是局部特化,要么特化一个类型,要么特化一个大类(如指针)的类型。但无法做到更加精细。比如,我
希望一个模板能够针对所有的整数(int,long,short,char等)进行特化,这在原先是无法做到的。但拥有了concept之后,我们便可以
定义一个代表所有整数的concept,然后使用这个整数concept执行特化。换句话说,concept使得特化更加精细了,整个泛型系统从原来“离
散”的变成了“连续”的。
不过上面那个concept特化的模板看起来实在不算好看,头上那一坨template...实在有碍观瞻。既然是concept based overload,那么何不直接使用重载的形式,而不必再带上累赘的template<...>:
void fun(anytype a){...} //#1,anytype是伪造的关键字,表示所有类型。这东西最好少用。
void fun(Integers a){...} //#2,Integers是concept,表示所有整型类型
void fun(Floats a){...} //#3,Floats是concept,表示所有浮点类型
void fun(long a){...} //#4
void fun(int a){...} //#5
void fun(double a){...} //#6
...
int x=1;
long y=10;
short z=7;
string s="aaa";
float t=23.4;
fun(x); //选择#5
fun(y); //选择#4
fun(z); //选择#2
fun(s); //选择#1
fun(t); //选择#3
这种形式在语义上与原来的模板形式几乎一样。注意,是几乎。如下的情形是重载形式无法做到的:
template<Integers T> T swap(T lhs, T rhs) {
T temp(lhs);
...
}
这里,模板做到了两件事:其一,模板萃取出类型T,在函数体中,可以使用T执行一些操作,比如上述代码中的临时对象temp的构造。这个问题容易解决,因为萃取类型T还有其他的方法,一个typeof()操作符便可实现:
Integers swap(Integers lhs, Integers rhs) {
typeof(lhs) temp(lhs);
...
}
其二,模板保证了lhs,rhs和返回值都是同一类型。这个问题,可以通过施加在函数上的concept约束解决:
Integers swap(Integers lhs, Integers rhs)
requires SameType<lhs, rhs>
&& SameType<lhs, retval> { //retval是杜撰的关键字,用以表示返回值
typeof(lhs) temp(lhs);
...
}
相比之下,重载形式比较繁琐。总体而言,尽管重载形式冗长一些,但含义更加明确,更加直观。并且在concept的接口功能作用下,对参数类型一致的要求
通常并不多见(一般在基本类型,如整型等,的运算处理中较多见。因为这些操作要求类型有特定的长度,以免溢出。其他类型,特别是用户定义类型,通常由于封
装的作用,不会对类型的内部特性有过多要求,否则就不应使用泛型算法)。如果可以改变语法的话,那么就能用诸如@代替typeof,==代替
SameType的方法减少代码量:
Integers swap(Integers lhs, Integers rhs)
requires @lhs == @rhs && @lhs == @retval {
@lhs temp(lhs);
...
}
Concept、类型和对象
事情还可以有更加夸张的发展。前面对泛型进行了特化,能不能对类型也来一番“特化”呢?当然可以:
void fun(int a);
void fun(int a:a==0); //对于类型int而言,a==0便是“特化”了
更完整的,也可以有“局部特化”:
void fun(int a); //#1
void fun(int a:a==0); //#2
void fun(int a:a>200); //#3
void fun(int a:a<20&&a>10); //#4
void fun(int a:(a>70&&a<90)||(a<-10)); //#5
...
int a=0, b=15, c=250, d=-50;
fun(80); //使用#5
fun(50); //使用#1
fun(a); //使用#2
fun(b); //使用#4
fun(c); //使用#3
fun(d); //使用#5
实际上,这无非是在参数声明之后加上一组约束条件,用以表明该版本函数的选择条件。没有约束的函数版本在没有任何约束条件匹配的情况下被选择。对于使用立
即数或者静态对象的调用而言,函数的选择在编译期执行,编译器根据条件直接调用匹配的版本。对于变量作为实参的调用而言,则需要展开,编译器将自动生成如
下代码:
//首先将函数重新命名,赋予唯一的名称
void fun_1(int a); //#1
void fun_2(int a); //#2
void fun_3(int a); //#3
void fun_4(int a); //#4
void fun_5(int a); //#5
//然后构造分派函数
void fun_d(int a) {
if(a==0)
fun_2(a);
else if(a>200)
fun_3(a);
...
else
fun_1(a);
}
在某些情况下,可能需要对一个对象的成员做出约束,此时便可以采用这种形式:
struct A
{
float x;
};
...
void fun(A a:a.x>39.7);
...
这种施加在类型上的所谓“特化”实际上只是一种语法糖,只是由编译器自动生成了分派函数而已。这个机制在Haskell等语言中早已存在,并且在使用上带
来很大的灵活性。如果没有这种机制,那么一旦需要增加函数分派条件,那么必须手工修改分派函数。如果这些函数,包括分派函数,是第三方提供的代码,那么修
改将是很麻烦的事。而一旦拥有了这种机制,那么只需添加一个相应的函数重载即可。
当concept-类型重载和类型-对象重载混合在一起时,便体现更大的作用:
void fun(anytype a);
void fun(Integers a);
void fun(Floats a);
void fun(long a);
void fun(int a);
void fun(double a);
void fun(double a:a==0.8);
void fun(short a:a<10);
void fun(string a:a=="abc");
...
concept-类型-对象重载体系遵循一个原则:优先选择匹配的函数中最特化的。这实际上是类型重载规则的扩展。大的来说,所有类型比所属的
concept更加特化,所有对象约束比所属的类型更加特化。对于concept而言,如果concept A refine自concept
B,那么A比B更加特化。同样,如果一个类型的约束强于另一个,那么前一个就比后一个更加特化,比如a==20比a>10更加特化。综合起来,可以
有这样一个抽象的规则:两个约束(concept,或者施加在对象上的约束)A和B,作用在类型或者对象上分别产生集合,如果A产生的集合是B产生的集合
的真子集,那么便认为A比B更加特化。
根据这些规则,实际上可以对一个函数的重载构造出一个“特化树”:
越接近树的根部,越泛化,越接近叶子,越特化。调用时使用的实参便在这棵“特化树”上搜索,找到最匹配的函数版本。
concept-类型-对象体系将泛型、类型和对象统一在一个系统中,使得函数的重载(特化)具有更简单的形式和规则。并且,这个体系同样可以很好地在类模板上使用,简化模板的定义和使用。
类模板
C++的类模板特化形式并不惹人喜爱:
template<typename T> A{...}; //基础模板
template<> A<int>{...}; //显式特化(全特化)
template<typename T> A<T*>{...}; //局部特化
在C++09中,可以直接用concept定义模板的类型形参:
template<Integers T> A{...};
实质上,这种形式本身就是一种局部特化,因而原本那种累赘局部特化形式可以废除,代之以concept风格的形式:
template<Pointer T> A{...}; //Pointer表示此处采用指针特化模板
同样,如果推广到全特化,形式也就进一步简单了:
template<int> A{...}; //这个形式有些突兀,这里只打算表达这个意思,应该有更“和谐”的形式
如果模板参数是对象,则使用现有的定义形式:
template<int a> A{...};
更进一步,可以引入对象的约束:
template<int a:a>10> A{...};
此外,C++中在模板特化之前需要有基础模板。但实际上这是多余的,D语言已经取消了这个限制,这对于简化模板的使用有着莫大的帮助。
从本质上讲...
从本质上讲,我们可以把所有类型看作一个集合T={ti},而concept则是施加在类型集合上的约束。通过concept这个约束,我们便可以获得类
型集合T的一个子集C。理论上,所有concept所对应的类型子集Cj构成了类型集合的幂集{Cj}。在{Cj}中,有两类类型子集是很特殊的。一组是
T本
身,即所有类型。存在一个concept不对T施加任何约束,便得到了C0=T。第二类则是另一个极端,存在一组concept,施加在T上之后所得的类
型子集仅包含一个类型:Ci={ti}。由于这组concept与类型存在一一对应的关系,那么我们便可以用这组concept来指代类型。也就是把类型
作为特殊的concept处理。如此,concept便同类型统一在一个体系中。这种处理可以使我们获得极大的好处。
这组特殊的concept仍旧使用对应的类型名作为称谓,仍旧称之为“类型”,但其本质上还是concept。任何一个类型,一旦创建,也就创建了相应的特殊concept。如果在模板特化中使用一个类型的时候,实际上就是在使用相对应的那个特殊concept:
void func(typeA a); //尽管使用了类型名typeA,但实际上这里所指的是typeA所对应的那个特殊concept。
在这个concept体系的作用下,函数模板的特化和重载整个地统一起来(concept based overload)。
至于作用在类型上的那种“特化”,也是同样的道理。对于一个类型T而言,它所有的对象构成一个集合O。如果存在一组约束作用于O,那么每
一个约束对应着O的一个子集。理论上,我们可以构造出一组约束,使得他们同O的每一个子集一一对应。同样,这些子集中有两类子集比较特殊。一类是所有对象
的集合。另一类便是只有一个对象的子集。于是,我们可以使用这组特殊对象子集所对应的约束指代相应的对象。也就是将对象看作特殊的约束。如此,类型和对象
也被统一在一个系统中了。
进而,类型在逻辑上被作为特殊concept处理,对象则被作为特殊的类型处理。于是,这三者便可以统一在一个体系下,一同参与特化。
总结
尽管形式不能代表本质,但形式的变化往往会带来很多有益的进步。更重要的是,很多本质上的变化总会伴随着形式上的改变。通过将concept、类型和对象
在逻辑上整合到统一的体系之中,便可以促使模板、特化、函数重载等机制在形式上达成统一。从而能够简化这些功能的使用。这也是当前重视语言(工具)易用性
的潮流的一个必然诉求。这个形式上的统一并非语法糖之类的表面变化。而是完全依赖于concept这个新型的类型描述(泛型)系统的确立和发展。
concept的出现,弥补了以往泛型的不足,找回了泛型系统缺失的环节,弥补了泛型同类型之间的裂痕。在此作用下,便可以构建起concept-类型-
对象的抽象体系,用统一的系统囊括这三个原本分立的概念。在这个新的三位一体的系统下,使得模板的特化和重载拥有了相同的形式,进而获得更直观的语义,和
更好的易用性。