第三单元总结:JML规格定义下的程序设计、验证与测试
JML语言及工具
JML语言理论
JML语言利用前置条件、后置条件、不变式等约束语法,描述了Java程序的数据、方法、类的规格,是一种契约式程序设计的实现工具。
- 常用的JML语言特性
- \result:表示方法的返回值。
- \old(expr):表示在方法执行前的值。一般将所关心的表达式取值整体括起来。
- \forall:全称量词修饰的布尔表达式,可声明局部变量、覆盖变量的取值范围,对目标条件进行验证。
- \exists:存在量词修饰的布尔表达式,类似\forall。
- \sum, \max, \min:对给定范围的表达式进行运算。表达式的由声明变量、取值范围、表达式定义给出。
- <==>, <==, ==>:逻辑推理表达式,含义与数理逻辑中相同。
- \nothing, \everything:当前作用域下的变量范围。
- 数据规格
- 不变式 invariant :在成员处于可见状态下必须满足的特性。其中可见状态可理解为完整的稳定状态。
- 修改约束 constraint :描述前序可见状态 —> 当前可见状态的变化约束。
- 和方法的后置条件一起对数据的变化作出规约。
- 数据规格可被子类继承。
- 方法规格
- 前置条件 requires BoolExpr1 || BoolExpr2 || …;
- 后置条件 ensures BoolExpr1 || BoolExpr2 || …;
- 副作用影响要求 assignable modifiable
- 副作用影响判断 \not_assigned(x,y,…) \not_modified(x,y,…),可应用于后置条件的判断。
- pure方法:可以被JML引用,只需撰写后置条件。
- JML可以调用Java程序中的pure方法进行访问、判断等操作。
- 方法的异常行为:normal_behavior, also, exceptional_behavior, signals () expr, signals_only;
- 方法规格需要考虑到的范围:
- 修改参数
- 返回值
- 修改成员变量
- 修改this的引用
- 方法规格的原则:
- 关注执行效果(需求决定)和造成的其他影响
- 无需关注实现方式本身
- 本质仍是数据约束(让数据产生变化、类间的数据通信)
- requires语句需要覆盖所有可能的情况,包括exceptional_behavior和normal_behavior!
- 条件互斥,并集为全集。
- 类规格
在类内利用一些规格变量对类的数据结构维护进行抽象描述,同样的与具体容器、对象等无关。(如pathList、pathIdList的双数组例子)- 规格变量和类中维护的数据有功能上的联系,但没有实现上的联系。
- 从类的层次化上来看,子类继承父类规格
- 子类可以重写父类的方法规格
- 子类不能违反父类的规格,但是可以进一步收窄
JML工具链
- 使用OpenJML对实现的代码进行:
- JML语法静态检查:给出JML语言上的语法错误,并不关心代码
java -jar specs/openjml.jar -check SourceToCheck.java
- 程序代码静态检查:给出程序中可能出现的潜在问题,并不关心JML语言
java -jar specs/openjml.jar -esc SourceToCheck.java
- 运行时检查:生成一个新的.class文件,其中包含了运行时检查的assertion,在运行和单元测试的时候将发挥作用
java -jar specs/openjml.jar -rac SourceToCheck.java
- JML语法静态检查:给出JML语言上的语法错误,并不关心代码
- 使用JMLUnitNG根据JML语言自动生成TestNG测试
- 基于JML生成测试文件:
java -jar ./specs/jmlunitng.jar SourceToTest.java
- 利用OpenJML的rac,生成含有运行时检查的特殊.class文件并替换原文件
java -jar ./specs/openjml.jar -d bin/ -rac SourceToTest.java
- 运行TestNG测试
java -cp ./specs/jmlunitng.jar:bin SourceToTest_JML_Test
- 基于JML生成测试文件:
- 组合使用,效果为:(对讨论区中的Demo.java进行试运行)
确定自己的JML规格的语法正确性。
运行前给出部分可能的警告:
运行TestNG时针对性的进行测试: - 使用注意:
- 环境应为java-8
- 路径内不能包含中文
- 在开展TestNg测试前要使用OpenJML的RAC(运行时assertion检查)重新编译待测程序字节码
- OpenJML不支持\forall int[] 和 \exists int[]
尝试使用OpenJML的SMT Solver对简单的类进行静态验证
首先,笔者尝试使用OpenJML的静态检查("-esc")对Path.java进行验证:
部分值得关注的规格代码如下:
- 构造函数及其规格:
1 private /*@spec_public@*/ ArrayList<Integer> nodes; 2 private /*@spec_public@*/ HashSet<Integer> distinct; // keep a unique set
3 /*@ public normal_behavior 4 @ requires nodeList != null && nodeList.length != 0; 5 @ assignable \everything; 6 @ ensures (\forall int i; 0<=i && i<nodeList.length; nodeList[i] == nodes.get(i)); 7 @ ensures (\forall int i; 0<=i && i<nodeList.length; distinct.contains(nodeList[i])); 8 @ ensures (nodes.size() == nodeList.length); 9 @ also 10 @ public normal_behavior 11 @ requires nodeList == null || nodeList.length == 0; 12 @ assignable \everything; 13 @ ensures (nodes != null && nodes.size() == 0); 14 @ ensures (distinct != null && distinct.size() == 0); 15 @*/ 17 public Path(int... nodeList) { 18 if (nodeList == null || nodeList.length == 0) { 19 nodes = new ArrayList<Integer>(); 20 distinct = new HashSet<Integer>(); 21 } else { 22 nodes = new ArrayList<Integer>(nodeList.length); 23 distinct = new HashSet<Integer>(nodeList.length); 24 for (int x : nodeList) { 25 nodes.add(x); 26 distinct.add(x); 27 } 28 } 29 } - containsNode()方法及其规格:
//@ ensures \result == distinct.contains(node); public /*@pure@*/ boolean containsNode(int node) { return distinct.contains(node); }
这两个方法和规格只是作为两个例子,剩下的规格大致按照指导书即可。(注意OpenJML不支持 \forall int[] 和 \exists int[] )
当运行静态语法检查时,没有warning和error。
当运行静态规格检查时,出现如下的很多warning:
src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:11: 注: ) in method Path public Path(int... nodeList) { ^ src/Path.java:11: 警告: Associated declaration: src/Path.java:17: 注: @ ensures (\forall int i; 0 <= i && i < nodeList.length; ^ src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:13: 注: ) in method Path public Path(int... nodeList) { ^ src/Path.java:13: 警告: Associated declaration: src/Path.java:17: 注: @ ensures (\forall int i; 0 <= i && i < nodeList.length; ^ src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:15: 注: ) in method Path public Path(int... nodeList) { ^ src/Path.java:15: 警告: Associated declaration: src/Path.java:17: 注: @ ensures (nodes.size() == nodeList.length);
src/Path.java:93: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:91: 注: ) in method containsNode return distinct.contains(node); ^ src/Path.java:91: 警告: Associated declaration: src/Path.java:93: 注: //@ ensures \result == distinct.contains(node);
可以看到,OpenJML的ESC检查认为我没有满足规格,甚至没有满足containsNode()方法中的return语句的规格(这个return和\result的对象是一模一样的)。
而笔者对其他小的程序的验证则没有问题。
由此看来,OpenJML单独作为一个验证器去对类和方法进行静态检查,是不靠谱的。
其根本原因在于OpenJML所支持和识别的类型和类型的方法太少,其对基本数据类型的支持基本可用,但一旦涉及到了ArrayList等高级数据结构类,就表现地十分迷惑。
应该将其的runtime assertion checking(RAC)与JMLUnitNg一起使用,才能发挥其部分功能(为TestNg中的测试提供Assertion)。
使用JMLUnitNg自动生成测试用例并进行测试
首先对Path.java进行RAC注入下的TestNg测试
改写JML和Java代码后的,可用的MyPath.java源文件如下Ubuntu PasteBin链接所示:
https://paste.ubuntu.com/p/zgsSCyRN5M/
首先进行如下生成操作:(可以写作一个AutoGenerate.sh,之后直接运行 ./AutoGenerate.sh src/MyPath.java 即可)
java -jar ./specs/jmlunitng.jar -cp lib/oolib.jar:src "$@" # 生成TestNg和测试策略文件 javac -cp ./specs/jmlunitng.jar:lib/oolib.jar -d bin/ src/*.java # 编译字节码 java -jar ./specs/openjml.jar -d bin/ -cp lib/oolib.jar:src -rac "$@" # 将RAC的assertion注入到Path.class中
之后进行测试:(可以写作一个AutoTest.sh,之后直接运行 ./AutoTest.sh MyPath 即可)
java -cp ./specs/jmlunitng.jar:lib/oolib.jar:bin "$@""_JML_Test" # 运行TestNg主文件
得到如下结果:
可以看到,JMLUnitNg为TestNg生成了31个测试策略/用例。
首先测试Runtime Assertion Checking是否开启,之后对各个方法进行测试(包括构造函数)。
对于参数为对象的方法,其生成的用例常常包括 NULL 和 空。
对于参数为int的方法,其生成的用例常常包括极值边界数据和0。
一般地,JMLUnitNg生成的数据多在参数上和this上作出两种变化,进行组合测试。
之后对PathContainer.java进行RAC注入下的TestNg测试
改写JML和Java代码后的,可用的MyPathContainer.java源文件如下Ubuntu PasteBin链接所示:
https://paste.ubuntu.com/p/RTWW6SmrfB/
首先仍然进行如下生成操作:
./AutoGenerate.sh src/MyPathContainer.java
之后进行测试:
./AutoTest.sh MyPathContainer
得到如下结果:
可以看到,本套件只会盲目的进行边界值、特殊值、NULL、空的测试,最多对this进行某些构造(外界不可知),
但是并不能对其进行针对性的测试,如传入有特定意义的Path对象。
中间会遇到一个“A catastrophic JML internal error occurred.”错误。
经笔者实验,原因为OpenJML不支持如下的forEach + Iterator语言特性:
1 for (int node : path) { // using the Path iterator, which implements the Iterable<> 2 // ... 3 }
应该换成如下显式的Iterator写法:
1 Iterator<Integer> it = path.iterator(); 2 while (it.hasNext()) { 3 int node = it.next(); // using the iterator EXPLICITLY !! 4 // ... 5 }
恕我直言,如果真的使用这些组合工具,那么程序员在写代码的时候不能首先考虑其美观性、整洁性、(程序和程序员的)高效性,
反而要时刻考虑自己写的代码能否被OpenJML理解,
那么这肯定是违背了我们使用这些工具的初衷的!
由于从Path和PathContainer中看出其测试水平十分平凡,故笔者不再打算继续对Graph类进行自动测试。
结论
经过笔者的测试,OpenJML + JMLUnitNG的实用工具组合——一点也不“实用”!
具体总结如下:
- 对复杂数据结构的支持差,对基本数据类型的支持好,对基本数据类型数组不支持量化表达式。
- 不支持对自定义类的自动智能构造,只能盲目测试null、empty等,更不用说自动构造一些特殊的Path对象来进行测试。
- 对稍稍高级的Java语言特性不支持,写代码的时候居然还要考虑其“可跑性”。
因此,笔者认为:
JML语言是一个好的契约化编程的工具,但它绝不是导致程序员花费额外时间去伺候、适应的理由。
JML语言(甚至混入一些自然语言进行描述)能够显著提升大型工程的正确性,进一步解放程序员和设计师等的工作,
但是其并不一定要用来真正的“跑起来”!
JML的重点是给人看的,而不是给机器看的。只要程序员会看、会写、会读JML,会用它来给自己和产品带来好处,这就够了!
架构设计与迭代
第一次作业
第一次作业十分简单,故没有采取什么特殊的设计。
由于对未来需求的不明朗,暂时没有使用Trie树手写的专一性强的数据结构等维护序列,而是使用了双向HashMap这一兼容性强的结构来维护Path。
类图如下:
第二次作业
第二次作业中,开始出现了图的结构模型。
类似传统算法竞赛中的邻接表结构存储图,笔者定义了若干辅助类对图结构进行管理:
- Pair类:统计无向边使用,本质是一个无序对。即(Node1, Node2)和(Node2, Node1)视作相等。
- Edges类:类似邻接表的数据结构,本质是 HashMap<Node, HashSet<Node>> 。即第一层HashMap为(有向)边的起始点索引用,第二层保存其所邻接的所有结点。
- Matrix类:类似二维不定长数组的数据结构,本质是HashMap<Node, HashMap<Node, Integer>> 。用来维护最短路径结果的dis[][]数组。
在第一次作业扩展上的类图如下:
第三次作业
第三次作业中,出现了带边权的有向图模型(拆点表示换乘后,图成为了有向图)。
在第二次作业的Matrix基础上,笔者将Matrix进行改造,使其:
- 既能表示(u, v)间的dis最短路径距离:(u, v, dis[u][v])
- 又能用于存储有向图及其边权:(u, v, w)
达成了数据结构的复用(源于二者的本质都是点-点-数据关系)。
此外,将原本的Integer表示节点,更改为封装一个Node(NodeId, PathId)类来进行管理:
- NodeId表示原先的结点号
- PathId表示由哪条线路拆点而来
- 拆点算法中,NodeId的公共源点为Node(NodeId, -1)(由于PathId满足PathId>0恒成立)
- 拆点算法中,NodeId的公共汇点为Node(NodeId, 0) (由于PathId满足PathId>0恒成立)
同时,将原先的无序对Pair扩展,变成有序对OrderedPair和无序对Pair,便于对原图(拆点前)和最短路图(拆点后)的边进行统计管理。
将部分操作移出MyRailwaySystem类,分散、归因到负责求最短路的ShortestPath类、负责求联通性的Connectivity类、负责维护图结构的GraphAction类中。
这样设计的直接好处是程序的各个方法和类的复杂度都不高,只有compareTo、查询邻接表等方法稍稍高,
但比起第一单元的多项式程序而言,复杂度控制有了长足的进步。
bug与测试
测试部分,由于本次作业属于传统的非时序输入-非时许输出问题,故可以使用对拍器+数据生成器进行对拍检查。
而对于数据生成器的构造策略,由于本单元的正确性和算法效率要求并存,故采用以下的测试策略:
- 针对算法时间复杂度测试,构造完全包含公测和互测数据规模的数据进行极限测试。
如要求PATH_ADD + PATH_REMOVE总共不超过50条时,构造PATH_ADD 和 PATH_REMOVE 均达到50条的规模。
这样测试才能保证自己的测试 完全覆盖了 正式测试的数据规模。 - 针对正确性测试,由于本单元作业中查询的正确性至关重要,故将数据规模减小,同时增加查询操作条数,加大随机查询点对的覆盖率。
在自我线下检查后,三次作业中均未出现公测和互测BUG。
规格和测试总结
- 撰写JML语言时应当注意对参数的所有不同的pre-condition进行全覆盖,即所有的pre-con互斥,且并集应为全集。
- JML对post-condition的描述不必要考虑实现,应该使用最简单、最本质的描述。
- 在实现功能后,首先利用单元测试框架,对规格中最大的normal_behavior进行几个基础功能测试。
- 之后,应当根据撰写的规格,对每一个方法 分支针对性地 对应开展单元测试。
- 注意组合pre-con(既有参数的状态、也有对象本身this的状态)的情况
- 注意组合post-con的情况,确保每个分支都进行了至少一次测试
- 使用OpenJML可以提示JML的语法错误和可能的简单错误(如溢出等),但不能指望其自动生成的测试数据和策略,还是应该手动根据规格构造样例。