那些优质API 的特征

那些优质API的特征

作者/Martin Reddy

是软件行业的一名老兵,有着15年以上的从业经验,共撰写过40多篇论文,拥有3项软件专利,并与他人合著了Level of Detail for 3D Graphics。另外,他还是ACM以及IEEE的会员。

早年,他曾在SRI International供职5年,主要从事分布式三维地形可视化技术方面的工作,他成功创建了在Web上描述3D地球空间信息模型的ISO标准,并且还连续两年被选为Web3D协会的会长。

他曾在Pixar动画工作室工作过6年,担任内部动画系统的首席工程师,设计并实现了很多高性能API,这些API在一些奥斯卡获奖及提名影片的制作中都发挥了关键作用,这些影片有《海底总动员》《超人总动员》《赛车总动员》《料理鼠王》,以及《机器人总动员》等。

他还开办了一家咨询公司Code Reddy,为各家软件公司提供技术咨询,主要客户有Linden Lab和Planet 9 Studios,为大型在线3D虚拟世界《第二人生》设计了API并改善了其基本架构。

现在他担任ToyTalk公司的首席技术官。

这里主要回答下面的问题:优质的API应该具有哪些基本特征?绝大多数开发者都会同意这样的观点:优质的API应该设计精巧且实用性强。它不仅能带来愉悦的使用体验,且能与各种应用程序完美融合,甚至让使用者感觉不到其存在(Henning, 2009)。这些定性的表述都没错,但是应该怎样设计API才能够使之拥有这些特征呢?很显然,每个API都是不同的,但是高质量API的设计具有某些共同的特征。API开发者应该尽量遵循这些特征,同时尽量避免那些导致糟糕设计的特征。

API设计没有放之四海而皆准的方法,不可能用一组固定规则应对所有情况。当遇到某些特别的项目时,你可能不会采用建议的方法设计API,但是这样做的前提是你已经合理而周全地考虑了该设计方案。这里提到的原则是设计API通常应该遵循的基本准则。

这里重点讨论API所具有的通用的、与语言无关的特征,比如信息隐藏、一致性以及松耦合。虽然我们是在C++语境下解释这些概念,但是,不管你使用的是C++、Java、C#还是Python,这里的建议大体上都能适用。

问题域建模

编写API的目的是解决特定的问题或完成具体的任务。因此, API应该首先为问题提供一个清晰的解决方案,同时能对实际的问题域进行准确的建模。例如, API应该对问题域进行很好的抽象并且模塑出问题域的核心对象,这样会使API更便于用户使用和理解,因为它与使用者已有的知识和经验关联密切。

提供良好的抽象

API应该对它所解决的问题提供逻辑抽象。也就是说,在设计API时,应该阐述在选定问题域内有意义的深层概念,而不是公开低层实现细节。当你把API文档提供给一位非程序员时,他应当能够理解接口中的概念并且知道它的工作机制。

对于非技术人员, API提供的一组操作应该合乎逻辑且共属同一单元。每个类都应该有一个主旨,且这个主旨应该能通过类名和类包含的方法名体现出来。有可能的话,还可以让非技术人员看看接口,要是连他们都能看懂接口的运行机制,那说明接口的抽象就非常到位了。

得出好的抽象并非易事。需要注意的是,任何问题的正确抽象不止一种。绝大多数API都能以多种方式建模,每种建模方式都能提供良好的抽象和易用的接口。关键是API应该具有一致且合理的支撑体系。

例如,为一个简单的地址簿程序设计API。从概念上讲,地址簿包含许多人的详细信息。因此,我们的API应当提供一个AddressBook对象,它包含一个Person对象集合,每个Person对象描述了单个联系人的姓名和地址。此外还要能进行如下操作——向地址簿中添加联系人和从地址簿中删除联系人。这些操作都会更新地址簿状态,因此逻辑上它们应是AddressBook对象的一部分。我们的初始设计可以使用UML(Unified Modeling Language,统一建模语言)描述这个API结构,如图3-1所示。

{%}

图3-1 地址簿API的高层次UML抽象

下面向那些不熟悉UML的人解释下这个图的含义。图3-1显示了一个AddressBook对象,该对象包含一组Person对象和两个操作(AddPerson() 、DeletePerson())。其中AddressBook和Person是一对多关系。 Person对象包含一组公有的属性,用于描述联系人的姓名和地址。稍后我会改进这个设计,但是目前暂且把它作为此问题域的逻辑抽象。

UML 类图

UML规范定义了一组面向对象软件系统的可视化建模符号(Booch et al., 2005)。如图3-1所示,本文会经常使用UML类图描述类的设计。在这些图表中,类用方框表示,该方框被分割成以下三个部分:

(1) 上层区域是类名;

(2) 中层区域列出类的属性;

(3) 下层区域列出类的方法。

方框中、下层区域中的每条记录都可以添加前缀符,以此标识对应属性或方法的访问级别(或可见性)。这些标识符有:

+表示公有的(public)类成员

-表示私有的(private)类成员

表示受保护的(protected)类成员

类之间的关系可以用多种样式的连接线和箭头表示。下面列出了UML类图中可能出现的常见关系。

关联:两个类之间简单的依赖关系,二者互不从属于对方。这种关系用实线表示。关联可以带有方向,UML用开放箭头表示方向,比如“>”。

聚合:“has-a”关系,或者整体与部分的关系,两个类互不从属于对方。用带有空心菱形的直线表示。

组合:“contains-a”关系,部分和整体具有统一的生存期。用带有实心菱形的直线表示。

泛化:类之间的父子关系,用带空心三角箭头的直线表示。

对象间的各种关系可以在某个对象边用注释定义,这样就能分辨出它们的关系是一对一、一对多还是多对多。一些常见的关系有:

0..1 表示零个或一个实例

1 表示只有一个实例

0..* 表示零个或多个实例

1..* 表示一个或多个实例

关键对象的建模

API同样需要对问题域的关键对象建模。该过程旨在描述特定问题域中对象的层次结构,因此经常被称作“面向对象设计”或者“对象建模”。对象建模的目的是确定主要对象的集合,这些对象提供的操作以及对象之间的关系。

再次声明,对于给定的问题域,正确的对象模型不止一个。对象建模的任务应该根据API的特定需求决定。只有根据需求制定相应的对象模型,才能以最佳的方式满足这些需求。例如,前面那个地址簿的示例,假定对API有以下需求。

(1) 每个联系人可能有多个地址。

(2) 每个联系人可能有多个电话号码。

(3) 电话号码可以验证并格式化。

(4) 地址簿可能包含多个同名的联系人。

(5) 已经存在的地址簿条目可以修改。

这些需求将会对该API的对象模型产生巨大影响。在图3-1的原始设计中,一个联系人只能有一个地址 。为了满足多个地址的需求,可以为Person对象添加新的属性(如HomeAddress1、WorkAddress1),但这个方法既不健壮也不优雅。另一种方法是引入一个新对象(如Address)表示地址,同时允许一个Person对象包含多个Address对象。

电话号码亦是如此。单独创建电话号码对象(如TelephoneNumber),同时允许Person对象拥有多个这样的对象。创建独立的TelephoneNumber对象的另一个理由是,我们需要支持像IsValid()这样的操作验证号码是否有效,支持像GetFormattedNumber()这样的操作返回格式化的电话号码。很自然,这些操作都针对电话号码而非针对联系人,所以电话号码应该设计成一个独立的对象。

“多个People对象可能拥有相同名字”的需求意味着,姓名并不能唯一确定Person对象的实例。因此,需要某种方法唯一确定一个Person实例,以保证能够定位并更新地址簿中已有的条目。满足此需求的一个简便做法是为每个联系人生成一个全局唯一标识(UUID)。综上所述,下面列出地址簿API的关键对象。

(1) 地址簿(Address Book):包含零个或多个Person对象,包括AddPerson()、DeletePerson()以及UpdatePerson()操作。

(2) 联系人(Person):完整地描述联系人的详细信息,包括零个或多个地址和电话号码。每个联系人用UUID唯一标识。

(3) 地址(Address):描述地址,包括一个类型字段,例如Home或者Work。

(4) 电话号码(Telephone Number):描述电话号码,包括一个类型字段,例如Home或者Cell。还要支持IsValid()和GetFormattedNumber()操作。

更新后的对象模型如图3-2所示。

{%}

图3-2 地址簿API关键对象的UML图

要特别注意的是,API的对象模型可能需要随时变化。由于产生的新需求或要添加新功能,之前切合需求的类和方法可能不再符合。因此,明智之举是根据新需求重新评估对象模型,以判断能否在重新设计中获益。例如,你预计可能会需要用到国际地址,或决定创建更加通用的Address对象以满足需求。然而,不要太过极端,不要尝试创建过于通用的对象模型。同时,确保理解最小完备性!

隐藏实现细节

创建API的主要原因是隐藏所有的实现细节,以免将来修改API对已有客户造成影响。因此,API最重要的特征就是要切实达到这一目标。也就是说,任何内部实现细节(那些很可能变更的部分)必须对该API的客户隐藏。David L. Parnas称此概念为信息隐藏(Parnas, 1972)。

主要有两种技巧可以达到此目标:物理隐藏和逻辑隐藏。物理隐藏表示只是不让用户获得私有源代码。逻辑隐藏则需要使用语言特性限制用户访问API的某些元素。

物理隐藏:声明与定义

在C和C++中,声明和定义是有特定含义的精确术语。声明只是告诉编译器一个名字以及它的类型,并不为其分配任何内存。与之相对,定义提供了类型结构体的细节,如果是变量则为其分配内存。

(C程序员所使用的术语“函数原型”与术语“函数声明”是等价的。)例如,以下都是声明:

extern int i;
class MyClass;
void MyFunc(int value);

而以下都是定义:

int i=0;
class MyClass
{
public:
   float x,y,z;
};

void MyFunc(int value)
{
   printf("In MyFunc(%d).", value);
}

提示 声明告诉编译器某个标识符的名称及类型。定义提供该标识符的完整细节,即它是一个函数体还是一块内存区域。

从类和方法的角度来说,以下代码定义了一个类,该类只声明了一个方法。

class MyClass
{
public:
   void MyMethod();
};

在方法的定义中包含其实现(体)。

void MyClass::MyMethod()
{
   printf("In MyMethod() of MyClass.\n");
}

一般来说,声明包含在.h文件中,相关的定义包含在.cpp文件中。当然,也可以在.h文件中声明方法的位置给出方法的定义。例如:

class MyClass
{
public:
   void MyMethod()
   {
   printf("In MyMethod() of MyClass.\n");
   }
};

该技巧隐式地要求编译器在任何调用MyMethod()的地方内联此成员函数。从API设计的角度来看,因为它不仅暴露了此方法的实现代码,而且将代码直接内联到客户程序中,所以这是很拙劣的做法。因此,应该尽量做到,在API的头部只提供声明。不过,为了支持模板和有意的内联,这个规则也会有例外。

提示 物理隐藏表示将内部细节(.cpp)与公有接口(.h)分离,存储在不同的文件中。

注意,如样例代码所示,有时我会内联函数的实现,但这么做纯粹是为了清晰和简洁,在实际的API开发中应该避免采用这种方式。

逻辑隐藏:封装

封装(面向对象中的一个概念)提供了限制访问对象成员的机制。在C++中,此机制通过对类和结构体(类和结构体在功能上是等价的,仅在默认访问级别上有所不同)使用以下访问控制关键字来实现。图3-3举例说明了这些访问级别。

{%}

图3-3 C++类的三种访问级别

  • public(公有的):能从类或结构体的外部访问这些成员。这是结构体的默认访问级别。

  • protected(受保护的):只能在该类或该类的派生类中访问这些成员。

  • private(私有的):只能在定义这些成员的类中访问它们。这是类的默认访问级别。

其他语言下的封装

C++为类成员提供了public、protected以及private访问控制,然而其他面向对象语言提供了不同粒度的级别。例如,在Smalltalk中,所有的实例变量都是私有的,而所有方法都是公有的。而Java语言则提供了public、private、protected以及包私有(package-private)访问级别。

Java中的包私有表示该成员只能被同一个包中的类访问,这是Java中的默认访问级别。若要让同一个JAR文件中的其他类访问该类的内部成员,而又不必将该类的内部成员暴露给客户,那么使用包私有是很好的做法。包私有在需要验证私有方法的单元测试中十分有用。

C++没有包私有的概念,而是使用更加宽泛的友元概念,以允许指定的类和方法访问某个类的受保护的或私有的成员。虽然友元可以用来加强封装,但是如果使用不当,它也会向用户过度暴露内部细节。

用户很可能不遵循公有API的约束。如果向用户提供内部成员的钩子(hook),并且用户可以通过这些钩子得到所需资源,那么他们就会利用这些钩子完成工作。这样做虽然看起来对用户有利,帮助他们快速找到问题的解决方案,但是这也使得将来修改这些实现细节变得更加困难,同时扼杀了

改进和优化产品的能力。

提示 封装是将API的公有接口与其底层实现分离的过程。

让我们看看一些用户滥用钩子的示例。多人第一人称射击游戏《反恐精英》(Counter-Strike)自2000年左右问世以来,经常成为外挂目标。最著名的外挂之一就是“wallhack”。从本质上来讲,它是一个修改过的OpenGL驱动,它将墙壁部分或完全地渲染为透明。这就使得用外挂的玩家占据了明显的优势,因为他们可以看到墙后的东西。虽然你现在可能不会编写游戏或将玩家作为你的目标用户,但是该准则说明用户会尽一切可能获得他们想要的东西。如果一些用户可以通过修改OpenGL图形驱动在游戏中获得优势,那么由此推断,也一定会有用户使用API暴露出来的内部细节实现老板交代的功能。

下面用一个更加直接的应用程序说明这一点。Roland Faber报告了在西门子公司内部出现的一些困难,原因是它们的一个团队决定使用另一个团队开发的API的内部细节(Feber, 2010)。

一个不在欧洲的团队需要为在德国开发用户接口的团队提供远程控制。因为自动控制接口还没有完成,于是他们擅自使用内部接口作为替代,而没有通知架构师。不一致的接口变更导致系统整合时遭遇了意想不到的问题,高昂的重构开销在所难免。

因此,接下来的部分会讨论如何使用编程语言的访问控制特性为API提供最大限度的信息隐藏。之后,我们也会提到一些C++语言特性影响封装的情况,如友元和外部链接。

提示 逻辑隐藏指的是使用C++语言中受保护的和私有的访问控制特性从而限制访问内部细节。

隐藏成员变量

术语“封装”也经常用于描述打包数据和操作这些数据的方法。在C++中,通过让类同时包含变量和方法来实现封装。然而,按照良好API的设计原则,不应该将成员变量设置为公有的。如果数据成员构成了API逻辑接口的一部分,那么应该使用getter或setter方法间接地访问成员变量。例如,应该避免将代码编写成:

class Vector3
{
public:
   double x,y,z;
};

而应该编写成:

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惯用法与直接暴露成员变量相比,还有以

下额外的好处。

  • 有效性验证。可以检验值的有效性,以确保该类的内部状态始终有效且一致。例如,如果有个可以让客户更新某个RGB颜色的方法,那么就可以检测每次输入的红、绿、蓝值是否在有效范围内,如0~255或者0.0~1.0。

  • 惰性求值。计算变量的值会带来巨大开销,应该仅在需要的时候才执行计算。通过使用getter方法访问内部数据的值,可以仅在需要这种高消耗的计算时再执行。

  • 缓存。典型的优化技巧是:存储频繁请求计算的值,在将来请求时直接返回存储的值。例如,解析Linux上的/proc/meminfo文件可以得到机器的内存总量。与每次请求内存总量时都去执行读文件操作相比,效率更高的方法是将第一次读取的值缓存起来,并在以后需要时直接返回缓存的值。

  • 额外的计算。如果需要,可以在客户试图访问某个变量时执行一些附加操作。例如,只要用户更改某个首选项设置的值,就将UserPreferences类的当前状态写到磁盘上的配置文件中。

  • 通知。其他模块可能需要知道类中的值何时发生了变化。例如,如果你正在实现一个进度条的数据模型,那么用户界面代码就需要知道何时更新了进度值,以便它能及时更新GUI。所以,你可能要在setter方法中发布变更通知。

  • 调试。可以增加调试或日志语句,从而追踪变量何时被客户访问或改变;也可以增加断言(assert)语句来强制假设成立。

  • 同步。在发布API的第一个版本后,可能发现它不是线程安全的。标准的解决方法是在任何访问值的地方增加互斥锁,而这样做的前提是要访问的数值都已包装在getter/setter方法中。

  • 更精细的访问控制。如果将成员变量设置为公有的,那么客户就可以随意读写该值。但在使用了getter/setter方法后,就可以提供更精细的读写控制级别。例如,不提供setter方法就可以使该值变为只读。

  • 维护不变式关系。一些内部数值可能彼此依赖。例如在汽车动画系统中,需要根据汽车在关键帧之间运动所花费的时间,来计算它的速度和加速度。速度可以通过一段时间内的位置变化来计算,加速度则可以通过一段时间内速度的变化来计算。然而,如果用户可以访问该计算的内部数值,他们可能会改变加速度的值,导致汽车的加速度和速度不再相关,进而导致意想不到的结果。

如果成员变量实际上不是逻辑接口的一部分,而表示与公有接口无关的内部细节,则应该将它们从接口中移除。例如,考虑下面这个整数栈定义:

Class IntegerStack
{
public:
   static const int MAX_SIZE=100;
   void Push(int val);
   int Pop();
   bool IsEmpty()const;
   int mStack[MAX_SIZE];
   int mCurSize;
};

很明显,这是一个糟糕的API,因为它暴露出该栈是通过长整型数组(拙劣地)实现的,变量mCurSize暴露了栈内部的状态。如果以后需要改进该类的实现,例如使用std::vector或者std::list替代静态分配的定长数组,就会发现很难下手。因为接口暴露了mStack和mCurSize变量,客户代码可能已经习惯直接访问这些变量,改变原有的实现就会导致客户的代码无法正常工作。

规范的做法是从一开始就隐藏这些成员变量,使客户代码无法访问它们。

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;
};

我已经解释清楚永远不要将成员变量声明为公有的原因了,那么是否可以将它们声明为受保护的呢?如果变量是受保护的,那么所有客户都可以通过继承类的方式直接访问变量,这样就和使用公有变量的情况相同了。所以不要将成员变量声明为受保护的。正如Alan Snyder所说,在面向对象编程语

言中,继承严重损害了封装所带来的好处(Snyder,1986)。

提示 类的数据成员应该始终声明为私有,而不是公有的或受保护的。

暴露成员变量的唯一貌似合理原因就是出于对性能的考虑。执行C++函数调用是有开销的,即需要将方法的参数和返回地址压到调用栈中,同时还要为方法中的局部变量预留存储空间。然后,函数执行完毕时,调用栈再次被回退到起始状态。在注重性能的代码区域中执行这些操作的开销是非常显著的,例如在紧凑循环中操作大量对象。直接访问公有成员变量的代码比通过getter/setter方法访问成员变量的代码快2~3倍。

但是即便在上述情况下,也不应该暴露成员变量。首先,方法调用的开销相对于全部API调用来说,很可能是微不足道的。即使你正在编写注重性能的API,谨慎地使用内联,结合现代编译器优化实现,通常会完全消除方法调用的开销,使你获得与暴露成员变量等同的性能。如果你仍然有所顾虑,试着测试一下内联的getter/setter方法和使用公有成员变量的时间开销。本。访问http://APIBook.com/下载代码,然后亲自试验一下。

隐藏实现方法

除了隐藏所有成员变量外,同样也应该隐藏不需要公开的方法。信息隐藏的原则是:将类的固定接口与其内部设计实现相分离。对几个大型程序的早期调研发现,那些使用信息隐藏技巧的程序的修改难度是没有使用该技巧程序的1/4(Korson and Vaishnavi, 1986)。虽然具体的修改难度各不相同,但可以明显地看出,隐藏API的内部细节会使软件更易于维护和升级。

记住关键的一点:类只应该定义做什么而不是如何做。考虑下面这个从远程HTTP服务器下载文件的类。

#include<string>
#include<cstddef>
#include<sys/socket.h>
#include<unistd.h>

class URLDownloader
{
public:
   UPLDownloader();
   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;
};

将所有的成员变量都声明为私有并没有错,这是一个好的开始。但一些涉及具体实现的方法却被暴露了,比如打开套接字并从中读取数据的方法,以及将内存中缓存的结果写回磁盘的方法。客户其实并不需要了解这些具体信息。他们想要的仅仅是指定一个URL,然后在磁盘上创建一个文件,此文件包含URL指定的远程位置上的内容。

此处的GetSocket()方法相当糟糕。该公有方法返回对私有成员变量的访问结果。只要调用该方法,客户就可以获得隐含的套接字句柄,并且可以在不了解URLDownloader类的情况下直接操作此套接字。更让人不安的是,GetSocket()方法竟然被声明为const,表示该方法不能修改类的状态。虽

然严格上说的确是这样,但是客户仍然可以通过该方法返回的整型套接字句柄修改类的状态。如果返回指向某个私有成员变量的non-const指针或引用,那也会发生同样的内部状态泄露(Meyers, 2005)。这么做使得客户能够得到内部数据成员的句柄,进而就能绕过API直接使用此句柄改变对象的状态。

提示 永远不要返回私有数据成员的非const指针或引用。这会破坏封装性。

对于URLDownloader类而言,另一种设计方法是将除了构造函数以及DownloadToFile()方法之外的所有方法都声明为私有,除此之外都是实现细节。这样做之后,就可以自由地改变类的实现,而不影响任何使用该类的客户。

但这种情况仍然有瑕疵。它只是从编译器的角度隐藏了实现细节,但是客户仍然可以查看头文件,获得类的所有内部细节。事实上,这几乎不能避免,因为必须向客户分发头文件,以便他们能够编译并使用你的API的代码。另外,即便这些类的私有成员独立于公有接口,也必须使用#include包含这

些成员所需要的所有头文件。例如,URLDownloader的头部需要使用#include包含所有特定平台的下的套接头文件。

很遗憾,由于C++语言的限制,所有公有的、受保护的和私有的类成员都必须出现在类的声明中。理想情况下,类的头部应该如下所示:

#include<string>

class URLDownloader
{
public:
   URLDownloader();
   bool DownloadToFile(const std::string &url,
                       const std::string &localFile);
};

随后可以在其他地方声明所有的私有成员,例如在.cpp文件中。但对于C++而言,这是不可能做到的。(因为如果这样做就需要在编译时知道所有对象的大小。)尽管如此,仍然有一些方法可以让私有成员在头文件中不可见(Headington, 1995)。一种常用的技巧称为Pimpl惯用法,它将所有的私有数据成员隔离到一个.cpp文件中独立实现的类或结构体内。之后,.h文件仅需要包含指向该类实例的不透明指针(opaque pointer)即可。

强烈建议在API中采用Pimpl惯用法,这样就可以将所有实现细节完全和公有头文件分开。如果你不想这么做,至少也要将头文件内不需要的私有方法移到.cpp文件中,并将它们转换为静态函数(Lakos, 1996)。但只有当私有方法仅访问类的公有成员或者根本不访问任何类成员时才能这么做,(例如接收文件名字符串,然后返回该文件名的扩展名的程序)。很多工程师认为如果类使用了私有方法,那么就必须将其包含在类的声明当中,但这么就暴露了多余的实现细节。

提示 将私有功能声明为.cpp文件中的静态函数,而不要将其作为私有方法暴露在公开的头文件中。(更好的做法是使用Pimpl惯用法。)

隐藏实现类

除了隐藏类的内部方法和变量之外,还应该尽力隐藏那些纯粹是实现细节的类。大多数程序员都习惯隐藏方法和变量,但是其中很多人忽视了一个事实——并非所有的类都是公开。实际上,一些类仅用于实现,因此应该将其从API的公有接口中移除。

例如,考虑一个简单的Fireworks类:该接口可以指定烟火动画在屏幕上的位置,同时可以控制火焰粒子的颜色、速度和数量。显然,该API需要记录制造烟火效果的每个粒子,以确保它能更新每个粒子在每一帧中的位置。这意味着需要引入一个包含单个火焰粒子状态的FireParticle类。API客户永远不需要访问FireParticle类,因为它纯粹是API的实现。因此,应该将其包含在Fireworks类的私有部分中,并声明为私有类。

#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;
};

注意,这里并没有为FireParticle类使用getter/setter方法。如果需要,可以这么做;但从严格上意义上讲,这并不是必要的,因为不能从公共接口中访问该类。在类似情况下,一些工程师会想使用结构体代替类,以表示该结构是POD(Plain Old Data)类型。

此外,也可尝试将FireParticle类的内容隐藏起来而不是出现在头文件中,这样即便检查头文件也不会发现它。

最小完备性

优秀的的API设计应该是最小完备的。即它应该尽量简洁,但不要过分简洁。

显然,API应该是完备的,即它应该提供客户需要的所有功能,但这些功能可能不那么显而易见。要解决此问题,应当提前收集需求并使用用例建模,以获知客户期望的API行为。之后你就可以宣称,API已经实现了这些期望的功能。

最小化API存在一个不太明显的矛盾。但是,最小完备是可以计划的最重要的特征之一,它对API的长期维护以及升级会产生重大影响。确切地说,今天作出的决定会限制将来所能做的事情。它还会影响API的易用性,因为紧凑的接口更符合用户的思维习惯(Blanchette, 2008)。所以,接下来的部分将探讨保证API最小化的各种技巧,以及为什么需要最小化。

提示 谨记奥卡姆(Occam)剃刀原理:若无必要,勿增实体。

不要过度承诺

API中每个公有元素都是一项承诺,它承诺了该功能在API的生命周期中都将得到支持。虽然你可以背弃某项承诺,但是此举会令客户受挫并迫使其重写代码。更糟糕的是,由于你无法保证API的稳定迫使用户不停地修正代码,或者由于你移除了支持其独特用例的功能导致他们无法使用API,用户很可能会弃用你的API。

关键的一点是,在发布了某个API并且已经有客户开始使用之后,增加功新能很容易,而移除功能就非常困难。一个很好的建议是:当不确定是否需要某个接口时,就不要提供此接口(Bloch, 2008; Tulach, 2008)。

该建议违背了API设计人员的良好意愿。因为工程师容易被解决方案的通用性和灵活性诱惑,所以可能情不自禁地为API增加抽象层次或通用性,盼望着它们将来可能被用到。工程师应该抵制这种诱惑,原因如下所述。

(1) 你想要添加的通用性可能永远不会用到。

(2) 如果某天用到了想要添加的通用性,那时你可能已经掌握了更多API设计知识,并可能有了与最初设想方案不同的解决方案。

(3) 如果你确实需要添加新功能,那么简单的API比复杂的API更容易添加新功能。

因此,应该保证API尽量简单:类及类中的公有成员暴露得越少越好。这样做的好处是API会更易于理解,更易于用户记住API模型,而且更易于调试。

提示 疑惑之时,果断弃之!精简API中公有的类和函数。

谨慎添加虚函数

继承(将某个成员函数设置为虚函数)暴露出的功能可能会超出预期,而且这种方式并不易察觉。客户可以通过继承API中的类重新实现任意虚方法。虽然继承十分强大,但仍要意识到其潜在的隐患。

  • 对基类看似无害的修改可能会给客户带来不利的影响。如果不了解客户基于虚API构建代码的方式就孤立地升级基类,则会导致这种情况。这就是常说的“脆弱基类问题”(fragile base class problem, Blanchette, 2008)。

  • 客户可能会以你根本无法预料的方式使用API。这将导致API执行你无法控制的代码进而可能导致非预期行为。一个极端的例子是,你无法阻止客户在某个重写的方法中调用delete this,而且这么做甚至可能是一个合理需求,而如果你设计的API实现不允许此操作,那么代码很可能会崩溃。

  • 客户可能采用不正确的或易于出错的方式扩展API。例如,你有一个线程安全的API,但是依照你的设计,客户可以重写某个虚函数,如果客户的实现没有执行恰当的互斥加锁操作,就会存在难于调试的竞态条件隐患。

  • 重写函数可能破坏类的内部完整性。例如,某个虚方法的默认实现可能调用该类的其他方法更新其内部状态。如果重写方法没有进行这些相同的调用,那么该对象就会处于不一致的状态,从而产生无法预料的行为或导致程序崩溃。

除了这些API层面的行为问题,当你在C++中使用虚函数时,还要注意以下常见问题。

  • 虚函数的调用必须在运行时查询虚函数表决定,而非虚函数的调用在编译时就能确定。这就使得虚函数的调用比非虚函数的调用慢。实际上,这部分开销可以忽略,特别是当函数处理的工作微不足道或当其调用不频繁时。

  • 使用虚函数一般需要维护指向虚函数表的指针,进而增加了对象的大小。创建需要很多实例的小对象时,这可能会成为一个问题。尽管如此,与各种成员变量实际所占用的内存空间相比,这可能是微不足道的。

  • 添加、重排或移除虚函数会破坏二进制兼容性。因为虚函数调用通常用类的虚函数表的整型偏移量表示,所以改变虚函数的顺序,或引起其他虚函数的顺序发生变化的操作,都需要重新编译现有代码,以确保客户仍然调用正确的方法。

  • 不是所有的虚函数都能内联,因而将虚函数声明为内联是没有任何意义的。因为虚函数是运行时确定的,而内联是在编译时进行优化。但是,在有一定约束的情况下,编译器可以内联虚函数。即便如此,同内联非虚函数的情况相比,内联虚函数的例子少之又少。(谨记C++中的inline关键字仅仅是给编译器的一个提示。)

  • 重载虚函数是需要技巧的。在派生类中声明的符号会隐藏基类中所有的同名符号。因此,基类中一组重载的虚函数会被子类中一个覆盖函数所隐藏。虽然存在一些解决此问题的方法(Dewhurst, 2002),但更好的方式是避免重载虚函数。

最后,仅在明确需要使用覆盖时才应该允许覆盖。没有虚函数的类比有虚函数的类更健壮且更易于维护。一般来说,如果API没有在其内部调用某个特定的方法,那么此方法很可能不应声明为虚方法。当潜在的子类与基类之间形成一种“is-a”的关系时,继承才是有意义的,这种情况下应该允许继承。

实际上,Herb Sutter指出,应该将虚函数声明为私有的,并且仅在派生类需要调用虚函数的基类实现时才将其声明为受保护的派生类(Sutter, 2001)。因此,Sutter建议接口应当是非虚的,同时在适当的情况下使用模板方法(Template Method)设计模式。这通常称为非虚拟接口惯用法(Non-Virtual Interface idiom, NVI)。

如果你仍然决定支持继承,那么确保你设计的API能够安全地使用继承。谨记以下几点原则。

(1) 如果类包含任一虚函数,那么必须将析构函数声明为虚函数。这样子类就可以释放其可能申请的额外资源。

(2) 一定要编写文档,说明类的方法是如何相互调用的。如果客户想要为某个虚函数提供替代实现,那么他们需要知道,在虚函数实现中需要另外调用哪些方法以维持对象的内部完整性。

(3) 绝不在构造函数或析构函数中调用虚函数,这些调用不会指向子类(Meyers, 2005) 。这是一条必须知道的优秀准则,尽管调用虚函数并不影响API的外部接口。

提示 避免将函数声明为可以重写的函数(虚的),除非你有合理且迫切的需求。

便捷API

简化API是一项困难的任务。在减少API函数数目与使API易于各种客户使用之间存在天然的矛盾。大多数API设计者面临这样的问题,是保持API纯粹和集中,还是允许便捷包装(convenience wrappers)。(术语便捷包装指封装了多个API调用的实用程序,它能提供更简单的高层操作。)

一方面,有人认为API应该仅提供一个方法,仅执行一项任务。这确保了API是最小化的、集中的、一致的且易于理解的,还减少了实现的复杂性,并具备更稳定、更易于调试和维护等优点。Grady Booch称其为原语性(primitiveness),即原语方法需要访问类的内部细节以便高效地实现,而非原语方法可以完全基于原语方法进行构建且不需要访问任何内部状态(Booch et al., 2007)。

另一方面,也有人认为API应该让简单的事情更简单。不应该要求客户编写大量代码完成基础任务。因为这么做会导致样板代码被大量复制粘贴到源码的其他部分。一旦代码块被复制,就会产生潜在的代码不一致等问题。不过,你也许想将API为多种客户服务,这些客户中有人需要能够灵活地控

制,有人则需要尽量简单地完成一项任务。

这两个目标都是合理的。所幸它们并不互相排斥。有几种方法可以为核心API的功能提供更高层次的便捷包装,而不淡化其主要目标。重要的一点是,不要将便捷API与核心API混在同一个类中。也就是说,应该创建补充类包装核心API的某些公有功能。便捷类应该与核心API完全隔离,比如将它们

放在不同的源文件中甚至是在完全独立的库中。这样做还有一个额外的好处,就是确保了便捷API只依赖于核心API的公有接口,而不依赖于任何内部的方法或类。让我们看一个真实的例子。

OpenGL API提供了一个方法,可以编写平台无关的2D和3D应用程序,此方法操作的对象都是简单的图元(如点、线和多边形),图元可以被变换、点亮以及光栅化到帧缓冲区中。OpenGL API十分强大,但它的定位是一个底层API。例如,在OpenGL中创建一个球体需要将其显式地建模为由多个小

的平面多边形构成的表面,如下面的代码片段所示:

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实用库(OpenGL Utility Library,GLU)。GLU是一个基于OpenGL API构建的API,它提供了更高层的函数,如mip-map生成、坐标变换、二次曲面、多边形镶嵌(polygon tessellation)以及简单摄像定位(simple camera positioning)。这些函数都被定义在一个与OpenGL完全独立的库中,同时为了与核OpenGL API区分,所有的函数名均以glu作为前缀。例如,如下代码片段展示了使用GLU创建一个球体有多么简单。

GLUquadric *qobj=gluNewQuadric();
gluSphere(qobj, radius, slices, stacks);
gluDeleteQuadric(qobj);

这个例子演示了如何在保持最小化设计和集中核心API的同时,仍然提供额外的便捷方法,使API更易于使用。事实上,基于OpenGL构建的其他API提供了更加实用的类,如Mark Kilgard的OpenGL Utility Toolkit(GLUT)。该API提供创建各种实心的和空心的几何图元(包括Utah立体茶壶)以及简单的窗口管理函数和事件处理。图3-4显示了GL、GLU和GLUT之间的关系。

{%}

图3-4 核心API(OpenGL)与基于它的便捷API(GLU 和 GLUT)分离的例子

Ken Arnold将这一概念称为渐进公开(progressive disclosure),它表示API应该通过易用接口来呈现基本功能,同时将高级功能隐藏在另一个独立的层次中(Arnold, 2005)。他指出这一概念经常出现在GUI设计中,即用一个“高级”(Advanced)或“专家”(Expert)按钮来隐藏复杂功能。这样,可以既提供强大API,又能确保专家用例不会搅乱基本的工作流程。

提示 基于最小化的核心API,以独立的模块或库的形式构建便捷API。

易用性

优秀的API设计应该使简单的任务更简单,使人一目了然。例如,好的API可以让客户仅通过方法签名就能知晓使用方法,而不需要另写文档。这种API特征与最小化特征非常相似:如果API是简单的,那么它也应该易于理解,而且它也应该遵循最小惊奇(least surprise)原则。可以采用现有的模型和模式实现最小惊奇,这样用户就能集中精力处理手头的任务,而不会因为接口的新奇或繁杂分散精力(Raymond, 2003)。

当然,不能因此就忽视优秀支持文档的必要性。事实上,这应该让编写文档的工作变得更容易。众所周知,优秀的示例对文档大有帮助。提供样例代码能够增加API的易用性。优秀的开发人员应该能够在阅读API的样例代码后,就能明白怎样将其运用到自己的任务当中。

接下来,我们讨论使API更易于理解的方法技巧。不过,在此之前需要注意的是,API也可能为专家用户提供一些不那么易用的复杂功能。但是,必须首先保证这个API能使简单的任务更简单,然后再提供复杂功能。

可发现性

可发现的(discoverable)API要求用户能够通过API自身明白如何使用它们,而不需参阅任何解释或文档。下面举一个UI设计领域中的反例阐明这一点。Windows XP中的“开始”按钮没有提供一个可发现的接口,以帮助用户找到“关闭计算机”选项;只有点击“关闭计算机”按钮才能看到“重启”选项,这种方式也很不直观。

可发现性并不一定能带来易用性。例如,就某个API而言,有可能初次使用的用户很容易上手,而经常使用它的专家用户可能就会觉得十分繁琐,但一般而言,可发现性有助于产生更加好用的接口。

在设计API时有很多方法可以提升可发现性。一种直观的方法是构思一个直观的、合乎逻辑的对象模型,例如为类和方法选择恰当的名字。事实上,给出清晰的、描述性强的且恰当的名字是API设计中最困难的任务之一。避免缩略语也是影响可发现性的一个因素(Blanchette, 2008),它的好处是用户无需记住API方法名到底是GetCurrentValue()、GetCurrValue()、GetCurValue(),还是GetCurVal()。

不易误用

优秀的API不仅要易于使用,而且还要不易误用,Scott Meyers认为这是最重要的通用接口设计准则(Meyers, 2004) 。最常见的误用API的方式是向方法传递错误的参数或非法值。当方法拥有多个相同类型的参数,但由于用户忘记了正确的参数顺序,或者使用int而非更具约束性的enum类型表示小范围的值时,误用情况很可能发生(Bloch, 2008)。例如,考虑如下方法的签名:

std::string FindString(const std::string &text,
                       bool search_forward,
                       bool case_sensitive);

用户很容易忘记第一个布尔型参数是搜索方向还是区分大小写的标记。使用错误的顺序传递标记将导致不法预料的行为,在用户意识到自己颠倒了布尔型参数之前,他们很可能会把时间浪费在调试程序上。你可以重新设计这个方法,为每个选项引入新的enum类型,以便让编译器捕获此类错误。例如:

enum SearchDirection{
   FORWARD,
   BACKWARD
};
enum CaseSensitive{
   CASE_SENSITIVE,
   CASE_INSENSITIVE
};
std::string FindString(const std::string &text,
                       SearchDirection direction,
                       CaseSensitivity case_sensitivity);

这么做之后,如果用户混淆两个标记的顺序,则会报出编译错误。这不仅意味着用户不能混淆两个标记的顺序,还意味着用户编写的代码的自描述性会更好。比较result=FindString(text, true, false);result=FindString(text, FORWARD, CASE_INSENSITIVE);

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

对于enum不能解决的复杂情况,为了确保每个参数都有唯一的类型,甚至可以引入新类。例如,Scott Meyers用一个Date类阐述了此方法(Meyers, 2004, 2005)。这个Date类指定了三个整数:

class Date
{
public:
   Date(int year, int month, int day);
   ...
};

Meyers注意到,按照上面的设计客户可能会按错误的顺序传递年、月、日的值,还可能设置非法值,比如把month的值设为13。为了避免这些问题,他建议引入特定的类来表示年、月、日的值,如:

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作为参数。

calss Date
{
public:
   Date(const Year &y, const Month &m, const Day &d);
   ...
};

采用此设计,客户就能使用如下没有歧义且易于理解的语法创建新的Date对象,而且使用不同的顺序指定参数值会导致编译错误。

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

提示 避免编写拥有多个相同类型参数的函数。

一致性

优秀的API应该采用一致的设计方法,以便于用户记住其风格,进而更容易被用户采用(Blanchette, 2008)。这一点适用于API设计的各个方面,比如命名约定、参数顺序、标准设计模式的使用、内存模型语义、异常的使用和错误处理等。

考虑其中的第一个方面,一致的命名约定表示整个API都使用相同的词语表述相同的概念。例如,如果你已经决定使用动词词组Begin和End,那么就不应该再夹杂着Start和Finish这样的词语。例如,Qt3 API中的几个方法名时而使用缩略语,时而又不使用(比如像prevValue()和preciousSib- ling())。这个例子再一次说明了无论如何都要避免使用缩略语。

使用一致的方法签名也是重要的设计准备。如果有几个方法接受相似的参数列表,那么应该尽力保证这些参数的数目和顺序相一致。下面给出一个反例,考虑标准C库中的如下方法:

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指针的用法。但它很容被忽视,而且如果不将源指针声明为const,编译器将无法捕获错误。

你可能还注意到,词语“copy”和“cpy”的使用也是不一致的。

再举一个标准C库中的例子。常用的malloc()函数用来分配一片连续的内存块,而calloc()函数也执行相同的操作,并附带将申请的内存初始化为零值。尽管它们的目标相似,方法签名却不同。

void *calloc(size_t count, size_t size);
void *malloc(size_t size);

malloc()函数分配size字节的空间,而calloc()分配count × size字节的空间。除了不一致,它还违背了最小惊奇原则。另一个例子是,标准C函数read()和write()将文件描述符作为其第一个参数,而fgets()和fputs()函数把文件描述符作为最后一个参数(Henning, 2009)。

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

虽然这些例子都集中在函数或方法层面,但是显然在类的层面上一致性也十分重要。拥有相似角色的类应该提供相似的接口。在这方面STL是一个很好的示例。std::vector、std::set,std::map甚至std::string类都提供了size()方法,用以返回容器中元素的数目。它们还都支持迭代器,一旦你知道了如何遍历std::set,也就等于知道了如何迭代std::map。这样做可以很容易地记住API的编程模式。

通过多态,可以毫不费力地达到一致性目标:将共享的功能放到一个公共的基类中。但是,让所有的类都继承该公共基类并没有意义,而且不应该纯粹为了达到一致性而引入基类,因为它增加了接口的复杂性以及类的数目。事实上,STL容器显然不是继承自一个公共的基类,而是人为地指定类中的公共概念并在每个类中使用相同的习惯来表述这些概念,设计以达到一致性。这通常被称为静态多态。也可以使用C++模板帮助定义和应用这种一致性。例如,可以为一个2D坐标类创建模板,然后分别用整型、单精度浮点型和双精度浮点型特化它。通过此方法,可以确保每种类型的坐标都提供完全相同的接口。如下的代码是一个简单的示例:

template<typename T>
class Coord2D
{
public:
   Coord2D(Tx,Ty):mX(x), mY(y){};

   T GetX() const{return mX;}
   T GetY() const{return mY;}

   void SetX(Tx){mX=x;}
   void SetY(Ty){mY=y;}

   void Add(Tdx,Tdy){mX+=dx; mY+=dy;}
   void Multiply(Tdx, Tdy){mX *=dx; mY *=dy;}

private:
   TmX;
   TmY;
};

使用此模板定义,可以构建Coord2D、Coord2D和Coord2D类型的变量,并且所有的这些变量都拥有完全相同的接口。

一致性的另一面是使用习惯的模式和标准的平台习语。比如你买了一辆新汽车,但你并不需要重新学习如何驾驶。刹车、油门踏板、方向盘以及离合器(手动的或自动的)的使用方式是全世界通用的。如果你能驾驶一辆汽车,那么驾驶另一辆相似的汽车也应该不是问题,即便两辆汽车的制作和模型不同,或者方向盘不在同一侧。

最容易使用的API是简化用户学习过程的API。例如,大部分C++开发人员都熟悉STL以及容器类和迭代器的用法。因此,如果你决定编写具有相似目标的API,那么模仿STL的模式是十分有益的,因为开发人员将会发现他们很熟悉你的API的用法,进而更容易采用你的API。

正交

在数学中,如果两个向量互相垂直(成90°),那么就称它们是正交的,也就是说,它们的内积为零。该特性使得两个向量线性独立,即不存在一组标量能够作用于第一个向量而产生第二个向量。地理上的类比就是东和北这两个相互垂直的方向,即向东边走多远都不能走到北边。使用略带计算机特色的术语表述即是,东向坐标的变化并不影响北向坐标。

在API设计中,正交性意味着方法没有副作用。调用设置特定属性的方法应该仅改变那个属性,而不能额外改变其他可以公共访问的属性。这样一来,变更API某部分的实现将不会影响到API的其他部分(Raymond, 2003)。尽力保证API的正交性有助于预测和理解API的行为。此外,不产生副作用的代码,或者不依赖其他代码的副作用的代码,更加易于开发、测试、调试及修改,因为代码的影响范围是局部的且有边界的(Hunt and Thomas, 1999)。

提示 正交的API意味着函数没有副作用。

让我们看一个具体的例子。也许你曾经在一家汽车旅馆住宿,那里的淋浴控制器并不直观。想分别设置水压和温度,但是仅有一个控制器,它似乎通过一种复杂且隐晦的方式同时影响这两种属性。可以使用如下API建模:

class CheapMotelShower
{
public:
   float GetTemperature() const;//单位华氏温度
   float GetPower() const;//单位百分比0~100
   void SetPower(float p);

private:
   float mTemperature;
   float mPower;
};

为了进一步阐述这一点,考虑该类公有方法的实现:

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()方法的结果产生副作用。在更加复杂的系统中,这种相互依赖性可能会被程序员遗忘,或者被忽视,进而对代码区域的简单地改动就可能对系统其他部分的行为产生的影响。

另一种方式是设计一个理想的、正交的淋浴器接口,其温度和水压的控制是相互独立的。

class IdealShower
{
public:
   float GetTemperature() const;//单位华氏温度
   float GetPower() const;//单位百分比0~100
   void SetTemperature(float t);
   void SetPower(float p);

private:
   float mTemperature;
   float mPower;
};

float IdealShower::GetTemperature() const
{
   return mTemperature;
}

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设计领域中,正交指令集中的指令可以通过任何寻址模式使用任意CPU寄存器;而在非正交设计中,特定的指令只能使用特定的寄存器。就API设计而言,STL提供了满足此特性的良好示例。STL提供的一组泛型算法和迭代器适用于所有的容器。例如,STL中的std::count算法适用于任意std::vector、std::set或std::map容器。因此,算法的选择并不依赖所使用的容器类。

健壮的资源分配

内存管理是C++编程中最富技巧性的方面之一。特别是对于那些习惯使用Java或C#语言的开发人员,内存管理更加重要。在这两种语言中,对象会被垃圾回收器自动释放。相对而言,大部分的C++错误都是由于误用指针或引用所导致的。

  • 对NULL解引用:尝试对NULL指针使用->或*操作。

  • 二次释放:为一块内存调用两次delete或free()。

  • 访问非法内存区域:对尚未分配或已经被释放的指针使用->或*操作。

  • 混用内存分配器:用delete释放由malloc()分配的内存,或用free()释放new分配的内存。

  • 数组释放不正确:使用delete操作符而非delete[]释放数组。

  • 内存泄露:内存使用后没有释放。

产生这些问题的原因是无法辨别C++指针是否引用合法的内存,或指针否指向尚未分配的或已经释放的内存。因此要依赖程序员追踪指针状态并确保永远不会错误地解引用指针。然而,众所周知,程序员难免会犯错误。但是,这些特定问题中多数可以通过使用托管指针(或者说智能指针)避免。

(1) 共享指针。共享指针是引用计数指针,即当一段代码要保留该指针时,引用计数加一;当这段代码使用完该指针时,引用计数减一。如果引用计数达到零,则该指针指向的对象自动释放。这种指针通过令指针在使用期间有效来避免访问已经释放的内存。

(2) 弱指针。弱指针包含一个指向对象的指针,通常是共享指针,但是并不增加其指向的对象的引用计数。如果一个共享指针和一个弱指针引用了相同对象,那么在共享指针被销毁时,弱指针的值会立即变为NULL。通过此方式,弱指针可以检测其所指向的对象是否已经过期:即其指向对象的引用计数是否为零。这样就避免了悬挂指针(指向已经释放的内存)问题。

(3) 作用域指针。作用域指针仅属于单一对象,并且当作用域指针超出其作用域时自动释放所指向对象。有时也称其为自动指针。作用域指针定义为单一对象的所有者,因此无法复制。

这些智能指针不是最初的C++98标准的一部分,它们包含在TR1(Technical Report 1)中。TR1是一项C++新功能的提案(ISO/IEC 2007)。在计划中的新版C++标准(C++0x)中也包含了这些智能指针。Boost库提供了这些智能指针的可移植的且开源的实现,包括boost::shared_ptr、boost::weak_ptr 以及boost::scoped_ptr。

使用这些智能指针可以使API更加易于使用,同时降低犯前面所列举的内存错误的几率。例如,使用boost::shared_ptr可以不再要求用户显式释放动态创建的对象。当不再引用该对象时,它会被自动删除。例如,考虑以下API,它通过名为CreateInstance()的工厂方法创建对象实例。

#include<boost/shared_ptr.hpp>

typedef boost::shared_ptr<class MyObject>MyObjectPtr;

calss MyObject
{
public:
   static MyObjectPtr CreateInstance();
   ~MyObject();

private:
   MyObject();//必须使用工厂方法来创建实例
};

工厂方法的实现方式如下:

MyObjectPtr MyObject::CreateInstance()
{
   return MyObjectPtr(new MyObject());
}

根据该API,客户可以使用以下方式创建MyObject的实例:

int main(int argc, char *argv[])
{
   MyObjectPtr ptr=MyObject::CreateInstance();
   ptr=MyObject::CreateInstance();
   return 0;
}

此例创建了两个MyObject实例,并且当ptr变量超出作用域时(此处即在程序的末尾),实例就会销毁。而如果CreateInstance()方法仅返回MyObject *类型,那么之前给出的示例将永远不能调用析构函数。因此,使用智能指针可以简化内存管理,进而提高API的易用性。

通常情况下,如果函数返回了需要客户销毁的指针,或者你预计,客户需要的指针的生命周期比对象的生命周期更长时,就应该返回智能指针,如boost::shared_ptr。而如果由对象持有指针的所有权,那么可以返回一个标准指针。例如:

//MyObject*的所有权转移给调用者。
boost::shared_ptr<MyObject>GetObject() const;

//MyObject*的所有权由API持有。
MyObject* GetObject() const;

提示 当需要客户销毁指针时,使用智能指针返回动态申请的对象。

值得注意的是,此类内存管理问题实际上仅仅是更广泛的资源管理分类中的一个特例。类似的问题也可能在操作互斥锁或文件句柄时遇到。智能指针的概念可以被泛化为具有如下特点的资源管理任务,即资源分配是对象构造,资源释放是对象析构。人们经常将其缩写为RAII(Resource Acquisition Is Initialization),表示资源获取即初始化。

考虑如下代码示例,它展现了一个典型的同步错误。

void SetName(const std::string &name)
{
   mMutex.lock();

   if (name.empty()){
      return;
   }
   mName=name;

   mMutex.unlock();
}

显然,如果给方法传递一个空字符串,那么该代码将不能释放互斥锁,进而导致程序在下一次尝试调用此方法时陷入死锁,因为此时互斥锁仍处于锁定状态。还可以创建一个类ScopedMutex,还是由构造函数负责加锁,而析构函数负责释放锁。使用这个类,可以将前面的方法重新编写如下:

void SetName(const std::string &name)
{
   ScopeMutex locker(mMutex);

   if (name.empty())
      return;

   mName=name;
}

现在可以保证方法在返回时锁会被立即释放,因为只要ScopedMutex变量超出作用域,互斥锁就会被解锁。因此不需要检查每个返回语句,以确保它们显式地释放了锁。而且该代码的可读性也更高。

需要指出的是,在API设计中,如果API提供对某些资源的分配和回收,那么应当考虑提供一个类来管理这一操作。在该类中,资源的分配发生在构造函数中,回收发生在析构函数中。(也许需要另外通过公有的Release()方法,以便客户能够控制何时释放资源。)

提示 将资源的申请与释放当作对象的构造和析构。

平台独立

设计精良的C++ API不应在公共头文件中出现平台相关的#if/#ifdef语句。如果API给出了问题域的高层次的逻辑模型(API本身应该如此),那么API几乎不会因平台而异。只有在编写使用专用平台资源的接口的API时,比如某个程序绘制一个窗口,并为本地操作系统传递正确的窗口句柄,这样做才是合适的。除此之外,永远不要在公共头文件编写平台相关的#ifdef语句。

例如,考虑封装移动电话功能的API。一些移动电话提供内置的GPS设备,该设备能发送电话的地理位置,但并不是所有的设备都有该功能。然而,不应该在API中直接暴露这一事实,如下例所示:

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的后续版本中增加了对另一个设备类的支持(如Windows Mobile),就不得不更新公有头文件中的#if语句,使其包含_WIN32_WCE。然后,你的API客户就必须在他们的代码中查找所有已经嵌入的TARGET_OS_IPHONE定义,并且扩展它使其也包含WIN32_WCE,这都是因为你在无意中暴露了API的实现细节。

正确的方法是隐藏某些功能只适用于特定平台这一事实,并提供一个方法判定当前平台是否支持所需的功能。例如:

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()方法:

bool MobilePhone::HasGPS() const
{
#if defined TARGET_OS_IPHONE
   return true;
#else
   return false;
#endif
}

现在的设计远比最初的设计优秀,因为平台相关的#if语句现在被隐藏在.cpp文件中,而不是暴露在头文件中。

提示 不要将平台相关的#if或#ifdef语句放在公共的API中,因为这些语句暴露了实现细节,并使API因平台而异。

松耦合

1974年,Wayne Stevens、Glenford Myers和Larry Constantine联合发表了关于结构化软件设计的开创性论文。该论文引入了耦合和内聚两个互相关联的概念(Stevens et al., 1974),它们的定义如下所述。

  • 耦合:软件组件之间相互连接的强度的度量,即系统中每个组件对其他组件的依赖程度。

  • 内聚:单个软件组件内的各种方法相互关联或聚合强度的度量。

优秀的软件设计应该是低耦合(或松耦合)且高内聚的,即最小化不同组件之间功能的关联性和连通性。达到这一目标之后,每个组件的使用、理解以及维护就能实现相互独立了。

提示 优秀的API表现为松耦合和高内聚。

Steve McConnell对松耦合作了一个特别形象的类比。火车模型的各车厢间使用链子、车钩彼此连接,仅通过单点连接就将两个车厢挂接在一起。这就是一种松耦合系统。试想一下,如果需要使用几种不同类型的连接(可能包括螺丝和缆线)才能将车厢连到一起,或者特定类型的车厢只能和其他几种特定类型的车厢连接,该有多困难(McConnel, 2004)。

一种理解耦合的方式是给定A和B两个组件,当A改变时需要改变B中多少代码。评估组件之间的耦合度可以采用下面几种不同的度量方式。

  • 尺度。与组件之间的连接数相关,包括类的数目、方法的数目、每个方法的参数数目等。例如,拥有较少参数的方法的组件与调用此方法的组件之间的耦合度较低。

  • 可见度。指组件之间的连接的显著程度。例如,改变一个全局变量以便间接影响另一个组件的状态,这种可见度较低。

  • 密切度。指组件之间连接的直接性。如果A同B耦合,并且B同C耦合,那么A间接地同C耦合。而继承某个类比引入该类为成员变量(组成)的耦合度要高,因为继承还支持访问类中所有受保护的成员。

  • 灵活度。与改变组件之间连接的难易度相关。例如,如果要改变对象A中某方法的签名以使对象B能够调用它,那么灵活度就代表修改此方法和所有依赖的代码的难易度。

一种令人生厌且应该杜绝的紧耦合形式是,有两个直接或间接相互依赖的组件,即依赖循环或者说循环依赖。这样要重用一个组件而不包含它的循环依赖组件就非常困难,甚至 无法实现。

接下来,将给出不同的降低API内的类和方法的耦合度(API内耦合)的技巧。

这里还有一个同样有趣的问题,即API设计的决策如何影响客户应用的内聚和耦合(API内的耦合)。由于设计API是为了解决特定的问题,所以API应该很好地体现这一共同的目标,使其成为客户应用程序中的高内聚组件。但从耦合角度考虑,API越庞大,暴露的类、方法和参数就越多,这样在客户的应用程序中,访问和联接API的方式就越多。实现最小完备性能降低耦合度。在设计API时也要考虑联接了多少其他组件的问题。例如,libpng库依赖于libz库。在编译时会在png.h中引入zlib.h头文件,这样就暴露它们之间的关系,且这种情况在链接时也存在。这就要求libpng的客户注意到libz的依赖性,并确保在构建和链接时也使用这个链接库。

仅通过名字耦合

如果类A仅需要知道类B的名字,即它不需要知道类B的大小或调用类B的任何方法,那么类A就不需要依赖类B的完整声明。在这种情况下,可以为类B使用前置声明,而非包含整个接口,这样就降低了这两个类之间的耦合(Lakos, 1996)。例如:

class MyObject;//只需知道MyObject的名字

class MyObjectHolder
{
public:
   MyObjectHolder();

   void SetObject(MyObject*obj);
   MyObject*GetObject() const;

private:
   MyObject*mObj;
};

在该例中,如果相关联的.cpp文件仅仅存储并返回MyObject的指针,同时限制任何除指针比较外的与该指针的交互,就不需要#include"MyObject.h"了。这样类MyObjectHolder就可以同MyObject的物理实现进行解耦。

提示 除非确实需要#include类的完整定义,否则应该为类使用前置声明。

降低类耦合

Scott Meyers建议,如果情况允许,那么优先声明非成员、非友元的函数,而非成员函数(Meyers, 2000)。这么做在促进封装的同时还降低了这些函数和类的耦合度。例如,考虑以下类片段,它提供PrintName()成员函数将成员变量输出到stdout。该方法使用公有的getter方法GetName()获取成员变量当前的值。

//myobject.h
class MyObject
{
public:
   void PrintName() const;
   std::string GetName() const;
   ...

protected:
   ...

private:
   std::string mName;
   ...
};

依照Meyers的建议,应该优先使用以下表述:

//myobject.h
class MyObject
{
public:
   std::string GetName() const;
   ...

protected:
   ...

private:
   std::string mName;
   ...
};

void PrintName(const MyObject &obj);

后一种形式降低了耦合度,因为自由函数PrintName()只能访问MyObject的公有方法。(在这个特殊的例子中,只能访问const公有方法。)如果PrintName()是类的成员函数,它还能访问MyObject所有的私有和受保护的成员函数以及数据成员,如果MyObject有基类,则还包括其基类中受保护的成员。因此,优先使用非成员、非友元的形式意味着此方法与类内部的细节不会耦合。因此,当MyObject的内部细节改变时,对方法造成破坏的可能性会小很多。

该技巧也会促成最小完备的接口,因为类仅包含需要实现的最小功能,而基于公有接口实现的功能声明都在类的外部。(参考之前讨论的关于便捷API的示例。)值得一提的是,该技巧在STL中很常见,诸如std::for_each()和std::unique()算法都声明在容器类之外。

为了更好地传达MyObject和PrintName()在概念上的关联性,可以将它们声明在同一个命名空间中。要么将PrintName()声明在自己的命名空间中(如MyObjectHelper),要么作为新的帮助类MyObjectHelper的静态方法。和便捷API那部分讨论的一样,这个帮助类的命名空间应该包含在一个独立的模块中。例如:

//myobjecthelper.h
namespace MyObjectHelper
{
   void PrintName(const MyObject &obj);
};

提示 与成员函数相比,使用非成员、非友元的方法能降低耦合度。

刻意的冗余

通常,优秀的软件工程实践的目标是去除冗余,即确保每个重要的知识点或行为有且仅有一次实现(Pierce, 2002)。而代码复用意味着耦合,因此略微增加重复以断绝过分的耦合关系有时是值得的(Parnas, 1979)。此类有意的重复可能表现为代码或数据的冗余。

举个代码冗余的示例,考虑两个互相依赖的大型组件。当你深入调查其依赖关系时,会发现实际上是一个组件依赖于另一个组件的某个微不足道的功能,例如计算最小值或最大值的函数。标准的做法是将该功能实现为一个更低层次的功能,使得两个组件都依赖于低层次的功能,而非两个组件互相依赖。有时这种重构是没有意义的,例如,该功能可能由于不够通用而不能降解为系统内的低级层次。因此,在特定的情况下,为了避免耦合,复制代码的行为是有意义的(Lakos, 1996)。

在增加数据冗余方面,考虑以下为文字聊天系统设计的API,该系统记录用户发送的每条信息。

#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()方法返回指定聊天事件的格式化的版本,如:

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

TextChatLog类显然和ChatUser类是耦合的,而ChatUser是个负担很重的类,它引入了许多其他依赖。因此,你决定调查此情形,结果发现TextChatLog仅使用了用户名,即它保留着ChatUser对象仅仅是为了调用ChatUser::GetName()方法。因此,去除这两个类之间的耦合的一个简单方案是,将用户名传递给TextChatLog类,如以下重构后的版本所示:

#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)小于std::sizeof(ChatUser)时,就降低了TextChatLog的内存开销。

尽管如此,即使是有意的冗余,也是冗余,应该小心谨慎地使用,同时添加良好的注释。例如,如果聊天系统升级以允许用户改变他们的名字,并且在名字发生变化时,更新原来的旧信息以显示新名字,那么你就必须回归到最初紧耦合的版本。(或者在名字发生变化时让ChatUser通知TextChatLog,但是这样会带来更多的耦合。)

提示 有时,使用数据冗余降低类之间的耦合是合理的。

管理器类

管理器类拥有并协调几个低层次的类。可以用它打破基于一组低层次的类的一个或多个类的依赖关系。例如,考虑一个结构化的画图程序,可以用它创建二维对象,选择对象以及在画布上移动对象。此程序支持几种输入设备供用户选择和移动对象,例如鼠标、输入板以及操纵杆。一种简单的设计是要求选择和移动操作了解每种输入设备,如UML图中所示(见图3-5)。

{%}

图3-5 多个高层次类,每个都和低层次类耦合

另一种方式是引入管理器类协调对每个特定输入设备类的访问。这样,SelectObject和MoveObject类仅需要依赖这一个管理器类,且只有此管理器类需要依赖每个输入设备类。这也可能需要为基本类创建一些抽象的形式。例如,注意MouseInput、TabletInput和JoystickInput各自的接口略有不同。因此,管理器类需要使用通用的输入设备接口,此接口将特定设备的细节抽象出来。改进后的松耦合的设计如图3-6所示。

{%}

图3-6 使用管理器类降低与低层次类的耦合

注意,该设计同时也具有良好的可扩展性。因为该系统可以加入更多的输入设备,但是不会引入更多对SelectObject或MoveObject的依赖。如果要增加操作对象(如RotateObject和ScaleObject),那么它们仅需要依赖InputManager即可,从而不会引入与底层设备类的耦合。

提示 管理器类可以通过封装几个低层次类降低耦合。

回调、观察者和通知

最后一个在API内降低耦合的技术是关于当一些事件发生时通知其他类的问题。设想一个3D多人在线游戏,该游戏允许多个玩家彼此之间进行游戏竞技。

在系统内部,每个玩家可能需要使用唯一标识符(即UUID)表示,如e5b43bbafbf2-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调用InGame::SetPlayerName()方法而进一步增加耦合度吗?

一个更好的解决方法是,为PreGameLobby和InGame类注册NameCache更新的监听器。一旦玩家姓名发生变化,NameCache就能通知所有注册监听的一方,进而避免直接依赖那些模块。实现这一操作有几种方案,如回调、观察者和通知。我将在之后详细讲解每种方案,这里首先介绍使用这些方案需要注意的一些共通的问题。

  • 重入(reentrancy)。当编写API需要未知的用户代码调用时,需要考虑该代码可能回调此API。实际上,客户可能根本就不知道发生了回调。例如,如果你正在处理一个对象队列,并且每处理一个单独的对象就执行一次回调,那么回调可能尝试通过添加或删除对象修改队列的状态。你的API至少应该抛出错误来防止此类行为。而更好的解决方案是允许此类重入行为,并且确保API的实现代码能够保持一致的状态。

  • 生命周期管理。API应该给客户提供一种能够干净利落地与此API断开连接的方式,即声明它们不再接收更新消息。当客户对象被删除时,该方式尤为重要,因为此后发送信息给客户对象将导致崩溃。同理,API要能够防止重复注册,以避免为同一个事件多次调用相同的客户代码。

  • 事件顺序。回调或通知的序列应该明确告知API用户。例如,Cocoa API通过使用名字willChange和didChange表明通知是在某个事件之前还是之后发送,这种做法就十分明确。而Qt工具包在这方面就不够具体:一个改变信号有时在相关的对象更新之前就已经被发送出去了。

以上几点强调了一些共通的问题,应该明确告知用户在回调函数中他们能做什么和不能做什么,他们能对你的API能做哪些假设且不能做哪些假设。可以通过API文档或显式地提供受限回调接口(接口仅暴露所有潜在操作的安全子集)达到这一目的。

1. 回调

在C和C++中,回调是模块A中的一个函数指针,该指针被传递给模块B,这样B就能在合适的时间调用A中的函数。模块B对模块A一无所知,并且对模块A不存在“包含”(include)或者“链接”(link)依赖。回调的这种特性使得低层代码能够执行与其不能有依赖关系的高层代码。因此,在大型项目中,回调是一种用于打破循环依赖的常用技术。

有时为回调函数提供一个“闭包”也是有用的。闭包是模块A传递给模块B的一条数据,该数据包含在A提供给B的回调函数中。这是模块A传递一些重要的回调状态信息给模块B的一种途径。

以下的头文件展示了如何在C++中定义简单的回调API:

#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;
};

然后该类就可以调用回调函数(如果已经设置了回调函数),如以下代码所示:

if(mCallback)
{
   (*mCallback)("Hello World", mClosure);
}

复杂的示例将支持为模块B增加多个回调(将它们存储在std::vector中),让后轮流调用每个注册的回调。

在面向对象的C++程序中使用回调有一个难题,即使用非静态(实例)方法作为回调有些复杂。因为,此情况下对象的“this”指针也需要传递。本书附带的源代码中给出如何实现非静态回调函数的示例,该示例为每个成员回调方法创建了一个静态的封装方法,并且使用额外的回调参数传递this指针。

Boost库中为此问题提供了更加优雅的解决方案,即boost::bind功能。它的实现采用了functors(带有状态的函数)。在C++中,functors可以实现为一个类,该类使用私有成员变量存储状态,并包括一个重载的operator()方法执行函数。

2. 观察者

回调给出的解决方案在纯C程序中能够正常工作,而正如前面提到的,如果不使用类似boost::bind的辅助功能,在面向对象的C++程序中使用回调是非常复杂的。相比之下,更加面向对象的解决方案是使用观察者的概念。

这是一种软件设计模式,对象维护一个它所依赖的对象(观察者)列表,并通过调用观察者的方法通知观察者。对API设计中最小化耦合度而言,这是一个十分重要的模式。

3. 通知

回调和观察者适用于特定的任务,它们的使用机制通常定义在执行实际回调的对象中。一个替代的解决方案是,在系统中不连通的部分之间构建集中发送通知机制或事件。发送者事先不需要知道接收者,这样可以降低发送者和接收者之间的耦合度。虽然通知机制有好几种,但是最流行的是信号和槽(signals and slots)。

信号和槽的概念由Qt库引入的,它提供一种通用的、允许发送任意事件(如鼠标点击或计时器事件)给任意接受方法的方式。但在C++代码中有几种信号和槽的替代实现,包括Boost的boost::signals和boost::signals2库。

信号可以被认为是含有多个目标(槽)的回调。当信号被调用(或者发出)时,所有该信号的槽都会被调用。举一个具体的例子,以下代码片段使用boost::signal创建了一个没有参数的信号。然后将一个对象连接到信号上。最终,发出该信号会调用MySlot::operator(),进而会打印一条信息到标准输出上。

class MySlot
{
public:
   void operator()() const
   {
      std::cout<<"MySlot called!"<<std::endl;
   }
};

//创建MySlot类的一个实例
MySlot slot;

//创建一个没有参数且返回值为空的信号
boost::signal<void() >signal;

//连接槽和信号
signal.connect(slot);

//发出信号从而调动所有的槽
signal();

在实际应用中,低层次的类可以创建并拥有信号,然后允许任何不连通的类成为该信号的槽。然后低层次的类可以在任意恰当的时间发出信号,进而所有已连接的槽都会被调用。

稳定的、文档详细且经过测试的API

优秀的API设计应该是稳定的且具有前瞻性。在当前语境中,稳定并不一定意味着API不会改变,而是应该将接口版本化,并且在版本升级时保证向后兼容。习语“前瞻性”表示API应该设计为可扩展的,方便以后优雅地升级而不被修改得一塌糊涂。

优秀的API设计也应该有很好的文档支持,以便用户获取API的功能、行为、最佳实践以及错误条件的明确信息。最终,应该为API的实现编写可扩展的自动化测试程序,确保新的变更不会破坏现有的用例。

这些主题在文末浓缩为一个小节,并不是因为它们微不足道或无关紧要,实际上恰恰相反,这些问题对高质量、健壮、易于使用的API的开发而言举足轻重。

 

{%}

《C++ API设计》就“如何构建高效、健壮、稳定且可扩展的优质API”展开讨论。Martin Reddy凭借长期的从业经验,对优质API所应具备的各要素进行了全面分析,针对API的不同风格及模式,以及大型长期项目的内在要求,给出了种种最佳设计策略,从而对API设计过程的规范性及可持续性作出了重大的贡献。

本书适合软件工程师、高校计算机相关专业学生,以及编程爱好者阅读。