FISHGUI项目进入到面向对象设计的阶段了,面向对象设计阶段做些什么东西呢?有没有一
些基本原则?又有哪些需要注意的问题呢?
概括的讲,面向对象设计就是对分析模型的细化,在“分析模型”一章也强调过,面向对象设
计是以面向分析阶段的分析模型作为输入,通过对分析模型中所有对象和类的分析,明确对
象的属性和操作,制定属性的类型特征,补全对象之间的关系,并在不断细化的基础上,把
分析模型转化成详细描述如何解决问题、如何实现软件系统的对象模型。具体地说,面向对
象设计工作包括以下几个主要的工作步骤:
(1)细化和重组类;
(2)细化和实现类间关系,明确其可见性;
(3)增加遗漏的属性,制定属性的类型和可见性;
(4)分配职责,定义执行每个职责的方法;
(5)对消息驱动的系统,明确消息传递方式;
(6)利用设计模式进行局部设计;
(7)画出详细的顺序图或协作图。
书中的CPU难题,说明类可以通过继承无限度的细分下去。无限度的划分,肯定不是正确的
方法,继承不能滥用,在使用继承的时候应该考虑以下的情况:
· 首先,必须基于需求来确定继承的粒度。如果提取对象的某一个共性对于我们要解决
的问题没有任何帮助,或只有很小的帮助,那就没有必要因为提取该共性而增加继承树
的复杂程度。
· 其次,对象的某些共性更适合于用属性而不是用新的基类和派生类来表达。例如,CP
U的一级缓存和二级缓存只是简单的数量值,用一个属性来表达非常方便,将其作为共
性来归纳、抽象成类反而会造成派生类的数目无限增长的麻烦。
· 最后,程序员要清楚,利用类和对象来模拟现实世界是手段而不是目的。我们不能为
了提取类而提取类。其实,软件的内部结构和现实世界一模一样并不见得是件好事。我
们最终的目的是最大限度地实现代码复用,提高软件质量。因此,如果不能达到代码复
用的目的,就没有必要再提取新的类了。
以支不支持多重继承为标准,面向对象语言可分为两大阵营。C++、Eiffel和CLOS都支持多
重继承,而Java和Delphi不支持多重继承。多继承在提供了灵活性的同时,也带来了使用的
复杂性,使用与否,还是要仔细斟酌。在处理有包容关系的类时,究竟该使用继承机制,还
是聚合机制?这没有一定的答案,就看哪种方法在表述问题时更简单、更合理。由于面向对
象设计的目标并不是要和现实世界一模一样,所以为了提高代码的复用性,减少代码的重复
,我们也可以添加某些与实际物体无法对应的类。
聚合也可以无限的分解下去,在处理聚合的问题时,我们需要遵循的原则与处理继承粒度时
的原则完全一致:模拟现实世界只是手段而不是目的,我们的最终目的是改善代码结构,提
高代码的复用性。只有从这一原则出发,我们才能较好的确定概念分解的层次,将聚合的粒
度控制在一个既能满足需求又不过分增加软件复杂性的水平上。
马丁·福勒在《重构》中提出的几个对于类的重构方法,对面向对象设计也很有用。有关的
方法如下:
·提炼类(Extract Class):在迭代的过程中,如果类的属性和方法逐渐增多,最终变得
职责不够明显。这是我们就可以把一部分的属性和方法分离出去,形成一个新的类。这
可以提高类的内聚性。
·将类内联化(Inline Class):在提炼类的过程中,有可能一些类变得很小,负责的职责
过少,这时,我们可以把该类去掉,把它的工作移到其它类中。这是提炼类的反向过程。
·以类取代型别码(Replace Type Code with Subclasses):如果在某个类中有一个表达
具体对象类型的代码(型别码),该型别码影响了该类的行为,并可能使得程序中频繁
出现与该型别码相关的分支语句,这时,我们应该创建新的派生类,并使用多态来代替
它。
·提炼子类(Extract Subclass):有时,一个类中的某些属性只对部分对象实例有意
义,这说明该类的设计并不完美,我们还可以把这些属性单独提取出来,形成该类的一
个派生类。例如,在PC服务器的模拟软件中,如果我们只有一个CPU类,该类中的
_have3Dnow属性就只对AMD系列的CPU有意义,那么,添加一个派生类AMD_CPU来单独处理
该属性就是最好的选择了。
·提炼超类(Extract Superclass):如果两个类有相同的属性和方法,两个类中就必定
有一些代码是重复的。为了消除重复代码“的坏味道”,我们可以创建两个类的共同基类
,把重复的属性和方法上移到基类中。
通过细化和重组,找出了设计类,接下来的就是要“细化和实现类间关系,明确其可见性”。
类间关系不难理解,但什么是关系的可见性?
“关系的可见性指的是一个对象能够‘看见’并且引用另一个对象的能力。”
假设A对象要引用B对象,即A对象发送消息给对象B,那么,可见性就规定了对象A能够以何
种方式引用对象B。一般说来,对象A到对象B的可见性有以下几种:
·属性可见性:对象A的一个属性指向或引用了对象B,这是一种相对持久的关系,只要
对象A存在,它就能引用对象B。
·参数可见性:即对象B是对象A中一个方法的参数,对象A只有在该方法内才能发送消息
给对象B.
·局部声明可见性:即对象B是对象A中一个方法内部定义的局部变量,同样的,对象A只
有在该方法中才会发送消息给对象B。
·全局可见性:即对象B是全局变量,对象A任何时刻都可以发送消息给对象B。这是看似
方便,却会对系统整体结构造成很大危害的可见性,在面向对象设计中,应该尽量避免
全局可见性。必要时,可以使用单件模式来代替全局变量,在后面的“单件模式”一章中,
我们还会对此做进一步的描述。
其中,全局可见性并不会在类图中表现出来(只要声明某一个类为单件类,整个系统中的其
他类就可以任意访问该类),参数可见性和局部可见性是一种比较短暂的关系,一般也不需
要在类图中出现(特别必要时可以用依赖关系来表述)。最终,在类图中出现的基本都是属
性可见性的关系,即那些通过类属性来实现的类间关系。
可见性大致有以上几种,类间的关系可分为:依赖关系(Dependency Relationship)、关
联关系(Association Relationship)、聚合关系(Aggregation Relationship)、双向关
系。
依赖关系是一种比较弱的关系,这种使用方式可能只是以参数可见性或局部声明可见性的方
式使用。C++语言中,对头文件的使用,指明了类间的依赖关系;
关联系系表示在某个对象
的数据成员指向另一个类对象的实例,通过该数据成员,该对象的生命周期内可以随时向另
一个对象实例发送消息;
聚合关系是一种特殊的关联关系,表达的是整体和部分之间的关联
;
关联关系和聚合关系都有可能发展成双向关系,双向关系既可以画成一条没有箭头的连接线
,也可以画成两条关联关系的带箭头连接线。在C++语言中,两个类各自通过一个指向对方
的指针来实现双向的关联关系。在两个类的生命周期中,它们可以随时互发消息。
面向对象设计阶段的产出,要能指导实际的编码工作,还需要进一步指定分析类属性的类型
、增加遗漏的属性,并且制定外部对象对属性的访问权限(Privated、Protected、Public)
,按照面向对象强调封装的思路,类不应该暴露内部细节给客户程序,所以属性一般都设置
为私有或保护,而另外提供访问属性的函数。
类要向客户提供服务,这应该提供的服务的义务就是类的职责。一般说来,面向对象系统中
的类所承担的职责可以分为两大类。
·“做”型职责:自己要做某事;自己要为其他对象提供某种服务;发送消息给其他对象
以要求提供某种服务;控制和协调其他对象的活动。
·“知道”型职责:知道自己内部封装了哪些数据;知道哪些对象和自己发生了关系。通
用职责分配软件模式(GRASP)可以指导我们进行职责分配,GRASP模式包括以下9种:
·专家(Expert)
·创建者(Creator)
·低耦合(Low Coupling)
·高内聚(High Cohesion)
·控制者(Controller)
·多态(Polymorphism)
·纯虚构(Pure Fabrication)
·中介者(Indirection)
·不要和陌生人说话(Don't talk to Stranger)
专家模式认为,职责应该分配给信息专家,即分配职责时,我们应该了解履行职责需要哪些
信息,这些信息为哪个类或哪些类所拥有。如果这些信息为一个类所拥有,就把该职责分配
给它;如果为多个类所拥有,就为每个类分配一个职责,然后通过消息传递和交互,使这些
类协同完成该职责。该模式体现了面向对象的封装性,能够得到高内聚、低耦合的设计方案
。
系统运行过程中需要创建的对象,究竟把这创建的职责分配给谁比较合适呢?创建者模式就
为这问题提出了指导性的方案。按照创建者模式的要求,如果类A和类B满足下列条件中的某
一个:
·A聚合了B对象;
·A包含了B对象;
·A的一个属性记录了B对象
·A要经常使用B对象;
·B对象被创建时,A要传递初始化数据给B对象。
那么,我们就可以把创建B对象的职责分配给A对象。因为这样做,不会增加系统的耦合性。
要软件系统具备低耦合,也就是要类对象间的关系做到低耦合。这要求我们在面向对象设计
时尽量用耦合度低的结构来实现系统。类A和类B之间的耦合包括以下几种情况:
·类A的一个方法参数引用了类B,或方法内定义了类B的一个局部变量;
·类A的一个属性引用了类B的实例对象;
·类A的一个属性聚合了类B的多个实例对象;
·类A是类B间接或直接的派生类。
以上几种情况,从上至下耦合程度越来越强,继承是最强的一种耦合,这是因为派生类没有
任何声明,就悄悄地把基类的属性和方法全部包含进自己内部。
高内聚说的是一个类的各个职责之间的相关程度或几种程度。一般说来,内聚度高的类应只
包含很少的方法数,方法之间的关联程度很高,每个方法承担的工作量不是太大,任务也比
较单一。当我们在设计中发现一个类负担的任务太多、太杂,就应该主动把一些职责分配给
其他的类,这样才能保证设计方案的高内聚。显然,内聚度高的设计方案更易理解、易维护
、易重用。
控制者模式要求把协调处理系统消息的职责分配给不同的控制类。
当某一职责在不同的派生类中表现为不同的行为时,我们就可以使用多态模式,即利用一个
同名的多态方法把该职责分配给不同的派生类,让他们表现不同的行为。面向对象语言的多
态性就是为这个目的而生的。
有时,我们可以虚构出一些类,把一组高度内聚的职责分配给它,该人造类只是虚构出来的。
使用纯虚构也是为了获得高内聚、易重用的类。但由于纯虚构更多是以功能来划分不同的类,
这与面向对象思想相违背,因此使用时要给外销新。
中介者模式是指:把一些职责分配给一个虚构的中介类,让该中介类来协调多个类的协作关
系。使用中介者模式可以隔离耦合度过大的多个类,使易发生变化的对象不会影响其他的对
象。GoF模式中的中介者(Mediator)模式,适配器(Adapter)模式、观察者(Observer)
模式等都体现了GRASP模式中的中介者模式的思想。
不要和陌生人说话,这个模式要求一个类尽量只和它的直接对象交互,避免和间接对象进行
交互,这样,它就可以和最少的类产生耦合,使系统的耦合度保持最低。该准则要求,在一
个对象的方法中,只能给下面这些对象发送消息:
·该对象自己;
·这个方法的一个参数;
·该对象的一个属性;
·该对象的一个属性集合中的一个元素;
·在该方法中创建的一个对象。
与越少的类(只和熟人)交互,不仅能降低耦合,而且能提高内聚。下面是一个违犯此模式
的例子:
class Client
{
private:
ClassA * m_pClassA;
public:
void func()
{
m_pClassA->getClassB()->getClassC()
->getClassD()->getTimer();
}
};
在分配职责时,CRC(Class Responsibility Collaborator Card)卡也有很大的帮助,此
外,CRC卡对于理清对象间的协作关系亦很有裨益。
今天,大多数面向对象的系统都采用了消息驱动的设计方案,这是因为,面向对象设计很容
易造成对象之间的关系数目日益膨胀,形成复杂的网状结构,关系的复杂性同样会导致类和
对象间的耦合度增大,而消息驱动的设计方案可以很好地避免上述缺陷。仅仅依靠静态类图
,我们还无法准确表示一个外部消息时如何传递到最终责任者那里的。对于这样的系统,我
们必须进一步明确消息传递的机制、路径和实现方式。
以上各步完成了之后,该是设计模式一展身手的时候了。我们应该尽量使用成熟的设计模式
来优化模型的局部设计。还记得前面提到过的设计模式的几个要点吗?设计模式是为了适应
需求的变化,针对接口编程,优先使用聚合。
在面向对象分析阶段,为了了解系统的主要业务流程,我们需要画出一些顺序图和协作图。
到了面向对象设计阶段,以最终的设计类及其内部的详细设计模型为依据,绘制特定用例实
现的顺序图就显得更为必要了,因为这时的顺序图才是系统真正的运行顺序图,图中的很多
步骤都可以简单地影射成为最终的实现代码。由于我们在设计过程中补充了一些遗漏的类、
属性和方法,与面向对象分析阶段相比,这是绘制的顺序图就包含了更多的细节。但根据心
理学的原理,人们在一次观察过程中,能够仔细关注的对象通常只有六七个,因此我们不要
试图在一幅顺序图中画出所有的细节。对于比较复杂的用例实现,完全可以把它们拆分到几
个相互关联的顺序图中。一般说来,面向对象设计阶段产生的顺序图应详尽到能知道代码编
写的程度。
总结:
·在面向对象设计过程中,体现对象的个性和归纳它们的共性时,要注意适度的原则,
脱离实际的概括和没有止境的西化都是相当危险的事情;
·继承关系和聚合关系各有各的使用范围,取舍的惟一标准就是保证设计方案的低耦合、
高内聚;
·合理分配类和对象的职责,有效组织它们的相互关系,这是面向对象设计思想的核心
内容。