软件构造课程随笔——3-2【设计规约】

程序规约

为什么要使用规格说明

在编程中,很多让人抓狂的bug是由于两个地方的代码对于接口行为的理解不一样。虽然每一个程序员在心里都有一份“规格说明”,但是不是所有程序员都会把他们写下来。最终,一个团队中的不同程序员对于同一个接口就有不同的“规格说明”了。当程序崩溃的时候,就很难发现问题在哪里。简洁准确的的规格说明使得我们远离bug,更可以快速发现问题所在。

规格说明对使用者(客户)来说也是很有用的,它们使得使用者不必去阅读源码。如果你还不相信阅读规格说明比阅读源码更简单易懂的话,看看下面这个标准的Java规格说明和它对应的源码,它是 BigInteger 中的一个方法:

public BigInteger add(BigInteger val)

Returns a BigInteger whose value is (this + val).

Parameters: 
val - value to be added to this BigInteger.

Returns: 
this + val

可以看到,通过阅读 BigInteger.add 的规格说明,客户可以直接了解如何使用 BigInteger.add ,以及它的行为属性。如果我们去阅读源码,我们就不得不看 BigInteger 的构造体, compare­Magnitude, subtract以及trusted­StripLeadingZero­Ints 的实现——而这还仅仅只是开始。

另外,规格说明对于实现者也是很有好处的,因为它们给了实现者更改实现策略而不告诉使用者的自由。同时,规格说明可以限定一些特殊的输入,这样实现者就可以省略一些麻烦的检查和处理,代码也可以运行的更快。
在这里插入图片描述
如上图所示,规格说明就好像一道防火墙一样将客户和实现者隔离开。它使得客户不必知道这个单元是如何运行的(不必阅读源码),也使得实现者不必管这个单元会被怎么使用(因为客户要遵守前置条件)。这种隔离造成了“解耦”(decoupling),客户自己的代码和实现者的代码可以独立发生改动,只要双方都遵循规格说明对应的制约。

行为等价

思考下面两个方法的异同:

static int findFirst(int[] arr, int val) {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == val) return i;
    }
    return arr.length;
}

static int findLast(int[] arr, int val) {
    for (int i = arr.length -1 ; i >= 0; i--) {
        if (arr[i] == val) return i;
    }
    return -1;
}

当然,这两个方法的代码是不同的,名字的含义也不一样。为了判断“行为等价”,我们必须判断一个方法是否可以替换另一个方法,而程序的行为不发生改变。
但是当val在数组中仅有一个的时候,这两个方法的行为是一样的。也只有在这种情况下,我们才可以将方法的实现在两者中互换。
“行为等价”是对于“旁观者”来说的——就是客户。为了让实现方法可以发生改动,我们就需要一个规格说明要求客户遵守某一些制约/前置条件。

规格说明的结构

一个规格说明含有以下两个“条款”:
一个前置条件,关键词是requires
一个后置条件,关键词是effects
其中前置条件是客户的义务(谁调用的这个方法)。它确保了方法被调用时所处的状态。
而后置条件是实现者的义务。如果前置条件得到了满足,那么该方法的行为应该符合后置条件的要求,例如返回一个合适的值,抛出一个特定的异常,修改一个特定的对象等等。
如果前置条件不满足的话,实现也不需要满足后置条件——方法可以做任何事情,例如不终止而是抛出一个异常、返回一个任意的值、做一个任意的修改等等。
在这里插入图片描述

规格说明应该说些什么

一个规格说明应该谈到接口的参数和返回的值,但是它不应该谈到局部变量或者私有的(private)内部方法或数据。这些内部的实现应该在规格说明中对读者隐藏。
在Java中,规格说明的读者通常不会接触到实现的源码,应为Javadoc工具通过你的源码自动生成对应的规格说明并渲染成HTML。

测试与规格说明

在测试中,我们谈到了黑盒测试意味着仅仅通过规格说明构建测试,而白盒测试是通过代码实现来构建测试。但是要特别注意一点:即使是白盒测试也必须遵循规格说明。 你的实现也许很依赖前置条件的满足,否则方法就会有一个未定义的行为。而你的测试是不能依赖这种未定义的行为的。测试用例必须遵循规格说明,就像每一个客户一样。
一个好的单元测试应该仅仅关注于一个规格说明。我们的测试不应该依赖于另一个要测试的单元。
而对于一个好的综合测试(测试多个模块),它确保的是各个模块之间是兼容的:调用者和被调用者之间的数据输入输出应该是符合要求的。同时综合测试不能取代系统的单元测试,因为各个模块的输出集合很可能在输入空间中没有代表性。

设计程序规约

决定性的 vs. 待决定性的规格说明

可以看下面的find函数
static int findExactlyOne(int[] arr, int val)
- requires:
  val occurs exactly once in arr
- effects:
  returns index i such that arr[i] = val
  

这里的后缀名 ExactlyOne也仅是一种提示符,为了区分开同一模块的不同规格说明设计。
我们说,findExactlyOne是完全决定性的(fully deterministic):当输入满足前置条件后,输出能够完全确定——仅仅只有一种可能的返回情况。不存在一个输入对应多种输出。
findFirst 和 findLast 的实现方法都满足这个规格说明的要求,所以如果有一个客户使用了这个规格说明的find模块,我们可以用 findFirst 和 findLast 进行等价替换。
下面是find的另一种规格说明:

static int findOneOrMore,AnyIndex(int[] arr, int val)
- requires:
  val occurs in arr
- effects:
  returns index i such that arr[i] = val

这个规格说明就不是决定性的——当val出现多次时,它没有要求返回哪一个的下标。即它仅仅承诺了你可以根据返回的下标找到对应的val 。对于一个输入,这个模块有多种输出的可能性。

这里要注意一点,当我们说“非决定性”(underdetermined)的时候,并不是指“不确定性”(nondeterministic)。不确定性的代码是指一会的行为是这样,过一会又变成了那样(即使对于同一个输入)。有很多不确定的例子:例如一个依赖于随机数的函数,或者一个依赖于当前时间的程序。但是一个非决定性的规格说明并不一定代表对应的模块的行为是非确定性的,模块可以是由完全确定行为(一个输入就对应一个确定的输出)的代码写的。

声明性的 vs. 操作性的规格说明

笼统的说,规格说明分为两种:操作性的(Operational)规格说明给出了实现过程的步骤(就像伪代码一样),而声明性的(Declarative)规格说明不对实现过程进行要求,它们仅仅给出最后输出的属性和意义,以及它们和输入之间的关系。
在绝大多是情况下,声明性的规格说明更合适。它们通常会更简洁、更易懂、并且最重要的是,它们不会让使用者尝试依赖特定的实现方案(很多时候一个模块的实现方案会不得不改变)。例如,如果我们想要允许多种方案来实现find ,我们就不会在规格说明要求“从数组低位开始向上遍历搜索”。
有些时候,程序员想要给维护者(maintainer)模块的实现信息,于是他们将实现描述写在了规格说明中。要记住,规格说明是给使用者而非模块的开发者使用的,如果你想要用描述模块的实现方法,将它们注释在模块里面。

更强或更弱的规格说明

假设你想要改变一个方法——不管是它的实现方法还是规格说明本身。并且现在已经有使用者在依赖你之前的规格说明来使用方法了,你该怎么确定新的规格说明可以安全的替换原有的规格说明呢?
定义:规格说明S2强于(等于)规格说明S1,如果:

1.S2的前置条件弱于或等价于S1的
2.S2的后置条件强于或等于S1的后置条件。

如果S2强于S1,那么任何S2的实现方法都可以拿来实现S1,并且在程序中可以安全的用S2的模块替换S1模块。
这两个条件实际上表现了一种思想:你可以弱化前置条件,即更容易满足前置条件,或者说满足前置条件的集合扩大了,这会让使用者的限制更少,例如不用对模块的输入先进行一些检查,也可以强化后置条件,在使用者看来,就是模块的返回更清晰,更有保证性,不用对多种可能情况进行处理。
例子:
在这里插入图片描述

图示化规格说明

试着将Java中的全部方法想象成一个太空,太空中的每一个星星就是一个方法。在这里我们先将上面提到的 findFirst 和 findLast 画出来。记住,对于 findFirst 和 findLast ,它们的算法/行为是固定的,不能在这个空间中表示一个范围,所以我们用点来表示实际的方法。
而一个规格说明会在这个太空中描述出一个范围,在这个范围中的实现方法都满足规格说明的要求(即前置条件和后置条件),而在范围之外的不满足规格说明的要求。
findFirst 和 findLast 都是满足 findOneOrMore,AnyIndex的,所以它们都在 findOneOrMore,AnyIndex描述的范围内:
在这里插入图片描述
某个具体实现,若满足规约,则落在其范围内;否则,在其之外。
更强的规约,表达为更小的区域
更强的后置条件意味着实现的自由度更低了图中的面积更小
更弱的前置条件意味着实现时要处理更多的可能输入,实现的自由度低了 面积更小

设计好的规格说明

一个好的规格设计应该简洁清楚、结构明确、易于理解的。
但是,规格说明的具体内容是很难用一套固定的设计规则描述的,不过这里我们有一些有用的指导方针。
1.规格说明应该逻辑明确
2.调用的结果应该清晰
3.规格说明应该足够“强” 这里的强主要指后置条件的强度
4.规格说明也应该足够“弱”这里的强度主要指前置条件的强度
5.规格说明应该尽可能使用抽象的数据类型
我们之前在“Java基础”中谈到了Java的聚合类型,里面说到了对数据抽象的要求说明例如 List 和 Set 以及具体的实现方法例如 ArrayList 和 HashSet.
在规格说明中使用抽象的数据类型会给使用者和实现者更多的自由

使用前置条件还是后置条件

另一个设计的问题就是是否使用前置条件,如果使用的话,是否需要模块在一开始对参数进行检查,判断其符合前置条件后再进行后续工作。事实上,使用前置条件的一个最常见的要求就是输入必须精确满足前置条件,因为模块检查参数的资源代价可能会很大。
正如上面所提到的,“重量级”的前置条件会让使用者不方便,因为他们必须确保输入不违反前置条件的要求,如果违反了,从错误中恢复的方法将是不可预测的。所以使用者大多不喜欢前置条件,这也是为什么Java API 类趋向于(作为后置条件)在参数不合法的时候抛出一个非检查的异常。这样的手段使得发现bug更加容易。通常情况下,快速失败/报错总是更好的(即离bug越近越好),而不是让错误的参数继续参与剩下的运算。例如,atan(y, x)可能会要求输入不能是(0,0)(但不是前置条件),但是它依然会接受这种参数并抛出一个明确的异常,而不是让这个参数参与剩下的计算并返回一个“垃圾值”。
有时候,检查参数是不可行的,这个时候前置条件就是必须的了。例如我们想用二分查找的办法实现find ,我们会要求这个数组是已经排序过的了。如果强制要求模块检查这个数组是否已经排好序,这个带来的线性复杂度相对于我们要实现的目标是承受不起的。
关于是否使用前置条件是一个工程上的判断。关键点在于检查需要使用的资源量以及这个模块被使用的范围。当这个模块仅仅在类的内部使用时,我们设置前置条件,仔细检查所有的调用是否合理。但是如果这个方法是公开的,并且会被其他的开发者使用,那么使用前置条件就不那么合理。像Java API一样,你应该抛出一个异常。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值