第三单元总结——JML契约式编程
OO第三单元博客作业——JML与契约式编程
OO第三单元的三次作业都是在课程组的JML规格下完成。完成作业的过程是契约式编程的过程:设计者完成规格设计,实现者按照规格具体实现。作业正确性的检查同样围绕规格进行:主要验证作业是否严格按照规格实现。
课程组承诺如果作业严格实现规格,作业的正确性就没有问题。此即是契约式编程的意义之一:工程的每一个环节都有人负责,兼顾高效性和正确性。
JML基础
JML(Java Model Language)是一种建模语言,用形式化的符号语言对Java的类和方法进行描述。
JML理论基础
在面向对象设计的一个原则就是代码的具体实现尽量推迟。例如一个面向对象软件实现的典型过程是:需求分析、框架设计、代码实现、软件测试。在例子的过程中代码的具体实现被放在第三步,工程师在具体实现代码时,基本上只会有算法上的困惑,而不会对软件的设计产生困惑。其中就有一个很关键的问题:设计者如何将自己的设计清晰准确的传递给实现者,测试者又如何全面高效的对实现者实现的代码进行测试。
一种解决方案就是通过文档传递信息,但是在大量的实践中人们发现,自然语言并不能清晰准确的转递程序工程的信息。自然语言的二义性和程序的无二义之间的矛盾、自然语言对基于逻辑的程序描述效率低,这两个问题使得自然语言不能胜任程序信息的传递。
JML则完美的解决自然语言面临的两个问题,其形式化的符号语言具有无二义的性质。基于数理逻辑的语法对程序的描述能力很强。设计者用JML可以简明清晰的对软件的前置条件、后置条件、影响范围和行为进行限制,测试者可以根据JML高效的构造出全面的测试样例。对于实现者,通过JML可以清晰地理解设计者地意图,实现代码的效果和影响范围。
JML语法描述
接下来就是对JML的语言细节介绍,在JML中的关键字有:
关键字 | 含义 | 作用 |
---|---|---|
normal_behavior | 正常行为 | 说明方法的正常行为 |
exceptional_behavior | 异常行为 | 说明方法的异常行为 |
requires | 前置条件 | 调用者保证满足的方法运行条件 |
assignable | 约束条件 | 约束方法可以修改的数据域 |
ensures | 后置条件 | 方法在前置条件满足的情况下保证的输出 |
signals | 异常抛出 | 说明抛出某个异常要满足的条件 |
invariant | 不变式 | 数据域在所有可见状态下都必须满足的特性 |
constraint | 状态变化约束 | 数据域状态变化所满足的约束 |
\old(expr) | 执行前状态 | 表示expr在方法执行前的取值 |
\result | 结果代词 | 指代一个非void型方法执行的结果 |
以上关键字使得对程序的规格化描述变得简洁清晰,建模变得简单。在关键字中提到很多条件,JML中用布尔表达式表达条件:
布尔算符 | 含义 |
---|---|
&& | 逻辑与 |
|| | 逻辑或 |
! | 逻辑非 |
== | 相等 |
==> | 蕴含 |
<==> | 互蕴含 |
\forall | 全程量词 |
\exists | 特称量词 |
JML建模原则
用行为描述、前置条件、后置条件、影响范围对方法的行为建模,用不变式和状态变化约束对类的数据域建模,方法建模在类建模的基础之上。二者结合就可以对类的状态和行为进行完整的建模。
子类继承父类的规格,满足规格规定的行为。JML同样允许子类修改父类的方法规格,但必须满足以下条件:满足前置条件的集合必须扩大或不变,满足后置条件的集合必须减小或不变。
JML工具链
openjml:以SML Solver为组建,进行jml语法检查、代码静态检查、生成运行时测试类
JMLUnitNG/JMLUnit: 针对类自动生成测试样例并进行测试。
SMT Solver 验证
SMT Solver是openjml的基础组件,在进行openjml在进行验证时调用SMT Solver。
以下是我在eclipse中对代码的验证结果,因为jml不支持全程量词和特称量词,所以此次验证使用自己编写的展示类。
展示类代码如下:
public class Demo {
public static void main(String[] args) {
System.out.println(Demo.div(-2147483648, -1));
}
//@ ensures \result == a + b;
public static int add(int a, int b) {
return a + b;
}
//@ ensures \result == a / b;
public static int div(int a, int b) {
return a / b;
}
//@ ensures \result > 0 <==> a > b;
//@ ensures \result == 0 <==> a == b;
//@ ensures \result < 0 <==> a < b;
private static int compare(int a, int b) {
return a - b;
}
}
检查结果如下:
TRACE of jml.Demo.add(int,int)
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:9: 注: return a + b;
VALUE: a === 2147483647
VALUE: b === 1
VALUE: a + b === ( - 2147483648 )
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:9: 注: ArithmeticOperationRange assertion: !(0 < a && 0 < b) || a <= 2147483647 - b
VALUE: !(0 < a_193_193___7 && 0 < b_200_200___8) || a_193_193___7 <= 2147483647 - b_200_200___8 === false
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:9: 注: Invalid assertion (ArithmeticOperationRange)
============================================================================================================================================================
TRACE of jml.Demo.compare(int,int)
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:21: 注: return a - b;
VALUE: a === 0
VALUE: b === ( - 2147483648 )
VALUE: a - b === ( - 2147483648 )
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:21: 注: ArithmeticOperationRange assertion: !(0 < a && b < 0) || a <= 2147483647 + b
VALUE: !(0 < a_474_474___7 && b_481_481___8 < 0) || a_474_474___7 <= 2147483647 + b_481_481___8 === true
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:21: 注: ArithmeticOperationRange assertion: !(a < 0 && 0 < b) || -2147483648 + b <= a
VALUE: !(a_474_474___7 < 0 && 0 < b_481_481___8) || -2147483648 + b_481_481___8 <= a_474_474___7 === true
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:17: 注: ensures \result > 0 <==> a > b;
VALUE: \result === ( - 2147483648 )
VALUE: 0 === 0
VALUE: \result > 0 === false
VALUE: a === 0
VALUE: b === ( - 2147483648 )
VALUE: a > b === true
VALUE: \result > 0 <==> a > b === false
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:21: 注: Invalid assertion (Postcondition)
: D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:17: 注: Associated location
============================================================================================================================================================
TRACE of jml.Demo.div(int,int)
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:14: 注: return a / b;
VALUE: a === 0
VALUE: b === 0
VALUE: a / b === 0
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:14: 注: PossiblyDivideByZero assertion: b != 0
D:\Core-professional-courses\OO\chapter-3\c3-hw11\hw11\src\main\java\jml\Demo.java:14: 注: Invalid assertion (PossiblyDivideByZero)
可以看到,openjml检查出三处、两类错误,分别是溢出错误和除零错误。eclipse下的openjml使用体验丝般顺滑,而命令行下的openjml的使用体验就十分粗糙。而且openjml在调试的过程中也是问题不断。从这个角度来说,openjml并不是一个成熟的工具,只适合学习研究,并不适合作为生产工具。
JMLUnitNG自动化测试
因为不能使用全程量词和特称量词,所以难以对课上作业代码进行依赖openjml的自动化测试。在博客中,同样只用Demo类进行自动化测试。测试过程如下:
//生成jmlunitng测试类集
java -jar .\jmlunitng.jar .\jml\Demo.java
//编译jmlunitng测试类集
javac -cp .\jmlunitng.jar .\jml\*.java
//生成openjml运行时检查文件
java -jar .\openjml.jar -rac .\jml\Demo.java
//运行测试类
java -cp .\jmlunitng.jar jml.Demo_JML_Test
[TestNG] Running:
Command line suite
Passed: racEnabled()
Passed: constructor Demo()
Failed: static add(-2147483648, -2147483648)
Passed: static add(0, -2147483648)
Passed: static add(2147483647, -2147483648)
Passed: static add(-2147483648, 0)
Passed: static add(0, 0)
Passed: static add(2147483647, 0)
Passed: static add(-2147483648, 2147483647)
Passed: static add(0, 2147483647)
Failed: static add(2147483647, 2147483647)
Passed: static div(-2147483648, -2147483648)
Passed: static div(0, -2147483648)
Passed: static div(2147483647, -2147483648)
Failed: static div(-2147483648, 0)
Failed: static div(0, 0)
Failed: static div(2147483647, 0)
Passed: static div(-2147483648, 2147483647)
Passed: static div(0, 2147483647)
Passed: static div(2147483647, 2147483647)
Failed: static main(null)
===============================================
Command line suite
Total tests run: 21, Failures: 6, Skips: 0
===============================================
可以看到,jmlunitng自动生成的测试样例都在边界条件和特殊值上,这种测试方法十分高效。jmlunitng仅用几组测试样例就检查出openjml静态检查出的所有bug:溢出和除零。
架构设计和增量开发
第三单元作业是在课程组给出的规格下完成的,所以架构设计就成为了第三次作业的关键。虽然可以完全独立地实现新增加的功能,但这样损失的性能过多。如何平衡性能和代码独立性是我在第三次作业中的重点。
第一次作业的类图如下:
第一次作业几乎没有结构设计,因为并不需要其余的类辅助,而Path和PathContainer之间的接口已经被课程组定义好。所以此次作业的重点就转移到性能上:保存中间变量,加快查询指令。
第二次作业的类图如下:
仔细看第二次作业的类图,会发现一件令人惊讶的事:MyPathContainer类消失了,只有MyGraph类存在。明明课程组给出的接口是继承关系,为什么我会直接修改MyPathContainer类到MyGraph呢?我刚开始的确采用继承的结构,因为这样既符合课程组的类关系,也体现出面向对象的特点,更使得代码简洁明了。
纸上得来终觉浅,绝知此事要躬行;理想很丰满,现实很骨感。java的安全性机制使得子类不能继承父类的私有数据域,但在MyGraph中需要用到MyPathContainer的数据域nodeMap进行高性能计算。虽然可以完全独立平行的重建数据域,但是这样效率太低。其次,完成MyGrap的功能需要在addPath中添加代码。如果直接重写addPath方法,则需要使用父类的数据域,这就与java的权限机制发生矛盾。虽然可以开放父类数据域的访问方法,但是这样会破坏父类数据域的私有性。综合考量,我放弃了继承结构而采用直接重写MyPathContainer为MyGraph的方案。
第三次作业的类图如下:
第三次作业采用了继承的结构。这是因为系统的功能越来越复杂,如果继续改写MyGraph为MySubwaySystem会导致代码行数超过五百行。为了使用父类的数据域,我设置了父类的数据域的访问方法,并重写了addPath方法。这样实现使得第三次作业的实现与第二次作业的实现完全平行,使得工程结构清晰明了。
另一个架构设计则是在解耦层面的。路径图和全局图之间使用MapMessege类进行通信,它们互相并不知道对方的存在。这样对路径图类的修改并不会影响到全局图类,进行了充分的解耦。
bug分析
bug往往出现在意想不到的地方!在第三单元中一共出现了一个bug:整数运算溢出bug。这是同学们最喜欢的22号bug。产生此bug的代码如下:
@Override
public int compareTo(Path path) {
Iterator<Integer> front = this.iterator();
Iterator<Integer> after = path.iterator();
Integer frontInteger = null;
Integer afterInterger = null;
while (front.hasNext() && after.hasNext()) {
frontInteger = front.next();
afterInterger = after.next();
if (!frontInteger.equals(afterInterger)) {
return frontInteger - afterInteger; //bug在这里
//修复如下:
//return frontInteger.compareTo(afterInterger);
}
}
boolean frontHave = front.hasNext();
boolean afterHave = after.hasNext();
int r = 0;
if (frontHave) {
r = 1;
} else if (afterHave) {
r = -1;
}
return r;
}
第一次作业中我的重点在性能优化,所以在完成compareTo方法时没有仔细思考。或者说,人的精力是有极限的,注意力集中到一个方面,另一个方面就会相对忽视。溢出错误如此常见,但是即使是老练的程序员仍然有可能会犯下。这点就体现出自动生成测试样例的必要性。虽然自动测试不能测试出和架构有关的bug,但它可以帮助程序员检查疏漏,节约程序员的精力,降低发生bug的可能性,提高生产力。其次是不能重复造轮子,使用现有的模块可以节省时间,减少出bug的可能。
心得体会
架构工程师、代码工程师、测试工程师之间如何协同?如何有效的交流信息?自然语言的二义性和低效如何解决?这些问题困扰了信息界的前辈很多年。在文档的时代,代码出bug后甚至不能确定是谁的责任,这充分说明了以自然语言作为交流介质的缺陷。随着形式化验证的兴起,JMl的出现在某种程度上帮助我们解决问题。逻辑化的建模使得信息传递清晰而高效,符号化的语言使得能够对规格的代码实现进行静态验证和运行时自动测试。
计算机的理论基础之一是数理逻辑,编程模型也是采用的图灵机的思想。那么用形式化的符号语言作为信息传递的介质就再自然不过。在JML下,许多bug失去了存在的基础,根本不会出现。对前置条件和后置条件的严格定义使得实现和维护代码变得容易,不再会担心产生新的神秘bug令人秃头。在自己撰写JML规格时,程序员会发现自己对程序的理解随着规格的撰写而更加深刻。在看自己一个星期前写的代码时,也不会再感到那么痛苦:有形式化的JML模型存在。
JML是我们对抗复杂性和bug的有效工具,加以正确利用会使编程事半功倍。