. .
软件接口设计指南
拟制人 日期 审核人 日期 批准人 日期
. .
. .
目 录
1 目的 .............................................................................. 1 2 适用围 ............................................................................ 1 3 参考文件 .......................................................................... 1 4 定义和缩写 ........................................................................ 1 5 规定 .............................................................................. 1 5.1 JAVA接口设计方法 ............................................................... 1 5.2 C++接口设计方法 ................................................................. 5 5.3 接口设计对软件性能的影响 ........................................................ 7 5.4 面向对象设计中,接口设计的一般原则 ............................................. 10 6 附件 ............................................................................. 10
. . . .
. .
1 目的
为大家在进行软件接口设计时提供一些指导,以帮助大家更好的理解软件接口设计的方法和原则。
2 适用围
适用于公司软件开发的接口设计过程。
3 参考文件
本过程文件中的过程裁剪应依据《组织标准过程裁剪指南》的规定。
4 定义和缩写
本过程文件的编写依据是美国软件工程研究院(SEI)的集成成熟度模型软件分支1.2版本(CMMI-DEV V1.2)。
5 规定
5.1 JAVA接口设计方法
我们在设计系统接口时,经常会遇到这样的问题: 我们的接口应该提供多少方法才合适?
我们的接口应该提供\"原子方法\"还是\"复合方法\"?
我们的接口是否应该封装(或者,能否封装)所有的细节?
接口的设计需要考虑用户的使用习惯、使用的方便程度、使用的安全程度,根据我的编程经验,下面会详细讨论接口设计的2个需要权衡的方面:接口的单一化 & 复合化。
接口
接口提供了不同系统之间或者系统不同组件之间的界定。在软件中,接口提供了一个屏障,从而从实现中分离目标,从具体中分离抽象,从作者中分离用户。 站在用户的角度看,一个接口建立并命名了一个目标对象的使用方法。一些约束(例如:编译时的类型系统、运行时的异常机制及返回值)使得类作者的目的得以体现和加强。供给(affordances)指事物的被感知的真实的属性,这些属性可以决定事物使用的可能方法,供给提供了对事物操作的线索。
类设计者的一个职责便是在接口中减小约束与供给之间的隔阂、匹配目标以及一定程度上的自由度,尽可能减小错误使用目标对象的可能。
封装
对于封装来说,远不止数据私有那么简单。在设计中,封装往往会涉及到自我包含(self-containment)。如果一个类需要你知道如何调用它方法(e.g. 在一个线程的环境中,在一个方法调用后调用另一个方法,你必须明确地同步对象),那么它的封装性就不如将所有这些全部包含并隐藏的类(e.g. 这个类是thread-safe的)好。前一个设计存在着设计的漏洞,它的许多限定条件是模糊的,而且把部分责任推给了用户,而不是让类提供者做这些工作来完成类的设计。
在空间或者时间上分离方法的执行(例如,线程,远程方法调用,消息队列),能够对设计的正确性和效率产生意义深远的影响。这种分离带来的结果是不可忽视的: 并发引入了不确定性和环境(context)选择的开销;
. . . .
. .
分布引入了回调的开销,这些开销可能不断增加,而且会导致错误。 这些是设计的问题,修改它们可不是象修改bug那样简单。
如果一个接口主要由存取方法(set和get方法)组成,每个方法都相应的直接指向某个私有域,那么它的封装性会很差。接口中的域存取方法通常是不会提供信息的:他们在对象的使用中不能通讯、简单化和抽象化,这通常会导致代码冗长,并且容易出错。
所以,我们首先考虑接口设计的第一个原则:
命令与查询分离(Command-Query Separation)
要求:保证一个方法不是命令(Command)就是查询(Query) 定义:
查询:当一个方法返回一个值来回应一个问题的时候,它就具有查询的性质; 命令:当一个方法要改变对象的状态的时候,它就具有命令的性质;
通常,一个方法可能是纯的Command模式或者是纯的Query模式,或者是两者的混合体。在设计接口时,如果可能,应该尽量使接口单一化,保证方法的行为严格的是命令或者是查询,这样查询方法不会改变对象的状态,没有副作用(side effects),而会改变对象的状态的方法不可能有返回值。也就是说:如果我们要问一个问题,那么就不应该影响到它的答案。实际应用,要视具体情况而定,语义的清晰性和使用的简单性之间需要权衡。
例如,在java.util.Iterator中,hasNext可以被看作一种查询,remove是一种命令,next合并了命令和查询: public interface Iterator{ boolean hasNext(); Object next(); void remove(); }
这里,如果不将一个Iterator对象的当前值向前到下一个的话,就不能够查询一个Iterator对象。如果没有提供一个复合方法next,我们将需要定义一系列的命令方法,例如:初始化(initialization)、继续(continuation)、访问(access)和前进(advance),它们虽然清晰定义了每个动作,但是,客户代码过于复杂: for(initialization; continuation condition; advance){ ... access for use ... }
将Command和Query功能合并入一个方法,方便了客户的使用,但是,降低了清晰性,而且,可能不便于基于断言的程序设计并且需要一个变量来保存查询结果: Iterator iterator = collection.iterator(); while(iterator.hasNext();){
Object current = iterator.next(); ... use current... }
下面,我们考虑接口设计的第二个原则:
组合方法(Combined Method)
组合方法经常在线程和分布环境中使用,来保证正确性并改善效率。
. . . .
. .
一些接口提供大量的方法,起初,这些方法看来是最小化的,而且相关性强。然而,在使用的过程中,一些接口显现得过于原始,它们过于简单化,从而迫使类用户用更多的工作来实现普通的任务,并且,方法之间的先后顺序及依赖性比较强(即,暂时耦合)。这导致了代码重复,而且非常麻烦和容易出错。
一些需要同时执行成功的方法,在多线程、异常、和分布的情况下会遇到麻烦。如果两个动作需要同时执行,它们由两个独立的方法进行描述,必须都完全成功的执行,否则会导致所有动作的回滚。
线程的引入使这种不确定性大大增加。一系列方法同时调用一个易变的(mutable)对象,如果这个对象在线程之间共享,即使我们假设单独的方法是线程安全的,也无法确保结果是意料之中的。看下面对Event Source的接口,它允许安置句柄和对事件的查询:
interface EventSource{
Handler getHandler(Event event);
void installHandler(Event event, Handler newHandler); }
线程之间的交叉调用可能会引起意想不到的结果。假设source域引用一个线程共享的对象,对象很可能在1、2之间被另一个线程安装了一个新的句柄: class EventSourceExample{
public void example(Event event, Handler newHandler){ oldHandler = eventSource.getHandler(event); // 1 //对象很可能在这里被另一个线程安装了一个新的句柄 eventSource.installHandler(event, newHandler); // 2 }
private EventSource eventSource; private Handler oldHandler; }
为了解决问题,也需要由类的使用者而不是类的设计者来完成: class EventSourceExample{
public void example(Event event, Handler newHandler){ synchronized(eventSource){
oldHandler = eventSource.getHandler(event); eventSource.installHandler(event, newHandler); } }
private EventSource eventSource; private Handler oldHandler; }
我们假设:目标对象eventSource是远程的,执行每一个方法体的时间和通讯的延迟相比是很短的。在这个例子中,eventSource的方法被调用了两次,并可能在其他的实例中重复多次,因而,开销也是至少两倍。
此外还有一个问题是对外部的synchronized同步块的使用需求。对synchronized块的使用之所以会失败,主要因为我们通过代理对象来完成工作,所以,调用者的
. . . .
. .
synchronized块,同步的是代理对象而不是最终的目标对象,
调用者不可能对其行为做太多的保证。
Combined Method必须在分布的环境,或者,线程环境中同时执行。它反映了用户直接的应用,恢复策略和一些笨拙的方法被封装到Combined Method中,并简化了接口,减少了接口中不需要的累赘。Combined Method的效果是支持一种更像事务处理风格的设计。
在一个组合的Command-Query中提供一个单独的Query方法通常是合理的。提供分离的Command方法是不太常见的,因为Combined Method可以完成这一工作,只要调用者简单的忽略返回结果。如果返回一个结果招致一个开销的话,才可能会提供一个单独的Command方法。
回到前一个例子中,如果installHandler method返回上一次安装的句柄,则设计变得更加简单和独立: interface EventSource{
Handler installHandler(Event event, Handler newHandler); }
客户代码如下:
class EventSourceExample{
public void example(Event event, Handler newHandler){
oldHandler = eventSource.installHandler(event, newHandler); }
private EventSource eventSource; private Handler oldHandler; }
这样,我们给调用者提供了一个更加安全的接口,并且不再需要他们解决线程的问题。从而降低了风险和代码量,将类设计的职责全部给了类设计者而不是推给用户,即使有代理对象的出现也不会影响到正确性。
一个Combined Method可以是许多Query的集合,许多Command的集合,或者两者兼有。这样,它可能补充Command、Query方法,也可能与之相抵触。当冲突发生的时候,优先选择Combined Method会产生一个不同的正确性和适用性。
在另一个例子中,我们考虑获得资源的情况。假设,在下面的接口中,方法acquire在资源可用前阻塞: interface Resource{ boolean isAcquired(); void acquire(); void release(); }
类似于下面的代码会在一个线程系统中推荐使用: class ResourceExample{ public void example(){
. . . .
. .
boolean acquired = false; synchronized(resource){ if(!resource.isAcquired()) resource.acquire(); else
acquired = true; }
if(!acquired) ... }
private Resource resource; }
然而,即使我们放弃可读性和易用性,这样的设计也不是一个Command-Query分离的设计。如果引入了代理,它就会失败:
class ActualResource implements Resource {...} class ResourceProxy implements Resource {...}
如果用户既可以通过ActualResource来完成工作,也可以通过ResourceProxy来完成工作,而且,ActualResource和ResourceProxy都没有处理同步,则synchronized块可能会失败。因为,既然我们可以通过代理对象ResourceProxy来完成工作,那么,调用者的synchronized块,同步的就是代理对象ResourceProxy而不是最终的目标对象ActualResource。
一个Combined Method解决了这个问题,它使并发和间接性更加透明。 interface Resource{ boolean tryAcquire(); }
下面的代码清晰、简单并且正确: class ResourceExample{ public void example(){ if(!resource.tryAcquire()) ... }
private Resource resource; }
Combined Method带来的一个结果是使一些测试和基于断言的程序设计变得十分笨拙,然而,它适合解决线程和分布问题。
实际应用中,接口应该单一化还是复合化,要视具体情况而定。
5.2 C++接口设计方法
在系统中,观察一个class有两个角度,从外部或者用户角度我们看到的是接口,从部我们看到的是实现。因为系统肯定要不断修改,因此实现免不了不停的变化,但是接口又被要求尽量保持稳定。这两者的矛盾必须通过良好的设计尽量避免,基本原则就是将实现细节与接口隔离。下面列出几条比较具体点的:
. . . .
. .
·接口的设计保持最小而完整
精简接口函数个数,使每一个函数有代表性,函数功能恰好覆盖class的职能。一个最小的接口可以使维护简单,增加潜在的代码重用性,减少客户的迷惑,并且也可以缩小头文件长度和编译时间。当改进函数时,应该用类似函数名实现改进而保留原函数,代码注释里应该有相应的说明。可以增加新函数,但不能删除旧函数。
·成员变量应该都为私有,显而易见,public变量破坏封装性以及接口和实现的分离;protected变量也可能使客户编写继承类而依赖于父类的实现细节。
·避免函数返回成员变量的指针或引用,这么做也会使客户代码依赖于实现细节。
·考虑是否禁用编译器缺省产生的函数,这些函数包括:复制构造函数,赋值操作符(operator =)。如果我们不打算定义自己的版本而不禁用默认版本的话,可能使客户代码在不注意的情况下调用这些函数。当实现发生改动时就可能引起问题,比如class多了一个heap memory指针。如果我们允许对象拷贝,比较稳妥的方法是禁用它们,而定义一个专门的clone()函数。 兼容性(compatibility)
不用说,兼容性是非常重要的。Intel和Microsoft之所以如此成功,其中一个重要方面就是他们的产品,不管是硬件还是软件,都做到了很好的兼容老产品。代码的兼容也是如此。难以想象,如果客户依赖于你的library产品,而要因为你的产品的更新而不断的重写他的代码,他还会继续用你的产品。
代码兼容可以简单分为二进制兼容和源代码兼容。二进制兼容也就是说,客户的已编译代码可以在不用重新编译的情况下,直接使用你的不同版本的已编译代码。源代码兼容就是,如果你的代码更新了,客户的代码不需要修改,只需要重新编译就可正常运行。在C++中,接口一般是由头文件和library二进制代码提供,因此,任何 可能造成library代码和旧的头文件不一致的情况都可能破坏二进制兼容,因为客户代码必须和新的头文件重新编译一次。
因此,遵循几条准则可以使你更轻松地解决兼容性问题: ·不改变类的大小或者改变成员变量的顺序
包括几个方面:不增加或减少成员变量;不修改成员变量类型;不改变成员变量的声明顺序;不改变虚函数的有无。显而易见,增加或减少成员变量会改变类的大小,并且需要更新头文件,从而可能造成与客户代码不兼容。类型的变化也可能引起类的大小的变化。成员变量的访问一般是由编译器按偏移量确定,顺序如果改变,偏
移量也就会改变,破坏了二进制兼容。至于虚函数的有无,决定是否存在虚函数表指针,也就影响了类的大小和成员变量的顺序。 ·不使用inline函数
inline函数声明于头文件中,并且被编译于客户代码中,如果inline函数访问了private成员,该成员又改变了顺序,那么inline函数虚要被重新编译,破坏了二进制兼容。
·接口函数不使用虚函数
虚函数的访问和成员变量类似,是通过虚函数表中的偏移。虚函数顺序的改变会影响偏移。因此,在条件允许时,应该避免使用public虚函数。比如:
class Picture { public:
. . . .
. .
virtual void Draw(); };
应该改为
class Picture { public:
void Draw(); private:
virtual void DoDraw(); };
void Picture::Draw() {
DoDraw(); }
·不改变接口函数的顺序
在很多嵌入式系统中,库通过输出函数表(exported function table)暴露接口以节省空间。此时,对接口函数的访问也是通过索引值进行,因此改变顺序也会破坏兼容性。 ·避免使用函数缺省参数
给函数形参设定缺省值可以方便客户,但是可能破坏兼容。缺省值随头文件给出,缺省值的改变也就会引起兼容问题。
5.3 接口设计对软件性能的影响
性能方面的问题有好多种。最容易修正的一种是,在执行一项计算任务时使用了一个性能不好的算法,例如,在对数目很多的数据进行排序时采用了起泡算法,每次使用时对一个经常使用的数据项进行计算而不是将它保存起来,这些问题一般我们都能很容易发现,而且一旦发现后,都能很方便地进行改正。然而,许多Java程序性能方 面的问题都是是由一些比较深奥的、不容易修改的代码━━程序组件的接口设计引起的。
大多数的程序都是由部人员开发的或从外部购买的组件\"组装\"而成的。即使软件不完全依赖于原有的组件,面向对象的设计过程也使得应用程序在开发时采用组件形式,因为这样可以简化程序的设计、开发和调试方面的工作。尽管采用组件的好处是不可否认的,我们还应该意识到组件的接口会对使用它们的程序的性能和运行状态产生重大的影响。
也许会有读者问,接口跟性能有什么关系?一个类的接口不但定义了类可以完成的功能,而且还定义了它的对象创建行为和使用它所需要调用的方法的顺序,一个类如何定义它的构造器和方法会影响这个对象是否可以重用,是它本身的方法创建还是要求其客户创建中间对象,客户要使用这个类需要调用多少个方法。
所有这些因素都会影响到程序的性能。Java软件性能管理方面的基本原理之一是:避免创建过多的对象。这并不意味着你不能创建任何对象从而不充分利用面象对象语言带来的诸多好处,而是说在开发对性能敏感的代码时需要对对象的创建保持谨慎。对象创建的代价相当高昂,我们应该在对性能敏感的软件中尽量避免创建临时或中间对象。
在处理字符的程序中,String类是引起对象创建的最大源。因为String类是不可变的,每当一个String类的对象被修改或构造时,都会创建一个新的对象。因此,一个
. . . .
. .
具有性能意识的编程人员总是避免过多地使用String类对象。然而,尽管你在编程中尽量避免使用String对象,还是会经常发现使用的组件接口必须使用String对象,因此,你不可能不使用String类对象。 例子:表达式的匹配
作为一个例子,可以假设你在编写一个名字为MailBot的服务器。MailBot需要处理每个顶部的MIME头部━━例如发送日期或者发送者的地址,它将通过使用一个匹配表达式的组件处理MIME头部,以使这一处理过程会更简单一些。它把输入的字符放在一个字符缓冲区中,通过对缓冲区进行索引处理标题。由于MailBot将
调用这一表达式匹配子程序来处理每一个标题,因此这个匹配子程序的性能将十分地重要。
我们首先来看一个性能十分低下的表达式匹配类的接口: public class AwfulRegExpMatcher {
/**创建一个给定表达式的匹配过程,它将对给定的字符串进行处理*/ public AwfulRegExpMatcher(String regExp, String inputText);
/**找到针对输入文本的下一个匹配模式,如果匹配,返回匹配的文本,否则返回一个空字符 */
public String getNextMatch(); }
即使这个类采用了一个很高效的匹配算法,大量调用它的程序的性能也不会很好。因为匹配器对象是与输入文本捆绑在一起的,每次调用它时,都需要首先生成一个新的匹配器对象。由于我们的目标是减少不必要的对象创建工作,实现对匹配过程代码的重用应该是一个良好的开端。
下面的这个类定义了匹配器的另一种可能的接口,它允许匹配器重用,但性能仍然不够好:
public class BadRegExpMatcher {
public BadRegExpMatcher(String regExp);
/** 试图针对输入文本匹配指定的表达式,如果匹配则返回匹配的文本,否则返回一个空白字符串*/
public String match(String inputText);
/** 得到下一个匹配的字符,否则返回一个空白字符*/ public String getNextMatch(); }
避开返回的匹配子表达式等敏感的表达式匹配问题不谈,这个类的定义有什么问题吗?如果仅仅从其功能方面看,它没有任何问题,但如果从性能方面来考虑,则它存在许多问题。首先,匹配器要求其调用者创建一个String来表示被匹配的文本。MailBot应该尽量避免生成String对象,但当它发现一个需要处理的标题时,它必须创建一个String对象供BadRegExpMatcher调用:
BadRegExpMatcher dateMatcher = new BadRegExpMatcher(...);
while (...) { ...
String headerLine = new String(myBuffer, thisHeaderStart, thisHeaderEnd-thisHeaderStart);
. . . .
. .
String result = dateMatcher.match(headerLine);
if (result == null) { ... }
}
其次,即使MailBot仅仅需要得到是否匹配的返回信息,而无需得到匹配的文本,匹配器也会返回一个匹配的字符串。这意味着为了简单地使用BadRegExpMatcher来验证一个特定格式的日期标题,你也必须创建二个 String对象━━供匹配器使用的输入文本和匹配结果文本。创建二个对象似乎不会对性能产生重大影响,但如果必须为MailBot处理的每条的标题创建二个对象,就可能严重地影响程序的性能。这一问题并不出在MailBot本身的设计上,而是出在BadRegExpMatcher的设计上。
注意:不返回String对象而返回一个\"轻量级\"的Match对象也不会在性能上带来很大的改进。尽管创建一个Match对象的代价要比创建一个String对象的代价低一些,它还是会产生一个char数组,并拷贝数据,仍然创建了一个对调用者并非必需的临时性的对象。
BadRegExpMatcher只接受它需要的输入数据类型,而不是可以接受我们方便提供的数据类型,仅就这一点,它就非常不理想。使用BadRegExpMatcher还会带来别的危害,其中的一个潜在的危害是这样将对MailBot的性能带来更多的影响。尽管在处理的标题时必须避免使用Strings,但又必须创建许多的Strings对象供
BadRegExpMatcher使用,因此你可能放弃不使用String对象的目标,而更加不受限制地使用它。一个设计不恰当的组件会影响使用它的程序的性能,即使以后找到了一个无需使用String对象的表达式组件,整个程序仍然会受到影响。
一个恰当的接口如何定义BadRegExpMatcher才能避免上述的问题呢?首先,BadRegExpMatcher应该不指定其输入文本的格式,它应该能够接受其调用者可以高效地提供的任何一种数据类型。其次,它不应该为匹配结果自动地生成一个String对象,只需要返回足够的信息让调用者来决定是否需要生成匹配结果字符串。(也可以提供一个方法来完成这一任务,但这并非是必需的。)一个性能比较好的接口应该是这样的: class BetterRegExpMatcher {
public BetterRegExpMatcher(...);
/** 使匹配器可以接受多种格式的输入━━ String对象、字符数组、字符组数的子集,如果不匹配,返回-1;如果匹配,则返回开始匹配的偏移地址。*/
public int match(String inputText);
public int match(char[] inputText);
public int match(char[] inputText, int offset, int length);
/** 如果匹配,则返回匹配的长度;如果不是完全匹配,则调用程序应该能够从匹配的偏移处生成匹配的字符串 */
public int getMatchLength();
/** 如果调用程序需要,就可以很方便地得到匹配字符串的子程序 */ public String getMatchText();
. . . .
. .
}
新的接口消除了调用者将输入文本转化为匹配子程序所要求的格式的需求。MailBot可以用如下的方式调用 match():
int resultOffset = dateMatcher.match(myBuffer, thisHeaderStart, thisHeaderEnd-thisHeaderStart);
if (resultOffset < 0) { ... }
这样就既达到了设计目标又没有创建任何新的对象,另外,它的接口设计也体现了Java所倡导的\"多而简单的方法\"的设计思想。
创建对象对性能的精确影响取决于match()完成的工作量。通过创建和对二个不作任何实际工作的表达式匹配程序类的运行进行计时,就会发现它们在性能上存在着巨大的差异,在Sun 1.3 JDK中,使用BetterRegExpMatcher类的上述代码的运行速度比使用BadRegExpMatcher类快50倍。通过简单地支持子串匹配,BetterRegExpMatcher的运行速度就可以比BadRegExpMatcher快5倍。
5.4 面向对象设计中,接口设计的一般原则
1) 规划一个接口而不是实现一个接口。对公共接口中定义了大量访问方法的类多
加小心。大量访问方法意味着相关数据和行为没有集中存放。
2) 接口隔离原则(ISP)恰当的划分角色和接口,接口的污染(Interface
Contamination)一个没有经验的设计师往往想节省接口的数目,将一些功能相近或功能相关的接口合并,并将这看成是代码优化的一部分。从一个客户类的角度来讲:一个类对另外一个类的依赖性应当是建立在最小的接口上的。使用多个专门的接口比使用单一的总接口要好。 3) 不要绕开公共接口去修改对象的状态。
4) 对接口编程:对接口编程是面向对象设计的第一个基本原则。它的含义是:对
于所有完成相同功能的组件,应该抽象出一个接口,它们都实现该接口。具体到编程语言,可以是接口(Java语言),或者是抽象类(C++语言),所有完成相同功能的组件都实现该接口,或者从该抽象类继承。外部代码只应该和该接口通讯,这样,当需要用其它组件完成任务时,只需要替换该接口的实现,而代码的其它部分不需要改变。接口实际上就是一种抽象,所谓抽象就是一个固定的行为,但是对于这个行为可以由很多不同的具体实现方法。这样,用一个抽象的概念就可以代替那些容易变化的数量众多的具体概念,结果就是:系统新需求的增加,仅仅会引起具体的概念的增加,而不会影响它所依赖的抽象概念的改变。当现有的组件不能满足要求时,可以创建新的组件,实现该接口,或者,直接对现有的组件进行扩展,由子类去完成扩展的功能。
. . . .
因篇幅问题不能全部显示,请点此查看更多更全内容