前言
给想写类库的人。本文是vrcats按照一个Qt开发工程师写的《A Little Manual of API Design》翻译而成的。其中总结了多年Qt开发中关于API设计的一些经验和教训,提炼出了一系列关于API设计和面向对象类库设计的原则和方法论。水平很高,门槛不高,是vrcats目前看到的最好的关于C++API设计资料。
本文并非严格翻译。有许多段落思维太跳跃了不搭界,作了删节处理。有些例子不恰当的也作了一些修改,也加入了一点个人对API设计的理解。译者对于API
设计其实也是初哥,相信国内做API设计的也不多,从这方面资料的量就看得出来。翻译得虽然有点糙,总的思想不会错。全文万字左右,不长,大家慢慢咀嚼。
发现错误回帖告诉我,我会及时更正。转贴请注明原始来源,译者和转自CuteQt。
原著:
目录:
* 简介
* 好的API长啥样
* 设计流程
* 设计原则
简介
API是应用编程接口的缩写。说白了就是开放给编程者的一堆符号,这样他们才能使用你的库。API设计是库设计中最难搞的一步,会影响到上层应用程 序的结构。正如丹尼尔杰克逊大叔说的,软件就跟砍竹子是一样一样的,都那么抽象。你要找对了口儿就势如破竹,那叫顺水行舟一路狂奔,加新功能也不需要怎么 大改。你要是一开始设计错了,那就两步一个坎三步一个疖子,等着无穷的惊讶吧。
本手册来自多年Qt开发的经验。设计的时候要仔细考虑API设计,同时兼顾实现难度和性能。甭管你是设计公用API还是本地库,这些规则都挺有用的。
好的API长啥样?
这个问题太主观。在跟一些大牛研究之后,大家一致认为好的API要有如下特点:
* 好学好记
* 看了就知道是干啥的
* 不容易用错
* 容易扩展升级
* 完整
有俩特点这里没写,“简洁”和“一致”,因为这俩是废话。一套不一致的API不可能易学易用,也不可能很容易扩展。简洁当然应该是我们追求的目标,但是简洁的最终目的也是为了强化以上几个特点。
但是设计API的时候,一定要注意一致性。在系统设计中,一致性是非常重要的,尤其是复杂的系统。所有的想法都要围绕一个原则,采用一套方法。如果你手头已经有了一套很好的库,你想扩展它的时候一定要尽量学着原来的风格,这是成功的捷径。
好学好记
让API好学好记可不容易。命名,符号,概念和推测在这里都很重要。同一概念应该有同样的名字,不同的概念必须有不同的名字。
简洁的API好记,因为短。一致的API好记,因为能够联想推测。
API不仅仅是类和方法的名字那么简单,还包含其中隐含的语义。这个语义应该是简单明确的,让用户能够猜到。
有些API门槛很高,用起来很麻烦,要写一大堆的代码才能工作,这样就很难用。比如自己定义一个widget,3D界面,搞一个model,这都很让人头疼。如果API设计得好,用户应该能很容易写出一个”Hello World”,然后从这里开始慢慢扩展他们的知识。
比如说QPushButton就是个容易学的API代表。只要生成一个对象,甚至都不用调用show(),就可以完成一个完整的”Hello World”:
int main()
{
QPushButton button("Hello world");
return QApplication::run();
}
有时候,提供一些便利API有利于用户记忆和使用你的库。比如你有一个API叫insertItem(int, Item),一般就应该提供一个addItem(Item)来方便用户使用。
看了就知道是干啥的
有的计算机语言容易理解,但也有不好理解的,比如APL语言,简直就是数学公式的堆砌。Perl的名声也不好,看Perl程序简直就像在读密码本。
应用程序只写一次,但是是要被读很多次的。程序易读则易写文档易维护,也会比较少有错误,因为错误会比较明显容易发现。
比如在Qt3里,QSlider可以这样初始化:
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");
这样比较容易读,而且容易发现错误:设置值6对于slider越界了。
可读性强的代码来自于适当的抽象。不要隐藏重要的信息,也不要让用户提供无关的信息。
比如Qt Jambi的一段程序:
QGridLayout layout=new QGridLayout;
layout.addWidget(slider,0,0);
layout.addWidget(spinBox,0,1);
layout.addWidget(resetButton,2,1);
layout.setRowStretch(1,1);
setLayout(layout);
相比之下Java Swing的代码就傻多了:
GridBagLayout layout=new GridBagLayout();
GridBagConstraints constraint=new GridBagConstraints();
constraint.fill=GridBagConstraints.HORIZONTAL;
constraint.insets=new Insets(10,10,10,0);
constraint.weightx=1;
layout.setConstraints(slider,constraint);
constraint.gridwidth=GridBagConstraints.REMAINDER;
constraint.insets=new Insets(10,5,10,10);
constraint.weightx=0;
layout.setConstraints(spinner,constraint);
constraint.anchor=GridBagConstraints.SOUTHEAST;
constraint.fill=GridBagConstraints.REMAINDER;
constraint.insets=new Insets(10,10,10,10);
constraint.weighty=1;
layout.setConstraints(resetButton, constraint);
JPanel panel=new JPanel(layout);
panel.add(slider);
panel.add(spinner);
panel.add(resetButton);
知道为什么Qt比Java好了吧。
不容易用错
好的API会引导用户正确使用,甚至引导用户采取更好的编程风格,想用错都难。好的API不应该限制用户以某些特定的顺序和方式来调用API。
比如我们看如下三种语言的语法,html,tex和latex:
html: the goto label statement
tex: the {\bf goto \underline{label}} statement
latex: the \texbf{goto \underline{label}} statement
html的语法明显重复,而且容易搞错顺序,造成错嵌套。而tex的语法很容易忘记括号,造成错误蔓延。latex就解决了这个问题,使用比较长的命令,用户不太容易打错。
设计API其实和设计脚本语言很类似。你应当把API本身看作一种语言,而不是语言的扩展。以下的C++代码打印了上文的html文档:
stream.writeCharacters("the");
stream.writeStartElement("b");
stream.writeCharacters("goto ");
stream.writeStartElement("i");
stream.writeCharacters("label");
stream.writeEndElement("i");
stream.writeEndElement("b");
stream.writeCharacters(" statement");
方便一点,你可以让C++编译器检测是否有错误嵌套:
stream.write(Text("the ")+Element("b",Text("goto ")+Element("u","label"))+Text(" statement"));
这种问题其实很常见。比如在配置文件的使用中,你可能会写这样的代码:
QSettings settings;
settings.beginGroup("mainwindow");
settings.setValue("size",win->size());
settings.setValue("fullScreen",win->isFullScreen());
settings.endGroup();
还有一个来自脚本语言的教训。html开发者曾经试图用来代替,用来代 替,但是用户根本不买帐。因为敲,容易得多,而且意义更加明确。如果真的要修改成语义明确的符 号,他们也该改成之类的,这样不上不下的API设计让人很不爽。
在Qt3中大家经常把qPushButton, QLabel, QLineEdit的ObjectName和parent两个参数顺序弄错。你如果写成:
button=new QPushButton(this, "Hello");
编译能够通过,可是没有文字,因为参数顺序错了。所以在Qt4里改为:
button=new QPushButton(this);
button->setObjectName("Hello");
这样就明确多了。
最后,去掉多余的部分能够避免用户错用API。比如addItem(Item)就比insertItem(int, Item)更不容易用错。
容易扩展
库一直在成长。新的类加入,旧的类获得新的方法,新的枚举值。这都是API设计需要考虑的问题。在原始API设计的时候,更应该考虑到二进制兼容的问题。
Qt2
的QStyle是一个扩展性极差的反面典型。它定义了一系列的虚函数画widget,用户想要在不破坏二进制兼容的情况下实现自己的style几乎不可
能。Qt3注意到了这个问题,把style变成了基于枚举的,用户只要增加枚举元素就可以轻松定义新的style了。
有完整性
API必须提供用户需要的所有功能。这通常是困难的,一般来说,API可能不直接提供所有的功能,而是提供基础的功能,高级功能由用户自行设计实现,如subclassing。
完整性也是与时俱进的。使用中可能会不断出现用户需求。但是至少一开始要瞄准正确的方向,每一个新的需求都是往正确方向前进的一步。
设计流程
设计一套API可能要几人年的工作量。设计的每一步都是完善的过程,当然也可能是搞砸API的过程。以下的原则可能有助于你更好地设计API。
仔细研究需求
设计之前要仔细研究需求,知道需要的是什么。多咨询大家,比如你老板同事和用户,看他们想要怎样的功能。
Qt4.3的MDI实现之前就在内部开发邮件列表上征求了很多人的意见。大家对以前的MDI框架存在的问题和没有的功能进行了深入的讨论,对API设计的帮助很大。
设计之前先写用例
一般设计API的通病是先实现功能,然后设计API,最后发布。其实应该先设计再实现。
开始设计API之前,先写几个使用这套API的代码片段。在这个阶段先不要考虑实现的难度。用例写完,API的雏形也就出来了。总的原则是“让事情简化,不可能变为可能”。
一个例子就是QWizard。QWizard有两种,一种是简单的线性Wizard,不能跳来跳去的,另一种是复杂的。经过使用用例我们发现简单的Wizard可以看作是复杂Wizard的一个特例,这样就简化了API设计。
研究同一类库中类似的API设计
要设计XmlQuery,一个好的办法就是参考本类库中的SqlQuery。这两个概念很相似,都是完成查询,浏览结果,显示等。熟悉SqlQuery的用户不用费劲就能学会新的API。你也可以参考SqlQuery的构架方式,减轻设计工作量。
当然,完全照抄也是很傻的。你应该批判地继承,加以发展。首先完善这个设计,然后加以学习。
如果要给一套API写新版本,第一件事就是要透彻地了解这套API。不要全盘否定旧的设计,不要试图代替,而要创造性地设计。为了兼容的需要,你可能要包含所有上一版本的功能。
脑残的例子:Qt4.0中,QDockWindow被改名为QDockWidget,没有任何原因;QTextEdit::setOverwriteMode()被取消了,后来4.1又重新加入。
先设计,后实现
实现API之前,要确定API的语法。对于用户很多的库,认可你自己的实现麻烦复杂一些,也要让用户用着简单直接。
Qt4中,可以先生成一个QWidget,以后再设置它的parent。在4.0和4.1中,这将会在后台创造一个窗口句柄,开销很大。在4.2 中,实现了 delayed window creation,解决了这个问题,这是一个很好的API驱动设计的例子。在Qt3里,这个问题是通过QWidget::recreate()解决的。这 个API就是纯粹为了实现而实现的。
要记住,API和它的语法才是库提供的最终产品。很多产品的实现变了多次,但是API设计始终如初,如UNIX/POSIX, OpenGL, QFileDialog。
实现API的过程中,要不断写unit test。这样你才能发现很多漏洞和空白,然后细化你的设计。但是不要让实现细节过多影响API的设计,除非是为了一些特殊的原因如性能。
QGraphicsScene::setBspTreeDepth() 就是这样一个例子。这个API纯粹是为了提高性能。用户控制BSP树的深度可以提高性能,但是大多数情况下,系统缺省的树深度也可以满足性能需要。因此这 个API用了一个比较专业的词Bsp,表明了这是深入到API实现内部的一个高级API。初哥一看这个词不认识就不会轻易尝试了。
找人帮你评测API
你应该像孙子一样乞求别人多给你的API一些评测意见,特别是负面的。这些意见更能帮助你改进设计。
多写几个例子程序
设计好API后,一定要写几个例子。你可以使用设计之前写的用例。如果有人能帮你写这些例子程序那就更好了。
Qt所带的Class Wizard和License Wizard例子都来自于设计用例。
做好扩展的准备
有两类人会扩展你的API:API维护者:他们会增减你的API接口;用户:他们会通过定制和继承来丰富你API的功能。
扩展性设计要仔细分析实际的目标。对于那些有虚函数的类,至少要试着写3个子类来验证这些API实现了所有需要的功能,这个我们一般叫作“3个原则”。
在设计Qt4.0的时候,QAbstractSocket设计得就不怎么好。Qt4.3要加入QSSLSocket的时候,我们不得不手工降格其中好几个API,因为他们没被设计成虚函数。好在它们是在同一个库中,可以用“手工多态”解决,否则悲剧就无法避免。
内部API没评测之前不要发布
有些API一开始是内部使用的,后来大家觉得很有用,才公开发布。一个常见的错误就是发布之前没有进行完整的测试。比如Qt就曾经发布过带有拼写错误的API,不堪回首。
宁缺毋滥
如果对API的功能不是很确定,万万不要发布,宁可暂时当作内部API,或者日后再说。
用户的反馈很重要,但是实现用户所期待的所有功能是不可能的。一般等3个客户要求同样的功能后再实现是比较明智的。
设计原则
这里罗列了一些API设计的基本原则,大部分都来自实际的API设计经验。其中有些看似冲突,但是其实都有道理。掌握尺度的是你自己,没有什么能替代你自己的思考,原则只是原则而已。
命名
名字要能解释自己,要遵从英语语法。QPainterPath的作者建议,在文档里把它叫做vector path,因为这是大家通用的叫法。另一个例子是MDI,尽管实现的是MDI,在Qt4.2之前却叫做QWorkspace。在4.3之后,改为了QMdiArea。
另外,参数的命名也要清楚明白。尽量少用bool类型的参数,这样的代码不好读。QWidget::repaint()就带了一个bool类型的参 数,来指示是否在重画之前擦除背景。如果有repaint(false)这样的代码,就很容易让人误会,到底是不是不要repaint还是怎样?解决的方 法之一就是用枚举代替bool,如
repaint(QWidget::eraseBackground);
命名要统一。不要混用类似widget和control这类词语,这会让用户乱猜。参数的顺序也要一致,比如画方框的函数参数为(x,y,width,height),别的地方也要类似,不要弄成(x,width,y,height)。
比如QStackArray,是一种变长的数组。由于用了stack这个词,很容易和QStack混淆。4.1之后,这个类被改为QVarLengthArray。
了解你的用户也很重要。比如你实现了一套关于XML的API,名字里带有XML就是一个很好的主意。如果你自认为API很高档,一定要叫做什么IDREFs或者NCNames,用户会很讨厌的。
命名是API设计的一项重要内容。你设计的名字可能会出现在一些IDE的自动完成功能中,这些名字和参数名必须意义明确,简短有力。尤其要避免一个字母长度的参数名。
避免二义性
一个名字要严格对应一个概念。假如你有两种事件传递机制,一个是同步的,一个是异步的,分别叫做sendEventNow()和 sendEventLater()就不错。如果用户必须了解同步异步概念,你也可以叫做sendEventSynchronously()和 sendEventAsynchronously()。
如果你要鼓励用户多用同步方式,可能会把同步的方法改为sendEvent()。如果你希望用户用异步方式,就可以反过来把异步方法命名为sendEvent()。
Qt中的sendEvent()是同步的,postEvent()是异步的。这里就利用了英语中send和post的微妙语义差别。
在命名复制初始化函数的参数时尤其要注意。下列代码:
Car &Car::operator=(const Car &car)
{
m_model=car.m_model;
m_year=car.m_year;
...
return *this;
}
这段代码很不好,两个car很容易混淆。
注意完整性
API设计跟写书一样,要注意对称和前后照应。格式尽量一样,过程尽量一样,这样读者能更容易了解你的意图。比如所有的set函数都用set开头,这样用户更容易习惯。
在 Qt3中,有一个函数QStatusBar::message(text,msecs)能在状态条上显示一条信息msecs毫秒。但这个函数怎么看都像一 个get函数。Qt4中,我们曾考虑改名为setMessage()以达到一致性。但是setMessage有两个参数,不太像set函数,最终我们决定 改名为showMessage(),以便区分。
再看event那个例子。同步的时候,可以把event对象当作参数传递,因为马上就会返回,函数可以直接删除局部变量。但是异步时,就要创建一个新对象,完成以后删掉,否则就会有内存泄露。所以我们应该把两个API分别设计成:
sendEventNow(Event event);
sendEventLater(Event* event);
以避免用户乱用。很不幸地,Qt在这里犯了脑残的错误,sendEvent和postEvent都是接受Event*的参数,这就很容易造成内存泄
露。当然,为了一致,你可以定义两个都接受Event*,然后自己管理event对象的生存期,这样效率很低下但是很安全。有时候我们就是要在平衡之间作
出选择。
别用缩写
尽量避免缩写。当然有一些常见的例外,如min,max,dir,rect,prev。但是要注意有一致性,不能有的用有的不用。Qt本身在这方面做得其实相当不好。对于参数命名来说,可以适当放宽限制,但是也要保证意思清楚明晰。
名字要专不要通
API的名字空间是很宝贵的。尽量用专用名,否则一旦通用的名字被用了,以后就很难有机会收回来。QRegExp其实被叫做QStringPattern也很恰当,但是这个名字太通用了,所以最后还是选择了QRegExp。
比如你要给SQL添加一个错误报告类,最好叫做SqlErrorHandler而不是ErrorHandler,否则将来很难与XmlErrorHandler作出区分。将来扩展库的时候,如果要用到ErrorHandler作为基类也不会头疼。
Qt在某些方面做得也很不好。比如QDom系列类,就没有区分SAX和DOM的分支,这造成了一定的混乱。
不要太过迁就下层API
如果你要包装一系列API,不要被它的命名方式所支配。按照你自己的命名规则统一命名方式。你设计的目的是让用户使用方便高效,而不是迁就下层的库。
选择合适的缺省值
在Qt中设计一个按钮很容易:
QPushButton * button=new QPushButton(text,paret);
如果你编过Cocoa程序,你就知道,要生成一个按钮要设置9个参数,而99%的时间你选择的初始参数都是一样的。为什么不用缺省值呢?这就是Qt聪明的地方。尽量让你的客户省事,猜测他们需要什么缺省值,不要让他们费劲,隐藏不必要的细节,这就是API的设计之道。
通过选择合适的缺省值,不仅可以减少代码量,还可以让API简单可预测。尤其当你有bool类型参数的时候,尽量让缺省值为false。不要以为参数越多API就越强大,你需要的是易用的API。
不要自作聪明
API应该简单清楚,尽量少让用户产生惊讶的感觉。如果过于自作聪明把API弄得不易用,就远离了API的本来目的。尽量贴近你用户的习惯而不是试图教他们怎么做,否则你就等着写文档去吧。
Qt3 的QLabel就是一个自作聪明的例子。QLabel::setText()集成了显示普通文本和html文本的功能。貌似节省了一个API,但是这样很 容易被客户误用。如果客户想显示一些html的源代码,还必须调用setTextFormat(),大部分人并不知道这个从而变得无所适从。避免自作聪明 的方法是分开两个setText()和setHtml()。
注意边界值
对于类库来说,边界值的处理相当重要,认为边界值发生概率很小就不加注意是很幼稚的。边界值造成的问题会在使用这个类的其他类中得到扩散和放大。比如字符串查找函数有边界值问题,在正则表达式中,这个问题很可能就会被放大。
处理边界值的一个常见错误是在函数开始的时候就加入边界检查。这样做大多数时候并不是必要的。建议你先按照正常的情况进行处理,最后才对边界值进行处理,这样可以提高API的效率。另外就是要记得在unit test中加入边界值的测试。
小心定义虚API
虚API一般更难定义,并且很容易在新版本发布时出错。这个问题叫做“fragile base class problem”。设计虚API时,要注意以下两个问题:
第一是定义的虚API太少,以后发现不够用。一开始很难知道将来要用什么样的API,要用多少。万一定义的API不够用,会限制用户的扩展功能。
第二个就是滥用virtual。在C++中,虚函数效率是很低的。如果你的类并不需要扩展这个功能,就不要定义成虚函数,否则不仅效率低下,还会误导使用者。
设计API时,你必须全盘考虑,逐个过滤来决定哪些API应该是虚的,哪些不应该是,在文档里应该详细说明你的类如何使用这些虚方法。
在C++里,大部分虚函数应该被声明为保护的,以保证不被错误修改调用影响其他类的访问。
Qt4 的QIODevice就是一个很好的例子。公用API为read(),write(),而虚函数为readData()和writeData()。这样就 避免了访问混乱的情况发生。QWidget也类似,公用API为show(),resize(),repaint(),而虚函数为 showEvent(),resizeEvent(),paintEvent()。
C++的一个很操蛋的地方就是加入虚函数肯定会破坏二进制兼容。有一个很恶心的办法可以避免这个问题,那就是定义一个通用的虚函数占位:
virtual void virtual_hook(int id, void * data);
结构性
很多API在创建对象的时候要求用户指定一大堆的属性,比如Win32编程:
m_hWindow = ::CreateWindow("AppWindow", /* class name */
m_pszTitle, /* title to window */
WS_OVERLAPPEDWINDOW, /* style */
CW_USEDEFAULT, /* start pos x */
CW_USEDEFAULT, /* start pos y */
m_nWidth, /* width */
m_nHeight, /* height */
NULL, /* parent HWND */
NULL, /* menu HANDLE */
hInstance, /* */
NULL); /* creatstruct param */
这么多参数对于用户来说是个噩梦。一般现代API会采用另外一种方式,就是基于属性的设计。这样用户就可以用很多行代码慢慢设计一个类实例,不需要干预的非必须属性完全可以不管。
window = new Window;
window->setClassName("AppWindow");
window->setWindowTitle(winTitle);
window->setStyle(Window::Overlapped);
window->setSize(width, height);
window->setModuleHandle(moduleHandle);
这样做有多个优点:
* 看起来比较简单
* 不用记住参数的顺序
* 可读性强,不需要特别说明注释
* 属性可以有缺省值,不是必须指定所有的属性
* 随时可以更改属性
* 可以随时取得属性,便于除错
* 方便进行可视化图形化设计
对于开发库的牛人来说,对此要多多考虑一层。因为属性设置的顺序不确定,一般要进行”lazy initialization”来避免每一个属性变化的时候都重新初始化整个对象。
比如 QRegExp,用户可以这样初始化:
QRegExp regExp("*.wk?", Qt::CaseInsensitive, QRegExp::Wildcard);
也可以这样初始化:
QRegExp regExp;
regExp.setPattern("*.wk?");
regExp.setCaseSensitivity(Qt::CaseInsensitive);
regExp.setPatternSyntax(QRegExp::Wildcard);
在实现中,QRegExp把编译表达式的过程延后到第一次使用时,避免了多次编译。
最高境界是手中无剑
剑客的最高境界是手中无剑,心中有剑。最好的API是让用户完全不觉得在用你的API,而是在用他们最熟悉的工具,完全没有障碍和隔阂。
在Qt3中,QWidget的最大限制是32768×32768。在Qt4中,已经没有了这种限制。Qt4还增加了pdf格式的支 持,StyleSheet支持,OpenGL支持。虽然这些功能很强大,但是API接口并没有太大的变化,用户体验并没有太多变化,也不用花费太多时间重 新学习。在用户不知不觉之间,新的功能,新的API已经进入了用户的视野。用户虽然浑然不知却已不知不觉获得了更加强大的工具而进入了编程的自由王国。什 么时候,你能让用户忘记API而快乐自然地使用你提供的功能时,你会发现自己已然是个API设计大师了。