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

打开正确的使用设计模式的姿势

March 8, 2017 - 2 minute read -
design pattern

设计模式采用多种方法解决面向对象设计中经常碰到的问题。这里给出在面向对象设计中的典型问题以及如果使用设计模式解决它们的方法。

> 寻找合适的对象 - Finding Appropriate Objects 
> 决定对象的粒度 - Determining Object Granularity
> 指定对象接口 - Specifying Object Interfaces
> 描述对象的实现 - Specifying Object Implementations
> 运用复用机制 - Putting Reuse Mechanisms to Work
> 关联运行时刻和编译时刻的结构 - Relating Run-Time and Compile-Time Structures
> 设计应支持变化 - Designing for Change

1. 寻找合适的对象

面向对象程序由对象组成,对象包括数据和对数据进行操作的过程,过程通常称为方法操作。对象在收到客户请求(或消息)后,执行相应的操作。

客户请求是使用对象执行操作的唯一方法,操作又是对象改变内部数据的唯一方法。由于这些限制,对象的内部状态是被封装的,它不能被直接访问,它的表示对于对象外部是不可见的。

面向对象设计最困难的部分是将系统分解成对象集合。因为要考虑许多因素:封装、粒度、依赖关系、灵活性、性能、演化、复用等等,它们都影响着系统的分解,并且这些因素通常还是相互冲突的。

面向对象设计方法学支持许多设计方法。你可以写出一个问题描述,挑出名词和动词,进行创建相应的类和操作;或者,你可以关注于系统的协作和职责关系;或者,你可以对现实世界建模,再将分析时发现的对象转化至设计中。至于哪一种方法最好,并无定论。

设计的许多对象来源于现实世界的分析模型。但是,设计结构所得到的类通常在现实是家中并不存在,有些是像数组之类的低层类,而另一些则层次较高。例如,Composite模式引入了统一对待现实世界中并不存在的对象的抽象方法。严格反映当前现实世界的模型并不能产生也能反映将来世界的系统。设计中的抽象对于产生灵活的设计是至关重要的。

设计模式帮你确定不明显的抽象和描述这些抽象的对象。例如,描述过程或算法的对象现实并不存在,但它们却是设计的关键部分。Strategy模式描述了怎样实现可互换的算法族。State模式将实体的每一个状态描述为一个对象。这些对象在分析阶段,甚至在设计阶段的早期都并不存在,后来为使设计更灵活、复用性更好才将它们发掘出来。

2. 决定对象的粒度

对象在大小和数目上的变化极大。它们能表示下自硬件或上自整个应用的任何事物。那么我们怎样决定一个对象应该是什么?

设计模式很好地讲述了这个问题。Facade模式描述了怎样对对象表示完整的子系统,Flyweight模式描述了如何支持大量的最小粒度的对象。其他一些设计模式描述了将一个对象分解成许多小对象的特定方法。Abstract FactoryBuilder产生哪些专门负责生成其他对象的对象。VisitorCommand生成的对象专门负责实现对其他对象或对象组的请求。

3. 指定对象接口

对象声明的每一个操作指定操作名、作为参数的对象和返回值,这就是所谓的操作的型构(signature)。对象操作所定义的所有操作型构的集合被称为该对象的接口(interface)。对象接口描述了该对象所能接受的全部请求的集合,任何匹配对象接口中型构的请求都可发送给该对象。 类型(type)是用来标识特定接口的一个名字。如果一个对象接受“Window”接口所定义的所有操作请求,那么我们就说该对象具有“Window”类型。一个对象可以有许多类型,并且不同的对象可以共享同一个类型。对象接口的某部分可以用某个类型来刻画,而其他部分则可用其他类型刻画。两个类型相同的对象只需要共享它们的部分接口。接口可以包含其他接口作为子集。当一个类型的接口包含另一个类型的接口时,我们就说它是另一个类型的子类型(subtype),另一个类型称之为它的超类型(supertype)。我们常说子类型*继承了它的超类型的接口。

在面向对象系统中,接口是基本的组成部分。对象只有通过它们的接口才能与外部交流,如果不通过对象的接口就无法知道对象的任何事情,也无法请求对象做任何事情。对象接口与其功能实现是分离的,不同对象可以对请求做不同的实现,也就是说,两个有相同接口的对象可以有完全不同的实现。

当给对象发送请求时,所引起的具体操作既可以与请求本身有关又与接收对象有关。支持相同请求的不同对象可能对请求激发的操作有不同的实现。发送给对象的请求和它的相应操作在运行时刻的连接就称之为动态绑定(dynamic binding)

动态绑定是指发送的请求直到运行时刻才受你具体的实现的约束。因而,在直到任何有正确接口的对象都将接受此请求时,你可以写一个一般的程序,它期待着那些具有该特定接口的对象。进一步讲,动态绑定允许你在运行时刻彼此替换有相同接口的对象。这种可替换性就称为多态(polymorphism),它是面向对象系统的核心概念之一。多态允许客户对象仅要求其他对象支持特定接口,除此之外对其假设几近于无。多态简化了客户的定义,使得对象间彼此独立,并可以在运行时刻动态改变它们相互的关系。

设计模式通过确定接口的主要组成成分及经接口发送的数据类型,来帮助你定义接口。设计模式也许还会告诉你接口中不应包括哪些东西。Memento模式是一个很好的例子,它描述了怎样封装和保存对象内部的状态,以便一段时间后对象能恢复到这一状态。它规定了Memento对象必须定义两个接口:一个允许客户保持和复制Memento的限制接口,和一个只有原对象才能使用的用来存储和提取Memento中状态的特权接口。

设计模式也指定了接口之间的关系。特别地,它们经常要求一个类具有相似的接口;或它们对一些类的接口做了限制。例如,Decorator和Proxy模式要Decorator和Proxy对象的接口与被修饰的对象和受委托的对象一致。而Visitor模式中,Visitor接口必须反映出visitor能访问的对象的所有类。

4. 描述对象的实现

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

对象通过实例化类来创建,此对象被称为该类的实例。当实例化类时,要给对象的内部数据(由实例变量组成)分配存储空间,并将操作与这些数据联系起来。对象的许多类似实例是由实例化同一个类创建的。

新的类可以由已存在的类通过类继承(class inheritance)来定义。当子类(subclass)继承父类(parent class)时,子类包含了父类定义的所有数据和操作。子类的实例对象包含所有子类和父类定义的数据,且它们能完成子类和父类定义的所有操作。

抽象类(abstract class)的主要目的是为了它的子类定义公共接口。一个抽象类将把它的部分或全部操作的实现延迟到子类中,因此,一个抽象类不能被实例化。在抽象类中定义却没有实现的操作被称为抽象操作(abstract operation)。非抽象类称为具体类(concrete class)**。

子类能够改进和抽芯定义它们父类的操作。更具体地说,类能够重定义(override)父类定义的操作,重定义使得子类能够接管父类对请求的处理操作。类继承允许你只需简单的扩展其他类就可以定义新类,从而可以很容易的定义具有相近功能的对象族。

  • 类继承与接口继承的比较

    理解对象的类(class)与对象的类型(type)之间的差别非常重要。一个对象的类定义了对象是怎样实现的,同时也定义了对象的内部状态和操作的实现。但是对象的类型只与它的接口有关,接口即对象能响应的请求的集合。一个对象可以有多个类型,不同类的对象可以有相同的类型。

    当然,对象的类和类型是有紧密关系的。因为类定义了对象所能执行的操作,也定义了对象的类型。当我们说一个对象是一个类的实例时,即指该对象支持类所定义的接口。

    理解类继承和接口继承(或子类型化)之间的差别十分重要。类继承根据一个对象的实现定义了另一个对象的实现。简而言之,它是代码和表示的共享机制。然而,接口继承(或子类型化)描述了一个对象什么时候能被用来替代另一个对象。尽管大部分程序设计语言并不区分接口继承和实现继承的差别,但使用中人们还是分别对待它们。很多设计模式依赖于这种差别。例如:Chain of Responsibility模式中的对象必须有一个公共的类型,但一般情况下它们不具有公共的实现。在Composite模式中,构件定义了一个公共的接口,但是Composite通常定义一个公共的实现。Command、Observer、State、Strategy通常纯粹作为接口的抽象类实现。

  • 对接口编程,而不是对实现编程

    类继承是一个通过复用父类功能而扩展应用功能的基本机制。它允许你根据旧对象快速定义新对象。它允许你从已存在的类中继承所需要的绝大部分功能,从而几乎无需任何代价就可以获得新的实现。

    然而,实现的复用只是成功的一半,继承所拥有的定义具有相同接口的对象族的能力也是很重要的(通常可以从抽象类来继承)。为什么?因为多态依赖于这种能力。

    当继承被恰当使用时,所有从抽象类导出的类将共享该抽象类的接口。这意味着子类仅仅添加或重定义操作,而没有隐藏父类的操作。这时,所有的子类都能响应抽象类接口中的请求,从而子类的类型都是抽象类的子类型。

    只根据抽象类中定义的接口来操作对象有以下来个好处: 1)客户无需知道他们使用对象的特定类型,只需对象有客户所期望的接口。 2)客户无须知道他们使用的对象是用什么类来实现的,他们只需知道定义接口的抽象类。 这将极大地坚守子系统实现之间的相互依赖关系,也产生了可复用的面向对象设计的如下原则:针对接口编程,而不是针对实现编程

当你不得不在系统的某个地方实例化具体的类(即指定一个特定的实现)时,创建性模式(Abstract Factory,Builder,Factory Method,Prototype,和Singleton)可以帮你。通过抽象对象的创建过程,这些模式提供不同方式以在实例化时建立接口和实现的透明连接。创建型模式确保你的系统是采用针对接口的方式书写的,而不是针对实现而书写的。

5. 运用复用机制

理解对象、接口、类和继承之类的概念对大多数人来说并不难,问题的关键在于如何运用它们写出灵活的、可复用的软件。设计模式将告诉你怎样去做。

5.1 继承和组合的比较

面向对象系统中功能复用的两种最常用技术是类继承和对象组合(object composition)。正如我们已解释过的,类继承允许你根据其他类的实现来定义一个类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继续方式中,父类的内部细节对子类可见。

对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或者组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只是以“黑箱”的形式出现。

继承和组合各有优缺点。类继承是在编译时刻静态定义的,且可以直接使用,因为程序设计语言直接支持类继承。类继承可以较方便地改变被复用的实现。当一个子类重定义一些而不是全部操作时,它也能影响它所继承的操作,只要在这些操作中调用了被重定义的操作。

但是类继承也有一些不足之处。首先,因为继承在编译时刻就定义了,所以无法再运行时刻改变从父类继承的实现。更糟的是,父类通常至少定义了部分子类的具体表示。因为继承对子类揭示了其父类的实现细节,所以继承常被认为“破坏了封装性”。子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。

当你需要复用子类时,实现上的依赖性就会产生一些问题。如果继承下来的实现不适合解决新的问题,则父类必须重写或被其他更适合的类替换。这种依赖关系限制了灵活性并最终限制了复用性。一个可用的解决方法就是只继承抽象类,因为抽象类提供较少的实现。

对象组合是通过获得对其他对象的引用而在运行时刻动态定义的。组合要求对象遵守彼此的接口约定,进而要求更仔细地定义接口,而这些接口并不妨碍你将一个对象和其他对象一起使用。这还好产生良好的结果:因为对象只能通过接口访问,所以我们并不破坏封装性;只要类型一致,运行时刻还可以用一个对象来替代另一个对象;更进一步,因为对象的实现是基于接口写的,所以实现上存在较少的依赖关系。

对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小的规模,并且不太可能增长为不可控的庞然大物。另一方面,基于对象组合的设计会有更多的对象(而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。这导出了我们的面向对象设计的第二个原则:优先使用对象组合,而不是类继承

理想情况下,你不应为获得复用而去创建新的构建。你应该能够只使用对象组合技术,通过组装已有的构件就能获得你需要的功能。但是事实很少如此,因为可用构件的集合实际上并不足够丰富。使得继承的复用使得创建新的构件要比组装旧的构件来得容易。这样,继承和对象组合常一起使用。然后,我们的经验表明:设计者往往过度使用了继承这种复用技术。但依赖于对象组合技术的设计却有更好的复用性(或更简单)。你会看到设计模式中一再使用对象组合技术。

5.2 委托

委托(delegation)是一种组合方法,它使组合具有与继承同样的复用能力。在委托方式下,有两个对象参与处理一个请求,接受请求的对象将操作委托给他的代理者(delegate)。这类似于子类将请求交给它的父类处理。使用继承时,被继承的操作总能引用接受请求的对象。委托方式为了得到同样的效果,接受请求的对象将自己传给被委托人(代理人),使被委托的操作可以引用接受请求的对象。

委托的主要优点在于它便于运行时刻组合对象操作以及改变这些操作的组合方式。假定矩形对象和圆对象有相同的类型,我们只需简单的用圆对象替换矩形对象,则得到的窗口就是圆形的。

委托与那些通过对象组合以取得软件灵活性的技术一样,具有如下不足之处:动态的、高度参数化的软件比静态软件更难于理解。还有运行低效问题,不过从长远来看人的低效才是更主要的。只有当委托使设计比较简单而不是更复杂时,它才是好的选择。要给出一个能确切告诉你什么时候可以使用委托的规则是很困难的。因为委托可以得到更好的效率是与上下文有关,并且还依赖于你的经验。委托最适合于符合特定程式的情形,即标准模式的情形。

有一些模式使用了委托,如State、Strategy、和Visitor。在State模式中,一个对象将请求委托给一个描述当前状态的State对象来处理。在Strategy模式中,一个对象将一个特定的请求委托给一个描述请求执行策略的对象,一个对象只会有一个状态,但它对不同的请求有许多策略。这两个模式的目的都是通过改变受托对象来改变委托对象的行为。在Visitor中,对象结构的每个元素上的操作总是被委托到Visitor对象。

其他模式则没有这个多地用到委托。Mediator引用了一个中介其他对象间通信的对象。有时,Mediator对象只是简单地将请求转发给其他对象;有时,它沿着指向自己的引用来传递请求,使用真正意义的委托。Chain of Responsibility通过将请求沿着对象链传递来处理请求,有时,这个请求本身带有一个接受请求对象的引用,这时该模式就使用了委托。Bridge将实现和抽象分离开,如果抽象和一个特定实现非常匹配,那么这个实现可以代理抽象的操作。

委托使对象组合的特例。它告诉你对象组合作为一个代码复用机制可以替代继承。

5.3 继承和参数化类型的比较

另一种功能复用技术(并非严格的面向对象技术)是参数化类型(parameterized type),也就是类属(generic)模板(templates)。它允许你在定义一个类型时并不指定该类型所用的其他所有类型。未经指定的类型在使用时以参数形式提供。例如,一个列表类能够以它所包含元素的类型来进行参数化。如果你想声明一个Integer列表,只需将Integer类型作为列表参数化类型的参数值;声明一个String列表,只需提供String类型作为参数值。语言的实现将会为各种元素类型创建相应的列表类模板的定制版本。

参数化类型给我们提供除了类继承和对象组合外的第三种方法来组合面向对象系统中的行为。许多设计可以使用这三种技术中的任何一种来实现。实现一个以元素比较操作为可变元的排序例程,可有如下方法:

  • 1)通过子类实现该操作(Template Method的一个应用)。
  • 2)实现为传给排序例程的对象职责(Strategy)。
  • 3)作为C++模板或Ada类属的参数,以指定元素比较操作的名称。

这些技术存在着极大的不同之处。对象组合技术允许你在运行时候改变被组合的行为,但是它存在间接性,比较低效。继承允许你提供操作的缺省实现,并通过子类重定义这些操作。参数化类型允许你改变所用到的类型。但是继承和参数化类型都不能在运行时刻改变。哪一种方法最佳,取决于你设计和实现的约束条件。

6. 关联运行时刻和编译时刻的结构

一个面向对象程序运行时刻的结构通常与它的代码结构相差较大。代码结构在编译时刻就被确定下来了,它由继承关系固定的类组成。而程序的运行时刻是由快速变化的通行对象网络组成。事实上两个结构是彼此独立的,试图由一个区理解另一个就好像试图从静态的动、植物分类去理解活生生的生态系统的动态性。反之亦然。

考虑对象聚合(aggregation)相识(acquaintance)的差别以及它们在编译和运行时刻的表示是多么的不同。聚合意味着一个对象拥有另一个对象或对另一个对象负责。一般我们称一个对象包含另一个对象或者是另一个对象的一部分。聚合意味着聚合对象和其所有者具有相同的生命周期。

相识意味着一个对象仅仅知道另一个对象。有时相识也被称之“关联”或“引用”关系。相识的对象可能请求彼此的操作,但是它们不为对方负责。相识是一种比聚合要弱的关系,它只标识了对象间较松散的耦合关系。

聚合和相识很容易混淆,因为它们通常以相同的方法实现。从根本上讲,是聚合还是相识是由你的意图而不是由显示的语言机制决定的。尽管它们之间的区别在编译时刻的机构中很难看出来,但这些区别还是很大的。聚合关系使用较少且比相识关系更持久;而相识关系则出现频率很高,但有时只存在于一个操作期间,相识也更具动态性,使得它在源代码中更难被辨别出来。

程序的运行时刻结构和编译时刻结构存在这么大的差别,很明显代码不可能揭示关于系统如何工作的全部。系统运行时刻结构更多地收到设计中的影响,而不是编程语言。对象和它们的类型之间的关系必须更加仔细地设计,因为它们决定了运行时刻程序结构的好坏。

许多设计模式(特别是那些属于对象范围的)显示地记述了编译时刻和运行时刻结构的差别。Composite和Decorator对于构造复杂的运行时刻结构特别有用。Observer也与运行时刻结构有关,但这些结构对于不了解该模式的人来说是很难理解的。Chain of Responsibility也产生了继承所无法展示的通信模式。总之,只有理解了模式,你才能清楚代码中的运行时刻结构。

7. 设计应支持变化

获得最大限度复用的关键在于对新的需求和已有需求发生变化时的预见性,要求你的系统设计要能够相应的改机。

为了设计适应这种变化、且具有健壮性的系统,你必须考虑系统在它的生命周期内会发生怎样的变化。一个不考虑系统变化的设计在将来就有可能需要重新设计。这些变化可能是类的重新定义和实现,修改客户和重新测试。重新设计会影响软件系统的许多方面,并且位曾料到的变化总是代价巨大的。

设计模式可以确保系统能以特定方式变化,从而帮助你避免重新设计系统。每一个设计模式允许系统结构的某个方面的变化独立于其他方面,这样产生的系统对于某一种特殊变化将更健壮。

下面阐述了一些导致重新设计的一般原因,以及解决这些问题的设计模式:

  • 通过显示地指定一个类来创建对象(Creating an object by specifying a class explicitly.)

    在创建对象时指定类名使你受特定实现的约束而不是特定接口的约束。这会使未来的变化更复杂。要避免这种情况,应该间接地创建对象。

    设计模式:Abstract Factory,Factory Method, Prototype。

  • 对特殊操作的依赖(Dependence on specific operations.)

    当你为请求指定一个特殊操作时,完成该请求的方式就固定下来了,为了避免把请求代码写死,你将可以再编译时刻或运行时刻很方便地改变响应请求的方法。

    设计模式:Chain of Responsibility,Command。

  • 对硬件和软件平台的依赖(Dependence on hardware and software platform.)

    外部操作系统接口和应用编程接口(API)在不同的软硬件平台上是不同的。依赖于特定平台的软件将很难移植到其他平台上,甚至都很难跟上本地平台的更新。所以设计系统时限制其平台相关性就很重要了。

    设计模式:Abstract Factory,Bridge。

  • 对对象表示或实现的依赖(Dependence on object representations or implementations.)

    知道对象怎样表示、保存、定位或实现的客户在对象发生变化时可能也需要变化。对客户隐藏这些信息能阻止连锁发生。

    设计模式:Abstract Factory,Bridge,Memento,Proxy。

  • 算法依赖(Algorithmic dependencies.)

    算法在开发和复用时常常被扩展、优化和替代。依赖于某个特定算法的对象在算法发生变化时不得不变化。因此有可能发生变化的算法应该被孤立起来。

    设计模式:Builder,Iterator,Strategy,Template Method,Visitor。

  • 紧耦合(Tight coupling.)

    紧耦合的类很难独立地被复用,因为它们是相互依赖的。紧耦合产生单块的系统,要改变或删掉一个类,你必须理解和改变其他许多类。这样的系统是一个很难学习、移植和维护的密集体。

    松散耦合提高了一个类本身被复用的可能性,并且系统更易于学习、移植、修改和扩展。设计模式使用抽象耦合和分层技术来提高系统的松散耦合性。

    设计模式:Abstract Factory,Command,Facade,Mediator,Observer,Chain of Responsibility。

  • 通过生成子类来扩充功能(Extending functionality by subclassing.)

    通常很难通过定义子类来定制对象。每一个新类都有固定的实现开销(初始化、终止处理等)。定义子类还需要对父类有深入的了解。如,重定义一个操作可能需要重定义其他操作。一个被重定义的操作可能需要调用继承下来的操作。并且子类方法会导致类爆炸,因为即使对于一个简单的扩充,你也不得不引入许多新的子类。

    一般的对象组合技术和具体的委托技术,是继承之外组合对象行为的另一种灵活方法。新的功能可以通过以新的方式组合已有对象,而不是通过定义已存在类的子类加到应用中去。另一方面,过多使用对象组合会使设计难于理解。许多设计模式产生的设计中,你可以定义一个子类,且将它的实例和已存在实例进行组合来引入定制的功能。

    设计模式:Bridge,Chain of Responsibility,Composite,Decorator,Observer,Strategy。

  • 不能方便地对类进行修改(Inability to alter classes conveniently.)

    有时你不得不改变一个难以修改的类。也许你需要源代码而又没有(对于商业类库就是这种情况),或者可能对类的任何改变会要求修改许多已存在的其他子类。设计模式提供在这些情况下对类进行修改的方法。

    设计模式:Adapter,Decorator,Visitor。

这些例子反映了使用设计模式有助于增强软件的灵活性。这种灵活性所具有的重要程度取决于你将要建造的软件系统。