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