有关JML
JML(Java Modeling Language) 是用于对 Java 程序进行规格化设计的一种表示语言,一般用来开展规格化设计、提高代码的可维护性等用途。
注释结构
JML 以 javadoc 注释的方式来表示规格,每行都以 @ 起头。有两种注释方式,行注释和块注释。其中行注释的表示方式为 //@annotation
,块注释的方式为 /* @ annotation @*/
。
JML表达式
原子表达式
\result
表达式:表示一个非 void
类型的方法执行所获得的结果,即方法执行后的返回值。例如,@ ensures \result == 0;
\old(expr)
表达式:用来表示一个表达式 expr
在相应方法执行前的取值。例如@ ensures people.length == \old(people.length);
\not_assigned(x,y,...)
表达式:用来表示括号中的变量是否在方法执行过程中被赋值。
量化表达式
\forall
表达式:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。例如,(\forall int i,j; 0 <= i && i < j && j < 10; a[i] < a[j])
。
\exists
表达式:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。例如,(\exists int i; 0 <= i && i < 10; a[i] < 0)
。
\sum
表达式:返回给定范围内的表达式的和。(\sum int i; 0 <= i && i < 5; i)
,这个表达式的意思计算 [0,5)
范围内的整数 i
的和,即 0 + 1 + 2 + 3 + 4 = 10
。
方法规格
前置条件(pre-condition)通过 requires
子句来表示:requires P;
。其中 requires
是 JML 关键词,表达的意思是“要求调用者确保P为真”。
后置条件 (post-condition)通过 ensures
子句来表示:ensures P;
。其中 ensures
是 JML 关键词,表达的意思是“方法实现者确保方法执行返回结果一定满足谓词P的要求,即确保 P
为真”。
设计策略
JML相比自然语言描述更加严谨,但也相对冗长,需要仔细阅读。自然语言通常能显式地描述类与方法、方法与方法之间的层次关系(如调用关系、继承关系)。但是JML通常不能很好地体现出上述关系,需要程序员自己去挖掘。
一些直白简单的JML可以“直译”,按照JML写可以将方法的执行逻辑、抛出异常的逻辑整理得更加清晰。但是我们需要注意,一些容器的选择、函数的实现方法不能按照JML无脑写,否则会使程序的性能大大降低。
比如说,JML定义了如下两个数组,但是实际上,我们可以用一个HashMap来存储这两个数组的内容(以Person为键值,以value为权值),这样能更好地体现映射关系,后续的查找、存储、删除操作也远比两个数组效率高。
@ public instance model non_null Person[] acquaintance; //熟人 @ public instance model non_null int[] value; //与各个熟人间的距离
另外,JML使用的方法可能具有较高的时间复杂度,如第二次作业中的getAgeMean,如果按照JML写,每次都将整个组遍历一遍,复杂度为O(n)。但是如果我们换一种思路,在每次addPerson的时候更新ageMean相关的值,就能将复杂度降为O(1),也能很好地完成JML要求的功能。因此,我们需要先通读一遍JML,理解其用意,再想想有没有化简策略。
基于JML规格设计测试的方法和策略
对于这一单元,笔者主要采取Junit框架进行测试。由于Junit测试是白盒测试,程序员需要准确地知道被测试的软件如何完成功能和完成什么样的功能。因此,在测试之前,我们需要准确理解JML语义,否则再怎么测也有偏差。
我们可以采用对Junit对每个方法进行测试,判断程序执行结果与预期是否相符,快速定位bug的位置。Junit测试的思路大体如下:
-
根据JML的前置条件设置对象的状态
-
调用被测试的方法并检查所有后置条件是否都满足
-
测试边界条件是否满足
下面我们以queryGroupValueSum()
方法为例,编写测试用例进行Junit测试。
@org.junit.jupiter.api.Test void queryGroupValueSum() throws EqualPersonIdException, PersonIdNotFoundException, EqualRelationException, EqualGroupIdException, GroupIdNotFoundException { //TODO: Test goes here... Group group1 = new MyGroup(1); Network network = new MyNetwork(); network.addPerson(new MyPerson(1,"Lucy",12)); network.addPerson(new MyPerson(2,"Sarah",12)); network.addRelation(1,2,3); network.addPerson(new MyPerson(3,"Sam",12)); network.addPerson(new MyPerson(4,"Tree",12)); network.addGroup(group1); network.addRelation(1,3,5); network.addRelation(2,3,2); network.addToGroup(1,1); Assert.assertEquals(network.queryGroupValueSum(1), 0); network.addToGroup(2,1); Assert.assertEquals(network.queryGroupValueSum(1), 6); network.addToGroup(3,1); Assert.assertEquals(network.queryGroupValueSum(1), 20); network.addToGroup(4,1); Assert.assertEquals(network.queryGroupValueSum(1), 20); network.addRelation(4,1,2); Assert.assertEquals(network.queryGroupValueSum(1), 24); network.addRelation(4,2,3); Assert.assertEquals(network.queryGroupValueSum(1), 30); network.delFromGroup(2,1); Assert.assertEquals(network.queryGroupValueSum(1), 14); network.delFromGroup(3,1); Assert.assertEquals(network.queryGroupValueSum(1), 4); }
另外,我们还可以手动捏造很长的数据来对程序进行压力测试,尤其是一些复杂度较高的函数,如queryBlockSum()
、sendIndirectMessage(int id)
等。另外,我们可以随机生成数据,采用和别人对拍的方式测试代码的正确性、性能以及鲁棒性。
容器的选择和使用
在本单元作业中,JML规格给出的数据存储方式都是静态数组,但显然我们选择静态数组存储数据不易维护而且性能较差,因此我们需要选择合适的容器对元素进行查找、插入、删除等操作。
对于具有一一对应关系的数据(person.getId()
和person
),建议使用HashMap存储,这样可以通过键值直接查找所需内容。下面是MyNetWork类中HashMap的使用情境。
private Map<Integer, Person> people = new HashMap<>(); private Map<Integer, Group> groups = new HashMap<>(); private Map<Integer, Message> idToMessage = new HashMap<>(); private Map<Integer, Integer> emojiIdToHeat = new HashMap<>();
另外,对于一些需要选择特定插入位置的数据,建议使用List进行存储,如Person
类中的messages
。
private List<Message> messages = new ArrayList<>();
因为SendMessage(int id)
需要将该message插入个人messages列表中的头部,而且getReceicedMessages()
中需要获取messages列表中的前四条消息,因此使用ArrayList比较合适。
@Override public List<Message> getReceivedMessages() { if (messages.size() <= 4) { return messages; } return messages.subList(0,4); }
另外,要注意数据的相关性,有时候可以将两个List合并为一个Map,如acquaintance
与value
可以合并为一个HashMap,以熟人为键值去查找value。我们之所以选择这么做,是因为这两个List的元素始终对应到同一个下标i,具有一一映射关系,故可以合并。
@ public instance model non_null Person[] acquaintance; @ public instance model non_null int[] value; /*@ public normal_behavior @ requires (\exists int i; 0 <= i && i < acquaintance.length; @ acquaintance[i].getId() == person.getId()); @ assignable \nothing; @ ensures (\exists int i; 0 <= i && i < acquaintance.length; @ acquaintance[i].getId() == person.getId() && \result == value[i]); @ also @ public normal_behavior @ requires (\forall int i; 0 <= i && i < acquaintance.length; @ acquaintance[i].getId() != person.getId()); @ ensures \result == 0; @*/ public /*@pure@*/ int queryValue(Person person);
性能问题
第一次作业
第一次作业的性能问题主要涉及isCircle(int id1, int id2)
(判断两个人之间是否存在一条路径相连)和queryBlockSum()
(查询关系网中连通块的个数)。通过交流,笔者发现实现这两个方法的主要算法有深度优先搜索算法、广度优先搜索算法、并查集算法等。笔者采用了朴素的深度优先方法进行求解,代码如下。
public void dfs(int id) { visited.put(id, true); MyPerson target = (MyPerson) getPerson(id); for (Person person : target.getLinks().keySet()) { if (visited.get(person.getId()) == false) { dfs(person.getId()); } } }
第二次作业
第二次作业的性能问题主要出在getValueSum()
和getAgeMean()
上,如果每次给出qgvs和qgam指令的时候都重新计算组内的距离总和平均年龄,就会让时间复杂度至少变为O(n)。而如果我们每次addToGroup(int id1, int id2)
和addRelation(int id1, int id2, int value)
的时候都更新组内距离总和和年龄总和的值,然后在给出qgvs和qgam指令时,就不需要进行遍历计算,时间复杂度就会降低。
我们以Group类中的addPerson()为例,给出优化方式:
@Override public void addPerson(Person person) { for (Integer id : people.keySet()) { valueSum = valueSum + people.get(id).queryValue(person) * 2; } valueSum += person.queryValue(person); ageSum += person.getAge(); // ageMean = (ageMean * (getSize()) + person.getAge()) / (getSize() + 1); people.put(person.getId(), person); ((MyPerson) person).getGroups().put(id, this); }
第三次作业的性能问题主要关于sendIndirectMessage(int id)
中对两个人之间最短路径的求解。如果采用传统的dijkstra算法会CPU_TLE,如果进行堆优化(采用PriorityQueue,覆写比较器,存储)就会提升性能。
public int dijkstra(Person fromPerson, Person toPerson) { Map<Person, Boolean> tag = new HashMap<>(5000); //是否被访问过,Key是Person Map<Person, Integer> dist = new HashMap<>(5000); //从源点start到各个顶点的最短距离,Key是Person Queue<Vertex> pq = new PriorityQueue<>(5000, new Comparator<Vertex>() { @Override public int compare(Vertex o1, Vertex o2) { return o1.getDistance() - o2.getDistance(); } });//优先级队列,最小堆,边权值越小优先级越高 for (Person person : people.values()) { tag.put(person, false); dist.put(person, INFINITY); } pq.add(new Vertex(fromPerson, 0)); dist.put(fromPerson, 0); while (!pq.isEmpty()) { Vertex v = pq.poll(); MyPerson topPerson = (MyPerson) v.getPerson(); if (tag.get(topPerson)) { continue; } tag.put(topPerson, true); for (Map.Entry<Person, Integer> entry : topPerson.getLinks().entrySet()) { int min = dist.get(entry.getKey()); int temp = dist.get(topPerson) + entry.getValue(); if (min > temp) { dist.put(entry.getKey(), temp); pq.add(new Vertex(entry.getKey(), temp)); } } } return dist.get(toPerson); }
Bug分析
第一次作业
貌似无bug。
第二次作业
每次更新ageMean的采用两个int类型的数相除,导致精度损失,结果错误。修复bug的时候,在Group类中增加一个ageSum变量,每次更新ageSum,然后调用getAgeMean()
的时候再return ageSum / people.size();
,这样就不会造成精度损失,结果正确。
@Override public int getAgeMean() { if (people.size() == 0) { return 0; } return ageSum / people.size(); }
第三次作业
-
一个异常类的名字写错了。。。
-
dijkstra算法中笔者设置了一个无穷大INFINITY为32767,导致比32767大的距离都无法求出。。。
-
dijkstra算法每次将NetWork中所有人都遍历了一遍,实际上只需遍历每个结点的邻边即可。
架构设计
UML类图如下:
本次大部分类都是继承官方包中的类。图模型构建与数据维护都已在上文进行了介绍。总而言之,图模型构建就是用几个HashMap存储图以及其子图的结点、结点的邻边等信息,涉及到的图论算法主要有dfs、堆优化的dijkstra等。数据维护主要就是每次增删结点或边的时候及时更新valueSum和AgeSum。
心得体会
这三次作业让我能够耐心地读懂JML并会写了一些简单的JML,也懂得了规格设计的重要性。笔者回顾了多种图论算法,并了解了其优化算法,可以说是很有意义。另外,这几次作业对性能的要求较高,让笔者不得不思考关于容器选择、数据维护以及优化算法的一些策略,督促我尝试了一些优化,这正是笔者在前两个单元欠缺的东西。
然后,笔者也尝试使用了Junit测试,对编写测试模块有了初步的认识。
唯一的不足就是后两次作业的强测又翻车了。不过,只剩最后一个单元了,翻车的机会也不多了(手动狗头),还是要冲冲冲!!!