普契尼的独幕歌剧歌剧《贾尼·斯基基》完成于1918年,同年初演于纽约。
本剧的剧情取自意大利诗人但丁(1265- 1321)的长诗《神曲·地狱篇》中的一个故事:富商多纳蒂死了。其遗嘱内,将遗产全数捐献给某一教堂。在场亲友大失所望。众人请贾尼·斯基基假扮多纳蒂,骗过公证人,另立遗嘱,遗产由众亲友均分。公证人到场。结果斯基基将少量遗产分与众人,大部分留给了自己。遗嘱录毕,公证人离去。众大哗,斯基基从病榻跃起,持棒驱散众人。
剧中斯基基的女儿劳蕾塔为表达对青年努奇奥的爱情,对其父唱起了这首美妙绝伦的咏叹调——“我亲爱的爸爸”:
按照C/C++中对于后置操作符++的定义,操作数增加1,并返回原来的值。于是,有人根据这个给C++遍了一段笑话,流传甚广。那么,C++是否相对C加了那么一点点,然后还是返回原来的值呢?那就让我们来“实地考察”一下,了解这个++究竟加了多少。
我不打算罗列C++的各种纷繁复杂的特性。已经有无数书籍文章做了这件事,肯定比我做的好得多。我要做的,是探索如何运用C++的一些机制,让我们能够更方便、快捷、容错地开发软件。这些特性很多都是非常简单的,基本的。正因为它们基本,很容易为人们所忽略。另一些则是高级的,需要多花些时间加以掌握的。但是,这些特性也具有一些简单,但却非常实用、灵活和高效的用法。
相对于C,C++最主要的变化就是增加了类。严格地讲,类是一种“用户定义类型”,是扩展类型系统的重要手段。类从本质上来说,是一种ADT(Abstract Data Type,抽象数据类型)。笼统地讲,ADT可以看作数据和作用在这些数据上的操作的集合。
类提供了一种特性,称为可见性。意思是说,程序员可以按自己的要求,把类上的数据或函数隐藏起来,不给其他人访问。于是,通过可见性的控制,可以让一个类外部呈现一种“外观”,而内部可以使用任何可能的方法实现类的功能。这称为“封装”。
呵呵,听烦了吧。这些东西是学过C++(或者任何时髦的OOP语言)的都已经烂熟于胸了。这样的话,我们就来点实际的,做个小案例,复习复习。温故而知新嘛。:)
案例非常简单,做一个圆类。让我们从“赤裸”的C结构开始吧:
然后,面积计算公式稍作改动就行了:
这时,如果我改变了Rectangle的数据存储方式,也不会影响Area函数:
运用了封装之后,类的实现和接口分离了。于是我们便可以在使用方神不知鬼不觉的情况下,改变我们的实现,以获得更好的利益,比如效率的提升、代码维护性的提高等等。
当我们尝到封装的甜头之后,便会继续发扬光大:
作为一个思想纯正的OOP程序员而言,这是一个漂亮的设计。不过,对于我这样一个同样思想纯正的Multiple-paradigm程序员而言,这是个不恰当的设计。
我承认,这个设计完成了工作,达到了设计目标。但是,这种被Herb Sutter称为“单片式”的设计是一种典型的过度OO的行为。Sutter在他的《Exceptional C++ Style》一书中,用了最后四个条款,详细地批判了以std::string为代表的这种设计。
这里,没有那么复杂的案例,我就简单地介绍其中存在的一些问题,其余的,请看Sutter的书。首先,当Cycle的内部存储形式发生变化时,需要修改不只一个地方:
当然,如果get_left()等成员函数通过get_center_x()等成员函数计算获得:
这样在改变数据存储的情况下,修改get_left()等函数了。不过,get_center_x()等函数本来就是从left等成员数据上计算获得,get_left()再逆向计算回去,显得有些奇怪。
这还只是小问题。更重要的是增加了这些冗余的函数,使得类在接口的灵活性上变差。假设我们在Cycle类上增加一个offset()函数,实现平移:
在Cycle的使用代码中,调用了offset():
假设,此时来了一个需求,要求offset()可以接受size类的对象作为参数。那么就必须修改Cycle类的定义,改变或重载offset()。如果这个Cycle是别人写的,不是我们所能改变的,那么事情就比较麻烦。
按照现代的Multiple-paradigm的设计理念,这类操作应当以non-member non-friend的形式出现,而类仅仅保持最小的、无冗余的接口集合:
此时,如果需求改变,那么只需编写一个函数重载,便可以解决问题,而无需考虑类的修改了。
关于这方面的问题,Meyes有一篇很有见地的文章:http://www.ddj.com/cpp/184401197。作者认为,冗余的成员函数实际上只会降低类的封装性,而不是提高。这看似一个哗众取宠的论点,但是Meyes所给出的论据却非常具有吸引力。他给出了一个“封装性”的具体度量:封装性的好坏取决于类实现变化时,对使用代码产生的影响。类的接口的冗余度越大,越容易受到实现变化的影响。
所以,现在主流的C++社群都提倡用小类+non-member non-friend函数实现,以提高灵活性。这一点反过来也更接近计算机软件“数据+操作”的本质。
经过长时间的开发工作,我们逐步积累起很多圆类,都是面向不同实现。有的通过传统的<圆心,半径>存放数据;有的通过外接正方形坐标保存数据;有的通过一个长轴等于短轴的椭圆存放数据;有的通过内接正方形保存数据;…。不过它们的接口都是相同的,即<圆心,半径>形式。
面对这些圆的实现,为它们各自开发一套算法实在让人泄气。大量的重复代码,和重复劳动,简直是对程序员的智慧的侮辱。我们需要开发一套算法,然后用于所有圆类。这就需要动用C++的MDW(大规模杀伤性武器)——模板:
这样,同一个算法便可以用于(我们)所有的圆类:
不过,有些顽固的人认定一个圆应当用外接正方形的形式定义(接口形式是外接正方形的坐标)。并且基于这种构造,开发了一堆有用的函数模板。比如说inflate<>()。
可我们这些理智的人已经开发了<圆心,半径>形式的Cycle。只是看中了顽固派的哪些操作函数,希望能够重用一下,免得自己重复劳动。同时,我们又不希望重做一个Cycle类,来符合那些缺乏理智的Cycle定义。
怎么办?设计模式告诉我们,可以用Adapter解决问题:
此后,我们便可以使用顽固派的函数了:
唉,世事难料,上头下命令,必须同时使用我们自己的圆类和顽固派的圆类。(肯定是收了他们的好处了)。没办法,命令终究是命令。可从今往后,我们就得同时开发两套算法。痛苦。不过相比使用算法的人来说,我们还算幸运的。他们必须不断地在Ours和Theirs命名空间里跳来跳去,时间长了难保不出错。
算法使用者希望一个算法就是一个名字,在同一个命名空间,以免混乱。幸运的是,在一种未来技术的支持下,我们做到了。这就是C++的BM(弹道导弹,MDW的运载器)——concept:
在concept和特化的共同作用下,我们便可以很方便地(不需考虑我们的,还是他们的)使用这些算法了:
随着应用的发展,我们不仅仅需要操作一个图形,还要把它画出来。这件事不算难。但是,面对不同的需求,我们有完全不同的两套方案。
先看一下常见的方案——OOP。这是经典的OOP案例,我就简单地描述一下,诸位别嫌我罗嗦J。为了方便,这里用mfc作为绘图平台,尽管我讨厌mfc。
定义一个抽象类:
所有图形类从Graph继承而来,并且重写(override)Draw():
此后,便可以创建一个对象并绘制:
但这同不用虚函数有什么区别?请看以下代码:
(附注:我这里不辞辛劳地用了智能指针,为的是无忧无虑地编写代码,不必为资源的安全而烦恼。同时,标准算法for_each和成员函数适配器mem_fun的使用也是为了获得更简洁、更可靠的代码。这些都是应当广泛推荐的做法,特别是初学者)。
抛开智能指针,gv中包含的是基类Graph的指针,当各种继承自Graph的对象插入gv时,多态地转换成基类Graph的指针。当后面for_each算法执行时,它会依次取出gv的每一个元素,并通过mem_fun适配器调用每个元素(即Graph指针)上的Draw成员函数。(关于for_each和mem_fun的奇妙原理,我这里就不说了,有很多参考书都有很详细的解释,比如《C++ STL》、《C++ Standard Library》等等)。
这里的核心在于,当我们调用Graph指针上的Draw成员函数时,实际上被转而定向到继承类(Cycle、Rectangle等)的Draw()成员函数上。这个功能非常有用,也就是说,当一组类(Cycle等)继承自同一个基类(Graph)后,可以通过覆盖基类上的虚函数(Draw)实现对基类行为的修改和扩充。同时,基类(Graph)成为了继承类(Cycle等)的共同接口,通过接口我们可以将不同类型的对象放在同一个容器中。这种技术可以避免大量switch/case的硬编码分支代码,(也称为tag dispatch),大大简化我们软件的构架。同时也可以大幅提高性能,操作分派可以从O(n)复杂度变成O(1)(hash_map)或O(logN)(map)。
这种通常被称为“动多态”的OOP机制,允许我们在运行时,根据某些输入,比如从一个图形脚本文件中读取图形数据,创建对象,并统一存放在唯一容器中,所有图形对象都以一致的方式处理,极大地优化了体系结构。
有“动”必有“静”。既然有“动多态”,就有“静多态”。所谓“静多态”是指模板(或泛型)带来的一种多态行为。关于模板前面我们已经小有尝试,现在我们通过模板上的一些特殊机制,来实现一种多态行为。
作为独立于OOP的一种新的(其实也不怎么新,其理论根源可以追溯到1967年以前)范式,模板(泛型)相关的编程被称为“泛型编程”(GP)。gp最常用的一种风格就是算法独立于类,这在前面我们已经看到过了。所以,这里的Draw也作为自由函数模板:
当用不同的图形对象调用Draw时,编译器会自动匹配不同的版本:
这里使用了函数模板特化这种特性,促使编译器在编译时即根据特化的情况调用合适的函数模板版本。不过,仔细看函数模板的声明,会发现这同函数的重载几乎一样。实际上此时使用函数重载更加恰当。(函数重载通常也被认为是一种多态)。这里使用模板,是为了引出未来的concept的方案:
同前面的move模板一样,这里的Draw也实现了编译期的操作分派(以类型为tag)。此时,我们便可以看出,引入了concept之后,模板的特化(针对concept)不仅仅使得代码重用率提高,而且其形式同函数重载更加类似。也就是说,重载多态和函数模板的静多态有了相同的含义(语义),两者趋向于统一。
以上代码另一个值得注意的地方是get_left()等函数。这些函数实际上是函数模板,分别针对不同的类型特化。这使得所有相同语义的操作,都以同样的形式表现。对于优化开发,提高效率,这种形式具有非常重要的作用。
模板的这种静多态同OOP的动多态有着完全不同的应用领域。更重要的是,两者是互补的。前者是编译时执行的多态,具有很高的灵活性、扩展性和运行效率;后者是运行时执行的多态,具备随机应变的响应特性。所以,通常情况下,凡是能在开发时确定的多态形式,比如上述代码中get_left是可以在编译时明确调用版本,适合使用模板。反之,只能在运行时确定的多态行为,比如从图形脚本文件中读取的图形数据,则应当使用OOP。
最后,这里还将涉及一种非常简单,但却极其实用的C++特性:RAII。所谓RAII,是Bjarne为一种资源管理形式所起的笨拙的名字,全称是Resource Acquisition Is Initialization。其实这个名称并不能表达这种技术的特征。简单地讲,就是在构造函数中分配资源,在析构函数中加以释放。由于C++的自动对象,包括栈对象、一个对象的子对象等等,在对象生成和初始化时调用构造函数,在对象生命期结束时调用析构函数。所以,RAII这种资源管理形式是自动的和隐含的。下面用文件句柄来做一个说明:
在一个函数中,当我们使用这个类时,可以无需考虑如何获取和释放资源,同时也保证了异常的安全:
这样,资源管理会变得非常简单、方便,即便是最铁杆的C程序员,也能从中获得很大的好处。
而且,RAII不仅仅可以用来管理资源,还可以管理任何类似资源的东西(也就是有借有还的东西)。我们还是拿绘图作为案例。
用过mfc的都知道,有时我们需要改变dc的设置,比如pen的宽度、brush的颜色等等,在绘图完成之后在回到原来的设置。mfc(确切地说是Win32)提供了一对函数,允许我们把原先的dc设置保存下来,在完成绘图后在恢复:
这种“赤裸裸”地使用Save/RestoreDC并非是件好事,程序员可能忘记调用RestoreDC返回原来状态,或者程序抛出异常,使得dc没机会Restore。利用RAII,我们便可以很优雅地解决这类问题:
此后,可以很简单地处理dc的Restore问题:
除此以外,RAII还可以用于维持commit or rollback语义等等方面。关于这些内容,可以参考一本非常实用的书:《Imperfect C++》。
C++拥有很多非常好的和实用的机制,限于篇幅(以及我未来的文章J)只能就此打住。这里我蜻蜓点水般的扫描了一下C++的一些主要的特性,意图告诉大家,如果你觉得C++并没有加多少,那么还是请认真地了解一下真正的C++。尽管C++在这些特性之外,存在很多弊病,并非那么容易掌握。但是,了解这些基本的特性,对于程序员,无论是否使用C++,都有非常大的帮助。
最近,一次普通的开发活动<