第三章 模式
前一章所讨论的品质是用来区分设计良好和糟糕的API。在接下来的几个章节将重点关注构建高品质的API的技术和原则。这个特殊的章节将涵盖一些有用的设计模式和C++ API设计的相关习惯用法。
设计模式是一种为共同的软件设计问题而采用的通用解决方案。这个术语在设计模式的书籍中经常出现:可复用的面向对象的要素,也称作the Gang of Four book(Gamma等人,1994)。那本书介绍了下列通用设计模式,分成三大类:
[P65 排版]
创建型模式(Creational Patterns)
抽象工厂(Abstract Factory) 封装一组相关的工厂。
生成器(Builder)从对象的表示层分离一个复杂的对象构造。
工厂方法(Factory Method) 让类的实例化推迟到子类中。
原型(Prototype) 指定类的一个原型实例来克隆生成一个新的对象。
单件(Singleton) 确保一个类只有一个实例。
结构型模式(Structural Patterns)
适配器(Adapter) 把一个类的接口转换成另一个接口。
桥接(Bridge) 从它的表现层解耦一个抽象,这样就可以对两者进行独立地更改。
组合(Composite) 把对象组合成树形结构,表示部分和整体的层次。
装饰(Decorator) 采用动态的方式添加额外的行为到当前对象。
外观(Facade)对子系统中的一组接口提供一个统一的高级别接口。
代理(Proxy) 为另一个对象提供一个代理或者占位符来控制对它的访问。
行为模式(Behavioral Patterns)
责任链(Chain of Responsibility) 让一个接收者对象有更多的机会来处理来自发送者对象的请求。
命令(Command) 把请求或操作封装成一个对象,用来支持做不了的操作。
解释器(Interpreter) 用来指定如何表示和评估语言中的句子。
迭代器(Iterator) 提供了一种按照序列来访问聚合对象的元素的方法。
中介(Mediator) 定义了一个对象,封装了一组对象的相互作用。
回忆(Memento) 捕获对象的内部状态,以便在稍后可以还原。
观察者(Observer) 在对象间状态改变时,允许发布一对多的通知。
状态(State) 当对象内部状态改变时,允许对象改变它的类型。
策略(Strategy) 定义了一系列算法,封装每一个并使其在运行时是可互换的。
模板方法(Template Method) 在一个操作上定义了算法的骨架,把一些步骤推迟到子类。
访问者(Visitor) 表示一个操作要在一个对象结构的元素上执行。
自从1994年,设计模式的书首次出版以来,已经添加了几种设计模式,包括全新的并发分类设计模式。最近,原书作者也建议改进的分类,核心、创建型、外围和其它(Gamma等,2009)。
然而,本书并不打算覆盖所有这些设计模式,市面上有很多书是专注于这一主题的。我将集中讨论那些对设计高品质API很重要的内容,还要讨论在C++中的实现。我也会涵盖C++的一些习惯用法,或许这些并不被视为真正的通用设计模式,不过也是C++
API设计中的重要技术。特别地,我会详述下面的技术细节:
qPimpl idiom:此技术可以让你从公共头文件中完全隐藏内部细节。从本质上来说,它允许你把私有的成员数据和函数移入到.cpp文件中。它是一个创建隔离良好的API的不可或缺的工具。
q单件和工厂方法:这是两个非常普通的创建型设计模式,很容易深入理解。当你需要强制一个对象只能有一个实例时,单件的作用就体现出来了。它在C++实现方面有点棘手,我将放在后面讲述,包括初始化和多线程的问题。工厂方法模式提供了一种通用的方式来创建一个对象的实例,这个非常好方法能够为派生类隐藏实现细节。
q代理、适配器和外观:这些结构型模式用来描述API的各种解决方案,该API是基于现有不兼容的或旧系统的接口上的包装。这常常是编写API的目的,改进那些原先设计代码很糟糕的接口。代理和适配器模式提供了一个新类到已经存在类的一对一映射,而外观模式为一个较大的类集合提供一个简化的接口。
q观察者:这种行为模式可以用来减少类之间的直接依赖。它允许概念无关的类进行通信,通过一个类(观察者)去注册一个来自另一个类(主体)的通知。因此,该模式是松耦合API设计的一个重要方面。
除了这些模式和C++惯用法,我也会在书末的第12章讨论访问者行为模式。访问者模式给用户一种方法,可以让他们采用自己的算法操作API中的数据结构。从为用户设计可扩展的API的观点上来看,它是非常有用的,这也是我为什么把这个话题推迟到扩展性章节。
3.1 Pimpl Idiom
术语pimpl(平普尔)首次是由Jeff Sumner提出的,作为“指向实现的指针”(pointer to implementation)的缩写(Sutter, 1999)。使用该技术可以避免在公共头文件中暴露私有细节(见图3.1)。因此,这是一种帮助你实现分离API的接口和实现的重要机制(Sutter and Alexandrescu, 2004)。不过pimpl并不是一种严格的设计模式(受限于C++特定的问题),这种用法可以认为是桥接设计模式的特例。
在你阅读本书后,如果你要改变一种编程习惯,我希望是你会选择pimpl来编写API代码。
提示
使用pimpl用法来保持实现细节和公共头文件的分离。
3.1.1 使用Pimpl
Pimpl会依赖于C++类中定义的一个数据成员是一个前置声明类型的指针。也就是说,引入的类型仅仅是一个名字,还未定义完全,这样就允许我们可以把类型定义隐藏到.cpp文件中。这也常常被称为透明指针,因为用户无法看到指针所指向的对象的细节。从本质上说,pimpl这种方式可以同时从逻辑上和物理上隐藏私有成员变量和函数。
[P67 图 3.1]
图3.1
Pimpl用法,一个public类有一个指向隐藏实现类的私有指针。
让我们看一个演示例子。考虑一下的这个“自动计算器”的API,该对象在销毁时输出它的生存期。
[代码 P68 第一段]
该API违背了很多上个章节提到的重要品质要求。例如,它包含特定平台的定义,计时器是如何存储在不同平台的实现细节对任何人是可见的(通过查看头文件)。公平地说,该API只暴露必要的方法为公共的(例如,构造和析构),而其余的方法和数据成员都是私有的。然而,C++需要你把这些私有成员声明在公共头文件中,这就是为什么你不得不包含特定平台的#if指令。
你所要做的就是把所有的私有成员隐藏到.cpp文件中去。然后,你就不需要包含任何那些带来麻烦的平台细节。Pimpl用法让你把所有的私有成员放入到类(或结构)中,是在.ccp文件中采用头文件中的前置声明。例如,你可以使用pimpl重写前面的头文件:
[代码 P68 第二段]
现在的API简洁了很多。已经没有了平台特定的预处理器指令,当读者查看头文件时就没法看到任何类的私有成员了。
有个较隐蔽的内容是我们定义的AutoTimer构造函数,现在必须分配一个AutoTimer::Impl类型对象,接着要在析构函数中销毁。而且,所有的私有成员都要通过mImpl指针来访问。然而,对绝大多数实际案例来说,给出一个简洁和可*实现的(implementation-free)API的开销是非常值得的。
接下来是完整的例子,看看底层的实现是怎样配合pimled类的。因为平台特定的#ifdef多行代码,导致.cpp文件看起来有点散乱,但是重要的是这些散乱已经完全是包含在.cpp文件之中了。
[代码 P69 第二段]
你看到的AutoTimer::Impl类的定义,包含了所有原先暴露在头文件中的私有方法和变量。还得注意到AutoTimer的构造函数分配了一个新的AutoTimer::Impl对象和初始化它的成员,而析构函数负责销毁这个对象。
在前面的设计中,我声明的Impl类为AutoTimer类的私有嵌套类。声明成嵌套类是为了避免这个平台特定的符号采用全局名空间,把它声明为私有的是为了不让它暴露在类的公共API中。然而,声明成私有的也会带来一个限制,就是AutoTimer成员只能访问Impl的成员。其它函数或.cpp文件中的*函数则无法访问Impl。可供选择的是,如果这造成太多的限制,你可以把Impl类声明成公共的嵌套类,请看下面的例子:
[代码 P70 第二段]
提示
当使用pimpl方法时,可以使用私有的嵌套实现类。只在其它类或.cpp文件中的*函数必须访问Impl成员时,才使用公共的嵌套Impl类(或者是公共的非嵌套类)。
还有另一个设计上的问题值得考虑,下面给出一些选项,Impl类中的相关逻辑性:
(1).只有私有成员变量
(2).私有成员变量和方法
(3).公共类的所有方法,使得公共方法都简单地包装在Impl类中同等的方法之上。
每个选项都可能在不同的情况下是合适的。然而,在通常情况下,我推荐选项2:把所有的私有成员变量和私有方法放入到Impl类中。这让你能够维持数据和方法的封装,并避免在公共头文件中声明私有方法。注意,先前的例子中,我采用了这种方法,通过把GetElapsed()方法置入Impl类中。Herb Sutter给出了关于这个方法的两个说明(Sutter, 1999):
(1).你不能在实现类中隐藏私有的虚拟方法。这些必须出现在公共的类部分,以便任何派生类可以重写它们。
(2).虽然你也可以把公共类传入到需要它的实现类的方法中,但还是可以在实现类中添加一个指向公共类的指针,以便Impl类能够调用公共方法。
3.1.2 拷贝语义学
如果没有显式定义,C++编译器会给类创建一个构造函数的副本和赋值运算符(assignment operator)。然而,这些默认的构造函数只能为对象执行浅复制(shallow copy)。这对pimpled类不是什么好事,因为这意味着如果用户复制你的对象的话,那么两个对象都会指向同一个Impl实现对象。不过,这两个对象都会在析构时删除同一个Impl对象,这将很可能导致崩溃。下面给出两个解决方法:
(1).让类不能被复制。如果你不打算让用户创建对象的副本,那么可以把对象声明成不可复制的(non-copyable)。你可以显式声明一个拷贝构造函数和赋值运算符。你不需要为这些提供实现;只要定义了就可以阻止编译器自动生成默认版本的构造函数。把这些声明成私有的也是一个不错的主意,以便复制对象时会生成一个编译错误,而不是链接错误。或者,如果你在使用Boost库,你只需要从boost::noncopyable继承就可以了。而且,在新的C++0x规范中,允许你完全禁用这些默认的函数(请参照第六章中的细节)。
(2).显式定义拷贝语义。如果你想要用户可以拷贝你的pimpled对象,那么你应该声明和定义自己的构造函数和赋值运算符。这些可以执行对象的深拷贝(deep copy),也就是说,创建一个Impl对象的拷贝来代替拷贝一个指针。我将在本书的C++用法章节中讲述如何编写构造函数和运算符。
下面的代码是AutoTimer API经过改进的版本,我通过声明私有拷贝构造函数和赋值运算符,来让对象不可复制。而关联的.cpp文件不需要改动。
[代码 P72 第一段]
3.1.3 Pimpl和智能指针
Pimpl不便的和易错的方面是需要对实现对象进行分配和解除分配。在访问Impl对象前,你已经分配过对象或者在销毁后,只要你忘记在析构函数中删除对象都会造成错误。作为约定,你应该确保必须在构造函数中首先分配Impl对象(最好通过它的初始化列表),最后,在析构函数中删除它。
还有一种办法是采用智能指针,这样更简单。你可以使用共享指针(shared pointer)或范围指针(scoped pointer)来保存实现对象指针。因为范围指针在定义上就是不可复制的,对象使用该类型的智能指针,不允许用户复制,也就避免声明私有拷贝构造函数和赋值运算符。这样的话,API可以简化成下面那样:
[代码 P72 第二段]
或者,你可以使用boost::shared_ptr,允许对象复制而不会导致在鉴定前重复删除的问题。使用共享指针意味着任何拷贝都指向内存的同一个Impl对象。如果你需要拷贝Impl对象,你仍然需要编写自己的拷贝构造函数和赋值运算符(或者使用写时复制(copy-on-write)指针,将在性能章节找讲述)。
提示
思考下pimpl类的拷贝语义和使用智能指针来管理实现指针的初始化和销毁。
当AutoTimer销毁时,使用共享或共享指针意味着Impl对象可以自动释放:你不再需要在析构函数中显式删除了。因此,autotimer.cpp文件的析构函数可以简化了:
[代码 P73 第二段]
3.1.4 Pimpl优点
在类中使用pimpl有很多优点。包括如下几项:
q信息隐藏:私有成员可以从公共接口完全隐藏。这允许你保持执行细节的隐藏(在闭源API中是专有的)。这也意味着公共头文件更加简洁和让公共接口表现得更加清楚和准确。因此,它们也易于被用户阅读和理解。还有一个信息隐藏的好处是用户无法采取投机取巧的方法来访问私有成员,下面的代码在C++是完全合法的(Lakos,
1996):
[代码 P73 第三段]
q降低耦合:在先前的AutoTimer例子讲过,不采用pimpl的话,公共头文件需要包含所有的私有成员变量。在我们的例子中,这意味着需要包含windows.h或sys/time.h。这增加了系统中的API其它部分的编译时耦合。使用pimpl,你能够把那些依赖移动到.cpp文件中去和移除那些耦合元素。
q更快的编译速度:把特定的实现(implementation-specific)移到.cpp文件的隐含之处是包含的API层级减少了。这个对编译时间有直接影响(Lakos, 1996)。我将在性能章节中讲述包括依赖的最小化的好处。
q更佳的二进制兼容性:pimpled对象的大小不会改变,因为该对象总是单个指针的大小。你对私有成员变量(回想一下,成员变量应该总是私有的)的任何更改只影响隐藏在.cpp文件中的实现类的大小。这就可以对实现进行重大修改而不会改变对象的二进制表示。
q缓式分配(Lazy Allocation):mImpl类可以根据需要来构造。如果类要分配一个有限制的或开销大的资源(如一个网络连接),这就比较有用了。
3.1.5 Pimpl的缺点
Pimpl用法的主要缺点就是:每个创建的对象,你必须分配和释放一个额外的实现对象。这会通过指针的大小来增加对象的大小,也可能因为指针间接需要访问所有的成员变量而带来性能影响,还包括额外的new和delete调用。如果你关心存储分配器的性能,那么你可以考虑使用“快速Pimpl”用法(Sutter,
1999),你可以使用更加高效的小内存、固定大小的分配器,为Impl类重载new和delete运算符。
开发人员还有不便的地方,就是在所有私有成员访问时加个诸如mImpl->的前缀。因为多了一层抽象,这会让实现代码变得难以阅读和调式。当Impl类有一个指针指回公共类时,这会变得更复杂。然而,这些不便不会暴露给API用户,因此从API设计的观点来看是不必关心的。为了让用户有个更加简洁和高效的API,这个负担应由API开发人员来承担。引用一个科学家的名言:多数人的需要大于少数人的需要。
最后一个要知道的问题是:编译器不再捕捉const方法中的成员变量的变化。这是因为成员变量生存在一个独立的对象中了。编译器只检查你没有修改const方法中的mImpl指针的值,但是不检查你是否改变mImpl中任何成员的指向。实际上,pimpled类的每个成员函数都可以定义成const(除了构造函数或析构函数)。下面的代码演示了const方法,合法地修改Impl对象中的变量:
[代码 P74 第一段]
3.1.6 C中的透明指针
到目前为止,我已经讲解过C++,你也可以在C语言中创建透明指针。它们在概念上是一样的:创建一个只定义在.c文件中的指向结构的指针。下面的头文件演示了C中的情形:
[代码 P75 第一段]
关联的.c文件如下所示:
[代码P75 第二段]
3.2 单态(SINGLETON)
单态模式设计(Gamma等,1994)用于确保一个类只有一个实例。该模式也提供对单一实例(single instance)的全局访问(图 3.2)。你可以把单态看成一个更优雅的全局变量。不过,它比全局变量多了几个优点,因为:
[图 3.2]
单态设计模式的UML图示。
(1).强制类只能创建一个实例。
(2).提供控制对象的分配和销毁的功能。
(3).允许支持线程安全的对象全局状态的访问。
(4).
单态模式是用来构造从本质上说是固有单数的资源。例如,一个访问系统时钟、全局剪贴板或键盘的类。它也用于创建管理类,提供一个访问多个资源的单一点,例如一个线程管理器或事件管理器。不过,单态也是向系统中添加全局变量的一种基本方法,是一种更加容易管理的方法。因此,它可以引入全局状态和依赖到API中,使得在以后难以重构,也让编写和代码其它隔离部分相作用的单元测试变得困难。
我打算在这里介绍部分的单态概念,因为它们提供的API设计技术是大家都在用的。不过,另外一个原因是:它们在健壮地实现C++中又是很复杂的,因此值得讨论一些这方面的实现细节。还有,很多程序员倾向于过度使用单态模式,我也会着重讲述单态模式的一些缺点,并给出可选的其它技术。
提示
单态是一种维持全局状态更优雅的方式,但是你要考虑到你是否需要全局状态。
3.2.1 C++中实现单态
单态模式包括创建一个带有静态方法的类,当每次调用类时,都返回一个同样的实例。这个静态方法通常叫做 GetInstance(),或者类似的名称。当设计单态类时,有几个C++的语言特点需要考虑到:
q不允许用户能够创建新的实例。这可以通过声明私有的构造函数来实现,这样就可以阻止编译器自动创建一个公共的构造函数。
q让单态成为不可复制的,强制第二个实例无法创建。如前面看到的,这可以通过声明私有拷贝构造函数和私有赋值运算符来实现。
q要阻止用户删除单态实例。这可以通过声明私有的析构函数来实现。(注意,有些编译器,如Borland 5.5和Visual Studio 6,当你试图声明一个私有的析构函数时,会错误地提示一个错误。)
qGetInstance()方法可以返回一个指针或单态类的引用。不过,如果返回指针的话,用户就能够删除掉对象。因此,最好还是返回引用。
下面看看C++中的单态的一般形式(Alexandrescu, 2001):
[代码 P77 第一段]
用户可以这样引用单态实例:
[代码 P78 第二段]
还要注意的是:把构造函数和析构函数声明成私有的,也意味着用户无法创建这个单态的子类。不过,如果你允许这么做,你可以简单地声明成受保护的(protected)。
提示
把构造函数、析构函数、拷贝构造函数和赋值运算符声明成私有的(或受保护的),来强制单态属性。
就实现而言,有个需要十分小心的方面是单态实例是如何分配的。这个重要的C++初始化问题已经由Scott Meyers解释过:
非局部静态对象在不同的转换单元的相对初始化顺序是不明确的。(Meyers, 2005)
这意味着使用一个非局部静态对象来初始化单态是会有危险的。非局部对象是声明在函数之外的对象。静态对象包括全局对象和类中、函数中或者文件范围内声明成静态的对象。因此,有种初始化单态的方式是在类中的方法里创建一个静态变量,如下所示:
[代码 P78 第三段]
这种方式有个很好的属性就是:实例只会在GetInstance()方法首次调用时分配。这表示,如果单态没有被请求,对象也不会被分配。不过,也有不好之处,这个方法不是线程安全的。而且,Andrei Alexandrescu提醒我们,这种技术依赖于标准的静态对象的后进先出(last-in-first-out)存储单元分配行为,这会导致单态过早解除分配,在析构函数中单态调用其它单态之前。给个这个问题的例子,考虑这两个单态:Clipboard和LogFile。当Clipboard初始化后,它也初始化LogFile,用来输出一些诊断信息。在程序退出时,LogFile首先销毁,因为它是最后创建的和Clipboard已经销毁了。不过,Clipboard析构函数试图调用LogFile记录它正被销毁的情形,但是LogFile已经被释放了。这很可能导致程序崩溃退出。
在他的现代C++设计书(Modern C++ Design)中,Alexandrescu给出了这个销毁顺序问题的几个解决方法,包括如果单态在销毁之后还需要它的话,“复活”这个单态,增加这个单态的生存期,使它比其它单态存在的更久和不会简单地销毁单态(即依赖操作系统释放所有分配过的内存和关闭任何文件的句柄)。如果你要实现某个这样的解决方法,我建议你可以参考这本书的相关细节(Alexandrescu,
2001)。
3.2.2使单态线程安全
前面GetInstance()的实现不是线程安全的,因为在初始化静态Singleton中存在竞赛条件(race condition)。如果两个线程遇到同时调用这个方法,那么实例可能会被构造两次或者在它被其它线程完全初始化之前,已被使用。如果你查看编译器为该方法生成的代码,这种竞赛条件会更明显。下面的例子是由编译器展开后的GetInstance()方法:
[代码 P79 第一段]
和大多数解决非线程安全代码的方案一样,你可以通过在竞赛条件周围添加互斥锁来使方法变得线程安全:
[代码 P79 第二段]
这个解决方案有个潜在的问题:因为每次调用方法时都会获取锁,这可能导致较大的开销。不过,应该注意的是这并不一定成为API的性能问题。在实际应用中,总是在决定优化之前再衡量性能。例如,如果这个方法不会被用户频繁地调用,那么这个解决方案是完全可以接受的。当有用户报告性能问题时,你可以建议他们每调用方法一次(或者每线程一次),就在他们代码中缓存结果。不过,如果你觉得这个方法的性能确实是个问题,那么处理起来就会有点复杂了。
有个更受推荐的解决方案是采用双重检查锁模式(Double Check Locking Pattern,DCLP),来优化过度锁的副作用:
[代码 P80 第二段]
然而,不能保证DCLP可以运行在所有的编译器和处理器内存模型上。例如,一个共享内存的对称多处理器通常会提交突发地内存写入,这会导致为不同线程的写入重新排序。为了解决这个问题,常常可以看到volatile关键字的使用,因为同步读写的操作会让数据变得不稳定。然而,在多线程环境中,即使采用这种方法也可能产生错误(Meyers and Alexandrescu, 2004)。你也可以使用平台特定的内存屏障(memory barriers)来解决这个问题。或者,如果你只使用POSIX线程,也可以使用pthread_once(),但是此时也值得回头看看,或许你不必优化前面提到的GetInstance()方法。不同的编译器和平台特质意味着这样的API只能对部分用户有用,对于其它用户来说,这就比较复杂和不好调试,他们就无法使用了。这些困难都是编程语言中要强制线程安全导致的,这些是不易察觉和缺乏对并发的支持的。
如果一个线程安全的GetInstance()的性能至关重要,那么你应该考虑避免使用前面提到的懒惰初始化模型(lazy instantiation model),用启动时的初始化单态来代替。例如,在main()被调用前或者通过互斥锁API初始化时调用。这些选项有个共同的好处就是不需要修改单态类的实现就能支持多线程:
(1).静态初始化(Static initialization):静态初始化程序会在main()之前调用,你可以认为程序仍然是单线程的。因此,你可以创建单态实例作为静态初始化程序的一部分和避免使用任何互斥锁。当然,你需要确定构造函数不依赖于其它.cpp文件中的非局部静态变量。然而,这里需要提醒的是:你可以添加下面的静态初始化调用singleton.cpp文件来确保实例会在main()调用之前被创建。
[代码 P81 第一段]
(2).显式API初始化(Explicit API initialization):你可以考虑给库添加一个初始化例程,如果还没有这么做的话。假如这样,你可以从GetInstance()方法中移除互斥锁,并使用单态作为该库初始化例程的一部分,把互斥锁放到这个地方:
[代码 P81 第二段]
这样做的好处是你可以指定所有单态的初始化顺序,万一存在单态依赖的问题(希望是没有的)。而有时候需要用户显式初始化库,回想一下,这只有在你得提供一个线程安全的API时才需要。
提示
在C++中,创建一个线程安全的单态是比较困难的。可以考虑采用一个静态构造函数或者API初始化函数来对它进行初始化。
3.2.3 单态对比依赖注入
依赖注入(Dependency injection)技术是把一个对象传入到一个类中(注入),来代替通过类来创建和保存对象本身。Martin Fowler在2004年创造了这个术语,是控制反转(Inversion of Control)概念的特殊形式。作为一个简单的例子,考虑下面这个依赖于一个数据库对象的类:
[代码 P81 第三段]
这个方法的问题是如果Database的构造函数该变了或者有人改掉了数据库中帐户“user”的密码,那么你就要相应地修改MyClass类来修复带来的问题。还有,从效率的观点上来看,每个MyClass类的实例都会创建一个新的Database实例。还有一种做法是,你可以使用依赖注入来传递一个已经配置好的Database对象到MyClass类中,如下所示:
[代码 P82 第二段]
采用这种方式,MyClass类不再需要知道如何创建Database实例。它只要接受已经构造好和配置好的Database对象以供它使用。这个例子演示的是构造注入(constructor injection),也就是说,通过构造函数来传递依赖的对象,不过你也可以通过setter成员函数或定义一个可重用的接口来注入某种类型的对象,接着在类中继承这个接口。
当然,Database对象需要在某处创建。这通常是由依赖容器完成的。例如,有个依赖容器负责创建MyClass类的实例和传给它一个合适的Database实例。换句话说,可以把依赖容器认为是一个普通的工厂类。两者的唯一区别就是依赖容器会维持状态,例如本例中的Database实例。
提示
依赖注入让使用单态时测试代码变得更容易。
因此,可以认为依赖注入是一种通过采用接口来接收单个实例(而不是在GetInstance()方法中进行请求)作为输出来避免单态扩散(proliferation of singletons)的方式。这也导致更多的可测试接口,因为就单元测试的目的而言,一个对象的依赖可以被存根或模拟版本(stub
or mock versions)所代替(这将在测试章节中讨论)。
3.2.4单态对比Monostate模式
单态模式的大多数相关问题都来自它是设计用来保持和控制对全局状态的访问。然而,如果状态在初始化或不需要在单态对象本身中存储状态时,你都不需要对其进行控制,那么就可以使用其它技术,例如Monostate设计模式。
Monostate模式允许创建某个类中的多个实例,所有这些实例使用相同的静态数据。例如,这里有一个Monostate模式的简单案例:
[代码 P83 第一段]
在这个例子中,你可以创建Monostate类的多个实例,但是所有对GetTheAnswer()方法的调用都返回相同的结果,因为所有的实例都共享相同的静态变量:sAnswer。你可以完全从头文件中隐藏静态变量的声明,把它声明成monostate.cpp中的文件范围(file-scope)的静态变量,来代替声明成私有的类静态变量。以为静态成员对类的每个实例的大小没有影响,因此这样做对API不会产生实质上的影响,除了从头文件隐藏实现细节。
下面是Monostate模式的一些优点:
q允许创建多个实例。
q因为不需要什么特殊的GetInstance()方法,所以可以提供较为透明的使用。
q通过使用静态变量,提供了定义良好的初始化和销毁语义。
正如Robert C. Martin提醒过的:单态通过仅允许创建一个实例,强制为一种单独的结构。相反,Monostate通过所有实例共享相同的数据来强制单独的行为(Martin, 2002)。
提示
如果你不需要全局数据的懒惰初始化或让类的单数类型是透明的,那么可以考虑使用Monostate来代替单态模式。
举个实际中的例子,Second Life源码中给LLWeb类使用Monostate类型。这个例子所使用的Monostate版本的所有成员函数都被声明成静态的。
[代码 P83 第二段]
在本例中,LLWeb仅仅是一个管理类,用来提供打开Web页面功能的单个访问点。Web浏览器的真正功能是由其它类实现的。虽然LLWeb类并不保存自己的任何状态,但是内部的所有静态方法都可以访问静态变量。
这种Monostate的静态方法版本的一个缺点是:你不能继承任何静态方法,因为静态成员函数不能是虚拟的(virtual)。还有,因为你不再实例化这个类,你就不能编写构造函数或析构函数来执行任何初始化或清理工作。在本例中是必需的,因为LLWeb有在动态访问已分配的的全局状态,而不是依赖于由编译器初始化的静态变量。LLWeb的创建者通过引入initClass()的静态方法来解决这个缺陷,这需要客户端程序显式实例化这个类。一个更好的设计就是在.cpp文件内隐藏这个调用,并从每个公共的静态方法中调用它。然而,那样做的话,又带来相同的线程安全方面的忧虑。
3.2.5单态对比会话状态
在最近的一次采访回顾中,设计模式一书的作者讲到:他们考虑从设计模式列表里面移除单态。因为这是一种存储全局数据的基本方式和倾向于是不良设计的标志(Gamma等,2009)。
因此,单态主题中最后要注意的是:你要考虑清楚单态模式是否是你需要的。人们常常容易认为将只需要一个给定类的单个实例。然而,需求在变,代码也会跟着变,在将来你可能发现要支持类的多个实例。
例如,考虑你正在编写一个简单的文本编辑器。你使用单态来保存当前的文本样式(如黑体、斜体和下划线),因为同一时间用户只能激活其中的一种状态。然而,这种限制只在下面的假设中才是有效的:在同一时间,程序只能编辑一个文档。在程序的后续版本中,要求你加入对多文档的支持,每个都有自己的当前文本样式。现在你就不得不重构代码,移除单态。最后,单态只能用在那些本质上是单数的对象模型。例如,因为只存在一个系统剪贴板,所以在文本编辑器中把剪贴板设计成单态是比较合适的。
常常也会在早期考虑引入“会话”或“执行上下文”对象到你的系统中。这是一个保存代码的所有状态的单一实例,而不是表示多个单态的状态。例如,在文本编辑器的例子中,你可以引入一个Document对象。这个带有一些访问器,诸如用来访问当前的文本样式,但是那些对象没有被强制成单态。它们只是平常的类,通过Document类来访问:document->GetTextStyle()。你可以采用单一的Document实例,通过调用Document::GetCurrent()来获取实例。你甚至可以在开始把Document设计成单态。然而,如果你在稍后需要添加多上下文支持(例如,多文档),那么支持这一修改的代码状态还是不错的,因为你只需要重构一个单态,而不需要改很多东西。J.B.
Rainsberger把这称为一个工具箱单态(Toolbox Singleton),程序变成一个单态,而不是单个类。(Rainsberger, 2001)。
提示
单态模式有几种可选的方式,包括依赖注入、Monostate模式和使用一个会话上下文。
3.3 工厂方法
工厂方法是一种创建型设计模式,允许创建一个对象而不需要指定要创建对象的特定C++类型。从本质上来说,工厂方法是构造方法(构造函数)的泛化形式。C++中的构造方法有几个限制,如下所示:
(1).没有返回值:你不能给构造函数返回一个值。这意味着在对象初始化时无法通过返回一个NULL指针来表示一个错误,例如(虽然你可以在构造函数中抛出一个异常来表示一个错误)。
(2).受约束的命名:构造函数的名称是很容易识别的,因为这得和它所在的类的名称一样。然而,这也限制了它的灵活性。例如,你无法拥有两个接收单个整型参数的构造函数。
(3).静态边界创建:当构造一个对象时,你必须制定一个具体的在编译时就已知的类名。例如,你可以这么写:Foo *f = new Foo(),Foo必须是编译器已知的特定类型。C++的构造函数中没有运行时动态绑定的概念。
(4).没有虚构造函数:在C++中,你不能声明一个虚构造函数。正如刚刚提到过的,你必须在编译期指定要构造的对象的具体类型。因此,编译器会为指定的类型分配内存和调用任何基类的默认构造函数(除非你在初始化列表中显式定义了非默认的构造函数)。接着它为指定的类型本身调用构造函数。这也就是为什么你不能从构造函数调用虚方法,除非它们是调用派生重写的(因为派生类尚未初始化)。
相比之下,工厂方法绕开了上述的这些限制。在基础层面,工厂方法只是一个普通的方法调用,返回一个类的实例。然而,它们常常结合到继承中使用,派生类可以重写工厂方法和返回派生类的一个实例。使用抽象基类(abstract base class,ABC)来实现工厂也是相当普遍和有用的(DeLoura, 2001)。我会在这里讲到抽象基类,在更深入讲解使用工厂方法之前,先了解一下这些类。
3.3.1 抽象基类
ABC(抽象基类)就是包含若干个纯虚成员函数的类。这样的类不能使用new操作符来实例化。它是作为基类存在的,派生类提供了纯虚方法的实现。例如:
[代码 P86 第一段]
这里定义了抽象基类来描述一个非常简单的3D图形渲染器。“=0”方法后缀是把它们声明成纯虚方法,意味着这由派生类重写成有具体内容的类。不过要注意的是:这并不是严格地说纯虚方法不提供任何实现,你可以在其.cpp文件中提供一个默认的实现。例如,虽然已经显式重写了下面的方法,但是你还是可以在renderer.cpp中,给SetViewportSize()方法提供一个实现,接着就可以在派生类中调用IRenderer::SetViewportSize()。
因此,抽象基类是用来描述抽象的行为单元,可以被多个类所共享;它指定了一个具体化的派生类所必须遵从的协议。在Java中,这叫做接口(Java的接口是有限制的,只能包含公共方法、静态变量和不能定义构造函数)。前面我命名的IRenderer类带有一个“I”的前缀,这表示它是一个接口类。
当然,在抽象基类中,你也可以为方法提供实现:不是所有的方法都必须是纯虚的。就这一点而言,抽象基类可以用来模拟混合体(mixins),可以认为是松散的实现方法接口。
对于任何拥有一个或多个虚方法的类,你总是应该把抽象基类的析构函数声明成虚的。下面的代码解释了这么做的重要性:
[代码 P86 第二段]
3.3.2 简单工厂例子
现在,我已经重温过什么是抽象基类。接着,让我们应用到简单工厂方法中。我将继续使用前面的renderer.h例子,先为IRenderer类型对象声明一个工厂:
[代码 P87 第一段]
看看上面的全部代码,声明一个工厂方法就是这么简单:它只是一个普通的方法,返回一个对象的实例。要注意的是:该方法无法返回特定的IRenderer类型的实例,因为那是一个抽象基类,无法被实例化。然而,它可以返回派生类的实例。而且,你可以使用字符串参数来通过CreateRenderer()指定你要创建的派生类型。
我们假设你已经实现了三个具体的派生自IRenderer的类:OpenGLRenderer、DirectXRenderer和MesaRenderer。接着我们将指定你不想让你的API用户知道这些类型的存在:它们必须完全隐藏在API里面。基于这些条件,你可以提供如下的工厂方法实现:
[代码 P87 第二段]
这个工厂方法可以返回IRenderer的三个派生类的任何一个,这取决于用户传入的字符串的类型。这可以让用户在运行时决定创建哪个派生类,而不是像普通构造函数中,是在编译期决定的。这是非常有用的,因为你可以根据用户的输入或者根据运行时的配置文件来决定创建不同的类。
还有要注意的地方是:各种具体化的派生类的头文件只能包含在工厂的.cpp文件中。它们不能出现在rendererfactory.h的公共头部中。实际上,这些是私有的头文件,并不需要随API一起发布。因此,用户是看不到这些不同渲染器的私有细节。用户只能通过一个字符串变量(或者是枚举,看你喜欢用哪种)来指定渲染器。
提示
使用工厂方法可以提供更为强大的类构造语义和隐藏子类细节。
本例很好地演示了令人满意的工厂方法。然而,有个潜在的缺点,采用的派生类包含硬编码。如果你往系统中添加一个新的渲染器,你就得重新编辑rendererfactory.cpp。这个负担并不算大,不过更为重要的是对公共API不会产生什么影响。不过,这意味着在运行时你不能为新的派生类添加支持。讲得更具体点,用户无法向系统中添加新的渲染器。这个问题可以通过一个扩展的对象工厂来解决。
3.3.3 扩展工厂例子
为了实现来自工厂方法的具体化的派生类的解耦和允许在运行时添加新的派生类,你可以更新工厂类来维持一个对象创建回调到关联的类型名字的映射(Alexandrescu, 2001)。这样就可以使用几个新的方法调用来注册和撤销注册新的派生类。在运行时注册新类就允许工厂方法模式用来给API创建可扩展的插件接口,详细请参见第十二章。
还有个问题值得注意:工厂对象现在必须保存状态。因此,最好是强制只创建一个工厂对象。这也正是为什么绝大多数工厂对象都是单态的原因。为了简单起见,在我们的例子中将使用静态方法和变量。把全部这些概念都整合起来,下面就是新的对象工厂:
[代码 P89 第一段]
相关的.cpp文件如下所示:
[代码 P89 第二段]
现在使用API的用户就可以在你的系统中注册(和注销(unregister 移除注册))新的渲染器。编译器就会确保用户的新渲染器遵循你的IRenderer抽象接口,也就是说,它为IRenderer中的所有纯虚方法提供实现。为了演示这个,下面的代码显示了用户如何定义他们自己的渲染器,使用对象工厂来注册它,接着让工厂创建它的一个实例。
[代码 P90 第二段]
这里有个值得注意的一点是:我往UserRenderer类添加了一个Create()函数。这是因为工厂的注册方法需要返回对象的回调。这个回调不需要是IRenderer类的一部分(例如,它可以是一个*函数)。不过,把它添加到IRenderer类是个不错的主意,可以让所有的相关功能都在同一个地方。事实上,你可以通过在IRenderer抽象基类上添加Create()调用作为另一个纯虚方法来强制遵循这个协议。
最后,请注意这里给出的扩展工厂例子,渲染器回调在运行时对RegisterRenderer()函数是可见的。不过,这不意味着你要在API中暴露内置的渲染器。这些仍然可以在API实现例程中隐藏或混合使用简单工厂和扩展工厂,凭借工厂方法首先检测比较类型字符串和一些内建的名称。如果没有匹配的,它会接着检测用户注册过的所有名称。这种混合方式具有令人满意的行为模式,用户无法重写你的内置类。
3.4 API包装模式
编写一个基于其它类集之上的封装接口在API任务设计中是很常见的。例如,或许你正在使用的庞大的旧系统代码,不想重新设计所有的代码。你决定设计一个简洁的新API来隐藏旧系统的代码(Feathers, 2004)。或者可能你已经编写了一个C++ API,并需要向用户暴露一个纯C接口。或者你有一个第三方库依赖,你要让用户可以访问,却不直接把库暴露给用户。
创建一个封装API的缺点是潜在的性能问题,这取决于间接的额外层数和封装级别上任何需要保存的状态的开销。不过,这些代价常常是值得的,为了创建更高品质或更专注的API,正如刚刚提到的那个例子。
有几种结构化设计模式是用来处理封装一个基于其它接口之上的接口。我会在下个章节介绍这些模式中的三种。这些是,增加封装层和原始接口的偏差:代理、适配器和外观模式。
3.4.1 代理模式
代理设计模式(图 3.3)为另一个类提供了一对一前置接口(forwarding interface):在代理类(proxy class)中调用FunctionA()会导致它调用原始类(original class)的FunctionA()。也就是说,代理类和原始类拥有相同的接口。这可以看做是单组件(single-component)封装,采用Lakos的术语(1996),也就是在代理API中的每个单态类(single
class)都映射到原始API的每个单态类上。
这种模式的实现常常通过让代理类存储一份原始类拷贝、或者更像是指向原始类的指针。代理类的方法简单地重定向到原始对象的同名方法上。这种技术的缺点是需要重新暴露原始对象中的函数,这么做从本质上等同于代码冗余。因此,这种方式就需要维护好代理接口的完整性,当修改原始对象时。下面的代码是关于这个技术的一个简单的例子。需要注意的是:我把拷贝构造函数和赋值运算符声明成私有成员函数,用来阻止用户拷贝对象。当然,你可以通过提供这些函数的显式实现来允许拷贝。我将在稍后的C++用法章节中讲述如何做到这一点。
[图 3.3]
代理设计模式的UML图
[代码 P92 第一段]
一种可选的解决方案是增加一个由代理和原始API共享的抽象接口。这是用来保持两个API同步的,不过这需要你能够修改原始的API。这种方法的演示代码如下:
[代码 P92 第二段]
提示
代理提供了一个接口,前置函数调用另一个同样形式的接口。
代理模式是用来修改Original类的行为且仍然保留它的接口。这是特别有用的,如果Original是第三方库中的类,直接修改是不容易的。代理模式的一些用例如下所示:
(1).原始对象的懒惰初始化实现:在这种情况下,Original对象直到某个方法调用执行时才会被真正的初始化。当初始化Original对象是开销大的操作时,你希望推迟到非要不可的时候。
(2).实现到原始对象的访问控制:例如,你可能希望在Proxy对象和Original对象之间添加许可层,以确保用户只能调用Original对象中特定的方法(如果他们获得合适的授权)。
(3).支持调式或“演习”模式:这让你能够把调试语句插入到Proxy方法,用来记录所有到Original对象的调用或者你可以在某个带有标志的Original方法处停止运行,来允许你在演习(dry run)模式中调用Proxy;例如,关闭掉把对象的状态写入到磁盘中。
(4).使Original类线程安全:要解决这个可以往相关的非线程安全的方法中添加互斥锁。或许这不是最有效率的方式来让底层的类线程安全,但是如果你不能修改Original,它还算蛮有用的权宜之计。
(5).支持资源共享:你可以拥有多个Proxy对象共享相同的底层Original对象。例如,这可以用来实现引用计数或写时复制语义。这种情况实际上是另一种设计模式,叫做享元模式(Flyweight pattern),就是多个对象共享相同的底层数据来最大限度减少内存占用。
(6).保护将来对Original类的修改:在这种情况下,你有考虑到某个依赖库在将来会有改动,因此给API创建一个代理封装,模拟当前的行为。当将来库发生变化时,你可以通过代理对象和简单地修改它的底层实现来保留旧的接口,这样就可以使用新的库方法了。在这一点上,你将不再需要代理对象,而是需要适配器,接下来就介绍这个不错的模式。
[图 3.4]
适配器设计模式的UML图
3.4.2 适配器模式
适配器设计模式(图 3.4)可以把一个类的接口转换成另一个兼容的不同接口。因此,这和代理模式是相似的,是一个单组件的封装。不过,适配器类的接口和原始类的接口可以是不同的。
这个模式是用来为已经存在的API暴露不同的接口,允许它和其它代码一起正常运行。在代理模式下,讨论中的两个接口可以来自不同的库。例如,有个几何包允许你定义一系列的基础形状。某些方法的参数可能和你所使用的API中的那些有不同的顺序,或者它们是定义在不同的坐标系统,或者使用不同的约定(例如center,size 对比 bottom-left,top-right),或者方法名不遵循你的API中的命名约定。因此,你可以通过一个适配器类来把这个接口转换成你的API中兼容的。例如:
[代码 P94 第一段]
在本例中,RectangleAdapter使用了一个不同的方法名和调用约定来设置矩形的尺寸,而不是采用底层的Rectangle类,不过从功能上说都是一样的。这只是暴露了一个不同的接口来允许你更容易地操作一个类。
提示
适配器就是把一个接口转换成一个兼容的,却不一样的接口。
应该注意的是:适配器可以通过组合(比如上述的例子)或继承来实现。这两种风格也常常叫做对象适配器或类适配器。在采用继承的情况下,RectangleAdapter是从Rectangle基类继承下来的。如果需要暴露适配器API中的Rectangle接口,这可以通过公共继承来实现,不过你很可能使用私有继承,因此你只会把新的接口定义成公共的。
API设计中的适配器设计模式有如下一些优点:
强制API中的一致性:正如上个章节中讨论过的一样,一致性是一个良好的API的重要品质。使用适配器模式,你可以整理那些包含不同接口风格的类,为它们提供一致的接口。这样让你的API更为统一和易于使用。
把一个依赖库封装到API中:例如,你的API能够装载一张PNG图像。你通过libpng库实现这一功能,但是你不想对API用户暴露libpng调用。这是因为你要呈现一致和统一的API或者因为你要防范将来的libpng可能会有变化。
转换数据类型:例如,现在你有个MapPlot的API,允许你在2D地图上标示一个坐标位置。MapPlot只接受一对纬度和经度(使用WGS84测量基准),是两个double(双精度浮点型)参数。然而,你的API中有个GeoCoordinate类型用来表示几个坐标系统中的坐标,如通用横轴墨卡托(Universal Transverse Mercator)或兰勃特等角圆锥(Lambert Conformal Conic)。你可以编写一个适配器用来接受GeoCoordinate对象为参数,把这个转换成大地测量学坐标(纬度,经度),如果需要的话,把这两个double类型传入到MapPlot API中去。
为API暴露不同的调用约定:例如,可能你已经有一个纯C的API,现在你想为C++用户提供一个面向对象的版本。你可以创建一个适配类,把C中的调用封装到C++的类中。这种做法是还有争论的:是否可以严格地称作适配器模式,因为设计模式最初是和面向对象系统相关联的,但是如果你灵活地看待这个术语,你就会发现从概念上是一样的。请看下面的例子,一个为纯C
API服务的C++适配器。(我会在下个章节中讨论C和C++ API在风格上的更多细节)
[代码 P96 第一段]
3.4.3 外观模式
外观设计模式(图 3.5)为大型的类集提供了一个简化过的接口。实际上,它定义了一个高级别的接口,使底层的子系统易于使用。根据Lakos分类,外观模式是多组件封装的一个例子。(Lakos, 1996)。因此,外观模式和适配器模式是不同的,因为外观模式简化了一个类的结构,而适配器模式保持的还是同一个类的的结构。
[图 P97 第一张]
图 3.5
外观设计模式的UML图
当API的规模增大时,使用接口的复杂度也会增加。外观模式可以改造API的结构,分解成子系统以降低复杂度,这就使API对大部分用户来说变得易于使用。外观模式可以提供改进过的接口的同时仍然允许访问底层系统。这也符合上一个章讲过的便捷API的概念,通过添加额外的类来提供聚合功能,使API变得简洁和易于使用。或者,外观模式可以完全对底层系统和公共接口进行解耦,这样就可让它们都无法被访问。这也常常叫做:“封装外观”。
提示
外观模式为其它类集提供了简化过的接口。在封装外观模式中,底层的类是不可访问的。
让我们来看看这个模式的一个例子。现在假设你在度假,已经预定了一个酒店。你打算吃个晚餐后再去看个演出。有了这个计划,首先你得打电话给餐馆预约一下,再打电话给影院订票,或许还需要安排的士来酒店接你。在C++中你得使用三个独立的对象来完成这些交互:
[代码 P97 第一段]
不过,假设你住的这个酒店是非常高端的,里面的服务生可以帮你完成上面说的那些。服务生需要查明演出的时间,通过他对当地的熟悉,安排你的晚餐和叫的士的合适时间。把这些转换成C++的设计方式,就是只需要通过简单的接口和一个对象进行交互。
[代码 P98 第二段]
(1).隐藏旧代码:你常常需要处理老旧的系统,不那么友好且没有提供一致的对象模型。在这种情况下,可以轻松地创建基于旧代码之上的设计良好的新API。接着,所有的新代码都可以使用这些新的API。一旦全部现有的用户都更新到新的API,旧代码就会完全隐藏到新的外观模式之下(采用封装外观模式)。
(2).创建便捷API:正如上个章节中讨论过的,常常存在这样的矛盾:要设计通用、灵活和更加强大的程序,又要让易于使用的程序让用例变得简单。外观模式就是用来解决这个矛盾的,允许两者共存。从本质上说,便捷API就是外观模式。先前我用过OpenGL的例子,提供低级别的基础例程和GLU库,它提供基于GL库之上的高级别和易于使用的例程。
(3).支持简化的或功能可选的API:为了抽象对底层系统的访问,在不影响用户代码的情况下,是有可能替换某个子系统的。这可以用来在存根子系统中进行交换以支持API的演示或测试版本。它也允许在不同功能间进行交换,如在游戏中使用不同的3D渲染引擎或使用不同的图像读取库。举个实际中的例子,Second Life查看器构建于KDU JPEG-2000专利解码库之上。然而,开源版本的查看器是基于性能差些的OpenJPEG库。
3.5 观察者模式
一个对象在其它对象中调用方法是相当普遍的。毕竟要完成任何复杂的任务通常需要有几个对象一起合作。然而,为了做这些,对象A必须能够访问且知道对象B的接口,这才能调用B中的方法。实现这个的最简单方式就是在A.cpp文件中包含B.h,这样就可以在类中直接调用方法了。不过,这会导致A和B之间的编译时依赖,使类之间的耦合变得紧密。因此,A类的通用性会降低,在没有B类的情况下,它就无法被其它系统所复用。而且,如果A类还调用了C类和D类,修改A类会影响到这些耦合紧密的类。此外,编译时耦合意味着用户无法在运行时动态地往系统中添加新的依赖。
提示
观察者就是让你可以对各组件进行解耦和避免循环依赖。
我会对这些问题给出演示说明的,还会给出观察者模式是怎么用的,通过流行的模型-视图-控制器(MVC)框架来进行说明。
3.5.1 模型-视图-控制器
MVC框架模式需要分离业务逻辑(模型 Model)和用户界面(视图 View),控制器用来接收用户输入和协调两者。MVC分离支持程序功能的模块化和提供了不少好处:
(1).分离模型和视图组件,这样就可以实现多个用户界面重用公共的核心业务逻辑。
(2).低级的模型代码的副本消除了多版本UI实现。
(3).模型和视图代码的解耦为核心业务逻辑代码能够更好地编写单元测试。
(4).组件的模块化允许核心业务开发人员和GUI开发人员同时进行开发,而不相互影响。
在1987年年,MVC模型是由Steve Burbeck 和 Trygve Reenskaug 在Xerox PARC发明的,时至今日,这个架构模式在程序和工具包(toolkits)中依然非常流行。例如,现代的UI工具包,如Nokia的 Qt、Apple的Cocoa、Java的Swing和Microsoft’s Foundation Class库(微软基础类MFC)都是MVC所启示的。举个选择框按钮的例子,按钮当前的开/关状态是存储在模型中,视图负责绘制按钮在屏幕中的当前状态,而当用户点击按钮时,控制器负责更新模型的状态和视图的显示。
提示
MVC架构模式促进了核心业务逻辑、或模型和用户界面、或视图的分离。它也隔离了影响模型修改和视图更新的控制器逻辑。
MVC隔离性是就代码依赖而言的,这表示视图代码可以调用模型代码(用来侦测最新的状态和更新UI),但是反过来就不行了:模型代码在运行时无法获知视图代码(因为它是把模型绑定到一个单一的视图上)。请参考图示3.6,说明了这种依赖。
在一个简单的程序中,控制器可以基于用户输入而影响模型修改和对那些修改与视图进行通信,这样才能更新UI。不过,在实际应用程序中,视图通常也需要根据底层模型的额外修改做出相应的更新。这是必要的,因为修改模型的某个方面也会更新其它依赖的模型状态。当这个状态发生改变时,就需要模型代码通知视图层。不过,前面说过,模型代码不能静态绑定和调用视图代码。这时候观察者模式就派上用场了。
观察者模式是一个发布/订阅(Publish/Subscribe 简称pub/sub)特殊实例。这些技术定义了对象之间的一对多的依赖,当发生任何状态更改时,发布对象可以通知所有的订阅对象,而且不直接依赖它们。因此,观察者模式是API设计中的一种重要技术,它可以帮助你降低耦合和增加代码重用。
[图 P100 第一张]
图3.6
MVC模型的依赖概况。控制器和视图都依赖在模型之上,但是模型代码并没有依赖控制器或视图代码。
3.5.2 实现观察者模式
实现观察者模式的典型方式就是引入两个概念:主体(subject)和观察者(observer)(也叫做发布者和订阅者)。一个或多个观察者注册感兴趣的主体,接着当有任何状态更改时,主题都会通知所有注册过的观察者。这个如图3.7所示。
这个都可以通过使用基类指定抽象接口实现,请参照下面的这些例子:
[代码 P101 第一段]
[代码 P102 第一段]
[图 P101 第一张]
图3.7
观察者模式的UML图示
在这个设计中,我为主体添加了支持注册和发送通知到多个不同的消息类型。这允许观察者订阅那些它们感兴趣的特定消息。例如,有个表示一堆元素的主体想要发送各自的通知,当这些元素被添加进或删除出堆的时候。使用前面提过的接口,你可以这样定义一个最小的主体类,如下所示:
[代码 P102 第二段]
最后,你可以从IObserver抽象基类继承,创建观察者对象并实现Update()方法。下面的代码将把所有的这些概念整合起来:
[代码 P102 第三段]
本例创建了三个不同的观察者类和为它们订阅了由MySubject类定义的两个消息组合。最后,subject.Notify()的调用使主体遍历它的观察者列表,这些观察者订阅了给定的消息和调用它们各自的Update()方法。要注意的要点是:MySubject类对MyObserver不存在编译时依赖。两个类之间的关系是在运行时动态创建的。
当然,这种灵活性需要付出一点性能上的开销:在调用(虚)函数之前,在观察者列表上进行迭代的开销。不过,比起减低代码耦合和增加代码重用的好处,这些开销是完全可以忽略的。而且,正如我上章讲过的,在你销毁观察者之前,你必须小心处理任何取消订阅它们的操作,否则下个通知会导致崩溃。
3.5.3 推对比拉观察者
还有很多实现观察者模式的不同方法,前面提到的这个例子只是其中一种方法。不过,要注意下两大类的观察者模式:推送式(push-based)和抽拉式(pull-based)。这种分类取决于所有的信息是通过Update()方法的参数推送给观察者,还是通过Update()方法简单地发送一个关于事件发生的通知;如果观察者希望了解更多的细节,那么它们必须直接查询主体对象。举个例子,用户在一个文本输入点按下Return键的通知需要传送用户输入的文本,这作为Update()方法(推送)的参数或者依赖于观察者调用主体中的GetText()方法来获取它需要的信息(如果它需要的话)(抽拉)。
图3.7演示的是抽拉观察者模式,因为Update()不含参数和观察者可以从主体查询它的当前状态。这种方法允许你为系统中的所有观察者使用简单的IObserver。对比推送方法需要你为每个带有唯一签名的Update()方法定义不同的抽象接口。推送方法比较适合发送通知中带有的少量数据,如选择框状态修改时的开/关状态。不过,如果是大量数据的话就变得没什么效率了,例如当用户每按一个键时都发送整个文本。