易栈 · 一盏

塞外秋来,衡阳雁去

设计模式:可复用面向对象软件基础 上

设计面向对象软件时,一般要先找到相关的对象。以适当的粒度将它们归类,再定义类的接口和继承层次,建立对象之间的基本关系。要一下得到复用性和灵活性好的设计非常困难,所以设计者更愿意使用以前使用过的,经过验证的设计。设计模式就是归纳整理出来的一些设计,这些设计在面向对象系统中很重要并且反复出现。

“每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动”

另外需要注意的是,设计模式在通过引入额外的间接层次获得灵活性和可变性的同时,也会使设计变得更复杂并牺牲一定性能。所以一个设计模式只有当它提供的灵活性是真正需要的时候,才有必要使用。

根据设计模式使用的目的,可以把设计模式分为三个类别:创建型模式、结构型模式和行为型模式。下面将分别讲述这三种类别的模式。

一、创建型模式

创建型模式抽象了实例化的过程。

1、Abstract Factory(抽象工厂)

描述:AbstractFactory用来创建一系列相关或者互相依赖的对象。AbstractFactory创建的对象称为产品,AbstractFactory实际是是一个产品类库

实例:不同的桌面主题,可能对话框、状态栏的风格都不一样。在使用某个主题时,程序需要获取该主题的对话框对象、状态栏对象,将它们组合起来,这时候AbstractFactory可以起对象创建器的作用。

实现:定义一个抽象类,这个类的每一个接口(虚函数)用来返回一类产品对象。如果想定义新系列的产品,只需要继承该抽象类,重定义接口就行了。也就是每个子类对应一个系列的产品。

总结:难以支持新种类的产品。因为AbstractFactory的每个接口对应一个产品,新增产品种类就需要扩展该工厂的接口,这样需要在所有子类中同步添加。

2、Builder(生成器)

描述:Builder可以使对象的构建和它的表示分离,使得同样的构建过程可以创建不同的表示。Builder模式中一般还有一个director(导向器),负责获取信息,然后调用相应的builder的接口。director调用builder的接口一步一步装配产品,仅当该产品完成时director才从builder取回它。

实例:想象一个用来建房子的机器,我们只需要调用它的操作(挖地基、搭支柱,砌墙、装门窗),并且提供相应的物料(沙子、钢筋、水泥、砖、门、窗户),这个机器就会一步一步构建房子。最终成品房子的样子,就是前面所说的“表示”。同样的构建过程,使用不同的builder,最终可能得到不同的风格的房子。对于机器来说,每个操作(例如装门窗)都只有一份实现,我们可以重复调用来实现不同的设计(例如一面墙上装多少个窗户,装在哪个位置)。机器向我们隐藏砖石水泥门窗的装配过程,让我们可以专注于设计房子(构建对象的算法)。

实现:定义一个builder的抽象类,抽象类的每个接口对应一个构建的操作。

3、Factory Method(工厂方法)

描述:在基类中定义一个用来创建对象接口(虚函数),让子类决定在这个接口中要实例化哪一个类(创建什么对象)。前面讲的AbstractFactory是专门定义一个类作为抽象工厂,用来创建同一个系列的产品对象;而FactoryMethod则是,我们已有一个基类,这个基类可能是一个框架,用来处理一些产品,但是在基类中无法决定要处理哪种产品,又或者基类想让子类来自由选择要处理哪些产品,于是基类把创建产品对象的接口定义为虚函数,方便子类重新定义,这些接口就是工厂方法(factory method)。

实例:有一个阅读器框架,通过派生这个框架,可以实现不同类型文档的阅读器。在框架中定义CreateDocument这些操作的时候,无法指定Document的具体类型,可以把它定义成虚函数,让具体的阅读器来实现。CreateDocument就是一个工厂方法。

实现:基类把创建产品对象的操作定义成虚函数,子类可以自定义这个函数,返回不同的产品对象。

4、Prototype(原型)

描述:用原型实例来指定可创建的对象种类,并且通过拷贝这些原型来创建新的对象。之前我们一般用来表示产品的“种类”,创建新产品的手段往往是派生出新的类,而Prototype是用不同的对象来表示“种类”。同一个类的实例,进行不同的配置/组合之后可以实现不同的功能。把这些不同配置/组合的对象用作原型,可以方便地定义新“类”。使用原型可以在运行时刻动态地添加、删除、替换产品(只要改变原型对象即可)。

实例:用原型来实现乐谱编辑器中的音符、休止符、五线谱对象的创建。

实现:在C++中,实现Prototype模式最困难的是实现对象拷贝,实现拷贝构造函数时要考虑深拷贝和浅拷贝的问题。另外Prototype模式一般需要一个原型管理器,用户可以向管理器注册/删除新的原型或者请求创建原型。书中使用一个Factory类来做原型管理器,初始化Factory对象时需要传入所有产品的原型对象。

5、Singleton(单件)

描述:保证一个类仅有一个实例,并且提供一个全局的对这个实例的访问方法

实例:例如公司里只有一个打印机,同一时间可能有很多个应用都想访问,但是打印机在系统中只应该有一个实例。

实现:Singleton有两个要点:1、保证只有一个实例,2、提供访问方法。使用全局对象可以使一个对象被访问,但是不能防止实例化多个对象。一个好方法是:让类自身负责保存它的唯一实例。可以在类中定义一个静态的数据成员,用来保存这个唯一的实例,然后定义一个静态的成员函数,用于获取这个实例。在调用成员函数去获取实例的时候才去检查实例是否已存在,已存在则直接返回该实例(保证只有一个实例),否则再去创建。

二、结构型模型

结构型模式涉及到如何组合类和对象以获得更大的结构。

1、Adapter(适配器)

描述:将一个类的接口转换成客户希望的另一个接口。Adapter模式使的原来由于接口不兼容而不能一起工作的那些类可以一起工作。有时候我们想使用一个已存在的类,但是它的接口不符合要求,这时候就可以用Adapter模式。

实例:一个绘图编辑器,允许用户通过绘制和排列基本图元(线、多边形和正文等)生成图片。图元的接口由抽象类Shape来定义,通过派生Shape类来新增图元,例如LineShape、PolygonShape。TextShape的实现比较复杂,于是我们想到复用界面工具箱里面已有的类TextView,但是TextView的接口和Shape不匹配。这时候我们可以通过TextShape来封装TextView,把TextView的接口适配成可以被绘图编辑器使用。TextShape就是一个适配器(adapter)。

实现:有两种结构:类适配器和对象适配器。类适配器使用多重继承来实现,例如上述例子,TextShape可以同时继承Shape和TextView:在C++中实现的时候,TextShape应该用public方法来继承Shape的接口,用private方式继承TextView的实现。对象适配器使用对象组合来实现,例如上述例子,TextShape可以继承Shape的接口,然后定义一个数据成员指针,指向一个TextView对象,然后把请求都委托给这个TextView对象处理。

2、Bridge(桥接)

描述:抽象部分与它的实现部分分开,使它们可以独立地变化。抽象部分也就是接口。我们之前讨论的问题,大多是一个抽象多个实现,这时候一般用继承来实现。也就是先定义一个抽象类(接口),然后派生出多个子类,在子类中做不同实现。但是假如我们需要改变抽象部分呢(例如扩充接口)?这时候如果还用继承来实现,那么对于n种抽象,n种实现,就要实现n*n个子类。Bridge模式就是用来解决这个问题。

实现:分别给抽象实现定义一个抽象类,例如Abstraction和Implementor,然后在Abstraction定义一个成员指针指向Implementor。Implementor实现底层的操作,Abstraction调用Implementor的接口来完成更高层次的操作。

总结:Bridge的优点都来自“独立变化”,抽象和实现可以各自进行扩充,一方的改变,不会对另一方产生影响。多个抽象可以共享一个实现,反之依然,从而用n+n份代码实现n*n种效果。

3、Composite(组成)

描述:将对象组合成树形结构以表示“部分-整体”的层次结构。树形结构中叶节点就是单个对象,子节点就是组合对象,组合对象又可以以其他组合对象为子节点。Composite使得单个对象和组合对象有相同的接口,这样用户不用关心处理的是单个对象还是组合对象,可以用一致的方法进行处理。

实例:绘图编辑器中有简单图元(线、矩形、文本)和图元组合(图片,由其他图元组合而成),两者都有相同的接口,例如draw。绘图编辑器不对两者进行区分,在显示的时候只管调对象的draw接口。图元组合的draw操作一般是通过对它的子部件调用draw,这个过程同样也不会去区分子部件的类型。

实现:定义一个抽象类(Component)作为单个对象(Leaf)和对象组合(Compsite)的基类,然后对象组合还要存储子部件(可能是单个对象也可能是组合对象),并实现与子部件相关的操作(例如添加子部件,删除子部件)。

总结:书中对子部件的相关操作应该定义在Component中还是Compsite中有一个讨论,详见P110。

4、Decorator(装饰)

描述:动态地给一个对象添加一些额外的职责。有时候我们希望给某个对象而不是整个类添加一些功能。继承是添加功能的有效途径,但是这样一来就是对整个类添加功能,而且这种添加是静态的,不能动态地决定添加的时机和方式。一种方法是将想要添加功能的对象(Component)嵌入另外一个对象(Decorator)中,由Decorator来添加功能。Decorator可以看作是Component的外壳,两者要接口一致。

实例:一个图形用户界面工具箱,允许给其中的用户界面组件添加边框、滚动条等。

实现:Decorator和Component要有一致的接口,所以先定义一个抽象类,作为两者共同的基类。Decorator需要维持一个指向Component对象的指针,并且给每个接口提供一个缺省的实现,这个缺省的实现总是调用Component对象的相应接口。

5、Facade(外观)

描述:子系统中的一组接口提供一个单一而简单界面。将系统划分成若干个子系统有助于降低系统的复杂性,子系统之间通过对方提供的接口通信。有的子系统可能提供很多偏底层功能强大的接口,但是这个对一般的用户来说过于复杂,大多数用户需要的只是一个傻瓜式的操作,Facade模式就是用来提供这个傻瓜式的操作。Facade提供的接口并不隐藏原来的底层接口,高阶用户仍可以根据需要调用这些接口。注意和Adapter的区别,Facade定义一个新的接口,而Adapter则复用一个原有的接口

实例:编译环境中的编译子系统,有多个组件:代码扫描、代码分析和二进制代码生成等。有些特殊的应用程序需要直接访问这些组件,但是大多是编译器用户并不关心代码分析和生成的细节,而只是希望编译一些代码。这时候可以提供一个Compiler的外观(facade),它给用户提供单一而简单的编译子系统接口。

实现:定义一个Facade类,通过调用子系统中的接口,来实现Facade类提供的接口。例如上述的编译子系统,编译接口中其实调用了一系列组件的操作:扫描代码->分析代码->生成二进制数据。

总结:子系统与类的相似之处在于,两者都封装了一些东西:类封装了状态和操作,而子系统封装了一些类

6、Flyweight(享元)

描述:运用共享技术有效地支持大量细粒度的对象。有时候我们需要用到粒度很细的对象,但是当这些小对象数量很大时,会耗费大量内存。一个解决方法是给对象分类,每一类只创建一个共享对象(flyweight)。flyweight可以用于多个场景,而且在每个场景中都作为一个独立的对象。这里的关键的概念是内部状态外部状态之间的区别。内部状态存储与flyweight中,它包含了独立于场景的信息,这些信息使flyweight可以被共享。而外部状态取决于场景,并根据场景变化,因此不可共享。用户负责在调用的时候将外部状态传给flyweight。

实例:文档编辑器中要显示很多字符,如果为每个字符都创建一个对象,当文档很大的时候非常耗费内存。可以为字母表中的每一个字母创建一个flyweight对象,字符代码就是flyweight的内部状态,而位置、字体等就是外部状态。调用字符flyweight的draw接口的时候,编辑器要把外部状态传给它。

实现:共享对象和非共享对象通常有相同的接口(抽象类)。通常有一个FlyweightFactory类用于创建并管理flyweight对象,当用户请求一个flyweight对象时,如果对象已创建则直接返回,否则创建一个。

总结:外部状态可以即时计算也可以预先存储,书中讲到用Btree存储文档的字体信息,详见P134。

7、Proxy(代理)

描述:为其他对象提供一种代理以控制对这个对象的访问。使用Proxy模式常见的情况如下:

  • 远程代理:为远程的对象提供本地代表,例如协议转换、对接。
  • 虚代理:缓存实体的附加信息,以便延迟对它的访问。这个比较抽象,可以看下面介绍的实例来理解。
  • 保护代理:访问权限控制。
  • 智能指引:在访问对象时执行附加操作,例如做引用计数,实现智能指针。

实例:一个文档编辑器,有的图形对象的创建开销很大,为了打开文档的速度,可以用一个图像proxy来替代真正的图像,图像Proxy提供图像的附加信息(如图像尺寸),以满足排版的要求,但是只有当文档编辑器激活图像Proxy的Draw操作以显示这个图像的时候,图像Proxy才创建真正的图像。

实现:实体对象和Proxy对象通常有相同的接口(抽象类),客户可以像使用实体对象一样使用Proxy对象。Proxy对象有一个指针指向实体对象,Proxy对象控制对实体对象的操作调用。C++中常常会重载Proxy对象的运算符->,使得Proxy对象使用起来象一个指针。

本文链接:易栈 - 设计模式:可复用面向对象软件基础 上