设计模式怎样解决设计问题

1.6设计模式怎样解决设计问题
设计模式采用多种方法解决面向对象设计者经常碰到的问题。这里给出几个问题以及使用设计模式解决它们的方法。
1.6.1寻找合适的对象
面向对象程序由对象组成,对象包括数据和对数据进行操作的过程,过程通常称为方法或操作。对象在收到客户的请求(或消息)后,执行相应的操作。
客户请求是使对象执行操作的唯一方法,操作又是对象改变内部数据的唯一方法。由于这些限制,对象的内部状态是被封装的,它不能被直接访问,它的表示对于对象外部是不可见的。
面向对象设计最困难的部分是将系统分解成对象集合。因为要考虑许多因素:封装、粒度、依赖关系、灵活性、性能、演化、复用等等,它们都影响着系统的分解,并且这些因素通常还是互相冲突的。
面向对象设计方法学支持许多设计方法。你可以写出一个问题描述,挑出名词和动词,进而创建相应的类和操作;或者,你可以关注于系统的协作和职责关系;或者,你可以对现实世界建模,再将分析时发现的对象转化至设计中。至于哪一种方法最好,并无定论。
设计的许多对象来源于现实世界的分析模型。但是,设计结果所得到的类通常在现实世界中并不存在,有些是像数组之类的低层类,而另一些则层次较高。例如,Composite(4.3)模式引入了统一对待现实世界中并不存在的对象的抽象方法。严格反映当前现实世界的模型并不能产生也能反映将来世界的系统。设计中的抽象对于产生灵活的设计是至关重要的。
设计模式帮你确定并不明显的抽象和描述这些抽象的对象。例如,描述过程或算法的对象现实中并不存在,但它们却是设计的关键部分。Strategy(5.9)模式描述了怎样实现可互换的算法族。State(5.8)模式将实体的每一个状态描述为一个对象。这些对象在分析阶段,甚至在设计阶段的早期都并不存在,后来为使设计更灵活、复用性更好才将它们发掘出来。
1.6.2决定对象的粒度
对象在大小和数目上变化极大。它们能表示下自硬件或上自整个应用的任何事物。那么我们怎样决定一个对象应该是什么呢?
设计模式很好地讲述了这个问题。Facade(4.5)模式描述了怎样用对象表示完整的子系统,Flyweight(4.6)模式描述了如何支持大量的最小粒度的对象。其他一些设计模式描述了将一个对象分解成许多小对象的特定方法。AbstractFactory(3.1)和Builder(3.2)产生那些专门负责生成其他对象的对象。Visitor(5.10)和Command(5.2)生成的对象专门负责实现对其他对象或对象组的请求。
1.6.3指定对象接口
对象声明的每一个操作指定操作名、作为参数的对象和返回值,这就是所谓的操作的型构(signature)。对象操作所定义的所有操作型构的集合被称为该对象的接口(interface)。对象接口描述了该对象所能接受的全部请求的集合,任何匹配对象接口中型构的请求都可以发送给该对象。
类型(type)是用来标识特定接口的一个名字。如果一个对象接受“Window”接口所定义的所有操作请求,那么我们就说该对象具有“Window”类型。一个对象可以有许多类型,并且不同的对象可以共享同一个类型。对象接口的某部分可以用某个类型来刻画,而其他部分则可用其他类型刻画。两个类型相同的对象只需要共享它们的部分接口。接口可以包含其他接口作为子集。当一个类型的接口包含另一个类型的接口时,我们就说它是另一个类型的子类型(subtype),另一个类型称之为它的超类型(supertype)。我们常说子类型继承了它的超类型的接口。
在面向对象系统中,接口是基本的组成部分。对象只有通过它们的接口才能与外部交流,如果不通过对象的接口就无法知道对象的任何事情,也无法请求对象做任何事情。对象接口与其功能实现是分离的,不同对象可以对请求做不同的实现,也就是说,两个有相同接口的对象可以有完全不同的实现。
当给对象发送请求时,所引起的具体操作既与请求本身有关又与接受对象有关。支持相同请求的不同对象可能对请求激发的操作有不同的实现。发送给对象的请求和它的相应操作在运行时刻的连接就称之为动态绑定(dynamicbinding)。
动态绑定是指发送的请求直到运行时刻才受你的具体的实现的约束。因而,在知道任何有正确接口的对象都将接受此请求时,你可以写一个一般的程序,它期待着那些具有该特定接口的对象。进一步讲,动态绑定允许你在运行时刻彼此替换有相同接口的对象。这种可替换性就称为多态(polymorphism),它是面向对象系统中的核心概念之一。多态允许客户对象仅要求其他对象支持特定接口,除此之外对其假设几近于无。多态简化了客户的定义,使得对象间彼此独立,并可以在运行时刻动态改变它们相互的关系。
设计模式通过确定接口的主要组成成分及经接口发送的数据类型,来帮助你定义接口。设计模式也许还会告诉你接口中不应包括哪些东西。Memento(5.6)模式是一个很好的例子,它描述了怎样封装和保存对象内部的状态,以便一段时间后对象能恢复到这一状态。它规定了Memento对象必须定义两个接口:一个允许客户保持和复制memento的限制接口,和一个只有原对象才能使用的用来储存和提取memento中状态的特权接口。
设计模式也指定了接口之间的关系。特别地,它们经常要求一些类具有相似的接口;或它们对一些类的接口做了限制。例如,Decorator(4.4)和Proxy(4.7)模式要求Decorator和Proxy对象的接口与被修饰的对象和受委托的对象一致。而Visitor(5.11)模式中,Visitor接口必须反映出visitor能访问的对象的所有类。
1.6.4描述对象的实现

至此,我们很少提及到实际上怎么去定义一个对象。对象的实现是由它的类决定的,类指定了对象的内部数据和表示,也定义了对象所能完成的操作,如右图所示。


我们基于OMT的表示法,将类描述成一个矩形,其中的类名以黑体表示的。操作在类名下面,以常规字体表示。类所定义的任何数据都在操作的下面。类名与操作之间以及操作与数据之间用横线分割。
返回类型和实例变量类型是可选的,因为我们并未假设一定要用具有静态类型的实现语言。
对象通过实例化类来创建,此对象被称为该类的实例。当实例化类时,要给对象的内部数据(由实例变量组成)分配存储空间,并将操作与这些数据联系起来。对象的许多类似实例是由实例化同一个类来创建的。
下图中的虚箭头线表示一个类实例化另一个类的对象,箭头指向被实例化的对象的类


新的类可以由已存在的类通过类继承(classinheritance)来定义。当子类(subclass)继承父类(parentclass)时,子类包含了父类定义的所有数据和操作。子类的实例对象包含所有子类和父类定义的数据,且它们能完成子类和父类定义的所有操作。我们以竖线和三角表示子类关系,如下图所示。


抽象类(abstractclass)的主要目的是为它的子类定义公共接口。一个抽象类将把它的部分或全部操作的实现延迟到子类中,因此,一个抽象类不能被实例化。在抽象类中定义却没有实现的操作被称为抽象操作(abstractoperation)。非抽象类称为具体类(concreteclass)。
子类能够改进和重新定义它们父类的操作。更具体地说,类能够重定义(override)父类定义的操作,重定义使得子类能接管父类对请求的处理操作。类继承允许你只需简单的扩展其他类就可以定义新类,从而可以很容易地定义具有相近功能的对象族。
抽象类的类名以斜体表示,以与具体类相区别。抽象操作也用斜体表示。图中可以包括实现操作的伪代码,如果这样,则代码将出现在带有摺角的框中,并用虚线将该摺角框与代码所实现的操作相连,图示如下。


混入类(mixinclass)是给其他类提供可选择的接口或功能的类。它与抽象类一样不能实例化。混入类要求多继承,图示如下。


1.类继承与接口继承的比较
理解对象的类(class)与对象的类型(type)之间的差别非常重要。一个对象的类定义了对象是怎样实现的,同时也定义了对象的内部状态和操作的实现。但是对象的类型只与它的接口有关,接口即对象能响应的请求的集合。一个对象可以有多个类型,不同类的对象可以有相同的类型。
当然,对象的类和类型是有紧密关系的。因为类定义了对象所能执行的操作,也定义了对象的类型。当我们说一个对象是一个类的实例时,即指该对象支持类所定义的接口。
C++和Eiffel语言的类既指定对象的类型又指定对象的实现。Smalltalk程序不声明变量的类型,所以编译器不检查赋给变量的对象类型是否是该变量的类型的子类型。发送消息时需要检查消息接收者是否实现了该消息,但不检查接收者是否是某个特定类的实例。
理解类继承和接口继承(或子类型化)之间的差别也十分重要。类继承根据一个对象的实现定义了另一个对象的实现。简而言之,它是代码和表示的共享机制。然而,接口继承(或子类型化)描述了一个对象什么时候能被用来替代另一个对象。
因为许多语言并不显式地区分这两个概念,所以容易被混淆。在C++和Eiffel语言中,继承既指接口的继承又指实现的继承。C++中接口继承的标准方法是公有继承一个含(纯)虚成员函数的类。C++中纯接口继承接近于公有继承纯抽象类,纯实现继承或纯类继承接近于私有继承。Smalltalk中的继承只指实现继承。只要任何类的实例支持对变量值的操作,你就可以将这些实例赋给变量。
尽管大部分程序设计语言并不区分接口继承和实现继承的差别,但使用中人们还是分别对待它们的。Smalltalk程序员通常将子类当作子类型(尽管有一些熟知的例外情况[Coo92]),C++程序员通过抽象类所定义的类型来操纵对象。
很多设计模式依赖于这种差别。例如,ChainofResponsibility(5.1)模式中的对象必须有一个公共的类型,但一般情况下它们不具有公共的实现。在Composite(4.3)模式中,构件定义了一个公共的接口,但Composite通常定义一个公共的实现。Command(5.2)、Observer(5.7)、State(5.8)和Strategy(5.9)通常纯粹作为接口的抽象类来实现。
2.对接口编程,而不是对实现编程
类继承是一个通过复用父类功能而扩展应用功能的基本机制。它允许你根据旧对象快速定义新对象。它允许你从已存在的类中继承所需要的绝大部分功能,从而几乎无需任何代价就可以获得新的实现。
然而,实现的复用只是成功的一半,继承所拥有的定义具有相同接口的对象族的能力也是很重要的(通常可以从抽象类来继承)。为什么?因为多态依赖于这种能力。
当继承被恰当使用时,所有从抽象类导出的类将共享该抽象类的接口。这意味着子类仅仅添加或重定义操作,而没有隐藏父类的操作。这时,所有的子类都能响应抽象类接口中的请求,从而子类的类型都是抽象类的子类型。
只根据抽象类中定义的接口来操纵对象有以下两个好处:
1)客户无须知道他们使用对象的特定类型,只须对象有客户所期望的接口。
2)客户无须知道他们使用的对象是用什么类来实现的,他们只须知道定义接口的抽象类。
这将极大地减少子系统实现之间的相互依赖关系,也产生了可复用的面向对象设计的如下原则:
针对接口编程,而不是针对实现编程。
不将变量声明为某个特定的具体类的实例对象,而是让它遵从抽象类所定义的接口。这是本书设计模式的一个常见主题。
当你不得不在系统的某个地方实例化具体的类(即指定一个特定的实现)时,创建型模式(AbstractFactory(3.1),Builder(3.2),FactoryMethod(3.3),Prototype(3.4)和Singleton(3.5))可以帮你。通过抽象对象的创建过程,这些模式提供不同方式以在实例化时建立接口和实现的透明连接。创建型模式确保你的系统是采用针对接口的方式书写的,而不是针对实现而书写的。
1.6.5运用复用机制
理解对象、接口、类和继承之类的概念对大多数人来说并不难,问题的关键在于如何运用它们写出灵活的、可复用的软件。设计模式将告诉你怎样去做。
1.继承和组合的比较
面向对象系统中功能复用的两种最常用技术是类继承和对象组合(objectcomposition)。正如我们已解释过的,类继承允许你根据其他类的实现来定义一个类的实现。这种通过生成子类的复用通常被称为白箱复用(white-boxreuse)。术语“白箱”是相对可视性而言:在继承方式中,父类的内部细节对子类可见。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-boxreuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
继承和组合各有优缺点。类继承是在编译时刻静态定义的,且可直接使用,因为程序设计语言直接支持类继承。类继承可以较方便地改变被复用的实现。当一个子类重定义一些而不是全部操作时,它也能影响它所继承的操作,只要在这些操作中调用了被重定义的操作。
但是类继承也有一些不足之处。首先,因为继承在编译时刻就定义了,所以无法在运行时刻改变从父类继承的实现。更糟的是,父类通常至少定义了部分子类的具体表示。因为继承对子类揭示了其父类的实现细节,所以继承常被认为“破坏了封装性”[Sny86]。子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。
当你需要复用子类时,实现上的依赖性就会产生一些问题。如果继承下来的实现不适合解决新的问题,则父类必须重写或被其他更适合的类替换。这种依赖关系限制了灵活性并最终限制了复用性。一个可用的解决方法就是只继承抽象类,因为抽象类通常提供较少的实现。
对象组合是通过获得对其他对象的引用而在运行时刻动态定义的。组合要求对象遵守彼此的接口约定,进而要求更仔细地定义接口,而这些接口并不妨碍你将一个对象和其他对象一起使用。这还会产生良好的结果:因为对象只能通过接口访问,所以我们并不破坏封装性;只要类型一致,运行时刻还可以用一个对象来替代另一个对象;更进一步,因为对象的实现是基于接口写的,所以实现上存在较少的依赖关系。
对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小规模,并且不太可能增长为不可控制的庞然大物。另一方面,基于对象组合的设计会有更多的对象(而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。
这导出了我们的面向对象设计的第二个原则:
优先使用对象组合,而不是类继承。
理想情况下,你不应为获得复用而去创建新的构件。你应该能够只使用对象组合技术,通过组装已有的构件就能获得你需要的功能。但是事实很少如此,因为可用构件的集合实际上并不足够丰富。使用继承的复用使得创建新的构件要比组装旧的构件来得容易。这样,继承和对象组合常一起使用。
然而,我们的经验表明:设计者往往过度使用了继承这种复用技术。但依赖于对象组合技术的设计却有更好的复用性(或更简单)。你将会看到设计模式中一再使用对象组合技术。
2.委托
委托(delegation)是一种组合方法,它使组合具有与继承同样的复用能力[Lie86,JZ91]。在委托方式下,有两个对象参与处理一个请求,接受请求的对象将操作委托给它的代理者(delegate)。这类似于子类将请求交给它的父类处理。使用继承时,被继承的操作总能引用接受请求的对象,C++中通过this成员变量,Smalltalk中则通过self。委托方式为了得到同样的效果,接受请求的对象将自己传给被委托者(代理人),使被委托的操作可以引用接受请求的对象。
举例来说,我们可以在窗口类中保存一个矩形类的实例变量来代理矩形类的特定操作,这样窗口类可以复用矩形类的操作,而不必像继承时那样定义成矩形类的子类。也就是说,一个窗口拥有一个矩形,而不是一个窗口就是一个矩形。窗口现在必须显式的将请求转发给它的矩形实例,而不是像以前它必须继承矩形的操作。下面的图显示了窗口类将它的Area操作委托给一个矩形实例。


箭头线表示一个类对另一个类实例的引用关系。引用名是可选的,本例为“rectangle”。
委托的主要优点在于它便于运行时刻组合对象操作以及改变这些操作的组合方式。假定矩形对象和圆对象有相同的类型,我们只需简单的用圆对象替换矩形对象,则得到的窗口就是圆形的。
委托与那些通过对象组合以取得软件灵活性的技术一样,具有如下不足之处:动态的、高度参数化的软件比静态软件更难于理解。还有运行低效问题,不过从长远来看人的低效才是更主要的。只有当委托使设计比较简单而不是更复杂时,它才是好的选择。要给出一个能确切告诉你什么时候可以使用委托的规则是很困难的。因为委托可以得到的效率是与上下文有关的,并且还依赖于你的经验。委托最适用于符合特定程式的情形,即标准模式的情形。
有一些模式使用了委托,如State(5.8)、Strategy(5.9)和Visitor(5.11)。在State模式中,一个对象将请求委托给一个描述当前状态的State对象来处理。在Strategy模式中,一个对象将一个特定的请求委托给一个描述请求执行策略的对象,一个对象只会有一个状态,但它对不同的请求可以有许多策略。这两个模式的目的都是通过改变受托对象来改变委托对象的行为。在Visitor中,对象结构的每个元素上的操作总是被委托到Visitor对象。
其他模式则没有这么多地用到委托。Mediator(5.5)引进了一个中介其他对象间通信的对象。有时,Mediator对象只是简单地将请求转发给其他对象;有时,它沿着指向自己的引用来传递请求,使用真正意义的委托。ChainofResponsibility(5.1)通过将请求沿着对象链传递来处理请求,有时,这个请求本身带有一个接受请求对象的引用,这时该模式就使用了委托。Bridge(4.2)将实现和抽象分离开,如果抽象和一个特定实现非常匹配,那么这个实现可以代理抽象的操作。
委托是对象组合的特例。它告诉你对象组合作为一个代码复用机制可以替代继承。
3.继承和参数化类型的比较
另一种功能复用技术(并非严格的面向对象技术)是参数化类型(parameterizedtype),也就是类属(generic)(Ada、Eiffel)或模板(templates)(C++)。它允许你在定义一个类型时并不指定该类型所用到的其他所有类型。未经指定的类型在使用时以参数形式提供。例如,一个列表类能够以它所包含元素的类型来进行参数化。如果你想声明一个Integer列表,只需将Integer类型作为列表参数化类型的参数值;声明一个String列表,只需提供String类型作为参数值。语言的实现将会为各种元素类型创建相应的列表类模板的定制版本。
参数化类型给我们提供除了类继承和对象组合外的第三种方法来组合面向对象系统中的行为。许多设计可以使用这三种技术中的任何一种来实现。实现一个以元素比较操作为可变元的排序例程,可有如下方法:
1)通过子类实现该操作(TemplateMethod(5.10)的一个应用)。
2)实现为传给排序例程的对象的职责(Strategy(5.9))。
3)作为C++模板或Ada类属的参数,以指定元素比较操作的名称。
这些技术存在着极大的不同之处。对象组合技术允许你在运行时刻改变被组合的行为,但是它存在间接性,比较低效。继承允许你提供操作的缺省实现,并通过子类重定义这些操作。参数化类型允许你改变类所用到的类型。但是继承和参数化类型都不能在运行时刻改变。哪一种方法最佳,取决于你设计和实现的约束条件。
本书没有一种模式是与参数化类型有关的,尽管我们在定制一个模式的C++实现时用到了参数化类型。参数化类型在像Smalltalk那样的编译时刻不进行类型检查的语言中是完全不必要的。
1.6.6关联运行时刻和编译时刻的结构
一个面向对象程序运行时刻的结构通常与它的代码结构相差较大。代码结构在编译时刻就被确定下来了,它由继承关系固定的类组成。而程序的运行时刻结构是由快速变化的通信对象网络组成。事实上两个结构是彼此独立的,试图由一个去理解另一个就好像试图从静态的动、植物分类去理解活生生的生态系统的动态性。反之亦然。
考虑对象聚合(aggregation)和相识(acquaintance)的差别以及它们在编译和运行时刻的表示是多么的不同。聚合意味着一个对象拥有另一个对象或对另一个对象负责。一般我们称一个对象包含另一个对象或者是另一个对象的一部分。聚合意味着聚合对象和其所有者具有相同的生命周期。
相识意味着一个对象仅仅知道另一个对象。有时相识也被称为“关联”或“引用”关系。相识的对象可能请求彼此的操作,但是它们不为对方负责。相识是一种比聚合要弱的关系,它只标识了对象间较松散的耦合关系。
在下图中,普通的箭头线表示相识,尾部带有菱形的箭头线表示聚合:


聚合和相识很容易混淆,因为它们通常以相同的方法实现。Smalltalk中,所有变量都是其他对象的引用,程序设计语言中两者并无区别。C++中,聚合可以通过定义表示真正实例的成员变量来实现,但更通常的是将这些成员变量定义为实例指针或引用;相识也是以指针或引用来实现。
从根本上讲,是聚合还是相识是由你的意图而不是由显式的语言机制决定的。尽管它们之间的区别在编译时刻的结构中很难看出来,但这些区别还是很大的。聚合关系使用较少且比相识关系更持久;而相识关系则出现频率较高,但有时只存在于一个操作期间,相识也更具动态性,使得它在源代码中更难被辨别出来。
程序的运行时刻结构和编译时刻结构存在这么大的差别,很明显代码不可能揭示关于系统如何工作的全部。系统的运行时刻结构更多地受到设计者的影响,而不是编程语言。对象和它们的类型之间的关系必须更加仔细地设计,因为它们决定了运行时刻程序结构的好坏。
许多设计模式(特别是那些属于对象范围的)显式地记述了编译时刻和运行时刻结构的差别。Composite(4.3)和Decorator(4.4)对于构造复杂的运行时刻结构特别有用。Observer(5.7)也与运行时刻结构有关,但这些结构对于不了解该模式的人来说是很难理解的。ChainofResponsibility(5.1)也产生了继承所无法展现的通信模式。总之,只有理解了模式,你才能清楚代码中的运行时刻结构。
1.6.7设计应支持变化
获得最大限度复用的关键在于对新需求和已有需求发生变化时的预见性,要求你的系统设计要能够相应地改进。
为了设计适应这种变化、且具有健壮性的系统,你必须考虑系统在它的生命周期内会发生怎样的变化。一个不考虑系统变化的设计在将来就有可能需要重新设计。这些变化可能是类的重新定义和实现,修改客户和重新测试。重新设计会影响软件系统的许多方面,并且未曾料到的变化总是代价巨大的。
设计模式可以确保系统能以特定方式变化,从而帮助你避免重新设计系统。每一个设计模式允许系统结构的某个方面的变化独立于其他方面,这样产生的系统对于某一种特殊变化将更健壮。
下面阐述了一些导致重新设计的一般原因,以及解决这些问题的设计模式:
1)通过显式地指定一个类来创建对象     在创建对象时指定类名将使你受特定实现的约束而不是特定接口的约束。这会使未来的变化更复杂。要避免这种情况,应该间接地创建对象。
设计模式:AbstractFactory(3.1),FactoryMethod(3.3),Prototype(3.4)。
2)对特殊操作的依赖     当你为请求指定一个特殊的操作时,完成该请求的方式就固定下来了。为避免把请求代码写死,你将可以在编译时刻或运行时刻很方便地改变响应请求的方法。
设计模式:ChainofResposibility(5.1),Command(5.2)。
3)对硬件和软件平台的依赖     外部的操作系统接口和应用编程接口(API)在不同的软硬件平台上是不同的。依赖于特定平台的软件将很难移植到其他平台上,甚至都很难跟上本地平台的更新。所以设计系统时限制其平台相关性就很重要了。
设计模式:AbstractFactory(3.1),Bridge(4.2)。
4)对对象表示或实现的依赖      知道对象怎样表示、保存、定位或实现的客户在对象发生变化时可能也需要变化。对客户隐藏这些信息能阻止连锁变化。
设计模式:AbstractFactory(3.1),Bridge(4.2),Memento(5.6),Proxy(4.7)
5)算法依赖     算法在开发和复用时常常被扩展、优化和替代。依赖于某个特定算法的对象在算法发生变化时不得不变化。因此有可能发生变化的算法应该被孤立起来。
设计模式:Builder(3.2),Iterator(5.4),Strategy(5.9),TemplateMethod(5.10),Visitor(5.11)
6)紧耦合     紧耦合的类很难独立地被复用,因为它们是互相依赖的。紧耦合产生单块的系统,要改变或删掉一个类,你必须理解和改变其他许多类。这样的系统是一个很难学习、移植和维护的密集体。
松散耦合提高了一个类本身被复用的可能性,并且系统更易于学习、移植、修改和扩展。设计模式使用抽象耦合和分层技术来提高系统的松散耦合性。
设计模式:AbstractFactory(3.1),Command(5.2),Facade(4.5),Mediator(5.5),Observer(5.7),ChainofResponsibility(5.1)。
7)通过生成子类来扩充功能     通常很难通过定义子类来定制对象。每一个新类都有固定的实现开销(初始化、终止处理等)。定义子类还需要对父类有深入的了解。如,重定义一个操作可能需要重定义其他操作。一个被重定义的操作可能需要调用继承下来的操作。并且子类方法会导致类爆炸,因为即使对于一个简单的扩充,你也不得不引入许多新的子类。
一般的对象组合技术和具体的委托技术,是继承之外组合对象行为的另一种灵活方法。新的功能可以通过以新的方式组合已有对象,而不是通过定义已存在类的子类的方式加到应用中去。另一方面,过多使用对象组合会使设计难于理解。许多设计模式产生的设计中,你可以定义一个子类,且将它的实例和已存在实例进行组合来引入定制的功能。
设计模式:Bridge(4.2),ChainofResponsibility(5.1),Composite(4.3),Decorator(4.4),Observer(5.7),Strategy(5.9)。
8)不能方便地对类进行修改     有时你不得不改变一个难以修改的类。也许你需要源代码而又没有(对于商业类库就有这种情况),或者可能对类的任何改变会要求修改许多已存在的其他子类。设计模式提供在这些情况下对类进行修改的方法。
设计模式:Adapter(4.1),Decorator(4.4),Visitor(5.11)。
这些例子反映了使用设计模式有助于增强软件的灵活性。这种灵活性所具有的重要程度取决于你将要建造的软件系统。让我们看一看设计模式在开发如下三类主要软件中所起的作用:应用程序、工具箱和框架。
1.应用程序
如果你将要建造像文档编辑器或电子制表软件这样的应用程序(ApplicationProgram),那么它的内部复用性、可维护性和可扩充性是要优先考虑的。内部复用性确保你不会做多余的设计和实现。设计模式通过减少依赖性来提高内部复用性。松散耦合也增强了一类对象与其他多个对象协作的可能性。例如,通过孤立和封装每一个操作,以消除对特定操作的依赖,可使在不同上下文中复用一个操作变得更简单。消除对算法和表示的依赖可达到同样的效果。
当设计模式被用来对系统分层和限制对平台的依赖性时,它们还会使一个应用更具可维护性。通过显示怎样扩展类层次结构和怎样使用对象复用,它们可增强系统的易扩充性。同时,耦合程度的降低也会增强可扩充性。如果一个类不过多地依赖其他类,扩充这个孤立的类还是很容易的。
2.工具箱
一个应用经常会使用来自一个或多个被称为工具箱(Toolkit)的预定义类库中的类。工具箱是一组相关的、可复用的类的集合,这些类提供了通用的功能。工具箱的一个典型例子就是列表、关联表单、堆栈等类的集合,C++的I/O流库是另一个例子。工具箱并不强制应用采用某个特定的设计,它们只是为你的应用提供功能上的帮助。工具箱强调的是代码复用,它们是面向对象环境下的“子程序库”。
工具箱的设计比应用设计要难得多,因为它要求对许多应用是可用的和有效的。再者,工具箱的设计者并不知道什么应用使用该工具箱及它们有什么特殊需求。这样,避免假设和依赖就变得很重要,否则会限制工具箱的灵活性,进而影响它的适用性和效率。
3.框架
框架(Framework)是构成一类特定软件可复用设计的一组相互协作的类[Deu89,JF88]。例如,一个框架能帮助建立适合不同领域的图形编辑器,像艺术绘画、音乐作曲和机械CAD[VL90,Joh92]。另一个框架也许能帮助你建立针对不同程序设计语言和目标机器的编译器[JML92]。而再一个也许能帮助你建立财务建模应用[BE93]。你可以定义框架抽象类的应用相关的子类,从而将一个框架定制为特定应用。
框架规定了你的应用的体系结构。它定义了整体结构,类和对象的分割,各部分的主要责任,类和对象怎么协作,以及控制流程。框架预定义了这些设计参数,以便于应用设计者或实现者能集中精力于应用本身的特定细节。框架记录了其应用领域的共同的设计决策。因而框架更强调设计复用,尽管框架常包括具体的立即可用的子类。
这个层次的复用导致了应用和它所基于的软件之间的反向控制(inversionofcontrol)。当你使用工具箱(或传统的子程序库)时,你需要写应用软件的主体并且调用你想复用的代码。而当你使用框架时,你应该复用应用的主体,写主体调用的代码。你不得不以特定的名字和调用约定来写操作地实现,但这会减少你需要做出的设计决策。
你不仅可以更快地建立应用,而且应用还具有相似的结构。它们很容易维护,且用户看来也更一致。另一方面,你也失去了一些表现创造性的自由,因为许多设计决策无须你来作出。
如果说应用程序难以设计,那么工具箱就更难了,而框架则是最难的。框架设计者必须冒险决定一个要适应于该领域的所有应用的体系结构。任何对框架设计的实质性修改都会大大降低框架所带来的好处,因为框架对应用的最主要贡献在于它所定义的体系结构。因此设计的框架必须尽可能地灵活、可扩充。
更进一步讲,因为应用的设计如此依赖于框架,所以应用对框架接口的变化是极其敏感的。当框架演化时,应用不得不随之演化。这使得松散耦合更加重要,否则框架的一个细微变化都将引起强烈反应。
刚才讨论的主要设计问题对框架设计而言最具重要性。一个使用设计模式的框架比不用设计模式的框架更可能获得高层次的设计复用和代码复用。成熟的框架通常使用了多种设计模式。设计模式有助于获得无须重新设计就可适用于多种应用的框架体系结构。
当框架和它所使用的设计模式一起写入文档时,我们可以得到另外一个好处[BJ94]。了解设计模式的人能较快地洞悉框架。甚至不了解设计模式的人也可以从产生框架文档的结构中受益。加强文档工作对于所有软件而言都是重要的,但对于框架其重要性显得尤为突出。学会使用框架常常是一个必须克服很多困难的过程。设计模式虽然无法彻底克服这些困难,但它通过对框架设计的主要元素做更显式的说明可以降低框架学习的难度。
因为模式和框架有些类似,人们常常对它们有怎样的区别和它们是否有区别感到疑惑。它们最主要的不同在于如下三个方面:
1)设计模式比框架更抽象    框架能够用代码表示,而设计模式只有其实例才能表示为代码。框架的威力在于它们能够使用程序设计语言写出来,它们不仅能被学习,也能被直接执行和复用。而本书中的设计模式在每一次被复用时,都需要被实现。设计模式还解释了它的意图、权衡和设计效果。
2)设计模式是比框架更小的体系结构元素    一个典型的框架包括了多个设计模式,而反之决非如此。
3)框架比设计模式更加特例化      框架总是针对一个特定的应用领域。一个图形编辑器框架可能被用于一个工厂模拟,但它不会被错认为是一个模拟框架。而本书收录的设计模式几乎能被用于任何应用。当然比我们的模式更特殊的设计模式也是可能的(如,分布式系统和并发程序的设计模式),尽管这些模式不会像框架那样描述应用的体系结构。
框架变得越来越普遍和重要。它们是面向对象系统获得最大复用的方式。较大的面向对象应用将会由多层彼此合作的框架组成。应用的大部分设计和代码将来自于它所使用的框架或受其影响。

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页