我刚开始进博客园的第一篇文章是不是就是说抽象这个事的?时光荏苒啊.... 有段日子不上了,刚才在园子里看见这么篇文章,觉得有必要就我这些年的思想进展,重新讨论下这个问题。
原文在这里:http://www.cnblogs.com/yuyijq/archive/2011/04/26/2028789.html,不长,一定要读一下好知道我这篇文章讨论的基础,我就不重复文章中的重构过程了。类似的例子几年前在博客园的评论上探讨过(好象是跟伍迷),不过现在重新再看这个问题,我个人是清晰了不少。
我们先讨论 IsValid(三个String参数一个Int参数) 重构为 IsValid(User) 的情况,因为弄清楚这个,其他的在很大程度上也就是不言自明的了。
文中的这个User和EMail的提取和打包,其实不是抽象,是具体化。
说到抽象,那个基于字符串的接口,虽然可能大家会觉得这很诡异,但我仍然要说,那才是真正的抽象。说白了就是y=f1(x1,x2,x3,x4),其中x1,x2,x3,x4分别代表原文中对应的变量,这都是为了得出y,f1不可缺少的自变量。
再看变成基于对象的方式,y=f2(x),x现在的概念是个User,事实上这个概念是f2根本不关心的;而且无论你实现别的功能没有,User这个概念都天生隐含着超出f2所关心的属性。这就好比丈量面积,你不用线和面,却偏偏要用墙和院子这样的概念。
抽象的定义是抽出仅当前问题关心的属性。
那么看f1和f2,哪个更符合这一定义呢?即便不这么学究化,是User更具体呢,还是String更具体呢?诚然,我们关心的是类似于“用户名”、“电子邮件地址”这样的东西,但在f1这个抽象中,它必须关心的仅仅是那三个String一个Int的值。
同时,即便我们把这些变量打包到一个具体的模型上去,就作者使用的这个编程语言而言,我们也不可能使得附属于这个模型的字符串得到什么约束。我还是可以错把邮件地址赋给UserName而把用户名赋给EMail;当然,出错的地方肯定变了。
(学究化的问题:根本地,我们当然关心这个字符串是不是用户名;可上面说了无论打不打包,我们也无法保证它。但这真的是没法解决的吗?我个人的答案是这至少可以部分解决,但需要语言提供新的、真正表达概念而不是建立具体化模型的能力)
文中的IsValid经重构,丧失了它本来有的最大的通用性并且变成了对变化敏感的。
由于f1只关心必要的属性,这就使得他产生了最大的通用性和最小的依赖。而f2由于依赖一个接口,这就产生了耦合。大家都把解耦当作面向对象的一个用途,却总是通过具体化不断的产生对接口的依赖。这最终会造成f2虽然明明不用改动其内在,但却大大缩小了复用的范围同时也就容易被变化影响;而这正是面向对象的所要解决的。
道理很简单:其一是有了User、Email这样的具体化(无论是不是纯接口)以后,这些更大的概念必然会承担更多的工作,各种各样的设计都可能导致接口变化从而殃及池鱼;其二,如果一个对象,包含属性x1、x2、x3、x4,但却属于另一个接口,那么f2是不可用的。
当然,我们可以把x1、x2、x3、x4做成一个纯接口Z的属性,并让具体化对象(在这里是User)继承这个接口。但这在很多时候是得不偿失的:为了f3(Z)的效果,我们只是把f1(user.x1、user.x2、user.x3、user.x4)的工作放到了User继承接口Z的代码里。每一个新来的,如果要利用f3,哪怕只用f3一次,也要实现Z接口,或者包装到一个实现Z接口的类中。而若Z只是为了f3一个方法准备的,这代价未免太大了些。
文中的例子只是基本的网状模型,是“基于对象”而不是“面向对象”。
显而易见的,面向对象只是一个名词,怎么叫没有太大意义。但是我们要采取可以达成一致的共识,使得交流更加容易。判断“一个设计是否是面向对象的”,有一个非常明显的特征,就是是否是使用了多态。
类似定义被不同的权威做出过,比如C++的作者Stroustrup,我在这里就不重复了,我的想法是既然无力改变,只能跟从。文中的例子很显然并没有利用多态(当然文中的例子也没有多态的用武之地)。
对象使用的便捷性 vs 通用性+可重用性
为什么文中的这个例子如此具有代表性、似乎每个人都经常这么做?一个理由可能是现代化IDE提供的对网状模型的使用的便捷性,而且人们总是不自觉的做出实体可能被复用的假设。不过,一个需要注意的事实是:单纯复用实体并省不下多少工作量。
当然,基于对象的网状模型通过具体化带来的便捷性(这里的便捷性是基于User导航到其子属性考虑的,不是从IsValid考虑的),不仅是那些领域爱好者,即便是我也爱不释手的。在动态语言如JavaScript中,由于我们没有“死”接口,基于字典的对象使用起来就很方便,又没有上述种种损失(如果忽略打错字造成的麻烦的话)。为什么呢?
这是因为,字典作为一种通用接口,我们在使用时,往往基于人为的约定和协议。比如这个例子里,你传进来的字典必须有name字段等等。去迎合这些约定,其手工成本要比实现接口低得多,而且一个函数签名可以兼容所有的字典:没有类,只有ducktypes。
那么在静态语言里又怎么办呢?很遗憾,我们没有太好的办法。要么丧失通用性并承担可能的接口改变所造成的风险、要么放弃对象属性导航使用上的便利性;或者对于相对大型的项目而言,最可靠的办法应该是小粒度的纯接口。不过即便在最后一种情况下,我们也应该仔细衡量接口的设计,心里始终记得一点,接口是那种一旦发布并被四处依赖,就很难再改变的东西。
更多
接下来看看原文作者的接下来的重构:把IsValid放入User,既然IsValid严重耦合与User的接口这也就是必然的了(让我们回忆下GoF95一书的副标题吧..);引入Email类,使得我们又多了一个更具体的概念去依赖。由于这些重构没有一个是基于最小化接口的(而是创造实体),与作者的结论相反,这些行为不是提升而是降低了抽象的层次。
这里值得引起我们兴趣的是作者虽然是在进行属性打包的工作,最后又部分的走向了拆分。我个人倾向于,在这来来回回的过程中,就会得到粒度合适于项目及项目可能的变化的接口。虽然这个接口可能不是最符合抽象原则的(而f3(Z)显然符合),但一般会是一个好的平衡点。
即便在现实工作中,我们不得不经历这样的反复,但是探讨何时开始使用基于对象的能力仍旧是必要的(和很多人的直觉相反,多态的面向对象倒是很容易找到用武之地:它总是对应着预期的未来的变化)。在我看来,若有一组操作:f3、f4、f5...,都可以接受一个粒度相对合适的Z,那么Z的创造就成为必然的了(这说的是Z主要作为接口)。
另一方面,原文作者提到的信息隐藏也是一个考虑因素。对于命令式语言,很多时候状态的维护都是不可避免的,有时候,一些状态要严格的保证一致性(这里需要说明的是,纯粹的信息隐藏不具有任何意义),比如状态a、b,他们之间有一定的联系,我们不能只改变a而不改变b,由于使用者可能不了解这种复杂性,我们就需要把它们封装起来。
最后的废话
园子里不是推荐一篇《程序设计的 Top 10 做与不做》说“面向对象比你想象中的还难很多”么,我重复过无数遍的bob大叔的说法也是如此。这里不讨论面向对象的优劣,对我而言,面向对象当然有其用武之地,但是我们必须有明确的目的:这目的除了上述何时使用“基于对象”的因素,便是需要多态发挥作用的时候,比如GoF95中所描述的那些使用场景、比如IoC等等。
希望我这篇文章中阐述的我的一些经验,能对大家有点用途。我的整体建议是,我们最好还是对面向对象抱点敬畏之心。这整套方法加上各种权威的草根的书籍文章,其中的陷阱不可胜数啊....