面向对象第三单元:规格化的面向对象设计方法
一、JML语言
1、JML语言简介
JML(Java Modeling Language)是用于对Java程序进行规格化设计的一种表示语言。JML是一种行为接口规格语言(Behavior Interface Specification Language,BISL),基于Larch方法构建。BISL提供了对方法和类型的规格定义手段。所谓接口即一个方法或类型外部可见的内容。通过JML及其支持工具,不仅可以基于规格自动构造测试用例,并整合了SMT Solver等工具以静态方式来检查代码实现对规格的满足情况。
一般而言,JML有两种主要的用法:
① 开展规格化设计。这样交给代码实现人员的将不是可能带有内在模糊性的自然语言描述,而是逻辑严格的规格。
② 针对已有的代码实现,书写其对应的规格,从而提高代码的可维护性。这在遗留代码的维护方面具有特别重要的意义。
JML以javadoc注释的方式来表示规格,每行都以@起头。有两种注释方式,行注释和块注释。其中行注释的表示方式为 //@annotation ,块注释的方式为 /* @ annotation @*/ 。按照Javadoc习惯,JML注释一般放在被注释成分的近邻上部。
JML有一些常用且重要语句:
(1)requires子句定义该方法的前置条件(precondition),即方法的输入应满足的要求。
(2)副作用范围限定,assignable列出这个方法能够修改的类成员属性。\nothing是个关键词,assignable \nothing表示这个方法不对任何成员属性进行修改,所以是一个pure方法。
(3)ensures子句定义了后置条件,即方法执行后的结果。
原子表达式:
(4)\result表达式:表示一个非 void 类型的方法执行所获得的结果,即方法执行后的返回值。\result表达式的类型就是 方法声明中定义的返回值类型。
(5)\old( expr )表达式:用来表示一个表达式 expr 在相应方法执行前的取值。
(6)\not_assigned(x,y,...)表达式:用来表示括号中的变量是否在方法执行过程中被赋值。如果没有被赋值,返回为
true ,否则返回false 。
(7)\not_modified(x,y,...)表达式:与上面的\not_assigned表达式类似,该表达式限制括号中的变量在方法执行期间的取 值未发生变化。
(8)\type(type)表达式:返回类型type对应的类型(Class)。
(9)\typeof(expr)表达式:该表达式返回expr对应的准确类型。
量化表达式:
(10)\forall表达式:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。
(11)\exists表达式:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。
(12)\sum表达式:返回给定范围内的表达式的和。
(13)\product表达式:返回给定范围内的表达式的连乘结果。
(14)\max表达式:返回给定范围内的表达式的最大值。
(15)\min表达式:返回给定范围内的表达式的最小值。
(16)\num_of表达式:返回指定变量中满足相应条件的取值个数。
2、JML工具链
JML有与之相关的一整套工具链。如OpenJML能够整合SMT solver等其他工具对代码是否符合JML规格进行静态检查。JMLUnit等工具可以自动生成与JML规格相对应的测试样例来对规格实现情况进行动态检查。总的来说,通过这些工具可以确保我们规格实现的正确性,由此确定我们过程性的模块的正确性,进而使我们对象设计拥有稳定安全的基础。
OpenJML
OpenJML可以根据JML对实现进行静态的检查,其中用于进行JML分析的部分在solvers中,常见的包括cvc4以及z3
JMLUnitNG
可以根据JML自动生成对应的测试样例,进行单元化测试。
二、JUnit测试用例分析
尝试探索JMLUnitNG的使用方法,对如下代码进行测试:
public class Demo {
/*@ public normal_behaviour
@ ensures \result == lhs - rhs;
*/
public static int compare(int lhs, int rhs) {
return lhs - rhs;
}
public static void main(String[] args) {
compare(114514,1919810);
}
}
1、生成测试文件:通过指令java -jar jmlunitng.jar Demo.java
生成测试用例。
2、用 javac 编译 JMLUnitNG 的生成文件:通过指令javac -cp jmlunitng.jar D*.java
,生成带有运行时检查的 class 文件。
3、运行文件:通过指令java -cp jmlunitng.jar Demo_JML_Test
运行,测试结果如下:
[TestNG] Running:
Command line suite
Passed: racEnabled()
Passed: constructor Demo()
Passed: static compare(-2147483648, -2147483648)
Failed: static compare(0, -2147483648)
Failed: static compare(2147483647, -2147483648)
Passed: static compare(-2147483648, 0)
Passed: static compare(0, 0)
Passed: static compare(2147483647, 0)
Failed: static compare(-2147483648, 2147483647)
Passed: static compare(0, 2147483647)
Passed: static compare(2147483647, 2147483647)
Passed: static main(null)
Passed: static main({})
===============================================
Command line suite
Total tests run: 13, Failures: 3, Skips: 0
===============================================
从测试样例可见,使用JUnit进行测试,测试强调了方法的鲁棒性。对整数范围内的最大值、最小值、0、空值及其组合都进行了测试。
三、作业递进架构
作业一:MyPathContainer架构
第一次作业,需要完成的任务为实现两个容器类Path和PathContainer。最终实现的是一个路径管理系统。可以通过各类输入指令来进行数据的增删查改等交互。
MyPath类有两个属性:trajectory属性为一个ArrayList,即对应构造方法中传入的nodeList;set属性为一个HashSet,是轨迹中编号不同的点的集合。
MyPathContainer类有如下四个属性。dataset是一系列Path对象组成的ArrayList,用来记录容器中的所有轨迹。id是一系列pathId组成的ArrayList,index为i的pathId对应dataset中index为i的path。nodeCounter属性是一个HashMap,用来记录容器中不同节点出现的个数。edgeCounter属性也是一个HashMap,用来记录容器中不同边出现的个数。
nodeCounter属性和edgeCounter属性都是为了应对DISTINCET_NODE_COUNT指令所生成的私有属性。
并且,nodeCounter属性和edgeCounter属性这两个属性基本建立了一张图,这对于作业二和作业三的拓展也是大有裨益的。值得注意的是,edgeCounter属性在本次作业的方法中并没有应用,只是为了可扩展性的架构添加的属性。在本文下一段中可以发现edgeCounter属性在后续作业中求最短路径发挥了重要作用。
本次作业的类图如下:
作业二:MyGraph架构
第二次作业,需要完成实现容器类Path和数据结构类Graph,最终需要实现一个无向图系统。可以像PathContainer一样,通过各类输入指令来进行基于路径的增删查改管理。还可以将内部的Path构建为无向图结构,进行基于无向图的一些查询操作。
由于第一次作业可扩展性架构做得很好,第二次作业只加了get_shortest_distance这一个最主要函数,所以第二次作业做起来较为轻松,第一周在架构和设计上花费的时间在第二周得到了回报。
MyPath类没有变化。MyGraph类在MyPathContainer类的基础上变化也不大,延用第一次作业的nodeCounter属性和edgeCounter属性这两个属性基本构建图的结构。nodeCounter属性用来记录容器中不同节点及其出现次数。edgeCounter属性用来记录容器中不同边及其出现次数。图的构建完毕。
针对最短路径的计算,加入distance属性。distance属性是一个HashMap,key值是无向图中的节点编号,value值是一个从节点编号映射到最短距离的HashMap。distance记录了从节点到其可达节点的最短距离。
最短距离算法:考虑到相邻节点之前的距离为1,即这是一幅权重全为1的特殊无向图。于是,BFS广度优先搜索就可以派上用场,而不必应用Dijkstra算法。从某一节点fromNode出发对图进行广度优先搜索,搜索到的节点顺序其实与Dijkstra算法搜索的节点顺序是一致的,这就保证了算法的正确性。所以以某一节点fromNode出发对图进行一次BFS搜索算法,可以得到fromNode到图中所有结点的最短距离。
为了节约CPU运行时间,并不是每次addPath或removePath时都进行一遍图的广度优先搜索。每次用户进行 get_shortest_distance 查询时,先查询distance属性中是否有与fromNodeId相同的键值key,若有,则表示之前以fromNode为出发点进行过BFS搜索,则直接读取相应结果,省去了BFS搜索的时间;若没有,再进行以fromNode为起点的BFS搜索,并加搜索结果加入distance属性中。换言之,distance属性并不记录无向图中所有起点到其它所有点的最短距离,而只是记录一部分起点到其它所有点的最短距离。
进行一次BFS搜索的代码如下:
LinkedList<Integer> q = new LinkedList<>();
HashMap<Integer, Integer> map = new HashMap<>();
q.addLast(fromNode);
map.put(fromNode, 0);
while (q.size() != 0) {
int node = q.removeFirst();
int d = map.get(node) + 1;
HashMap<Integer, Integer> graph = edgeCounter.get(node);
for (Integer c : graph.keySet()) {
if (!map.containsKey(c)) {
map.put(c, d);
q.addLast(c);
}
}
}
第二次作业的类图如下,由于整体架构改变不大,类图差异很小。
作业三:MyRailwaySystem架构
第三次作业需要完成实现容器类Path和地铁系统类RailwaySystem,最终需要实现一个简单地铁系统。可以像PathContainer一样,通过各类输入指令来进行基于路径的增删查改管理;还可以将内部的Path构建为无向图结构,进行基于无向图的一些查询操作;再可以构建一个简单的RailwaySystem地铁系统,进行一些基本的查询操作。
从三次作业的功能就可以看出是不断递进的关系。第三次作业在第二次作业基础上主要增加了查询最低票价、最小换乘次数、最小不满意度的计算。由于是需求是不断递进关系,每次的作业其实都可以继承上一次作业的类。例如,我在此次作业实现MyRailwaySystem类时,就继承了第二次作业的Graph类,因为除了功能在递进外,本质上地铁系统图就是一个更具体的无向图。
public class MyRailwaySystem extends MyGraph implements RailwaySystem {
// TODO : IMPLEMENT
}
MyPath类没有变化。而MyRailwaySystem类随继承了MyGraph类,但仍然针对最低票价、最小换乘次数、最小不满意度的查询功能增加了私有属性。nodeSet属性和edgeSet属性存储了地铁图结构,price、transfer、unpleasant三个HashMap属性原理与第二次作业中distance属性类似,分别记录了从结点到图中所有其他结点的最低票价、最小换乘和最小不满意度。与distance属性类似,为了节约CPU运行时间,price、transfer、unpleasant三个属性并不记录无向图中所有起点到其它所有点的最低票价、最小换乘和最小不满意度,而只是记录一部分起点到其它所有点的最低票价、最小换乘和最小不满意度。
最低票价、最小换乘和最小不满意度算法:最低票价、最小换乘和最小不满意度算法原理完全相同,都是有向图的最短路径问题,只是各自的边权重不一样。这里以最小不满意度算法具体展开。由于边权重不再全是1,所以不能简简单单像第二次作业一样使用BFS搜索算法。我这次采用了传统的Dijkstra算法,但又与传统的Dijkstra算法稍有不同。由于引入了换乘,换乘的不满意度不为0,传统的Dijkstra算法不再具有最优子结构,我的解决方法是对地铁图做一定的改进,采用拆点的做法。具体来说:对于每个地铁站node,拆分成2+x个点,其中x为经过这个地铁站的Path数。其中,前两个点为抽象出来的起点和终点,用于解决换乘和答案统计,后面的x个点可以理解为每个Path在地铁站node的站台。之后是连边,设一条Path中相邻两个点的边权为x(双向边),每个地铁站的终点到起点(没有写错)连边的边权为y(换乘,单向边)。x,y的值由需求决定(即四种图)。每个站台往它所在的终点连边的边权为0,每个地铁站的起点往它的每个站台连边的边权为0,图即建好。换言之,将图结构进行了改进后,图即变成了一个普通的有向图,传统的Dijkstra算法在求解最小不满意度时由于具有了最优子结构,可以发挥作用。求从一个点fromNode到图中所有点的最小不满意度的算法伪代码如下:
HashMap<Node, Integer> cost = new HashMap<>();
HashSet<Node> unprocessed = new HashSet<>();
HashMap<Node, Integer> straight = edgeSet.get(fromNode);
for (Node key : edgeSet.keySet()) {
if (straight.containsKey(key)) {
cost.put(key, straight.get(key));
} else {
cost.put(key, inf);
}
unprocessed.add(key);
}
cost.put(fromNode, 0);
unprocessed.remove(fromNode);
while (unprocessed.size() > 0) {
Integer lowest = inf;
Node lowestNode = null;
for (Node n : unprocessed) {
Integer val = cost.get(n);
if (val < lowest) {
lowest = val;
lowestNode = n;
}
}
if (lowestNode == null) {
break;
}
unprocessed.remove(lowestNode);
Integer nodeCost = cost.get(lowestNode);
HashMap<Node, Integer> neighbors = edgeSet.get(lowestNode);
for (Node neighbor : neighbors.keySet()) {
Integer stepCost = neighbors.get(neighbor);
Integer newCost = nodeCost + stepCost;
if (newCost < cost.get(neighbor)) {
cost.put(neighbor, newCost);
}
}
}
第二次作业的类图如下,可以看到由于三次作业的递进关系,MyRailwaySystem继承了MyGraph类,MyGrapgh又继承了MyPathContainer类,继承关系一目了然。其它结构与前两次作业基本相同。
四、Bug分析
1、错误一
输入:
PATH_ADD 1 2 3 4 5 6
PATH_ADD 3 6 9
PATH_COUNT
PATH_REMOVE_BY_ID 2
PATH_COUNT
输出:
Ok, path id is 1.
Ok, path id is 2.
Total count is 2.
Failed, path id not exist.
Total count is 1.
错误分析:
此Bug出在第三次作业,而用相同的测试样例输入第一、二次作业都不会产生Bug,于是分析Bug应该产生在第三次作业新增的属性更改上,即nodeSett或edgeSet属性。从两句PATH_COUNT可以看出,PATH_REMOVE_BY_ID语句确实使得容器内的轨迹数减了一,而PATH_REMOVE_BY_ID语句的执行结果却显示没有找到id为2的轨迹。
第三次作业MyRailwaySystem类继承了第二次作业MyGraph类。错误代码的MyRailwaySystem类中removePathById (int pathId)方法的执行可以分为三步:
① 调用父类MyGraph类的removePathById方法,将编号为pathId的路径从父类私有属性dataset, id, nodeCounter中移除;
② 在父类的dataset里找编号为pathId对应的path;
③ 将path从MyRailwaySystem类中新增的属性nodeSett和edgeSet中移除。
经过以上分析,Bug便比较明显了:既然第①步已经将编号为pathId的轨迹从dataset中移除了,第②步怎么可能再在dataset中查询到编号为pathId的路径呢?所以,修改此Bug的方式为将第①步与第②步调换顺序,先为步骤③查询出path,再删去dataset中的此路径。
2、错误二
输入:
PATH_ADD 10 9 8 7 6 6 6 6 5 5 5 5 4 3 2 1
PATH_ADD 10 9 8 7 6 6 5 5 5 4 3 2 1
PATH_REMOVE 10 9 8 7 6 6 5 5 5 4 3 2 1
输出:
Ok, path id is 1.
Ok, path id is 2.
......Exception: Null Pointer Exception......
错误分析:
错误产生在第三次作业。第三次作业架构中,地铁图的存储采用拆点的方式。在不同路径上的节点,即使节点编号相同,在新增的nodeSet和edgeSet中也认为是不同的节点,于是我便忽略了同一路径中也可能出现编号相同的节点这件事。每次从nodeSet和edgeSet中直接删除某节点时,直接删除所有以该节点为起点的边和所有以该节点为终点的边。所以当同一路径中的相同编号的节点再次出现时,就会出现Null Pointer Exception的异常。
正确做法是用nodeSet记录每个节点在图中的个数,只有删除节点时该节点在图中只剩一个时,再彻底删除该节点。
五、心得体会
第三单元主要练习的是规格化的面向对象设计方法,这四周的训练是令人收获颇丰的。跟学长了解到,之前OO课程并没有JML语言的学习,我不得不敬佩老师和助教对今年OO课程的改革,引入规格化的编程十分成功,更十分必要。
JML语言能很好的表示一个方法的前置条件和后置条件,让程序员和用户都能很明确直观地看到某方法的要求和执行结果。作为程序员,可以更清晰地指导我们如何编写一个函数;作为用户,可以更清晰地指导我们如何给方法提供输入数据。
这次的作业是逐级递进的。这与一般用户的需求相近,每一次的功能是单调递增的,而且具备一定的继承性。这个系列作业,从PathContainer,到Graph再到RailwaySystem,很形象地描述了实际OOP开发中,一个功能模块的演化过程。从低层次抽象,逐步形成一个面向实际需求的模块。这对我今后步入工作岗位或科学研究都是很有帮助的。
另外,每次做作业前应注意架构和设计,所有在设计上花费的时间都会在未来收到回报。不必着急开始动手编写代码,要事先考虑好图的结构用什么存储,最短路径用什么算法实现等等,此外还要考虑好结构的可扩展性。