软件构造第五次课程笔记
上一节关注了编程语言中的“数据类型”、“变量”、“值”,尤其是mutable和immutable的类型/值/引用
本节转向“方法/函数/操作”如何定义—编程中的“动词”、规约
1.编程语言里的函数/方法
参数类型、返回值是否匹配,在静态检查阶段完成
2.规约:为了交流而编程
2.1 编程中的文档记录
写“假设”的目的:自己记不住,别人看不懂
编写程序时必须牢记两个目标:
A.与计算机通信。首先说服编译器你的程序是合理的——语法正确,类型正确。然后让逻辑正确,以便在运行时给出正确的结果。(代码中蕴含的“设计决策”:给编译器读)
B.与他人交流。使程序易于理解,以便将来有人需要修复、改进或调整程序时,他们可以这样做。(注释形式的“设计决策”:给自己和别人读)
2.2规约与协议(关于方法)
2.2.1规约的意义
■没规约,没法写程序;即使写出来,也不知道对错
■规约充当了实现者的契约,实现者负责履行合同,使用该方法的客户可以依赖合同。程序与客户端之间达成的一-致
-说明方法和调用方的责任
-定义正确的实施意味着什么
■规约对双方都有要求:当规约有前提,客户也有责任。规格给“供需双方”都确定了责任,在调用的时候双方都要遵守
2.2.2为什么需要规约?
现实:
1.程序中许多最糟糕的错误都是由于对两段代码之间接口行为的误解而产生的。
2.虽然每个程序员心中都有规约,但并不是所有程序员都写下来。因此,团队中的不同程序员有不同的规约。
3.当程序失败时,没有规约,很难确定错误在哪里。
优势:
1.代码中的精确规约可以将责任归咎于某段代码片段,有助于区分责任。
2.规约对于方法的客户端来说是很好的。客户端无需阅读调用函数的代码,只需理解规格即可
2.2.3方法规约与实现体示例
2.3行为对等
站在客户角度看行为等价性
I.当val缺失时,findFirst返回arr的长度,findLast返回-1;当val出现两次时,findFirst返回较低的索引,findLast返回较高的索引
II.但是,当val正好出现在数组的一个索引上时,这两种方法的行为是相同的。
无论何时调用该方法,它们都会传入一个arr,其中只有一个元素val。对于此类客户,这两个方法是相同的
III.因而为了能够用一种实现替代另一种实现,并知道何时可以接受,我们需要一个明确说明客户依赖什么的规约
IV.因为上面两个函数规约相同,故等价
2.4规约结构:前置条件和后置条件
2.4.1规约结构
前置条件(用requires强调):对客户端的约束,在使用方法时必须满足的条件
后置条件(用effects强调):对开发者的约束,方法结束时必须满足的条件
契约:如果前置条件满足了,后置条件必须满足
前置条件不满足,方法可以做任何事情
※如果出现错误需要有错误提示信息
2.4.2 Java中的规约
静态类型声明是一种规约,可据此进行静态类型检查static checking
方法前的注释也是一种规约,但需人工判定其是否满足
把前置条件放到@param里,后置条件放到@return和@throws里
2.4.3 例子
这个练习表明,在规约中,除了@param和@return子句之外,还可以在其他地方找到部分前置条件和后置条件,因此仔细阅读非常重要
2.4.4 规约包括什么内容
方法的规约可以讨论方法的参数和返回值,但决不能讨论方法的局部变量或方法类的私有字段
该实现对规约的读者是不可见的。
在Java中,该方法的源代码通常对规约的读者不可用,因为Javadoc工具从代码中提取规约注释,并将其呈现为HTML
2.4.5 可变方法的规约
Ex1,2将传入参数改变了
注意:
除非在后置条件里声明过,否则方法内部不应该改变输入参数
应尽量遵循此规则,尽量不设计mutating的spec,否则就容易引发bugs。
程序员之间应达成的默契:除非spec必须如此,否则不应修改输入参数
尽量避免使用mutable的对象
2.4.6 可变对象使得规约变得复杂
不能靠程序员的“道德”,要靠严格的“契约”
程序中可能有很多变量指向同一个可变对象(别名),无法保证一致性
无法强迫类的实现体和客户端不保存可变变量的“别名
例如remove方法,如果在迭代过程中以除调用此方法之外的任何方式修改了基础集合,则迭代器的行为是不特定的。
尽量避免使用可变的全局变量,但是为了性能原因,有时候却不得不用。
然而这对程序的安全性造成了巨大破坏。
2.4.7 可变对象降低了可变性
可变对象使客户机和实现者之间的契约变得更加复杂,减少了客户机和实现者进行更改的自由。换句话说,使用允许更改的对象会使代码更难更改。
A.例子
①在数据库中查找用户名并返回用户9位标识符的方法
②使用此方法打印用户标识符的客户端:
③现在,客户机和实现者分别决定进行更改
客户担心用户的隐私,决定隐藏id的前5位
实施者担心数据库的速度和负载,因此实施者引入了一个缓存,可以记住已查找的用户名
实际上用户和实现者同时操作id数组,比如传进来一个username,在getMitId里的cache没发现有这个key于是把username和对应ID添加到cache里并返回,然而在用户输出时它将id前5位全给改成*****了,这就导致cache中存的key对应的value变了,下一次再查这个username的id时就会发现前五位是全是*。如图所示
因此共享可变对象会使契约复杂化,如上面例子那样,客户没有义务不修改取回的物品,实现者没有义务不保留它返回的对象
B.对上面的例子建立规约,但
第二个规约至少说明array必须是新的,但它不能防止实现者持有新array的别名,不能防止实现者在将来更改该array或将其用于其他用途
C.改正措施
不可变的字符串返回值保证了客户机和实现者永远不会像使用字符数组那样相互攻击。这并不取决于程序员仔细阅读规约注释。
字符串是不可变的。不仅如此,这种方法(与①不同)还让实现者可以自由地引入缓存(实现③中改进)——这是一种性能改进。
关键就在于“不可变”,在规约里限定住
*2.5测试和验证规约
2.5.1正式合同规约
这是一种具有优势的理论方法:自动生成运行时检查,是正式验证的基础
,自动分析的工具
缺点
需要大量工作,在大范围内不切实际,行为的某些方面不符合正式规约
2.5.2 文本规约-Javadoc
是一种实用方法
记录每个参数、返回值、每个异常(已检查和未检查)、方法的功能,包括用途、副作用、任何线程安全问题、任何性能问题
不记录实施细节
2.5.3 遵守合同的语义正确性
A.编译器确保类型正确(静态类型检查)
–防止许多运行时错误,例如“找不到方法”和“无法将布尔值添加到int”
B.静态分析工具(如FindBugs)识别许多常见问题(错误模式)
–例如:重写equals而不重写hashCode
2.5.4 形式验证
使用数学方法证明形式规约的正确性
正式证明实现的所有可能执行符合规约
体力劳动;部分自动化;不能自动判定
2.5.5 黑盒测试
测试:在受控环境中使用选定的输入执行程序,目的是为了揭示缺陷,以便修复(主要目标),评估质量,阐明规约、文档
黑盒测试:检查被测试的程序是否以独立于实现的方式遵循指定的规约。
测试用例不应依赖于任何具体的实现行为。测试用例必须遵守合同,就像其他客户一样。
第一个测试用例假设了一个特定的实现即find总是返回最低的索引。这是不对的, 正确的应当像第二个那样
3.设计规约
3.1规约分类
3.1.1规约对比
I.规约的确定性
规约是否只为给定的输入定义了一个可能的输出,或者允许实现者从一组合法输出中进行选择
II.规约的陈述性
规约只是描述了输出应该是什么,还是明确说明了如何计算输出?
III.规约的强度
该规约是有一小部分合法实现,还是有一大部分?
3.1.2 规约强度如何比较
如果规约强度S2>=S1,那么需要满足S2前置条件比S1弱(对客户宽容)且S2后置条件比S1强(对自己严格)那么就可以用S2代替S1
spec变强:更放松的前置条件+更严格的后置条件
↑与Or spec,St spec比,前置变弱,后置没变化,整体上比这两个强
↑与MS spec比,前置变弱但后置也变弱,无法比较强弱
总结
当规约得到加强时:–满足它的实现越少,可以使用它的客户端越多
越强的规约,意味着实现者的自由度和责任越重,而客户的责任越轻
3.1.3 规约确定性
确定性:当呈现满足前提条件的状态时,结果就完全确定了。
–只有一个返回值和一个最终状态。
–没有多个有效输出的有效输入。
欠定的规约:同一个输入可以有多个输出
非确定的规约:同一个输入,多次执行时得到的输出可能不同
为避免混乱,not d… == under d…
欠定的规约通常有确定的实现
3.1.4 操作性规约VS声明性规约
1.操作性规约给出了该方法执行的一系列步骤。例如,伪代码描述是操作性规约。声明性规约没有给出中间步骤的细节。相反,它们只是给出最终结果的属性,以及它与初始状态的关系。
2.声明式规约更有价值,它们通常更短、更容易理解,而且最重要的是,不会无意中暴露客户端可能依赖的实现细节。
3.那么为什么存在操作规约?
–程序员使用规约为维护人员解释实现。
–不要那样做。内部实现的细节不在规约里呈现,放在代码实现体内部注释里呈现
3.2图表化规约
空间中的每个点代表一个方法实现。规约在所有可能的实现空间中定义了一个区域。一个给定的实现要么按照规约运行(在区域内),要么不符合(在区域外)。
程序员可以在规约的范围内自由选择实现方式,. 客户端无需了解具体使用了哪个实现
更强的后置条件意味着实现的自由度更低了–>在图中的面积更小
更弱的前置条件意味着实现时要处理更多的可能输入,实现的自由度低了–>面积更小
3.3设计好的规约
关于规约的形式:它显然应该简洁、清晰、结构良好,以便易于阅读。
然而,规约的内容更难规定。没有绝对正确的规则,但有一些有用的准则
一个好的“方法”设计,并不是你的代码写的多么好,而是你对该方
法的spec设计得如何。 一方面:客户用着舒服 ,另一方面:开发者编着舒服
(1)Spec描述的功能应单一、简单、易理解
除了糟糕地使用全局变量和打印而不是返回之外,规约也不一致——它做两件不同的事情,计算单词和查找最长的单词。
将这两项职责分成两种不同的方法将使它们更简单(易于理解),并且在其他环境中更有用(准备好改变)
(2)调用函数结果应该是信息丰富的,不能让客户端产生理解的歧义
这不是一个很好的设计,因为返回值是无用的,除非确定没有插入null。
(3)规约应当足够强(后置条件不应过弱)
在一般情况下,规约应该为客户提供足够有力的保证——它需要满足客户的基本要求。在指定特殊情况时必须格外小心,以确保它们不会破坏原本有用的方法。
如果抛出NullPointerException,客户必须自行确定list2的哪些元素实际到达了list1。
太弱的spec,客户不放心、不敢用 (因为没有给出足够的承诺)。
开发者应尽可能考虑各种特殊情况,在post-condition给出处理措施。
(4)规约同样应当足够弱(前置条件不应过弱)
这个规约过于强大了,因为它无法保证打开一个文件。它运行的进程可能没有打开文件的权限,或者文件系统可能存在程序无法控制的问题。
相反,规范应该说一些更弱的东西:它试图打开一个文件,如果成功,该文件具有某些属性。
太强的spec,在很多特殊情况下难以达到,给开发者增加了实现的难
度(client当然非常高兴)。
(5)规范应该使用抽象类型
在规约里使用抽象类型,可以给方法的实现体与客户端更大的自由度
(6)前置条件与后置条件
实现者:不写Precondition(不限制客户),就要在代码内部check(客户责任小,程序员任务量变大);若代价太大,在规约里加入precondition,把责任交给client
客户端:不喜欢太强的precondition,不满足precondition的输入会导致失败。
惯用做法是:
不限定太强的precondition,而是在postcondition中抛出异常:输入不合法
尽可能在错误的根源处fail,避免其大规模扩散
归纳:是否使用前置条件取决于(1) check的代价;(2) 方法的使用范围
– 如果只在类的内部使用该方法(private),那么可以不使用前置条件,在使用
该方法的各个位置进行check——责任交给自己;
(6)前置条件与后置条件
实现者:不写Precondition(不限制客户),就要在代码内部check(客户责任小,程序员任务量变大);若代价太大,在规约里加入precondition,把责任交给client
客户端:不喜欢太强的precondition,不满足precondition的输入会导致失败。
惯用做法是:
不限定太强的precondition,而是在postcondition中抛出异常:输入不合法
尽可能在错误的根源处fail,避免其大规模扩散
归纳:是否使用前置条件取决于(1) check的代价;(2) 方法的使用范围
– 如果只在类的内部使用该方法(private),那么可以不使用前置条件,在使用
该方法的各个位置进行check——责任交给自己;
– 如果在其他地方使用该方法(public),那么必须要使用前置条件,若客户不满足则方法抛出异常