哈工大软件构造阅读心得6-1: 规格说明
本文参考:MIT Reading6哈工大学长汉化
注:这个系列是本人看过阅读资料之后,对看过的内容进行的总结
为什么使用规格说明
在编程中,很多让人抓狂的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
if (val.signum == 0)
return this;
if (signum == 0)
return val;
if (val.signum == signum)
return new BigInteger(add(mag, val.mag), signum);
int cmp = compareMagnitude(val);
if (cmp == 0)
return ZERO;
int[] resultMag = (cmp \> 0 ? subtract(mag, val.mag): subtract(val.mag, mag));
resultMag = trustedStripLeadingZeroInts(resultMag);
return new BigInteger(resultMag, cmp == signum ? 1 : -1);
可以看到,通过阅读 BigInteger.add 的规格说明,客户可以直接了解如何使用BigInteger.add ,以及它的行为属性。如果我们去阅读源码,我们就不得不看 BigInteger的构造体, compareMagnitude, subtract以及trustedStripLeadingZeroInts的实现——而这还仅仅只是开始。
规格说明对于实现者也是很有好处的,因为它们给了实现者更改实现策略并且可以不告诉使用者的自由。规格说明也可以限定一些特殊的输入,这样实现者就可以省略一些麻烦的检查和处理,代码也可以运行的更快。
如上图所示,规格说明就好像一道防火墙一样将客户和实现者隔离开。它使得客户不必知道这个单元是如何运行的(不必阅读源码),也使得实现者不必管这个单元会被怎么使用(因为客户要遵守前置条件)。这种隔离造成了“解耦”(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;
}
为了判断“行为等价”,我们必须判断一个方法是否可以替换另一个方法,而程序的行为不发生改变。
它们的行为不一样:
1.当val找不到时,fingFirst返回arr的长度而findLast返回-1;
2.当数组中有两个val的时候,findFirst返回较小的那个索引,而findLast返回较大的那个。
但是当val在数组中仅有一个的时候,这两个方法的行为是一样的。也只有在这种情况下,我们才可以将方法的实现在两者中互换。
“行为等价”是对于“旁观者”来说的——就是客户。为了让实现方法可以发生改动,我们就需要一个规格说明要求客户遵守某一些制约/前置条件。
所以,我们的规格说明可能是这样的:
static int find(int[] arr, int val)
- requires:
val occurs exactly once in arr
- effects:
returns index i such that arr[i] = val
练习
Best behavior
对于上述的两个代码,我们接着来讨论。现在来改变一下规格说明,假设客户对返回值要求:
如果val在a中,返回任何索引i ,使得a[i] == val 。否则,返回一个不在a索引范围内的整数j
在这种情况下,findFirst 和 findLast 的行为等价吗?
Yes
规格说明的结构
一个规格说明含有以下两个“条款”:
1.一个前置条件,关键词是requires
2.一个后置条件,关键词是effects
其中前置条件是客户的义务(谁调用的这个方法)。它确保了方法被调用时所处的状态。
而后置条件是实现者的义务。如果前置条件得到了满足,那么该方法的行为应该符合后置条件的要求,例如返回一个合适的值,抛出一个特定的异常,修改一个特定的对象等等。如上图所示。
如果前置条件不满足的话,实现也不需要满足后置条件——方法可以做任何事情,例如不终止而是抛出一个异常、返回一个任意的值、做一个任意的修改等等。如上图所示。
练习
Logical implication
思考下面这个规格说明
static int find(int[] arr, int val)
- requires:
val occurs exactly once in arr
- effects:
returns index i such that arr[i] = val
作为find的实现者,下面哪些行为是合法的?
[x] 如果arr为空,返回0
[x] 如果arr为空,抛出一个异常
[x] 如果val在arr出现了两次,抛出一个异常
[x] 如果val在arr出现了两次,将arr中的元素都设置为0,然后抛出一个异常
[x] 如果arr不为空但是val没有出现,选取一个随机的索引,将其对应的元素设置为val
,然后返回这个索引
[x] 如果arr[0]是val
,继续检查剩下的元素,返回索引最高的那个val对饮的索引(没有再次找到val就返回0)
Logical implementation
作为find的实现者,当arr为空的时候,为什么要抛出一个异常?
[ ] DRY(译者注:Don’t repeat yourself)
[x] 快速失败/报错
[ ] 避免幻数
[ ] 一个变量只有一个目的
[ ] 避免全局变量
[ ] 返回结果
Java当中的规格说明
Java对于文档注释有一些传统,例如参数的说明以@param作为开头,返回的说明以@return 作为开头。你应该将前置条件放在@param的地方,后置条件放在 @return的地方。
例如,一个规格说明可能是这样:
static int find(int[] arr, int val)
- requires:
val occurs exactly once in arr
- effects:
returns index i such that arr[i] = val
它在Java中可能被注释为这样:
/**
* Find a value in an array.
* @param arr array to search, requires that val occurs exactly once in arr
* @param val value to search for
* @return index i such that arr[i] = val
*/
static int find(int[] arr, int val)
Eclipse也可以根据你的规格说明产生对应的文档),或者产生和Java API一个格式的HTML文档,这对你和你的客户来说都是很有用的信息。
练习
Javadoc
思考以下规格说明:
static boolean isPalindrome(String word)
- requires:
word contains only alphanumeric characters
- effects:
returns true if and only if word is a palindrome
注:回文,例:aba
对应的Javadoc注释:
/*
* Check if a word is a palindrome.
* A palindrome is a sequence of characters
* that reads the same forwards and backwards.
* @param String word
* @requires word contains only alphanumeric characters
* @effects returns true if and only if word is a palindrome
* @return boolean
*/
请问Javadoc中哪一行是有问题的?
[x] /*
注:应该是两个星
[ ] * Check if a word is a palindrome.
[ ] * A palindrome is a sequence of characters
[ ] * that reads the same forwards and backwards.
[x] * @param String word
[x] * @requires word contains only alphanumeric characters
[x] * @effects returns true if and only if word is a palindrome
注:不需要写effects,这应该写到return里
[x] * @return boolean
注:需要再多说明返回ture的情况
[ ] */
Concise Javadoc specs
思考下面这个规格说明Javadoc,判断每一句的作用(逆序):
/**
* Calculate the potential energy of a mass in Earth’s gravitational field.
* @param altitude altitude in meters relative to sea level
* @return potential energy in joules
*/
static double calculateGravitationalPotentialEnergy(double altitude);
static double calculateGravitationalPotentialEnergy(double altitude);
[ ] 前置条件
[ ] 后置条件
[ ] 是前置条件也是后置条件
[x] 都不是
@return potential energy in Joules
[ ] 前置条件
[x] 后置条件
[ ] 是前置条件也是后置条件
[ ] 都不是
@param altitude altitude in meters relative to sea level
[x] 前置条件
[ ] 后置条件
[ ] 是前置条件也是后置条件
[ ] 都不是
Calculate the potential energy of a mass in Earth’s gravitational field.
[ ] 前置条件
[ ] 后置条件
[ ] 是前置条件也是后置条件
[x] 都不是
null引用
在Java中,对于对象和数组的引用可以取一个特殊的值null
,它表示这个这个引用还没有指向任何对象。Null值在Java类型系统中是一个“不幸的黑洞”。
原始类型不能是null :
int size = null; // illegal
double depth = null; // illegal
我们可以给非原始类型的变量赋予null值:
String name = null;
int[] points = null;
在编译期的时候,这是合法的。但是如果你尝试调用这个null对象的方法或者访问它里面对应的数值,发产生一个运行时错误:
name.length() // throws NullPointerException
points.length // throws NullPointerException
要注意是,null并不等于“空”,例如一个空的字符串""或者一个空的数组。对于一个空的字符串或者数组,你可以调用它们的方法或者访问其中的数据,只不过它们对应的元素长度是0罢了(调用length() )。而对于一个指向null的String类型变量——它什么都不是:调用 length()会产生一个NullPointerException。
另外要注意一点,非原始类型的聚合类型例如List可能不指向null但是它的元素可能指向null:
String[] names = new String[] { null };
List<Double> sizes = new ArrayList<>();
sizes.add(null);
快照图如下图所示:
如果有人尝试使用这些为null的元素,报错依然会发生。
使用Null值很容易发生错误,同时它们也是不安全的,所以在设计程序的时候尽可能避开它们。在这门课程中——事实上在大多数好的Java编程中——一个约定俗成规矩就是参数和返回值不是null。
所以每一个方法都隐式的规定了前置条件中数组或者其他对象不能是null,同时后置条件中的返回对象也不会是null值(除非规格说明显式的说明了可能返回null,不过这通常不是一个好的设计)。总之,避免使用null!
在Java中你可以在类型中显式的禁用null ,这样会在编译期和运行时自动检查null值:
static boolean addAll(@NonNull List\<T\> list1, @NonNull List\<T\> list2)
阅读
NullPointerException accessing exercise.name()
下面哪些变量可以是null ?
[ ] int a;
[ ] char b;
[ ] double c;
[x] int[] d;
[x] String e;
[x] String[] f;
[ ] Double g;
[x] List<Integer> h;
[x] final MouseTrap i;
[x] static final String j;
There are null exercises remaining
public static String none() {
return null; // (1)
}
public static void main(String[] args) {
String a = none(); // (2)
String b = null; // (3)
if (a.length() > 0) { // (4)
b = a; // (5)
}
return b; // (6)
}
哪一行有静态错误? -> 6
如果们将上一个问题的行注释掉,然后运行 main…
哪一行会有运行时错误? -> 4
规格说明说些什么
一个规格说明应该谈到接口的参数和返回的值,但是它不应该谈到局部变量或者私有的(private)内部方法或数据。这些内部的实现应该在规格说明中对读者隐藏。