C++ API 设计 07 第二章 品质

第二章 品质

本章的目标是回答下面的问题:一个良好的API需要什么样的品质?大多数开发人员都同意,一个良好的API应该设计得很优雅,而且仍然非常容易使用。它应该在后台运行且使用起来让你觉得很惬意。这些都是很好的定性陈述,不过什么样的具体设计可以实现这些目标?显然,每个API都是不同的。不过,高品质的API设计是应该尽可能坚持的,那些导致糟糕设计的方式都应避免。

API设计中也没有绝对的规则:你不能为每一种情况都套用固定的规则。不过,仍然可能出现个别的情况,导致你决定要在项目中不采纳本章的某些建议,你应该只在充分和细致地考虑后再作出这种决断。本章的指导原则应成为API设计决策的基石。

本章主要关注通用的和语言无关的API品质方面的内容,例如:信息隐藏、一致性和松散耦合。在讲述这些概念时,虽然会以C++代码为例,但总的来说,无论你是使用C++、Java、C#还是Python,这些建议都十分有用。后面的章节将专门讨论C++的具体问题,例如常量正确性(const correctness)、名空间和构造函数的用法。

本书后面也对本章的很多主题进行了深入探讨。例如,提到使用Pimpl方法在C++中隐藏内部的细节的同时,本书随后关于设计模式的那一章还会拿出更多篇幅来讲述这个重要的主题。

2.1 构建问题域模型

编写API的目的是解决一个特定的问题或者执行一个特定的任务。因此,首先API应该为相应问题提供明确的解决方案并通过某种方式明确地构建问题模型。例如,它应该对问题进行良好的抽象并构建出问题域中关键对象的模型。这样做可以让你设计的API易于被用户理解和使用,因为他们可以利用已有的知识和经验。

2.1.1 良好的抽象

API应该为它要解决的问题提供一个逻辑抽象。也就是说,它应该明确反映所选问题的高层次概念,而不是对外暴露底层执行细节。即使把你的API文档拿给一个非程序员看,那人也应该能够理解接口的概念以及应该如何使用它。

此外,在非技术读者看来,API提供的各种操作是否容易理解而且浑然一体也应该是显而易见的。每个类都应该有一个中心目的,这应该在类和方法的名称中反映出来。事实上,应该尽量早一点找人评审一下你的API,以确保它们在外行人眼里也是合乎逻辑的。

因为提供一个良好的抽象并不是一个简单的任务,所以我决定在第四章中利用大量篇幅来讲解这个复杂的主题。然而,应该指出的是,任何给定的问题都没有唯一正确的抽象。大多数API都可以通过几种不同的方式来建立模型,每个都有可能提供良好的抽象和实用的接口。关键是要知道你的API必须遵循一些不变的、符合逻辑的基本原则。

例如,让我们考虑一个简单的关于通讯录程序的API。从概念上讲,地址簿通讯录是一个包含多人详细资料的容器。下面的做法看起来是符合逻辑的,API应该提供一个包含Person对象的AddressBook对象,其中Person对象描述的是由姓名和地址组成的联系方式。此外,你希望能够执行往地址簿通讯录里面添加或者删除一个人的操作。这两个操作涉及更新地址簿通讯录的状态,因此从逻辑上应该归属于AddressBook对象。这个初始的设计可以使用统一建模语言(UML)形象地表示出来,如图2.1所示。

对于不熟悉UML的人来说,图2.1显示的是一个AddressBook对象包含一或多个Person对象(一对多),而且还包含两个操作:AddPerson()和DeletePerson()。Person对象则包含一组公共属性来描述一个人的姓名和地址。我将在稍后完善这种设计,但是目前可以先将它作为这个问题的一个初始逻辑抽象。

[图 P22 第一张]

图2.1

通讯录API的高级UML抽象

[排版 P23 开始]

UML类图标

UML规范定义了构建面向对象的软件系统的可视化符号模型(Booch et al., 2005)。本书常使用UML类图来描述,如图2.1。在这些图中,一个类表示为分割成三个部分的盒子。

1:上部包含类名

2:中部罗列出类的属性

3:下部是列举类的方法

类的中部和下部都可以用一个符号前缀为属性和方法来表示访问级别或者能见度,这些符号包括:

+表示一个public类成员

-表示一个private类成员

#表示一个protected类成员

各个类之间的关系是通过各种风格的连接线和风格化的箭头来表示。一些关系在UML类图中是这样表示的:

联合(Association):这是一种两个类之间的简单依赖关系,互相不属于对方互补隶属,可以用实心线表示。联合可以指定方向,用一个开放的箭头,如“>”来表示。

集合(Aggregation):是“有一个”(has-a)或者整体/部分的关系,两个类不互相隶属,使用带线的空心钻石符号来表示。

组合(Composition):是“有一个”关系,部分的生命周期是由属于整体进行管理的,使用带线的实心钻石符号来表示。

归纳(Generalization):类之间的子类关系,显示为一条带有空心三角的箭头线。

一个关系的每一边都可以定义其多样性(multiplicity)。这可以让你指定关系是一对一、一对多或者多对多。一些常见的多样性包括:

0..1=0个或1个实例

1=仅有一个实例

0..*=0个或多个实例

1..*=1个或多个实例

[排版 P23 结束]

2.1.2 构建关键对象模型

API是为问题域构建关键对象的模型。这个过程常常通常被称为面向对象设计或对象建模,这是因为它描述在特定问题域上的层次结构。对象建模的目的是确定主要对象的集合它们提供的操作,以及它们是如何相互关联的。

再重复一次,任何一个任一正确的对象模型都不可能适用于所有给定的问题域。创建对象模型的任务应该是由API的具体需求决定的。由于出于某个API有不同的需求,就可能需要不同的对象模型来满足那些需求。例如,继续我们的地址薄的例子,我们假设已经收到如下API需求:

(1).每个人可以有多个地址。

(2).每个人可以有多个电话号码。

(3).每个电话号码必须是有效的和经过统一格式化的。

(4).通讯录可能包含允许多个拥有相同名字的人

(5).可以修改通讯录地址薄中某个条目。

这些需求有一项对该API的对象模型有很大的影响。在图2.1中,我们最初的设计,图2.1中只支持每人拥有单个地址。为了支持至此多个地址,你可以往Person对象添加额外的字段(例如,HomeAddress1、WorkAddress1),但是这将是一个并不友好和优雅的解决方案。你可以引入一个表示地址的对象,例如Address对象,允许Person对象包含多个地址。

电话号码的情况也是一样的:你应该构建一个它们自己的对象,例如,TelephoneNumber对象,并允许Person对象持有多个这些对象。建立独立的TelephoneNumber对象的另一个原因是我们需要支持一些操作,如IsValid()来验证电话号码和GetFormattedNumber()方法返回一个格式化好的电话号码。这些操作都是针对电话号码来执行的,不是针对一个人,这表明电话号码应该由它们自己的首类(first-class)对象来表示。

需求中有一个是多个People(人)人的对象允许相同的名字,从本质上说人名不能用来标识Person对象。因此,你需要某种方式来标识一个Person实例,这样你就可以定位和修改通讯录中的某个已有存在的条目。有一种方法可以用来满足这个要求,那就是为每个人生成一个通用唯一标识符(universally unique identifier UUID)。把这些整合起来全部放在一起,你可以总结出我们通讯录API的主要对象,如下所示:

(1).通讯录(Address Book):包含0个或多个Person对象,还有操作AddPerson()、DeletePerson()和and UpdatePerson()等操作

(2).人(Person):描述一个人的详细资料,包含0个或多个地址和电话号码。每个人通过一个UUID来区别。

(3).地址(Address):描述单个地址,包含某种类型的字段,如“Home”(家庭)或者“Work”(单位)。

(4).电话号码(Telephone Number):描述单个地址,包括某种类型的字段如“Home”(家庭固话)或者“Cell”(手机)。还支持一些操作,如IsValid() 和GetFormattedNumber()。

修改过后的对象模型可以通过UML图来表示,如图2.2

[P24  第一张2.2]

 

图 2.2

通讯录API关键对象的UML图

 

API对象模型可能需要随时改变,这点很重要。由于新的需求或功能的添加,相应的类和方法就需要通过修改来满足那些要求。根据新的需求是否可以在重新设计对象模型中受益,来重新评估对象模型是十分明智的。例如,你可能预见到国际地址的需求,并决定创建一个更通用的地址对象来处理这个问题。不过,别太心急,你先要创建一个更通用的地址对象。请务必先完整地阅读紧接的章节。

 

2.2 隐藏实现细节

创建API的首要原因就是可以隐藏所有的实现细节以便可以随时更改而不影响现有的用户。因此,API的最重要的品质要求就是实现这个目标。也就是说,任何的内部细节,最可能更改的部分,必须对使用该API的用户保密。David L. Parnas把这个概念称为信息隐藏(information hiding)。(Parnas, 1972)

关于这个目标有两大类的技术:物理和逻辑隐藏。物理隐藏的意思是源代码不提供给用户。逻辑隐藏是通过语言特性来限制API的某些内容。

2.2.1 物理隐藏:声明(declaration)和 定义(definition)

在C和C++中,声明和定义是两个有特定含义的严谨的术语。声明只是简单地引入一个命名和它的类型,对编译器来说并没有给它分配任何内存。相反,一个定义提供了类型的结构细节或者给变量分配内存。(术语函数原型[function prototype],由C程序员使用,等同于术语函数声明[function declaration]。)例如,下面的例子都是声明:

[代码 P25 第一段]

extern int i;

class MyClass;

void MyFunc(int value);

对比一下,下面的例子全是定义:

[代码P25 第二段]

int i = 0;

class MyClass

{

public:

float x, y, z;

};

void MyFunc(int value)

{

printf("In MyFunc(%d).", value);

}

提示

声明给编译器引入了一个符号化的命名和类型。定义为这个符号提供了完整的细节,或是一个函数体(function body),或是一段内存区域(region of memory)。

就类和方法而言,下面的代码引入了一个只有单个方法声明的类:

[代码 P26 第一段]

class MyClass

{

public:

void MyMethod();

};

方法的实现(方法的主体)部分给出了它的定义:

[代码 P26 第二段]

void MyClass::MyMethod()

{

printf("In MyMethod() of MyClass.\n");

}

通常说来,你在.h文件中放置声明,在.cpp文件实现相关的定义。然而,你可以在.h文件中声明一个方法的地方提供定义,例如:

[代码 P26 第三段]

class MyClass

{

public:

void MyMethod()

{

printf("In MyMethod() of MyClass.\n");

}

};

这种技术隐含地要求编译器在所有调用MyMethod()成员函数的地方把它看成内联函数。就API设计而言,这种做法是不可取的,因为它对外暴露代码显示了方法是如何实现的和直接把代码内联到你的用户[q1]客户端程序中。因此,你要限制API头文件,只能提供声明。稍后的章节会谈谈这个规则的例外情况,用来支持模板和内联自觉行为(conscious acts of inlining)。

提示

物理隐藏的意思是通过一个公共接口(.h),在一个独立的文件(.cpp)中隐藏内部细节。

请注意,有时候我也会在本书的例子中使用内联函数。不过,我这么做完全是因为出于简洁明了的目的,而你在实际现实开发中应该避免这么做。

 

2.2.2 逻辑隐藏:封装(Encapsulation)

封装,这个面向对象的概念提供了一种限制访问对象成员的机制。在C++中,为类和方法提供了如下访问控制关键字(类和结构在功能上是相当的,区别只是在默认的访问级别)。访问级别如图2.3所示:

[图 P27 第一张2.3]

图 2.3

C++类的三种访问级别

(1).Public:成员可以在类和结构之外访问。这是结构的默认访问级别。

(2).Protected:成员只可以在类内部和其子类中访问。

(3).Private:成员只可以在定义它的类内部访问。这是类的默认访问级别。

[排版 P27 开始]

其它语言中的封装

C++为类成员提供了public、protected和private三种访问级别控制,而其它面向对象语言也提供了不同尺度的访问级别。例如,在Smalltalk语言中,所有实例变量(instance variable)都是私有的(private),所有方法都是公共的(public),而Java语言也提供了public、private、protected和package-private可见性级别。

Java中Package-private的意思是成员只能在类所在的包内被访问。这是Java的默认可见性设定。Package-private是种很好的方式,允许JAR文件中的其它类访问其内部成员,而不需要向你的用户暴露。例如,在单元测试中是十分有用的,测试需要验证私有方法的行为。

C++没有Package-private可见性的概念。它使用更加友好和自由的方式来允许命名类(named class)和函数访问类中的protected和private成员这种友好可以用来加强封装不过如果使用不当的话,也会对你的用户暴露过多过度的内部细节。

       [排版 P27 结束]

       你的用户很可能并不遵守重视公共API边界(public API boundary)。如果你让他们可以进入内部操作工作,并可以实现他们所想要的,那么他们将用这个它们来完成他们的工作。虽然这可能对他们有好处,因为他们能够找到一个解决他们当前问题的方案。不过这样做会导致你在将来难以修改实现部分的细节,从而让你无法改进和优化产品。

 

提示

封装是一种把API的公共(public)接口和它的内部实现相分离的过程。

用户可能遇到过下面的情况例子,在2000年左右,多人第一人称射击游戏反恐精英成为外挂的重灾区。其中一个最出名的就是“穿墙”。这个的基本原理就是修改OpenGL驱动,只渲染部分的墙或者完全透明掉。这个让玩家可以看到墙的另一面。虽然你可能不会开发一款游戏或者把游戏玩家当成目标客户,但是用户总是尽一切可能得到他们想要的。如果一些用户修改了OpenGL显卡驱动来让他们在游戏中获得优势,那么这相当于他们利用你的API对外暴露的细节来满足他们的老板所要求的功能。

下面说明一个更直接、更适用的例子,Roland Faber报告了发生在西门子公司的困难,一个小组决定依赖同个公司其他小组的API内部细节(Faber, 2010):

在欧洲之外的一个团队必须提供一个由德国实现的用户接口的远程控制。由于自动化接口尚未完成,他们决定使用内部接口来代替,而不通知架构师。因为不协调的接口发生变化,所以系统集成遭受意想不到的问题,而昂贵的重构就不可避免了。

因此,接下来的各个章节将讨论如何使用编程语言的访问控制功能来为API提供最大限度的信息隐藏。稍后章节讲述C++语言特性中影响封装的例子用例,例如对外依赖和外部联系(external linkage)。

提示

逻辑隐藏的意思是使用C++的语言特性protected和private来限制访问内部细节。

2.2.3 隐藏成员变量

术语封装也常常用在描述利用方法来打包需要操作的数据。在C++中,这是通过类中可同时包含变量和方法来实现的。然而,就良好的API设计而言,你千万不要把成员变量设置成公有的(public)。如果数据成员来自API逻辑接口的一部分,那么你应该使用getter和(或者)setter方法来间接访问成员变量。例如,你应该避免下面的写法:

[代码 P28 第一段]

class Vector3

{

public:

double x, y, z;

};

你应该这么做:

[代码 P29 第二段]

class Vector3

{

public:

double GetX() const;

double GetY() const;

double GetZ() const;

void SetX(double val);

void SetY(double val);

void SetZ(double val);

private:

double mX, mY, mZ;

};

后面的这段代码从语法上来说比较冗长,对程序员来说需要多敲一会儿键盘,但是多花些时间做这些能够节省更多的时间,就是在你需要更改接口的时候。此外,不直接暴露成员变量,而使用getter/setter,其中的额外好处如下所示:

(1).验证(Validation):你可以通过验证来确保类中的内部状态的有效性和一致性。例如,如果有一个让用户设置新的RGB颜色的方法,你可以检查提供的每个红色、绿色和蓝色值是否在有效范围内,如从0到255或者是0.0到1.0。

(2).延迟评估(Lazy evaluation):计算一个变量的值可能会带来昂贵的开销,非到必要你可能不会这么做。通过使用getter方法来访问底层数据的值,你可以推迟代价昂贵的计算,直到真正需要时。

(3).缓存(Caching):一项经典的优化技术是把经常需要计算的值保存起来,以供日后需要的时候使用。例如,在Linux中可以通过解析/proc/meminfo文件得到一台机子的内存大小。取代每次获取内存大小都要执行文件读取,更好的方法是在第一次获取请求时把结果保存到缓存,以供日后使用。

(4).额外计算运算过程(Extra computation.):如果需要的话,一旦用户试图访问变量时,你可以执行额外的操作。例如,当用户修改偏好设置(preference setting)的值时,你总要把UserPreferences对象的当前状态写入到磁盘上的配置文件。

(5).通知(Notifications):其它模型或许希望知道类中的值什么时候发生改变。例如,你要实现进度条的数据模型,用户接口代码需要知道进度值已经更改了,接着它才可以更新GUI。因此,你可能需要发布一个更新通知,做为setter方法的一部份。

(6).调试(Debugging):为了记录变量何时被访问和被用户修改,你可以添加调试或者日志语句,或者你希望添加断言语句来加强假设。

(7).同步(Synchronization):你可能发布了一个API的首个版本,接着你需要添加线程安全的特性。标准的做法就是当值被访问的时候,添加互斥锁(mutex locking)。要想这么做就必须通过getter/setter方法来访问数据。

(8).出色的访问控制(Finer access control):如果你把一个成员变量设置成公有的(public),用户可以任意地读写。然而,采用getter/setter方法,你可以提供更好的读写控制。例如,你可以通过不提供setter方法来设置某个值是只读的。

(9).维持不变量关系(Maintaining invariant relationships):一些内部数据值可能互相依赖。例如,在车辆动画系统中,你可以根据两个关键帧之间的时间差和位移来计算车辆的速度和加速度。你可以通过单位时间内的位移来计算车辆速度,而加速度又是基于单位时间内速度的变化。然而,如果用户可以访问这个计算的内部状态值,他们就可以修改加速度值,而这并没有和车辆的速度关联起来,这么做会导致难以预期的结果。

但是,如果成员变量不是逻辑接口的一部分,也就是说,它们所代表的是和公共接口不相关的内部细节,那么它们就应该简单地从接口处隐藏。例如,参考下面的几个整数的定义:

[代码 P30 第一段]

Class IntegerStack

{

public:

static const int MAX_SIZE= 100;

void Push(int val);

int Pop();

bool IsEmpty() const;

int mStack[MAX_SIZE];

int mCurSize;

};

Clearly this is a really b ad API becau se it expose s the way that the stack has been (poorly) imple-

men ted as a fixed array of integer s and it expose s the intern al sta te of the stack via the mCurSize var-

iable. If at som e future date you decided to improv e the imple mentation of this class, for exampl e, by

usin g a std::vector or std::list rather than a fixed-siz e staticall y alloca ted array, then you may

find this difficul t to do. That’s because you have expose d the existe nce of the mStack and mCurSize

variabl es and so client code could be relying on the abili ty to access thes e variables direct ly. By

changing y our imple mentati on you could brea k your clients’ code.

Instead , these membe r variables shoul d be hidden from the start so that client code cannot access

them:

Class IntegerStack

{

public:

void Push(int val);

int Pop();

bool IsEmpty() const;

private:

static const int MAX_SIZE= 100;

int mStack[MAX_SIZE];

int mCurSize;

};

我说过成员变量不应该声明成public,但它们可以声明成protected吗?如果把变量声明成protected,那么客户就可以通过继承类来直接访问了,这样做的负面结果就和声明成public一样了。因此,你永远也不要把成员变量声明成protected。正如Alan Snyder说过,在面向对象编程语言中的继承会严重影响封装所带来的好处(Snyder, 1986)。

提示

类中的数据成员应该总是声明成private,绝不能是public或protected。

暴露成员变量的唯一貌似有理的根据就是出于性能的原因。执行一个C++函数调用会带来导致方法参数的入栈和返回地址到调用堆栈的开销,以及为例程中的任一局部变量保留内存空间。然后,当方法完成时,调用堆栈要被再次释放。这些操作在性能上的开销可能在对性能要求高的代码区域引人注意,例如,在对大量对象进行某个密集循环操作时。代码直接访问public成员变量将比使用getter/setter快两三倍。

然而,即使在这种情况下,你还是不该暴露成员变量。首先,对API调用来说,一个方法调用的开销很可能会微不足道。即使你正在编写对性能要求很高的API,谨慎使用内联和结合使用现代优化编译器,将完全可以消除一般方法调用所带来的开销,这也就相当于给你带来了直接暴露成员变量所带来的性能优势。如果你仍然关心感兴趣的话,可以给使用内联getter/setter的API和public成员变量进行关于开销的时间测算。本书附带的源代码包含这方面的例子。你可以到http://APIBook.com/下载,并自己试一试。我也将在讲述性能的章节中继续讨论这个问题。

2.2.4 隐藏实现方法(Hide Implementation Methods)

除了隐藏所有的成员变量,你也应该隐藏所有不需要公开的方法。这是信息隐藏的基本原则:在类的设计中,要把稳定的接口和用来实现这一接口的内部设计分隔开来。在几个大项目的早期研究中发现,采用信息隐藏技术的程序比没有采用的,在修改程序时,要容易4倍(Korson 和 Vaishnavi 1986)。虽然你的经历可能有所不同,但是在设计API时,隐藏内部细节可以设计出更易维护和升级的软件,这是毋庸置疑的。

有一个关键点要记住,就是一个类是确定要做什么,而不是它是如何做的。例如,让我们考虑一个从远程HTTP服务器下载文件的类:

[代码 P31 第一段]

#include <string >

#include <stdio.h>

#include <sys/socket.h>

#include <unistd.h >

class URLDownloader

{

public:

URLDownloader();

bool DownloadToFile(const std::string &url,

const std::string &localFile);

bool SocketConnect(const std::string &host, int port);

void SocketDisconnect();

bool IsSocketConnected() const;

int GetSocket() const;

bool SocketWrite(const char *buffer, size_t bytes);

size_t SocketRead(char *buffer, size_t bytes);

bool WriteBufferToFile(char *buffer,

const std::string &filename);

private:

int mSocketID;

struct sockaddr_in mServAddr;

bool mIsConnected;

};

所有的成员变量都正确地声明成私有的,这是一个好的开始。然而,有几个对外暴露并实现特定功能的方法,例如打开通过socket(套接字)打开和读取数据,把内存中的缓存结果写入到磁盘中的文件。用户并不需要知道这些,他们所要做的仅仅是指定一个URL,然后就可以在远程的磁盘上创建一个带有内容的文件。

这里有一个非常特别的方法:GetSocket()。这是一个公共方法,返回一个私有变量。通过调用该方法,用户可以得到相关的socket句柄,并可以直接操作socket而不需要了解URLDownloader类。这里还有一个值得注意的是:GetSocket()声明成常量方法(const method),这意味着它不会修改类的状态。虽然这是完全对的,但是用户便可以使用返回的整数socket句柄来修改类的状态。如果返回的是一个指向私有成员变量的非常量指针(non-const pointer)或引用,那么这就和发生内部状态泄漏一样(Meyers, 2005)。这样做可以让用户处理你的内部数据成员,由此可以让他们不需要仔细审查API就可以修改对象的状态。

提示

千万不要返回指向私有成员变量的非常量指针或引用,这会破坏封装性。

很明显,一种更好的设计URLDownloader类的方法是把每个方法都设置成私有的,除了构造函数和DownloadToFile()方法,其它的都是负责实现细节的。这样做的话,你就可以很自由地修改实现而不会影响使用这个类的用户了。

不过,这里还是有非常不尽如人意的地方。从编译器的角度上看,我隐藏了实现细节,不过大家还是可以查看到头文件并了解到类中的所有内部细节。事实上,这非常像你把头文件发布给用户,并允许用户对你的API编译他们自己的代码。此外,还要给类中需要的私有成员使用#include导入所有的头文件,即使它们不依赖于公共接口。例如,URLDownloader头部需要导入特定平台的所有socket头文件。

这是C++语言的一个限制:所有的public、protected、private成员变量必须在类的声明处声明。最理想的是,我们的这个类的头部如下所示:

[代码 P33 第一段]

#include <string >

class URLDownloader

{

public:

URLDownloader();

bool DownloadToFile(const std::string &url,

const std::string &localFile);

};

上面的代码声明完公共成员后,所有的私有成员可以在其它地方声明,如.cpp文件。然而,这在C++中是不可能的(因为所有对象的大小在编译期都必须知道)。不过还是有办法在公共头文件处隐藏私有成员的((Headington, 1995)。有个流行的技术叫做Pimpl idiom,它可以把类的所有私有数据成员放置到独立的实现类或结构的.cpp文件中。.h文件只需要包含指向这个实现类的不透明指针(opaque pointer)。我会在下面的模式章节中着重介绍这个很有用的技术。

我强烈推荐你在API设计时采用Pimpl idiom技术,这样就可以在公共头文件中隐藏所有的实现细节。然而,如果你不想这么做,当私有方法没有必要移到.cpp文件中时,你也至少应该尝试把私有方法从头部移除,并把它们转换成静态函数(static functions )(Lakos, 1996)。当私有方法只访问类的公共成员或者根本不访问任何类的成员时(例如接收一个文件名字符串并返回文件名的扩展名的例程),上面的方法才是可行的。很多开发人员认为一个类使用了私有方法才需要把它包含到类的声明中。然而,这样做就暴露了太多超过必要的实现细节。

提示

在.cpp文件中,请尽量把不对外公开的功能声明成静态函数,而不是在公共头部文件中声明成私有方法。(但是使用Pimpl idiom是更好的方法)

2.2.5 隐藏实现类(Hide Implementation Classes)

除了隐藏类中的内部方法和变量外,你也应该尽力隐藏任何一个只是纯粹地实现细节的类。绝大部分程序员常常习惯隐藏方法和变量,却往往忽略了不是所有的类都该设置成公共的。事实上,一些类是仅仅在你实现某项功能时才用得到,就不应在API的公共接口部分抛头露面。

例如,考虑一下Fireworks(烟花)类:其中的一个接口允许你指定屏幕上的烟花动画的位置,还可以控制颜色、速度和烟花粒子的数量。显然,API需要记录烟花效果的每个粒子,这样才可以在每一帧修改每个粒子的位置。这意味着FireParticle(烟花粒子)类必须引入可以保存单个烟花粒子状态的功能。然而,使用API的用户并不需要访问这个类,这个类仅仅是API实现中需要的。所以这个类应该被设置成私有的,可以嵌套在Fireworks类的私有部分。

[代码 P34 第一段]

#include <vector >

class Fireworks

{

public:

Fireworks();

void SetOrigin(double x, double y);

void SetColor(float r, float g, float b);

void SetGravity(float g);

void SetSpeed(float s);

void SetNumberOfParticles(int num);

void Start();

void Stop();

void NextFrame(float dt);

private:

class FireParticle

{

public:

double mX, mY;

double mVelocityX, mVelocityY;

double mAccelerationX, mAccelerationY;

double mLifeTime;

};

double mOriginX, mOriginY;

float mRed, mGreen, mBlue;

float mGravity;

float mSpeed;

bool mIsActive;

std::vector <FireParticle *> mParticles;

};

请注意,我并没有给Fireworks类使用getter/setter方法。如果你想这么做的话,完全可以这么做。不过这个类并不能通过公共接口访问,所以就没那么必要这么做了。这种情况下,有些开发人员还喜欢使用一个结构(struct)来代替类,可以看得出来这个结构是一种简单旧式数据(Plain Old Data 译者注:本质上就是和C兼容的数据类型)类型。

同样,你也可以尝试隐藏在头文件中出现的FireParticle类的内容,甚至可以隐藏来自头文件的不定期检查。我将在下个章节中介绍是如何实现这些的。

2.3 最低限度完整性(minimally complete)

一个良好的API应该是符合最低限度完整性的。也就是说,它应该尽可能的小,无法更小了。

API应该是完整的,这点是显而易见的,也就是为用户提供所有需要的功能,虽然什么样的功能是需要的可能会没那么明显。为了解决这个问题,你应该收集需求和通过早期的用例建模(case modeling)来理解要设计的API到底要做什么。接着你就可以确定要实现的是符合需求所期望的。我会在设计章节中讲述更多需求和用例的。

把API设计成符合最小限度是容易让人忽略的。然而,这是你要做到的最重要的品质要求之一,这对以后的API维护和升级的影响是非常大的。从现实角度看中来说,你今天的决定将限制你明天做的事情。这个对API的易用性也影响很大,因为一个紧凑的接口是非常符合用户口味的(Blanchette, 2008)。接下来的部分我会着重讨论各种让API达到最小限度的技术,还有就是为什么要这么做。

提示

记住奥卡姆剃刀原理:如无必要,勿增实体。(plurality should not be posited without

necessity)

2.3.1 请勿过度承诺(Don’t Overpromise)

API中的每一个公共元素都是一个约定:就是在API生命周期内支持的功能。你可以违背这个约定,但是这样做会让你用户感到沮丧,并导致他们重写代码。更糟的是,他们可能弃用你的API,因为你可能移除了他们唯一需要的功能或者你设计的API不够稳定,他们已经厌烦了反复地修改代码。

至关重要的一点是:一旦你发布API给用户使用,添加功能是比较容易的,而移除功能就比较难了。最佳的建议是:肯定不了就删掉(Bloch, 2008; Tulach, 2008)。

这个建议违背了最优API设计的初衷。做为作为一个开发工程师,你要提供一个灵活和通用的解决方案。你总有一种欲望,往API中添加额外的抽象层或通用性,因为你认为它可能在将来会有用。你应该抵制这种欲望,理由如下:

(1).你需要额外通用性的那天或许永远不会到来。

(2).如果真的来了,你可能已经更加理解如何使用API会想出解决你最初设想的问题的不同方法。

(3).如果你真的需要添加额外的功能,往一个简单的API添加可比往一个复杂的添加要容易得多。

因此,你应该尽力让API变得简洁,暴露最少数量的类和这些类中的公共成员。这样带来的好处是使你设计的API容易易于理解和调试,也容易易于在用户脑海里建立API的构建构思模型。

提示

肯定不了就删掉!在API设计中,做到暴露最少数量的类和这些类中的公共成员。

 

2.3.2 明智而谨慎地添加虚函数(Virtual Function)

通过继承可以巧妙地暴露你想要的更多功能,也就是定义虚拟成员函数。这么做的话将允许用户通过继承你的类而得到子类并实现所有的虚方法(virtual method)。虽然这个功能很强大,但是你也应该知道潜在的一些问题:

q你可能实现一些貌似无害的对基类的修改,而实际上对用户带来不利的影响。这是可能发生的,因为对基类的改进影响了隔离性,并不知道用户是采用何种方式来利用这个虚API(virtual API)。这个也叫做“脆弱的基类问题”(fragile base class problem)(Blanchette, 2008)。

q用户可能会使用你从未考虑或想象过的方式来使用你的API。这可能使导致对你的API执行代码不在你控制下并可能导致意想不到的后果。举个严重的例子,没有办法阻止用户调用一个重写方法中delete this译者注:调用析构函数并释放内存删除的功能。这甚至可能还有一个有效的事情要处理,但是如果你没有设计这方面的实现,那么你的代码很可能会崩溃。

q用户还可能会使用错误或容易出错的方式来扩展API。例如,你有一个线程安全的API,根据你的设计,用户可以重写一个虚方法并可能实现提供一个未执行适当互斥锁操作的实现,这会导致出现难以调试的潜在问题。

q重写函数可能会破坏类的内部完整性。例如,一个虚拟方法的默认实现可能会调用同一个类的其它方法以修改其内部状态。如果重写的方法不执行这些相同的调用,则该对象可能会处于不一致的状态,从而导致难以预料的结果或崩溃。

除了对这些API级别的行为要关注外,你也应该知道在C++中使用虚函数的一些注意事项:

q虚函数调用必须在运行时通过查询虚拟函数表(vtable)进行解析,而非虚函数调用可以在编译时进行解析。这会导致虚函数的调用比非虚函数调用慢。实际中,这个开销是可以忽略不计的,特别是函数执行比较复杂的工作或不是很频繁调用

q使用虚函数会增加对象的大小,特别是增加指向虚拟函数表的指针的大小。如果在需要创建小型对象而生成大量实例时会导致一定的问题。还有,在实际中,比起各种成员变量的内存消耗,这还算微不足道的了。

q添加、重新排序或者移除一个虚函数都会破坏二进制兼容性。这是因为虚拟函数调用通常表示为类的虚拟函数表中的整数偏移。因此,改变其顺序或造成任何其它虚拟函数顺序改变的行为都意味着现有代码需要重新编译,以确保它仍然可以调用到正确的函数。

q虚函数不能总是采用内联的方式。你可能有理由认为声明一个虚函数为内联是没有意义的,因为虚函数是在运行时才解析的,而内联是在编译的时候进行优化的。然而,编译器内联一个虚函数的情况是有一定限制的。尽管如此,比起非虚函数的内联,这样的例子还是非常少的。(请记住,在C++中的inline关键字仅仅是对编译器的提示)

q重载虚函数时必须小心。在派生类中的符号声明会覆盖基类中所有同名的符号。因此,基类中的一组被重载的虚函数会被子类中的单个重载函数所隐藏。是有办法可以解决这个问题(Dewhurst, 2002),但最好和简单的方法是简单地避免重载虚函数。

最后,你应该只在明确打算这么做的时候使用重写。一个没有虚函数的类是更加稳健的,维护起来也比有虚函数的类来得轻松。做为作为一般原则,API并不调用一个特殊的内部方法,则那个方法就很可能不是虚拟的。你应该只允许子类出现这样有意义的情况:子类是来自基类的“is-a”关系。

其实,Herb Sutter说过你应该把虚函数设置成private并只在需要调用虚函数基类实现时才设置成protected(Sutter, 2001)。因此,Sutter建议接口应该是非虚的(non-virtual),在适当的情况应该使用模板方法(Template Method)的设计模式。这通常被称为非虚接口用法(Non-Virtual Interface idiom  NVI)。

如果你仍然决定允许子类,请确保你设计的API足够安全。请记住如下规则:

如果类中存在虚函数的话,请把析构函数声明为虚的。这是因为子类可能分配了一些资源并能够释放任何这些额外的资源。

要给类中方法的互相调用编写好文档。如果用户要为虚函数提供一个替代实现,他们将需要知道哪些方法被调用以维护对象的内部完整性。

不要在构造函数或析构函数中调用虚函数。这些调用将永远不会作用于子类((Meyers, 2005)。这不会影响API的功能,不过知道这个规则总是没有坏处的。

提示

应避免声明可覆盖(overridable 也就是虚的)函数,除非你有不得不这么做的有效理由。

2.3.3 便捷API(Convenience APIs)

让API尽量的小是一件困难的任务。要让API一边减少函数的数量,一边又要让API便于易于广大用户使用是很不容易的。这里有个绝大多数API设计者都要面对的问题:是要让API专注和集中于功能方面,还是做到便捷的封装(convenience wrappers)(这里的术语便捷的封装,是指封装了多个API调用的程序例程,提供更为简单和高层次的操作。)

另一方面,有个争论的地方是每个API应该只提供一种方式来执行一项任务。这就保证API的最小化,非常专一和容易易于理解。它也减少了实现起来的复杂度,还带来所有相关的稳定性、容易易于调试和维护。GradyBooch称之为原始性(primitiveness),也就是一种需要访问类的内部细节的方法能高效执行的特质,相对的非原始(non-primitive)方法是可以完全构建于原始方法之上,而不必访问任何内部状态(Booch et al., 2007)。

另一方面,还有一个观点论点就是每个API要让简单的问题容易易于解决。用户不应该被要求编写大量的代码来执行基本任务。这样做可能会导致引起样板代码块被复制并粘贴到源代码的其它部分;这样做随时会导致代码出现问题和增加离散度。另外,你或许希望API的用户群是这样的:能够进行很多控制并具有灵活性,且尽可能简单地解决一个单一的任务。

这些目标都是有用和可取的。幸运的是,它们他们并不相互排斥。这儿有几种方法在不削弱主要目的的情况下为核心API的功能提供高层次的便捷封装包装。最重要的一点是,你不需要把便捷API和同一个类的核心API混搭起来。相反,你可以创建一个辅助类,包装核心API的公共功能。这些便捷类应该完全和核心API隔离开来,例如,在不同的源文件、甚至是完全独立的库。这样做有额外的好处,能保证便捷API只依赖于核心API的公共接口,而不是任何内部方法或类。让我们看一个实例。

OpenGL API为编写2D和3D程序提供了与平台无关的特性。它操作的都是简单的几何形状,例如点、线和多边形,还可以进行变换、打灯光(lit)和栅格化到帧缓存。OpenGL的API是​​非常强大的,但是它是面向最底层的。例如,在OpenGL中创建一个球体是通过利用小而平滑的多边形作为表面来构建模型,如下面的代码片段演示:

[代码 P38 第一段]

for (int i = 0; i<= stacks;++i)

{

GLfloat stack0= ((i - 1.0) / stacks - 0.5) * M_PI;

GLfloat stack1= ((GLfloat) i / stacks - 0.5) * M_PI;

GLfloat z0 = sin(stack0);

GLfloat z1 = sin(stack1);

GLfloat r0 = cos(stack0);

GLfloat r1 = cos(stack1);

glBegin(GL_QUAD_STRIP);

for (int j = 0; j<= slices;++j)

{

GLfloat slice = (j - 1.0) * 2 * M_PI / slices;

GLfloat x= cos(slice);

GLfloat y= sin(slice);

glNormal3f(x * r0, y * r0, z0);

glVertex3f(x * r0, y * r0, z0);

glNormal3f(x * r1, y * r1, z1);

glVertex3f(x * r1, y * r1, z1);

}

glEnd();

}

然而,大多数OpenGL实现还包括OpenGL实用工具库,如GLU。这是一个构建在OpenGL API之上的API,它提供了更高级别的功能,例如生成mip-map(译者注:源于拉丁文multum in parvo,一种图形图像技术,在三维图像的二维代替物中实现达到立体感效应)、坐标转换、二次曲面(quadric surfaces)、多边形镶嵌(polygon tessellation)和简单相机定位。这些函数全部定义在OpenGL库中的完全独立的子库,而且这些函数都以glu前缀命名便和核心OpenGL API相区别。例如,以下代码片段显示了如何使用GLU来轻松地创建一个球体:

[图 P39 第一张]

图2.4

例子:核心API(OpenGL)和基于它的便捷API(GLU和GLUT)分隔开来

[代码 P39 第一段]

GLUquadric *qobj = gluNewQuadric();

gluSphere(qobj, radius, slices, stacks);

gluDeleteQuadric(qobj);

这是一个讲解如何维持最小化设计和关注核心API关注的好例子,而且还是一个容易易于使用API的额外例程。事实上,其它构建于OpenGL之上的API提供了更实用的类,例如Mark Kilgard’s OpenGL程序工具包(GLUT)。这个API提供了创建各种实心和线框的基本几何图形的功能(包括Utah茶壶),还有简单的窗体管理函数和事件处理。图2.4显示的是GL、GLU和GLUT间的关系。

Ken Arnold称之为逐步公开的概念,也就是说API应通过易于使用的接口呈现其基本功能,同时为高级功能保留一个单独的层(Arnold, 2005)。他指出,这一概念经常出现在GUI设计中,形如有更复杂功能的高级或导出按钮。这样,你仍热可以提供强大的API,同时确保专业用户使用时不至于混淆基本工作流程。

提示

添加的便捷API是作为构建在最小化核心API上的分离模块或库。

2.4 容易易于使用

一个设计良好的API应该让任务变得简单明了。例如,应该允许用户查看API的方法签名(method signature),还要能知道如何使用它而不需要任何其它额外的文档。这API品质符合极简主义(minimalism)的要求:如果API够简单,那么它应该是很容易理解的。同样,它也应该遵循最小差异规则(the rule of least surprise)。这是通过采用现有的模型和模式实现的,使用户可以专注于自己的任务而不会被你设计的接口所分心或迷惑(Raymond, 2003)。

当然,你不能以此为借口忽略支持文档的需要。事实上,它应该是让书写文档变得容易很多。众所周知,一个好的例子非常有帮助。样例代码对如何使用API大有帮助。优秀的开发人员应该可以通过阅读样例代码知道如何把API应用到他们自己的任务中去。

以下各节讨论各种使你的API更容易理解并最终更容易使用的内容方面和技巧,使你的API更容易理解并最终更易使用。在我这样做之前,应该指出的是API也可能为专家级用户提供复杂的功能,而且不是那么容易使用的。然而,这在一个简单的案例的情况下是不应该这么做的。

2.4.1 可发现(Discoverable)

可发现的API是指用户可以通过自己就知道如何使用API,而不需要通过解释或借助文档。举一个UI设计领域的反例,Windows XP的开始按钮并没有提供一个可发现的界面来定位关机选项。同样的,重启选项需要通过单击关机按钮才能找到。

可发现性并不一定导致易用性。例如,某个API可能对第一次使用的普通用户易于掌握,而对专家级用户来说难以通过正规方式使用。然而,在一般情况下,可发现性可以帮助你制作更有用的接口。

在设计API时,有多种方法来帮助你促进可发现性。可以通过制定一个直观的和富有逻辑的对象模型来实现,正如给类和函数取一个好名字。事实上,在API设计中,选择一个明确的、具有描述性的和适当的名称是最困难的任务。当在第四章讨论API设计技术时,我会给出类和函数命名的具体建议。避免使用缩写也可以促进可发现性(Blanchette, 2008)。因此用户不必记住API中是否使用了GetCurrentValue()、GetCurrValue()、GetCurValue()或GetCurVal()。

2.4.2 难于滥用(Difficult to Misuse)

一个良好的API,除了容易易用使用,也应该很难被滥用。Scott Meyers认为,这是最重要的通用接口设计指南(Meyers, 2004)。一些最常见的滥用API方式就是包含传递了错误的参数或非法值的方法。当有同类型的多个参数和用户忘记了参数的正确顺序或用int表示小范围的值来代替更有约束的枚举值时,这些是可能发生的(Bloch, 2008)。例如,看看下面方法签名:

[代码 P40 第一段]

std::string FindString(const std::string &text,

bool search_forward,

bool case_sensitive);

用户容易忘记第一个bool参数到底是搜索方向还是大小写敏感的标记。传递错误顺序的标记会导致难以预期的结果并可能导致用户耗费时间去调试这个问题,直到他们发现只要调换一下bool参数的位置就可以了。然而,你可以设计一个方法,通过引入枚举可以让编译器捕捉这种类型的错误。例如:

[代码 P41 第一段 勘误 在CASE_SENSITIVE后加入一个逗号[l2]]

enum SearchDirection {

FORWARD,

BACKWARD

};

enum CaseSensitivity {

CASE_SENSITIVE,

CASE_INSENSITIVE

};

std::string FindString(const std::string &text,

SearchDirection direction,

CaseSensitivity case_sensitivity);

这不仅意味着用户不能混淆这两个标志的顺序,因为它会生成一个编译错误,而且他们编写的代码更具有自我描述(self-descriptive)的特性。请对比:

[代码 P41 第二段]

result = FindString(text, true, false);

[代码 P41 第三段]

result = FindString(text, FORWARD, CASE_INSENSITIVE);

提示

使用枚举类型来代替布尔类型,可以提高代码的可读性。

对于更复杂的情况下,枚举是不够的,你设置可以引入新的类来确保每个参数都有一个唯一的类型。例如,Scott Meyers通过一个由三个整数构成的日期类说明了这种方法(Meyers, 2004, 2005):

[代码 P41 第四段]

class Date

{

public:

Date(int year, int month, int day);

...

};

Meyers指出,这种设计会让用户传入错误顺序的年、月和日的值,也可能指定非法值,例如13月。为了解决这个问题,他建议可以引入指定的特定的类来表示年、月和日的值。例如:

[代码 P41 第五段]

class Year

{

public:

explicit Year(int y) : mYear(y) {}

int GetYear() const { return mYear; }

private:

int mYear;

};

class Month

{

public:

explicit Month(int m) : mMonth(m) {}

int GetMonth() const { return mMonth; }

static Month Jan() { return Month(1); }

static Month Feb() { return Month(2); }

static Month Mar() { return Month(3); }

static Month Apr() { return Month(4); }

static Month May() { return Month(5); }

static Month Jun() { return Month(6); }

static Month Jul() { return Month(7); }

static Month Aug() { return Month(8); }

static Month Sep() { return Month(9); }

static Month Oct() { return Month(10); }

static Month Nov() { return Month(11); }

static Month Dec() { return Month(12); }

private:

int mMonth;

};

class Day

{

public:

explicit Day(int d) : mDay(d) {}

int GetDay() const { return mDay; }

private:

int mDay;

};

现在,Date类的构造函数可以通过这些Year、Month和Day类来表示了:

[代码 P42 第二段]

class Date

{

public:

Date(const Year &y, const Month &m, const Day &d);

.. .

};

使用这种设计,用户可以通过下面的明了和容易易于理解的语法创建一个新的Date对象。而且,所有指定错误顺序值的尝试都会导致编译期错误。

[代码 P42 第三段]

Date birthday(Year(1976), Month::Jul(), Day(7));

提示

要避免定义多个参数都是同一种类型的函数。

2.4.3 一致性(Consistent)

一个良好的API设计应该遵循一致性的设计原则,这样可以让它的约定容易易于记忆,也容易易于使用采用(Blanchette, 2008)。这适用于API设计的所有方面,如命名约定(naming conventions)、参数顺序(parameter order)、标准模式的使用(the use of standard patterns)、内存模型语义(memory model semantics)、异常使用(theuse of exceptions)和错误处理等(error handling)。

在上述这些的第一个方面,一致的命名约定意味着对整个API的相同概念重复使用相同的词汇表达。例如,如果你已决定使用适用动词对Begin(开始)和End(结束),就不该再混用Start和Finish来表达同样的意思。再举一个例子,Qt3 API混用了它的几个方法名的缩写词,例如,prevValue() 和 previousSibling()。这个例子指出应该不惜一切代价避免这样使用缩写词。

采用一致的方法签名对设计品质也是同样重要的。如果你有几个方法接受类似的参数列表,你应尽力保持这些参数有一致的数量和顺序。举一个反例,下面讲述一个标准C库中的函数:

[代码 P43 第一段]

void bcopy(const void *s1, void *s2, size_t n);

char *strncpy(char *restrict s1, const char *restrict s2, size_t n);

这两个函数都是要从内存的某个区域拷贝n字节的数据到另一区域。然而,bcopy()函数是从s1拷贝数据到s2,而strncpy()是从s2拷贝到s1。如果开发人员没有仔细阅读这两个函数的使用说明,那么就容易在交替使用这两个函数时产生导致难以察觉的内存错误。可以肯定的是,这里存在有规范冲突的函数签名中存在与规范冲突的地方:请注意每种情况下使用的const指针。然而,如果源指针(source pointer)没有声明成const的话情况下那么这是容易被忽视且不会被编译器捕捉到。

还要注意下不一致的单词:“copy”和“cpy”。

让我们再看一个标准C库的例子。大家熟悉的malloc()函数是用来分配一个连续的内存块,calloc()函数执行相同的操作,此外还用0字节初始化保留的内存。然而,尽管它们有类似的目的,但是它们有不同的函数签名:

[代码 P43 第二段]

void *calloc(size_t count, size_t size);

void *malloc(size_t size);

malloc()函数接收的单位大小是以字节计是以字节为大小,而calloc()分配(以字节的倍数计)多个字节。除了不一致外,还违反了最小差异的原则。做为作为另一个例子,read()和write()标准C函数接收一个文件描述符(file descriptor)做为作为它们的第一个参数,而fgets()和fputs()函数需要在最后指定文件描述符(Henning, 2009)。

提示

使用一致的函数命名和参数顺序。

这些例子都集中在函数或方法的级别,当然,这在类的级别也是同样重要的。类也有相似的规则提供相似的接口。STL是一个关于这个的好例子。std::vector、std::set、std::map和std::string类都提供了size()方法,用来返回容器中元素的个数。因为它们也都支持迭代功能,只要你知道如何在std::set上迭代,你也就一样会在std::map上使用迭代。这使得更容易易于记忆API中的编程模式。

你可以通过多态性来轻松获得这种类型的一致性:把可共享的功能放到一个公共基类里面去。然而,不是所有的类都得从一个公共基类中继承,因为这样会增加复杂性和接口所在的类的数量。值得注意的是STL容器类并不是从公共基类中继承过来的。你应该明确地为这个设计通过手动来确定类中的公共概念,并使用相同的约定来表示每个类的这些概念。这通常称为静态多态(static polymorphism)。

你也可以使用C++模板来帮助你定义和应用这种类型的一致性:例如,你可以使用模板创建一个2D坐标类,适用于整数、浮点数和双精度。这样做的话,你要保证坐标的每个类型都提供相同的接口。下面的代码示例提供了一个简单的例子:

[代码 P44 第一段]

template <typename T >

class Coord2D

{

public:

Coord2D(T x, T y) : mX(x), mY(y) {};

T GetX() const { return mX; }

T GetY() const { return mY; }

void SetX(T x) { mX = x; }

void SetY(T y) { mY = y; }

void Add(T dx, T dy) { mX += dx; mY += dy; }

void Multiply(T dx, T dy) { mX *= dx; mY *= dy; }

private:

T mX;

T mY;

};

在这个模板定义中,你可以创建如下类型变量:Coord2D<int>、Coord2D<float>和Coord2D<double>,这些全部都有相同的接口。

一致性的更深方面的应用是关于熟悉的模式和标准平台风格(standard platform idioms)。当你买了一辆新车,你不需要重新学习如何开。使用刹车、油门、方向盘和变速杆(手动挡或者自动挡)的方法全世界都是一样的。如果你会开某种车,你也很可能会开另一种类似的车,即使两种车是不同的厂商型号都不同或者方向盘在不同的一侧。

同样地,最简单的API就是要让用户只需要花最少的精力去学习。例如,大部分C++ 开发人员都熟悉STL和它的容器类和迭代的使用。因此,如果你也要编写这样的API,不妨套用STL的设计模式,这样的话其他开发人员也会更熟悉如何使用你的API。

2.4.4 直角(Orthogonal)

在数学中,如果两个向量是互相垂直的(成90度),就称为直角,即内积(inner product)为零。这使它们线性无关,这意味着无标量集可以被应用到第一个向量以生成第二个向量。举一个地理上的比喻,垂直方向的正东边和正北边是相互独立的:你朝东边怎么走都到不了北部。按照计算机的术语上来讲,修改东部的坐标不会对北部坐标造成任何影响。

就API设计而言,相互垂直相当于方法不会有副作用。调用一个设置特定属性的方法应该只会改变属性值而不会改变其它公共可访问的属性。因此,对API的某个实现部分做出修改不会影响API的其它部分(Raymond, 2003)。

这种相互垂直的设计方法让API更加容易理解。更进一步,这样的代码不会有副作用,或者依赖于其它代码的副作用,而且更加容易易于开发、测试、调试和修改,因为它的影响更加局部化本地化和有边界(Hunt and Thomas, 1999)。

提示

一个垂直的API意味着函数没有副作用。

让我们看一个特定的例子。或许你呆过某个汽车旅馆,里面的淋浴设备非常不直观。你想要需要设置水的流量和温度水温,你可能只想通过一种复杂和非显而易见不显著的方式来进行单一的控制,并影响这两个属性。这个可以采用下面的API模型:

[P45 代码 第一段]

class CheapMotelShower

{

public:

float GetTemperature() const; // units = Fahrenheit

float GetPower() const; // units= percent, 0..100

void SetPower(float p);

private:

float mTemperature;

float mPower;

};

再来一个更深一点的演示,让我们看看下面这个类的公共方法:

[P46 代码 第一段]

float CheapMotelShower::GetTemperature() const

{

return mTemperature;

}

float CheapMotelShower::GetPower() const

{

return mPower;

}

void CheapMotelShower::SetPower(float p)

{

if (p < 0) p= 0;

if (p > 100) p = 100;

mPower = p;

mTemperature= 42.0f+ sin(p/38.0f) * 45.0f;

}

在这个例子中你可以看到设置水流的流量会通过非线性的关系影响到水温。因此,不可能把温度和流量组合起来,比较喜欢的既要热水和又要最大流量的组合是难以实现的。而且,你要是修改了SetPower()方法的实现,就会对GetTemperature()方法产生影响。在一个更复杂的系统,我们程序员可能会忽略掉互相依赖,或者根本没留意,因此在代码某处的修改都可能对系统的其它部分带来很大的影响。

让我们考虑一个理想的、符合垂直设计的淋浴系统控制水温和流量的控制是相互独立的:

[代码 P46 第一段]

class IdealShower

{

public:

float GetTemperature() const; // units = Fahrenheit

float GetPower() const; // units= percent, 0..100

void SetTemperature(float t);

void SetPower(float p);

private:

float mTemperature;

float mPower;

};

float IdealShower::GetTemperature() const

{

return mTemperature;

}

float IdealShower::GetPower() const

{

return mPower;

}

void IdealShower::SetTemperature(float t)

{

if (t < 42) t = 42;

if (t > 85) t = 85;

mTemperature= t;

}

void IdealShower::SetPower(float p)

{

if (p < 0) p= 0;

if (p > 100) p = 100;

mPower = p;

}

设计一个符合垂直设计的API有两个重要的因素,如下所示:

(1).减少冗余 确保相同的信息只会用一种方式来表达。每份信息都只有单个权威的来源。

(2).增加独立性 确保对外暴露的概念部分不发生重叠。任何重叠的概念都要分解成更基础的组件。

垂直设计模式的另一个流行的解释就是:不同的操作都可以应用到每个可用的数据类型。这个定义在编程语言领域和CPU设计中是非常普遍的。对于后一种情况,垂直指令集是指那些可以使用CPU的任何寄存器的寻址模式,而非垂直指令是指那些只能使用特定寄存器的指令。同样,就API设计而言,STL有一个很好的例子。它提供了一个通用的算法和迭代器,可以应用到任何容器中去。例如,std::count算法可以应用到任何std::vector、std::set或 std::map容器中去。因此,算法的选择是不依赖于所使用的容器类的。

2.4.5 健壮的资源分配(Robust Resource Allocation)

C++编程中最棘手的问题之一是内存管理。对使用托管语言的用户来说尤其如此,如Java或C#,对象是由垃圾收集器自动释放的。相比之下,C++的大部分错误都是因为指针或者引用的误用所造成的,例如:

qNull 非关联化(Null dereferencing):试图对NULL指针使用->或*操作符。

q双重释放(Double freeing):在某个内存块中两次调用delete 或 free()。

q访问无效的内存(Accessing invalid memory):试图对未分配或已经释放的指针使用->或*操作符。

q混合分配器(Mixing Allocators):使用delete释放由malloc()分配的内存或使用free()来返回新分配的内存。

q错误的数组释放(Incorrect array deallocation):使用delete来代替delete []释放数组。

q内存溢出(Memory leaks):当用完时未及时释放内存块。

出现这些问题是因为编译器不可能判断一个普通的C++指针是指向有效的内存还是指向未分配或已释放的内存。因此,这得靠程序员来跟踪此状态并确保指针不会被错误地解除引用。然而,正如我们知道的,程序员也是会犯错的。不过,这些特殊种类的问题还是能够通过使用托管(或者智能)(managed (or smart))指针来避免的,如下所示:

(1).共享指针(Shared pointers)这是引用计数指针,当有某段代码开始使用指针时,引用计数会增加,当使用指针完毕后减少。当引用计数变为0时,指针指向的对象就会被自动释放。这种指针可以避免释放内存的问题,确保你需要使用时指针是有效的。

(2).弱指针(Weak pointers)一个弱指针包含一个指向对象的指针,通常是一个共享指针,不过并不用来给该对象进行引用计数。如果你有一个共享指针和弱指针引用同一个对象,当共享指针被销毁时,弱指针也立刻变成NULL。通过这种方式,弱指针可以用来检测对象是否已经过期:如果该对象指向的引用计数为0。这有助于避免悬空指针(dangling pointer)的问题,你可以获得一个引用已释放内存的指针。

(3).局部指针(Scoped pointers)这些指针支持单个对象独占并在指针出了作用域的时候自动释放对象。有时候也叫做自动指针。局部指针被定义成拥有单个对象并且不能被复制。

这些智能指针并不属于最初的C++98规范。不过,它们被列入TR1(技术报告1),这是一个新的C++功能提议(ISO/IEC, 2007)。它们也包括在计划的C++标准中,叫做C++0x。与其同时,Boost库为智能指针提供了可移植便携、开源的实现,包括boost::shared_ptr、boost::weak_ptr、和 boost::scoped_ptr。

使用这些智能指针可以使你的API更容易易于使用,不容易出现前面提到的那些内存错误。例如,使用boost::shared_ptr可以明确减少用户释放动态创建的对象的需要。对象在不被引用时会自动删除掉。例如,考虑一个API,允许你通过CreateInstance()的工厂方法创建对象的实例:

[代码 P48 第一段]

#include <boost/shared_ptr.hpp>

typedef boost::shared_ptr <class MyObject> MyObjectPtr;

class MyObject

{

public:

static MyObjectPtr CreateInstance();

~MyObject();

private:

MyObject(); // must use factory method to create instance

};

工厂方法的实现:

[代码 P49 第二段]

MyObjectPtr MyObject::CreateInstance()

{

return MyObjectPtr(new MyObject());

}

有了这个API,用户可以创建一个MyObjects的实例,如下所示:

[代码 P49 第三段]

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

{

MyObjectPtr ptr = MyObject::CreateInstance();

ptr = MyObject::CreateInstance();

return 0;

}

在这个例子中,创建了两个MyObject实例,这两个实例都会在ptr变量超出范围后被销毁(在这个例子中是程序的末尾)。反之,如果让CreateInstance()方法只返回一个myObject*类型,那么之前给定的析构函数都不会被调用。因此,智能指针的使用让内存管理变得更容易了,从而让你的API也易于被用户使用用户能够更容易地使用API

一般说来,如果有一个函数,返回一个指针,用户应该自行删除或者你期望用户在项目中可以比你的对象生命周期更长久地保留这个指针,那么你就应该使用一个智能指针例如,boost::shared_ptr。不过,如果指针是由你的对象所保留的你的对象要保留指针那么你可以返回一个标准指针。例如:

[代码 P49 第段]

// ownership of MyObject* is transferred to the caller

boost::shared_ptr<MyObject > GetObject() const;

// ownership of MyObject* is retained by the API

MyObject* GetObject() const;

提示

如果用户客户负责释放指针,那么可以通过使用智能指针返回一个动态分配的对象。

值得一提的是这些内存管理的问题仅仅是资源管理这个更普遍的类别中一特定的情况。例如,在互斥锁操作或文件处理中也会遇到同类型的问题。智能指针的概念可以推广到资源管理任务中去,这需要注意下面如下内容:资源分配就是对象构造,资源释放就是对象销毁。这个也缩写成RAII,表示获得资源就是初始化(Resource Acquisition Is Initialization)。

举个例子,检查如下的代码,举例说明了一个典型的同步错误:

[代码 P505 第一段]

void SetName(const std::string &name)

{

mMutex.lock();

if (name.empty()) {

return;

}

mName= name;

mMutex.unlock();

}

显然,如果有空字符串传入方法,这段代码将无法解锁互斥体(mutex)。因此,当下次尝试调用方法时,程序会遇到死锁,因为互斥体仍然被锁定。然而,你可以创建一个ScopedMutex类,它的构造函数锁定互斥体,析构函数用来解锁。要实现这样的类,你得重写上面的方法,如下所示:

[代码 P50 第二段]

void SetName(const std::string &name)

{

ScopedMutex locker(mMutex);

if (name.empty())

return;

mName=name;

}

现在你可以放心了,无论方法什么时候返回,锁都会被释放,因为只要ScopedMutex变量超出范围,互斥体就会被解锁。因此,你不需要认真检查每个返回语句以确保它们已经释放了锁。更好的是,这段代码可读性更好。

就API设计而言,应该记住的一点是如果API需要对某些资源进行分配和释放,那么你就该考虑提供一个类来管理,在构造函数中分配资源,在析构函数中释放资源(也可通过公共Release()方法来实现,这样用户在资源释放时,可以进行更多的控制)。

提示

可以把资源分配和释放看成是对象的构造和析构。

2.4.6 平台独立性(Platform Independent)

一个设计良好的API总是应该始终避免固定在某个平台上,在公共头文件中使用#if或#ifdef。如果API为问题域提供高层次的逻辑模型,那么这个API不应该因平台不同而不同。唯一的例外情况是当你编写的API针对的接口是特定平台的资源,例如在窗口中进行绘制的例程和需要适当的窗口句柄传入到原生的操作系统。除非这些情况下,你不该在特定平台的公共头文件中使用#ifdef代码行。

例如,让我们看一个API,它封装了移动电话提供的功能。一些移动电话内置有GPS设备,可提供手机的地理位置,但并非所有的设备都有这种功能。然而,你千万不要通过你的API来对外暴露这情况,示例代码如下如下例子所示

[代码 P51 第一段 勘误 GetGPSLocation函数中传入的应该是指针,而不是引用[l3]]

class MobilePhone

{

public:

bool StartCall(const std::string &number);

bool EndCall();

#if defined TARGET_OS_IPHONE

bool GetGPSLocation(double &lat, double &lon);

#endif

};

这个糟糕的设计让不同的平台上要创建不同的API。这样做会强迫使用API的用户也把平台专一的特性引入他们自己的程序。例如,在上面的例子中,用户要保证在所有调用GetGPSLocation()的地方使用同样的#if条件语句,否则在其它平台上,代码可能因为出现未定义符号错误而编译失败。

此外,如果在以后的API版本中还添加了其它设备的支持,如Window手机,那么你就必须更新公共接口头文件中#if代码行,其中要包括_WIN32_WCE。用户客户不得不在代码中寻找所有嵌入TARGET_OS_IPHONE定义并扩展到也包含_WIN32_WCE。这是因为你已经在不知不觉中暴露了API的实现执行的细节。

取而代之的是,你应该隐藏只能在一个平台运行的函数行为,并提供一个方法来确定给定的方法是否符合当前平台的所需的功能。例如:

[P51 代码 第二段]

class MobilePhone

{

public:

bool StartCall(const std::string &number);

bool EndCall();

bool HasGPS() const;

bool GetGPSLocation(double &lat, double &lon);

};

现在所有平台上的API都是一致的了,并未对外暴露平台支持的GPS坐标的细节。用户通过调用HasGPS(),可以编写代码检查当前设备是否支持GPS设备。如果可以的话,他们可以调用GetGPSLocation()方法返回实际的坐标。HasGPS()方法大概就像这样:

[代码 P52 第一段]

bool MobilePhone::HasGPS() const

{

#if defined TARGET_OS_IPHONE

return true;

#else

return false;

#endif

}

提示

千万不要把特定平台的#if或#ifdef语句放入公共API。它除了对外暴露细节外还让API因不同的平台而不同(这样就无法跨平台了)。

2.5 松耦合(LOOSELY COUPLED)

1974年,Wayne Stevens、Glenford Myers和Larry Constantine发布了影响深远的关于结构程序设计的论文。该论文引入了两个相关联的概念:耦合(coupling)和内聚(cohesion)(Stevens 等 1974),定义成:

q耦合 度量软件组件之间的关联强度力度,也就是说,其程度决定了系统中每个组件对其它组件的依赖。

q内聚 度量单个软件组件内部各种函数之间的聚合度或关联的强度。

优秀的软件设计倾向于低的(松散的)耦合度和高的内聚度,也就是说,设计要将不同组件间功能的关联性减到最小。要达到这个目标应该允许组件在使用、理解和维护时都要相互独立的。

提示

优秀的API应同时具备松耦合和高内聚。

Steve McConnell提出了一个松耦合的特别生动的比喻。模型火车使用简单的挂钩或关节耦合器把车厢连接起来。这种方式可方便的连接车厢,通常只需要把两个车厢放在一起(通过单个连接点连接起来)。因此,这是一个松耦合的例子。想象一下,如果是连接汽车,你得使用多种类型的连接,可能需要螺丝和金属丝,或者某种车只能和其它某种类型的车相连接,那会是多么困难啊(McConnell, 2004)!

有一种考虑耦合的方式,给定两个组件A和B,看看当A改变时,B中有多少代码也需要改变。有各种度量方法用来评估组件之间的耦合度:

q大小(Size) 这涉及到组件间连接的数量,包括类、方法和每个方法中参数的数量等例如,当组件调用方法时,这个方法的参数越少,耦合度也就越低。

q可见性(Visibility) 是指组件间连接的显著程度。例如,改变一个全局变量,间接影响另一个组件的状态,则这种可见性水平就比较糟糕。

q亲密度(Intimacy) 是指组件间连接的直接程度。如果A耦合到B,而B又耦合到C,那么A就间接和C也是耦合的。另一个例子是从一个类继承,这是比把这个类做为作为成员变量(组合)更紧密的耦合,因为继承类也可以访问这个类中所有受保护的成员。

q灵活性(Flexibility) 是指组件间连接的缓和程度。例如,如果对象A中的方法签名需要改变,以便对象B可以调用它,那么修改这个方法的难易程度取决于对代码的依赖。

一个特别令人厌恶的紧耦合形式总是应该避免让两个组件直接或间接地相互依赖,也就是一个依赖循环或循环依赖。这样就很难或者不可能在未包含所有的循环依赖组件时重用组件。这种紧耦合形式将在第四章中进一步讨论。

以下各节将介绍各种技术,以减少API(跨API耦合)中类和方法之间的耦合度。

不过,还有一个有趣的问题:你的API设计决策是如何影响用户的应用程序(内部API耦合)的内聚度和耦合度的。因为API是设计用来解决某个具体问题的,应该可以很好地把API转化成在用户程序中具有高内聚性的组件。然而,就耦合而言,API越庞大,对外暴露的类、方法和参数也越多,则访问和连接到用户程序的API方式也越多。因此,最低限度的完整性质量也有助于松耦合。还有一个问题是,其它设计到API中的组件耦合度。例如,libpng库依赖于libz库。这个耦合是在编译时和链接时(link time)通过zlib.h头文件(在png.h中)暴露的。这需要libpng的用户知道libz依赖性和确保他们也编译并链接了这个附加库。

2.5.1 只通过名字耦合(Coupling by Name Only)

如果A类只需要知道B类的名字,也就是说,它并不需要知道B类的大小或调用类中的任何方法,那么A类就不需要依赖B类的完整声明。在这种情况下,你可以给B类使用前置声明(forward declaration),而不是包含(include)整个接口,从而降低了两个类的耦合度(Lakos, 1996)。例如:

[代码 P54 第一段]

class MyObject; // only need to know the name of MyObject

class MyObjectHolder

{

public:

MyObjectHolder();

void SetObject(MyObject *obj);

MyObject *GetObject() const;

private:

MyObject *mObj;

};

在这个例子中,如果关联的.cpp文件只用来储存和返回MyObject指针,并限制任何与它交互的指针比较,那么它就不需要#include “MyObject.h”。在那种情况下,MyObjectHolder类可以从MyObject的物理实现来解耦。

提示

为类使用前置声明,除非你确实需要#include它的完整定义。

2.5.2 降低类耦合

Scott Meyers建议,只要你可以选择的话,你应该把函数声明为一个非成员非友元(non-member non-friend)函数,而不是成员函数(Meyers,2000)。这样做可以提高封装性并降低类中那些函数的耦合度。例如,考虑下面的类代码片段,提供了一个PrintName()成员函数来输出成员变量的值到标准输出(stdout)。该函数使用一个公共的getter方法GetName(),用来检索成员变量的当前值。

[代码 P54 第二段]

// myobject.h

class MyObject

{

public:

void PrintName() const;

std::string GetName() const;

.. .

protected:

.. .

private:

std::string mName;

...

};

根据Meyers的建议,你应该使用下面的方式:

[代码 P55 第一段]

// myobject.h

class MyObject

{

public:

std::string GetName() const;

...

protected:

...

private:

std::string mName;

...

};

void PrintName(const MyObject &obj);

后面的这段代码降低了耦合,因为PrintName()函数只能访问MyObject的public方法(在这个特例中只有const public 方法)。反之,PrintName()成员函数,它也可以访问所有的MyObject的private和protected成员函数和数据成员,也包括基类所有的protected成员(如果有的话)。函数使用非成员非友元形式意味着没有连接到类中的内部细节。因此,当MyObject内部细节发生改变时,它就不大可能产生影响(Tulach, 2008)。

该项技术还有助于最低限度完整性接口,类中只包含最小的功能实现,功能是构建在类中对外声明的public接口之上(如先前讨论过的便捷API的例子)。值得注意的是在STL中,这是很常见的,每个容器类中的算法std:: for_each() 和 std::unique()都有对外声明。

为了更好地表述MyObject 和 PrintName()概念相关性,你可以把它们他们声明到单个名空间中去。或者,你可以把PrintName()声明在它自己的名空间中,如MyObjectHelper,或作为在辅助类(helper class)MyObjectHelper中的静态函数。正如在便捷API部分提到过的一样,这个辅助名空间可以也应该包含在一个独立的模块中。例如:

[代码 P55 第一段]

// myobjecthelper.h

namespace MyObjectHelper

{

void PrintName(const MyObject &obj);

};

提示

使用非成员非友元函数代替成员函数来降低耦合性。

2.5.3 故意冗余(Intentional Redundancy)

通常,良好的软件工程的目标是移除冗余:确保每个重要的行为仅实现一次(Pierce, 2002)。然而,代码重用意味着耦合,有时值得添加少量的冗余来切断过分的耦合关系(Parnas, 1979)。这种故意的重复会以代码或数据冗余的方式显现出来。

举个代码冗余的示例,假设有两个相互依赖的大型组件。当你要更深入地探讨依赖时,发现它解析为一个组件依赖于另一个的一小块功能之上,例如计算最小值或最大值的函数。标准的做法是把这个低级别的小块功能独立出来,这两个组件也就不会因为这小块功能而相互依赖。然而,有时候这种重构是没有意义的,例如,如果要实现的功能是不通用的,无法分解成系统中的低级别功能模块。因此,在某些情况下,它实际上是有意义的重复代码,以避免耦合(Lakos, 1996)。

对增加数据冗余来说,考虑下面的文本聊天系统API,日志记录了每个用户发送的信息:

[代码 P56 第一段]

#include "ChatUser.h"

#include <string >

#include <vector >

class TextChatLog

{

public:

bool AddMessage(const ChatUser &user, const std::string &msg);

int GetCount() const;

std::string GetMessage(int index);

private:

struct ChatEvent

{

ChatUser mUser;

std::string mMessage;

size_t mTimestamp;

};

std::vector <ChatEvent> mChatEvents;

};

这个设计接受个人的文本聊天事件,接受一个描述用户的对象,还有他们输入的信息。此信息包含当前的时间戳并添加到内部列表中。GetCount()方法用来计算文本聊天事件触发的次数,GetMessage()方法返回一个给定的聊天事件的格式化版本,例如:

[代码 P57 第一段]

Joe Blow [09:46]: What’s up?

然而,毫无疑问,TextChatLog类和ChatUser类耦合,它可能是个重量级的类,涉及到很多其它的依赖。因此,你可能想知道这种情况,找到那些使用昵称的用户的TextChatLog,也就是说,它把ChatUser对象放在一边,只调用了ChatUser::GetName()方法。移除两个类之间的耦合度的一个解决方案是只简单地把用户的昵称传入TextChatLog类,请看下面重构过的版本:

[代码 P57 第二段]

#include <string >

#include <vector >

class TextChatLog

{

public:

bool AddMessage(const std::string &user, const std::string &msg);

int GetCount() const;

std::string GetMessage(int index);

private:

struct ChatEvent

{

std::string mUserName;

std::string mMessage;

size_t mTimestamp;

};

std::vector <ChatEvent> mChatEvents;

};

这个创建用户昵称的冗余版本(现在它是存储在TextChatLog和ChatUser类中),不过它打破了两个类之间的依赖关系。这也带来额外的好处,如果sizeof(std::string)< sizeof(ChatUser)的话,那么将减少TextChatLog的内存开销。

然而,即使这是一个故意的重复,它仍然是一种冗余,要特别小心、充分考虑和遵循优秀建议听取优秀的意见后再使用。例如,如果聊天系统进行了更新,允许用户更改自己的昵称,并决定在发生这种情况后,该用户所有以前的消息都要更新,以显示他们的新昵称,那么你必须恢复到原始的紧密耦合的版本(或风险更大的耦合是当发生昵称改变时,ChatUser需要通知TextChatLog)。

提示

数据冗余有时候可以减少类之间的耦合。

2.5.4 管理类(Manager Classes)

管理类就是一个拥有和协调一些低级别类(lower-level classes)的类。这可以用来打破一个或多个构建在低级别类的集合上的类的依赖性。例如,考虑一个结构化的绘图程序,让你创建一个2D对象、选择对象和在画布上移动。该程序支持多种输入设备,让用户选择和移动物体,如鼠标、手写板和游戏杆。一种比较幼稚的设计是同时需要选择和移动操作,来了解每种输入设备,如UML图示(图2.5)。

或者,你引入一个管理类来协调每种特定输入设备的类。在这种方式中,SelectObject和MoveObject类只需要依赖单个管理类,而管理类只需要依赖单独的输入设备类。这或许需要为基础类创建某种形式的抽象。例如,注意一下MouseInput、TabletInput和JoystickInput都有一个稍微不同的接口。这个管理类可以设立一个通用输入设备接口,抽象了特定设备的细节。改进后,让耦合更加松散了,设计如图2.6所示。

请注意,这种设计的尺度也是不错的。这是因为可以添加更多的输入设备到系统,而不会给SelectObject或MoveObject对象带来任何更多的依赖。此外,如果你要添加额外的操作对象,如RotateObject和ScaleObject,它们只需要单独依赖一个InputManager类,而不需要依赖于每个基础设备类都耦合的类。

[图 P58 第一张]

图2.5

多个高级别类(high-level classes)各自和几个低级别类耦合。

[图 P59 第一张]

图2.6

利用一个管理类来减少降低低级别类的耦合。

提示

管理类可以通过封装几个低级别类来降低耦合。

2.5.5 回调、观察者和通知(Callbacks, Observers和Notifications)

我要讲述最后一个技术,关于降低耦合性,是API中涉及到当某个事件触发时,通知其它类的问题。

试想一个3D在线多人游戏,允许多个用户相互对战。在内部,每个玩家都可以用一个唯一标识符来表示,或UUID,如e5b43bba-fbf2-4f91-ac71-4f2a12d04847。然而,用户却要看到其他玩家的名字,而不是难以理解的UUID字符串。因此,这个系统实现了一个玩家名字的缓存,NameCache来存储和UUID相对应的、人们可读的名字。

现在,比方说管理赛前大厅的类,PreGameLobby,要显示每个玩家的名字。下面显示一些可能的操作:

(1).PreGameLobby类调用NameCache::RequestName()

(2).NameCache发送请求到游戏服务器,为了获取与玩家UUID相关联的名字

(3).NameCache从服务器接收玩家名字信息

(4).NameCache调用PreGameLobby::SetPlayerName()

然而,在这个例子中,PreGameLobby依赖于NameCache调用RequestName ()方法,且NameCache依赖PreGameLobby调用SetPlayerName()方法。这是一个过于脆弱和紧耦合的设计。考虑一下下面的情况会发生什么:如果游戏中的系统还需要知道玩家的姓名显示与用户前为了玩家的名字能显示在用户之上,系统还需要知道玩家的名字。你扩展NameCache并调用the InGame::SetPlayerName(),让耦合更紧密吗?

一种更好的解决方法是设计一个PreGameLobby和InGame类的注册,用来关注来自NameCache的更新。然后,NameCache可以通知任何感兴趣的部分而无需对那些模块产生直接的依赖。这有几种方式可以做到这一点,如回调、观察者和通知。我会马上讲解这些细节的,但是在这之前,还需要探讨在使用这些模式时出现的普遍问题。

q重入(Reentrancy)当编写API调用未知用户代码时,应该考虑到这些代码可能回调API。事实上,客户端可能没有意识到这种情况正在发生。例如,如果你正在处理对象的队列,发出一个回调处理每个单独的对象,回调有可能尝试通过添加或移除对象来修改队列的状态。至少,API应该防止这种行为产生编码错误。然而,一个更优雅的解决方法是允许这个重入行为,执行你的代码,使它保持一致的状态。

q生存期管理(Lifetime management)用户需要有一个清晰的方式来从API断开,也就是说,对外宣布他们不再对接收更新感兴趣。当用户对象被删除时,这一点尤其重要,因为进一步尝试给其发消息可能导致崩溃。同样,API也要防止重复注册,避免为相同事件多次调用用户代码。

q事件顺序(Event ordering)对你的API用户来说,回调或通知的顺序应该很清楚。例如,Cocoa API很清楚地告诉你通知是在某某之前还是之后发送的或者事件使用这样的名称:willChange 和 didChange。然而,Qt工具包就没有这样子了:某个更改信号的发送有时发生在对象实际更新之前。

这些要点是你应该让用户清楚地知道哪些是能做的、哪些是不能做的。在他们的回调代码中,对你的API,哪些假设是可以的,哪些是不可以的。这个可以通过API文档来完成,也可以通过给定一个限制接口的回调来更明确的强制,这个接口只暴露所有可能的操作的一个安全的子集。

回调

在C/C++中,回调是指向函数的指针,是在模块A中被传递到模块B,这样B就可以在适当的时机调用A中的函数。模块B对模块A一无所知且并未包含A或对A有连接依赖。这种回调允许低级别代码执行高级别代码且不产生连接依赖。因此,在大型项目中,回调是一种非常流行的技术,不会造成循环依赖。

有时,在回调函数中也提供一个“封闭”(译者注:闭包函数)。这是一段从模块A传递到模块B的数据,模块B中包含回调到A的函数。这种方式可以使模块A把一些需要传递的重要状态放到回调函数中去。

下面的头文件说明了在C++中如何定义一个简单的回调API:

[代码 P61 第一段]

#include <string >

class ModuleB

{

public:

typedef void (*CallbackType)(const std::string &name, void *data);

void SetCallback(CallbackType cb, void *data);

...

private:

CallbackType mCallback;

void *mClosure;

};

这个类可以调用回调函数,如果已设置好,代码如下所示:

[代码 P61 第二段]

if (mCallback)

{

(*mCallback)("Hello World", mClosure);

}

当然,一个更复杂的例子是支持添加多个针对模块B的回调,可以把它们存储在一个std::vector,然后依次调用每个已注册的回调。

在面向对象C++程序中,使用回调会带来一个问题,那就是回调得用非静态(实例)方法。这是因为也需要传递对象的“this”指针。本书附带的源代码通过创建一个静态包装方法来实现这些,该方法是实例方法回调和把传递的“this”指针作为额外的回调参数。

Boost库为这个问题也提供了更优雅的解决方案:boost::bind功能。这是通过使用函子(functor,带状态的函数)实现的,C++中的函子可以让带有私有成员的类保存状态和重载operator()来执行函数。

观察者

回调在纯C程序中表现良好,但正如刚才指出的那样,在没有使用诸如boost::bind时,在面向对象C++编程中使用回调会让人费解。一个更加面向对象的解决方案是使用观察者的概念。

这是一种软件设计模式,通过一个对象保持它的依赖对象的列表,并通过调用它们其中之一的方法来通知它们。这是最低限度耦合API设计中一种重要的模式。事实上,在模式那章中有一整节在讲述这些。因此,我会推迟介绍观察者模式的细节。

通知

回调和观察者模式往往是为特定任务创建的,使用它们的机制通常是定义在对象中,对象需要执行回调。另一种可选的方法是在系统未连接的部分建立一个集中的机制来发送通知或者事件。发送方不需要事先知道接收方,这样就可以降低发送方和接收方的耦合度。这里有几种通知设计,不过最流行的是标志和槽(signals and slots)。

标志和槽的概念是由Qt库引入的,做为作为一种允许任何事件的通用方法,例如按钮单击或计时器事件,可以发送到任何需要响应的方法上去。然而,现在有几种替代实现标志和槽的纯C++代码,包括Boost的 boost::signals和boost::signals2库。

标志可以简单地认为是多个目标(槽)的回调。所有标志的槽都会在标志被请求时调用,或者“发出”。这里一个具体的例子,下面的代码片段使用boost::signal来创建一个不带参数的标志。接着,你把一个简单的对象连接到标志。最后,你发出标志,这将导致MySlot::operator()被调用,结果就是把一个消息打印到stdout(标准输出)。

[代码 P62 第一段]

class MySlot

{

public:

void operator()() const

{

std::cout << "MySlot called!" << std::endl;

}

};

// Create an instance of our MySlot class

MySlot slot;

// Create a signal with no arguments and a void return value

boost::signal <void () > signal;

// Connect our slot to this signal

signal.connect(slot);

// Emit the signal and thereby call all of the slots

signal();

在实际应用中,低等级类可以创建和拥有一个标志。然后,它允许任何无关的类添加它们自己到这个标志的槽。接着,低等级类可以在任何适当的时候发出标志,且所有连接的槽都会被调用。

2.6 稳固、文档化和测试           (STABLE, DOCUMENTED, AND TESTED)

一个设计良好的API应该是稳定和经得起时间考验的面向未来的。在这里,稳定并不意味着API是一成不变的,而是接口需要版本化和在下个版本中也要保持兼容。与此相关的是,面向未来经得起时间考验的意思是API应设计成可扩展的,能够优雅地改进,而不是变得混乱不堪。

一个好的API也应该有好的文档,这样用户就可以清楚地知道API的功能、行为、最佳用法和错误条件等。最后,应该有这样的API实现,为API进行大量的自动化测试,这样就可以有信心对API进行新的改动而不会影响破坏已经在用的案例。

这些主题已经在本章的末尾凝结成一个整体,不是因为它们不重要,事实上正好相反。要开发高品质、健壮和易用的API是非常重要的,我会用专门的章节来讲解每个主题。

因为我觉得拿一个章节讲解API质量应该至少涵盖这些重要的主题,所以这里先提一下。然而,对于具体细节,你可以参阅版本控制、可扩展性、文档和测试章节,会有详尽描述的将全面涵盖每个相关的主题

 因为API是写给程序员使用的,所以这里统一翻译成用户,而不使用客户一词。

 原书勘误 P41

 原书勘误 P51

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值