BUAA OO Unit3 Summary——万物即可形式化

BUAA OO Unit3 Summary——万物即可形式化

一. JML语言的理论基础

  JML是对Java程序进行规格化设计的一种表示语言。通过规格化描述,精确地描述了代码的功能,并且为测试设计提供了严密的依据。尤其在多人协同开发的时候,相比可能带有内在模糊性的自然语言描述,JML能过更清晰地描述需求,有利于团队开发的交互。

JML表达式

原子表达式

表达式 定义 示例
esult 方法执行之后的返回值 esult == getGroup(id).getConflictSum();
old(expr) 表达式expr在方法执行前的取值 old(getGroup(id2).hasPerson(i))
ot_assigned(x,y,...) 表示括号中的变量在方法执行过程中是否被赋值 (forall int i; 0 <= i < groups.length; ot_assigned(groups[i]))

量化表达式

表达式 定义 示例
forall 对于范围内的元素,每个元素都满足相应的约束 (forall int i; 0 <= i < groups.length; ot_assigned(groups[i]))
exists 对于范围的内的元素,存在某一个元素满足相应的约束 (exists int i; 0 <= i && i < groups.length; groups[i].getId() == id2)
sum 对于范围内的元素,满足条件的表达式之和 (sum int i; 1 <= i && i < pathM.length; pathM[i-1].queryValue(pathM[i])))

方法规格

  方法规格分为正常行为规格和异常行为规格两种,分别用normal_behaviorexceptional_behavior表示,两者之间用also隔开。每一种方法规格包括了前置条件、后置条件和副作用范围限定。

表达式 定义
前置条件 requires 描述方法输入的要求
后置条件 ensures 描述方法执行结束后结果的约束
副作用范围限定 Assignable 限定方法执行过程修改的范围

示例:

/*@ public normal_behavior
      @ requires !(exists int i; 0 <= i && i < groups.length; groups[i].equals(group));
      @ assignable groups;
      @ ensures groups.length == old(groups.length) + 1;
      @ ensures (forall int i; 0 <= i && i < old(groups.length); 
      @          (exists int j; 0 <= j && j < groups.length; groups[j] == (old(groups[i]))));
      @ ensures (exists int i; 0 <= i && i < groups.length; groups[i] == group);
      @ also
      @ public exceptional_behavior
      @ signals (EqualGroupIdException e) (exists int i; 0 <= i && i < groups.length; 
      @                                     groups[i].equals(group));
      @*/

类型规格

定义
不变式(invariant) 描述数据状态应该满足的要求
约束(constraint) 描述数据状态变化应该满足的要求

二. JML工具链及其使用

  此次测试可用的工具链包括OpenJML,JMLUnitNG和Junit。前两者是针对JML进行的测试,后者主要用于单元测试。

  • OpenJML: OpenJML是一个检查JML语法,验证代码规格的工具。
  • JMLUnitNG: JMLUnitNG是基于OpenJML的以以JML语言规格描述作为指导的自动生成单元测试工具。
  • Junit: Junit是一个Java语言的单元测试框架,可以根据规格自行构造单元测试。

1. OpenJML

  OpenJML主要是针对JML语法和规格内容进行检查的工具,提供了三种检查形式。

1.1 JML语法检查(JML type-checking)

OpenJML最基础的功能就是对JML的语法进行检查。

java -jar $OPENJML /openjml.jar <options> <files>

较高的Java版本运行OpenJML会出现如下的报错。

img

该报错出现的原因是OpenJML开发时的java版本较低,依赖类版本较低的包,因此和名称功能功能一样但是版本较高的包发生的冲突,通过将本地java版本换成JDK8就可以解决。

  每次都通过命令行调用openjml.jar来运行有点麻烦,可以通过自定义命令行指令来简化一下操作。

MacOS

  命令行中输入如下指令:

vim ~/.bash_profile

  在.bash_profile中输入:

alias openjml='java -jar YOUR_OPENJML_PATH/openjml.jar'

  运用.bash_profile文件:

source ~/.bash_profile

  这样,我们就可以通过openjml命令来运行OpenJML了。

openjml <options> <files>

  除了自定义命令行指令外,我们还可以直接在IDEA中通过External Tools配置OpenJML。

 1.2 静态检查(Extended Static Checking)

  Type-checking仅对JML的语法进行检查,对于规格内容和代码是否符合规格要求时不加检查的,如果需要检查规格内容,就需要用到静态检查(Extended Static Checking)和运行时检查(Runtime Assertion Checking)。

  SMT solver

  SMT是一款基于数理逻辑的用于自动化验证函数正确性的工具,其支持多平台的SMT解释器(SMT-Solver),其中应用最广泛的平台之一是微软所研发的 z3。

  从官方下载的OpenJML文件夹下已经为我们提供了不同平台不同版本的SMT solver。因为SMT solver并不属于OpenJML的一部分,因此在进行静态检查的时候我们需要告知OpenJML需要使用的solver名称以及路径。

openjml -esc -prover provername -exec solver_path <options> <files>

 1.3 运行时检查(Runtime Assertion Checking)

  执行运行时检查的时候,首先会将被检查的代码编译成可RAC的.class文件,然后运行程序观察是否断言有冲突。

openjml -rac <options> <files>
java -cp FILE_PATH:$OPENJML/jmlruntime.jar <.class>

  对于JML规格的检查,一般可以使用静态检查。使用静态检查对Group中的JML规格进行检查,结果如下:

  可以发现规格中存在两个错误,原因是OpenJML并不不支持三目运算符,为了后续的测试,我们需要将getAgeMeangetAgeVar两个方法的规格做如下修改。

/*@ public normal_behavior
      @ requires people.size() > 0;
      @ ensures  
esult == ((sum int i; 0 <= i && i < people.size(); 							@												people.get(i).getAge()) / people.size());
      @ also
      @ public normal_behavior
      @ requires people.size() == 0;
      @ ensures 
esult == 0;
      @*/
public /*@pure@*/  int getAgeMean() 
  
/*@ public normal_behavior
      @ requires people.size() > 0;
      @ ensures  
esult == ((sum int i; 0 <= i && i < people.size();
      @          ((people.get(i).getAge() - getAgeMean())*(people.get(i).getAge() - getAgeMean()))) /
      @           people.size());
      @ also
      @ public normal_behavior
      @ requires people.size() == 0;
      @ ensures 
esult == 0;
      @*/
    public /*@pure@*/ int getAgeVar()

2. OpenJML与JMLUnitNG联合进行自动单元测试

  使用MyGroup.java代码来进行JMLUnitNG自动单元测试。为了测试方方便,对规格和代码进行了一定的删改。

  运行JMLUnitNG.jar。

java -jar jmlunitng-1_4.jar MyGroup.java

  编译当前目录下的所有.java文件。

javac -cp jmlunitng-1_4.jar *.java
openjml -rac MyGroup.java MyPerson.java

  执行完这一步之后,工程文件目录树如下:

  可以看到JMLUnitNG基于方法规格自动生成了测试文件。

  运行生成的测试代码。

 java -cp jmlunitng-1_4.jar MyGroup_JML_Test

  测试结果如下:

  从JMLUnitNG生成的测试样例来看,大多测试的都是边界数据。因此JMLUnitNG可以作为一个提醒程序员考虑数据边界的辅助测试工具,但是程序正确性的保证,还是需要JUnit进行单元测试以及考虑自动或手动构造更多的测试样例。

3. Junit

  这次作业使用了Junit对部分方法进行了单元测试。使用Junit进行单元测试,在后期进一步优化代码的时候可以方便地进行回归测试,保证了方法实现的正确性。

三. 架构设计分析

第一次作业

  第一次作业较为简单,对性能分没有要求,因此我仅是普通地按照JML规格描述实现了方法,isCirclr方法采用DFS进行查找。

第二次作业

  第二次作业对性能分有了要求,因此我对第一次作业对方法实现进行了部分重构,并且进行了优化。

  • 为了提高访问效率,采用了Hashmap,并且对其大小进行了初始化,防止扩容浪费时间。
  • isCircle使用了并查集,利用Hashmap存储每一个人的祖先。每次新增人的时候,将新增的人加入祖先队列,祖先设置为自己。每一次新增关系的时候,将两棵树进行合并。并且在每次寻找祖先的时候进行路径压缩,进一步减小算法时间复杂度。
 public int getRoot(int id) {
        int root = id;
        int son = id;
        int temp;
        while (root != roots.get(root)) {
            root = roots.get(root);
        }
        while (son != root) {
            temp = roots.get(son);
            roots.put(son, root);
            son = temp;
        }
        return root;
    }

    public void mergeRoot(int id1, int id2) {
        int root1 = getRoot(id1);
        int root2 = getRoot(id2);
        if (root1 == root2) {
            return;
        }
        roots.put(root2, root1);
    }
  • 在MyGroup中,有很多双重循环的方法,如果按照JML规格写,会造成TLE。为了解决这个问题,我采用了缓存的方法,在每次新增人的时候,对relationSum,valueSum,conflictSum,ageSum和sqAgeSum进行更新。在使用缓存的方法时需要注意两个问题:

    • 在每一次Network新增关系的时候,需要对其所属Group中的relationSum和valueSum进行更新。

    • 在计算方差的时候,需要注意精度的问题。(讨论区有大佬分享了这个问题)

          public int getAgeVar() {
              int mean = getAgeMean();
              int peopleSum = people.size();
              if (peopleSum == 0) {
                  return 0;
              }
              return (sqAgeSum - 2 * mean * ageSum +
                      peopleSum * mean * mean) / peopleSum;
          }
      

第三次作业

  第三次作业相比前两次,对性能的要求更高了,作业的难度主要在于最短路径算法和点双连通分量算法。

  最短路径

  最短路径算法我采用了Dijkstra算法,并且进行了堆优化,利用PriorityQueue容器来实现堆。

  点双连通分量

  求点双连通分量时,我采用了递归的Tarjan算法,在每一次求出点双连通分量并弹栈时判断两个点是否同时包含在这个点双连通分量中,并且需要特判一下该点双连通分量点数是否大于2,否则只包含两个点的分量不是点双连通分量。

  另外,为了避免MyNetwork类长度超出checkstyle的限制,我将Tarjan算法单独抽象出了一个类,具体实现如下:

public class Tarjan {
    private int clock;
    private HashMap<Integer, Boolean> visited;
    private HashMap<Integer, Integer> dfn;
    private HashMap<Integer, Integer> low;
    private HashMap<Integer, Integer> parent;
    private HashMap<Integer, Person> people;
    private Stack<Integer> stack;

    public Tarjan(HashMap<Integer, Person> people) {
        this.people = people;
        clock = 0;
        visited = new HashMap<>(1300);
        dfn = new HashMap<>(1300);
        low = new HashMap<>(1300);
        parent = new HashMap<>(1300);
        stack = new Stack<>();
    }

    public Person getPerson(int id) {
        return people.get(id);
    }

    public boolean queryBiconnected(int id1, int id2) {
        int biconnected = 0;
        stack.push((Integer) id1);
        visited.put(id1, true);
        if (tarjan(getPerson(id1), id1, id2)) {
            return true;
        }
        while (!stack.empty()) {
            int id = stack.pop();
            if (id == id1) {
                biconnected++;
            } else if (id == id2) {
                biconnected++;
            }
        }
        return biconnected == 2;
    }

    public boolean tarjan(Person person, int id1, int id2) {
        clock++;
        dfn.put(person.getId(), clock);
        low.put(person.getId(), clock);
        MyPerson myPerson = (MyPerson) person;
        HashMap<Integer, Person> acquaintance = myPerson.getAcquaintance();
        for (Person nextPerson : acquaintance.values()) {
            if (!visited.containsKey(nextPerson.getId())) {
                stack.push(nextPerson.getId());
                visited.put(nextPerson.getId(), true);
                parent.put(nextPerson.getId(), person.getId());
                if (tarjan(nextPerson, id1, id2)) {
                    return true;
                }
                int currentLow = Math.min(low.get(person.getId()),
                        low.get(nextPerson.getId()));
                low.put(person.getId(), currentLow);
                if (low.get(nextPerson.getId()) >= dfn.get(person.getId())) {
                    if (popStack(person.getId(), nextPerson.getId(),
                            id1, id2)) {
                        return true;
                    }
                }
            } else if (parent.get(person.getId()) != null
                    && parent.get(person.getId()) != nextPerson.getId()) {
                int currentLow = Math.min(low.get(person.getId()),
                        dfn.get(nextPerson.getId()));
                low.put(person.getId(), currentLow);
            }
        }
        return false;
    }

    public boolean popStack(int currentPerson, int nextPerson,
                            int id1, int id2) {
        int biconnected = 0;
        int length = 0;
        while (stack.peek() != nextPerson) {
            int id = stack.pop();
            length++;
            if (id == id1) {
                biconnected++;
            } else if (id == id2) {
                biconnected++;
            }
        }
        int id = stack.pop();
        length++;
        if (id == id1) {
            biconnected++;
        } else if (id == id2) {
            biconnected++;
        }
        length++;
        if (currentPerson == id1) {
            biconnected++;
        } else if (currentPerson == id2) {
            biconnected++;
        }
        return biconnected == 2 && length > 2;
    }
}

四. BUG分析

  前两次作业并没有出现bug,房间也比较安静,唯一是第二次作业有一个同学因为Group中没用进行缓存处理,所以被卡了TLE。

  第三次作业强测TLE了三组,被卡的是queryMinPath这一个方法,经bug修复检查之后发现是因为MyPerson中存储acquaintance的hashmap初始容量开得太大,并且queryMinPath中使用了valueSet对其进行遍历,导致queryMinPath这个方法代码复杂度增大,从而TLE。

  所以,容器不能盲目使用,一定要理解其本质!!!

  BUG修复的手段如下:

  • 减小acquaintance的hashmap初始容量(修复问题的主要手段)。
  • queryMinPath中使用了entrySet对其进行遍历。
  • 对代码结构进行了一些改进,distance使用getOrDefault代替containKey的判断。
  • 对queryMinPath中的局部变量的hashmap进行了初始化(其实没有什么用)。

五. 体会与感想

  • JML——万物皆可形式化:规格是个好东西,可以清晰地描述方法的功能和需求,避免了自然语言描述中的歧义,尤其适合运用在团队开发中。并且规格表述信息冗余较小,可以以简短的表述完整地对方法需求进行描述。
  • OpenJML与JMLUnitNG——使用体验很糟,但出发点很好:这次课程组推荐的两个JML工具OpenJML和JMLUnitNG,相信大家使用下来都会有一个想法:令人头秃。这两个古早工具因为开发时的java版本太老,对很多java和JML新语法都不支持,并且对IDEA的支持不是很好,所以使用体验极差。但是这两个工具的出发点确实很好,因为规格已经是一个形式化的语言了,所以我们可以直接基于规格来对代码进行自动测试,很大程度上能够辅助我们进行测试,如果使用体验感再好的一点的话,确实不失为一个好工具。可惜就是太古早,尤其是JMLUnitNG,最新版本已经是六七年前的了,或许是因为实际测试中情况比较复杂,要做到真正的自动测试确实比较困难,但是这个工具的想法是真的好,有一点好奇到底为什么这个工具没有再进一步完善?
  • 使用容器时一定要深入理解容器本身:在这次作业之前,我对于java容器都是大概知道是什么,怎么用就OK了,但是在这次作业中就因为对容器的理解不够栽了跟头。所以在使用容器之前一定要深入理解这个容器,最好翻一翻源码看看这个容器是怎么实现的,切勿盲目使用!
原文地址:https://www.cnblogs.com/liujiahe0v0/p/ljh_BUAA_OO_Unit3_Summary.html