10.11 工厂模式:封装对象的创建

当发现需要添加新的类型到一个系统中时,最明智的首要步骤就是用多态机制为这些新类型创建一个共同的接口。用这种方法可以将系统中其余的代码与新添加的特定类型的代码分开。新类型的添加并不会扰乱已存在的代码……或者至少看上去如此。起初它似乎只需要在继承新类的地方修改代码,但这并非完全正确。仍须创建新类型的对象,在创建对象的地方必须指定要使用的准确的构造函数。因此,如果创建对象的代码遍布整个应用程序,在增加新类型时将会遇到同样的问题—仍然必须找出代码中所有与新类型相关的地方。这是由类的创建而不是类的使用(类型的使用问题已被多态机制解决了)而引起,但是效果是一样的:添加新类型将导致问题的出现。

这个问题的解决方法就是强制用一个通用的工厂(factory)来创建对象,而不允许将创建对象的代码散布于整个系统。如果程序中所有需要创建对象的代码都转到这个工厂执行,那么在增加新对象时所要做的全部工作就是只需修改工厂。这种设计是众所周知的工厂方法(Factory Method)模式的一种变体。由于每个面向对象应用程序都需要创建对象,并且由于人们可能通过添加新类型来扩展应用程序,工厂模式可能是所有设计模式中最有用的模式之一。

举一个例子,考虑常用的Shape例子。实现工厂模式的一种方法就是在基类中定义一个静态成员函数:

10.11 工厂模式:封装对象的创建 - 图1

10.11 工厂模式:封装对象的创建 - 图2

函数factory()允许以一个参数来决定创建何种类型的Shape。在这里,参数类型为string,也可以是任何数据集。在添加新的Shape类型时,函数factory()是当前系统中惟一需要修改的代码。(对象的初始化数据大概也可以由系统外获得,而不必像本例中那样来自硬编码数组。)

为了确保对象的创建只能发生在函数factory()中,Shape的特定类型的构造函数被设为私有,同时Shape被声明为友元类,因此factory()能够访问这些构造函数。(也可以只将Shape:factory()声明为友元函数,但是似乎声明整个基类为友元类也没什么大碍。)这样的设计还有另外一个重要的含义—基类Shape现在必须了解每个派生类的细节—这是面向对象设计试图避免的一个性质。对于结构框架或者任何类库来说都应该支持扩充,但这样一来,系统很快就会变得笨拙,因为一旦新类型被加到这种层次结构中,基类就必须更新。可以使用下一小节将要讨论的多态工厂(polymorphic factory)来避免这种循环依赖。

10.11.1 多态工厂

在前面的例子中,静态成员函数static factory()迫使所有创建对象的操作都集中在一个地方,因此这个地方就是惟一需要修改代码的地方。这确实是一个合理的解决方法,因为它完美地封装了对象的创建过程。然而,“四人帮”强调工厂方法模式的理由是,可以使不同类型的工厂派生自基本类型的工厂。工厂方法模式事实上是多态工厂模式的一个特例。这里修改了ShapeFactory1.cpp,所以工厂方法模式作为一个单独的类中的虚函数出现:

10.11 工厂模式:封装对象的创建 - 图3

10.11 工厂模式:封装对象的创建 - 图4

10.11 工厂模式:封装对象的创建 - 图5

现在,工厂方法模式作为virtual create()出现在它自己的ShapeFactory类中。这是一个私有成员函数,意味着不能直接调用它,但可以被覆盖。Shape的子类必须创建各自的ShapeFactory子类,并且覆盖成员函数create()以创建其自身类型的对象。这些工厂是私有的,只能被主工厂方法模式访问。采用这种方法,所有客户代码都必须通过工厂方法模式创建对象。

Shape对象的实际创建是通过调用ShapeFactory:createShape()完成的,这是一个静态成员函数,使用ShapeFactory中的map根据传递给它的标识符找到相应的工厂对象。工厂直接创建Shape对象,但是可以设想一个更为复杂的问题:在某个地方返回一个合适的工厂对象,然后该工厂对象被调用者用于以更复杂的方法创建一个对象。然而,似乎在大多数情况下不需要这么复杂地使用多态工厂方法模式,基类中的一个静态成员函数(正如ShapeFactory1.cpp中所示)就能很好地完成这项工作。

注意,ShapeFactory必须通过装载它的map与工厂对象进行初始化,这些操作发生在单件ShapeFactoryInitializer中。当增加一个新类型到这个设计时,必须定义该类型,创建一个工厂并修改ShapeFactoryInitializer,以便将工厂的一个实例插入map中。这些额外的复杂操作再次暗示,如果不需要创建独立的工厂对象,尽可能使用静态(static)工厂方法模式。

10.11.2 抽象工厂

抽象工厂(Abstract Factory)模式看起来和前面看到的工厂方法很相似,只是它使用若干工厂方法(Factory Method)模式。每个工厂方法模式创建一个不同类型的对象。当创建一个工厂对象时,要决定将如何使用由那个工厂创建的所有对象。“四人帮”书中的例子实现各种图形用户界面(GUI)的可移植性:创建一个适合于正在使用的GUI的工厂对象,然后它将根据对它发出的对一个菜单、按钮或者滚动条等的请求自动创建适合该GUI的项目版本。这样就能够在一个地方隔离从一个GUI转变到另一个GUI的作用。

再举一个例子,假设要创建一个通用的游戏环境,并且希望它能支持不同类型的游戏。请看以下程序是如何使用抽象工厂模式的:

10.11 工厂模式:封装对象的创建 - 图6

10.11 工厂模式:封装对象的创建 - 图7

10.11 工厂模式:封装对象的创建 - 图8

在此环境中,Player对象与Obstacle对象交互,但是Player和Obstacle类型依赖于具体的游戏。可以选择特定的GameElementFactory来决定游戏的类型,然后GameEnvironment控制游戏的设置和进行。在本例中,游戏的设置和进行很简单,但是那些动作(初始条件(initial condition)和状态变化(state change))在很大程度上决定了游戏的结果。在这里,GameEnvironment不是设计成继承的,即使这样做可能是有意义的。

这个例子也说明将在稍后讨论双重派遣(double dispatching)。

10.11.3 虚构造函数

使用工厂方法模式的主要目标之一就是更好地组织代码,使得在创建对象时不需要选择准确的构造函数类型。也就是说,可以告诉工厂:“现在还不能确切地知道需要什么类型的对象,但是这里有一些信息。请创建类型适当的对象。”

此外,在构造函数调用期间,虚拟机制并不起作用(发生早期绑定)。在某些情况下这是很棘手的事情。例如,在Shape程序中,在Shape对象的构造函数内部建立一切需要的东西然后由draw()绘制Shape,这似乎是合理的。函数draw()应该是一个虚函数,它将根据传递给Shape的消息绘制相应的图形,消息表明图形本身是Circle、Square或者Line。然而,这些操作在构造函数内部不能采用这种方法,因为当在构造函数内部调用虚函数时,将由虚函数决定指向哪个“局部的”函数体。

如果想要在构造函数中调用虚函数,并使其完成正确的工作,必须使用某种技术来模拟虚构造函数。这是一个难题。请记住,虚函数的思想就是发送一个消息给对象,而让对象确定要做的正确事情。但是对象是由构造函数创建的。因此,虚构造函数好像是在对一个对象说:“我不能准确知道你是什么类型的对象,但是无论如何要以正确的类型建造你。”对于普通的构造函数来说,编译器在编译时必须知道虚指针(VPTR)指向的虚函数表(VTABLE)的地址;而对于虚构造函数,即使存在这样的虚函数表,它也不可能做到这一点,因为它在编译时不知道任何类型信息。构造函数不能为虚函数是有道理的,因为它是这样一种函数,必须完全知道有关对象类型的所有信息。

可是,程序员有时还想要得到接近于虚构造函数的行为。

在Shape的例子中,在参数表中对Shape构造函数提交一些特定的信息,使构造函数创建特定类型的Shape对象(一个Circle或是一个Square)而无须更多的干涉,这将是很好的。通常,程序员自己必需显式调用Circle或是Square的构造函数。

Coplien[1]将他给出的解决此问题的方法取名为“信封和信件类”。“信封”类是基类,它是一个包含指向一个对象的指针的外壳,该对象也是一个基类类型。“信封”类的构造函数决定采用什么样的特定类型,在堆上创建一个该类型的对象,然后对它的指针分配对象(决定是在运行中调用构造函数时做出的,而不是在编译中做类型正常检查时做出的)。随后的所有函数调用都是由基类通过它的指针来进行处理。这实际上就是状态模式的小小变形,其中基类扮演派生类的代理的角色,而派生类提供行为中的变化:

10.11 工厂模式:封装对象的创建 - 图9

10.11 工厂模式:封装对象的创建 - 图10

基类Shape包含一个对象指针作为其惟一的数据成员,该指针指向Shape类型的对象。(在创建一个“虚构造函数”的模式时,务必确保这个指针总是被初始化成指向一个激活的对象。)这个基类实际上就是一个代理,因为这是客户程序惟一所能看到和与之进行交互的对象。

每次从Shape派生新的子类时,必须回到基类并且在基类Shape的“虚构造函数”内的一个位置增加那个类型的创建。这并不是件很繁重的任务,但缺点是在Shape类和其所有的派生类之间形成了依赖关系。

在这个例子中,交给虚构造函数的关于要创建对象的类型信息必须是显式说明的:它是一个用来命名类型的string。但是模式也可以用其他信息—比如说,在一个语法分析器中,可以把扫描器的输出结果给虚构造函数,而构造函数将利用这些信息来决定创建何种类型的对象。

虚构造函数Shape(type)在所有派生类未声明前不能定义。然而,默认的构造函数能够在class Shape中定义,但是它应该被声明为protected的,所以不能创建临时的Shape对象。这个默认的构造函数只能被派生类对象的构造函数调用。程序员被迫显式地创建一个默认构造函数,因为如果没有定义构造函数编译器将会自动创建一个。因为必须定义Shape(type),所以也必须定义Shape()。

在这种模式中,默认构造函数至少有一个重要的工作要做—它必须将指针s设置为零值。这在初听起来有点奇怪,但是应当记得,默认构造函数将作为实际对象的构造的一部分被调用—用Coplien的术语来说,它是“信件”而不是“信封”。然而,“信件”也是从“信封”派生出来的,它也继承数据成员s。在“信封”中s很重要,因为它指向实际的对象,但是在“信件”中,s只是一个超重行李。可是,即便是额外行李也应该被初始化。如果不调用默认构造函数为“信件”把s赋为零值,将会出现问题(这在后面将会看到)。

虚构造函数使用其参数提供的信息,这些信息完全能够决定对象的类型。注意,这些类型信息在运行时才能读取和使用,而在一般情况下,编译器在编译时必须知道确切的类型(这是本系统能够有效地模拟虚构造函数的另外一个原因)。

虚构造函数使用其参数来选择要构造的实际对象(“信件”),然后对“信封”内的指针赋值。至此,“信件”类对象创建完成,因此任何虚函数的调用得以正确地重定向。

作为一个例子,考虑在虚构造函数中调用函数draw()。如果跟踪这个调用(手工或者使用调试器),将会看到它是从基类Shape中的函数draw()开始调用的。这个函数调用“信封”的draw(),“信封”指针s指向它的“信件”。所有从Shape中派生出来的类型共享同一个接口,所以这个虚调用能够正确执行,虽然它似乎在构造函数中。(实际上,“信件”类的构造函数已经执行完毕。)只要基类中所有的虚调用通过这个指向“信件”的指针仅调用同一个虚函数,系统就能正确地运作。

为了了解它是如何工作的,请思考main()函数中的代码。为了填充vector shapes,调用“虚构造函数”以便产生Shape对象。通常像这样的情况,应该用调用实际类型的构造函数,这种类型的虚指针(VPTR)应该安置在该对象中。然而在这里,在每种情况下虚指针(VPTR)都是指向Shape的一个对象,而不是指向特定的一种类型如Circle、Square或是Triangle。

在for循环中,为每个Shape对象调用函数draw()和erase(),虚函数调用通过VPTR解析到相应类型。然而,在各种情况下它都是Shape。事实上,读者也许想知道为什么draw()和erase()要声明为虚函数。在下一步可以看到原因:draw()的基类版本通过“信件”指针s调用“信件”的虚函数draw()。这时,这个调用解析到对象的实际类型,而不是基类Shape。因此每次调用虚函数时,使用虚构造函数的运行时代价只是一个额外的虚间接引用(virtual indirection)。

为了创建如draw()、erase()和test_()等任何将被覆盖的函数,如前所述,必须全部前向调用基类实现中的指针s。这是因为当调用发生时,调用“信封”的成员函数将被解析指向Shape而不是Shape的派生类。只有当前向调用的时候,s才发生虚行为。在main()函数中,可以看到所有工作都能正确执行,即使调用发生在构造函数和析构函数中。

析构函数操作

在这种模式中析构活动同样也是很复杂的。为了了解这点,让我们从头至尾说明,当对指向创建在堆上的一个Shape对象(尤其是一个Square)的指针调用delete时会发生什么情况。(这是比建立在栈上的对象复杂得多的对象。)这将是一个经过多态接口的delete,并通过调用purge()来完成。

Shapes中任何指针的类型都是基类Shape的类型,所以编译器通过Shape产生调用。通常情况下,可以说这是一个虚调用,所以Square的析构函数将被调用。但是在用虚构造函数系统中,由编译器来创建实际的Shape对象,即使构造函数初始化“信件”的指针为指向一个特定的Shape类型。这里使用了虚机制,但是,在Shape对象中的VPTR是Shape对象的虚指针,而不是Square对象的虚指针。这样就解析到Shape的析构函数,该析构函数调用delete,该指针实际指向一个Square对象。这还是个虚调用,不过这时它解析指向Square对象的析构函数。

C++通过编译器确保继承层次结构中的所有析构函数都被调用。Square的析构函数最先被调用,然后顺序调用任何中间类的析构函数,直至最后,基类的析构函数被调用。这个基类的析构函数中包含代码delete s。当这个析构函数最初被调用时,它针对的是“信封”的s,而现在它针对的是“信件”的s,这是因为“信件”从“信封”中继承,而不是因为它包含了什么东西。所以这个delete调用不应该有任何操作。

解决此问题的方法是使“信件”的指针s指向零。这样,当调用“信件”的基类析构函数时,实际上得到的就是delete 0,它的定义是不执行任何操作。因为默认构造函数被设为保护的,它只是在“信件”对象的构造过程中被调用。这是将s置为零值的惟一情况。

虽然这种描述很有趣,但是可以看到这是一个复杂的方法,所以隐藏构造的最常见的工具一般是普通的“工厂方法”而不是“虚构造函数”模式这样的方法。

[1]James O.Coplien,《Advanced C++Programming Styles and Idioms》,Addison Wesley,1992.