设计模式(Design Patterns)

Table of Contents

设计模式(Design Pattern)是一本神作,而且需要注意子标题就是这个设计模式是针对面向对象编程而言的。

笔记只是将原著想表达的内容转换称为我的理解然后写下来,阅读完了设计模式之后写代码结构上确实会好看很多。不过这本书似乎不是一蹴而就就可以的,随着代码经验不断丰富然后回头看可能会更好,是一个迭代的过程。

不过现在觉得设计模式其实并不那么重要了,重要的是能够快速完成代码让其他人理解和使用就好。并且网上一句话对于影响也很多:设计模式是语言表达能力弱的表现,使用函数式编程的话这些模式都可以省去。

没有必要在使用中刻意地去拼凑使用某种模式,相反应该让模式渗入到自己的思想中去,指导自己写出更加优美更加可维护的代码,而不用在意"我使用了哪种模式"。

1. 原则

  • 针对接口而不是实现来进行编程。
  • 不要拘泥于具体的实现方式,我们更应该注重每种模式的意图是什么。

2. 分类

根据模式目的可以分为三类:

  • 创建型(Creational).创建型模式与对象的创建相关。
  • 结构型(Structural).结构型模式处理类或者是对象的组合。
  • 行为型(Behavioral).行为型模式对类或者是对象怎样交互和怎样分配职责进行描述。

3. 继承/组合/参数化类型

在面向对象中最常用的两种代码复用技术就是继承和组合,而参数化类型也是一种有效的代码复用技术。 设计模式着重于代码的复用,所以在选择复用技术上,有必要看看三种复用技术优劣。

3.1. 继承

  • 通过继承方式,子类能够非常方便地改写父类方法,同时保留部分父类方法,可以说是能够最快速地达到代码复用。
  • 继承是在静态编译时候就定义了,所以无法再运行时刻改写父类方法。
  • 因为子类没有改写父类方法的话,就相当于依赖了父类这个方法的实现细节,被认为破坏封装性。
  • 并且如果父类接口定义需要更改时,子类也需要提更改响应接口。

3.2. 组合

  • 对象组合通过获得其他对象引用而在运行时刻动态定义的。
  • 组合要求对象遵守彼此约定,进而要求更仔细地定义接口,而这些接口并不妨碍你将一个对象和另外一个对象一起使用。
  • 对象只能够通过接口来访问,所以我们并没有破坏封装性。
  • 而且只要抽象类型一致,对象是可以被替换的。
  • 使用组合方式,我们可以将类层次限制在比较小的范围内,不容易产生类的爆炸。
  • 相对于继承来说,组合可能需要编写更多的代码。

3.3. 参数化类型

  • 参数化类型方式是基于接口的编程,在一定程度上消除了类型给程序设计语言带来的限制。
  • 相对于组合方式来说,缺少的是动态修改能力。
  • 因为参数化类型本身就不是面向对象语言的一个特征,所以在面向对象的设计模式里面,没有一种模式是于参数化类型相关的。
  • 实践上我们方面是可以使用参数化类型来编写某种模式的。

3.4. 总结

  • 对象组合技术允许你在运行时刻改变被组合的行为,但是它存在间接性,相对来说比较低效。
  • 继承允许你提供操作的缺省实现,通过子类来重定义这些操作,但是不能够在运行时改变。
  • 参数化允许你改变所使用的类型,同样不能够在运行时改变。

4. 创建型

一个类创建型模式使用继承改变被实例化的类,而一个对象创建型模式将实例化委托给另外一个对象。 在这些模式中有两种不断出现的主旋律:

  • 将该系统使用哪些具体的类封装起来
  • 隐藏了实例是如何被创建和存储的

总而言之,效果就是用户创建对象结果是一个抽象类而非具体类。

4.1. Abstract Factory

意图:提供一个创建一系列相互依赖的对象的接口。

假设存在A,B两个抽象类,然后有SubA1,SubB1以及SubA2,SubB2,并且SubA1和SubB1相互依赖,SubA2和SubB2也相互依赖。 我们希望存在一个接口,假设指定外部version=1的话,那么调用CreateA创建SubA1返回A*,调用CreateB创建SubB1返回B*. 如果version=2的话,那么每次CreateA/CreateB那么就分别返回SubA2和SubB2.

我们定义一个抽象类提供CreateA和CreateB接口,那么这个类就是Abstract Factory。在子类来实现CreateA和CreateB接口, 子类Factory1每次都创建SubA1和SubB1,子类Factory2每次都创建SubA2和SubB2。而在初始化的时候,传入version来获得 这个具体的Factory,但是得到的还是AbstractFactory*.

4.2. Builder

意图:将一个复杂对象构建过程和元素表示分离。

假设我们需要创建一个复杂对象,而这个复杂对象是由很多元素构成的。这些元素的组合逻辑可能非常复杂, 但是逻辑组合和创建这些元素是无关的,独立于这些元素本身的。

那么我们可以将元素的组合逻辑以及元素构建分离,元素构建我们单独放在Builder这样一个类里面,而元素的 组合逻辑通过Director来指导,Director内部包含Builder对象。创建对象是通过Director来负责组合逻辑部分的, Director内部调用Builder来创建元素并且组装起来。最终通过Builder的GetResult来获得最终复杂对象。

4.3. Factory Method

意图:抽象类需要创建一个对象时,让子类决定实例化哪一个类。

这是一个非常实际的问题。假设我们编写了一个抽象类X,现在想创建一个调用CreateY创建类Y.而如果Y是一个抽象类的话, 那么我们这个时候是不能够创建出来的。那么我们可以在子类X中在来实现这个方法,我们可以创建一个Y的子类。 这就是Factory Method。

很明显着这里有一个问题,如果Y有相当数量子类的话,那么X就必须被迫实现相同数量的子类。 也就是说,不管如何我们必须实现一个平行的类层次结构,每个X的子类对应一个创建Y的子类。

4.4. Prototype

意图:通过克隆原型实例来创建新的对象。

意图只是说明了实现方式,但是却没有说明背景。这种模式可以解决的问题,就是Factory Method所遇到的问题。 现在假设我们存在一个抽象类X,需要创建管理父类为Y的对象比如SubY,SubY2等。如之前Factory Method提到的, 我们可以通过添加X的子类来完成。但是这容易产生类爆炸。

如果使用Prototype方式的话,对于抽象类X的子类中可以首先存放Y*,内容可以是SubY1,SubY2的实例.一旦需要 创建对象的话,那么直接使用Y->Clone()就可以直接创建一个SubY1或者是SubY2了。并且因为X中存放的是Y*, 所以这样可以很方便地进行动态修改。很明显,这里的Clone必须是虚函数。

4.5. Singleton

意图:保证一个类仅仅有一个实例并且提供一个访问它的全局访问点。

这个模式主要的对比对象就是全局变量。相对于全局变量,单件有下面这些好处:

  • 全局变量不能够保证只有一个实例。
  • 某些情况下面,我们需要稍微计算才能够初始化这个单件。全局变量也行但是不自然。
  • C++下面没有保证全局变量的初始化顺序.

5. 结构型

结构型类模式采用继承机制来组合接口或者是实现。结构型对象模式不是对接口和实现进行组合, 而是描述如何对一些对象进行组合,从而实现新功能的一些方法。

5.1. Adapter

意图:将一个类的接口转化成为客户希望的另外一个接口。

假设A实现了Foo()接口,但是B希望A同样实现一个Bar()接口,事实上Foo()基本实现了Bar()接口功能。 Adapter模式就是产生一个新类C来使用A的Foo()接口实现Bar()。

在实现层面上可以通过继承和组合两种方式达到目的,但是代价可能稍有不同,视情况而定。

5.2. Bridge

意图:将抽象部分和具体实现相分离,使得它们之间可以独立变化。

只是看意图的前半句话,会觉得这个东西直接使用子类就可以搞定,所以重点在后半句上。最主要的原因在于, 抽象部分和实现部分可能演化速度就不一样,或者是类层次结构不同

一个很简单的例子就是类Shape,有个方法Draw[抽象]和DrawLine[具体]和DrawText[具体],而Square和SquareText 继承于Shape实现Draw()这个方法,Square调用DrawLine(),而SquareText调用DrawLine()+DrawText()。而且 假设DrawLine和DrawText分别有LinuxDrawLine,LinuxDrawText和Win32DrawLine和Win32DrawText。如果我们简单地 使用子类来实现的话,比如构造LinuxSquare,LinuxSquareText,Win32Square和Win32SquareText,那么同样 类很快爆炸。

事实上我们没有必要再Shape这个类层面跟进变化,而只需要在实现底层跟进变化。为此我们就定义实现一套接口, 比如就几个原语DrawLine,DrawText这些,然后Linux和Win32产生一个这样接口实例比如称为跨平台GDI。最终 Shape内部持有这个GDI对象,即可以在Linux和Win32下面很容易地写出跨平台的Sahpe类。

总之,抽象部分是和具体实现部分需要独立开来的时候,就可以使用Bridge模式。

5.3. Composite

意图:将对象组合成为树形以表示层级结构,对于叶子和非叶子节点对象使用需要有一致性。

Composite模式强调这种层级结构下面,叶子和非叶子节点需要一直对待,所以关键是需要定义一个抽象类。 然后对于叶子节点操作没有特殊之处,而对于非叶子节点操作不仅仅需要操作自身,还要操作所管理的子节点。 至于遍历子节点和处理顺序是由应用决定的,在Composite模式里面并不做具体规定。

5.4. Decorator

意图:动态地给对象添加一些额外职责,通过组合而非继承方式完成。

给对象添加一些额外职责就好像增加新的方法,很容易会考虑使用子类方式来实现。使用子类方式实现很快但是却不通用, 考虑一个抽象类X,子类有SubX1,SubX2等。现在需要为X提供一个附加方法echo,如果我们使用子类的话,那么需要为每个 子类都实现EchoSubX1和EchoSubX2。如果子类过多的话,那么需要为每个子类实现。如果使用对象持有方式持有X*的话, 那么只需要单独实现echo方法,而定义其他方法让X*来处理就OK了。

我们必须理解,装饰出来的对象必须包含被装饰对象的所有接口。所以很明显这里存在一个问题, 那就是X一定不能够有过多的方法,不然Echo类里面需要把X方法全部转发一次。当然可以不用转发所有的请求, 但是Decorator针对的就是这样全部转发的请求,所以X的方法一定不能够过多。

5.5. Facade

意图:为子系统的一组接口提供一个一致的界面。

编译器是一个非常好的的例子。对于编译器来说,有非常多的子系统包括词法语法解析,语义检查,中间代码生成, 代码优化,以及代码生成这些逻辑部件。但是对于大多数用户来说,不关心这些子系统,而只是关心编译这一个过程。

所以我们可以提供Compiler的类,里面只有很简单的方法比如Compile(),让用户直接使用Compile()这个接口。 一方面用户使用起来简单,另外一方面子系统和用户界面耦合性也降低了。

Facade模式对于大部分用户都是满足需求的。对于少部分不能够满足需求的用户,可以让他们绕过Facade模式提供的界面, 直接控制子系统即可。就好比GCC提供了很多特殊优化选项来让高级用户来指定,而不是仅仅指定-O2这样的选项。

5.6. Flyweight

意图:运用共享技术有效地支持大量细粒度对象。

这个模式与其放在结构型里面不如放在创建型里面。通过共享的技术,在创建对象的时候首先查看是否存在某个对象, 如果存在的话直接返回,如果不存在的话那么就创建并且保存起来。使用Flyweight一方面可以有效地减少对象的数量, 尤其是对象种类比较少的情况下,另外一方面可以有效地维护对象的一致性。

但是使用享元需要区分的是内部状态和外部状态,内部状态作为享元的一部分存在是统一的, 而外部状态不是存放在享元内部的,而是存放在外部或者是实时计算来获得的。

5.7. Proxy

意图:为其他对象提供一种代理以控制对这个对象的访问。

通常使用Proxy模式是想针对原本要访问的对象做一些手脚,已达到一定的目的,包括访问权限设置,访问速度优化, 或者是加入一些自己特有的逻辑。至于实现方式上,不管是继承还是组合都行,可能代价稍微有些不同,视情况而定。 但是偏向组合方式,因为对于Proxy而言,完全可以定义一套新的访问接口。

5.8. 对比

这里个人感觉Adapter,Decorator以及Proxy之间比较相近,虽然说意图上差别很大,但是对于实践中, 三者都是通过引用对象来增加一个新类来完成的,但是这个新类在生成接口方面有点差别:

  • Adapter模式的接口一定要和对接的接口相同。
  • Decorator模式的接口一定要包含原有接口,通常来说还要添加新接口。
  • Proxy模式完全可以重新定义一套新的接口。

6. 行为型

行为型涉及到算法和对象之间职责的分配。行为模式不仅描述对象或者是类的模式,还描述它们之间的通信模式。 这些模式刻画了在运行时难以追踪的复杂的控制流,它们将你的注意从控制流转移到对象之间的联系上来。

行为类模式使用继承机制在类之间分派行为,而行为对象模式描述了一组对等的对象之间怎样相互协作, 以完成其中任意一个对象都无法单独完成的任务。

6.1. Chain of Resonsibility

意图:将对象连成一条链并沿着链传递某个请求,直到有某个对象处理它为止。

大部分情况下连接起来的对象本身就存在一定的层次结构关系,少数情况下面这些连接起来的对象是内部构造的。 职责链通常与Composite模式一起使用,一个构件的父构件可以作为它的后继结点。许多类库使用职责链模式来处理事件, 比如在UI部分的话View本来就是相互嵌套的,一个View对象可能存在Parent View对象。如果某个UI不能够处理事件的话, 那么完全可以交给Parent View来完成事件处理以此类推。

6.2. Command

意图:将一个请求封装成为一个对象。

Command模式可以说是回调机制的一个面向对象的替代品。对于回调函数来说需要传递一个上下文参数, 同时内部附带一些逻辑。将上下文参数以及逻辑包装起来的话那么就是一个Command对象。 Command对象接口可以非常简单只有Execute/UnExecute,但是使用Command对象来管理请求之后, 就可以非常方便地实现命令的复用,排队,重做,撤销,事务等。

6.3. Interpreter

意图:为语言文法定义对应的类层次结构并配上响应的解释逻辑。

如果一种特定类型的问题发生频率足够高的话,那么就值得将该问题的各个实例表述为一个简单语言的句子。 每一种语言都会对应文法,解释器模式强调的就是将这些文法匹配到对应的类,然后对这个类进行解释来达到对语言解释的效果。 虽然结构上可以使用Composite模式,解释过程中遍历行为可以使用Visitor模式,但是这些都不是Interpreter模式所强调的。

6.4. Iterator

意图:提供一种方法顺序访问一个聚合对象中各个元素,但是又不需要暴露该对象内部表示。

将遍历机制与聚合对象表示分离,使得我们可以定义不同的迭代器来实现不同的迭代策略,而无需在聚合对象接口上面列举他们。 一个健壮的迭代器,应该保证在聚合对象上面插入和删除操作不会干扰遍历,同时不需要copy这个聚合对象。 一种实现方式就是在聚合对象上面注册某个迭代器,一旦聚合对象发生改变的话,需要调整迭代器内部的状态。

6.5. Mediator

意图:用一个协调对象来封装一系列的对象交互。

虽然将一个系统分割成为许多对象通常可以增强可复用性,但是对象之间连接的激增会降低可复用性。 通过将集体行为封装在一个单独的协调者对象上,协调者负责控制和协调一组对象之间的交互, 这样各个对象只是知道协调者而不用知道其他对象的存在。

其实这是一个矛盾的问题。如果对象粒度过小的话那么可维护性会出现在对象之间的通信上,就像我们这里需要一个中介者一样。 但是如果对象粒度过大的话,所有请求都是发送给协调者的话,那么可维护性就会出现在协调对象本身上。

6.6. Memento

意图:在不破坏封装性前提下,捕获一个对象的内部状态,并且在对象之外保存这个状态。

对于被保存的对象叫做原发器(originator),备忘录(memento)对象保存原发器内部的状态。 在实现方式上面备忘录对象可以保存状态的增量修改,减少备忘录占用空间大小。

6.7. Observer

意图:定义对象之间的依赖关系,当一个对象状态发生改变的话,所有依赖这个对象的对象都会被通知并且进行更新。

被观察的对象需要能够动态地增删观察者对象,这就要求Observer提供一个公共接口比如Update()。然后每个Observer 实例注册到被观察对象里面去,在被观察对象状态更新时候能够遍历所有注册观察者并且调用Update()。

至于观察者和被观察之间是采用推还是拉模式的话完全取决于应用。对于观察这件事情来说的话, 我们还可以引入方面(Aspect)这样一个概念,在注册Observer的时候不仅仅只是一个Observer对象, 还包括一个Aspect参数,比如告诉被观察者我仅仅希望订阅你的增加而不是更新信息。

6.8. State

意图:允许一个对象在其内部状态改变时改变它的行为。

这里State模式意图是,将内部状态改变时对象可能改变的行为封装成为一个对象S(有多少种可能的状态就有多少个 这样的对象,比如S1,S2,S3等).当状态进行转换,通过切换S的实例,来达到改变对象的行为。

6.9. Strategy

意图:定义一系列算法封装起来并且确保有相同接口,使得算法可替换。

6.10. Template Method

意图:定义一个操作里面算法的骨架,而将一些步骤延迟到子类。

假设父类A里面有抽象方法Step1(),Step2(),默认方法Step3(),然后有一个操作X()分别使用Step1(),Step2(),Step3(). 对于子类的话,必须实现Step1(),Step2(),可以选择性地实现Step3(),最后调用X()就有自己的一个单独过程了。 这里操作X()就是算法的骨架,子类需要复写其中部分方法。

很重要的一点是模板方法必须指明哪些操作是钩子操作(可以被重定义的,比如Step3),以及哪些操作是抽象操作 (必须被重定义,比如Step1和Step2).要有效地重用一个抽象类,子类编写者必须明确了解哪些操作是设计为有待重定义的。

6.11. Visitor

意图:表示一个作用于某个对象结构中的各个元素的操作。

考虑一个编译器产生的抽象语法树,我们要在语法树上面做很多操作比如类型检查,代码生成,代码优化等工作。 如果我们直接在语法树的节点上加入这些方法的话,那么语法节点的方法会越来越多难以管理。 Visitor模式就是要求把访问逻辑和元素表示分开。语法节点上面提供一个Accept(Visitor* visitor)接口,实现就是visitor->Visit(this)。 然后我们可以定义类型检查Visitor,代码生成Visitor,代码优化Visitor来实现Visit接口。Visitor模式比较 适合在内部结构已经固定,但是外部需要增加很多操作这种内部结构接口的情况。