C++ API 设计 10 第五章 设计风格

第五章 设计风格

上个章节处理的问题是品质对设计高质量API的影响和如何设计一个拥有这些品质的良好API。我通过特定的C++例子讲解了这些概念,设计API的抽象过程是和编程语言无关的。不过,在接下来的几个章节中,我将开始关注着重于更多API设计中关于C++方面的内容。

本章涵盖的主题是关于API风格的内容。本文中的风格是指你是如何表示API的功能的。也就是说,API通过所提供的访问内部状态和例程,用来执行需要的功能,不过调用这些功能行为是采用什么样的形式呢?这个问题的答案或许是显而易见的:在API中,你是通过创建类来表示每个关键对象,并给那些类编写方法。不过,你还可以采用其它的风格,面向对象的设计风格并不会在所有情况下都适用。本章准备讲述四种完全不同的API风格。

(1).纯C API:这些API可以由C编译器来编译。它们仅仅包含一组自由函数,用来支持数据结构和常量。因为这种接口的风格不包含对象或继承,所以它才被叫做“纯的”。

(2).面向对象C++ API:作为一个C++程序员,这是你最熟悉的风格了。它包含对象的使用,相关的数据和方法,还有一些应用到的概念:继承、封装和多态等。

(3).基于模板的API:通过模板功能,C++也支持泛型编程(generic programming)和元程序设计(metaprogramming)。这允许函数和数据结构采用泛型类型,可以在稍后指定具体的数据类型。

(4).数据驱动API:这种接口类型是发送命名命令( named commands)到一个句柄,带有通过巧妙的数据结构封装好的参数,而不是调用特定方法或自由函数。

我将按照顺序讲解这些API风格,并会对比这些风格所使用的情况。本章中我将采用FMOD API作为例子来说明前面提到的三种风格。FMOD是一个商业库,用来创建和回放音频,它被很多游戏公司所采用,如Activision、Blizzard、 Ubisoft和Microsoft。它提供了纯C API、C++ API和数据驱动API来访问它的核心音频功能。它给出了本章所涵盖的大部分API风格的对照。

5.1 纯C API

术语纯C API的意思是:C语言不支持封装对象和继承层次结构的概念。因此,使用纯C语法的API受到更多语言特性的限制,如类型定义、结构和在全局名空间中的函数调用。因为在C中没有namespace关键字,所以采用这种风格的API必须在所有的public函数和数据结构之前使用一个前缀,以避免和其它C库发生名称冲突。

当然,你仍然可以使用内部链接连接(Lakos, 1996)来隐藏实现中的符号名称,例如把它们声明成静态的,是.cpp文件中的文件范围级别。通过这种方式,你可以保证任何这样的函数都不会对外导出,这样也就不会另一个库产生名称冲突了。(虽然C++可以使用首选偏爱的匿名名空间来达到相同的结果,但是这也可以应用到C++程序中去。我会在下章讲述这方面的内容。)

今天还有很多流行的C API的例子,如下所示:

q标准C库:如果你正在编写一个C程序,那么你一定对标准C库很熟悉。这由很多文件组成(如stdio.h、stdlib.h和string.h),还包括各种库例程:I/O、字符串处理、内存管理、数学操作等(还有printf()、malloc()、floor()和strcpy())。绝大部分C和许多C++程序都是构建在这个库之上的。

qWindows API:常常也叫做Win32 API,这是开发基于微软 Windows范围操作系统程序所使用的核心接口集。它所包含的API分组成多个类别,例如基于服务的,图形设备接口(GDI)、通用对话框库和网络服务。另一个库叫做微软基础类(Microsoft Foundation Class,MFC),提供了对Windows API的C++封装。

qLinux核心API:整个Linux核心都是用纯C编写的。这包括Linux核心API,为底层软件提供稳定可靠的接口,如用来访问操作系统功能的设备驱动。该API包括驱动函数、数据类型、基础C库函数、内存管理操作、线程和进程函数和网络函数等。

qGNOME Glib:这是一个通用的开源工具库,包含很多用于编程的有用底层例程。其中包括:字符串处理程序、文件访问、数据结构(如树、哈希表和列表)和一个主循环抽象(main loop abstraction)。该库还为GNOME桌面环境提供基础功能,曾经是GIMP工具包(GTK+)的一部分。

qNetscape可移植运行时(Netscape Portable Runtime,NSPR):NPSR库为底层功能提供跨平台的API,如线程、文件I/O、网络访问、间隔计时、内存管理和共享库链接。它用于各种Mozilla程序的核心,包括火狐浏览器和Thunderbird电子邮件客户端。

q图形库:绝大多数开源图形库都可以帮助你给应用程序添加对各种图形文件格式的支持,这些库都完全用C编写的。例如:libtiff、libpng、libz、libungif和jpeg库都是纯C API。

5.1.1 ANSI C 属性

如果你曾经编写过C++ API,那么当你编写一个纯C API时就会遇到很多语言特性没有了。例如,C不支持类、引用、模板、STL、默认参数、访问级别(public、private和protected)或布尔类型。C API只支持如下的组合:

(1).内建类型,如:int、float、double、char和数组,还有指向这些的指针。

(2).通过typedef和enum关键字创建的自定义类型。

(3).使用struct或union关键字声明的自定义结构。

(4).全局自由函数。

(5).预处理器指令,如#define。

事实上,完整的C语言关键字很。这里给出完整的列表:

qauto:定义一个生命周期在局部范围的局部变量。

qbreak:跳出while、do、for或者switch语句。

qcase:定义在switch语句中的一个分支点。

qchar:字符数据类型。

qconst:声明一个变量值或指针参数是不可不允许被改变的。

qcontinue:回到while、do或for语句开头执行。

qdefault:定义switch语句的后备分支点。

qdo:开始一个do-while循环。

qdouble:双精度浮点数据类型。

qelse:声明一个当if语句解析为false时执行的语句。

qenum:定一个一组整型常量。

qextern:引入一个定义在其它地方的标识名称。

qfloat:单精度浮点类型。

qfor:定义一个for循环。

qgoto:代码执行跳转到标签行。

qif:提供有条件地执行一个语句序列。

qint:整数数据类型。

qlong:扩展内建数据类型的大小。

qregister:指示编译器把一个变量存储到CPU的寄存器中。

qreturn:退出函数并返回一个可选的值。

qshort:缩小内建数据类型的大小。

qsigned:声明一个可以处理负数复数值的数据类型。

qsizeof:返回一个类型或表达式的大小。

qstatic:当一个变量出了值域后仍然可以保留它的值。

qstruct:允许把多个变量组合成一个单一的类型。

qswitch:控制一个可能执行的语句分支列表。

qtypedef:通过现有的类型来创建一个新的类型。

qunion:组织多个变量共享相同的内存位置。

qunsigned:声明一个只处理正数值的数据类型。

qvoid:空数据类型。

qvolatile:指示一个变量可以被外部线程修改。

qwhile:定义一个当条件计算为false时退出的循环。

虽然C并不是C++严格意义上的子集,但是编写的好的ANSI C程序会倾向于也是合法的C++程序。一般说来,C++编译器比C编译器施加更为严格的类型检查。当你正在编写一个纯C API,通常值得用C++编译器来编译代码,接着可以修复出现的额外的警告或错误。

[底纹段落排版 P154 开始]

强类型检查可以节约精力

我记得有一次在我职业生涯的早期,我那时在SRI国际公司当经理。Yvan Leclerc遇到一个C程序的崩溃bug。他花了一天时间来追踪错误,最后我们一起逐行检查代码。

在消耗了很多精力后,我们终于发现他使用了calloc()函数,但是只传递了单个参数。你可能已经回想起第二章,malloc()函数接收一个参数,而calloc() 函数要接收接受两个参数。

接着他用malloc()来代替calloc(),来返回一个内存初始化块,在函数调用时忘记修改参数。结果,返回的内存块的尺寸不是他想要的。这在C中不会提示一个错误(即使是在今天,绝大多数C编译器也只会给出一个警告),但是在C++中会给出一个编译错误。使用C++编译器来编译C代码可能会立即出现潜在的问题。

[排版底纹段落 P154 结束]

提示

通过C++编译器来编译C API可以进行强类型检查,可以确保C++程序也可以使用你的API。

5.1.2 ANSI C API的好处

使用C来编写API的一个重要原因就是有个现有的项目完全是由纯C编写的。不过这种情况是越来越少了,因为现在大部分项目从底层开始都是用C++编写的,但是有时也会遇到用户对你设计的API有这方面的限制。这种情况的例子在先前罗列过的现有的大型C API中提到过。例如,如果你要和Linux核心API打交道,那么你就需要用C来编写接口。

首选偏好使用纯C来编写API的另一个原因是二进制兼容性。如果你需要在各个发布的API库中维持二进制兼容性,那么实现起来,使用纯C编写的API要比使用C++来得容易的多。我会在版本控制章节讨论这方面的内容,不过现在先说些皮毛的:对C++ API做些看起来并不起眼的修改都会对结果对象和库文件造成较大的二进制兼容性影响,用户就不能通过简单地替换共享库,在不需要重新编译他们的代码情况下让代码继续正常工作。

当然,没有什么可以阻止你开发可以同时在C和C++上运行的代码。事实上,或许你更喜欢使用C++来创建API,因为这样就可以利用C++面向对象的特性,但是也可以在C项目中给这个接口做一个纯C的包装,或者暴露一个简单API和低表面积(low surface area)版本的API来更容易地易于强制二进制兼容性。FMOD API就是这样一个例子,它同时提供了C++和C版本的API。

5.1.3 使用ANSI C编写API

C语言没有提供对类的支持,因此你不能使用带有操作数据的方法的对象来封装数据。你可以使用结构(或联合体)来代替,它们包含数据并可以当成参数传入到操作那些数据的函数中。例如,考虑下面的C++类定义:

[代码 P155 第一段]class Stack

{

public:

void Push(int val);

int Pop();

bool IsEmpty() const;

private:

int *mStack;

int mCurSize;

};

 

下面再来看看采用纯C API:

[代码 P155 第二段 勘误 修改 Stack * 为 struct  Stack *[l1]]

struct Stack

{

int *mStack;

int mCurSize;

};

void StackPush(Stack *stack, int val);

int StackPop(Stack *stack);

bool StackIsEmpty(const Stack *stack);

请注意:每个C函数关联的堆栈都必须接收一个Stack数据结构的参数,通常做为第一个参数。还要注意的是:函数的名称通常应该包含它要操作的数据的说明,这个名称并不在C++类声明范围内。在这种情况下,我选择给每个函数添加一个前缀“Stack”来表示函数是在操作Stack数据类型。这个例子可以进一步通过使用一个透明指针来隐藏私有数据,例如:

[代码 P155 第三段]

typedef struct Stack *StackPtr;

void StackPush(StackPtr stack, int val);

int StackPop(StackPtr stack);

bool StackIsEmpty(const StackPtr stack);

还有,C也不支持构造函数和析构函数的概念。因此,任何结构必须由用户显式地初始化和销毁。这通常是这么做的:通过API调用来创建和销毁一个数据结构:

[代码 P155 第四段]

StackPtr StackCreate();

void StackDestroy(StackPtr stack);

现在,我已经对比了C和C++ API在同一个任务上的应用,接着让我们看一看用户要使用这两种风格的API需要编写什么样的代码。首先,这里有一个使用C++ API的例子:

[代码 P156 第一段]

Stack *stack = new Stack();

if (stack)

{

stack- >Push(10);

stack- >Push(3);

while (! stack->IsEmpty())

{

stack- >Pop();

}

delete stack;

}

再看看利用C API执行同样的操作所需要的代码:

[代码 P156 第二段]

StackPtr stack= StackCreate();

if (stack)

{

StackPush(stack, 10);

StackPush(stack, 3);

while (! StackIsEmpty(stack))

{

StackPop(stack);

}

StackDestroy(stack);

}

5.1.4 从C++调用C函数

C++编译器也能编译C代码,即使你是在编写C API,或许你也允许C++用户来使用你设计的API。这是一个相对容易的工作,在你发布一个C API时,我建议你采取如下几个步骤。

第一个步骤是确保你的代码是通过C++编译器编译的。前面已经讲过,因为C标准会更加随意些,C编译器比起C++编译器更容易通过草率的代码。

做为这个过程的一部分,你也要保证在你代码中没有使用任何的C++保留字。例如,下面的代码在C中是合法的,但是在C++中却会报错,因为class是C++的保留字:

[代码 P156 第三段]

enum class {RED, GREEN, BLUE};

最后,C函数对C++函数有不同的链接。也就是说,同一个函数通过C和C++编译器编译后会生成对象文件中不同的表示方式。其中一个原因是C++支持函数重载:声明同名的方法,但是有不同的参数或返回值。因此,C++函数的名称在符号名称中会加入额外的编码信息,比如每个参数的数量和类型。因为这个链接的不同,这样的代码无法通过C++编译器的编译,使用一个函数,叫做DoAction(),接着链接到一个定义了DoAction()函数的库的C编译器。

为了解决这个问题,你必须在extern “C”构造函数中包装C API,告知C++编译器包含的函数应该使用C风格的链接。C编译器无法解析这个语句,因此最好只用C++编译器来有条件地编译它。下面的代码片段演示了这种最好的实践方式:

[代码 P157 第一段]

#ifdef __cplusplus

extern "C" {

#endif

// your C API declarations

#ifdef __cplusplus

}

#endif

 

提示

在C API的头部使用extern "C"extern “C”范围,这样C++编译器就可以正确地编译和链接你的API。

5.1.5 用例学习:FMOD C API

下面的源代码是一个小程序,利用FMOD C API来播放一个声音样例。这个现实中的例子是利用纯C API。请注意函数命名约定的用法,用来创建名空间的多个层,所有的函数都是用FMOD_打头,所有的系统级调用都是用FMOD_System_打头。还要注意的是:为了增加可读性,本例没有执行任何错误检查。显然,在现实中的程序会检查每个程序在没有错误下完成调用。

[代码 P157 第二段]

#include "fmod.h"

int main(int argc, char *argv[])

{

FMOD_SYSTEM *system;

FMOD_SOUND *sound;

FMOD_CHANNEL *channel = 0;

unsigned int version;

// Initialize FMOD

FMOD_System_Create(&system);

FMOD_System_GetVersion(system, &version);

if (version < FMOD_VERSION)

{

printf("Error! FMOD version %08x required\n", FMOD_VERSION);

exit(0);

}

FMOD_System_Init(system, 32, FMOD_INIT_NORMAL, NULL);

// Load and play a sound sample

FMOD_System_CreateSound(system, "sound.wav", FMOD_SOFTWARE, 0, &sound);

FMOD_System_PlaySound(system, FMOD_CHANNEL_FREE, sound, 0, &channel);

// Main loop

while (! UserPressedEscKey())

{

FMOD_System_Update(system);

NanoSleep(10);

}

// Shut down

FMOD_Sound_Release(sound);

FMOD_System_Close(system);

FMOD_System_Release(system);

return 0;

}

5.2 面向对象C++API

当你考虑使用C++来编写API,你应该会想到面向对象设计。面向对象是一种编程风格,操作的数据和函数都被一起打包成一个对象。面向对象编程可以追溯到20世纪60年代的Simula和Smalltalk语言,直到上个世纪90年代才成为主导的编程模型,是由C++和Java引入的。

在上一个章节我已经涵盖了许多OOP的关键技术,这里我就不再花太多时间在那上面了。特别的,我重提一下上个部分关于类设计的环节,有各种OOP术语,如:类、对象、继承、组合、封装和多态。

应该指出的需要注意的特性,如:方法和运算符重载、默认参数、模板、异常和名空间从严格意义上说并不是面向对象的概念,虽然它们是C++语言中包含的新特性,并不属于原始的C语言。除了OOP,C++还支持几种编程模型,如过程式编程(上个部分提到过)、泛型编程(接下来会讲到)和函数式编程。

5.2.1 面向对象API优点

使用面向对象API的主要好处就是能够使用类和继承,也就是说,以相互关联的数据来搭建软件模型,而不是以过程的集合。这可以提供概念和技术上的优势。

从概念上的优势来说,你要使用代码构建物件和过程的模型都可以描述成对象。例如,地址薄通讯录就是一个我们都熟悉的物件,它包含几个人的描述,这又是一个概念性的单位,任何人都可以容易地和其关联。因此,面向对象编程的核心任务就是识别给定问题的关键对象,并确定它们是如何相互关联的。很多工程师都认为,除了所有的动作集合必须执行,有更合乎逻辑的方式来处理软件设计(Booch等,2007)。

从技术上优势来说,使用对象提供了一种方式为某处的一个概念单元来封装所有的数据和方法。从本质上来说,就是为所有相关的方法和变量创建一个唯一的名空间。例如,我们早先所有的C++Stack方法存在于Stack名空间,如:Stack::Push()和Stack::Pop()。对象也提供了public、protected和private访问级别的概念,这对API设计来说是十分重要的概念。

提示

面向对象API让你以对象代替动作的方式来构造软件模型。它们也提供了继承和封装方面的优势。

5.2.2 面向对象API的缺点

然而,使用面向对象概念也有不利的方面。其中很多是因为滥用强大的面向对象技术。第一个是添加继承到对象模型,会引入复杂度和微妙度,这并非所有的工程师都可以充分理解。例如,要知道基类的析构函数通常必须标注为虚的或者子类中的重写方法会隐藏基类中所有同名的方法。

而且,因为接口分布在多个头文件中,要查看由一个对象提供的深层继承(deep inheritance)层次的完整接口将是一个挑战(当然,好的文档工具,如Doxygen可以减轻这个需要特别关注的地方)。还有,一些工程师会滥用、误用OOP的概念,如在没有意义的场合(对象并形成“is-a”关系)使用继承。这样会造成做作且不易了解的设计会导致设计变得不自然和不易理解并且难以利用。

最后,使用面向对象的C++概念来创建一个二进制兼容的API是一件特别困难的任务。如果二进制兼容性是你的目标,或许你应该选择本章描述的其它API风格,如纯C API或数据驱动API。

5.2.3 用例学习:FMOD C++ API

下面的源代码和早些的部分是描述相同的问题,除了这个例子通过使用FMOD C++ API来代替C API。要注意到现在可以使用C++的名空间特性,所有的类和函数都在FMOD名空间下。还要注意到的是API的包含文件和C API有相同的基址名(base name),除了它使用.hpp扩展名来指出那是一个C++头文件。再有,这里为了让代码更易读而忽略了错误检查。

[代码 P160 第一段]

#include "fmod.hpp"

int main(int argc, char *argv[])

{

FMOD::System *system;

FMOD::Sound *sound;

FMOD::Channel *channel = 0;

unsigned int version;

// Initialize FMOD

FMOD::System_Create(&system);

system->getVersion(&version);

if (version < FMOD_VERSION)

{

printf("Error! FMOD version %08x required\n", FMOD_VERSION);

exit(0);

}

system->init(32, FMOD_INIT_NORMAL, NULL);

// Load and play a sound sample

system->createSound("sound.wav", FMOD_SOFTWARE, 0, &sound);

system->playSound(FMOD_CHANNEL_FREE, sound, 0, &channel);

// Main loop

while (! UserPressedEscKey())

{

system->update();

NanoSleep(10);

}

// Shut down

sound- >release();

system->close();

system->release();

return 0;

}

5.3 基于模板API

模板是C++的一个特性,允许你以泛型(未指定类型的)类型方式编写函数或类。你可以在这些模板实例化后再具体指定特定的类型。因此,模板编程也常常叫做泛型编程。

模板是非常强大和灵活的工具。它们可以在运行时生成代码或执行代码。这可以用来完成给人深刻印象的例子,如解开循环(unrolling loops)、计算数学序列的值、在编译时生成查询表和根据预定的递归次数展开递归函数。就这些而论,模板可以用来在编译时执行任务并提高运行时的性能。

然而,本书的关注点不是模板编程。市面上已经有很多不错的书是讲解这些内容的(Alexandrescu 2001;Vandevoorde 和 Josuttis 2002)。我们这里把注意力放在API设计中的模板用法。在这点上,有几个精心设计的基于模板的例子,你可以参考和借鉴一下:

q标准模板库(STL):你所熟悉的所有STL容器类,如:std::set、std::map和std::vector都是类模板,这就是为什么它们可以用来持有不同类型的数据。

qBoost:这些库提供了强大且有用的功能套件,其中的许多都会包含在新的C++0x标准中。绝大多数Boost类都有使用模板,如:boost::shared_ptr、boost::function和boost::static_pointer_cast。

qLoki:这是Andrei Alexandrescu编写的类模板库,用来支持他写的《现代C++设计》(modern C++ design)一书。它提供了各种设计模式的实现,如访问者、单态和抽象工厂。这种优雅的代码为基于模板的API设计提供了非常好的范本。

虽然模板在C++中也常常和面向对象技术一起用,但是有个值得注意地方就是这两者是完全正交的(orthogonal 译者注:此处指两个概念互不影响)概念。模板可以等同于自由函数一样使用,还有结构和联合体(当然,你可能已经知道:在C++中,结构从功能上和类是相同的,除了它们默认的访问级别)。

5.3.1 基于模板API的例子

继续我们那个堆栈的例子,让我们来看看如何使用模板来创建一个泛型堆栈声明,接着用整型来初始化。你可以使用泛型类型T来定义基于模板的堆栈类,如下所示:

[代码 P161 第一段]

#include <vector >

template <typename T >

class Stack

{

public:

void Push(T val);

T Pop();

bool IsEmpty() const;

private:

std::vector <T > mStack;

};

请注意:为了保持例子的简洁,我已经省略了方法定义。在稍后的C++用法章节中,我会讲述模板定义的最佳实践方式。还有值得注意的地方是:名称T并没有什么特殊的。只是大家习惯使用名称T来表示泛型类型,如果你喜欢的话,也可以使用MyGenericType来代替。

有了这个泛型堆栈声明,接着你就可以创建一个Stack<int>类型对象,使用int类型来初始化模板。这样就会促使编译器为这个特定的类型实例生成代码。你也可以定义一个简单的typedef来更方便访问模板的实例,例如:

[代码 P162 第一段]

typedef Stack<int > IntStack;

接着,IntStack类型就可以像显式编写过这个类一样使用。例如:

[代码 P162 第二段]

IntStack *stack = new IntStack();

if (stack)

{

stack- >Push(10);

stack- >Push(3);

while (! stack->IsEmpty())

{

stack- >Pop();

}

delete stack;

}

5.3.2 模板和宏

模板的另一个用处是使用C预处理器定义文本块,可供头文件多次使用,例如:

[代码 P162 第三段]

#include <vector >

#define DECLARE_STACK(Prefix, T) \

class Prefix##Stack \

{\

public: \

void Push(T val); \

T Pop(); \

bool IsEmpty() const; \

\

private: \

std::vector <T> mStack; \

};

DECLARE_STACK(Int, int);

除了一些难看的代码(如:每行使用一个反斜杠结束和使用预处理器连接),预处理器没有类型检查或域的概念。这是一种简单的文本复制机制。这是因为宏的声明实际上并没有编译,任何宏中的错误都只会报告一处展开后的错误行,并不是报告在它声明的地方。相似的,你无法用调试器步进到方法内部,因为展开后的整个代码块只是源码文件中的一个单独的行。相比之下,模板提供的类型安全能够在编译时生成代码。你可以调试类模板中的每一行。

总结一下,除非在编写一个纯C API而无法使用模板,你应该避免使用预处理器来模拟模板。

5.3.3 基于模板API的优点

模板最明显的功能就是让你可以从单个声明创建(初始化)许多不同的类。先前给过的堆栈例子,你可以很容易地添加基于字符串和浮点堆栈的类,只需要添加下面的声明:

[代码 P163 第一段]

typedef Stack<std::string > StringStack;

typedef Stack<double > DoubleStack;

就这点来说,模板能够帮助去除重复,因为不再需要复制、粘贴和微调实现代码。没有模板的话,你就得创建(和维护)很多非常相似的代码来支持IntStack、StringStack和DoubleStack类。

模板另一个重要的属性是可以提供静态(编译时)多态,相对于继承,它是提供动态(运行时)多态的。模板允许不同的类的创建都显现出相同的接口。例如,堆栈类的每个实例:IntStack、DoubleStack或StringStack提供的方法集是完全一样的。你也可以使用模板来创建函数接收这些类型的任何一个,而不需要使用虚方法而带来的运行时开销。这是通过函数在编译时生成不同特定类型的版本来实现的。下面的模板函数演示了这种功能:它用来从任一个堆栈类型中取出最上面的元素。在本例中,在编译时会生成两种不同版本的函数:

[代码 P163 第二段]

template <typename T >

void PopAnyStack(T *stack)

{

if (! stack->IsEmpty())

{

stack->Pop();

}

}

...

IntStack int_stack;

StringStack string_stack;

int_stack.Push(10);

string_stack.Push("Hello Static Polymorphism!");

PopAnySack(&string_stack);

PopAnyStack(&int_stack);

使用模板还有更多的好处,你可以为特定的类型实例量身定做特定的类方法。例如,泛型堆栈模板被定义成Stack<T>,但是你可以为某个类型提供自定义的函数实现,如Stack<int>。这是非常方便的,用来优化某些类型的类或给某些具有唯一行为的类型添加自定义功能。这可以用下面的语法来完成,通过提供一个方法定义:

[代码 P163 第三段]

template <>

void Stack <int >::Push(int val)

{

// integer specific push implementation

}

5.3.4 基于模板API的缺点

就使用模板的缺点而言,API设计中最严重的缺点就是把类模板的定义暴露于公共接口处。这是因为编译器为了进行特殊化处理必须访问模板代码的全部定义。这么明显地暴露内部细节,你应该懂得这是API开发中的大忌。这也意味着编译器每次都要对包含的文件进行重新编译内联代码,会导致生成的代码让使用API的每个模型都添加到对象文件中去。这会导致增加编译时间和让代码变得臃肿。不过,应该注意到你可以把模板执行细节隐藏到.cpp文件中去,使用一个叫做显示初始化(explicit instantiation)的技术。我会在下章C++用法中讨论这种技术的更多细节。

前面部分给出的静态多态的例子演示的是另一种潜在的源代码臃肿。这是因为编译器必须为每个使用PopAnyStack()函数的不同版本生成代码。这和多态虚方法的风格不同,这种只需要编译器生成一种方法,不过会导致运行时开销,要知道调用的是哪个类的IsEmpty()和Pop()方法。因此,如果代码大小比运行时开销更重要,你可能决定采用面向对象的解决方案,而不是使用模板。或者,如果运行时性能对你更重要,那么这时候你可以选择模板。

另一个普遍认为公认的模板缺点就是:绝大多数部分编译器会在模板内部出现代码错误时给出冗长或令人迷惑的错误信息。这种出现的简单错误在有大量代码的模板中出现这种简单错误却会输出数十行错误信息,你肯定会头疼一阵时间。事实上,市面上甚至有产品可以简化模板的错误信息,可以让调试变得容易,如来自BD软件的STLFilt程序。这个不仅仅像你这样的API开发人员应该关注,对你的用户客户也是有用的,因为当他们错误地使用了你设计的API时,也可以暴露出易懂的错误信息。

5.4 数据驱动API

数据驱动程序是指:程序运行时通过提供不同的数据输入,每次能够执行不同的操作。例如,有个数据驱动程序可以接受包含一系列需要执行命令的磁盘上的文件名。这对API设计是有很大影响的,因为这意味着不再依赖提供各种方法调用的对象集合,你得提供更通用的例程来接受已命名的命令和参数字典。这有时也叫做消息传递(message passing)API或基于事件的(event-based)API。下面的函数调用演示了API类型是如何区分标准标志C和C++调用的:

qfunc(obj, a, b, c)=纯C风格的函数

qobj.func(a, b, c)=面向对象C++函数

qsend("func", a, b, c)=带参数的数据驱动函数

qsend("func", dict(arg1=a, arg2=b, arg2=c))=使用已命名的参数(伪代码)字典的数据驱动函数

下面给个具体的例子,让我们看看该如何使用数据驱动模型重新设计堆栈例子:

[代码 P164 第一段]

Class Stack

{

public:

Stack();

Result Command(const std::string &command, const ArgList &args);

};

接着,这个简短的类就可以用于执行多个操作,如:

[代码 P165 第二段]

= new Stack();

if (s)

{

s->Command("Push", ArgList().Add("value", 10));

s->Command("Push", ArgList().Add("value", 3));

Stack::Result r = s->Command("IsEmpty");

while (! r.convertToBool())

{

s->Command("Pop");

= s->Command("IsEmpty");

}

delete s;

}

这是一个数据驱动API,因为各自的方法被单个方法Command()所代替,用来支持多个可能的字符串数据输入。可以简单地想象成编写了一个简单的程序来解析包含各种命令的ASCII文本文件的内容,并按照顺序执行每个命令。输入文件就像下面给出的那样:

[代码 P165 第三段]

# Input file for data-driven Stack API

Push value:10

Push value:3

Pop

Pop

处理这种数据文件的程序使用数据驱动的堆栈API,接收每行的第一个用空格分隔的字符串(忽略空白行和以#号开头的注释行)。这个程序可以创建一个ArgList结构接收更多的用空格分隔的字符串,该字符串跟随在开始的命令之后。它可以传送字符串到Stack::Command(),并继续处理文件的剩余部分。这个程序通过给定不同的文本文件,可以执行很多不同的堆栈操作,这显然不需要程序重新编译。

5.4.1 数据驱动Web Services

不是所有的接口都适合用数据驱动风格来表示。不过,这种风格特别适合无状态的通信频道,如客户端/服务器端程序中的API允许发送命令到一个服务器并可以把结果返回给客户端。它也可以用来在松耦合组件之间传递消息。

比较特殊之处是:Web服务可以很自然地用数据驱动API来表示。Web服务通常是通过发送带有一系列查询参数的URL或采用某种结构格式的消息(如JSON JavaScriptObject Notation或XML)来实现的。

例如,Digg Web网站(译者注:这是一个掘客类网站,其实是一个文章投票评论站点,它结合了书签、博客、RSS 以及无等级的评论控制)提供一个API来允许用户和Digg.com的Web服务进行交互。这里有个特别的例子,Digg API提供的digg.getInfo调用是返回特定的“掘客”新闻。这是通过发送一个HTTP GET请求实现的,格式如下:

[代码 P166 第一段]

http://services.digg.com/1.0/endpoint?method=digg.getInfo&digg_id=id

这种映射非常契合前面讲过的数据驱动API,这个HTTP请求可以这样调用:

[代码 P166 第二段]

= new DiggWebService();

d-> Request("digg.getInfo", ArgList().Add("digg_id", id));

虽然已经对协议细节进行了抽象,但是这和底层的协议的关系还是很密切。例如,具体哪种适合实现还是可以选择的,如用GET、POST或JSON或XML来发送请求。

5.4.2 数据驱动API的优点

我已经提到过数据驱动API的主要好处:程序的业务逻辑可以抽象成我们可以编辑的数据文件。用这种方式,可以修改程序的行为而不用重新编译可执行文件。

你可能会决定支持某个独立的设计工具来允许用户轻松地编辑数据文件。有几个商业套装是这么做的,如FMOD:包含一个FMOD Designer数据驱动的FMOD事件API。还有Qt UI工具包所包含的Qt Designer程序,允许用户通过流行的可视化风格来创建用户界面。Qt的QuiLoader类在运行时可以加载.ui文件。

数据驱动API的另一个好处就是在将来修改API时更加容易。那是因为在很多情况下,添加、移除或修改一个命令都对公共的API方法签名没什么影响。常常只需要简单地修改传入到命令处理程序的字符串。换句话说,传递一个未被支持或过时的命令给处理程序,不会导致编译时错误。相似的,根据提供的参数数量和类型,可以支持不同的参数版本,本质上这是模仿C++的方法重载。

举个数据驱动堆栈API的例子,它提供了Stack::Command()方法,一个API的新版本要添加一个Top命令(返回最顶端的元素但并不从堆栈弹出),还有扩展Push命令来接受多个值,并按照顺序入栈。请看下面实现了这些新特性的例子:

[代码 P166 第三段]

= new Stack();

s-> Command("Push", ArgList().Add("value1", 10).Add("value2", 3));

Result r = s->Command("Top");

int top = r.ToInt(); // top == 3

应该注意得是:添加的新功能并未影响到头文件中的函数签名。它仅仅是修改了支持的字符串和参数列表(可以传递给Command()方法)。因为这个属性,当移除或修改现有的命令时,使用数据驱动模型可以很容易地创建修改后的向后兼容的API。相似地,因为你很可能不需要修改所有公共方法的签名,所以创建二进制兼容修改也会容易很多。

数据驱动API还有一个好处是更好地支持数据驱动测试技术。这是一种自动化测试技术,不需要编写许多独特的测试程序或例程来测试API,你只要编写一个简单的数据驱动程序来读取文件中的一系列命令并检测断言。那么编写多个测试就仅仅是简单地创建多个数据输入文件。因此,这种测试开发迭代的速度更快,因为不需要编写新的测试步骤。而且,不精通C++开发技巧的QA(质量保证)工程师也可以很好测试你所开发的API。

堆栈例子的剩余部分就是创建一个接受数据输入文件的测试程序,如下所示:

[代码 P167 第一段]

IsEmpty= > True # A newly created stack should be empty

Push value:10

Push value:3

IsEmpty= > False # A stack with two elements is non-empty

Pop => 3

IsEmpty= > False # A stack with one element is non-empty

Pop => 10

IsEmpty= > True # A stack with zero elements is empty

Pop => NULL # Popping an empty stack is an error

这个测试程序很像先前讲过的从一个数据文件读取堆栈命令的程序。最大的不同就是我添加了对“=>”符号的支持,让你可以检查Stack::Command()方法返回的结果。添加这个后,你就可以通过这个灵活的测试框架来允许你为API创建任意数量的数据驱动测试。

提示

数据驱动API可以很好地映射Web服务和其它 客户端/服务器端API。它们也支持数据驱动测试技术。

5.4.3 数据驱动API的缺点

正如已经讲述过的,数据驱动模型并不适合所有的接口。它比较适合数据通讯接口,如传送客户端/服务器端之间的消息的Web服务。然而,它就不适用于实时3D图形API。

由于某种原因,API的简单性和稳定性会带来运行时开销。这是因为寻找正确的内部例程来调用给定的命令名称字符串会带来额外的开销。这是使用内部哈希表或字典,来映射命令名称到可随时调用的函数,用来加速这个过程,不过这还是不会和直接调用函数一样快。

此外,数据驱动API的另一个不足之处是:物理头文件不能反映出逻辑接口。这意味着用户无法通过简单地查看你的公共头文件来知道接口提供什么样的功能和语义。然而,回想一下本书的前面部分,我把API定义成头文件的集合和相关联的文档。因此,只要你为API提供了一份好的文档,指出所支持的命令列表和所期望的参数,你就可以适度地抵消这个不足。

最后,数据驱动API无法利用接口编译时检测所带来的好处。这是因为:本质上是你自己亲自执行解析和参数的类型检查。这也加重了测试代码的负担并要你确保不能影响破坏任何重要的行为。

5.4.4支持变体参数列表

到现在为止,我在前面给定的例子中掩盖了Result和ArgList类型的使用。这些意味着所表示的数据值可以包含不同的类型值。例如,ArgList可以用来传递0个参数、单个整型参数或两个参数,一个是字符串,另一个是浮点型。弱类型语言(Weakly typed languages),如Python显式支持这个概念。不过,C++不支持:数组和容器容易必须包含相同类型的元素,而类型在编译期必须是已知的。因此,你应该引入一个持有值对象(value-holding object)的概念,能够存储各种可能类型的值。这常常叫做变体类型(variant type)。

此外,你需要能够知道值是什么类型,而且你要能够把值转换成需要的类型(如:如果有一个字符串类型,但是你要把它当成一个整型)。有几个工具包支持这个概念。这里有三个有代表性的例子,包括Qt的QVariant、Boost的any和Second Life第二人生的LLSD。

qQVariant:提供的持有对象可以保存几个一般的Qt类型,包括QChar、double、QString、QUrl、QTime和QDate等等。还提供方法来检测对象是否包含值,判断值的类型和把值转换成另一个可能的类型。QVariant还能持有变体容器对象,如列表、数组和QVariant对象的映射,例如:QVariant可以包含一个QMap<QString, QVariant>。

qboost::any:这个类模板允许你存储任意值类型,而不是固定的显式支持的类型集。接着你可以抽取出原始的值,通过转换得到期望的类型。这个类还提供一个type()方法来获取所持有对象的类型。然而,并未提供对不同值类型的显式转换,除非转换运算符已经支持这个转换。

qLLSD:支持各种标量数据类型,包括布尔、整型、实数类型、UUID、字符串、日期、URI和二进制数据。一个单一的LLSD也可以是包含标量LLSD值的一个数组或映射(字典)。它也有提供方法来支持检测对象是否包含任何值、判断值的类型和把值转换成另一个可能的类型。此外,方法作为LLSD接口可用的一部分来访问对象中的数组和映射数据,例如:LLSD::has()和LLSD::insert()。

就实现而言,有几种标准的方法来实现一个变体类型。下面列举几种普通的方法:

(1).联合体:联合体是用来保存每个受支持的类型实例,使这个结构有足够的空间来容纳这些类型中最大的类型。有个额外的类型变量用来指定联合体的哪个字段是有效的。微软的Win32 VARIANT类型就是使用这种技术。

(2).继承:每种类型代表的是从一个共同的抽象基类所派生出来的一个类。抽象基类特定的方法返回类型标识符和有选择地把内容转换成一个不同的类型。这实质上是由Qvariant所采用的方法。

(3).Void*:这和联合体的方式相似,除了void*指针是用来保存指向对象的指针,或者是对象的拷贝。和联合体技术一样,需要一个额外的变量void*指针来表示变量所指向的类型。显然,这是三种中最小类型安全(least type safe)的解决方案。

我将介绍一个简单变体类型,通过这个API来演示这种对象所具有的功能。我将在Qvariant上构建这个API例子的模型,这个简单的设计比起LLSD方式要直观得多。((LLSD是非正交的,因为它所重复的数组和映射功能也可以在其它容器对象中找到。有趣的是,Open Metaverse API开发人员,基于Second Life第二人生的对象模型,在他们的OSD类中却选择不重复LLSD这方面的内容。)然而,这里我并没有给出实现细节,本书的随书源码有给出使用继承方法来保存值类型的一个完整的可运行的例子。

下面是一个Arg类的接口:

[代码 P169 第一段]

class Arg

{

public:

// constructor, destructor, copy constructor, and assignment

Arg();

Arg();

Arg(const Arg&);

Arg &operator = (const Arg &other);

// constructors to initialize with a value

explicit Arg(bool value);

explicit Arg(int value);

explicit Arg(double value);

explicit Arg(const std::string &value);

// set the arg to be empty/undefined/NULL

void Clear();

// change the current value

void Set(bool value);

void Set(int value);

void Set(double value);

void Set(const std::string &value);

// test the type of the current value

bool IsEmpty() const;

bool ContainsBool() const;

bool ContainsInt() const;

bool ContainsDouble() const;

bool ContainsString() const;

// can the current value be returned as another type?

bool CanConvertToBool() const;

bool CanConvertToInt() const;

bool CanConvertToDouble() const;

bool CanConvertToString() const;

// return the current value as a specific type

bool ToBool() const;

int ToInt() const;

double ToDouble() const;

std::string ToString() const;

private:

.. .

};

使用Arg的这个声明,你就可以定义把ArgList定义成一个字符串到Arg的映射,例如:

[代码 P169 第二段]

typedef std::map<std::string, Arg> ArgList;

这允许你使用一个可选数量的命名参数(可以是bool、int、double或string类型)来创建一个接口。例如:

[代码 P169 第三段]

= new Stack();

ArgList args;

args["NumberOfElements"] = Arg(2);

s-> Command("Pop", args);

还有,你可以声明ArgList为它自己的类,包含一个私有的std::map并支持便捷例程,如Add()方法来往映射中添加一个新的条目并返回ArgList的一个引用。这可以让你使用命名参数用法(已在上个章节给出)所提供的一个更紧凑的语法,如下所示:

[代码 P169 第四段]

= new Stack();

s-> Command("Pop", ArgList().Add("NumberOfElements", 2));

有了这个新类,就支持方法接受单个参数(ArgList类型),用来传递任何bool、int、double或 std::string的组合。同样地,将来就可以对API行为进行更改(如:向参数列表添加方法支持的新参数)而不会影响方法的签名。

5.4.5用例学习:FMOD数据驱动API

总结一下,下面是一个使用FMOD数据驱动API风格的简单程序例子。请注意这只有一个数据驱动接口的例子,并没有说明我讨论过的所有概念。不过,它所说明的例子是在运行时加载的,大部分逻辑都是存储在一个数据文件中。这个sound.fev文件,是由FMOD Designer工具创建的。程序显示的是访问文件中事件的一个命名参数并修改参数的值。

[代码 P171 第一段]

#include "fmod_event.hpp"

int main(int argc, char *argv[])

{

FMOD::EventSystem *eventsystem;

FMOD::Event *event;

FMOD::EventParameter *param;

float param_val = 0.0f;

float param_min, param_max, param_inc;

// Initialize FMOD

FMOD::EventSystem_Create(&eventsystem);

eventsystem->init(64, FMOD_INIT_NORMAL, 0, FMOD_EVENT_INIT_NORM

// Load a file created with the FMOD Designer tool

eventsystem->load("sound.fev", 0, 0);

eventsystem->getEvent("EffectEnvelope", FMOD_EVENT_DEFAULT, &e

// Get a named parameter from the loaded data file

event- >getParameter("param", &param);

param- >getRange(&param_min, &param_max);

param- >setValue(param_val);

event- >start();

// Continually modulate the parameter until Esc pressed

param_increment= (param_max - param_min) / 100.0f;

bool increase_param= true;

while (! UserPressedEscKey())

{

if (increase_param)

{

param_val += param_increment;

if (param_val > param_max)

{

param_val = param_max;

increase_param = false;

}

}

else

{

param_val -= param_increment;

if (param_val < param_min)

{

param_val = param_min;

increase_param = true;

}

}

param- >setValue(param_val);

eventsystem->update();

NanoSleep(10);

}

// Shut down

eventsystem->release();

return 0;

}

 根据原书勘误P155


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值