本文发表在《程序员》2009年第四期(总第100期)
本文列出了我在平时发现和积累的在面向对象编程中一些常见的“不够面向对象”的情况。
需要指出两点:
1.我们虽然列出了这九种情况,但并不是说出现了下面的情况就一定有问题了;我们希望读者这可以将其作为一种信号——仔细考虑一下是不是有更好的设计。
2.我们这里所说的面向对象的对象特指领域对象,即对象中包含领域数据和业务逻辑。
要确定不够面向对象的对象,首先要了解什么样的对象算是面向对象的,或者说好的面向对象的对象。关于面向对象设计的原则从不同的角度有很多种说法,我们这里采用一种比较简单的说法,即高内聚低耦合。所谓高内聚是指对象内的数据和方法是紧密相关的;所谓低耦合是指对象之间的依赖应当比较小,一个对象发生改变时不应当对不相关的对象产生影响。
一. 低内聚对象
我们把低内聚对象分为两种:一种是应该属于该对象的行为和数据分散到了其他对象中;另一种是该对象内部的行为和数据关系不够紧密。下面的1、2是属于前一种情况,3、4、5则是属于后一种情况。
1.贫血对象(Anemic Object)
瞧,那条贫血的狗!
故事的发生是这样的...
你养了一条宠物狗,在学习了面向对象编程之后,你打算为这条狗设计一个面向对象的系统。于是,根据你在C语言编程时的开发经验,结合你对“封装”二字的理解,你设计了这样一条狗:)这条狗由四部分组成:头、身子、腿和尾巴。
图 1
隔壁住着一位面向对象大师——法号鉴摩,你拿着设计图给他看。鉴摩大师只扫了一眼便说:
没有行为的对象不是好对象。
你似懂非懂地点了点头,正要往下说,大师挥了挥手说:“你明天再来罢。”
如果一个对象只有数据没有行为,它就是一个贫血对象,它只能被别人操作,或者作为某个操作的结果。对于简单的getter和setter,我们一般不将其归为领域行为。所以,上面这个对象就是一个贫血对象。这条狗还不会叫、不会跑,甚至还不会摇尾巴讨好你,真不知道你养这样一条狗干啥。
处理贫血对象时可以考虑把操作对象数据的行为移动到这个对象里面。对数据的封装只是面向对象中“封装”这个概念的一部分,我们的对象中除了封装数据还应当封装行为。
对于跟物理世界一一对应的对象,一般来说,我们不容易犯这样的错误。我们不妨来看一个实际工作中遇到的例子。在某个商店收银系统中,有一个对象叫做Product,它被设计成这样:
图 2
这个Product就是一个贫血类。单纯看这个类,是没有什么问题的。我们需要结合其他的类来观察。由于不同类型的产品打印方式不同,计税规则也不同,所以我们还有一个处理Product的类:
图 3
我们可以明显的看出在这两个类的方法中存在非常相似的代码结构。如果Product的类型出现扩展,我们在这两个类(Product、ProductHandler)里面都需要做修改。这不符合面向对象编程中OCP原则。对于贫血对象的改进应当考虑将相关的行为移动到对象里面。
图 4
如果我们发现相关行为移动到Product中去后ProductHandler所做的事情仅仅是将调用转发给Product,可以考虑将这个类消除。这里我们没有将Product形成继承结构,有兴趣的同学可以参考《重构》一书中的“以多态取代条件式”。
引申阅读:
1.《重构》一书种关于“以多态取代条件式”的内容。
2.管理者对象(Manager Object)
狗摇尾巴,还是摇狗尾巴,这是个问题!
第二天,你拿着另一个对象的类图去找鉴摩大师。你对鉴摩大师说:“我想大师昨天的意思是说我设计的Dog对象没有行为吧,其实所有的行为我都放到这个DogController对象中了。”
图 5
大师看了一下你的图,说道:“到底是狗摇尾巴,还是你在摇狗尾巴?”
你不解道:“这样有什么不同吗?”
鉴摩大师闭着眼睛说道:
不要问我,告诉我。
你更加迷惑了。不过你知道“知之为知之,不知Google之”的名言,所以你用大师的话为关键字Google了一下,还真有不少内容。
我们经常会看到一些类命名为:XxxxManager、XxxxHandler。这样类表面上是面向对象的,但其实质往往是面向过程的,只不过在外面包了一个Class而已。管理者对象往往是跟贫血对象成对出现的,业务数据保存在贫血对象中,而业务逻辑行为(或者从数据的角度来说也可以称为“对数据的操作”)则在管理者对象中。
管理者对象的问题是其中的各个方法之间的关系非常不明显,它们往往只是共享一个被操作的数据对象。去掉其中的几个方法,这个对象似乎还是一个完整的对象。上例中ProductHandler就是一个管理者对象的例子。
对于管理者对象,最基本的解决方法就是职责分组。首先创建或者从系统中找出相关的领域对象,尽量地将职责划分到多个领域对象中去。当管理者对象和贫血对象成对出现时,往往部分跟业务紧密相关的贫血对象既是领域对象。分层、数据字典都是常用的提取领域对象的方法。
3.储柜对象(Cabinet Object)
狗尾巴不见了!
第三天,你的朋友送了一条狗给你,并告诉你是在路边捡到的。所以,你需要在你的系统中再添加一只小狗啦。根据你丰富的内存管理方面的经验,你认为在内存中保留两个实例,实在是浪费,所以你扩展了一下API。你把新的设计方案交给鉴摩大师去看。
图 6
鉴摩大师冷漠地看了你一眼,仿佛看到一个陌生人似的,大师慢悠悠地说道:
今天的你不是昨天的你。
你一脸茫然地回到自己家里,突然发现狗尾巴不见了。谁调用了setTail(NULL)!
所谓储柜对象,是指它所有的数据都是可以通过setter动态设置的。也就是说getter返回什么或者对象的行为如何表现,完全取决于当时的设置了什么。这个对象中的数据,看起来就像临时分配的一块可读写的内存。
储柜对象的问题在于,我们编写和阅读代码的时候很难把握这种对象,因为其状态随时可能会被修改,而修改其状态的行为又分散在其他的地方。解决这个问题,可以先把储柜对象处理为Immutable Value,即在构造函数中传入必要的参数,只为那些可以动态修改的状态保留setter方法。如果有必要,还可以通过“以多态取代条件式”重构形成一个继承结构。
4.多管闲事的对象(Meddling Object)
你们家的狗会拿耗子吗?
你的朋友真好,第四天又送了一只猫给你。你已经开始庆幸自己学习了面向对象,这门支持“派生”的技术。你本来想直接从Dog派生一个Cat出来,觉得似乎有点问题。算了,你决定来个重新设计吧。为了用到面向对象里面最好用的技术——继承,你决定对狗和猫进行抽象,产生一个宠物(Pet)对象。好的,狗和猫有什么共同点呢?很快,结果出来了:
图 7
你甚至记得把Pet中makeSound和catchRat设计为抽象函数,让Dog和Cat分别实现。你高兴地拿给鉴摩大师去看。大师瞅了你一眼,问到:“你们家的狗会拿耗子?”
你狡黠一笑:“大师您看,我的catchRat是抽象函数,在Dog中实现地行为是‘do nothing’。”
“如果你们家有一百条狗,一百只猫呢?”大师说这句话的时候甚至连看都没看你一眼。过了一会儿,大师继续说道:
把变化的和不变的分离开。
你悻悻地回到家里,陷入了沉思...
对于一个对象而言,多管的闲事不属于自己的业务逻辑(虽然很可能有某种联系),我们应当把相关的代码完全隔离出去或者将相关职责委托给新的对象实现。隔离和委托的区别在于原对象是否持有新对象的引用。一般来说,委托的方式使用的更多一些,而且实现上也比较直观。对于上例而言,我们可以做如下的改进:
图 8
不恰当的抽象只是造成“多管闲事的对象”的原因之一。更常见的情况是,我们懒得为一个小功能创建一个新的类。比如下图就是一个实际工作中遇到的例子。
图 9
在计税的时候,我们需要做一些四舍五入的工作,这些职责本应该委托给一个工具类来完成。
引申阅读:
1.爱管闲事的对象违反了单一职责原则(SRP),容易导致设计不稳定。请参考有关SRP的文章。推荐《敏捷软件开发:原则、模式与实现》第8章。
2.《设计模式:可复用面向对象软件的基础》中关于在实现Composite模式时,安全性和透明性之间的权衡。
5.工具类(Utility Class)
你的设计越来越完善了!
第五天,你开始设计一些工具方法,给狗狗洗澡、喂食,可是这些方法放到哪儿呢?既然不能违反SRP原则,你决定每个工具方法设计一个类,但是看上去这些类也太简单了。
图 10
你把自己的想法和顾虑告诉鉴摩大师,大师摇了摇头,随后说道:
物以类聚。易懂易维护才是我们的目标。
说工具类不够OO听上去有点奇怪,因为它根本不需要实例化,所以也不会形成真的对象。工具类的典型特征是里面的函数都是静态的。这些静态的函数之间往往没有必然的联系,甚至都不会共享数据,所以它们本质上是非内聚的。这里,并不是说不应当有工具类,而是工具类的角色很多时候都是提供一种转换或者值操作,不包含领域逻辑,因而不属于领域对象。把这些方法放到一个对象里面,就像给它们归归类而已。所以,如果一个类是工具类,就让它扮演好这个光荣的角色吧,别往里面放业务逻辑。如果有些转换明显跟业务逻辑靠得比较紧,而又不适合放到领域对象里面,可以将其单独做一个工具类,将其跟通用的、业务无关的工具类分开。
简单工厂类是工具类的一种,所谓简单工厂是相对于抽象工厂和工厂方法来说的,它只是根据输入值返回一个领域对象。
二.高耦合
高耦合一般表现为对其他类型的强烈依赖,一个对象发生变化会对其他对象产生剧烈的影响。我们的原则是尽量依赖于稳定的类型(或接口)。
6.原生类型依赖对象(Primitive Obsession Object)
这只狗的生日是01/02/03。
第六天,你决定在Pet对象中加入狗狗和猫咪的生日。这项工作对于你来说已经算不上什么难事了。
图 11
你把程序交给大师去看,大师输入了一串字符串,运行的结果是:这只狗的生日是01/02/03。大师问你:“这是什么意思?01年2月3日还是03年1月2日?”
你满脸冒汗,因为你已经不记得自己怎么定义的了。大师微笑着说:
不要依赖于你自己都会忘记的事情。封装之。
你回到家里,百思不得其解。“难道我值得为一个生日设计一个类吗?”
有的对象强烈依赖于语言的原生类型,比如字符串、整型数字等。正常情况下,依赖于原生类型是没有危险的,因为这些类型相当稳定,向着稳定依赖正是我们的原则。但是,如果我们同时依赖于这些原生类型的表达方式,比如字符格式、用整型表达的类型,会使得我们的系统设计变得不稳定。
我们再来看一个实际工作中的例子吧。
我们要分析两个城市之间的路径,有的同学将从城市A经城市B到达城市C的路径用“ABC”来表示,有的同学则用“A-B-C”来表示。如果对象依赖于这样的字符串,编程中就很容易出错,而且一旦表达格式发生了变化,程序还需要作出相应的修改。
一般来说,在系统中总是有一些对象要依赖于原生类型,但是我们应当尽量早地使用领域对象对原生类型做封装。比如,一开始的设计是这样的:
图 12
我们可以对route进行封装,使其不再依赖于字符串的格式。
图 13
这时候,要添加城市A只要调用Route:addCity("A")就可以了。
7.链式依赖对象(Message Chain Object)
一只狗拴一条链子就够了!
第七天,你的朋友跑过来说,他找到了那只路边捡到的小狗的主人,并告诉了你他的电话。你决定把这个电话记录在你的系统中。
图 14
你把设计交给大师去看。大师看了看类图就去翻你的代码,然后皱了皱眉头,用手指着一行代码“dog.getOwner().getAddresss();”说道:
决定一个对象好坏的是它的使用者。
你看着那行代码,若有所思的点点头。
且不说,这个代码违反了“Tell,Don't Ask”原则,就这种链式导航结构就会使得客户端与链条上的所有对象直接耦合。一旦对象之间的关系发生任何变化,都会引起客户端的变化,这违反了迪米特法则,又称最少知识原则。要解决这个问题可以在链条中找一个合适的对象添加一个函数。比如,上例中我们可以为Dog添加一个getOwnerAddress()函数。这样在客户端要取得主人的地址就只要依赖于Dog对象就可以了:dog.getOwnerAddress()。
我们要特别强调,上述解决方案只是最简单的方案之一,而且不一定是最佳方案。如果链式调用出现的次数不多甚至可以不做修改。
引申阅读:
1.《重构》中关于Message Chains的内容。
2.《程序员修炼之道》中关于迪米特法则的内容。
8.假对象(Dummy Object)
需要为邻居家的宠物单独设计一个类吗?
第八天,你的邻居看到了你设计的系统,非常感兴趣,希望你能把他们家的宠物也纳入进来。你非常高兴地答应了,因为邻居家的女主人非常热情。因为你的系统里面已经有了Owner的概念,你决定为邻居家的宠物派生一个专门的类。而且你把这个决定告诉了邻居mm,让她觉得你专门为她做了一件天大的事情。
图 15
你把新的设计交给大师去看,大师瞅了一眼,说:
多一个类就多一份牵挂。
你的脸一红,因为你不知道大师是在说设计还是说你。
系统中每增加一个类,系统的复杂性就会提高一点。每个类都是有代价的。尽管小对象往往是我们追求的目标,但是如果对象小到不仅没有专属自己的数据,也没有专属自己的行为,这样的对象还是不要的好。假对象经常出现在类的派生体系中。在倒数第二层的抽象类中已经做足了数据和方法,假对象往往只要在构造函数中填空就行了。图15正是这样的情况。
解决这种问题的一个方法是引入合适的“工厂”模式。比如,对于这个例子,我们可以将其修改为:
图 16
引申阅读:
1.《重构》一书中关于折叠继承体系、将类内联化的内容。
9.积木对象(Bricks Object)
创建一只狗到底要分多少步?
第九天,你对修改后的设计已经充满了信心。你把整个设计拿给鉴摩大师去看。鉴摩大师问到:“你怎么创建一只宠物?”
你说:“我先创建PetHead、PetBody、PetLegs和PetTail,然后把它们跟相应的生日和主人信息一起传入PetFactory的工厂方法中,就可以返回一只完整的宠物了。”
大师问:“你怎么保证别人创建宠物的时候记得这么复杂的步骤呢?”
你简直怀疑大师在故意为难你了:“难道我的PetFactory的参数列表不够清晰吗?你是说让我增加点注释吗?”
大师摇了摇头,轻轻地吐出两个字:
封装。
封装?你简直不相信自己的耳朵,难道我做了九天了,又回到了面向对象的原点?
当对象的创建可以分为多个步骤时,为了防止在步骤上出错,我们经常要对这些步骤进行封装。否则对象的创建将依赖于(耦合于)代码中未指明的步骤,这可不是闹着玩的。同样道理,如果某件事情要求对几个函数按照某个顺序进行调用,也需要对其进行封装。
封装对象创建的步骤经常采用Builder模式,当然我们也可以采用比较简单的方案,即尽量在被创建对象的内部创建自己的各个组成部分。封装对几个函数的顺序调用,为其另外提供一个函数在该函数中按照要求的顺序完成调用。图17是采用内部创建各组成部分的方式设计的对象继承结构。
图 17
引申阅读:
1.《重构与模式》中关于组合方法、链构造函数、用Creation Method替换构造函数、用Builder封装Composite的内容。
2.《设计模式:可复用软件对象的基础》种关于创建型模式的内容。
第十天,大师问你:“什么样的对象算是好对象呢?”
你说:
好的对象添一分则嫌多,减一分则嫌少。
大师笑而不语。翌日,你再去找大师,大师已经离去了。
结语
实际上,对于如何认识和理解面向对象,业界也有很多争论,其中有代表性的是斯堪的纳维亚学派(Scandinavian school)和美国学派(American School)。前者强调对真实世界中的“对象”建模——即类是由一组数据和支持这些数据的方法组成;后者强调行为封装——即类是由一组方法和支持这些方法的数据组成。前者的典型代表语言是Simula,后者的典型代表语言则是Smalltalk(我并不想较真,但是较真的读者可能会看到一些说法认为Simula是Smalltalk之母的说法,恕我不擅考究)。以GoF、Martin Fowler、Robert C. Martin等人为代表的美国学派在这场争论中占有优势。实际上,本文从某种程度上也是引导读者从斯堪的纳维亚学派向美国学派靠拢。
面向对象技术出现的目的是让编写代码更容易,然而有意思的是,对于初学者来说面向对象语言要比面向过程语言更难理解和接受。实际上,要做出好的面向对象设计需要长时间的经验积累。我在上面列出了一些相关的书籍,除了阅读之外更重要的是要在实践中摸索和体会。掌握了基本地面向对象设计的技能之后,可以继续学习有关设计模式、重构、测试驱动开发等内容,这些对于深入理解面向对象概念有很大的帮助。
在面向对象编程中,考察对象设计的好坏关键是看该对象的客户端是否能够方便地使用它;它所应用的环境中是否体现出自己的价值,特别是在环境和需求变化时是否能够比较容易地适应。这句话反过来也是成立的,即我们在设计对象的时候也应当从环境和客户端的角度去思考。这种思路往往能给我们带来额外的好处,比如容易测试、容易面向接口编程、容易实现依赖倒置。这是一个更加深入的话题,希望有机会跟大家分享和交流。
后记
当我开始写这篇文章的时候我就意识到这不是一个容易成文的话题,因为不够面向对象的情况实在是太多了,不可能用九种来概括。从某种意义上讲,Martin Fowler在《重构》一书中所列出的“坏味道”都属于不够面向对象的情况,或者至少说是“不够好的面向对象”。所以本文列出的九种情况不追求全面,更不追求正交,而是追求实用。我的目的是为刚刚接触面向对象编程的程序员——特别是从非面向对象编程转到面向对象编程的同学——提供一个容易比照的检查列表。
感谢我的同事和朋友们在本文成文过程中给予的帮助。