冒号课堂§10.2:抽象类型

冒号课堂

第十课 多态机制(2)

抽象类型——实中之虚

郑晖

摘要

介绍抽象类型的种类、意义及其用法


有无相生,难易相成

《老子•道经》

!预览

  • 浅显的比方只是门槛前的台阶,借之或可拾级入门,却无法登堂入室

  • 具体类型是创建对象的模板,抽象类型是创建类型的模块

  • 抽象数据类型的核心是数据抽象,而抽象类型的核心是多态抽象

  • 必先以术养道,而后以道御术

  • 以社会身份而非个人身份作为公民之间联系的纽带,正是针对接口而非实现来编程的社会现实版

  • 个体身份对应的规范抽象借助封装,以数据抽象的形式出现

  • 家庭身份对应的规范抽象借助继承,以类型层级的形式出现

  • 社会身份对应的规范抽象借助多态,以多态抽象的形式出现

?提问

  • 具体类型与抽象类型的区别是什么?

  • 抽象数据类型与抽象类型的区别是什么?

  • 除接口与抽象类外还有其他抽象类型吗?它们有何特点和意义?

  • 抽象类型的主要作用是什么?

  • 在系统中应采用何种类型作为模块之间通讯的数据类型?

  • 接口是为了克服(Java或C#中)抽象类不能多重继承的缺点吗?

  • 接口与抽象类在语法和语义上各有什么不同?

  • 标记接口有何作用?

:讲解

冒号调整了焦点:“鉴于目前专注的范式是OOP,参数多态最好放在以后的GP专题再作探讨。除非特别说明,下面提到的多态专指子类型多态。谈到这类多态,就不得不提及抽象类型。谁来说说,究竟什么是抽象类型?”

冒号抬手内扬,摆出了对练的姿势。

叹号率先抢攻:“抽象类型指的是至少含有一个抽象方法的类型。”

冒号轻松化解:“在C++中这句话尚可勉强成立,但在Java和C#中则大不尽然:一个类即使没有一个抽象方法也可以被申明为抽象的;一个没有任何成员的空接口或称标记接口同样属于抽象类型。”

“抽象类型是指无法实例化的类型。”逗号发起二次进攻。

冒号见招拆招:“Java中的Math类也不能实例化,原因是它只有private构造器,并且没有一个能返回实例的静态方法。C#中的Math类是静态类,同样不能实例化。”

问号纵身而上:“抽象类型指能且只能通过继承来实例化的类型。Math类是final类,无法被继承。最主要的是,它的价值体现在它的静态方法上,压根儿就没有实例化的必要。”

冒号借力反打:“为什么要强调无法实例化呢?”

引号一旁助攻:“一个抽象类型代表着一个抽象概念,而抽象概念自然是无法具化的。比如你无法实例化抽象的形状,但可以实例化长方形、三角形等具体的形状;无法实例化抽象的水果,但可以实例化苹果、桔子等具体的水果。”

“很官方的说法。这就好比将继承关系说成‘is-a’关系一样,理论上虽通俗易懂,实践上却不足为训。”冒号收起架势,“要说抽象,Java和C#中的Object类可谓包罗万象,该够抽象了吧?不照样实例化?列表(list)与映射(map)是抽象的还是具体的?在C++中它们是具体类型,而在Java和C#中它们却是抽象类型[1]。这又是为什么?”

一连串的反问让大家陷入沉思。

“相比其他编程范式,OOP更贴合客观世界,人们经常用打比方的形式来描述和理解OOP的一些概念和思想。这本身并无不妥,但一定要保持清醒的头脑:浅显的比方只是门槛前的台阶,借之或可拾级入门,却无法登堂入室。”冒号谆戒道,“天下之理皆同,天下之人皆同,故凡学问殿堂之前皆一般景象:入门者众,入室者寡。本班的目的便是,引导诸位从徘徊于编程之门左右的人群中越众而出,早达内室。”

“那就成了传说中的内室弟子吧?大伙在门边转悠很久了,头都发晕了,师父还是快些领我等入室吧。” 逗号近乎戏谑地恳求。

冒号一笑:“我可算不得你们的师父,只不过是个闻道在先的师兄而已。”

一直没有出手的句号忽然开腔:“抽象是个相对概念,一个类型是否是抽象的完全取决于设计者对它的角色定位。如果想用它来创建对象,它就是可实例化的具体类型;如果想用它来作为其他类型的基类,它就是不可实例化的抽象类型。”

“这才击中了要害!”冒号不禁喝彩道,“整理一下你的观点:具体类型是创建对象的模板,抽象类型是创建类型的模块。一个是为对象服务的,一个是为类型服务的。显然,后者的抽象性正是源自其服务对象的抽象性。就拿刚才的实例来说,模板方法模式中的Authenticator类是抽象的,是为创建子类型SimpleAuthenticator、Sha1Authenticator等服务的;策略模式中的Authenticator类是具体的,是为创建对象服务的,但它合成的两个接口KeyValueKeeper和Encrypter又是为创建算法类型服务的。值得注意的是,不要把抽象类型与抽象数据类型(ADT)混为一谈,后者的抽象指的是类型的接口不依赖其实现。或者说,抽象数据类型的核心是数据抽象,而抽象类型[2]的核心是多态抽象。”

问号想让概念更明确些:“抽象类型就只有接口(interface)和抽象类(abstract class)两种吗?”

“在Java和C#中基本上是这样,但在C++中这两种类型没有显式的区别[3]。”冒号,“此外,动态OOP语言如Ruby、Python、Perl、Scala、Smalltalk等还至少支持mixin和trait中的一种类型。mixin直译为‘混入’,trait直译为‘特质’,为避免翻译上的问题,今后我们还是采用英文术语。这两种类型大同小异,为简便起见,下面以mixin类型为代表[4]。它们的出现是为了弥补接口与抽象类的一些不足,更好地实现代码重用。我们知道,接口的主要目的是创建多态类型,本身不含任何实现。子类型通过接口继承只能让代码被重用,却无法重用超类型的实现代码。抽象类可以重用代码,可又有多重继承的问题。Java和C#不支持这种机制,C++虽支持但有不少弊端。”

引号奇道:“这个问题上节课不是已经解决了吗?用合成来代替继承啊。”

冒号解释:“合成是一种解决办法,但也不是没有缺陷。首先,合成的用法不如继承那么简便优雅,这也是许多人喜欢用继承的主要原因;其次,合成不能产生子类型,而有时这正是设计者所需要的;再次,合成无法覆盖基础类的方法,也无法访问它的protected成员;最后,却可能是最大的缺点是:合成的基础类只能是具体类型,不能是抽象类型[5]。”

逗号不明所以:“这能算是缺点吗?”

“如前所述,具体类型的主要任务是创造新对象,如果用作合成或继承的基础类,等于是又承担了原本抽象类型的任务——创造新类型。这不仅有越俎代庖之嫌,而且这两个任务往往也是冲突的。我们曾提出,一个类的服务应该有纯粹性和完备性。一方面,人们希望创造的新对象无所不能,因此更看重服务的完备性,倾向它包含尽可能多的功能;另一方面,人们又希望创造的新类型有所不依,因此更看重服务的纯粹性,倾向它包含尽可能少的功能。”冒号擘肌分理,“妥协的结果是,一个新类型往往只用到基础类型的部分功能,却可能受到其他功能变动的影响。虽然这种影响在良好的封装之下会大大削弱,但也难以完全消弭。”

句号思索片刻,已明其意:“换句话说,以具体类型为代码重用的基本单位,难免颗粒度过大?”

“然也!”冒号的手在空中挽了个花,“其实作为抽象类型的接口也有类似的尴尬:对它的客户类来说,它承诺的服务是多多益善;对它的实现类来说,承诺越多负担却越重。如果能有这样一种可重用的模块,既不像具体类型那样面面俱到,又不像接口那样有名无实,也没有抽象类的多重继承之弊,岂不妙哉?”

“想必就是mixin了!”叹号眼中闪过一道光芒,旋即又暗淡下来,“只可惜Java并不支持啊。”。

“Java不支持就没兴趣了?” 冒号听出他的话里有话,“要成为优秀的程序员,千万不能画地为牢、自我禁锢。始终要保持一颗开放的心,不要拘于某些语言或范式,也不要囿于某些概念或技术。”

叹号的耳根有点发热。

“陌生的理论和技术开始总是拒人千里,不过一旦你了解其问题来源,它们会慢慢变得和蔼可亲起来。”冒号循循善诱,“既然具体类型和现存的两种抽象类型均有不足之处,mixin的产生便合情合理了。它是具体类型与接口类型的一种折衷,既可有抽象方法,也可有具体方法。这一点类似抽象类,但又没有抽象类的多重继承问题。举例来说,Ruby中的Comparable就是一个简单却很典型的mixin。”

问号插话:“Java中也有Comparable接口啊。”

冒号道出其中差异:“Java中的Comparable和C#中的IComparable只有一个抽象的比较方法,而Ruby中的除了有类似的抽象方法——比较(<=>)之外,还提供了小于(<)、小于等于(<=)、等于(==)、大于(>)、大于等于(>=)和介于(between?)等六种具体方法。显而易见,多出的方法均可通过唯一抽象的比较方法来实现。”

引号一点即通:“如此一来,重用Comparable的类只需实现一个抽象方法,便可自动拥有另外六个有用的功能。这既满足了客户类的需求,又不增加实现类的负担。”

“买一送六,这买卖划算!”逗号来劲了。

冒号双眼微眯:“更划算的买卖是Ruby中Enumerable。任何包含该mixin的类只要实现一个遍历方法each,便可免费得到二十多个有关遍历和搜寻的方法。如果再实现比较方法<=>,还可获赠排序和最值方法。相比Java中Enumeration和Iterator接口,优势历然。”

问号很好奇:“为什么称为mixin呢?”

冒号述说由来:“冰淇淋中经常会掺混一些薄荷、香草、巧克力之类的调味料和花生、坚果之类的小零碎,人们管它们叫mix-in。后来被借用来表示一种抽象类型,主要有如下特点:一、抽象性和依赖性:本身没有独立存在的意义,必须融入主体类型才能发挥作用;二、实用性和可重用性:不仅提供接口,还提供部分实现;三、专一性和细粒度性:提供的接口职责明确而单一;四、可选性和边缘性:为主体类型提供非核心的辅助功能。”

“这些特点与风味添加料还真的颇为神似。”叹号想着想着,嘴里不自觉地咂摸了一下。

“虽然C++、Java和C#在语法上尚不支持mixin,但C++可通过多重继承、Java和C#可通过合成和接口来分别模拟mixin。不仅如此,借助切面式编程(AOP),Java和C#甚至可完全实现mixin;借助泛型式编程(GP),C++也能通过模板更好地实现mixin[6]。”冒号点到为止,“就此我们重温前面提到的两个观点。一是编程范式之间的合作性:mixin属于OOP的范畴,但其他编程范式如切面式、泛型式以及二者背后的元编程都能与之相通;二是设计与语言的相关性:C++、Java和C#以及其他诸如Ruby、Python等动态语言对mixin有着不同的支持方式,这在一定程度上会影响系统的OOP设计。”

引号憧憬道:“语言是在发展的,说不定哪天Java也会支持mixin的。”

冒号以实相应:“Java的动态小兄弟Groovy在1.6版已经开始支持mixin ,而C#3.0也新引入了对mixin更友好的语法特性[7]。”

逗号提了一个长期困惑大家的问题:“每当一个新技术出现,我就觉得很矛盾:不追怕落伍,追吧又怕落空。如何判断一个它是昙花一现,还是大势所趋呢?”

“任何技术都是在赞美与批判中成长起来的,预测它们是流星还是恒星绝非易事。就拿OOP来说,上个世纪六十年代就出现了支持OOP的语言[8],但直到九十年代中后期它才真正成为主流的编程范式。这段时间恐怕比大多数人的程序员生涯还长吧。再说mixin,其实并非今日的重点,介绍它的目的不是盲目追新,而是希望透过其背后的需求驱动点,重新审视现有技术。至于它今后会不会为主流语言所接纳,反倒不是那么重要了。如果一定要我给个建议,那就是八个字:‘不执一法,不舍一法’。”冒号以禅语作答,“软件技术这棵大树经过多年的快速成长,早已枝蔓丛生。欲臻不执不舍之境,当如开班导言中所说:究其根本以知过去,握其主干以知现在,察其生长点以知未来。我之所以倾向于用抽象的方式来谈论技术,正是因为抽象的东西更接近根、更接近干、更接近生长点,从而更普泛深刻,也更稳定持久。”

句号借机问道:“您认为抽象比具体更重要?”

“抽象与具体无所谓孰高孰低,它们只是功用不同而已。”冒号轻轻晃了晃脑袋,“正所谓:必先以术养道,而后以道御术。也就是说,在学习时应注重从具体知识中领悟抽象思想,在应用时应注重用抽象理论来指导具体实践。类似地,软件开发也是如此:从具体需求中构建出抽象模型,再根据抽象模型来完成具体实现。因此,在设计阶段抽象类型尤为关键,而在实现阶段则是具体类型更为重要。”

问号表示理解:“假如从具体需求直接跨到具体实现,省去中间的抽象建模过程,那还用得着架构师和分析师吗?”

“话虽不错,但疑似倒果为因。”冒号洞若观火,“是否有必要抽象建模,关键看项目需求。如果需求简单而稳定,一步到位又何尝不可?甚至软件的开发效率和运行效率还更高——为劈几根细柴而磨刀,值吗?如果需求复杂而多变,引入抽象方有‘磨刀不误砍柴工’之效。毕竟抽象不是目的而是手段,对它片面的追求反会导致过度的设计。”

众人这才发现,给老冒戴顶“抽象派”的帽子是有些冤枉他了,应该是“抽象现实派”的。

冒号续道:“为进一步认识抽象类型,我举个非常实用的例子。它只适用于C++,而不适用于Java和C#。如果你对这一点感到遗憾的话,不要忘记我们的原则:具体实例永远是为抽象思想服务的。”

幻灯一闪,现出一段C++代码——

/** 一个不可复制的类 */
class NonCopyable
{
    
protected:
// 非公有构造函数防止创建对象
        NonCopyable() {}  
// 非公有非虚析构函数建议子类非公有继承
        ~NonCopyable() {}
    
private
// 私有复制构造函数防止直接的显式复制和通过参数传递的隐式复制
        NonCopyable(const NonCopyable&);
// 私有赋值运算符防止通过赋值来复制
        const NonCopyable& operator=(const NonCopyable&); // copy assignment
};

/** NonCopyable的一个私有继承类 */
class SingleCopy : private NonCopyable {};

/** 测试代码 */
int main()
{
    SingleCopy singleCopy1;
    SingleCopy copy(singleCopy1); 
// 编译器报错:企图复制singleCopy1

    SingleCopy singleCopy2;
    singleCopy2 
= singleCopy1; // 编译器报错:企图复制singleCopy1
    return 0;
}

冒号讲解道:“有些对象是不希望被复制的。比如一些代表网络连接、数据库连接的资源对象,它们的复制要么意义不大,要么实现困难。由于C++的编译器为每个类提供了默认的复制构造函数(copy constructor)和赋值运算符(assignment operator),要想阻止对象的复制,通常做法是将这两个函数私有化。引入NonCopyable后,它的任何子类将自动拥有不可复制的特性。这样为开发者节省了代码编写量,还免掉了相应的文档说明,使用者也一望而知其意,可说是一石三鸟。虽然NonCopyable从语法上说不是抽象类,但从本质上看是一种类似mixin功能的抽象类型。”

引号考量一番后说道:“单就它的功效而言,的确非常符合mixin的四大特点,只是它的子类用的是私有继承,而不是类继承或接口继承。”

“你说得很对。可问题是,我们并没有要求mixin或者trait一定要通过继承的方式来重用啊?事实上,有些mixin甚至可在运行期间产生,还能克服继承的静态缺陷。即使采用继承,一般也不满足‘is-a’关系。你总不能说草莓冰淇淋是一种草莓吧?”冒号淡淡地说,“先前你们总结出抽象类型有两个特征:需要继承和无法实例化,但它们并非本质,关键还是它的目的——为类型服务。提供可被继承的超类型只是一种服务方式,却非唯一的方式;无法实例化只因它不是为对象服务的,禁止实例化不过是语法上的加强,目的是让用户在编译期间就能发现用法错误。其实,即便NonCopyable类的构造函数是公有的,也不会有人去实例化。原因很简单,它的价值只有通过子类才能体现,这是由其抽象的本性所决定的。”

逗号有些奇怪:“为什么在Java中就没有类似的对象复制问题呢?”

“这是一个非常基础的问题,请容我下次再回答你。”冒号破天荒地没有立即解疑,“以下重点还是放在接口和抽象类上面,我们称之为基本抽象类型,以别于mixin、trait等其他抽象类型。我们先从语法上简单地对比一下这两种类型。”

屏幕上显示出一张表格(如表10-1所示)——

表 10-1. Java/C#的抽象类与接口在语法上的区别


抽象类 接口
提供实现代码
多重继承
拥有非public成员
拥有域成员 否(Java中的static final域成员除外)
拥有static成员 否(Java中的static final域成员除外)
拥有非abstract方法成员
方法成员的默认修饰符 public abstract(Java:可选;C#:不能含有任何修饰符)
域成员的默认修饰符 Java:public static final;C#:不允许域成员

冒号简明扼要地总结:“C#的语法与Java的稍有不同,但二者在接口与抽象类的关键区别上还是一致的:接口不能提供实现但能多重继承,抽象类则正相反;接口只能包含公有的、非静态的、抽象的方法成员[9],抽象类则无此限制。”

问号言明难处:“从语法上区分它们并不难,难的是从设计上区分它们。”

逗号实话实说:“按照上节课‘提倡接口继承,慎用实现继承’的方针,应该倾向用接口而非抽象类。但总觉得接口太虚了,没有抽象类实在。”

引号反驳:“要说实在,具体类型更实在啊。”

叹号坦言:“在编程中经常需要用到标准的或第三方的类库,可查起API来经常是左一个接口右一个接口的,迟迟不见具体类型现身,心里哪个急啊!”

冒号打了个比方:“如果到包子铺买包子,作为客户你也许会认为包子是具体类型,但对提供包子的人来说它却是抽象类型。他一定会问你:是要肉包、菜包还是豆沙包?是要蒸包、煎包还是小笼包?他的铺子开得越专业,给你出的选择题越多,众口难调嘛。同样道理, 要建一个高度可重用的类库,一些接口是必不可少的。”

句号悟道:“接口的意义就在于:提供者不是擅作主张,而是推迟决定,让客户选择实现方式。”

“言之有理!类似地,抽象类的意义就在于:父类推迟决定,让子类选择实现方式。‘推迟’二字道出了抽象类型除创建类型之外的另一功用:提供动态节点。如果是具体类型,节点已经固定,没有太多变化的余地[10]。反过来,要使节点动态化,一般通过多态来实现。由此,抽象类型常常与多态机制形影不离。”冒号稍加引申,“就说前面的验证类吧,用模板方法模式实现的Authenticator类将关键的方法交给子类SimpleAuthenticator或Sha1Authenticator处理,用策略模式实现的Authenticator类将关键的方法交给内嵌接口KeyValueKeeper和Encrypter的实现类处理。后者的两次接口继承比前者的一次实现继承多了一个动态节点,因而更加灵活。这也是为什么一个需要(M×N)个实现类,一个只要(M+N)个的原因。当然,这也不是完全没有代价的。比如要创建一个用SHA-1算法加密的验证类实例,两种方法对比如下——”

模板方法模式:new Sha1Authenticator()
策略模式: new Authenticator(new MemoryKeeper(), new Sha1Encrypter())

冒号指点着黑板:“显然,后者无论是使用上还是性能上都比前者稍有不如。但权衡利弊,多数时候它仍是更好的选择。”

“包子铺的包子用料种类越多、做法越多,买一个包子越费事。但只要不到饿得发昏的地步,大家还是更喜欢花样更多的包子铺。看来我也不该再抱怨类库的接口过多了。”叹号心下释然。

“大家再看看这个电脑主板,开过机箱攒过机的人应该对它并不陌生。”冒号终于亮出了蓄藏已久的道具, “上面密密麻麻地布满了各种元件,那是它的实部,而我们关注的是它的虚部——各种插槽和接口,包括CPU插槽、内存插槽、PCI插槽、AGP插槽、ATA接口、PS/2接口、USB接口以及其他林林总总的扩展插槽等等。这些接口的存在,使得主板与CPU、内存条、外围设备以及扩展卡等不必硬性焊接在一起,大大增强了电脑主机的可定制性。”

引号受到启发:“主板与其他硬件就好比一个个的具体类型,那些插槽和接口就相当于一个个的接口类型。所有的硬件以接口为桥来组装合成,以机箱为壳来封装隐藏,一个新的具体类型——具有完整功能的主机便产生了。”

“比喻非常到位!” 冒号很满意,“不过准确地说,与接口类型对应的不是物理接口,而是接口规范。如果仅仅是物理接口,只能保证该接口适用于某种特定型号的硬件产品,却不能保证同时适用于其他型号或者其他类型的硬件。以大家熟悉的USB(Universal Serial Bus)接口为例,它能接入各种外部设备,包括鼠标、键盘、打印机、外置硬盘、闪存和形形色色的数码产品。这当然不是偶然的,因为所有厂家在生产这些硬件时均遵循了相同的业界标准——USB协议规范。换言之,任何一个与USB接口兼容的设备,都可看作是实现了此接口的具体类型,而主机对该设备的自动识别能力则可看作一种多态机制。”

“这下我更深刻地理解那句话了:接口继承不是为了重用,而是为了被重用。”句号品味道,“比如一个鼠标,可以有串行接口、PS/2接口、USB接口或者无线接口,还可以同时拥有多个不同类型的接口。无论怎样,它本身都是完整的产品,根本不需要重用主机上的其他硬件,它实现某些接口的目的完全是为了能被主机所用。”

逗号意识到:“看样子,硬件设计也需要OOP思想呢。”

“相比软件设计师,硬件设计师往往能更好地贯彻OOP的理念。”冒号加强了语气,“他们的对象化概念更清晰更自然,因为硬件模块比软件模块更实在更具体;他们更注重设计,因为硬件比软件的修改成本大得多;他们更注重设计重用,因为硬件重新发明轮子的成本普遍很高;他们更注重实现重用,因为无法在举手之间完成‘复制-粘贴’工作;他们更注重接口明确、封装完好,因为把内部的接口或结构暴露在外不仅难看,还容易带来缠绕、磨损、短路等问题;他们采用合成和接口来组装模块,因为硬件没有类似实现继承的机制。”

“看起来我们真得向硬件设计师取经了。”叹号有些信服了。

冒号旧话重提:“我们曾对OOP有过这样的描述:如果把OOP系统看作民主制社会,每个对象是独立而平等的公民,那么封装使得公民拥有个体身份,继承使得公民拥有家庭身份,多态使得公民拥有社会身份。补充一下,其中的继承主要指类继承,多态主要指接口继承带来的多态。经过这段时间的学习,大家对此有何见解?”

问号发表看法:“广义封装让每个类成为独立的模块,从而让每个对象具备了个体身份。狭义封装又进一步地把类的接口与实现分离,从而让每个对象具有显著的外在行为和隐藏的内在特性。继承机制可使一个类成为其他类的子类或父类,从而确立了对象在类型家族中的身份。至于多态嘛,嗯。。。”

问号努力想抓住若隐若现的头绪。

句号接过话头:“一个公民的社会身份是指他在社会中所处的地位和扮演的角色。比如,一个人在学校里是学生,在公司里是职员,在商店里是顾客,他真正的个体身份往往是被掩盖的。同样地,一个对象在与外界联系时,通常不以其实际类型的身份出现,而是在不同的场合下以不同的抽象类型的身份出现。我想,这大概就是多态带来的社会身份吧。”

“这种社会身份的意义何在?”冒号不动声色地问。

句号接着回答:“社会身份既是一种资格也是一种义务。比如在列车上有人得了急病,可以通过广播找医生。人们不用事先知道来者的具体个人身份,只要他是医生,就会放心地让他第一时间去救人。”

“这个比喻很恰当。”冒号赞道,“不用事先知道个人身份,不正说明广播呼叫的对象是一个多态的抽象类型吗?同理,当一个具体类型显式继承了一个接口,它的对象便拥有了个体身份之外社会身份:有资格以该接口的形式与外界打交道,也有义务履行该接口的职责。”

“咦,那为什么把社会身份归功于多态而不是继承呢?”问号发出疑问。

冒号释疑:“继承自然有功劳,毕竟子类型多态要建立在它的基础上。但如果没有多态机制,要确保一个对象的实际方法而不是其超类型的方法被调用,必须将其还原为具体类型,从而使社会身份变得几乎有名无实。”

问号憬然醒悟。

冒号继续深入:“对象每多一种社会身份,便多一条与外界交流的渠道。为什么遮遮掩掩地不肯以本来面目示人呢?非是羞于见人,盖因一般的具体类型在公共场合是不为人知的,只有少数核心库里的核心类是例外。即使侥幸被认识,也难被认可,因为那会以代码的复杂度和耦合度为代价。社会身份则不然,它远比一般的个体身份更容易被接受。”

逗号举出例证:“这就好比上课得有学生证,上班得有工作证,上火车得有火车票,上飞机得有登机牌。只要不是炙手可热的公众人物,很多场合都是认牌认证不认人的。”

“道理人人都懂,可总有不少人以为自己编写的类都是明星大腕,大有‘天下谁人不识我’的豪迈,无牌无证就敢到处乱窜。更有甚者,不用多态就算了,连封装也不要,简直是在裸奔嘛。”冒号揶揄道。

全班笑不可仰。

冒号恢复肃容:“谈到这里,我们不能不再次提到‘针对接口编程’的基本原则。它有一种建立于数据抽象之上的形式,能让用户只关心抽象数据类型的API接口而无视其具体实现。不过,它至少有两大局限。其一,虽然在接口不变的情况下,实现代码的改变不会影响客户代码,但仍需要重新编译,对于需要头文件的C++来说则需要更多的编译链接时间。其二,虽然相同的接口可以有多种实现方法,但它们不能同时并存,更无法动态切换。于是,另一种建立于多态抽象之上的形式应运而生。它把抽象数据类型隐藏在抽象类型的背后,从而提升了抽象接口。同一个抽象接口允许有多种实现并存,且能动态切换,新增、删除或修改某种实现也不会导致其他代码的修改或重新编译。方才我们从主体类的角度来看,它的对象尽量以社会身份参与社会活动;现在再从客户类的角度看,它会尽量召集有社会身份的对象。两相结合,以社会身份而非个人身份作为公民之间联系的纽带,正是针对接口而非实现来编程的社会现实版。”

问号有所顾虑:“可是,有不少具体类型并没有实现任何接口,也就没有社会身份。”

“排除设计不良的因素,没有抽象超类型的具体类型最常见的有两种可能。一种是与世隔绝,一辈子几乎足不出户,至多在小圈子里活动。典型的有非公有类、内部类、局部类等等。一种是名满天下,他的脸就是一张天然名片,他的个人身份也就是社会身份。典型的有基本数据类型、字符串类型、日期类型等通用数据类型以及特定领域的通用数据类型。可见,个人身份与社会身份并无绝对的界限。同样,家庭身份与社会身份也有交合之处,正如名门望族也可成为社会身份一样。典型的有Java IO库中的InputStream和OutputStream、Reader和Writer,以及UI库中的Component和JComponent等等。”冒号信手拈来,“因此我们谈到的社会身份,不必拘泥于接口,甚至不必限于抽象类型,关键是该类型是否具备了足够的通用性和规范性、稳定性和独立性、灵活性和专业性。还是应了那句话:抽象不是目的而是手段。再拿现实社会说事,每种社会身份都代表了个体与社会缔结的一种契约,它有如下的特点:独立而稳定——先于个体而存在,且不随个体的变化而变化;公开而权威——为人所知、为人所信;规范而开放——制定的协议标准明确,且允许个体在遵守协议的前提下百花齐放。毫无疑问,推行契约制将使社会大受其惠。首先,相同身份的个体可相互替换、新型个体可随时加入,而且不会影响整体框架和流程,保证了系统的灵活性和扩展性。其次,整体不因某一个体的变故而受冲击,保证了系统的稳定性和可靠性;最后,个体角色清晰、分工明确,保证了系统的规范性和可读性。”

引号非常注重概念:“社会身份所代表的契约对应的正是规范抽象吧。”

“每种身份都是规范抽象的结果。” 冒号推而广之,“具体地说,个体身份对应的规范抽象借助封装,以数据抽象(data abstraction)的形式出现;家庭身份对应的规范抽象借助继承,以类型层级(type hierarchy)的形式出现;社会身份对应的规范抽象借助多态,以多态抽象(polymorphic abstraction)的形式出现。至此,我们分别从行为和规范两个角度分别诠释了OOP的三大特征与公民的三大身份之间的关系。这也非常合乎情理:一个合理设计和实现的类,其对象的行为与规范本应保持一致。”

句号欲印证自己的想法:“我的理解是,接口是一个携带契约的角色标签,接口继承的作用就是静态地为对象贴上该标签,而多态机制的作用就是动态地让对象发挥该角色。因此,要赋予对象某个角色,就应该让相应的类去继承相应的接口。”

“你的前半部分表述得非常精当,后半部分则稍有瑕疵。”冒号评论道,“接口可用来代表角色,但角色却不一定要通过接口。正如你提到的,接口继承是静态的,而角色却可能是动态的。比如学生毕业后变成职员,职员升迁后变成经理等等。对于静态类型语言来说,这类问题的解决单靠接口继承是不够的,还需要利用合成等手段,或者利用前面提到的其他抽象类型如mixin或trait。”

叹号仍有疑惑:“接口的意义已经很清楚了,那抽象类呢?它们的区别真的很大吗?”

“我们已经从语法上比较了它们的区别,那些只是表象的东西。如果对语言规则的理解仅仅停留于语法层面,那么它更多体现为对实现的束缚。只有提升到语义层面,它才更多体现为对设计的保障。”冒号保持一贯高举高打的风格,“从语义上看,抽象类与接口的区别,并不比它与具体类的区别小多少。”

叹号错愕不已:“怎么可能?抽象类与接口好歹都是抽象类型啊。”

冒号反诘:“为什么不说抽象类与具体类好歹都是类呢?”

叹号一时无语。

“先看段历史吧。”冒号幽幽地说,“开始C++是没有抽象类型的,直到1989年C++ Release 2.0发布前的最后一刻,Bjarne Stroustrup才力排众议引入抽象类。从C++的前身C with Classes 开始算起,其间已经整整十年了。即便如此,它的意义在当时仍不为大多数人所认识。推出一个看似小小的语法特征竟会如此艰难,恐怕远远超出诸位的想象吧!有人幻想只通过看语法书就能完全领会语言的精髓,又与痴人说梦何异?”

冒号的声音渐渐激昂起来。

逗号为自己找到了安慰:“难怪当初学到抽象类时,总感到只知其意而不知其用。”

冒号紧接着说:“抽象类的出现,让两种不同角色的类在语法上有了明确的界定:具体类描述对象,重在实现;抽象类描述规范,重在接口。这种分工降低了用户与实现者之间的耦合度,大大减少了代码的维护成本以及编译时间[11]。由于抽象类不是为了创建对象,它的实例化自然是没有意义的。又由于它是接口规范,在子类没有实现其所有规范之前,是不能实例化的,否则规范岂不成了一纸空文?在没有抽象类的语法之前,要实现类似的功能,唯一的办法是:在本该抽象的方法被调用时强行中止程序。烦琐丑陋不说,还只能在运行期间捕捉错误。在纯虚函数(pure virtual function)——相当于Java和C#中的抽象方法——被引入之后,任何含有抽象方法的类都是抽象类,编译器将保证它不会被实例化。”

问号连连点头:“从这个角度来理解抽象类的语法,一切都顺理成章了。不过,抽象类与接口的区别好像还是没有看到。”

谈到兴头,冒号出言更如下阪走丸:“从具体类中分离出抽象类是一次质的飞跃,从抽象类中进一步地分离出接口则是另一次飞跃。Java推出接口类型之时同样饱受质疑,最终还是经受了实践的考验,后又为C#所采纳。其实最初C++的抽象类是为了定义一组协议并强令各子类遵守,实质上正是Java和C#中的接口所起的作用。但在协议规范的实现过程中,可能会产生一些不完全实现类。允许这种类的存在固然是一种灵活的举措,但必须认识到它们与纯规范的抽象类已判若云泥。打个比方,如果把对象看作产品,把具体类看作一个制作产品的模具,那么接口就是模具的规格标准,而抽象类是在模具加工过程中产生的半成品。接口与抽象类无法实例化,模具规格与模具半成品也不能直接制作产品;一个具体类可以有多个接口,一个模具也可有多个不同方面的规格;一个具体类至多只能继承一个抽象类,一个模具也至多只能在一种模具半成品的基础上直接加工。”

引号细加回味:“如果具体类、抽象类和接口分别对应于模具、模具半成品和模具规格,那后两者的区别的确比前两者的区别还大。可是假如一个抽象类完全没有任何实现呢?抛开多重继承的限制,它与接口又有何区别呢?”

冒号辨析其别:“一个抽象类可以没有任何实现,但也随时可以加入实现。接口则不同,永远都不能有实现代码。这正是引入关键字interface的目的,明明白白地表明:此乃规范集合所在,杜绝任何自以为是、画蛇添足的实现。初看似乎不合常理:这不是自缚手脚、自废武功吗?殊不知自由源于自制。许多人为了贪恋一点点代码重用,总忍不住把一些实现放在本该只是规范的地方。一来,这模糊了规范与实现的界限,背离了接口与实现相分离的设计初衷。要知道,再完美的实现都有改动的余地,将其捆绑到规范中只会增加不稳定因素;再完美的实现也不应该影响其他的实现,先入为主只会降低灵活性。二来,带有实现的抽象类无法用于合成,必须通过类继承才能起作用,而实现继承的弊端我们已经见识过了。在有些情况下,规范的实现比较复杂,需要渐进实现,保留一些中间状态的抽象类也是合理的,但最初的接口最好保留。总不能因为有了模具半成品,就抛弃模具规格吧?以Java Collections Framework为例,既规范了Collection、Set、List、Map等接口,又为这些接口提供了抽象类和具体类,从而给了用户三种选择:直接利用具体类、扩展抽象类、直接实现接口,方便程度递减而灵活程度递增。”

句号进行反思:“我在想,为什么以前对接口总有本能的排斥心理?原因在于:满脑子更多想的是怎么让程序工作,而不是想怎么让程序工作得更好。因此更重视代码实现,比较忽视规范设计。”

众人皆有同感。

“确实,在缺乏设计观念的人看来,使用接口和脱裤放屁差不多。”冒号轻笑道,“特别需要注意一种常见的说法:接口是为了克服Java或C#中抽象类不能多重继承的缺点。这句话具有相当大的误导性,因为该处的多重继承是指多重实现继承,而接口甚至连单重实现继承都做不到!许多人对接口与抽象类的认识之所以模糊不清,原因是他们习惯于从定义和语法中寻找表象的答案,不习惯从本源和语义上进行本质的分析。然而不可否认,毕竟接口与抽象类提供了相似的抽象机制,在实践中往往确难抉择。因此光从语法上对比二者的差别是远远不够的,需要进一步在语义上进行对比(如表10-2所示)——”

表 10-2. Java/C#的抽象类与接口在语义上的区别


关系 共性 特征 联系 重用 实现 重点 演变
接口 can-do 相同功能 边缘特征 横向联系 规范重用 多种实现 可置换性 新增类型
抽象类 is-a 相同种类 核心特征 纵向联系 代码重用 多级实现 可扩展性 新增成员

冒号展开叙述:“先从本性上看:接口是一套功能规范集合,因此相同的接口代表相同的功能,多表示‘can-do’关系,常用后缀为‘-able’的形容词命名,如Comparable、Runnable、Cloneable等等。接口一般表述的是对象的边缘特征[12],或者说一个对象在某一方面的特征,因此能在本质不同的类之间建立起横向联系。由于一个对象可拥有多方面的角色特征,故而可有多种接口。与之相对地,抽象类是一类对象的本质属性的抽象,因此相同的抽象基类代表相同的种类,多表示‘is-a’关系,常用名词命名。抽象类一般表述的是对象的核心特征,只能在本质相同的类之间沿着继承树建立起纵向联系。由于一个对象通常只有一个核心,故而只能有一种基类。再从目的上看:接口是为了规范重用,让一个规范有多种实现,看重的是可置换性;抽象类主要是为了代码重用[13],能逐级分步实现基类的抽象方法,看重的是可扩展性。”

叹号追问:“演变指的又是什么呢?”

冒号答道:“严格说来,演变不属语义范畴,属于语法规则的一个推论。在系统演变过程中,接口与抽象类的表现差异很大。接口由于是被广泛采用的规范,相当于行业标准,一经确立不能轻易改动。一旦被广泛采用,它的任何改动——包括增减接口、修改接口的签名或规范——将波及整个系统,必须慎之又慎。抽象类的演变则没有那么困难,一则它在系统中用得没有接口那么广泛,更多地是家庭身份而非社会身份;二则它可随时新增域成员或有默认实现的方法成员[14],所有子类将自动得以扩充。这是抽象类的最大优点之一。不过接口也有抽象类所不具备的优点,虽然自身难以演化,但很容易让其他类型演化为该接口的子类型。例如,JDK5.0之前的StringBuffer、CharBuffer、Writer和PrintStream本是互不相关的,在引进了接口Appendable并让以上类实现该接口后,它们便有了横向联系,均可作为格式化输出类Formatter的输出目标。”

问号还留有一个疑点:“现在接口与抽象类之间的差异是越来越清晰了,我只是有一点一直没想通:标记接口究竟有什么用?它一个方法都没有,也就谈不上规范,也无法利用多态机制,继承这类接口又有何意义呢?”

逗号随口说:“这就好比有些社会身份是光挂名头不干事的虚衔,不足为奇。”

冒号回应道:“先需澄清一点,一个类型的规范不限于单个的方法,类型整体上也有规范,比如主要目的、适用场合、限定条件、类不变量等等。另外,接口的目的是为了产生多态类型,不能只看到‘多态’而忽略‘类型’。一个接口哪怕没有一个方法,也是有意义的。首先,接口是一种类型,有严格的语法保障和明确的语义提示,这也是静态类型的优势所在。让一个具体类继承特定接口,既凸显了设计者的用意,也授予用户针对性地处理该类型的权力。比如java.util.EventListener接口为所有的事件监听器提供了统一的根类型。其次,有时需要对某些类型提出特殊要求、提供特殊服务或进行特殊处理,而这些并不能通过公有方法来办到,也没有其他有效的语言支持。标记接口可担此任,成为类型元数据(metadata)的载体。比如给一个类贴上一个java.io.Serializable的标签,它的对象便能被序列化[15],具体工作由JVM来完成。用户也可以通过自定义私有的writeObject 、readObject等方法来定制序列化方式。值得指出的是,当标记接口仅仅用于元数据时,更好的办法是采用属性导向式编程(@OP),Java中的annotation、C#中的attribute即作此用。”

逗号摸了摸后脑勺:“原来标记接口并非虚有其名,还是在偷偷地干实事呢。”

冒号见时候已到,准备落下帷幕:“至此,我们探讨了OOP中最基本的机制——封装、最独特的机制——继承、最重要的机制——多态。在今天的课结束之前,请大家每人用一个关键词来形容自己眼中的OOP,并作简要说明。”

引号说:“责任——在契约化的公民社会中,最重要的是对自己、对家庭、对社会的责任感。”

问号说:“变化——采用封装以防个人之变,慎用继承以防家庭之变,采用多态以防社会之变。”

逗号说:“分合——数据与运算结合,接口与实现分离。”

句号说:“抽象——无论是封装、继承还是多态,都是施诸众对象之上的抽象机制。”

叹号说:“虚伪——用封装来掩盖内心,用多态来掩盖外表,提倡继承责任却不提倡继承财富!”

冒号欣赞道:“不错不错,虽然角度各异,但均深中肯綮。我也大可安心下课了!”

众人也乐得打道回府。

,插语

  1. C#中表示列表和映射的抽象类型(具体类型)分别是IList(List)和IDictionary(Dictionary)。

  2. 此处主要指以继承为基础的抽象类型,如接口与抽象类。

  3. 在C++中,如果一个类不含数据只含抽象的成员函数(即pure virtual function),则该类有时被称为纯抽象类(pure abstract class),与Java和C#中的interface的功用大致相当。

  4. 参考文献【4】和【5】对trait有深入的介绍,并与mixin作了详细的比较。

  5. 该问题的一个解决方式是依赖注射,即把创建被合成对象的职责交给外界。但严格说来这不是合成,而是聚合或关联。它们之间的详细区别请参见§11.2。

  6. C++可利用CRTP(Curiously Recurring Template Pattern)的惯用法来实现mixin。

  7. 指C#3.0的扩展方法(extension method)。

  8. 第一个支持OOP的语言是Simula 67(1967年)。

  9. 例外情形:Java的interface可含static final域成员,C#的interface还可含property、event或indexer成员。

  10. 虽然具体类型有可能被继承,但通常并不提倡。

  11. 据参考文献【1】中介绍,一些大型系统在引入抽象类后,编译时间少了一个数量级。

  12. 接口也可能描述对象的核心特征,但一个类至多一个这样的接口。

  13. 由于类继承同时也继承了接口,抽象类也能规范重用,但更侧重代码重用。

  14. 前提是新增的方法成员不与子类型的方法发生冲突。

  15. 严格说来,还要求该类所有非static非transient的域都是可序列化的。

。总结

  • 具体类型是创建对象的模板,抽象类型是创建类型的模块。

  • 抽象数据类型的核心是数据抽象,而抽象类型的核心是多态抽象。

  • 抽象类型除了接口和抽象类外,还有mixin、trait等,它们用来克服以下问题——

    合成的缺陷:

    用法不如继承那么简便优雅;

    不能产生子类型;

    无法覆盖基础类的方法,也无法访问它的protected成员;

    不能以抽象类型为基础类。

    具体类型的矛盾与缺陷:

    作为创造对象的单位,功能越多越好;

    作为可重用的单位,功能越少越好。

    不宜被继承。

    接口的矛盾与缺陷:

    客户类希望它提供尽可能多的服务;

    实现类希望尽可能少的实现代码。

    无法代码重用。

    抽象类的缺陷:

    多重类继承或复杂晦涩或未获支持。

  • mixin的特点:抽象性和依赖性;实用性和可重用性;专一性和细粒度性;可选性和边缘性。

  • 在设计阶段,从具体需求中构建出抽象模型,此时抽象类型尤为关键;在实现阶段,根据抽象模型来完成具体实现,此时具体类型更为重要。

  • 抽象类型除了能创建类型外,还能提供动态节点,以增加软件的灵活性和可扩展性。

  • 社会身份代表了个体与社会缔结的一种契约,具有独立、稳定、公开、权威、规范、开放等特点。

  • 以社会身份作为公民之间联系的纽带,以接口类型作为对象之间联系的纽带。

  • 系统中广泛用于模块之间通讯的数据类型相当于社会身份,提倡使用接口类型。但也不必拘泥,甚至不必限于抽象类型,关键是要确保该类型的通用性、规范性、稳定性、独立性、灵活性和专业性。

  • 个体身份对应的规范抽象借助封装,以数据抽象的形式出现;家庭身份对应的规范抽象借助继承,以类型层级的形式出现;社会身份对应的规范抽象借助多态,以多态抽象的形式出现。

  • 从具体类中分离出抽象类是一次质的飞跃,从抽象类中分离出接口则是另一次飞跃。

  • 接口与抽象类的语法区别:接口不能提供实现但能多重继承,抽象类则正相反;接口只能包含公有的、非静态的、抽象的方法成员,抽象类则无此限制。

  • 接口与抽象类的语义区别:接口是一套功能规范集合,相同的接口代表相同的功能,多表示“can-do”关系,一般是对象的边缘特征,在本质不同的类型之间建立横向联系;抽象类是一类对象的本质属性的抽象,相同的抽象基类代表相同的种类,多表示“is-a”关系,一般是对象的核心特征,在本质相同的类型之间建立纵向联系。接口看重规范重用和可置换性;抽象类看重代码重用和可扩展性。

  • 接口与抽象类的演变:抽象类的演变较为容易;接口自身很难演变,但很容易让其他类型演变为它的子类型。

  • 标记接口除了能定义类型外,还可作为类型元数据的载体。

“”参考

  1. Bjarne Stroustrup.The Design and Evolution of C++.Reading, MA:Addison-Wesley,1994.277-281

  2. Joshua Bloch.Effective Java: Programming Language Guide.Boston, MA:Addison-Wesley,2001.84-88

  3. Nathanael Schärli,Stéphane Ducasse,Oscar Nierstrasz,Andrew P. Black.Traits: Composable Units of Behaviour.ECOOP,2003,LNCS 2743:248–274

  4. Stéphane Ducasse,Oscar Nierstrasz,Nathanael Schärli,Roel Wuyts,Andrew P. Black.Traits: A Mechanism for Fine-grained Reuse.ACM Transactions,2006,28(2):331-388

  5. Wikipedia.Mixin.http://en.wikipedia.org/wiki/Mixin

课后思考
  • 10-01 多态类型在何种程度上解放了静态类型的束缚?
  • 10-02 请总结参数多态与子类型多态的特点和适用场合。
  • 10-03 为什么抽象类型如此重要?
  • 10-04 你认为有必要引入mixin或trait类型吗?
  • 10-05 区分接口与抽象类的意义何在?
  • 10-06 你常有往接口中放置代码的冲动吗?
  • 10-07 如何理解文中“多态使得公民拥有社会身份”这句话?
  • 10-08 “针对接口编程”与“公民之间以社会身份互相交流”有何相似之处?
  • 10-09 你是如何理解OOP中抽象、封装、继承和多态的?
  • 10-10 每当一项新技术出现时,你通常抱什么态度?
  • 10-11 你会在编程中对某些语法上的限制感到恼火吗?
  • 10-12 在理解或比较一些编程概念时,你是更习惯从定义和语法的角度,还是更习惯从本源和语义的角度?
  • 10-13 本课与前课均提到了编程与武术相通之处:攻守兼备,动静得宜,刚柔并济,虚实结合。对此你有何心得体会?

友情提示:如果您对本文感兴趣,欢迎到http://blog.zhenghui.org/上发表评论

又及:《冒号课堂》一书即日上市,详情请见:豆瓣主页

posted on 2009-10-28 08:16 郑晖 阅读(3430) 评论(0)  编辑  收藏 所属分类: 冒号课堂


只有注册用户登录后才能发表评论。


网站导航:
 

导航

统计

公告

博客搬家:http://blog.zhenghui.org
《冒号课堂》一书于2009年10月上市,详情请见
冒号课堂

留言簿(17)

随笔分类(61)

随笔档案(61)

文章分类(1)

文章档案(1)

最新随笔

积分与排名

最新评论

阅读排行榜

评论排行榜