3-2设计规范
一、程序设计语言的功能与方法
方法
public static void threeLines() {
STATEMENTS;
}
public static void main(String[] arguments){
System.out.println("Line 1");
threeLines();
System.out.println("Line 2");
}
参数
[…] NAME (TYPE NAME, TYPE NAME) {
STATEMENTS
}
To call:
NAME(arg1, arg2);
参数类型是否匹配,在静态类型检查阶段完成
返回值
public static TYPE NAME() {
STATEMENTS;
return EXPRESSION;
}
返回值类型是否匹配,也在静态类型检查阶段完成
变量的作用域
class SquareChange {
public static void printSquare(int x){
System.out.println("printSquare x = " + x); 5
x = x * x;
System.out.println("printSquare x = " + x); 25
}
public static void main(String[] arguments){
int x = 5;
System.out.println("main x = " + x); 5
printSquare(x);
System.out.println("main x = " + x); 5
}
}
“方法”是程序的“积木”,可以被独立开发、测试、复用
使用“方法”的客户端,无需了解方法内部具体如何工作—“抽象”
二、通信程序设计规范
1)编程文档化
Java API文档:一个实例
类层次结构和实现的接口列表
直接子类,以及接口,实现类
类的描述
结构概要
方法摘要列出了我们可以调用的所有方法
每个方法和构造函数的详细描述
-方法签名:我们看到返回类型、方法名称和参数。我们也看到例外情况。现在,那些通常意味着方法可以运行的错误
-完整描述
-参数:方法参数的说明
-以及该方法返回的描述
文档化假设
写一个变量的类型向下一个文件关于它的假设:例如,这个变量总是引用一个整数 变量的数据类型定义
-Java实际上在编译时检查这个假设,并保证在程序中没有任何地方违反这个假设
声明变量最终也是一种文档形式,声称变量在其初始赋值之后永远不会改变
final关键字定义了设计决策:“不可改变”
-Java也静态检查
代码本身就蕴含着你的设计决策,但是远远不够
通信程序设计
为什么要写出“假设”?
第一:自己记不住;第二:别人不懂
代码中蕴含的“设计决策”:给编译器读
注释形式的“设计决策”:给自己和别人读
黑客与工程
黑客是乐观主义者
-坏:在测试任何代码之前编写大量代码
-坏:把所有的细节放在脑子里,假设你会永远记住它们,而不是把它们写进你的代码中
-坏:假设错误将不存在或容易找到和修复
但是软件工程不是黑客攻击,工程师是悲观主义者
-测试优先
-随时记录代码中的“设计决策”
-工程师应该是悲观主义者
2)(方法的)规范和合同
规格(或称为合同)
规范是团队合作的关键。在没有规范的情况下实现一个方法是不可能的。没规约,没法写程序;即使写出来,也不知道对错
规范是合同约定:实施者负责合同,而使用该方法的客户可以依赖合同。程序与客户端之间达成的一致
-状态方法与呼叫者的责任
-定义对实现意味着什么是正确的
规范对双方的要求:当规范有前提时,客户也有责任
Spec给“供需双方”都确定了责任,在调用的时候双方都要遵守
-如果你按这个时间表支付我的钱…
-我会用下面的详细规范来建造一个
-有些合同有不履行的补救办法
为什么要规范?
现实:
-很多bug来自于双方之间的误解
-不写下来,那么不同开发者的理 解就可能不同
-没有规约,难以定位错误
优点:
-精确的规约,有助于区分责任
-客户端无需阅读调用函数的代码,只需理解spec即可
规格说明的一个例子
Java类双整数的一种方法add()
规范(合同)
规约可以隔离“变化”,无需通知客户端
规约也可以提高代码效率
规约:扮演“防火墙”角色
-它屏蔽客户机的工作细节
-它屏蔽了执行器和单元使用的细节
-此防火墙导致解耦,允许单位的代码和客户端的代码独立地更改,只要更改遵守规范即可
-解耦,不需了解具体实现
对象与其用户之间的协议
-输入/输出的数据类型
-功能和正确性
- 性能
只讲“能做什么”,不讲 “怎么实现”
3)行为等价
为了确定行为等价性,问题是我们是否可以将一个实现替换为另一个实现
这两个函数是否等价?
行为不同,但对用户来说 “是否等价”?
(站在客户端视角看行为等价性)
行为等价
为了使一个实现替换为另一个实现,并且知道何时可以接受,我们需要一个规范,它确切地说明客户端依赖于什么
根据规约判断是否行为等价
这两个函数符合这个规约,故它们等价。与实现无关
4)规范结构:先决条件和后条件
规范结构
一个方法的规范由几个子句组成:
-前提条件:关键字所要求的
-后置条件:关键字效应所指示的
-例外行为:如果前提条件违反,它会做什么?
前置条件:对客户端的约束,在使用方法时必须满足的条件
后置条件:对开发者的约束,方法结束时必须满足的条件
契约:如果前置条件满足了,后置条件必须满足
前置条件不满足,则方法可做任何事情
Java规范
静态类型声明是一种规约,可据此进行静态类型检查static checking
方法前的注释也是一种规约,但需人工判定其是否满足
参数由@param子句描述,结果由@return和@throws 来描述
在可能的情况下,将前提设置为@param,并将后置条件设为@return和@throws
突变方法规范
例1:一种突变方法
例2:一种突变方法
例3:不改变其参数的方法
除非在后置条件里声明过,否则方法内部不应该改变输入参数
应尽量遵循此规则,尽量不设计 mutating的方法
程序员之间应达成的默契
-除非另有说明,否则不允许突变
-没有输入突变
可变对象可以使简单的规范/合同非常复杂。
可变对象减少可变性
可变对象使简单契约复杂化
程序中可能有很多变量指向同一个可变对象
开发者和客户端之间的契约无法得到保证
不能靠程序员的“道德”,要靠严格的“契约”
作为这种非本地契约现象的一个征兆,考虑Java集合类,这些类通常被记录在一个类的客户端和实现者上非常明确的契约中。
-试着找出它在客户机上的关键需求,即在迭代时不能修改集合。
-谁来承担责任?迭代器?列表?收集区?
避免使用可变的全局变量
但是为了性能原因,有时候却不得不用。这对程序的安全性造成了巨大破坏
可变对象减少可变性
可变对象使得客户端与实现者之间的契约更加复杂,降低了客户端和实现者的自由度
-换言之,可变数据类型导致程序修改变得异常困难
一个例子:在数据库中查找用户名并返回用户的9位标识符的方法
使用此方法打印用户标识符的客户端:
现在客户和执行者分别决定做出改变
-客户端担心用户的隐私,并决定模糊ID的前5位
-执行者担心数据库上的速度和负载,因此实现者引入了一个缓存,它记住了查找的用户名:
共享可变对象使合同复杂化
谁为此事负责?
-客户有义务不修改它所返回的对象吗?
-实施者有义务不保留它返回的对象吗?
三、设计规范
1)分类规格
比较规范
规约的确定性。对于给定的输入,规范是否只定义一个可能的输出,或者允许执行者从一组合法输出中选择?
规约的陈述性。规格是否仅仅表征输出应该是什么,或者它明确地说明如何计算输出?
规约的强度。SPEC有一小部分法律实现,还是一个大集合?
确定性与欠定规范
确定性:当满足满足先决条件的状态时,结果是完全确定的。
-只有一个返回值和一个最终状态是可能的。
-没有有效的输入,其中有一个以上的有效输出。
确定的规约:给定一个满足precondition的输入,其输出是唯一的、明确的
欠定的规约:同一个输入可以有多个输出
非确定的规约:同一个输入,多次执行时得到的输出可能不同
-为避免混乱,not d.. == under d..
规范中的不确定性提供了实现者在执行时做出的选择
-欠定的规约通常有确定的实现
操作式规约,例如:伪代码
声明式规约:没有内部实现的描述,只有“ 初-终”状态
声明式规约更有价值
-它们通常较短,更易于理解,最重要的是,不要无意中暴露客户端可能依赖的实现细节
为什么存在操作规范
-程序员使用规范来解释维护者的实现
-内部实现的细节不在规约里呈现,放在代码实现体内部注释里呈现
声明性规范
标准:最清晰的,为客户和维护者的代码
强与弱规格
如何比较两个规约,以判断是否可以用一个规约替换另一个?
规约的强度S2>=S1:
-S2比S1的前置条件更弱
-S2的后置条件更强
-就可以用S2替代S1
spec变强:更放松的前置条件+更严格的后置条件
2)图解说明
该空间中的每个点表示方法实现
规范定义了所有可能实现的空间的区域
某个具体实现,若满足规约,则落在其范围内;否则,在其之外
程序员可以在规约的范围内自由选择实现方式
-这对于实现者能够提高算法的性能、代码的清晰度、或者当发现bug等时改变它们的方法是至关重要的
客户端无需了解具体使用了哪个实现
-他们必须尊重规范,但也有自由改变他们如何使用实现,而不必担心它会突然中断
当S2强于S1时,它在这个图中定义了一个较小的区域。一个较弱的规范定义了一个更大的区域
3)设计良好规范
规格的质量
什么是好方法?设计方法主要是指编写规范。
关于说明书的形式:它应该简洁明了,结构合理,便于阅读。
然而,说明书的内容更难规定。没有可靠的规则,但也有一些有用的准则
(1)规格应一致(内聚的)
Spec描述的功能应单一、简单、易理解
将这两种责任分离成两种不同的方法将使它们变得更简单(更容易理解),并且在其他上下文中更有用(准备好进行改变)
(2)调用的结果应该是信息性的(信息丰富的)
如果返回NULL,则无法判断密钥是否以前未绑定,或者它实际上是否绑定到NULL
这不是一个很好的设计,因为返回值是无用的,除非你确实知道你没有插入空值
(3)规格应足够强
规范在一般情况下应该给予客户足够的保证-它需要满足他们的基本要求。
在指定特殊情况时,我们必须格外小心,以确保它们不会破坏原本有用的方法
例如,没有一个点为一个不正确的论点抛出一个异常,但是允许任意的突变,因为客户端将无法确定究竟发生了什么突变。
-这是一个说明这个缺陷的规范(也写在一个不恰当的操作风格中):
如果抛出了一个 NullPointerException,则留给客户端自己解决List2的哪些元素实际上使之成为List1的问题
(4)规格也应足够弱
这是一个很差的规格。
-它缺少重要的细节:文件是为了阅读还是写作而打开的?它已经存在还是被创造了?
-它太强大了,因为它无法保证打开一个文件。它运行的过程可能缺少打开文件的权限,或者文件系统可能超出程序控制的某些问题
相反,规范应该说得更弱一些:它试图打开一个文件,并且如果文件成功,则该文件具有某些属性
太强的spec,在很多特殊情况下难以达到
(5)规范应该使用抽象类型
在规约里使用抽象类型,可 以给方法的实现体与客户端更大的自由度
在Java中,这通常意味着使用接口类型,如 Map或Reader,而不是特定的实现类型,如HashMap或FileReader。
-抽象概念,如List或Set
-具体实现如 ArrayList或HashSet
这迫使客户端传递一个ArrayList,并强制执行器返回一个ArrayList,即使可能有他们愿意使用的替代yList实现
(6)预调整或后置条件?
是否应该使用前置条件?在方法正式执行之前,是否要检 查前置条件已被满足?
对于程序员来说,最常见的前提条件是精确地要求一个属性,因为该方法检查它是困难的或昂贵的
对于用户来说,一个不重要的前提条件是客户不方便,因为他们必须确保他们不在坏状态下调用方法(违反了前提条件);如果他们这样做,就没有从错误中恢复的可预测的方法
客户端不喜欢太强的 precondition,不满足precondition的输入会导致失败
- 惯用做法是:不限定太强的precondition,而是在postcondition中抛出异常:输入不合法
-这使得在调用程序代码中发现错误或错误假设更容易导致传递不良参数
-尽可能在错误的根源处fail,避免其大规模扩散
衡量标准:检查参数合法性的代价多大?
如果它只在一个类中被本地调用,则可以通过仔细检查调用该方法的所有站点来释放预条件
如果该方法是公开的,并且由其他开发人员使用,那么使用前提条件就不那么明智了。相反,像Java API类一样,应该抛出异常
归纳:是否使用前置条件取决于(1)check的代价;(2)方法的使用范围
– 如果只在类的内部使用该方法(private),那么可以不使用前置条件,在使用 该方法的各个位置进行check——责任交给内部client;
– 如果在其他地方使用该方法(public),那么必须要使用前置条件,若client端不满足则方法抛出异常