(源自:http://www.ibm.com/developerworks/)
级别: 初级
Robert Brunner, 高级工程师, TradeAccess, Inc.
2002 年 3 月 26 日
2008 年 8 月 06 日 更新
设计模式以系统的方式获取一些软件开发专家的经验,提供一些常见的反复出现问题和解决方案以及这些方案的结果。本教程说明了:为什么模式在面向对象设计和开发中是有用的和重要的;如何对模式进行编制文档、分类和编目;何时应该使用模式;以及有哪些 重要的模式和如何实现它们。
本教程是针对那些希望通过学习设计模式来提高自身面向对象设计和开发技能的 Java 程序员的。阅读完本教程之后,您将:
本教程假设您熟悉 Java 语言和基本的面向对象概念,如:多态性、继承和封装。对“统一建模语言(UML)”有一定的理解也是有益的,但不是必需的;本教程将对这些基础知识做一些介绍。
设计模式以系统的方式获取一些软件开发专家的经验,提供一些常见的反复出现问题和解决方案以及这些方案的结果。
本教程说明了:
本教程中的示例都是用 Java 语言编写的。要理解基本概念,阅读这些代码可能足够了,但是要尝试这些代码需要一个最小化的 Java 开发环境。您所需要的仅仅是一个简单的文本编辑器(如 Windows 中的记事本(Notepad)或 UNIX 环境中的 vi)和 Java 开发工具箱(Java Development Kit)(版本 1.2 或更高版本)。
在创建 UML 图时还需要一些工具(请参阅参考资料)。但它们不是本教程所必需的。
设计模式第一次是由架构设计师 Christopher Alexander 在他所著的 A Pattern Language: Towns, Buildings, Construction(Oxford University Press,1977)一书中提到的。他引入了这一概念,并称为模式 ─ 对于反复出现设计问题的抽象解决方案 ─ 这一概念吸引了其它领域中一些研究人员的注意,特别是二十世纪八十年代中后期,那些开发面向对象的软件人员。
对软件设计模式的研究造就了一本可能是面向对象设计方面最有影响的书籍:Design Patterns: Elements of Reusable Object-Oriented Software(即后述《设计模式》一书),由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著(Addison-Wesley,1995; 请参阅参考资料)。这几位作者常被称为“四人组(Gang of Four)”,而这本书也就被称为“四人组(或 GoF)”书。
在《设计模式》这本书的最大部分是一个目录,该目录列举并描述了 23 种设计模式。另外,近来这一清单又增加了一些类别,最重要的是使涵盖范围扩展到更具体的问题类型。例如,Mark Grand 在 Patterns in Java: A Catalog of Reusable Design Patterns Illustrated with UML(即后述《模式 Java 版》一书)中增加了解决涉及诸如并发等问题的模式,而由 Deepak Alur、John Crupi 和 Dan Malks 合著的 Core J2EE Patterns: Best Practices and Design Strategies 一书中主要关注使用 Java 2 企业技术的多层应用程序上的模式。
有一个活跃的模式社区,它收集一些新模式,继续研究原有模式,并且领导大家推广模式。尤其值得一提的是,Hillside Group 主办了许多会议,其中包括在专家的指导下向初学者介绍模式。参考资源提供了有关模式和模式社区方面的信息的其它来源。
“四人组”将模式描述为“在一定的环境中解决某一问题的方案”。这三个事物 ─ 问题、解决方案和环境 ─ 是模式的基本要素。给模式一个名称,考虑使用模式将产生的结果和提供一个或多个示例,对于说明模式也都是有用的。
不同的编目员使用不同的模板来说明它们的模式。不同的编目员对于模式的各个不同部分还使用不同的名称。每个类别对于每个模式的详细程度和分析级别上也有所不同。下面几页描述了《设计模式》和《模式 Java 版》中所使用的一些模板。
《设计模式》模板
《设计模式》使用下列模板:
《模式 Java 版》模板
《模式 Java 版》使用下列模板:
首先要学习的最重要内容是每个模式的意图和环境:这个模式在什么情况下要解决什么问题。本教程只讲述一些最重要的模式,接下来,对于那些勤奋的开发人员,建议浏览一些类别并挑出关于每个模式的信息。在《设计模式》中,要阅读的相关章节是“意图”、“动机”和“适用性”。在《模式 Java 版》中,相关章节是“提要”、“环境”和“推动力与解决方案”。
进行背景研究可以帮助您确定一种模式,这种模式能提供所面临的设计问题的一种解决方案。然后,在详细考虑这个解决方案和它的结果之后,针对适用性,进一步评估这个候选模式。如果不行,可以看看相关模式。
在某些情况下,可能会发现可以有效地使用多个模式。而在另一些情况下,可能没有合适的模式,或者在性能或复杂性方面,使用合适的模式可能成本过高,而特定的解决方案可能是最好的办法。(也许这一解决方案还会造就一个尚未记录在案的新模式呢!)
在设计面向对象的软件中,关键步骤是发现对象。这里有许多技术可以帮助发现对象,例如:用例(use case)、协作图(collaboration diagram)或“类-职责-协作(Class-Responsibility-Collaboration,CRC)”卡,但是发现对象对于经验匮乏的设计人员来说是最困难的一步。
缺少经验或指导会导致过多的对象,而这些对象存在过多的交互及由此产生的相关性,对于所创建的整体式系统,很难维护而且不可能重用。这违背了面向对象设计的初衷。
设计模式帮助克服这类问题,因为它们传授从专家的经验中提炼出来的教训:模式文档专门技术。而且,模式不仅描述如何构造软件,更重要的是,还描述了类和对象如何交互(特别是在运行时)。明确地考虑这些交互及其结果会带来更灵活、可重用的软件。
在正确使用模式产生可重用代码的同时,其结果除了好处,还常常增加一定的成本。通常,通过引入封装或间接来得到可重用性,但封装和间接会降低性能和增加复杂性。
例如,您可以使用 Facade 模式,用单个类将多个松散相关的类包装起来,以创建一套易于使用的功能。一个可能的应用程序可以是创建 Java“国际化 API”的外观。该方法对于独立的应用程序可能是适合的,因为应用程序中的各部分都需要从资源束、格式化日期和时间等获得文本。但是,对于将表示逻辑与业务逻辑分开的多层企业应用程序来说,这可能并不适合。如果将对“国际化 API”的所有调用都隔离在一个表示模块中 ─ 可能通过将它们包装为 JSP 定制标记来实现 ─ 再多一个间接层可能是多余的。
在并发模式 (关于单线程执行模式的结果)中讨论了另外一个何时应该谨慎使用模式的示例。
随着系统的完善、经验的丰富或软件中瑕疵的暴露,偶尔重新考虑您以前所做的选择是有益的。您可能必须重新编写特定的代码以便它使用一种模式,或从一个模式更改到另一个模式,或除去整个模式以消除间接层。要允许更改(或至少要对此有所准备),因为这是不可避免的。
UML 已成为面向对象设计的标准图形化工具。在 UML 定义的各种图中,本教程只涉及类图。在类图中,类被描绘为带有三层的盒子。
顶层包含类名;如果类是抽象的,其名称用斜体表示。中间层包含类的属性(attribute)(也称为特性(property)或变量)。底层包含类的方法(也称为操作)。与类名相似,如果方法是抽象的,则它的名称用斜体表示。
根据所希望的详细程度,可能省略特性,而只显示类名及其方法,也可以忽略特性和方法,而只显示类名。当在说明整个概念性关系时,常用这种方法。
通过在类之间画一条线来描绘它们之间的任何交互。一条简单的线表示一个关联,这通常是任何未指明类型的概念性关联。可以改变连线,以提供关于关联的更明确的信息。在线上添加一个开放式箭头来表示导航性(navigability)。添加一个三角形箭头表示具体化( specialization)或子类化。还可以在每端添加基数(或用星号表示未指明有多少个)来表示关系,如一对一和多对一的关系。
下列各图显示了不同类型的关联:
参考资料提供了有关 UML 和 Java 语言关联的更多资料。
创建型模式(creational pattern)规定了创建对象的方式。在必须决定实例化某个类时,使用这些模式。通常,由抽象超类封装实例化类的细节,这些细节包括这些类确切是什么,以及如何及何时创建这些类。对客户机类来讲,这些类的细节是隐藏的。客户机类只知道抽象类或抽象类实现的接口。客户机类通常并不知道具体类的确切类型。
例如,Singleton 模式用来封装对象的创建,以便维护对它的控制。这不仅确保只创建一个实例,还允许延迟实例化(lazy instantiation);即,可以延迟对象的实例化,直到实际需要实例化时。如果构造器需要执行一个代价较高的操作(如访问远程数据库),这一点特别有用。
这段代码演示了如何使用 Singleton 模式来创建一个计数器,从而提供唯一的序列号,例如,可能需要用此序列号来作为数据库中的主键。
// Sequence.java public class Sequence { private static Sequence instance; private static int counter; private Sequence() { counter = 0; // May be necessary to obtain // starting value elsewhere... } public static synchronized Sequence getInstance() { if(instance==null) // Lazy instantiation { instance = new Sequence(); } return instance; } public static synchronized int getNext() { return ++counter; } } |
关于这个实现有几点要注意:
除了 Singleton 模式外,创建型模式的另一个常用示例是 Factory Method。在运行时必须决定要实例化几个兼容类中的哪一个时,使用这种模式。该模式的使用贯穿于 Java API 中。例如,抽象类 Collator 的 getInstance() 方法返回根据 java.util.Locale.getDefault() 确定的适用于缺省语言环境的整理对象:
Collator defaultCollator = getInstance(); |
返回的具体类实际上总是 Collator 的子类 RuleBasedCollator,但这是一个并不重要的实现细节。使用该类所需的只是抽象类 Collator 定义的接口。
结构型模式(Structural pattern)规定了如何组织类和对象。这些模式涉及类如何相互继承或如何从其它类组合。
常用的结构型模式包括 Adapter、Proxy 和 Decorator 模式。因为这些模式在客户机类与其要使用的类之间引入了一个间接层,所以它们是类似的。但是,它们的意图有所不同。Adapter 使用这种间接修改类的接口以方便客户机类使用它。Decorator 使用这种间接向类添加行为,而不会过度地影响客户机类。Proxy 使用这种间接透明地提供另一个类的替身。
通常,Adapter 模式用来允许重用与客户机类希望看到的类相似的但又不完全相同的类。这种情况一般发生在:原始类能够支持客户机类需要的行为,但没有客户机类希望的接口,而且改变原始类是不可能的,或是不切实际的。或许,是无法获得源代码,或者其它地方正在使用它,并且不适合更改接口。
这里有一个示例,它包装了 OldClass,使客户机类可以使用 NewInterface 中定义的 NewMethod() 方法来调用 oldclass:
public class OldClassAdapter implements NewInterface { private OldClass ref; public OldClassAdapter(OldClass oc) { ref = oc; } public void NewMethod() { ref.OldMethod(); } } |
Proxy 是另一个类的直接替身,它通常与所替代的那个类有相同的接口,因为它实现一个公共接口或抽象类。客户机对象并不知道它正在使用代理。当客户机希望使用某个类,而访问该类的方式很明显必须用间接的方式时(例如,因为客户机需要受限访问或它是一个远程进程),应该使用 Proxy 模式。
与 Proxy 相似,Decorator 也是另一个类的替身,通常因为它是一个子类,所以它也与所替代的那个类有相同的接口。但是,意图不同。Decorator 模式的目的是用对客户机类透明的方式来扩展原始类的功能。
Java API 中 Decorator 模式的示例可在用于处理输入和输出流的类中找到。例如,BufferedReader() 使得从文件中读取文本更方便更有效:
BufferedReader in = new BufferedReader(new FileReader("file.txt")); |
Composite 模式规定复杂对象的递归组合。其意图是以一种一致的方式处理所有的组成对象。参与这一模式的所有对象,不管是简单还是复杂的,都是从定义公共行为的一个公共抽象组件类派生而来的。
用这种方法将各种关系强制转换成部分-整体的层次结构,从而使系统(或客户机子系统)需要管理的对象类型变得最小。例如,画图程序的客户机以它要求其它对象(包括组合对象)的相同方式要求线条画出自身。
行为模式(Behavioral pattern)规定了对象之间交互的方式。它们通过指定对象的职责和对象相互通信的方式,使得复杂的行为易于管理。
Observer 是一个很常见的模式。在您用“模型/视图/控制器(Model/View/Controller)”体系结构实现应用程序时,通常会使用这一模式。该设计的“模型/视图”部分是为了去除数据的表示与数据本身的耦合性。
例如,设想这种情况:数据保存在数据库中,可以以多种格式(表格或图形)显示该数据。Observer 模式建议显示类向负责维护数据的类注册它们自身,这样在数据发生更改时可以通知显示类,从而它们可以更新它们的显示。
Java API 在它的 AWT/Swing 类的事件模型中使用该模式。它也提供了直接支持,这样出于其它目的时也能实现该模式。
Java API 提供了 Observable 类,它可以由要观察的对象进行子类化。Observable 提供了以下方法:
与此相应,还提供了一个 Observer 接口,它包含一个由 Observable 对象在其发生更改时调用的方法(当然,是在 Observer 已向 Observable 类注册了它自己的前提下):
public void update(Observable o, Object arg) |
下面示例演示了如何使用 Observer 模式来通知诸如温度等传感器的显示类已检测到变化:
import java.util.*; class Sensor extends Observable { private int temp = 68; void takeReading() { double d; d =Math.random(); if(d>0.75) { temp++; setChanged(); } else if (d<0.25) { temp--; setChanged(); } System.out.print("[Temp: " + temp + "]"); } public int getReading() { return temp; } } public class Display implements Observer { public void update(Observable o, Object arg) { System.out.print("New Temp: " + ((Sensor) o).getReading()); } public static void main(String []ac) { Sensor sensor = new Sensor(); Display display = new Display(); // register observer with observable class sensor.addObserver(display); // Simulate measuring temp over time for(int i=0; i < 20; i++) { sensor.takeReading(); sensor.notifyObservers(); System.out.println(); } } } |
Strategy 和 Template 模式是类似的,因为它们都允许对固定的一组行为使用不同的实现。但是,它们的意图有所不同。
Strategy 用来允许在运行时动态地选择算法或操作的不同实现。通常,在抽象类中实现任何公共行为,而具体子类提供那些有差异的行为。客户机一般知道可用的不同策略,并且可以在其中选择。
例如,抽象类 Sensor 可以定义测量,并需要具体子类实现不同的技术:一个可能提供连续的平均值,一个可能提供瞬时测量, 而另一个可能取某段时间内的峰值(或最低值)。
Template 模式的意图不是象 Strategy 中那样允许以不同的方法实现行为,而是确保实现确定的行为。换句话说,Strategy 关注的是允许多样化,而 Template 关注的是加强一致性。
Template 模式是作为抽象类来实现的,它常用来为具体子类提供蓝图或轮廓。有时,用它来实现系统中的挂接(hook),如应用程序框架。
并发模式(Concurrency pattern)规定协调或顺序对共享资源访问的方式。到目前为止,最常用的并发模式是单线程执行(Single Thread Execution),它必须确保一次只有一个线程有权访问某个代码段。这段代码称为临界段(critical section),通常它是获取对必须共享的资源的访问权的一段代码(如打开端口),或是应为原子性的一系列操作,如获取一个值,执行计算,然后更新该值。
之前讨论的 Singleton 模式包含两个不错的单线程执行模式示例。引发该模式的问题的出现首先是因为该示例使用延迟实例化 ─ 延迟实例化,直到需要时才进行 ─ 从而造成这样的可能性:两个不同的线程可能同时调用 getInstance()。
public static synchronized Sequence getInstance() { if(instance==null) // Lazy instantiation { instance = new Sequence(); } return instance; } |
如果没有用 synchronized 保护该方法免于同时访问,则每个线程都可能进入该方法,测试并查找静态实例引用是否为空,然后每个线程都可能尝试创建一个新实例。最后一个完成的线程获胜,它会覆盖第一个线程的引用。在这个特定的示例中,情况可能还不是最坏 ─ 它只是创建一个孤立对象,垃圾收集器最终会将这个对象清理掉 ─ 但如果已有一个共享资源,它强制要求单个访问,如打开一个端口或打开一个日志文件来进行读/写访问,则第二个线程要创建实例的尝试就会因为第一个线程已获得对该共享资源的独占访问而失败。
Singleton 示例中另一个临界段代码是 getNext() 方法:
public static synchronized int getNext() { return ++counter; } |
如果没有用 synchronized 保护该方法,则同时调用它的两个线程可能会获得相同的当前值,而不是该类原打算提供的唯一值。如果正在使用该方法来获得主键进行数据库插入,则第二个用同一主键进行插入的尝试会失败。
正如我们之前讨论的,您总是应该考虑使用模式所带来的代价。当一个线程进入这段代码后,阻塞任何其它线程,直到第一个线程完成为止,通过这种锁定代码段的方式来实现 synchronized。如果这是许多线程要频繁使用的代码,则这会导致性能上极大的降低。
另一个危险是,如果两个线程中的第一个因等待第二个线程的到来而被阻塞在某个临界段,同时第二个线程因等待第一个的到来而被阻塞在另一个临界段,则两个线程发生死锁。
由于下列重要理由,所以设计模式对于面向对象设计是一种有用的工具。
网上资源
UML 工具
David Gallardo 是一个自由软件顾问和作家,他专长于软件国际化、Java Web 应用和数据库开发。他作为一名职业软件工程师已有 15 年多,曾参与过许多操作系统、编程语言和网络协议的开发。David 最近在一家 B2B 电子商务公司 TradeAccess, Inc. 中领导数据库和国际化的开发。在此之前,他是 Lotus Development Corporation 国际产品开发(International Product Development)小组中一名高级工程师,在那里,他致力于跨平台库的开发,这个跨平台库为 Lotus 产品(包括 Notes 和 1-2-3)提供 Unicode 和国际语言支持。
可以通过 [email protected] 与 David 联系。