5 设计规约
5.1函数和方法
每个大程序都是由许多小方法构建而成的,每个方法都可以被独立开发、测试和复用;而作为方法的客户端,无需知道方法内部是如何实现的。
一个完整的方法应该包含方法的规约和实现体两部分
- 规约中应包含-描述、方法签名、参数、返回值等信息
- 实现体中包含具体代码
一个例子:
5.2 规约:编程的通信
5.2.1 编程中的文档记录
可以使用文档记录代码中哪个变量是什么数据类型或者哪个变量是final类型等。
编写程序时必须牢记两个目标:
-
代码中蕴含的“设计决策”:如哪个变量是什么数据类型的,为了方便编译器检查(to:编译器)
-
注释形式的“设计决策”:使程序易于理解,以便将来有人需要修复、改进或者调整程序时更方便(to:自己和别人)
5.2.2 方法的规约和合同
规约是合作开发的关键:写好规约可以分配任务。
规约是程序和用户之间的合同:实现者满足合同,使用者可以依赖合同进行使用。
规约给双方都确定了责任,在调用的时候双方都要遵守(如果用户不遵守规约则程序可以返回任意值)
规约作为用户和实现者之间的防火墙,可以隔离“变化”,即实现者可以在规约前提下自由改变代码而无需通知客户端
5.2.3 行为等价性
从程序员角度看,要确定行为等价性,问题是我们是否可以用一种实现替代另一种实现
但我们要站在客户端视角看行为等价性。看是否出现了用户需要的值,如何没有找到,返回值都不是用户需要的值,那么行为就是等价的。
单纯看代码不足以判断不同的实现是否是等价的,因此应该根据代码的规约判定行为等价性,并且在编写代码之前,需要弄清楚规约如何协商形成、如何撰写
5.2.4规约结构:前置条件和后置条件
- 前置条件:输入,对客户端的约束,在使用方法是必须满足的条件
- 后置条件:输出和异常,对开发者的约束,方法结束时必须满足的条件
- 异常行为:前置条件不满足时候的行为
如果调用状态的前提条件成立,则该方法必须遵守后条件,返回适当的值,抛出指定的异常,修改或不修改对象,等等;反过来前置条件不满足,则后置条件无论返回什么都不算违约。
如果调用方法时先决条件不成立,则实现不受后决条件的约束它可以自由地做任何事情,包括不终止、抛出异常、返回任意结果、进行任意修改等。
当前置条件被违反时,说明客户端有bug。虽然我们没有义务提醒,但是可以通过快速失败使bug更容易被找到和修复。
-
Java的静态类型声明实际上是方法的前置条件和后置条件的一部分,编译器会自动检查并强制执行该部分;
-
方法前的注释也是一种规约,但需人工判定其是否满足
规约中参数的写法:
1.规约的开头和结尾要用下面的格式
/**
*/
2.参数用@param表示,返回值和抛出用@return和@throw表示,如:
错误示例:
- 开头格式
- @param和@return后不需要写参数的类型,因为方法签名中已经有了
- requires和effects是书面格式,在编程中应使用2中的格式
在Java中,该方法的源代码通常对规约的读者不可见,因为Javadoc工具从代码中提取规约,并将其呈现为HTML。
可变方法的规约
需要在规约中明确写出是否修改了传入的可变参数
除非在后置条件里声明过,否则方法内部不应该改变输入参数(约定俗成的默契)
可变对象会使规约复杂化:
- 对同一可变对象(对象的别名)的多个引用可能意味着程序中的多个位置依赖该对象保持一致。
- 无法强迫类的实现体和客户端不保存可变变量 的“别名”
eg:
解决方法:防御式拷贝(只对用户端有效,无法保证开发者不会对可变对象进行操作)、在规约中提醒用户不要修改可变对象(不靠谱、需要道德的使用者)
最好的解决方法:采用不可变的数据类型
5.2.5测试和验证规约
黑盒测试
根据规约设计测试用例,不需要考虑实现,同client一样
上面的测试语句假设了实现的具体方法,而不能保证其真实性,因此最好使用下面的方法
5.3设计规约
5.3.1规约的分类
规约的比较
-
根据规约的确定性(输出是否确定)
-
根据规约的陈述性(建议只描述输出,不描述细节)
-
根据规约的强度(如何写规约,用于判断哪个规约更好)
判定规约的强度更强的标准:前置条件更少,后置条件更多(要的更少,给的更多)
如果S2的强度强于S1,那么就可以用S2代替S1
eg:
如果两个规约一个前置条件弱,另一个后置条件强,则无法比较强弱,也无法替代。
越强的规约,意味着implementor的自由度和责任越重,而client的责任越轻
5.3.2 图解规约
-
这个空间中的每个点代表一个方法实现
-
规约在所有可能的实现空间中定义了一个区域
-
某个具体实现,若满足规约,则落在其范围内;否则,在其之外。
程序员可以在规约的范围内自由选择实现方式,这对于实现者能够提高算法的性能、代码的清晰度,或者在发现错误时改变方法等来说至关重要。
图解规约的特征
规约越强,区域越小:
eg:
ExactlyOne 是最外面的橙色圆圈
5.3.3 设计好的规约
规约的质量:
-
规约要在编写方法的时候写
-
规约的形式:它显然应该简洁、清晰、结构良好,以便易于阅读
-
然而,规范的内容更难规定。
没有绝对正确的规则,但有一些有用的准则。如下:
内聚的
规约描述的功能应该简单、单一(只实现一个功能)、易懂
信息丰富
不能让用户产生歧义,如:
一旦返回null,无法判断是key不存在,还是key对应的值就是null,所以上述不是好的设计(除非确保不会insert null)
足够健壮
开发者应尽可能考虑各种特殊情况,在post-condition给出处理措施。如:
足够弱(不要太强)
不要给太高的承诺,如写“该方法打开名为xxx的文件”则太强了(可能遇到权限、文件不存在等问题)
使用抽象数据类型
在规约里使用抽象类型,可以给方法的实现体与客户端更大的自由度。
在Java中,这通常意味着使用接口类型,如Map或Reader,而不是特定的实现类型,如HashMap或FileReader
前置条件处理还是后置条件处理
是否应该使用前置条件?在方法正式执行之前,是否要检查前置条件已被满足?
对于开发者来说:不写Precondition,就要在代码内部check;若代价太大,就在规约里加入precondition,把责任交给client
但客户端不喜欢太强的precondition,不满足precondition的输入会导致失败
因此,惯用做法是:不限定太强的precondition,而是在postcondition中抛出异常:输入不合法。这使得在调用方代码中更容易找到导致传递错误参数的错误或错误假设。总的来说,尽可能在错误的根源处fail test(快速失败),避免其大规模扩散。
总结:是否使用前置条件取决于(1) check的代价;(2) 方法的使用范围 – 如果只在类的内部使用该方法(private),那么可以使用前置条件(方法内部不需要判断输入是否满足,认为client会保证前置条件),在使用该方法的各个位置进行check——责任交给内部client;如果在其他地方使用该方法(public),那么可以不使用/放松前置条件(在方法内部检查输入是否满足),若client端不满足则方法抛出异常。