背景: 常见的模块化开发、代码复用、组件化、动态链接库(DLL)、 软件框架、分布式计算以及面向服务的架构(SOA),都隐含了对髙超的API设计技能的需求。
目标: 健壮而优雅、稳定而耐用、抽象而隐藏,最主要的: 变更管理----应对变化、新需求、功能要求及错误修复。
本书支持网站:http://APIBook.com/
第一章:API简介
1.1 什么是API:
API的一个重要的基本定义是:API是一个明确定义的软件组件的逻辑接口, 可以为其他软件提供特定服务,可大可小,可互相依赖,隐藏了内部实现细节。API是为其所提供的服务或者行为定义的一个契约。
C++API通常会包含如下的元素:
(1)头文件:一组.h头文件
(2)一个或多个静态库或动态库文件
(3)文档
Win32 API是纯C API,而非C++API。可以在C++程序中直接使用C API, C++API中的杰出代表是STL( Standard Template Library,标准模板库)。STL包含了一组容器类、对容器中元素进行遍历的迭代器以及作用于容器的各种算法。例如,该算法集合中包括很多高级操作,比如std::search()、std::reverse()、std::sort()和std::set_intersection()。因此,STL 提供的是操作元素集合任务的逻辑接口,且没有暴露每个算法内部的实现细节。
1.2 API设计上有什么不同:
与实现相比:
1. API是为开发者设计的接口,会被多处重用;
2. 修改API时,必须尽可能保证向后兼容。
API描述了其他工程师构建他们的应用软件所使用的软件。因此,API必须拥有良好的设计、文档、回归测试,并且保证发布之间的稳定性。
1.3 为什么使用API
隐藏实现、延长系统寿命、促进模块化、减少代码重复(代码复用)、易于改变实现、易于优化、减少并行开发时的相互依赖.
1.6 文件格式和网络协议
在计算机应用中存在几个其他形式的常用通信“协议”,其中最常见的一个就是文件格式。它是使用众所周知的数据组织层次将内存中的数据存储到磁盘文件上的方法。比如,JPEG文件交换格式(JFIF) 是用来交换JPEG编码图像的图像文件格式,通常使用jpg或jpeg文件扩展名。表1-2展示了 JFIF文件头格式。
表1-2 JFIF文件格式头部规范 | ||
属性 | 字节数 | 描述 |
APP0 marker | 2 | 总是OxFFE0 |
Length | 2 | 除APPO marker外的片段长度 |
Identifier | 5 | 总是0x4A46494600(“JFIF\0") |
Version | 2 | 0x0102 |
Density units | 1 | 单位像素密度属性,0表示无单位 |
X density | 2 | 整型水平像素密度 |
Y density | 2 | 整型垂直像素密度 |
Thumbnail width (w) | 1 | 内嵌缩略图的水平大小 |
Thumbnail height (h) | 1 | 内嵌缩略图的垂直大小 |
Thumbnail data | 3 x w x h | 无压缩24位RGB栅格数据 |
有了数据文件的格式,例如表1-2给出的JFIF/JPEG格式,任何程序都能读写这种格式的图像文件。 这就使得不同的用户之间可以交换图像数据,从而也衍生了图像査看器和能操作这些图像的工具。
同样,网络协议也是一样。
这些例子在概念上都和api相似,在api中为了交换信息也需要定义标准的接口或规范。此外,任何规范的变更必定要考虑到对现有客户端的影响。虽然它们具有相似性,但文件格式和线路协议却不是真正的API,因为它们不是程序的编程接口,因此不能被链接到应用程序中。一个较好的经验法则是,每当你拥有一个文件格式或客户端/服务器协议时,就应该制作附属的API以管理规范变更。
每当你创建一个文件格式或者客户端/服务器协议时,同时也要为其创建API。这样,规范的细节以及未来的任何变更都将是集中且隐蔽的。
1.7 关于本书
应该考虑的特征==》 有助于API设计的设计模式/惯用法==》设计实践(功能收集+用例建模推动)==》风格(纯C型,面向对象型,基于模板型,数据驱动型)
==》C++用法 ==》性能==》版本控制==》文档==》测试==》脚本化(可选)==》可扩展性
第2章 特征:
优秀API特征: 信息隐藏、一致性、松耦合。
C++中使用Pimpl惯用法是一种隐藏内部实现细节的方法。
2.1 问题域建模
2.1.1 提供良好的抽象
对于非技术人员,API提供的一组操作应该合乎逻辑且共属同一单元。每个类都应该有一个主旨, 且这个主旨应该能通过类名和类包含的方法名一看就能看出来。
绝大多数API都能以多种方式建模,每种建模方式都能提供良好的抽象和易用的接口。关键是API应该具有一致且合理的支撑体系。
UML类图:UML规范定义了一组面向对象软件系统的可视化建模符号。
本书将经常使用UML类图描述类的设计。在这些图表中,类用方框表示,该方框被分割成以下三个部分:
(1) 上层区域是类名;
(2) 中层区域列出类的属性;
(3) 下层区域列出类的方法。
方框中、下层区域中的每条记录都可以添加前缀符,以此标识对应属性或方法的访问级别(有:
+表示公有的(public)类成员
-表示私有的(private)类成员
#表示受保护的(protected )类成员
类之间的关系可以用多种样式的连接线和箭头表示。下面列出了 UML类图中可能出现的常见关系。
关联:两个类之间简单的依赖关系,二者互不从属于对方,这种关系用实线表示。关联可以带有方向,UML用开放箭头表示方向,比如“>”。
聚合:“has-a”关系,或者整体与部分的关系,两个类互不从属于对方。用带有空心菱形的直线表示。
组合:“contains-a”关系,部分和整体具有统一的生存期。用带有实心菱形的直线表示。
泛化:类之间的父子关系,用带空心三角箭头的直线表示。
对象间的各种关系可以在某个对象边用注释定义,这样就能分辨出它们的关系是一对一、一对多还是多对多。一些常见的关系有:
0..1表示零个或一个实例 1表示只有一个实例
0..*表示零个或多个实例
1..*表示一个或多个实例
2.1.2 关键对象的建模:
----描述特定问题域中对象的层次结构,确定主要对象的集合,厘清这些对象提供的操作以及对象之间的关系。
2.2 隐藏实现细节:
主要有两种技巧可以达到此目标:物理隐藏和逻辑隐藏,物理隐藏表示只是不让用户获得私有源代码。逻辑隐藏则需要使用语言特性限制用户访问API的某些元素。
物理隐藏表示将内部细节(.cpp)与公有接口(.h)分离,存储在不同的文件中。
逻辑隐藏:封装,提供了限制访问对象成员的机制。public、protected以及private。封装是将API的公有接口与其底层实现分离的过程。逻辑隐藏指的是使用C++语言中受保护的和私有的访问控制特性从而限制访问内部细节。
Java中的包私有表示该成员只能被同一个包中的类访问,这是Java中的默认访问级别。若要让同一个JAR文件中的其他类访问该类的内部成员,而又不必将该类的内部成员暴露给客户,那么使用包私有是很好的做法。包私有在需要验证私有方法的单元测试中十分有用。
应该使用get或set方法间接地访问成员变量,可以在API不是线程安全的情况下,在get、set里增加互斥锁,免得直接访问时每次都加。
类定义中成员变量或对象应隐藏,即声明为私有。
2.2.4 隐藏实现方法
隐藏实现方法: 类只应该定义做什么而不是如何做。
很遗憾,由于C++语言的限制,所有公有的、受保护的和私有的类成员都必须出现在类的声明中。 理想情况下,类的头部应该可以在其他地方声明所有的私有成员。 尽管如此,仍然有一些方法可以让私有成员在头文件中不可见(Headington,1995)。一种常用的技巧称为Pimpl惯用法,它将所有的私有数据成员隔离到一个.cpp文件中独立实现的类或结构体内。之后,.h文件仅需要包含指向该类实例的不透明指针(opaque pointer)即可。在第3章中我将详细探讨这个极有价值的技巧。
强烈建议在API中采用Pimpl惯用法,这样就可以将所有实现细节完全和公有头文件分开。如果你不想这么做,至少也要将头文件内不需要的私有方法移到.cpp文件中,并将它们转换为静态函数(Lakos, 1996)。但只有当私有方法仅访问类的公有成员或者根本不访问任何类成员时才能这么做,(例如接收文件名字符串,然后返回该文件名的扩展名的程序)。很多工程师认为如果类使用了私有方法,那么就必须将其包含在类的声明当中,但这么就暴露了多余的实现细节。
2.2.5 隐藏实现类
并非所有的类都需要公开,有些仅仅是用于实现,因此应该将其从API的公有接口中移除。
2.3 最小完备性
API尽量简单,单一。
一个很好的建议是:当不确定是否需要某个接口时,就不要提供此接口。
2.3.2 谨慎添加虚函数
重写函数可能破坏类的内部完整性,客户可能采用不正确的或易于出错的方式扩展API;也不需要将虚函数设为内联,因为内联是编译时优化,虚函数是运行时确定的。
实际上,Herb Sutter指出,应该将虚函数声明为私有的。
如果类包含任一虚函数,那么必须将析构函数声明为虚函数。绝不在构造函数或析构函数中调用虚函数,这些调用不会指向子类。
2.3.3 便捷化API
在减少API函数数目与使API易于各种客户使用之间存在天然的矛盾。
原语性----一方面,有人认为API应该仅提供一个方法,仅执行一项任务。这确保了 API是最小化的、集中的、 一致的且易于理解的,还减少了实现的复杂性,并具备更稳定、更易于调试和维护等优点。
另一方面,也有人认为API应该让简单的事情更简单。这需要更高层次的封装。 两者并不矛盾,但最好不要出现在一块儿,即混在同一个类中。API应该通过易用接口来呈现基本功能,同时将高级功能隐藏在另一个独立的层次中。如基于OpenGL API的GLU和GLUT。
2.4 易用性
简单、易理解、文档。使用自描述的枚举类型代替bool类型。不依赖参数顺序(多定义几个类如:Date类,Year、Month、Day类),命名一致、参数顺序一致、内存模型语义、异常的使用和错误处理等。
不混用Begin/End和Start/Finish, src和end的顺序一致, 尽量不使用缩略语。减少冗余,增加独立性。
2.4.5 健壮的资源分配:
弱指针,弱指针包含一个指向对象的指针,通常是共享指针,但是并不增加其指向的对象的引用计数。如果一个共享指针和一个弱指针引用了相同对象,那么在共享指针被销毁时,弱指针的值 会立即变为NULL。通过此方式,弱指针可以检测其所指向的对象是否已经过期:即其指向对象的引用计数是否为零。这样就避免了悬挂指针(指向已经释放的内存)问题。
RAII ( Resource Acquisition Is Initialization)。
2.4.6 平台独立
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的后续版本中增加了对另一个设备类的支持(如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);
}
具体实现上:
bool MobilePhone::HasGPS() const
{
#if defined TARGET_OS_IPHONE
return true:
#else
return false;
#endif
}
2.5 松耦合
耦合:软件组件之间相互连接的强度的度量,即系统中每个组件对其他组件的依赖程度。
内聚:单个软件组件内的各种方法相互关联或聚合强度的度量。
优秀的软件设计应该是低耦合(或松耦合)且高内聚的,即最小化不同组件之间功能的关联件和连通性。达到这一目标之后,每个组件的使用、理解以及维护就能实现相互独立了。
除非确实需要include类的完整定义,否則应该为类使用前置声明。
如果情况允许, 先声明非成员、非友元的函数,而非成员函数。与成员函数相比,使用非成员、非友元的方法能降低耦合度。
有时,冗余是必要的。
2.5.4 管理器类:
管理器类可以通过封装几个低层次类降低耦合。
2.5.5 回调、观察者和通知
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 SetCal1back (Cal1backType cb, void *data);
.........
private:
Ca11backType mCa11back;
void *mClosure;
};
使用:
if (mCa11back)
(*mCallback)("Hello World", mClosure);
在面向对象的C++程序中使用回调有一个难题,即使用非静态(实例)方法作为回调有些复杂。因为,此情况下对象的“this”指针也需要传递.本书附带的源代码中给出如何实现非静态回调函数的示例,该示例为每个成员回调方法创建了一个静态的封装方法,并且使用额外的回调参数传递this指针。
Boost库中的boost::bind和C++11里的std::function和std::bind更优雅.
boost::bind()的实现使用了functors(带有状态的函数). C++中, functors可以实现为一个类, 该类使用私有变量存储状态,并包含一个重载的operator()方法执行函数.