关于API的设计与实现
API的设计是软件开发中一个独特的领域。最主要的特殊点在于API是供开发者使用的界面,即Application Programmer Interfaces。类似于用户可以直接使用到的GUI的作用一样。所以相对于依据软件设计的原则,考虑用户的”体验”会更加重要。
许多著名的工具和库的作者都写过相关的著作,详细的论述他们在API上的设计与实现要点。下面的论述,就是从这些前人的工作成果中总结而来。以下先列出参考资料:
- 1.软件框架设计的艺术 (Jaroslav Tulach, NetBeans)
- 2.Little Manual of API Design (Jasmin Blanchete, Qt)
- 3.Preserving Backward Compatibility (Garrett Rooney, Subversion)
- 4.API Design Principles (Qt Wiki)
- 5.How to design a good API and Why it matters (Google)
关于API
狭义上API可能只是一个动态库(共享库)提供功能的接口定义。广义上API分为public API,以及internal API之分。既有整体软件系统对外输出的接口(包括与设备通讯的接口),也有系统内一个底层模块提供给上层模块使用的接口定义。
API看似简单的名词,却代表着重要的架构设计。从架构设计的角度来看(所谓的组成论),软件系统就是模块和接口。模块(层次/组件)决定分工,接口决定交互。API就是接口的定义。模块间并不需要关心其它模块的实现,只需要了解如何进行协作即可。这样将复杂度分散到各个模块之中,使得整体系统更为可控。而API的本质,就是提供给模块开发者使用的接口,是给”人(Programmer)”用的。API的设计任务的核心就是保证使用者以较低的成本,正确的使用接口,驱动模块完成他们的业务。对于Public API,最大的设计挑战则是如何把API一次就做对!
附1的作者在书中提到了一个”无绪(cluelessness)”的概念,即API的使用者不需要对API的内在逻辑有了解,可以只依据API的定义来使用API。更直白一点就是傻瓜式的API。
什么是好的API
对于一般的开发任务,常常思考的是保证功能的正确性和设计的完美,可以不断尝试做创新和重构。但这些原则放到API设计上就不一定正确了,反而需要有些保守。先看一下KDE/Qt开发者总结出来的好API标准:
容易学习和记忆
(Easy to learn and memorize)
这包括了命名,模式的使用,最关键是对于经验式编程的包容。所谓经验式编程是指开发者常常不会认真读完接口的文档(如果提供的话),而是根据思维的连续性,以过往的经验来预先假定API的功能。比如,如果如下两个类都有相同方法:
void Widget::SetSize(int width, int height);
void View::SetSize(int width, int height);
另一个类,逻辑上会自然的认为是View的子类,但却提供如下的方法,就会让人捉摸不透了:
void Button::Layout(int width, int height);
从经验式编程的角度,使用Button::SetSize()是非常自然的事,程序员很可能不会认真核实这个Button竟然没有提供这个方法。
作为API设计者,不能假定使用者都会认真的看完所有的文档,而是要尽量做到两点:
- 保持与普遍认知一致的设计。
- 保持设计概念上的一致性(Consistency)。
那些被公认的行为和命名就非常重要,千万不要做太多创新。请遵守最小惊喜原则。
简洁清晰的语义
这样有助于理解,也很难被误用。当一个API无法满足所有的需求时,不要尝试为了一些极小场景来影响到一般的场景,可以另分一个独立的路径。这样的情况,往往反应在函数的参数上。比如这样的API(来自Win32), 你必须每次都要对着文档来调用了:
HWND CreateWindow(LPCTSTR lpClassName, LPCTSTR lpWindowName, DWORD dwStyle, int x, int y, int nWidth, int nHeight, HWND hWndParent, HMENU hMenu, HINSTANCE hInstance, LPVOID lpParam);
另外在附2里举了一个输出如下HTML文本的例子:
the <b>goto <u>label</b></u> statement
以C++的实现可以为:
stream.writeCharacters("the ");
stream.writeStartElement("b");
stream.writeCharacters("goto ");
stream.writeStartElement("i");
stream.writeCharacters("label");
stream.writeEndElement("i");
stream.writeEndElement("b");
stream.writeCharacters(" statement");
很显然,这里Element的Start与End需要开发者自己处理。如果想要编译器来帮助检查,让开发者少犯错,则代码可以变为:
stream.write(Text("the ")
+ Element("b", Text("goto ") + Element("u", "label"))
+ Text(" statement"));
容易扩展及保证向后兼容
之前的资料都是分散的谈到两者的,我将它们合并在这里,因为它们都是API演变所必须考虑的。
随着需求变化,API的演变是必须的,不可能存在一成不变的API。但是作为稳定的API则是对使用者的承诺,不单单是技术上。稳定的概念不是不变,而是指变化的成本要尽可能的低。
如果新增一个API会导致之前的代码无法编译,或者程序无法正常执行,都会影响使用者对API的信任。
能够鼓励编写可读性代码
还是前面强调的,API是给程序员用的,所以本身的命名必须具备可读性。同时,它还要设计成引导使用者写出更具可读性的代码。附2里举了如下的例子。
在Qt3中,Slider的建构函数允许用户指定多个参数:
slider = new QSlider(8, 128, 1, 6, Qt::Vertical, 0, "volume");
而在Qt4,则需要这样做:
slider = new QSlider(Qt::Vertical);
slider->setRange(8, 128);
slider->setValue(6);
slider->setObjectName("volume");
显然后者更具可读性。
这里还是有争议的。既不能为单独的追求可读性而将相关的东西分离开来,也不能为了简化代码,而将不同的内容合在一起。
简洁
这一点对于第一条特别重要。一个不断膨胀,十分臃肿的API必然会产生各种理解和使用上困扰,特别是当多个API存在功能重叠的情况时。举一个会带来理解上困扰的例子:
void View::SetSize(int width, int height);
void View::SetWidth(int width);
void View::SetHeight(int height);
后两者明显是前者的两个子任务,却因为某些特别的原因被公开出来。就会出来到底是调用SetSize(),还是根据变化调用对应的SetWidth()或SetHeight()呢?
完整
如果需要提供的功能就要提供,一个接口类应当具备的函数(包括setters/getters)也应当在这个类中提供。
API的设计实现
关于API的设计实现,不同的背景,不同的需求会有不同的描述了。我这里概括了一些他们间相通的要点。
工厂方法优于建构函数
如果公开一个构造函数,那么创建的对象一定是类的实例。而工厂方法更具灵活性,虽然参数完全相同,但可以返回一个子类的实例。同时更利于实现单例或者缓存对象实例。
在Chromium一些模块的接口上,常常可以看到这类的应用。
常量修饰符
常量修饰符,有助于限定不必要的修改动作,也是一种行为约定。无论是对参数,函数,或是返回值,都可以视需要添加常量修饰符。
基于属性的API
相对于在建构时传入一串参数的接口类,不如在建构后再以setter设置其它参数的方式。其区别在于后者更利于编写可读性的代码。在上面关于可读性代码中已举过例子,这里不再赘述。
要点是各个属性需要做到正交,且与顺序无关。
Virtual APIs
对于是否需要提供虚函数形式的API,也是一直有争论。这里并不是讨论接口类(纯虚类)的定义,接口类的定义的必要性是明确的,不需要额外讨论。
原则上对虚函数作为API是限制使用的,原因是继承下的override可能会导致接口的行为变得不符预期,因为子类的行为无法确定。
但在一些场景下确实有必要为使用者提供一定的扩展性,就可以提供虚函数,以便使用者可以通过继承改变原来的行为。
布尔值参数
以整型数据代替Enum的作法类似,关键在于使用者的理解。
可以改进的做法包括,分成不同的函数实现,或者以枚举变量代替。
示例:
widget->repaint();
widget->repaint(true);
widget->repaint(false);
分开函数的方式:
widget->repaint();
widget->repaintWithoutErasing();
使用整数代替格枚举变量时也是相同的问题。
异常处理
在附5中作者详细说明了关于API中的异常处理。我的总结是只抛必须抛的异常,绝不能自作聪明的默默处理。API的代码应当最真实的反应出执行中的问题,更不能用聪明的代码做某些特别处理。其背后的原因是这样做会使得API的行为与预期会发生偏差,违背了最小惊喜原则。
命名
在命名上,附2列举的比较详细。概括如下:
- 选择具有自解释能力的命名
核心是从用户和领域的角度命名,而不是从自身的设计命名。比如Qt 4.2中QWorkspace实现了MDI (multiple document interface)。好在这样的命名后来被修正为QMdiArea。 - 命名不要有歧义
如果遇到有概念相似的API,一定要从命名上将它们区分出来。如sendEvent()表示同步的事件,而sendEventLater()则表示异步事件。 - 保持一致性
这一点对于前面对经验式编程的支持很重要,也被称为对称性(Symmetry)。如果set前缀代表的是setters,就不要出现以set打头,但却不是setter的情况。再比如Chromium中对setters/getters的定义以非常明确的方式独立出来。 - 避免简写
简写除了是某种通用的缩写外,不要随意以首字母缩写的形式定义简写。不然,读者可能对名字完全不知所云。 - 优先使用特殊的命名,而不是通用的命名
一个通用的名字常常包含更为普遍的职责,如果API的功能带有明确的应用场景,就应当在API上体现出来。否则一旦遇到需要一个通用API的情况,就用很多余的加上XXXXInGeneral之类的命名,而且会让用户出现难以选择适用API的情况。 - 不要太迁就于既有的命名
比如包装一个旧的或子功能的API的时候,常常会延用原有的API命名。其实完全没必要,更合理的做法还是从新API的功能入手,选择合适的名字。
关于向后兼容
一个模块(库)的兼容性主要包括:
API兼容
主要是定义上的兼容性,即代码能否编译,以及行为的一致性。ABI兼容,即二进制级的兼容。
对于共享库就是需要有相同的符号表,包括全局的对象和定义。Linux里这类问题太多了。通讯协议的兼容
如果有自定义协议的网络通讯,就可能存在C/S之间通讯协议的兼容性问题。存储的数据及文件格式的兼容
如果用户升级后,发现以前的历史数据不可用了,大多数情况都是无法接受的,搞不好还要吃官司的。
保证兼容性
至于要保证哪些点的兼容性,取决于用户的规模,以及影响的程度(或者用户的承受能力)。从兼容性的角度,保证兼容性方法包括:
不要丢掉任何东西
非常悲催的现实。如果你弃用了API的某一部分(更不能改了),无论使用@Deprecated,还是在文档中反复声明,你都可能会造成使用者之前的代码失效。一定要保证之前API的完整性,除非你的兼容性规则允许你放弃,就比如像MicroSoft一样宣称将不再支持某个版本。隐藏细节
可以使用Opaque Pointer (PIMPL)或者利用建构函数来帮助API隐藏内部的数据结构,而且让使用者只能通过提供的函数来操作数据。保证协议及数据格式的扩展性
可以使用标准化的XML以及标准化的协议来取代自定义的格式。如果条件不允许,也记得在协议及数据格式中定义出版本,以便于后期做兼容性处理。
预留字段也是一个常用的做法。我曾经不止一次的遇到,通过协议中的预留字段解决紧急问题的案例。-
实现上保证兼容性
在实现逻辑上,特别是判断处理也要注意兼容性处理,这是一个常常犯错的地方。以某个字段flagA的处理为例:if (headers.flagA != 1) {
doB();
} else {
doA();
}
显然将判断条件改为headers.flagA == 1会让实现更具兼容性。否则,降级时,就是灾难了。
极端的意见有害无益
(主要参考附1)
关于API定义的评价中,漂亮或者优雅都是很主观的。我们应当设计易于使用,广为接受且富有成效的API(节自附1)。至于所定义的原则,完合取决于API自身的需求。比如因为性能的原因,一些API可能无法满足某些场景的需求,达不到完整性的要求。API的设计者不需要去满足所有人,重要的是API本身保持正向的演进。比如标准的优化流程就比较适合API的发展:
1. Make it work
2. Make it right
3. Make everything work
4. Make everything right
5. ……
转载请注明出处: http://blog.csdn.net/horkychen
进一步阅读: 避免类的膨胀 (接口类适用)