23 Design Patterns implemented by C++.
从本文开始,一系列的文章将揭开设计模式的神秘面纱。本篇博文是参考了《设计模式-可复用面向对象软件的基础》这本书,由于该书的引言
写的太好了,所以本文基本是对原书的摘抄。
评估一个面向对象系统的质量时,方法之一就是要判断系统的设计者是否强调了对象之间的公共协同关系。在系统开发阶段强调这种机制的优势在于,它能使所生成的系统体系结构更加精巧、简洁和易于理解,其程度远远超过了未使用模式的体系结构。
设计模式描述了在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。设计模式捕获了随时间进化与发展的问题的求解方法,因此它们并不是人们从一开始就采用的设计方案。它们反映了不为人知的重新设计和重新编码的成果,而这些都来自软件开发者为了设计出灵活、可复用的软件而长时间进行的艰苦努力。设计模式捕获了这些解决方案,并用简洁易用的方式表达出来。
根据模式的性质可以将设计模式划分为三种类型:创建型(creational)、结构型(structural)和行为型(behavioral)。
设计面向对象软件比较困难,而设计可复用的面向对象软件就更加困难。你必须找到相关的对象,以适当的粒度将它们归类,再定义类的接口和继承层次,建立对象之间的基本关系。设计应该对手头的问题有针对性,同时对将来的问题和需求也要有足够的通用性。避免重复设计或尽可能少做重复设计。
设计模式使人们可以更加简单方便地复用成功的设计和体系结构。
Christopher Alexander说过:“每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动”。
一般而言,一个模式有四个基本要素:
设计模式是对用来在特定场景下解决一般设计问题的类和相互通信的对象的描述。
在Smalltalk-80中,类的模型/视图/控制器(Model/View/Controller)三元组(MVC)被用来构建用户界面。MVC包括三类对象。模型(Model)是应用对象,视图(View) 是它在屏幕上的表示,控制器(Controller)定义用户界面对用户输入的响应方式。不使用MVC,用户界面设计往往将这些对象混在一起, 而MVC则将它们分离以提高灵活性和复用性。
视图必须保证它的显示正确地反映了模型的状态。一旦模型的数据发生变化, 模型将通知有关的视图,每个视图相应地得到刷新自己的机会。
下图显示了一个模型和三个视图(为了简单起见我们省略了控制器)。模型包含一些数据值,视图通过电子表格、柱状图、饼图等不同的方式来显示这些数据。当模型的数据发生变化时,模型就通知它的视图,而视图将与模型通信以获取这些数据值。
将对象分离,使得一个对象的改变能够影响另一些对象,而这个对象并不需要知道那些被影响的对象的细节。这个更一般的设计被描述成Observer模式。
MVC的另一个特征是视图可以嵌套。将一些对象划为一组,并将该组对象当作一个对象来使用。这个设计被描述为Composite模式,该模式允许你创建一个类层次结构,一些子类定义了原子对象(如Button)而其他类定义了组合对象(CompositeView),这些组合对象是由原子对象组合而成的更复杂的对象。
View使用Controller子类的实例来实现一个特定的响应策略。要实现不同的响应策略只要用不同种类的Controller实例替换即可。甚至可以在运行时通过改变View的Controller来改变View对用户输入的响应方式。例如,一个View可以被禁止接收任何输入,只需给它一个忽略输入事件的Controller。
View-Controller关系是Strategy模式的一个例子。一个策略是一个表述算法的对象。
用统一的格式描述设计模式,每一个模式根据以下模板被分成若干部分。模板具有统一的信息描述结构,有助于更容易地学习、比较和实用设计模式。
23个设计模式的名字和意图列举如下:
根据两条准则对模式进行分类。
根据以上两条准则,将 23 中设计模式进行如下:
还有一种方式是根据模式的“相关模式”部分所描述的它们怎样互相引用来组织设计模式。下图给出了模式关系的图形说明。
设计模式采用多种方法解决面向对象设计者经常碰到的问题。如下给出几个问题以及使用设计模式解决它们的方法。
面向对象程序由对象组成,对象包括数据和对数据进行操作的过程,过程通常称为方法或操作。对象在收到客户的请求(或消息) 后,执行相应的操作。客户请求是使对象执行操作的唯一方法,操作又是对象改变内部数据的唯一方法。
面向对象设计最困难的部分是将系统分解成对象集合。因为要考虑许多因素:封装、粒度、依赖关系、灵活性、性能、演化、复用等,它们都影响着系统的分解,并且这些因素通常还是互相冲突的。
设计模式帮你确定并不明显的抽象和描述这些抽象的对象。例如,描述过程或算法的对象现实中并不存在,但它们却是设计的关键部分。
对象在大小和数目上变化极大。它们能表示下至硬件或上至整个应用的任何事物。那么我们怎样决定一个对象应该是什么呢? 设计模式很好地讲述了这个问题。
对象声明的每一个操作指定操作名、作为参数的对象和返回值, 这就是所谓的操作的型构(signature)。对象操作所定义的所有操作型构的集合被称为该对象的接口(interface)。对象接口描述了该对象所能接受的全部请求的集合,任何匹配对象接口中型构的请求都可以发送给该对象。
类型(type)是一个用来标识特定接口的名字。一个对象可以有许多类型,并且不同的对象可以共享同一个类型。对象接口的某部分可以用某个类型来刻画,而其他部分则可用其他类型刻画。两个类型相同的对象只需要共享它们的部分接口。接口可以包含其他接口作为子集。当一个类型的接口包含另一个类型的接口时,我们就说它是另一个类型的子类型(subtype),而称另一个类型为它的超类型(supertype)。我们常说子类型继承了它的超类型的接口。
在面向对象系统中,接口是基本的组成部分。对象只有通过它们的接口才能与外部交流,如果不通过对象的接口就无法知道对象的任何事情,也无法请求对象做任何事情。
当给对象发送请求时,所引起的具体操作既与请求本身有关又与接受对象有关。支持相同请求的不同对象可能对请求激发的操作有不同的实现。发送给对象的请求和它的相应操作在运行时的连接就称为动态绑定(dynamic binding)。
动态绑定是指发送的请求直到运行时才受你的具体实现的约束。因而,在知道任何有正确接口的对象都将接受此请求时,你可以写一个一般的程序,它期待着那些具有该特定接口的对象。进一步讲,动态绑定允许你在运行时彼此替换有相同接口的对象。这种可替换性就称为多态(polymorphism),它是面向对象系统中的核心概念之一。多态允许客户对象仅要求其他对象支持特定接口,除此之外对其假设几近于无。多态简化了客户的定义,使得对象间彼此独立,并可以在运行时动态改变它们相互的关系。
设计模式通过确定接口的主要组成成分及经接口发送的数据类型来帮助你定义接口。设计模式也许还会告诉你接口中不应包括哪些东西。Memento模式是一个很好的例子,它描述了怎样封装和保存对象内部的状态,以便一段时间后对象能恢复到这一状态。它规定了Memento对象必须定义两个接口:一个允许客户保持和复制memento的限制接口,一个只有原对象才能使用的用来储存和提取memento中状态的特权接口。
设计模式也指定了接口之间的关系。特别是,它们经常要求一些类具有相似的接口,或它们对一些类的接口做了限制。例如, Decorator和Proxy模式分别要求Decorator和Proxy对象的接口与被修饰的对象和受委托的对象一致。而Visitor模式中, Visitor接口必须反映出visitor能访问的对象的所有类。
对象的实现是由它的类决定的,类指定了对象的内部数据和表示,也定义了对象所能完成的操作,如下图所示:
基于OMT的表示法,将类描述成一个矩形,其中的类名以黑体表示。操作在类名下面,以常规字体表示。类所定义的任何数据都在操作的下面。类名与操作之间以及操作与数据之间用横线分隔。
返回类型和实例变量类型是可选的,因为我们并未假设一定要用具有静态类型的实现语言。
对象通过实例化类来创建,此对象被称为该类的实例。
类继承与接口继承的比较
对象的类定义了对象是怎样实现的,同时也定义了对象的内部状态和操作的实现。但是对象的类型只与它的接口有关,接口即对象能响应的请求的集合。一个对象可以有多个类型,不同类的对象可以有相同的类型。
对象的类和类型是有紧密关系的。因为类定义了对象所能执行的操作,也定义了对象的类型。当我们说一个对象是一个类的实例时,即指该对象支持类所定义的接口。
类继承根据一个对象的实现定义了另一个对象的实现。简而言之,它是代码和表示的共享机制。然而,接口继承(或子类型化)描述了一个对象什么时候能被用来替代另一个对象。
C++中接口继承的标准方法是公有继承一个含(纯)虚成员函数的类。C++中纯接口继承接近于公有继承纯抽象类,纯实现继承或纯类继承接近于私有继承。
很多设计模式依赖于这种差别。例如,Chain of Responsibility 模式中的对象必须有一个公共的类型,但一般情况下它们不具有公共的实现。在Composite 模式中,构件定义了一个公共的接口,但Composite通常定义一个公共的实现。
Command 、Observer 、State 和Strategy 通常纯粹作为接口的抽象类来实现。
对接口编程,而不是对实现编程
只根据抽象类中定义的接口来操纵对象有以下两个好处:
1)客户无须知道他们使用对象的特定类型,只需要知道对象有客户所期望的接口。
2)客户无须知道他们使用的对象是用什么类来实现的,只需要知道定义接口的抽象类。
这将极大地减少子系统实现之间的相互依赖关系,也产生了可复用的面向对象设计的如下原则: 针对接口编程,而不是针对实现编程
。
不将变量声明为某个特定的具体类的实例对象,而是让它遵从抽象类所定义的接口
继承和组合的比较
面向对象系统中功能复用的两种最常用技术是类继承和对象组合(object composition)。类继承允许根据其它类的实现来定义一个类的实现,这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言的:在继承方式中,父类的内部细节对子类可见。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有定义良好的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。
继承对子类揭示了其父类的实现细节,所以继承常被认为“破坏了封装性”。子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。
因为对象只能通过接口访问,所以我们并不破坏封装性; 只要类型一致,运行时还可以用一个对象来替代另一个对象;更进一步,因为对象的实现是基于接口写的,所以实现上存在较少的依赖关系。
对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小规模,并且不太可能增长为不可控制的庞然大物。另外,基于对象组合的设计会有更多的对象(而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。
这导出了我们的面向对象设计的第二个原则: 优先使用对象组合,而不是类继承
。
委托
委托(delegation)是一种组合方法,它是组合具有与继承同样的复用能力。在委托方式下,有连个对象参与处理一个请求,接受请求的对象将操作委托给它的代理者(delegate)。
委托的主要优点在于它便于运行时组合对象操作以及改变这些操作的组合方式。假定矩形对象和圆对象有相同的类型,我们只需要简单地用圆对象替换矩形对象,得到的窗口就是圆形的。不足之处:动态的、高度参数化的软件比静态软件更难于理解。还有运行低效问题,不过从长远来看人的低效才是更主要的。
有一些模式使用了委托,如State、Strategy和Visitor。在State模式中,一个对象将请求委托给一个描述当前状态的State对象来处理。在Strategy模式中,一个对象将一个特定的请求委托给一个描述请求执行策略的对象,一个对象只会有一个状态, 但它对不同的请求可以有许多策略。这两个模式的目的都是通过改变受托对象来改变委托对象的行为。在Visitor中,对象结构的每个元素上的操作总是被委托到Visitor对象。
其他模式则没有这么多地用到委托。Mediator引进了一个作为其他对象间通信的中介的对象。有时,Mediator对象只是简单地将请求转发给其他对象;有时,它沿着指向自己的引用来传递请求,使用真正意义的委托。Chain of Responsibility通过将请求沿着对象链传递来处理请求,有时,这个请求本身带有一个接受请求对象的引用,这时该模式就使用了委托。Bridge将实现和抽象分离开, 如果抽象和一个特定实现非常匹配,那么这个实现可以代理抽象的操作。
继承和参数化类型的比较
另一种功能复用技术(并非严格的面向对象技术)是参数化类型(parameterized type),也就是类属(generic)(Ada、Eiffel)或模板(template)(C++)。它允许你在定义一个类型时不用指定该类型所用到的其他所有类型。未经指定的类型在使用时以参数形式提供。例如,一个列表类能够以它所包含元素的类型来进行参数化。
对象组合技术允许你在运行时改变被组合的行为,但是它存在间接性,比较低效。继承允许你提供操作的默认实现,并通过子类重定义这些操作。参数化类型允许你改变类所用到的类型。但是继承和参数化类型都不能在运行时改变。哪一种方法最佳,取决于你设计和实现的约束条件。
一个面向对象程序运行时的结构通常与它的代码结构相差较大。代码结构在编译时就被确定下来了,它由继承关系固定的类组成。而程序的运行时结构是由快速变化的通信对象网络组成的。
考虑对象**聚合(aggregation)和相识(acquaintance)的差别以及它们在编译时和运行时的表示是多么不同。聚合意味着一个对象拥有另一个对象或对另一个对象负责。一般我们称一个对象包含另一个对象或者是另一个对象的一部分。聚合意味着聚合对象和其所有者具有相同的生命周期。相识意味着一个对象仅仅知道另一个对象。有时相识也被称为“关联”或“引用”**关系。相识的对象可能请求彼此的操作,但是它们不为对方负责。相识是一种比聚合要弱的关系,它只标识了对象间较松散的耦合关系。
在下图中,普通的箭头线表示相识,尾部带有菱形的箭头线表示聚合:
聚合和相识很容易混淆,因为它们通常以相同的方法实现。C++中,聚合可以通过定义表示真正实例的成员变量来实现,但更通常的是将这些成员变量定义为实例指针或引用;相识也是以指针或引用来实现的。
从根本上讲,是聚合还是相识是由你的意图而不是显式的语言机制决定的。尽管它们之间的区别在编译时的结构中很难看出来,但这些区别还是很大的。聚合关系使用较少且比相识关系更持久;而相识关系则出现频率较高,但有时只存在于一个操作期间,相识也更具动态性,使得它在源代码中更难被辨别出来。
获得最大限度复用的关键在于对新需求和已有需求发生变化时的预见性,要求你的系统设计能够相应地改进。
为了设计适应这种变化且具有健壮性的系统,你必须考虑系统在它的生命周期内会发生怎样的变化。一个不考虑系统变化的设计在将来就有可能需要重新设计。这些变化可能是类的重新定义和实现,修改客户和重新测试。重新设计会影响软件系统的许多方面,并且未曾料到的变化总是代价巨大的。
设计模式可以确保系统以特定方式变化,从而帮助你避免重新设计系统。每一个设计模式允许系统结构的某个方面的变化独立于其他方面,这样产生的系统对于某种特殊变化将更健壮。
下面阐述了一些导致重新设计的一般原因,以及解决这些问题的设计模式:
让我们看一看设计模式在开发如下三类主要软件中所起到的作用:应用程序、工具箱和框架。
应用程序
如果你将要建造像文档编辑器或电子制表软件这样的应用程序(application program),那么它的内部复用性、可维护性和可扩充性是要优先考虑的。内部复用性确保你不会做多余的设计和实现。设计模式通过减少依赖性来提高内部复用性。松散耦合也增强了一类对象与其他多个对象协作的可能性。例如,通过孤立和封装每一个操作, 以消除对特定操作的依赖,可使在不同上下文中复用一个操作变得更简单。消除对算法和表示的依赖可达到同样的效果。
当设计模式被用来对系统分层和限制对平台的依赖性时,它们还会使一个应用更具可维护性。通过显示怎样扩展类层次结构和怎样使用对象复用,它们可增强系统的可扩充性。同时,耦合程度的降低也会增强可扩充性。如果一个类不过多地依赖其他类,扩充这个孤立的类还是很容易的。
工具箱
一个应用经常会使用来自一个或多个被称为**工具箱(toolkit)**的预定义类库中的类。工具箱是一组相关的、可复用的类的集合,这些类提供了通用的功能。工具箱的一个典型例子就是列表、关联表单、堆栈等类的集合,C++的I/O流库是另一个例子。工具箱并不强制应用采用某个特定的设计,它们只是为你的应用提供功能上的帮助。工具箱强调的是代码复用,它们是面向对象环境下的“子程序库”。
工具箱的设计比应用设计要难得多,因为它要求对许多应用是可用的和有效的。再者,工具箱的设计者并不知道什么应用使用该工具箱及它们有什么特殊需求。这样,避免假设和依赖就变得很重要,否则会限制工具箱的灵活性,进而影响它的适用性和效率。
框架
框架(framework)是一种高级的程序设计结构,它为特定应用领域提供了一组协作的类和预设的体系结构。这种结构不仅定义了应用的总体架构、类的组织方式、对象间的责任划分和相互协作的方式,还预设了控制流程,从而使开发者能够专注于应用的具体业务逻辑。框架的核心特点在于它的反向控制(inversion of control)机制,即框架控制程序的流程,而不是传统的程序代码控制框架。这种方法显著降低了代码之间的耦合度,并增强了代码的重用性。
框架的设计通常围绕特定领域的常见问题和模式,如图形界面开发、网络通信或数据分析等。它们提供了一套通用的解决方案和设计决策,从而减轻了开发者在处理这些通用问题时的负担。此外,许多框架包含了一些现成的实现,如具体的类或子类,这些可以直接在应用中使用或作为扩展的起点。
尽管框架为软件开发带来了便利,但它们的设计和维护是一个复杂且富有挑战性的过程。框架设计者必须具备深入的领域知识和前瞻性思维,以确保框架的灵活性、可扩展性和适应性。框架的改变或演化可能会对基于它的应用产生深远影响,因此设计时必须考虑到未来可能的需求和变化。
框架的成功不仅依赖于其技术实现,还依赖于良好的文档和社区支持。明确的文档和示例可以极大地帮助开发者理解和使用框架,而一个活跃的社区则有助于框架的持续改进和扩展。设计模式的应用在框架设计中也扮演着关键角色,它们提供了一套经过验证的解决方案,能够提高框架的设计质量和代码复用率。因此,一个成功的框架通常是那些充分利用了设计模式,同时具备强大功能、灵活性和良好文档的框架。
由于框架和模式有些类似,人们常常对它们的区别感到疑惑,它们最主要的不同在于如下三个方面:
给出如下几个方法,帮助你发现适合你手头问题的设计模式:
一旦选择了设计模式,该怎样使用它呢?下面给出一个有效应用设计模式的循序渐进的方法: