目录
【面向对象】第三单元总结-JML规格设计
一、梳理JML语言的理论基础、应用工具链情况
面向对象分析和设计的原则之一就是应当尽可能地把过程设想往后推。我们大多数人只在实现方法之前遵守这一规则。一旦确定了类及其接口并该开始实现方法时,我们就转向了过程设想。和大多数语言一样,编写 Java 代码时,我们需要为计算每个方法的结果一步一步地提供过程。
就其本身而言,过程化表示法只是说 如何做某事,却不曾说过我们正在设法做什么。在动手之前了解我们想要取得的结果,这可能会有用,但是 Java 语言没有提供显式地将这种信息合并到代码中的方法。
Java 建模语言(JML)将注释添加到 Java 代码中,这样我们就可以确定方法所执行的内容,而不必说明它们如何做到这一点。有了JML,我们就可以描述方法预期的功能,无需考虑实现。通过这种方法,JML 将延迟过程设想的面向对象原则扩展到了方法设计阶段。
JML 为说明性的描述行为引入了许多构造。这些构造包括模型字段、量词、断言的可见度范围、前提条件、后置条件、不变量、合同继承以及正常行为与异常行为的规范。这些构造使得 JML 的功能变得非常强大,但是没必要理解或使用所有这些构造,也没必要马上使用所有的构造。您可以从简单的起点开始逐步学习和使用 JML。
- JML 概述
使用 JML 来说明性地描述所希望的类和方法的行为,可以显著地改善整个开发过程。将建模表示法添加到 Java 代码中,其好处包括以下几点:
能更加精确地描述代码所完成的任务
能有效地发现和纠正错误
能减少随着应用程序的进展而引入错误的机会
能较早地发现客户没有正确使用类
能产生始终与应用程序代码保持同步的精确文档
JML 注释始终位于 Java 注解(comment)内部,因此它们不会对进行正常编译的代码产生影响。当我们想将类的实际行为与其 JML 规范进行比较时,可以使用开放源码 JML 编译器。用 JML 编译器编译过的代码如果没有做到规范中规定它应该做的事,那么该代码在运行时会抛出 JML 异常。这不仅能捕获代码中的错误,还能确保文档(JML 注释格式)与代码保持同步。
JML语法简介
原子表达式
\result
:表示一个非 void 类型的方法执行所获得的结果,即方法执行后的返回值。\old(expr)
:表示一个表达式expr在相应方法执行前的取值,该表达式涉及到评估expr中的对象是否发生变化。
如果是引用(如hashmap),对象没改变,但进行了插入或删除操作。v和odd(v)也有相同的取值。\not_assigned(x,y,...)
:用来表示括号中的变量是否在方法执行过程中被赋值。如果没有被赋值,返回为true ,否则返回 false 。用于后置条件的约束,限制一个方法的实现不能对列表中的变量进行赋值。\not_modified(x,y,...)
:该表达式限制括号中的变量在方法执行期间的取值未发生变化。\nonnullelements(container)
:表示container对象中存储的对象不会有null。\type(type)
:返回类型type对应的类型(Class),如type(boolean)为Boolean.TYPE。TYPE是JML采用的缩略表示,等同于Java中的 java.lang.Class。\typeof(expr)
:该表达式返回expr对应的准确类型。如\typeof(false)为Boolean.TYPE。量化表达式
\forall
:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。\exists
:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。\sum
:返回给定范围内的表达式的和。方法规格
定义前置条件和满足后置条件的东西。前置条件:对方法输入参数的限制,如果不满足前置条件,方法执行结果不可预测,或者说不保证方法执行结果的正确性。requires P;其中requires是JML关键词,表达的意思是“要求调用者确保P为真”。多个分开的requires是并列关系都要满足,或关系用requires P1||P2;
后置条件:对方法执行结果的限制,如果执行结果满足后置条件,则表示方法执行正确,否则执行错误。其中ensures是JML关键词,表达的意思是“方法实现者确保方法执行返回结果一定满足谓词P的要求,即确保P为真”。并列关系和或关系与前置相同。ensures P;
副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。JML提供了副作用约束子句,使用关键词assignable(表示可赋值)或者modifiable(可修改)。
signals (Exception e) b_expr;
signals_only (Exception e);
强调满足前置条件抛出相应异常。类型规格
不变式invariant
状态变化约束constraint
- 应用工具链
- 使用OpenJML检查JML规格的正确性,包括JML语法静态检查,代码静态检查,运行时检查。
- 使用JML UnitNG根据JML语言自动生成测试检查代码正确性。
- 使用SMT Solver验证代码等价性。
二、JMLUnitNG/JMLUnit应用
JMLUnitNG
public class Demo { /*@ public normal_behaviour @ ensures \result == a + b; @*/ public static int add(int a, int b) { return a + b; } public static void main(String[] args) { add(123,321); } }
测试结果:
[TestNG] Running: Command line suite Passed: racEnabled() Passed: constructor Demo() Passed: static main(null) Passed: static main({}) Passed: 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) Passed: static add(2147483647, 2147483647) =============================================== Command line suite Total tests run: 13, Failures: 0, Skips: 0 ===============================================
分析:
主要是对int型数据的边界(正、负、0)情况进行测试。JUnit测试
编写测试类:
示例:public void containsNode() { try { Graph graph = new MyGraph(); int[] nodes = new int[]{-1, -2, -3, -4, -5}; Path path = new MyPath(nodes); graph.addPath(path); assertTrue(graph.containsNode(-1)); assertTrue(graph.containsNode(-5)); assertTrue(graph.containsNode(-3)); assertFalse(graph.containsNode(0)); } catch (Exception e) { e.printStackTrace(); } }
测试结果:
三、三次作业架构设计及迭代中对架构的重构
第一次作业
TLE版
代码实现完全参照所给的JML注释,容器和路径实现均采用
ArrayList
数组,查询操作使用暴力for循环进行遍历,强测直接开花。MyPath:
private ArrayList<Integer> nodeArrayList;
MyPathContainer:
private ArrayList<Path> conArrayList; private ArrayList<Integer> idArrayList; private static int id = 1;
修复版
MyPath:
private ArrayList<Integer> nodeArrayList; private int result = 0; private boolean flag = false;
MyPathContainer:
建立两个映射表
conArrayList
和idArrayList
来映射路径和路径id的关系,difnode
映射了结点和结点对象(成员变量为结点大小,结点数目)之间的关系,用来存储不同结点的各自映射关系,size大小即为容器内不同结点的数目。private HashMap<Integer, Path> conArrayList; private HashMap<Path, Integer> idArrayList; private static int id = 1; private HashMap<Integer, SingleNode> difnode = new HashMap<Integer, SingleNode>();
新增类
SingleNode
实现对相同结点数目的存储。SingleNode:
private int node; private int num;
当图结构发生改变时,根据操作的不同,若为
addPath
操作,则根据映射关系加入difnode
,removePath
时则减小对应结点的数目,若为0则直接删除该结点。具体实现如下:for (int i = 0; i < path.size(); i++) { int tnode = ((MyPath)path).getNode(i); if (!difnode.containsKey(tnode)) { SingleNode tempNode = new SingleNode(tnode); difnode.put(tnode, tempNode); } else { if (kind == sub) { difnode.get(tnode).subNum(); if (difnode.get(tnode).getNum() == 0) { difnode.remove(tnode); } } else { difnode.get(tnode).addNum(); } } }
修复版类图
第二次作业
架构设计
MyPath
类沿用了第一次作业的代码实现。MyGraph
在MyPathContainer
的基础上做了一些改动。如下:private HashMap<Integer, Path> pathList; private HashMap<Path, Integer> pidList; private static int id = 1; private HashMap<Integer, SingleNode> difnode = new HashMap<Integer, SingleNode>(); private static int add = 1; private static int sub = -1; private int inf = 99999999; private int max = 250; private int[][] edge = new int[260][260]; private int[][] numedge = new int[260][260]; private boolean first = true; private int index = 1; private HashMap<Integer, Integer> toindex = new HashMap<Integer, Integer>();
最短路径算法采用Floyd算法,
edge
数组用来存储图的最短路径矩阵,numedge
数组则存储了图中存在的边的数目,考虑到存在结点为负的情况,不能根据数组下标直接查询一对结点构成的边的情况,使用toindex
建立了结点与index(正整数,从1开始递增)的映射关系。相比较第一次作业,第二次作业最大的改动在于加入了最短路径和连通性的求解。
比较了Dijkstra算法和Floyd算法后果断的选择了Floyd算法进行计算。
主要原因是:
Floyd简单
计算策略为:
在
addPath
直接加入容器,调用Floyd算法计算,刷新最短路径矩阵;在
removePath
和removePathById
时初始化数组,从容器内重新添加路径进行计算。最短路径的查询和连通性的查询只用根据
toindex
映射关系找到对应edge
数组的下标进行查询。类图:
第三次作业
架构设计
相比较前两次作业,第三次作业的难点在于算法的实现。最小票价,最小换乘次数,最小不满意度等等都是在考察算法。
MyGraph:
private int[][] edge = new int[maxnum][maxnum]; private int[][] numedge = new int[maxnum][maxnum]; private int[][] transfer = new int [maxnum][maxnum]; private int[][] tempminprice = new int [maxnum][maxnum]; private int[][] tempunhappy = new int [maxnum][maxnum]; private int[][] minprice = new int [maxnum][maxnum]; private int[][] minunhappy = new int [maxnum][maxnum];
MyRailwaySystem:
public class MyRailwaySystem implements RailwaySystem { private MyGraph graph = new MyGraph(); …… }
为了避免麻烦,我将所有的方法实现都写在了
Graph
类里,MyRailwaySystem
类只是Graph的外部方法接口。具体可见类图。连通块数目计算策略:
采用并查集的方法实现。
…… if (connect) { return total; } total = difnode.size(); for (int i = 1; i <= difnode.size(); i++) { pre[i] = i; } for (java.util.Map.Entry<Integer, Path> entry:pathList.entrySet()) { checkPath(entry.getValue()); } connect = true; return total; ……
对于已经查询过,没有增减路径的图直接返回记录下来的值;
对于被修改过的图,初始化并数组,从容器内重新添加边进行计算。
最小票价,最短路径,最小不满意度,最小换乘计算策略:
参考讨论区大佬算法(致谢):
对于LeastTransfer,构建一个图,首先把一个Path中所有边之间的weight全部设置为0,为体现换乘数,再将所有weight为0的边权值加1得到finalWeight。这样的话,我们从节点i1到节点i2的搭乘线路数就是以finalWeight为权值的图的最短路径,最小换乘数=最短线路数-1.
对于LeastPrice,构建图,首先把一个Path中所有边的weight如楼主设置(x),为体现换乘数,再将所有设置过weight的边权值加2得到finalWeight(2y)。最低票价 = 以finalWeight为权值最短路径 - 2。
对于LeastUnpleasantValue,构建图,首先把一个Path中所有点连通,边之间weight设置为实际从一站到另一站的总unpleasantValue,将所有设置过weight的权值加32得到finalWeight。最低不满意度 = 以finalWeight为权值最短路径-32。
建立
tempminprice
和tempunhappy
临时数组,在增减路径时进行对路径的计算,调用Floyd算法,存储单个路径内最小路径的矩阵,然后将单个路径矩阵同一复制到大图的最短路径矩阵内调用Floyd进行计算。在
addPath
、removePath
和removePathById
时初始化数组,从容器内重新添加路径进行计算。最短路径的查询和连通性的查询只用根据
toindex
映射关系找到对应edge
数组的下标进行查询。类图
四、按照作业分析代码实现的bug和修复情况
第一次作业
第一次作业由于个人头太铁,方法实现完全采用ArrayList和暴力for循环,强测互测两开花。
bug修复完全推翻了之前的设计,采用hashmap和迭代器映射关系和搜索对应项,哭着修完了数十个TLEbug。
架构设计参考上文。
第二次作业
公测有三个点wa了,互测没有发现bug。查完标准输出后发现所有错误均是We expected "Ok, length is xxx." but we got "Ok, length is 0." 原因是使用Floyd计算数组边界使用当前index号,导致遍历计算是全部置0。
第三次作业
公测和互测均无bug
五、阐述对规格撰写和理解上的心得体会
相对于前两个单元的作业,本单元的作业确实压力小很多,具体的算法实现并不难,重点在于怎样才能不TLE
理解JML规格设计,理解写好的JML注释,使自己的代码符合规范,保证正确性。
JML规格可以避免自然语言的二义性,自然语言的解释歧义,表述模糊完全可以通过数学语言解释清楚,保证了编写代码的严谨性和正确性。前两次作业是完全按照所给的规格规范实现的,可以很好的帮助整理思路。而第三次作业给的JML规格注释并不像前两次一样落在实处,这也正反应出JML规格对于功能复杂的函数,想要完全依靠数学语言表述清楚还是比较繁琐的。
随着OO课程的不断学习,我越发感觉到,代码实现其实是最后的,占时间最少的,真正需要花费时间的是思考作业的架构设计,实现策略,这也正是JML规格设计所需要的。
最后,Junit测试也是很有效的一种测试方法,构造测试样例能对自己的方法进行覆盖性的测试。