编程是艺术,这无可否认。不信的去看看高大爷的书就明白了。艺术对于我们这些成天挤压脑浆的程序员而言,是一味滋补的良药。所以,在这个系列中,每一篇我打算以艺术的形式开头。啊?什么形式?当然是最综合的艺术形式。好吧好吧,就是歌剧。当然,我没办法在一篇技术文章的开头演出一整部歌剧,所以决定用一段咏叹调来作为开始。而且,还会尽量使咏叹调同文章有那么一点关联,不管这关联是不是牵强。
求泛型给我安慰
“求爱神快给我安慰,
别让我再悲伤流泪!
让我丈夫回转身旁,
或者让我死亡!”
这是W. A. Mozart著名歌剧《费加罗的婚礼》第二幕开头,伯爵夫人罗西娜的一段摇唱。太美了,就是前奏长了点。《费加罗的婚礼》取材于法国戏剧家博马舍创作的“费加罗三部曲”的第二部。伯爵阿尔马维瓦在《塞维利亚理发师》(三部曲第一部,由罗西尼创作成歌剧,晚于《费加罗的婚礼》)中,苦苦追求罗西娜,在费加罗的帮助下,终成眷属。但阿尔马维瓦终于显露出他的喜新厌旧、朝三暮四的本性。成为伯爵夫人的罗西娜受到冷落,郁郁寡欢。在自己的卧房中,忧伤地唱起了这首咏叹调…
我们知道,程序员的朝三暮四,虽说不一定都是本性,但也几乎成了一种习惯。每当出现一种新的语言,很多程序员也不顾一切地投怀送抱。不管这种语言是好是坏,进步还是退步。尽管我们无法指责这种行为,就像无法把阿尔马维瓦伯爵告上法庭。但是,事实的真相还是应当昭示与天下的。
牢骚就不再多发了。今天的主题是GP。不,不是电池,也不是摩托比赛。全称是Generic Programming,泛型编程。一种非常强大,却有为人们所忽略的关键性技术。这种技术代表的是未来。
不,不要提Java、C#。他们的那种也叫Generic(泛型)的东西,和真正的GP沾不上什么边。只能算作大号的OOP,一会儿就会知道为什么我这么说。
本文起源于TopLanguage(http://groups.google.com/group/pongba)上的一次讨论(http://groups.google.com/group/pongba/browse_thread/thread/e 553a 21476ba2ebd)。我在讨论中做了一个案例,以说明GP的作用。现在我把这个案例整理出来,一同探讨。这个讨论还涉及了更深层次的理论和技术,其余内容看那个帖子。
案例提出这样一个需求:
搞过帐务系统或者学过财务的都应该知道,帐务系统里最核心的是科目。在中国,科目是分级的,(外国人好像没有法定的分级体系),一般有4、5级,多的有7、8级,甚至10多级。不管怎么分,科目的结构是树。
在科目上有一个操作称为"汇总",就是把子科目的金额累加起来,作为本科目的金额。这实际上是对指定科目的所有下级科目的遍历汇总。这是一个非常简单,但却非常重要的帐务操作。
首先,我通过两种方法:OOP和传统的SP来实现这种操作。先来看SP:
//科目类,具备树形结构
class Account
{
public:
typedef vector<Account> child_vect;
pubilc:
child_vect children(); //子级科目
int account_type(); //本科目类型
float ammount(); //本科目的凭证金额累计,非最明细科目返回0
};
//汇总算法
double collect(Account& item) {
double result(0);
Account::child_vect& children=item.children();
if(children.size==0) //最明细科目,没有子科目
return ammount(item.ammount);
Account::child_vect::iterator ib(children.begin()), ie(children.end());
for(; ib!=ie; ++ib)
{
result+=collect(*ib, filter, ammount);
}
return result;
}
当我需要对一个科目对象acc_x执行汇总算法,那么就是这样:
double res=collect(acc_x);
这非常简单。不过请注意,我这里还是利用了OOP的封装机制,为了使Account的实现和接口分离。但所使用的算法/数据分离的模式,则是SP风格的。
OOP风格的更加简单:
class Account
{
public:
child_vect children(); //子级科目
int account_type(); //本科目类型
double ammount() { //此成员直接执行子级科目的汇总任务
double result(0);
if(children.size==0) //最明细科目,没有子科目
return m_ammount; //或从其他途径获得,如数据库访问
T::child_vect& children=item.children();
T::child_vect::iterator ib(children.begin()), ie(children.end());
for(; ib!=ie; ++ib)
{
result+=ib->ammount();
}
return result;
}
};
使用起来是这样:
double res=acc_x.ammount();
都很好。不过,现在项目增加了需求,我们面临挑战:
假设现实世界的古怪客户,使我们面临一个挑战:他们的业务模型中,有部分科目不参与汇总计算,是一群特殊的科目。(这种科目我还真见过)。
那么,SP方式,可以另写一个collect函数:
double collect(Account& item) {
//假设g_SpecialAccounts是个Singleton,负责管理特殊科目
if(g_SpecialAccounts.IsSpecial(item->account_type()))
return 0
… //其余代码与原来的collect相同
}
而OOP方式,则需要修改Account类(也可以利用重载和多态):
class Account
{
public:
double ammount() {
//假设g_SpecialAccounts是个Singleton,负责管理特殊科目
if(g_SpecialAccounts.IsSpecial(account_type())
return 0;
}
… //其余代码与原来的Account相同
};
相比之下,SP更灵活些。如果我没有Account的源码,或者我无法修改Account,那么我可以直接重写一个collect(比如,collect_x)也能解决问题。而在这种情况下,OOP方式,只能重载或重写这个类。(重载不仅仅需要重写相关成员,而且还需要编写诸如构造函数等辅助代码)。更深层次的因素,是代码耦合的问题。关于这个问题请看前面给出的那个讨论。
接下来的一个需求,则提出了更大的挑战:
我们如果注意的话,MIS系统中有很多地方同科目有着相同的逻辑结构。比如,销售部门的分销组织机构,一个企业的部门组织机构。在这些结构上,通常也会发生汇总操作,比如某个省的分销商业绩汇总,或者某个部门的人数汇总。
于是,充满优化意识的程序员,会想到复用在帐务系统上已有的成果。假设我们定义了部门类:
class Department
{
public:
typedef vector<Account> child_vect;
public:
child_vect Children();
int dept_type();
int employee_num();
...
};
对于SP方案而言,意味着需要写一个collect算法,可以同时用于这两个(甚至更多)的类型。我们努力地尝试着:
double collect_g(void* item, bool (*pred)(void*), void (*mem)(void*, void *)) {
if(pred(item))
return 0;
double result(0);
vector<void*>& children=item.children();
if(children.size==0) //最明细科目,没有子科目
{
mem(item, &result);
return result;
}
vector<void*>::iterator ib(children.begin()), ie(children.end());
for(; ib!=ie; ++ib)
{
result+=collect(*ib, pred, mem);
}
return result;
}
此外,需要为Account和Department分别编写两个辅助函数:
//Account
bool Account_Pred(void* item) {
Acount* acc_=(Account*)item;
return g_SpecialAccount.IsSpecial(acc_);
}
void Account_Ammount(void* item, void* val) {
Acount* acc_=(Account*)item;
*((double*)val)=acc_->ammount();
}
//Department
bool Dept_Pred(void* item) {
Department* dpt_=(Department*)item;
return g_SpecialDepartment.IsSpecial(dpt_);
}
void Dept_EmpNum(void* item, void* val) {
Department* dpt_=(Department*)item;
*((int*)val)=dpt_->Employee_Num();
}
用起来,则是这样:
double res1=collect(&acc_x, &Account_Pred, &Account_Ammount);
int res2=collect(&acc_x, &Dept_Pred, &Dept_EmpNum);
对于OOP方案而言,则没有那么简单了。我们尝试着一步步来:
首先,应设法将算法分离。按照标准的OOP思路,用一个接口封装算法:
class ICollect
{
public:
virtual ??? cacul(??? item)=0;
};
class AccountCollect : publie ICollect
{
public:
AccountCollect
virtual ??? cacul(??? item){…};
};
class DepartmentCollect : publie ICollect
{
public:
virtual ??? cacul(??? item){…};
};
但是,我们立刻发现了问题,这些???的地方填什么?由于Account上需要汇总double类型的ammount,而Department上需要汇总人数,是int类型。两种实现的cacul()的类型不同。为解决问题,使用variant。参数item对应Account和Department,是不同的类型。解决的办法,就是使Account和Department(以及其他想共享算法的类型)都实现同一个接口:
class Account : public IData{…};
class Department : public IData {…};
于是,ICollect的接口变成:
class ICollect
{
public:
virtual variant cacul(IData* item)=0;
};
当然,还有一些细节上的问题还没解决,比如,IData中定义那些成员,如何处理类型问题等等。这里限于篇幅,不再深究了。
到目前为止,两种方案中,SP明显比OOP来的简洁灵活。原因还是因为耦合。OOP为了解决不同类型的共享算法问题,不得不通过接口一层一层地将类型归一化。其结果就是形成肥厚的“粘合层”(详见那个讨论)。SP天然地将算法和数据分离,并且通过强制类型转换将类型归一化。(尽管OOP也可以通过强制类型转换归一化类型,但受制于类的集成结构,也无法象SP那样灵活)。但是,强制类型转换所带来的负面作用,是人所共知的。
两种方案,都有共同的一个特点,就是类型归一化。SP方案的collect算法必须将类型归一化为void*,并在计算时转换回来。而OOP方案的ICollect和IData接口也都是为了归一化不同的类型而“强行”添加的。
针对这个问题,我们可以做一个想象(不是白日梦):如果算法不是只接受一种类型,而是可以接受一族类型的实例,而这些类型具备某些共有的特征。那么我们就无需象SP那样强制类型转换,或象OOP那样凭空增加一堆接口。
这就是GP的工作。
下面,我利用GP来改进SP和OOP的实现。首先是SP:
在改进版的SP实现中,Account和Department都没有什么变化,变动的是collect算法:
template<typename T, typename Pred, typename Sub, typename M>
typename M::return_type collect(T& item, Pred p, Sub s, M m) {
if(p(item)==false)
return 0;
typename M::return_type result(0);
T::child_vect& children=s(item);
if(children.size()==0)
return m(item);
T::child_vect::iterator ib(children.begin()), ie(children.end());
for(; ib!=ie; ++ib)
{
result+=collect(*ib, p, s, m);
}
return result;
}
(需要感谢一下pongba,他指出了原来算法的一个不足,我按照他的思路改进了算法,进一步减小了算法的耦合度,提高了灵活性)。
先解释一下模板参数:T,被计算的对象的类型,Account或Deparment等等;Pred,判别项目是否参与计算的“可调用物”的类型,可以认为是delegate;Sub,是用于从被计算对象上提取子级对象的“可调用物”类型;M,用于从被计算对象上获取计算数据的“可调用物”类型。
现在看一下如何使用这个算法:
double res1=collect(acc_x, mem_fun(&SpecialAccount::IsSpecial),
mem_fun(&Account::children), mem_fun(&Account::ammount));
int res2=collect(dpt_x, mem_fun(&SpecialDept::IsSpecial),
mem_fun(&Department::children), mem_fun(&Department::empl_num));
调用很长,比较晦涩,简单地解释一下:前一个调用把一个科目节点作为第一参数;第二个参数用mem_fun把特殊科目管理器绑定成一个“可调用物”(关于mem_fun参考stl);第三个参数包装了获取子级科目的成员函数;第四个参数包装了取得科目金额的成员函数。后一个调用也一样,只是把Account,及其相关内容,换成Department。
引入GP后,算法collect变成了一个计算框架。具体的对象判别,子级获取,数据获取,都参数化。使得这个算法是“可装配的”,根据不同的业务类型,和被计算的成员函数和数据类型,由算法的使用者在使用时“拼装”起来。
相比没有使用GP的SP方案,这个方案是类型安全的,无需使用强制类型转换。同时,原有的灵活性非但没有降低,反而提高。因为算法的三个delegate不再针对一个函数签名的,而是针对一簇函数签名,无需再为成员函数的返回类型变化,而放弃强类型的保护。更进一步,GP提高了整个算法的复用性,不再需要为每一个delegate包装器编写代码,只需复用已存在的mem_fun即可。
对于OOP而言,GP可以为其带来更好的效果:
//Biz_Alg类包含了若干常用的算法。做成traits形式,便于未来针对特别情况特化。template<typename T>
struct Biz_Alg
{
template<typename R, typename C, typename Pred, typename Sub, typename M>
R collect(C& item, M mem) {
//执行遍历汇总,调用item子节点的mem成员
}
};
//Account类和Department类
template<typename Alg=Biz_Alg<Account> >
class Account
{
public:
float ammount() {
Alg::collect(*this, mem_fun(&SpecialAccount::IsSpecial),
mem_fun(&Account::children), mem_fun(&Account::ammount));
}
…
};
template<typename Alg= Biz_Alg<Department> >
class Department
{
public:
float elec_fee() {
return Alg::collect(*this, &Department::elec_fee);
}
int empl_num() {
Alg::collect(*this, mem_fun(&SpecialDepartment::IsSpecial),
mem_fun(&Department::children), mem_fun(&Department::ammount));
}
…
};
使用起来倒是比GP化的SP方案更简单:
float res1=acc_x.ammount();
int res2=dept_x.empl_num();
这种方案则是以OOP为出发点,利用GP技术使其解耦,并组件化。而前一个方案则是以SP为出发点,利用GP使其泛化和组件化。也就是说,GP被分成了两种风格:OO风格的GP和SP风格的GP。从本质上来说,两者是等价的,都是将算法和数据对象分离。只不过前者是以类为主导,而后者则是以自由函数为主导。两者各有优势:前者集成度高,使用起来简便、清晰,更符合业务逻辑;后者更灵活,耦合度更小,扩展性更强。
根本上而言,OO风格的GP是以业务逻辑为核心,用类封装具体实现,程序员的注意力焦点都在业务逻辑上。比较适合高层的业务级别开发。而SP风格的GP ,以数据操作为核心,由自由函数将算法施加在数据实体上,程序员的注意力都集中在算法和数据本身上。这种风格比较适合底层的系统级开发。
从原先纯OO的方案来看,由于受制于强类型系统的约束,无法很优雅地将算法与数据分离。由此造成越来越严重的耦合。而GP 的引入,则利用其泛型特性,消除了强类型的负面作用,实现了解耦。
同样,在纯SP 方案中,算法与数据分离,但却没有完全独立于数据。一个自由函数要么只能针对一种类型(除非放弃类型安全的保证,和代码复用),要么促使数据对象的类型归一化。后者将依旧会引发数据对象间的耦合。但GP引入后,算法不再针对单一的类型,而是可以面向一族类型。在这种情况下,类型安全得到保证,代码复用得到实现,也无需强制地将不相关的类型归一化。
至于Java和C#的泛型存在的问题就很明显了。我们利用GP费尽心机,使得Account和Department不再需要实现同一个接口,大大弱化了类间的耦合。但Java和C#的泛型都要求类型参数共同继承自一个基类或接口。这反而迫使我们让类型耦合,做原本力图消除的事。这就是为什么Java和C#的泛型没什么用处的原因。
最后,我这里还有一个方案:
template<typename C, typename R, typename Pred,R (C::*mem)() >
class collect
{
public:
collect(C const& item): m_Item(item), m_Mem(mem){}
R operator() {
//执行遍历汇总
}
private:
C& m_Item;
Pred m_Pred;
R (C::*m_Mem);
}
//用起来像这样
typedef collect<Account, float, AllTrue<Account>,
&Account::ammount() > AccountCollect;
typedef collect<Account, int, AllTrue<Department>,
&Department::employee_num > DeptElecFeeCollet;
//使用SP风格的GP方案中的Account和Department的对象,即简单类
float res=AccountCollect(acc1)();
float res=AccountCollect(dept1)();
这种方式看上去有些古怪。实际上使用一个函数对象封装了算法,构成了一个"算法适配器"。collect是个算法框架,通过typedef 将相应的判别式、成员函数,同框架一起组装起来,构成一个类型。之后,直接使用这个类型,适配一个对象,以执行相应的计算。这可以看作是介于OOP和SP 风格之间的一种GP方案。它比OOP风格方案稍微灵活一点点,比SP 风格方案使用上稍微简便一点点。但是,在这个方案能为我们带来多少好处,我不敢说。放在这里,只是为了展示GP的几种可能的设计方案而已。
总结一下。在这里。我们首先试图用OOP和SP两种设计风格实现一个比较复杂的任务:让两种完全不同的业务类,共享相同的算法,而且希望获得最大的扩展性。但是,两种方案都不尽如人意。随后,我们引入GP,非常好的解决了两种方案的问题,却没有牺牲各自的优点。从中,我们可以看出,无论是OO还是SP,都可以通过GP来加以扩展,消除各自的缺陷。也就是“用OO或SP设计,用GP来优化(代码)”。
阿尔马维瓦实际上另有新欢,垂涎于费加罗的未婚妻——苏珊娜。然而,足智多谋的费加罗安排下了计谋。伯爵接到苏珊娜的信,约其夜晚在花园幽会。他大喜过望,欣然赴约。正当他大献殷勤的时候,四周灯火通明,伯爵发现怀里抱着自己的妻子罗西娜。伯爵羞愧难当,只好当众下跪向罗西娜道歉,保证以后再也不犯。聪明的费加罗大获全胜,顺利的与苏珊娜举行了婚礼。