C++ API 设计 17 第十二章 扩展性

第十二章 扩展性

这最后一章讨论的是API扩展性这个主题。通过这个,你的用户无需通过你就可以改进API,就可以修改接口的行为来满足他们的特定需要。这是一个重要的因素,你能够维护一个简洁和专注的接口的同时,也实现一个灵活的系统,能够让你的用户解决你从未预期到的问题。这个概念所表达的就是开/关原则,这在第4章中讨论过,也就是API应该对扩展开放,对修改关闭(Meyer, 1997)。

这里举一个现实中的例子,皮克斯的木偶动画系统支持关键帧动画,在关键帧之间支持一系列合理的插值设计[l1],如贝塞尔、Catmull-Rom、线性和步进插值。然而,在超人总动员和汽车总动员(The Incredibles and Cars)开发过程中,这成为必要的,允许我们的产品用户在更加复杂的插值例程中设计和迭代。而不是每次连续地更新核心动画系统,我们的用户还需要修改他们的自定义插值算法,我设计了一个插件系统来允许产品用户创建在运行时可用的动态库并可以被添加到内建的插值例程集合中。这被证明是一种非常有效的方式来解决特定的生产需求非常有效的方式,同时仍保持了一个通用的电影制作系统。

本章致力于讲解让你在API中实现同等级别灵活性的各种技术。我将通过大部分篇幅详细描述如何为C和C++ API创建一个耐用的、跨平台的插件架构,不过我也会通过继承和模板来涵盖其它扩展性技术。

12.1 通过插件扩展

大多数情况下,插件是一个在运行时被发现和装载的动态库,而不是一个在构建时由一个程序链接的动态库。因此,插件可以是由你的用户进行编写,使用你提供的定义良好的插件API。这允许他们用指定的方式扩展你的API的功能。图12.1说明了这个概念,白盒所表示的内容是由你的用户制作的。

不过,应该指出的是:静态库插件也是可以的,例如,为了嵌入系统,对于在编译时所有的插件都可以静态地链接到程序中嵌入系统。这是用来确保插件在运行时是能被找到的,而且它和主要的可执行文件是在相同的环境中构建的。不过,我在本章主要关注的是动态库模型,因为这更具有挑战性,让用户能够在运行时往系统中添加新的插件。

[图 P362 第一张]

图12.1

一个插件库是一个动态库,可以和一个核心API分离开来进行编译,能够根据需要由API进行加载。

12.1.1 插件模型概述

很多商业软件包允许使用C/C++插件扩展它们的核心功能。例如,Apache Web服务器软件支持基于C的模块,Adobe Photoshop支持一系列操作图像的插件类型,而Web浏览器:Firefly、Chrome和Opera都支持Netscape Plugin API (网景插件API,NPAPI)的浏览器插件,如Adobe Flash或PDF阅读器插件。Qt工具包也可以通过QpluginLoader类扩展。(基于服务的插件API,如Apache的模块接口,有时也叫做Server API或SAPI。)

在API中采用插件模块有如下一些好处:

q更佳的灵活性[l2]:你的API可以用来解决更大范围的问题,不要求你实现所有那些问题的解决方案。

q社区支持[l3]:让你的用户能够在你的API框架下解决他们自己的问题,你可以激发社区中的用户为你的基础设计做出额外的贡献。

q更小的更新:插件中的功能更新比较轻松,因为它是独立于程序之外,只要简单地丢入一个插件的新版本即可。比起重新发布整个程序版本,这种可以提供小得多的更新。

q适应未来[l4]:你的API的稳定性可能会达到你觉得没有必要再进一步升级的级别。不过,你可以通过继续开发插件,来进一步改进API的功能,让API保持其可用性和经得起时间的考验[l5]。例如,NPAPI最近几年中没什么改动,不过它仍然是一种给很多Web浏览器编写插件的流行方法。

q隔离风险:插件对于内部开发(in-house development)也是有好处的,这可以让工程师修改功能而不会影响系统核心的稳定性。

正如刚刚暗示过的,插件系统并不只能由你的用户来使用。你也可以把部分的核心API的实现开发成插件。实际上,这是一种良好的实践,因为它可以确保充分发挥你的插件架构,那样就可以和你用户一样使用你自己开发的产品(“吃你自己养的狗的食物” 译者注:从软件开发人员角度看,这句俚语的意思就是用你自己的产品)。[l6]例如,GNU图像处理程序(GNU Image Manipulation Program,GIMP)使用它的GIMP插件API承载了很多它内建的图像处理函数的插件。

[排版 P363 开始]

网景插件

网景插件API(NPAPI)提供了一个跨平台的插件API,用来向各种Web浏览器中嵌入自定义的功能。该接口起源于Adobe系统等,把PDF阅读器集成到网景浏览器的早期版本。[l7]这个纯C插件API今天仍在使用中,嵌入本地代码扩展到Web浏览器中,如:Mozilla的火狐、苹果的Safari和Google的Chrome。例如,Shockwave Flash、苹果的 QuickTime和微软的Silverlight都是做为浏览器插件实现的(试着在火狐浏览器中输入“about:plugins”就可以看到已安装插件的列表)。

NPAPI给出的本地代码在Web浏览器中实现了以下功能:

q注册新的MIME类型。

q绘制浏览器窗口的一个区域。

q接收鼠标和键盘事件。

q通过HTTP发送和接收数据。

q添加链接到新URL的超链接或热点。

q和文档对象模型(Document Object Model)进行通信。

当我上个世纪90年代在斯坦福国际研究所的时候,我们开发了一个叫TerraVision的基于Web的3D地形可视化系统(Leclerc and Lau, 1994)。启动这个桌面程序,需要一个4处理器SGI Onyx的虚拟现实引擎(SGI Onyx RealityEngine²)。不过,随着显卡硬件的发展,我们最终可以让它作为插件运行在网景公司的导航软件里和标准PC上的微软浏览器中。

[图 P363 第一张]

因为TerraVision曾是一个复杂的多线程程序,要让它运行在非线程安全的浏览器环境下,我们不得不把它运行在一个独立的进程中。也就是说,我们的网景插件会为运行TerraVision创建一个新的进程并传给它一个用来绘制的窗体句柄。插件和TerraVision进程的所有通信都是通过管道I/O实现的。

[排版 P363 结束]

12.1.2 插件系统设计问题

设计一个插件系统的设计方式有很多种。最适合你当前项目的解决方案对你的下个项目来说可能不是最好的选择。因此,我将梳理出一些你在设计一个插件系统时所应该知道的高级问题。

同时,对于所有的插件系统也有很多可应用的基本概念。例如,当要支持动态库插件时,你总是需要一种在那个文件中装载一个动态库和访问符号的机制。一般说来,当创建任何一个插件系统,你都必须设计两大特性(见图12.2)。

[图 P364 第一张]

图12.2

核心API中的插件管理器。它发现并加载已经由插件API构建过的插件。

(1).插件API:这个API是你的用户为了创建一个插件所必须编译和链接的。我把这和你的核心API相区分开来,这是你添加到插件系统中的一个较大的代码库。

(2).插件管理器:这是核心API代码中的一个管理所有插件生命周期的对象(常常是一个单例),也就是:加载、注册和卸载。这个对象也可以叫做插件注册表(Plugin Registry)。

在了解这些基本概念后,让我们看看会影响所构建的API构建的精确插件架构的设计决策。

qC对比C++:在先前的版本章节中讨论过,C++规范并未定义一个具体的ABI。因此,不同的编译器,甚至是同一个编译器的不同版本,都能生成二进制不兼容的代码。这对一个插件系统意味着用户使用带有不同ABI的编译器开发的插件或许是无法加载的。相比之下,纯C代码编写ABIAPI是有良好定义的,可以跨平台和编译器工作。

q版本化:你想要通过某种方式知道一个插件是否是和一个不兼容的API版本构建的。因为很难自动推测出什么构成了一个不兼容的API,所以常常把这留给插件的作者来指定。例如,火狐的扩展API让你指定扩展可以运行的版本最小和最大的范围(当有一个不兼容的API发布时,系统可以容易地更新一个扩展的最大版本)。它也可以用来分辨知道插件是编译自哪个版本的API。这可以自动嵌入到插件中或留给插件的作者来指定。例如,Google的Android(安卓)API除了让你指定android:minSdkVersion和android:maxSdkVersion外,还可以指定一个android:targetSdkVersion。

q内部元数据对比外部元数据:元数据,如一个人们可读的名称和版本信息,既可以定义在插件代码本身中,也可以在一个简单的外部文件格式中指定。使用一个外部元数据文件的优点是你实际上不需要为了知道所有可用的对象集而加载所有的插件。例如,你要为用户呈现所有插件的列表,接着只加载他们选择使用的插件。缺点是你不能简单地把一个新的插件丢入到目录里并让它自动被加载。你必须包含一个每个插件元数据的文件,或者为所有的插件更新全局元数据文件,这取决于你采用哪种特定的方法。

q通用对比专用的插件管理器:实现插件管理器的一种方式是把它设置成非常低的层次底层和通用的,也就是说:它仅仅是装载插件并访问那些插件中的符号。然而,这么做意味着插件管理器并不知道API中具体类型的存在。因此,它可能不得不为对象返回void*的指针,在你使用它们时必须把它们转换成具体的类型。或者,插件管理器至少可以为插件中的对象进行前置声明,这样就可以生成一个更符合类型安全的解决方案,不过结果是它不能独立于API之外执行了。一种妥协的方式是引入一个动态运行时类型系统到你的API中,插件管理器可以返回泛型类型的引用,可以稍后被API注册。

q安全性:你必须决定你信任用户插件的程度。插件是被任意编译的运行在你的进程中的代码。因此,插件可能做任何事,访问它不该访问的数据,删除最终用户硬盘中的文件,把整个程序弄个崩溃。如果你需要防止这种恶意插件,那么你可以考虑创建一个基于Socket(套接字)的解决方案,插件运行在一个独立的进程中并通过一个IPC通道和核心API进行通信。或者,你可以为支持用户脚本安全沙箱的语言实现绑定,如JavaScript或Mono,需要的所有插件都由那个脚本语言来编写。

q静态库对比动态库:正如已经提到过的,把插件定义成静态库是有可能的,这意味着它们必须被编译进应用程序中。用户程序中更加通用的方法是使用动态库,以便用户可以编写他们自己的插件和在运行时扩展程序。编写静态库的一个限制是你必须确保没有哪两个插件定义了相同的符号。也就是说,每个插件的初始化函数必须有唯一的命名,如<PluginName>_PluginInit()。对于动态库插件,你可以为每个插件使用相同的初始化函数名,如PluginInit()。

12.1.3 在C++中实现插件

我已经提到过支持C++插件会比较困难,因为会遇到跨平台和跨编译器的ABI问题。不过,因为本书是关于C++ API设计的,那就让我们花点时间来看看一些更健壮地使用C++插件的解决方案。

首先,如果你要求插件的开发者使用的编译器版本和你构建API时的版本是一样的,那么你就没有什么可担心的。

如果情况不是这样的,一种解决方案是为你的插件使用一种绑定技术。例如,Windows上的像COM一样的IPC方案,或者为你的API创建脚本绑定,让用户使用跨平台的脚本语言来编写扩展(如Python或Ruby,这在上一章讲述过)。

如果你确实需要使用C++插件,这是为了最大化性能或者你觉得创建一个COM或脚本绑定对于你的需求来说太重量级了, 那么仍然有几种在插件中更加安全地使用C++的方法。下面的列表提供了几种最佳的做法,其中的很多实现是由DynObj开源库提供的,你可以在http://www.codeproject.com/找到它。

q使用抽象基类:实现一个抽象基类的虚方法可以隔离插件来自ABIAPI的问题,因为一个虚方法调用常常被表示成一个类的虚表中的一个索引。理论上,编译器之间有不同的虚表格式,不过在实际中往往不会发生。(不过要注意的是:不同的编译器可能给重载虚方法不同的排序,因此最好要避免这些。)接口中的所有方法都需要是纯虚的,不过内联方法也可以被安全地使用,该代码可以直接嵌入到插件中。

q为自由函数使用C链接:插件API中的所有全局函数应该使用C链接来避免C++的ABI问题。也就是说,它们应该使用extern "C"来声明。相似地,为了实现可移植性的最大化,插件传入到核心API中的函数回调也应该使用C链接。

q避免STL和异常:STL类的不同实现,如std::string和std::vector可能和ABI是不兼容的。因此,在核心API和插件API之间的任何函数调用中最好避免使用这些容器。相似地,因为ABI对于异常在跨编译器时也比较不稳定,所以这些也应该在你的插件API中避免使用。

q别混用分配符:对于你的API,插件可能被链接到不同的内存分配符。例如,Windows中在debug构建时常常使用和release构建不同的分配符。这对于我们插件系统的设计意味着插件要么必须分配和释放它的所有对象,要么插件应该传递控制给核心API来创建和销毁所有的对象。然而,你的核心API绝不会释放一个插件分配的对象,反之亦然。

把这些信息全部整合起来,现在我要开发一个灵活和健壮的跨平台C++插件系统。这个插件系统将允许注册新的C++类,同时提供一个或多个工厂方法的核心API。我将继续使用来自第三章的扩展工厂例子并扩充它,允许从插件注册新的Irenderer类,这些插件是在运行时被动态加载的,而不是编译到核心API中。还有,插件架构支持几种不同的存储插件元数据的方式,既有在一个附带的外部文件中,也有在插件本身中。

12.1.4 插件API

插件API是你提供给用户创建插件的接口。在我们的例子中我把它叫做pluginapi.h。这个头文件包含允许插件与核心API通信的功能。

当核心API加载一个插件时,它需要知道要调用哪个函数或访问哪个符号,这是为了让插件执行它的工作。这意味着你应该在插件中具体地定义你的用户必须提供的命名入口点。你可以通过几种不同的方式来完成这个。例如,当编写一个GIMP插件时,你必须定义个叫PLUG_IN_INFO的变量,该变量用来列出插件中定义的各种回调。

[代码 P367 第一段]

网景插件使用一种简单的,不过略微灵活的技术。在这种情况下,插件的作者定义了一个NP_GetEntryPoints()函数并在NPPluginFuncs结构中填充适当的字段,该结构在插件注册期间被浏览器传入。NPPluginFuncs结构包括用来处理将来扩展的大小和版本字段。

另一个解决方案是核心API可以调用具体命名函数,如果它们是由插件导出的话。我会为我们的例子采用这种方法,因为这是比较简单和可伸缩的;例如,它不依赖于一个固定大小的数组或结构。

一个插件应该提供的两个最基本的回调是初始化函数和清理函数。正如之前提醒过的,这些函数应该使用C链接的方式声明,这样可以避免在编译器之间发生名字改编[l8](name mangling 译者注:这是一种编译过程中,将函数、变量的名称重新改编的机制。详见:http://en.wikipedia.org/wiki/Name_mangling)的差异。如果你要开发一个跨平台的插件系统,那么在Windows上也处理好正确使用__declspec(dllexport)和__declspec(dllimport)修饰符的问题。而不要求我们的插件开发者知道这些所有细节,我将提供一些宏来简化这一切。(正如之前提到过的,你应该避免为了声明某些(如API常量)而使用预编译器宏,如API常量;不过,它们是相当有效地影响了像这样的编译时配置。)

还有,我决定我们应该允许插件注册新的Irenderer派生类,因此我将提供一个插件API调用来让插件处理这个。这里是我们的插件API的第一份草稿:

[代码 P367 第二段]

这个头文件为一个插件提供了定义初始化和清理函数的宏:PLUGIN_INIT()和PLUGIN_FREE()。我也提供了PLUGIN_FUNC()宏来让插件为核心API调用导出函数,还有CORE_FUNC()宏也为插件调用导出了核心API的函数。最后我提供的RegisterRenderer()函数,允许插件用核心API注册新的Irenderer类。要注意的是一个插件必须为它们的新Irenderer类同时提供一个初始化函数和一个释放函数,这是用来确保在插件中分配和释放的执行(这是为了解决你不该混用内存分配符的问题)。

你或许也注意到CORE_API和PLUGIN_API定义的使用。这些是让我们在Windows平台下指定正确的DLL导出/导入修饰符。CORE_API是用来修饰函数是核心API的一部分,PLUGIN_API是用来指定插件中定义的函数。这些宏的定义都包含在defines.h头文件中,如下所示:

[代码 P368 第二段]

要注意的是为了这些宏能够正确地工作,你必须使用BUILDING_CORE定义集合构建你的核心API。例如,在Windows上把/DBUILDING_CORE添加到命令行。当编译插件时并不需要这个定义。

最后,为了完整起见,这里给出renderer.h文件的内容,也被包含在pluginapi.h中。

[代码 P368 第三段]

这从本质上说和第三章中给出的定义是一样的,除了我修改LoadScene()方法接收一个const char * const char *参数,代替了std::string(这是为了解决我们关心的在编译器之间STL类的二进制兼容性问题)。

12.1.5 插件例子

现在我已经开发了一个基本的插件API,让我们看看构建于这个API的插件看起来是什么样子的。你需要包含的基本部分有:

(1).新的Irenderer类。

(2).用回调创建和销毁这个类。

(3).一个用核心API注册创建/销毁回调的插件初始化例程。

这里有为这样的插件准备的代码是为这样的插件所准备的。这个插件定义和注册了一个新的叫做“opengl”的渲染器。这是定义在一个新的OpenGLRenderer类中,该类派生自Irenderer抽象基类。

[代码 P369 第二段]

在本例中,我已经定义了一个PLUGIN_INIT()函数,一旦插件加载时就会运行。这就注册了我们的OpenGLRenderer工厂函数:CreateRenderer(),还有相关的析构函数:DestroyRenderer()。这些都是使用PLUGIN_FUNC定义的,用来确保它们都能使用C链接被正确地导出。

RegisterRenderer()函数本质上只调用了在第三章中给出的RendererFactory::RegisterRenderer()方法(此外也能像CreateCallback一样传入一个析构回调)。我向插件API中添加一个显式的注册函数,而不是让插件用RendererFactory直接注册它们自己,其中的原因有两个。一个原因仅仅是给我们一个抽象层,以便你将来能够修改RendererFactory而不会影响现有的插件。另一个原因是避免插件使用STL字符串来调用方法:请注意,RegisterRenderer使用一个const char *来指定渲染器的名称。

12.1.6 插件管理器

现在你已经有了一个插件API,你可以构建这个API的插件了,你需要把那些插件装载和注册到核心API中。这就是插件管理器的功能。特别地,插件管理器需要处理下面的任务。

为所有插件装载元数据。这些元数据既可以存储在独立的文件中(如一个XML文件)或者嵌入到插件本身。对于后一种情况,插件管理器需要为所有的插件装载全部可用的插件并比较元数据。这些元数据让你可以为用户呈现可供使用的插件列表,他们可以从中进行选择。

装载一个动态库到内存中,提供对那个库的符号的访问,如果需要时可以卸载那个库。这在UNIX平台上(包括Mac OS X)包括使用dlopen()、dlclose()和dlsym(),而在Windows上是使用LoadLibrary()、FreeLibrary()和GetProcAddress()。我在附录A中提供这些调用的细节。

当加载插件时,调用插件的初始化例程;当卸载插件时,调用清理例程。这些函数是在插件内的PLUGIN_INIT()和PLUGIN_FREE()中定义的。

因为插件管理器提供了对系统中所有的插件的单一访问点,所以它常常用单例来实现。就设计而言,插件管理器可以认为是插件实例的一个集合,每个插件实例表示一个单独的插件并提供了装载和卸载插件的功能。这里有一个实现插件管理器的例子:

[代码 P370 第一段]

这个设计为所有的插件分离了元数据的访问和装载那些插件的需要。也就是说,如果元数据(如插件的显示名称)存储在一个外部文件中,那么你可以调用PluginManager::GetAllPlugins()而不需要加载实际的插件。然而,如果元数据是存储在插件中,那么GetAllPlugins()只能先调用LoadAll()。下面的例子是一个基于XML语法的外部元数据样例:

[代码 P371 第二段]

不论插件的元数据存储在一个外部文件中,还是嵌入在每个插件中,下面的代码为所有可用的插件输出显示名称:

[代码 P372 第一段]

有个相关的问题是插件发现[l9](plugin discovery)。上述的API并未限制PluginManager::Load()的实现方法通过搜索多个目录来发现所有的插件。传递到这个Load()方法的名称可以是一个不带任何路径或文件扩展名的基础插件名,例如:“glplugin”。接着,Load()方法能够搜索各种目录并寻找平台特定的扩展名,如:Mac OS X上的libglplugin.dylib或Windows上的glplugin.dll。当然,你总是可以引入自己的插件文件名的扩展名。例如,Adobe Illustrator为它的插件使用.aip扩展名,而微软的Excel使用.xll扩展名。

下面的核心API初始化代码注册了一个单独的内建渲染器,接着装载所有的插件,并允许在运行时往系统里添加额外的渲染器:

[代码 P372 第二段]

12.1.7 插件版本

最后要注意的是:我将展开插件版本化这一主题。对于API的版本化,你要确保你的插件系统的第一个发布要包含一个版本化系统。你既可以指定核心API的版本号,也可以引入一个具体的插件API版本号。我建议你选择后者,因为插件API实际上是核心API的一个独立的接口,而两者修改的速率是不一样的。例如,谷歌的安卓API使用API等级的概念(表格12.1)。这里有个整数会随着安卓API的新版本而单调地增加。

[表格 P373 第一张]

[表格 排版 开始]

表格12.1 安卓平台每个版本的安卓API等级

平台版本  API等级

……

[表格 排版 结束]

你要访问的其中一个最重要的信息是构建一个特定的插件的插件API(Plugin API)的版本。这可以让你判断一个插件是否和当前的发布是不兼容的,如果是的话就不应该被注册。例如,如果一个插件是由更高的API版本构建的或者是一个不兼容的比较旧的API。鉴于这些信息的重要性,最好是把这些信息自动嵌入到每个插件中。这可以确保每次总是把正确的版本编译到能够成功通过构建的插件中。有了已经给出的插件API,你可以在PLUGIN_INIT()宏中包含这些信息,因为用户为了让插件实现任何功能就必须调用这个。例如:

[代码 P373 第一段]

此外,用户能够可选地指定和插件共事的API的最低和最高版本。最低版本号是更经常被指定的。例如,如果在API的一个特定的发布中添加了一个新的会被插件使用到的特性会被插件使用到,那么那个发布应该被指定为最低版本。指定最高版本号只在下面的情况下有用:发布一个新版的API后,插件的作者发现它会影响他们的插件。通常,最高版本是未设置的,因为插件的作者会假定将来的API发布是向后兼容的。

这个最小/最大版本号可以在一个外部元数据格式中指定,例如:

[代码 P373 第二段]

或者,你可以利用额外的调用来扩展插件API,这可以让插件在代码中指定这些信息:

[代码 P374 第二段]

12.2 通过继承扩展

到目前为止,本章关注了在运行时通过插件支持API扩展。然而,你的用户为了他们自己的目的还有其它方法来扩展你的API的功能。对于扩展一个类,面向对象的基本机制就是继承。这可以用来让你的用户定义新的类,这个类是构建于你的API的现有类之上的,还可以修改它的功能。

12.2.1 添加功能

Jonathan Wood在微软的Visual C++开发者中心有一个视频,演示了通过继承扩展MFC的Cstring类,用来创建一个添加了一些路径操作函数到基本字符串类的CpathString类。由此生成的类看起来像这样:

[代码 P374 第三段]

这是一个扩展一个现有类的简单例子,只添加了新方法到基类。

这里得重申的要点是:这么做只在基类是设计用来继承的时候才是安全的。这个的主要标志就是看类是否有一个虚析构函数。在CpathString例子中,MFC的Cstring类没有虚析构函数。这意味着会存在CpathString的析构函数不被调用的情况,例如:

[代码 P375 第一段]

在这个特殊的例子中,这没有什么问题,因为所有的新CpathString方法是无状态的。也就是说,通过CpathString析构函数,它们并没有分配任何必须释放的内存。然而,如果你期望用户会从你的任何一个类中继承,那么这个问题就突出了,你应该把那些类的析构函数声明成虚的。

提示

为类声明一个虚析构函数标志着你是为用户继承这个类而这样设计的。

12.2.2 修改功能

除了往一个类中添加新的成员函数外,我们知道:如果在基类中已经把现有的函数标记成虚的,那么C++就允许你在派生类中定义一个重写基类现有函数的函数。[要注意的是:C++允许你重新定义,如在派生类中隐藏一个非虚基类方法,但是你绝不应该这么做(Meyers, 2005)]。如果你让他们这么做的话,那么这将提供给用户更多的途径来自定义类的行为。

例如,Qt库中的所有UI部件(widgets)提供了下面的虚方法:

[代码 P375 第二段]

这允许Qt库的用户创建这些部件的派生类和修改它们的外观。为了演示这个,下面的类继承自标准的Qt按钮部件并重写了用来指定按钮大小的sizeHint()方法。这个结果可以在图12.3中看到。

[图 P375 第一张]

图12.3

一个标准的Qt QpushButton部件(左边),重写了sizeHint()虚方法的QpushButton的派生类(右边)。

[代码 P375 第三段]

这个代码可以正常运行是因为sizeHint()是每个部件已知的一个方法,通过布局类调用来决定部件首选的大小。也就是说,Qt库的设计者往工具包中添加了这个自定义的设计并允许用户通过刻意声明一个虚拟的方法来在他们拥有的派生类中修改它。API允许用户采用这种方式有选择地重写或专注于他们的默认行为,这叫做“框架”,也就是为什么Qt通常被称为一个程序或UI框架的原因。这也是一个模板方法设计模式的例子,有个通用的算法允许一个或多个步骤的重写,用来支持不同的行为。

要特别注意的是:这和在MySquareButton构造函数中通过调用resize()方法来简单地修改部件的大小不是一回事。这个的效果是可以强制设置按钮的大小。然而,sizeHint()表面重点是为UI布局引擎提供了一种首选的大小的指示[l10]。也就是,对于API中的其它类,以便它可以重写,当有必要满足其它部件大小限制时。

不通过一个虚sizeHint()方法也可以实现这个。例如,可以添加非虚setSizeHint()和getSizeHint()方法到部件基类中。然而,这需要基类要在对象中把提示信息存储为一个数据成员,因此会增加从它继承的每个对象的大小。相比之下,为一个类使用sizeHint()虚方法可以支持这种功能来简单地计算每次调用时首选的大小,而不需要把大小存储在对象实例中。

在性能那章中,我建议过你只在你需要它们时才为一个类添加虚方法。那个建议仍然是有效的。在上述的例子中,Qt API的设计者往他们的API中小心地添加了这些虚方法并有意识地为用户扩展他们的类的基础功能提供了一种灵活的方法。

12.2.3 继承和STL

对于C++和标准模板库的编程新手来说,他们常常尝试继承STL容器类,例如:

[代码 P376 第二段]

然而,我已经提到过,你应该只继承一个已经定义了虚析构函数的类。STL容器类并未提供虚析构函数;实际上,它们根本就没有为你提供可供重写的任何虚方法。这是一个明确的指示,这些类不是用来被继承的。尝试这样做会给你的代码和用户的代码带来诡异的和不好调式的资源泄露。因此,一般的规则是你不应该从STL容器类处继承。

做为一种替代的方法,你能够以安全的方式使用组合来往一个STL容器中添加功能。也就是,使用std::string做为一个私有数据成员并提供存取方法,采用简易地包装底层的std::string方法。接着,你就可以往这个类添加你自己的方法。例如:

[代码 P377 第一段]

不过,STL中确实提供了几个为继承设计的类。这些中最明显的一个就是std::exception。这是所有STL异常的基类,包括:bad_alloc、bad_cast、bad_exception、bad_typeid、lock_error、logic_error和runtime_error。你可以非常容易地定义继承自std::exception的异常:

[代码 P377 第二段]

STL通过继承支持扩展的另一部分是iostream(输入输出流)库。这是一个非常强大的、设计良好的和可扩展的API,提供了各种的流抽象。流可以简单地认为是等待处理的一串字节,如标准的cin输入流和cout输出流。你可以通过从一个特殊的流类或从treambuf基类派生来编写自定义的流类。例如,你可以创建自定义的流类来发送和接收来自(到)Web服务器的HTTP数据。

还有一个Boost Iostreams库,可以更容易地处理STL流和流缓存,并为定义流和缓存上的过滤器提供了一个框架。该库带有一个方便的过滤器集合,包括正则表达式过滤器和数据压缩方案,如zlib、gzip和bzip2。

12.2.4 继承和枚举

用户有时常会扩展一个你定义自基类的一个枚举。例如,为已经添加到他们的派生类的新特性添加另外的枚举。这在C++中可以很容易地实现,如下所示:

[代码 P378 第一段]

这里有个例子演示了如何使用这个扩展枚举:

[代码 P378 第二段]

让这能够以健壮的方式运行的关键是Base定义了SHAPE_END,以便Derived类在Base::SHAPE类中定义的最后的值后面添加新的值。因此,当在类中定义了枚举而你又希望可以被你的用户所继承,这对你来说是一种良好的实现方式。没有这个的话,你的用户可以挑选一个任意大的整数来为他们的枚举编号。例如,OVAL=100,不过这种解决方案显得没那么优雅了。

提示

对于一个基类中的枚举,在枚举中给最后的值添加一个枚举值,如:<enum-name>_END。

12.2.5 访问者模式

在第三章中给出过各种类别的设计模式。然而,我把访问者模式的讨论推迟到现在,因为它是特别针对API扩展性的。访问者模式的核心目标是允许用户遍历一个数据结构中的所有对象和对每个对象执行给定的操作。这种模式本质上是模拟往现有的类中添加新的虚方法的一种方式。因此,它为用户提供了一种有用的模式,扩展了API的功能。例如,访问者模式可以用来让用户提供操作场景图的层级的每个节点(下面会给出一个这样的例子)或遍历一种编程语言解析器的派生树的一个方法集合,并输出人们可读的程序格式。

让我们开发一个访问者模式的例子来说明这种模式是如何运作的。我将使用一个场景图的层级的例子,用来描述一个3D场景,例如被Open Inventor(Open Inventor是SGI公司开发的基于OpenGL的面向对象三维图形软件开发包)、OpenSceneGraph(OpenSceneGraph是一款高性能的3D图形开发库)或Virtual Reality Modeling Language(虚拟现实建模语言)所使用。为了让例子比较简单,我们的场景图只包含三种不同的节点类型:Shape、Transform和Light。图12.4显示的是使用这些节点类型的层级的例子。

开始我先定义我们的抽象访问者接口。用户就能够创建这个接口的具体子类,这样就可以往场景图中添加自定义的操作。这从本质上就是为场景图中的每个节点类型声明一个Visit()方法。

[图 P379 第一张]

图12.4

本例的场景图层级显示了不同类型的节点

[代码 P379 第一段]

现在让我们看一看场景图的API。这提供了对每个节点类型的声明,还有SceneGraph类的框架。每个节点类型都继承自一个父节点类型,叫做BaseNode。

[代码 P380 第一段]

要注意到的是每个节点类型都声明了一个Accept()方法,接收一个访问者对象做为它的参数。这个方法用来调用访问者类中的适当的Visit()方法。这可以被认为是在每个节点中拥有一个单独的虚方法的一种方式,接着就可以调用任何用户提供的虚方法。请看图12.5采用UML图显示了这种访问者模式。

[图 P381 第一张]

图12.5

访问者设计模式的UML图

[代码 P381 第二段]

当构建好这些基础结构后,SceneGraph::Traverse()方法就可以由导航场景图的层级所实现,接着为图中的每个节点调用Accept()方法。这样你的用户就可以在场景图上定义自定义的访问者类来执行任意的操作。完成这些并不会暴露公开如何实现场景图的任何细节。例如,下面的代码演示了用户如何编写访问者来计算场景图中的每个节点类型的数量,还为场景图中的所有形状节点归纳出多边形的数量:

[代码 P382 第二段]

本例演示了很多访问者模式的好处,与扩展性这一主题相关的最大好处就是用户可以有效地往你的类层级中插入他们自己的方法。然而,其它的好处包括所有代码的托管,执行一个单独连贯的操作。例如,在本例中实现节点计算功能的所有代码都被包含在单独的MyVisitor类中,而不是分布在所有独立的节点类中。有个更进一步的好处是需要计算的各种节点和多边形(mNumShapes、mNumPolygons、mNumTransforms和mNumLights)数量的状态可以被隔离到MyVisitor类中,而不是直接存储在SceneGraph对象中,这样会增加对象的大小。

然而,访问者模式也有一些重要的缺点。能够往相关的类集合中添加新方法的灵活性也是有代价的,就是要添加新的相关类变得更加的困难。请注意:在visitor.h中,访问者接口必须知道能够访问的每个类,也就是,所有的节点类型。因此,添加一个新的节点到我们的场景图中将也需要更新访问者接口。因此,访问者模式最适用的情况就是类层级是稳定的(Alexandrescu, 2001)。

为了解决这个问题,让我们考虑一下添加一个新的叫做CameraNode的节点类型到我们的场景图中。采用这种缺乏经验的做法会往接收一个CameraNode引用的InodeVisitor接口中添加另一个Visit()纯虚方法。然而,我们知道:就API的向后兼容性而言,往一个接口中添加一个纯虚方法是一件不好的事情,因为这会影响所有现有用户的代码。做为代替,有很多种可供选择的方式来解决这个问题。

从长远考虑,你可以为BaseNode发布带有一个Visit()纯虚方法的InodeVisitor的第一个版本。这会有效地变成一个捕获一切的方法[l11],这个方法如果遇到一个节点类型没有一个显式的Visit()方法((BaseNode:: Accept()也需要修改,因此它不再是一个纯虚方法)时就会被调用。这种不优雅的结果是用户必须使用在这个捕获一切的方法中的一系列的dynamic_cast调用来计算出传入的是哪个节点。采用这种方案要把访问者接口修改成下面那样:

[代码 P383 第二段]

有个更好的方案是为新的节点类型添加一个新的Visit()虚方法,用来取代一个纯虚方法。也就是,你为新的方法提供一个空的实现,以便现有的代码能够继续通过编译,同时仍然允许用户为新的节点类型实现一个适当的类型安全的Visit()方法。这会把InodeVisitor接口修改成下面那样:

[代码 P384 第二段]

12.2.6 禁止子类继承划分

通过继承扩展的最后一个注意点是:我要解决的情况是你要禁止用户从你提供给他们的类中继承派生类。在Java中,你可以把一个类声明成final就可以阻止其它类从它那里继承了,但是C++中没有一个相似的概念。

正如已经提到过的,如果你声明一个带有非虚析构函数的类,那么这对一个优秀的程序员来说就等于告诉他:当想要继承这个类时要三思而后行。然而,如果你要用一种物理的机制来阻止用户继承你的类,那么最简单的方法就是把类中所有的构造函数都设置成私有的。任何从这个类继承的尝试都会生成一个编译错误,因为派生类的构造函数无法调用你这个类的构造函数。接着,你可以提供一个工厂方法来让用户创建对象的实例。

[代码 P384 第三段]

这种方法的缺点是NonBase的实例无法在堆栈上创建。你的用户必须总是要替代使用NonBase::Create()静态函数来分配NonBase的实例。如果这是不能令人满意的,还有另一种方案。你可以依赖虚继承来确保没有具体的类继承自NonBase,如下所示(Cline et al等,1998):

[代码 P385 第二段]

12.3 通过模板扩展

C++也常常叫做多范例(multiparadigm)语言,因为它支持不同的编程风格,如:过程式、面向对象和泛型。继承是使用面向对象概念来扩展类的主要方式。然而,当利用模板编程时,扩展一个接口的默认方式是使用具体的类型来具体特殊化一个模板。

例如,STL提供了各种容器类,如std::vector和std::set。你可以使用这些容器类来创建包含任意数据类型的数据结构,如std::vector<MyCustomClass *>,STL的设计者在他们设计这个库时并不知道MyCustomClass这个数据类型。

相似地,Boost有提供引用计数指针的功能,可以容纳任何指针类型而不需要求助于void*。这提供了一种强大和通用的措施,可以由用户为任何对象自定义创建类型安全的共享指针,如:boost::shared_ptr<MyCustomClass>。

因此,模板为你提供了一种很好的方式,用来编写可应用于很多不同类型的扩展代码,包括你的用户在他们自己的代码中定义的类型。下面几个章节讲述的是基于原则[l12]的模板概念,帮助你最大化你的类模板的灵活性,我也会探讨一个奇怪的通用模板模式,就是静态多态,做为面向对象编程的动态多态的一种替代。

      12.3.1 基于原则的模板

Andrei Alexandrescu在他的书中推广了基于原则的模板(policy-based templates)的用法(Alexandrescu, 2001)。这个术语是指从更小的类里构建出复杂行为的方法[l13],这些类叫做原则类(或策略类),其中的每个类为整个组件的一个单独的部分定义了接口。这个概念是使用接收几个模板参数(常常是模板参数的类型也是一个模板[l14])的类模板实现的,类的实例化遵守每个原则的接口。通过插入不同的原则类,你可以生成大量的具体类。

例如,Alexandrescu给出了下面的设计,一个智能指针类模板接收几个几个原则类来自定义它的行为(Alexandrescu, 2001):

[代码 P386 第一段]

SmartPtr指向的类型是由模板参数T表示的。其余的参数为智能指针指定各种原则或行为。这些可以由类来实例化,遵守每个参数定义的一个接口并为智能指针提供了可选的实现。下面是每个参数目的的概述。

qOwnershipPolicy:为智能指针指定所有权模型(ownership model)。预定义的原则类包括RefCounted、DeepCopy和and NoCopy。

qConversionPolicy:决定是否允许对象指向的类型的隐式转换。两个可供使用的类是:AllowConversion和DisallowConversion。

qCheckingPolicy:指定错误检查策略。这个参数的预定义原则类包括AssertCheck、RejectNull和NoCheck。

qStoragePolicy:定义指向的对象是如何被存储和访问的,包括DefaultSPStorage、ArrayStorage和HeapStorage。

基于原则的设计认识到在计算机科学中对于每个问题解决方案是存在多样性的。使用这些通用的组件意味着用户可以在成千上万的方案中做出选择,只需要在编译时提供原则类的不同组合。

创建你自己的基于原则的模板的第一步是把类分解成正交的(独立运作的)部分。凡是可以通过多种方式完成的就叫做候选者,可以分解成一个原则。互相依赖的原则也是候选者,可供进一步的分解或重新设计。当然,这里并没有什么新的玩意。优秀的软件工程的精髓就是对可实现的具体问题能够做出更为通用和灵活的抽象。

采用极端的操作,一个宿主类(常常叫做基于原则的模板类)成为一个外壳(shell),该壳装配生成聚集行为的原则的集合。然而,Alexandrescu强调对于任何给定的宿主类,你应该尝试控制原则类的数量,要注意在处理超过4个到6个模板参数时就会变得比较不好操作。这关系到我们大脑认知的限制,研究认为(这里指参数)数量的限制是7±2(Miller,1956)。

对于用来解决给定任务的原则类,为其特定的组合提供类型定义也是非常有用的。例如,如果你的API使用非默认策略传送智能指针,一直指定所有的那些参数将是枯燥单调的,如果在将来修改那些原则就需要你的用户相应地更新他们所有的代码。做为替代,你可以为特定的指针类型引入一个类型定义,例如:

[代码 P387 第一段]

12.3.2 奇特的递归模板模式

在这最后的章节中将涵盖通过模板实现扩展性的主题,我给出一个有趣的C++惯用法,这是由James Coplien在早期的模板代码中首先观察到的(Coplien, 1995),这可能对你的API设计是有帮助的。奇特的递归模板模式(Curiously Recurring Template Pattern,CRTP)包含一个模板类,该类继承自使用它自己做为模板参数的基类。换种说法(或许最后一句更清晰),基类的模板化所用的类型就是它的派生类。由此提供的引人入胜的特性就是基类能够访问它的派生类的名空间。这种模式的一般形式如下所示:

[代码 P387 第二段]

CRTP的本质其实就是一种提供编译时多态的方法。也就是,它允许你从基类继承一个接口,而在运行时却可以避免虚方法调用的开销。因此,它可以被认为是一个“混入”类,也就是一个带有实现方法的接口类。

做为这种模式的实际例子,CRTP可以用来跟踪统计一个模板的每次具体化。例如,你可以用它来跟踪一个给定类型的所有现有对象的计数或一个给定类型的所有现有对象占用的总内存量。我将演示一下后者。

下面的类为我们的内存跟踪接口提供了一个基类声明:

[代码 P387 第三段]

出于完整性,我也提供一下这个基类的相关定义:

[代码 P388 第二段]

这里直接在注释标记([*])后的代码行是比较有技巧的。这里的基类可以访问派生类的细节,在这个例子中是派生类的大小。不过,在不同的例子中,它可以仅仅是简单地 调用派生类中的一个方法。

使用CRTP,现在就可以知道从MemoryTracker类派生的某个类当前所消耗的内存。这甚至可以用来跟踪单个模板具体化的内存使用量,例子如下所示。所有的这些派生类本质上都是从早先描述过的基类中继承了BytesUsed()方法,不过重要的是这个方法是在编译时绑定的,而不是在运行时。

[代码 P389 第一段]

这个代码将输出8,4和12,假设是在一个32位的系统中,sizeof(MyClass1) ==sizeof(MyClass2) == 4字节。也就是,有两个MyClass1<char>(8字节)实例,一个MyClass1<wchar_t>(4字节)实例和三个MyClass2(12字节)实例。

 

 the Marionette animation system at Pixar supported key-frame

animation with a range of possible interpolation schemes between animation keys

 Greater versatility 意译

 Community catalyst 意译

 Future proofing

 allowing the API to maintain its usefulness and relevance for a

greater period of time. 意译

 you live in the same

world as your users (“eat your own dog food”). 意译

 意译,参考技术资料维基百科-历史段落http://en.wikipedia.org/wiki/NPAPI

 these functions should be declared with C linkage to avoid name mangling

differences between compilers

 A related issue is that of plugin discovery

 the point of sizeHint() is to provide an indication of the preferred

size to the UI layout engine

 This will effectively become a catch-all method

 Policy-Based 采用维基百科的翻译

 The term refers to the approach of building complex behaviors out of smaller classes

 http://zh.wikipedia.org/zh/%E5%9F%BA%E6%96%BC%E5%8E%9F%E5%89%87%E8%A8%AD%E8%A8%88

Power by  YOZOSOFT
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值