UML软件工程组织

面向对象软件开发和过程(六) 针对契约设计
林星 ( iamlinx@21cn.com )
针对契约设计是一种严谨的软件设计思路,它有助于提高软件的质量。软件设计中经常出现的bug往往是由于需要的前提条件或数据不能够得到满足而导致的。针对契约设计通过一种约束性的方法,解决了这个问题。

1.针对契约设计
我们知道,现代的社会是一种生人社会,这和我们几千年的熟人社会已经不一样了,人和人的关系变得很复杂,如何保证每个人的利益,如何保证这种复杂的关系不会对社会的稳定性造成影响。现代社会的解决方法是采用契约,或说是合同。我们和用人单位需要签订劳动合同,购买房产需要商品房买卖合同,甚至我们上车买票,车票本身也是一种合同。为什么合同如此的重要呢?因此它规定了人和人之间的一种关系,并为这种关系定义了严谨的责任和权利。软件设计也是类似的。一个大型的系统,类之间的关系非常的复杂,方法间相互调用,因此,我们也需要一种类似于契约一样的严谨规范,来约束每个类、每个方法,以保证软件整体的稳定性。这就是针对契约设计的实质。

Eiffel语言天生就是一种严谨、甚至可以说是保守的语言。例如,对于Eiffel中的公有字段,默认的情况下就是只读的,以此保证公有属性被不当修改。而Eiffel值得称道之处,在于在使用Eiffel语言的时候,你的大部分的精力都花在如何设计类的前置条件(precondition)、后置条件(postcondition)和不变式(invariant)上。不要小看这三者的作用,虽然看起来简单,在尝试着设计它们的时候,你就会发现,你的类将会变得非常的强壮。为什么呢?我们都知道,要保证信息传递的正确性,就需要有反馈机制。而在软件设计的时候如何引入这种反馈机制呢?Eiffel就用它自己的方式实现了这一点。对前置条件的检查,保证了方法开始之前状态的正确性,后置条件和不变式保证了方法执行完毕后我们得到了我们所需要的状态。虽然我们还可以找到很多其它的反馈机制,但是Eiffel的这种机制,无疑是很有效的。

Eiffel的特性粗看起来并没有什么特别之处,举一个最小的例子:

对于一个将内部count值加一的Inc的方法来说,它的后置条件是

 


count=old count+1
 

这里的old count指的是未被改变的值,即旧值。你可能会说,这不是吃饱了撑着吗,代码本身做的事情就是另count值加一,最后还要检验一次加一的结果,纯属浪费机器资源。这里例子非常的小,因此我们无法看出更为具体的思路。但是我们知道,在面向对象设计中,各个类、各个方法之间构成了细密的协作网。在这种情况下,即容易犯错误。这时候,后置条件的根本作用,就是强迫你找到另外一种方法,来验证你刚才的工作是正确的。这就好像我们在验算数学题的时候,如果方法相同,那么这个结果还未必是对的,因为方法一样,你可能漏掉了一些信息,但是如果我们能够从另一个渠道来验证结果,例如和同桌对对答案,这个结果正确的可能性就大了许多,你的把握也会大很多。所以,后置条件可以看作是另一个渠道的验算。

Eiffel的机制还导致了另一个结果,那就是优秀的面向对象软件设计。面向对象的最基本的功底,在于设计微小的、完成一个简单任务的类和方法。可惜从非面向对象转型来的程序员,仍然喜欢编写一些很长的代码块。使用Eiffel完全不会出现这种情况,使用这些冗长的方法,你根本无法实现前置条件和后置条件。学习使用它们,你可以很自然的学习重用的思路。基于这种考虑,建议向面向对象转型的程序员们都学学Eiffel,你的面向对象设计功力会大有长进的。

Eiffel并不是本文讨论的重点,因此本文并不打算花费太多的精力来介绍它,遗憾的是,国内关于Eiffel的资料比较少,我了解到的中文资料是人民邮电出版社即将出版的面向契约设计一书。

另外仍需要提及的一点是,Eiffel并不是一种国内流行的开发语言,但是这并不影响我们在其它语言中吸收这种优秀的思路。Java语言在其新的JDK版本中引入了断言机制,就可以用于实现前置条件和后置条件。即使是一些不支持断言的语言,也很容易编写自己的断言机制。

前置条件和后置条件的应用还扩展到了其它的方面。例如,在设计中和用例中,都引入了前置条件和后置条件的用法。不管是应用在哪一个地方,思路都是一样的。前置条件最大的好处就是排除非法的输入值,而后置条件的最大好处就是对结果进行验证,以保证过程的正确性。不同应用的前置条件和后置条件都有两个共同的收益;

  • 更好的结构性
  • 更优的重用性

和Eiffel的思路一样,我们把现实中的业务对象看作是由基本查询、派生查询、操作这三者组成的。同时利用这三种机制,才能够有效的运用前置条件和后置条件。将问题域中的问题划分为这三种类型,无疑提高了问题域组织的有效性,也就是获得了更好的结构性。另外,前置条件和后置条件使用的是半形式化的表述方式,因此问题域的表述是清晰的,严谨的。

在获得结构性的同时,我们得到了更优的重用性,怎么说呢?派生查询是由基本查询构成的,而操作中又运用了两种查询。由于分类清晰,我们可以很方便的进行重用。

这样的说法可能过于抽象了。我们说一个现实中的故事。我接触过一段的路由器,在学习路由器的第一堂课上,我学会了一个最重要的操作,就是在对任何的配置进行修改时候,你必须查看配置文件,以保证配置正确。这是一个很基本的操作,但是我却是在实践中吃过亏之后才发现它的重要性的。查看配置文件其实就是配置这个操作的后置条件,由它来保证配置操作过程的正确性,只要后置条件为真,出错的概率将会降低。当然,我们还可以加入更多的后置条件来进一步降低概率。而这里的前置条件是你已经正确登录到路由器,这是一个基本的条件,可能还有操作系统支持该配置条件等。

前置条件和后置条件确实是个好东西,不过,它并不是没有成本的。(作为精益编程的拥护者,我们做任何事情都需要考虑成本和收益的)。最大的成本是时间,在同一个问题域上花费的时间大大增多了,影响到了整体的软件过程,这对于很多的项目是要命的。如何看待这一成本呢?应该说,一开始应用前置条件和后置条件的时候,确实是需要付出额外的成本的。随着对应用的熟悉,这个成本会慢慢降低。而后续因为软件质量改进带来的其它方面的收益,将会超过这个成本。它们的曲线大致会是这样的:(遗憾的是,我们暂时做不到定量的分析)

当然,我们一方面注重效益,另一方面仍然要考虑尽可能降低成本。在成本方面,度是最重要的。前置条件和后置条件的应用的最精妙之处也在于此。要多少的前置条件和后置条件才能够满足需要?条件编写的细致程度如何?对不同的应用是否采用同样的度?这些数据都只能够来源于实践经验。度的不足难以表现前置条件和后置条件的威力,度的过剩又增加了投入成本。

前置条件和后置条件的思路在生活中到处可见,在数学中的应用就更多了。对我们软件开发人员来说,重要的是形成这样的操作思路,设计出稳定的软件。在Eiffel语言中,另一个重要的特性是不变式。由于篇幅所限,我们这里不可能进行大量的介绍,大家可以参考相关的资料。对于我们来说,最重要的是理解针对契约设计的优势,并运用到项目当中。

一开始我们就说过,按契约设计是一种严谨的设计思路,开发的成本随之提高,因此很多的软件开发团队并不愿意采用Eiffel语言,Eiffel语言本身也是一个阳春白雪的存在。但是随着软件规模日益扩大,质量要求不断提高,按契约设计的思路已经慢慢进入了很多的语言中了。

对于项目的开发者来说,质量的要求最难的就是如何实际操作。加强测试的力量和强度固然是一种办法,但是成本也不菲。审核也是一种有效的办法,但是对审核者的要求和压力都不小,一旦流于形式,也起不到什么效果。将软件工程的思路彻底贯彻到代码中一直是本文的主题,这里也不例外。按契约设计提供了一种方法,要求程序员按照严谨的方法进行方法调用和方法设计。虽然调用方和被调用方同时采用严谨的设计模式存在浪费的可能,但这个成本是很低的,而从软件工程文化的角度上来看,却能够逐渐形成高效的编码习惯,这还是非常划算的。

2. 规范
要定义一个好的规范,首先我们需要清楚的知道我们制定规范的目的。首先可以肯定的,我们的目的不是使用Eiffel语言,而是提高软件的质量,那么,在这个目的下,我们如何制定规范呢?如果你希望在组织内引入面向契约设计。那么,可以尝试着使用下文介绍的iContract工具,并根据iContract的方式定义你的按契约设计规范。如果你不希望改变现有的开发方式,只是想从按契约设计中学习一些知识,那么,你更重要的是从基本查询、派生查询、操作方面来考虑如何设计规范,来约束类的设计。这样,你同样可以从按契约设计的思路中获益。

3. 技能
类设计的学习。学习按契约设计,最关键的就是学习如何通过基本查询、派生查询、操作这三个方面来设计类。

对象约束语言。对象约束语言(OCL)是一种描述面向对象设计的语言,它由OMG组织管理和维护。前置条件、后置条件和不变式的描述采用了OCL的一个子集。学习OCL语言,关键并不是在语法本身,而是在于对象的设计上。如果一个对象的设计不够规范,你会发现,你无法使用OCL语言来描述它。如果你能够熟练的使用OCL来描述类和类之间的关系,那么,你会发现,软件的质量会得到大幅度的提高。

4. 过程
按契约设计属于设计范畴,所以,它可以很方便的和接口设计、测试等活动结合起来。例如,iContract中就提供了这方面的例子:

 


/**
*/
public interface IEmployee { 
	/** 
	* @pre hasOffice()
	*
	* @return iContract.examples.office_management_system.API.IRoom 
	*/
	public IRoom getOffice();
	/**
	* @post return == (office != null) // implementation, exposes null 
	*
	* @return boolean 
	*/
	public boolean hasOffice();
	/**
	* @pre office != null 
	* 
	* @post hasOffice() 
	* @post getOffice() == office 
	* 
	* @param office IRoom  
	*/
	public void setOffice(IRoom office);
}
 

同样的,测试活动中也可以针对以上的接口描述进行重点测试。测试前置条件违反的情况,测试后置条件和不变式是否满足。

5. 工具
Eiffel是一门非常优秀的语言,但是要在项目中完全采用Eiffel并不是一件容易的事情。除了Eiffel之外,其它的语言都没有对针对契约设计的明显支持,不过确实有人为非Eiffel语言设计了针对契约式设计支持工具。而Java中的iContract就是其中的一种。iContract其实是一种预编译器,它把注释中的特别标记翻译为标准的Java代码,插入到最终的代码中:

 


/**
* @pre f >= 0.0
*/
public float sqrt(float f) { ... }
 

@pre是前置条件的标志符号,它表示了函数sqrt的输入参数f需要满足的条件。同样的,还有@post、@inv等标识符,它们分别表示了前面讨论的后置条件和不变式。此外,iContract还支持forall、exists、implies等一些OCL语法。

关于作者

林星,辰讯软件工作室项目管理组资深项目经理,有多年项目实施经验。辰讯软件工作室致力于先进软件思想、软件技术的应用,主要的研究方向在于软件过程思想、Linux集群技术、OO技术和软件工厂模式。您可以通过电子邮件 iamlinx@21cn.com 和他联系。
 
 

版权所有:UML软件工程组织