浏览完代码设计中对象设计的核心准则和扩展原则后,最后我们再回头看看另外一个原则不像原则,规则不像规则,模式不像模式,实践不像实践的四不像原则 - GRASP原则。
说到对象设计原则,就不能不提GRASP原则,但是从我来看,这个原则完全不是原则,而是设计过程。
GRASP,全称为General Responsibility Assignment Software Pattern,即通用职责分配软件模式,它由《UML和模式应用》(Applying UML and Patterns)一书作者Craig Larman提出。它是站在面向对象设计的角度,告诉我们怎样设计问题空间中的类与分配它们的行为职责,以及明确类之间的相互关系等,它不是像GoF设计模式一样是针对特定问题而提出的解决方案,而是站在一个更高的角度来看待对象的设计问题。
GRASP是对象职责分配的基本原则,其核心思想是职责分配,用职责来驱动对象设计。它定义了9个基本的OO设计原则或基本的设计构件。这9个设计模式分别是:创建者(Creator)、信息专家(Information Expert)、低耦合(Low Coupling)、控制器(Controller)、高内聚(High Cohesion)、多态性(Polymorphism)、纯虚构(Pure Fabrication)、间接访问(Indirection)、防止变异(Protected Variations)。对于这9个方面的细节,我这里就不详细说明了,感兴趣的同学直接百度/谷歌"GRASP"就能搜到一大堆。
这里,我将打散这些专有名词的顺序,直接回归软件开发过程的各个相关方面。
对象的来源 - 需求
软件是解决特定需求,完成其指定功能的程序,所以设计中对象的来源主要是围绕需求展开的。
需求通常分为功能性需求和非功能性需求。
功能性需求,也是硬性需求,其实就是客户需要的功能。围绕客户的业务流程,我们可以得到支撑系统功能的核心对象。比如3D软件中常用的摄像机,材质,立方体,光线,场景等,这些都是直接从业务流程中转化来的对象。
非功能性需求,也就是软性需求,通常是指依一些条件判断系统运作情形或其特性,而不是针对系统特定行为的需求,它包括诸如安全性、可靠性、互操作性、健壮性、易使用性、可维护性、可移植性、可重用性、可扩充性等。为了达到这些条件指定的标准,我们需要提炼很多支撑系统各种非业务特性的对象。比如软件中常用的接口,抽象类,设计模式中各种中介者,代理,门面等等。离开这些对象,系统可能工作很好,但是一旦面临变化,系统的可重用性,可扩充性就不是那么好了。
对象设计目标 - "高内聚+低耦合"
毫无疑问,系统中所有的对象都尽可能是高内聚和低耦合的,这是对象设计的理想目标,在这种设计中,每个对象功能集中,访问权限合适,修改时波及范围很小,应对变化的性能最佳,扩展起来也最方便。
在任何的系统中,功能都是通过对象之间交互来完成的,所以对象设计中,我们根本无法做到对象之间零耦合,我们只能尽最大的努力来降低或减少对象之间的耦合,而通常的做法就是把相关功能都集中到一个对象中,并设置合适的访问权限来封装实现的细节,这其实就是信息专家的内涵。
信息专家模式建议把类的职责相关的所有成员都集中到一起封装起来。这个原则也几乎就是前面单一职责原则的另一种表述。
仔细看看各种发行的类库和框架,至少说写的比较好的类库,其对象基本上还是能满足高内聚低耦合的特征的。
不管采用任何方式,不管使用任何模式,我的心中时刻都会以"高内聚低耦合"的标准来验证设计出来的对象,这是我的必经步骤。
对象设计过程 - "折中+迭代+重构"
其实从上面的描述中我们所用的词汇可以看出,"高内聚+低耦合"这个标准注定是无法量化的,这也就注定了我们设计过程中,通常无法详细比较两种设计中内聚和耦合的程度,只能依靠直觉和经验,来折中团队中的每种意见,来达成这个目标,并且随着系统的不断发展,我们的设计也需要进行重新思考,最终的设计也会不断的调整。
每种方案都必然有优点和缺点,所有模式带来灵活性和扩展性的同时,必然会带来性能和可读性的损失,这使得我在考虑问题的时候必须要相当谨慎,必须要综合各个方面的因素和影响,来最终决定采用在当时情况下比较适合的一种方案,古人说的一句话特别有道理:因地制宜。
下面是我工作中的一个例子:
在我的工作中,我有一个StoryboardWindow(故事板)对象,里面包含了各种制作出来的动画,为了了UI上展示这些动画元素,我们需要一些辅助方法来完成UI上面对象的创建工作,于是一个辅助类StoryboardWindowUtil诞生了,它的伪代码如下:
class StoryboardWindowUtil { static Button CreateButton(/*parameters*/) { return new Button(); } static Label CreateLabel(/*parameters*/) { return new Label(); } }
随着业务逻辑的越来越复杂,我们需要处理UI上控件对应的SelectionSet的一些方法,于是我们把这些方法加到这里来:
SelectionSet GetStoryboardSelectionSet(); SelectionSet GetAnimationSelectionSet(); void SelectStoryboard(/*parameters*/); void SelectAnimation(/*parameters*/);
后来,随着UI显示一些需求,我们需要处理精度和格式化一些数据,于是加入了下面一些成员:
int Duration2Pixel(/*parameters*/); float Pixel2Duration(/*parameters*/); String formatTime(/*parameters*/);
再接着,由于我们需要重复使用一些UI的对象,比如StoryboardWindow,于是我们在这里缓存了这些对象:
void SetStoryboardWindow(StoryboardWindow window); StoryboardWindow GetStoryboardWindow(); // 很多类似的这种对象 //...
这个过程在软件停止研发前根本不会停止,随着需求的变化,类似的修改会越来越多,可以预见到随着这些修改的发生,StoryboardWindowUtil最终将不堪重负。
在我默念了几遍了"高内聚低耦合"的标准后,我决定抽个空重构一下这个类,每次类似的修改我都不希望在StoryboardWindowUtil内进行,而是每次如果确实需要,就增加一个新类来处理新的需求,这样我就能得到下列一些高内聚的对象:
UIObjectContainer - 缓存对象的作用 SelectionSetManager - 处理SelectionSet相关事宜 UnitFormatter - 处理单位相关的转换和格式化
且不说原来的做法都是直接在类中添加方法是不是考虑了OCP(开闭原则),光说这里是否需要新的对象也完全是反复考虑的一个过程,其实也有很多人也认为:为一两个方法创建一个新类完全是浪费的!这就是度和折中的思考过程,这个过程根本就没有一个公式来计算每种设计确定的内聚性和耦合性,而且显然每个人对度的认识和理解也是不同的。
从上面的这个过程中,我还发现:
1. 变化是必然了,没有什么是不变的,没有什么设计是永恒的,唯一的不变是变化。 2. 软件研发是一个不断迭代的过程,职责分配在迭代过程中总是不断进行。 3. 在一些对象的职责分配上,没有什么绝对的对与错。在不同的时间点上,代码完全可能与最初的设计思想是完全背离的。
最后送上一个N年前发生的小故事:
新人:微软为什么要推出扩展方法呢?(注:C#中的扩展方法指的是,通过一定的语法,你可以扩展一个类的方法,当引用了这个新类库后,被扩展的类的所有实例都可以调用扩展的方法)
我:我觉得这种做法满足面向对象的特性,虽然我们新添加了新的静态类,但并没有改变原有的类的代码,但是使用起来就好像是原来的类有了新方法一样。
一位弟兄:我觉得不是,我觉得就是图个方便的语法。如果要谈面向对象,那么我随便把这些方法塞到一个类中,我拿这个新的类调用就行了啊,所以显然不是为了面向对象考虑。
我:...
难道随便加一个对象,使用对象的语法去写代码,就是面向对象吗?你觉得呢?