Live软件开发面面谈
上QQ阅读APP看书,第一时间看更新

1.6 有必要针对接口编程吗

到现在为止,我们讲的都是针对接口编程的意义、消除依赖的实现方式,仿佛针对接口编程是一个先验的、放之四海而皆准的真理。市面上介绍编程的教程和文章、网络上分享经验的博客和帖子,无论是不是关于针对接口编程的主题,有许多在代码样例中每创建一个类前都先定义一个接口(在后面的讨论中不妨简称为接口先行),而且在文字中透露出这样做不言而喻的正确性。这时候,人们天生的怀疑精神又有用武之地了。有必要在一切场合都针对接口编程吗?或者更准确地说,什么情况下应该针对接口编程(什么情况下不需要、不应该)?

1.6.1 针对接口编程的成本

本章先前所论述的针对接口编程的好处都是真实的,与此同时还有一点也是真实的,就是针对接口编程的成本。不这么做时,调用者直接创建被调用者的实例,一行代码足矣。这么做时,先要抽象出被调用者的接口,让具体类型实现该接口,然后采用工厂、服务定位器或依赖注入的模式,还要设定配置文件、惯例或元数据,多写无数行代码。我们日常买东西的时候会讲究性价比,写程序时自然也要考虑这样做值不值得。如果为每个类都定义一个接口,一个项目里的代码文件数量就几乎要翻一倍。而且上述接口先行的样例往往是定义完接口就了事,针对接口编程所需的配套工作都略而不谈,让这些代码表面上既用到了接口,又不甚烦琐。实际上假若真将这种接口先行的方式贯彻到项目开发中,任何人也坚持不了——为被调用者创建了接口,那调用者要不要也有接口,工厂、服务定位器和依赖注入的容器要不要创建接口,解析配置文件和元数据的对象要不要接口,这些对象用到的任何一个哪怕是辅助的提供方便的对象要不要接口……项目代码的主体将变成实现针对接口编程的脚手架(Scaffold),业务逻辑反将退居其次。所以无论多么主张接口先行,最后要面对的问题都是什么情况下需要为类创建接口并针对接口编程?

1.6.2 接口的意义

针对接口编程是手段,目的是消除依赖。本来调用者使用被调用者的类型,针对接口编程后,调用者使用被调用者遵循的接口。为什么使用类型是依赖,使用接口就消除了依赖呢?如果说在前一种情况中调用者依赖了被调用者的类型,那么后一种情况为什么不说调用者依赖了被调用者的接口?可以从两个角度回答这个疑问。

首先,可以说“被调用者的类型”,即该类型是属于被调用者的;但严格地讲,我们不能说“被调用者的接口”,因为该接口不属于被调用者,同样也不属于调用者。对于需要合作的调用者和被调用者双方,接口是第三方的中介。虽然有时把名称上体现被调用者共同点的接口看成是它们的代表(如ICodec代表MP3Codec),又或者反过来把接口看作调用者对被调用者需要的功能的抽象,但实际上就像1.2节中最后的图例所示,接口是独立于调用者和被调用者的。所以将调用者使用的被调用者的具体类型换成接口后,调用者就不再依赖被调用者了,那么对第三方接口的引用算不算依赖呢?

其次,接口本质上也是类型,和普通类型的差别就在于它不包含具体的实现。一个概念的内涵越大,外延就越小;反之,内涵越小,外延就越大。用另一种方式来表述就是,一个对象越具体,应用范围就越小,有效时间越短,越容易发生变化;反之,一个对象越抽象,应用范围就越广,越稳定。【注:与之相应的是,越具体的对象在它的小范围内发挥的作用越大,越直接;越抽象的对象在它的大范围内发挥的作用越小,越间接。这个哲学的陈述有各种场景的应用(因为它本身就很抽象)。比如说对他人的关心,我常常发现一个人关心的人越少,感情就越浓烈,像一些溺爱子孙的父母和老人,大部分心思都用在孩子身上,吃穿用住具体到无以复加;而那些关心社会大众胸怀天下的大人物,对身边的人关心的强度却不怎么高。卢梭关心人类社会不公正的来源,写《社会契约论》和《爱弥儿》,对人类的爱很普遍、很抽象,自己的孩子却生一个抛弃一个。很多哲学家视婚姻为累赘。西方社会的人际关系和中国相比,正是亲人不亲,外人不外。】接口因为不包含具体的实现,是最抽象的,因而与实现它的类型相比,应用范围最广,最稳定。所以当调用者使用在广大范围内长久有效的接口时,依赖的问题就失去意义了,因为依赖一个对象本身不是问题,问题是发生在对象失效或者需要替换时。

由上述讨论可见,接口的第一个意义是它的高度抽象和由此带来的广泛适用性和稳定性。

提倡针对接口编程的人往往会这样推销:接口是对一个对象行为和功能的描述,是对象暴露给外界的信息的总和,是对象之间交流的契约。如何实现接口则应该是对象的隐私,是彼此间不知道也不应该知道的内部细节。一旦调用者获取了被调用者接口之外的信息,例如通过被调用者的具体类型创建实例,或者调用了接口以外的方法,被调用者就失去了替换和修改的自由。这些堂皇的陈述看上去都很正确,但问题是怎样理解“接口”。我们在学习面向对象编程时,都了解到相较于过程式编程,对象有三大好处或者说特点:封装、继承和多态。封装的意思就是一个对象只暴露它想暴露的方法和属性,外界无须知道的则隐藏起来。为此语言设计者发明了一堆存取限定符:public、private、protected、package,以精确地区分对象的信息对外界的可见性。那些标记为public的公开方法不就是一个对象的“接口”吗?调用者只要通过这些方法来使用,被调用者的私有方法不还是可以自由修改和替换吗?甚至更进一步说,即使在过程式编程中,一个函数暴露给外界的也仅仅是它的签名(名称、参数和返回值),实现的代码同样是隐藏的和可以修改的,函数的签名不就是它的“接口”吗?所以说,我们在Java这样的静态强类型语言中所说的接口,也就是语言中的Interface的要义不在于它包含的是一个对象公开的信息,而是这些信息是多个类型的对象共同具备的,也就是说,它描述的是多种对象具有的公开的共同点,因此另一个对象如果是通过这些共同点来使用这些对象中的一个,就可以随时替换成其他任何一个。这是接口的第二个意义。

1.6.3 何时针对接口编程

理解了接口的意义,也就有了前述有关问题的判别准则。

任何注定不会有多个实现的接口都是不必要的。

这里的注定不是中国男子国家足球队注定战胜不了巴西队的注定,几十年后,沧海桑田,一切皆有可能。总之,这里的注定指的是开发者能预知的必定。在为一个类创建接口前,开发者可以略微思考一下在可预见的将来该类是否会一直是这个接口的唯一实现。很多时候,这样的判断并不是太难。比如写一个在Word文档中插入代码的小工具,又或者为一家美容公司开发的项目中针对该公司特定规则的业务逻辑。用户需求的特殊性决定了代码的针对性(特殊性、选择性),从而不会产生多个对象遵循同一接口而实现细节有差异的需要。需求的易变性(缺乏长期信息化的积累、最佳实践和行业标准)导致代码的频繁改动,会使定义接口失去基础。项目的规模、时限等因素引致的资源和进度紧张让开发人员没有时间去细致地设计接口,为不同的实现预留空间。

上面的判别准则是否定的陈述,我们还需要从肯定的角度来看什么情况下接口是必要的。最简单的是完全相反的情况,凡是注定有多个实现的接口都是必要的。很多场合也是很容易做出这样的判断的。比如我们一直使用的编解码器的例子,具体编解码器类型不仅肯定有多个,而且会随着新的媒体格式的出现和现有编解码器的改进和尝试而不断增长。又比如Java和C#的Collections类库中的各种接口,在设计的时候就预知会有多个实现类,并且将来还会不断有类型因需要而实现。这些场合也有共同的规律可循。开发类库时,常常会有一些对象的行为是基础的、许多类型共有的,这样的行为就适合被抽象为接口。设计框架、架构和标准时,任务的目标往往就是抽象的接口,具体如何实现在设计时既有可能不清楚,更有可能是有意留给标准的参与者和遵循者未来去完成。软件公司和开源组织推出可扩展的产品时,为了第三方能够开发插件,必须提供接口。

然而许多程序员日常开发的项目不属于这类情况——定制化的用户界面和业务逻辑,系统完全在公司或组织内部完成,没有留给第三方开发的可能和空间。当处于上述正反两个凡是准则的中间地带时,就需要具体情况具体分析。特殊的、易变的、周边的对象倾向于不需要接口;普遍用到因而可能产生变体的、稳定的、核心的对象倾向于需要接口。项目和团队的大小也很有关系。项目越大,需求越多,建模形成的系统越复杂,就越容易演化出多个类型遵循一组共同行为的结构。另一方面,系统越庞大,对清晰的结构、稳定性和可扩展性的要求也就越高。项目规模大的一个衍生品是开发团队人数多,随之而来的是任务分解、分组负责,每个小组需要能够独立开发负责的模块,模块又能方便地合作以完成最终的产品,这就要求在各个模块间定义清晰的接口,实际上这种情况相当于前面所说的软件公司开发可扩展的产品,第三方提供插件,本质都是一个系统不是在一个开发人员群体内部完成,该群体的人在设计时必须想到系统有部分功能是不在自己控制范围之内的,必须通过接口与他人合作。如此一来,就有一个项目开始时不大,后来因某种原因越变越大的情况,该如何处理?也就是说,系统成长后,有些对象需要接口了,而最初设计时根据前述的标准和考量是不需要的。我们应该未雨绸缪,一开始就多定义接口吗?那样就回到前面否定的接口先行的老路上去了。还有一个不那样做的可行性上的理由是,在系统诞生时就预先设计好将来会用到的接口几乎是不可能的,随着需求和目标的扩展和变化,依据用户的反馈,包括引入接口这样结构上的变化是不可避免的,这也是重构在项目开发中的重要性所在。而且幸运的是,借助现代开发环境的重构工具,从一个类提取接口的工作可以轻松完成。