第三单元总结:JML规格定义下的程序设计、验证与测试

JML语言及工具

JML语言理论

JML语言利用前置条件、后置条件、不变式等约束语法,描述了Java程序的数据、方法、类的规格,是一种契约式程序设计的实现工具。

  1. 常用的JML语言特性
    • \result:表示方法的返回值。
    • \old(expr):表示在方法执行前的值。一般将所关心的表达式取值整体括起来。
    • \forall:全称量词修饰的布尔表达式,可声明局部变量、覆盖变量的取值范围,对目标条件进行验证。
    • \exists:存在量词修饰的布尔表达式,类似\forall。
    • \sum, \max, \min:对给定范围的表达式进行运算。表达式的由声明变量、取值范围、表达式定义给出。
    • <==>, <==, ==>:逻辑推理表达式,含义与数理逻辑中相同。
    • \nothing, \everything:当前作用域下的变量范围。

  2. 数据规格
    • 不变式 invariant :在成员处于可见状态下必须满足的特性。其中可见状态可理解为完整的稳定状态。
    • 修改约束 constraint :描述前序可见状态 —> 当前可见状态的变化约束。
    • 和方法的后置条件一起对数据的变化作出规约
    • 数据规格可被子类继承。

  3. 方法规格
    • 前置条件 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!
      • 条件互斥,并集为全集。
  4. 类规格
    在类内利用一些规格变量对类的数据结构维护进行抽象描述,同样的与具体容器、对象等无关。(如pathList、pathIdList的双数组例子)
    • 规格变量和类中维护的数据有功能上的联系,但没有实现上的联系
    • 从类的层次化上来看,子类继承父类规格
    • 子类可以重写父类的方法规格
    • 子类不能违反父类的规格,但是可以进一步收窄

JML工具链

  • 使用OpenJML对实现的代码进行:
    1. JML语法静态检查:给出JML语言上的语法错误,并不关心代码
      java -jar specs/openjml.jar -check SourceToCheck.java
    2. 程序代码静态检查:给出程序中可能出现的潜在问题,并不关心JML语言
      java -jar specs/openjml.jar -esc SourceToCheck.java
    3. 运行时检查:生成一个新的.class文件,其中包含了运行时检查的assertion,在运行和单元测试的时候将发挥作用
      java -jar specs/openjml.jar -rac SourceToCheck.java
  • 使用JMLUnitNG根据JML语言自动生成TestNG测试
    1. 基于JML生成测试文件:
      java -jar ./specs/jmlunitng.jar SourceToTest.java
    2. 利用OpenJML的rac,生成含有运行时检查的特殊.class文件并替换原文件
      java -jar ./specs/openjml.jar -d bin/ -rac SourceToTest.java
    3. 运行TestNG测试
      java -cp ./specs/jmlunitng.jar:bin SourceToTest_JML_Test

 

  • 组合使用,效果为:(对讨论区中的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的语法错误和可能的简单错误(如溢出等),但不能指望其自动生成的测试数据和策略,还是应该手动根据规格构造样例。
posted @ 2019-05-21 23:45  FuturexGO  阅读(854)  评论(0编辑  收藏  举报