OO第三单元作业总结与心得
第三单元作业总结与心得
本单元作业的主题为JML。JML是一种针对Java的规格描述语言,在弥补了自然语言的二义性缺陷的基础上为代码的架构与功能设计提供了一个统一的表达方式。本单元作业需要实现的具体功能均由课程组提供的JML规格给出,我们需要依据这些给定的规格提供自己的具体实现。这种作业模式与前两个单元有较大区别,因此值得关注的重点也不同。本文将以这次作业需要关注的重点话题为线索进行组织。
一、利用JML规格准备测试数据
这一部分讨论利用JML规格在自测阶段构建正确性检测使用的测试数据的方法。
在前两个单元的作业中,程序运行正确与否的判断准则仅通过自然语言进行了一番描述。第一单元的表达式化简在数据复杂度较高的情况下几乎无法不依赖其他外部程序辅助进行正确性判断;第二单元的电梯模拟具有程序化的正确性检验方法,但细节繁多,容易遗漏一些罕见的错误。无法考虑到所有错误情况,就难以针对性地构建出相关测试数据。
而在第三单元,JML规格提供了正确性检验所需的完备的判断标准。在JML规格本身足够严密且正确的前提下,只需对各require语句与ensure语句逐条判断便可以程序化地进行代码运行的正确性检验。于是,我们在构建测试数据时也可以依据各require语句与ensure语句的内容针对性地构造具有特点的数据。
以本单元作业Network
接口的addGroup
方法为例:
/*@ 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));
@*/
public void addGroup(/*@ non_null @*/Group group) throws EqualGroupIdException;
该方法以唯一的require语句为判断条件区分出了两个behavior,因此可以构造满足与不满足该条件的两组语句测试程序是否正确分别处理。三条ensure语句各自给出了程序正确运行的条件:groups
数组长度自增1,旧数据仍在数组中,需要添加的数据添加进了数组中。可以从“如何诱使程序违反这些要求”的角度出发考虑构造数据。如多次调用该方法,使通过定长数组实现groups
的做法导致数组长度超出上限;添加多个相似的数据,使某些通过对象某种不唯一属性进行管理的数据结构出现新旧数据辨认混乱的错误等等……(虽然这些低级错误过于超脱现实,基本不会出现,但构造思路值得参考。)
这种数据构造的方法能够帮助我们快速打开思路,尽管无法保证覆盖所有可能的错误,但仍能取得相当可观的测试效果。
二、JML规格基础上的架构设计
本单元的JML规格已经给出了所有我们需要实现的接口,只要正确地将这些接口实现就能够达成整个程序的正确性。因此,若不考虑具体实现中辅助性的类与接口,整个代码的架构便已由JML规格给出了描述。
考虑到大部分的架构设计由规格给出,为强调重点,这里就不再给出完整的UML类图,转而以文字的形式,描述规格之外的代码架构。
2.1 对象存储
本单元作业模拟的是一个社会网络系统。实现该系统的各类具有一定层次结构,一个类可能拥有多个其他类的对象引用,这在Network
中体现地最为明显,参见Network
的部分JML规格:
/*@ public instance model non_null Person[] people;
@ public instance model non_null Group[] groups;
@ public instance model non_null Message[] messages;
@ public instance model non_null int[] emojiIdList;
@ public instance model non_null int[] emojiHeatList;
@*/
可见Network
需要存储并管理多个Person
、Group
、Message
对象,emojiIdList
与emojiHeatList
存储的并非对象,但其存储与管理的思路与前三者是相通的。本次作业的这些会被其他类存储的对象均具有一个唯一的id标识,且在多数情况下,这些对象会被随机进行访问,因此采用HashMap
进行存储,具体实现如下:
private final Map<Integer, Person> peopleMap = new HashMap<>();
private final Map<Integer, Group> groupMap = new HashMap<>();
private final Map<Integer, Message> messageMap = new HashMap<>();
private final Map<Integer, Integer> emojiHeat = new HashMap<>();
在对象不被随机访问的场合,也可采用Set
存储对象,如Group
中的people
。
2.2 查询缓存
本单元作业为可交互系统,用户与系统的交互通过一条条的指令与系统反馈进行。在这些指令中,查询系统特定属性与状态的指令是作业的重点,并可能占用大量的CPU时间。为了减少时间上的消耗,几乎所有涉及计算的查询都配备了其对应的缓存空间,以复用之前的计算结果。
如queryLeastConnection
的具体实现中包含如下代码:
public int queryLeastConnection(int id) throws PersonIdNotFoundException {
// ...
boolean dirty = dirtyLeast.getOrDefault(r, true);
if (!dirty) { return least.get(r); }
// ...
least.put(r, rst);
this.dirtyLeast.put(r, false);
return rst;
}
其中least
即为该查询的缓存数据。由于系统的更新与查询是交错进行的,缓存中的查询结果存在过期的可能,所以许多缓存机制搭配有脏位标记(如上述代码中的dirtyLeast
)用于指示缓存内容是否失效。
同样由于系统具有动态性,部分查询涉及的计算不适合在查询到来时才临时开始进行,而更适合在系统更新的过程中进行。这同样被视作一种查询缓存,其具体实现机制将在第三部分展开说明。
2.3 图的构建
本单元的三次作业各描述了一条图论相关的查询,其依次分别是queryBlockSum
、queryLeastConnection
、sendIndirectMessage
,分别对应图论中查找连通分量、最小生成树、最短路的问题。Person
对象可视作图中的节点,Person
对象间互相认识的关系可视作图中的边,关系对应的value
值可视作边上的权,因此这是一个关于加权简单无向图的图论模型。
代码中并没有特意构建图的模型,仅在进行图论相关查询计算时将Person
对象的相关属性作为图的属性参与计算,除了引入Relation
类辅助图论计算以外并未在架构上体现特别的设计。
图论问题的具体算法与实现细节将在第三部分展开说明。
三、性能提升与数据维护
本单元的作业在正确性的基础上另有性能的要求,尽管本单元测试点没有设置性能分,但课程组有意限制了程序运行时间,若具体实现中毫不在意程序运行性能将极有可能在强测或互测中出现超时的情况。第二部分介绍的三方面架构设计均与性能提升有关。
3.1 存储方式的优化
本单元作业的JML规格对于需要存储的诸多对象均采用了数组的形式进行描述。若在具体实现上也采用数组,则定长的存储空间不足以应对动态增长的对象数量。因此容易想到应使用Java的某些容器类管理这些对象。
为与JML规格具有更好的形式上的一致性,本人本单元的前两次作业的具体实现中主要使用了List
容器类管理这些需要存储的对象。但如2.1节所说,程序中会频繁地出现对于这些存储对象的随机访问。仅从规格上分析,以Network
为例,接口提供了getPerson
方法,用于根据id返回对应的Person
对象。而在许多方法的规格中,getPerson
方法被直接使用在了条件子句中,可见该方法被调用的频繁程度。
若使用List
这类线性数据结构存储Person
对象,则getPerson
的具体实现过程便是遍历该List
,判断获取到的Person
对象是否与传入的id对应,直到获取到正确的Person
对象。对于规模为 \(n\) 的List
而言,该方法的平均时间复杂度为 \(\Theta(n)\)。
为降低这类关键方法的时间复杂度,本单元的最后一次作业将对象存储的方法改为了2.1节提到的做法,即利用HashMap
进行存储。由于本单元的各类待存储对象都具有唯一的id标识,且通过该标识进行查找,故利用该标识作为key值使用HashMap
管理对象可以将规模为 \(n\) 的该类查找方法的时间复杂度降至 \(\Theta(1)\)。
3.2 查询缓存
2.2节对查询缓存的概念以及最基本的缓存用例进行了介绍,本节将对一些动态维护计算结果的缓存方式进行介绍。
由于本单元的系统具有动态的交互性,除非两条相同的查询之间没有对相关的系统状态做出更新,否则两次查询不能复用一次计算的结果。鉴于许多查询的计算过程比较复杂,在频繁从头计算的前提下容易花费大量时间,因此可以考虑动态地维护一组数据,利用一次更新前后系统的相似性减少重复的计算量,以节省计算时间。下面介绍两种类型的动态维护思路。
3.2.1 更新时计算
第一种动态维护的思路是在系统更新时在之前维护的数据上进行新一步的计算。以Group
中的getAgeMean
方法为例:
private int ageSum = 0;
public void addPerson(Person person) {
// ...
ageSum += person.getAge();
}
public void delPerson(Person person) {
// ...
ageSum -= person.getAge();
}
public int getAgeMean() {
return ageSum / getSize();
}
该方法返回Group
中所有Person
对象age
属性的平均值。若在每次调用该方法时进行计算,则对于规模为 \(n\) 的Group
对象,方法的时间复杂度为 \(\Theta(n)\)。若只采用基本的查询缓存,则每两次该方法调用之间只要出现过addPerson
方法或delPerson
方法的调用便需要重新进行 \(\Theta(n)\) 的计算。
上述代码中实现的便是更新时计算的动态维护方法。ageSum
属性维护的便是所有Person
对象age
属性之和。调用getAgeMean
方法的计算过程仅需要进行一次除法,而加法的计算过程已分摊给了addPerson
与delPerson
这两个用于更新系统的方法。这样一来实现相关功能的CPU时间开销便依赖于后两者的调用频率,而与前者的调用频率基本无关,且在多数情况下总开销得到降低。
3.2.2 局部脏位标记
有的查询计算过程无论是从头开始进行还是在系统更新时即时进行均会造成较大的时间开销,这种情况下若采取更新时计算的做法反而会加大总体时间开销。此时可通过其他角度利用系统在更新前后的相似性节省计算时间。以Group
中的getValueSum
方法为例:
private final Map<Person, Integer> values = new HashMap<>();
private final Map<Person, Boolean> dirtyValue = new HashMap<>();
public void addPerson(Person person) {
// ...
for (Person p : people) {
if (!dirtyValue.get(p)) {
values.put(p, values.get(p) + person.queryValue(p));
}
}
// ...
dirtyValue.put(person, true);
// ...
}
public void delPerson(Person person) {
// ...
for (Person p : people) {
if (!dirtyValue.get(p)) {
values.put(p, values.get(p) - person.queryValue(p));
}
}
dirtyValue.remove(person);
values.remove(person);
// ...
}
// 该方法由外层的Network在添加关系时视情况调用
public void markDirty(Person person) {
dirtyValue.put(person, true);
}
public int getValueSum() {
int rst = 0;
for (Person person : people) {
if (dirtyValue.get(person)) {
rst += updateValue(person);
} else {
rst += values.get(person);
}
}
return rst;
}
private int updateValue(Person person) {
int rst = 0;
for (Person p : people) {
rst += person.queryValue(p);
}
dirtyValue.put(person, false);
values.put(person, rst);
return rst;
}
getValueSum
方法需要返回Person
对象两两之间的关系的value
值(不去重),若从头计算,在规模为 \(n\) 时时间复杂度为 \(\Theta(n^2)\)。因此该方法的多次调用成为了互测阶段常见的卡超时方式。由于Person
间新关系的添加以及Person
在Group
的添加与移除操作均会影响该方法的计算结果,因此基础的查询缓存无法起到很大作用。另外,由于计算过程比较复杂,上述系统更新发生时难以像3.2.1条中描述的那样依据更新前维护的数据进行少许计算更新维护的数据,因此,需要对维护的数据进行结构上的拆分。
上述代码维护了名为values
的Map
结构,该结构记录了每个Person
对象与该Group
中其他Person
对象关系的value
之和。在调用getValueSum
时,只需对values
中记录的数据求和便可计算出查询结果。可见values
将维护的计算结果以Person
对象为单位进行了拆分,dirtyValue
便是与value
各条目对应的脏位记录结构。对于脏位未被标记的Person
对象,代码采用更新时计算维护values
;对于脏位被标记的Person
对象,代码在调用getValueSum
时重新计算其对应的values
条目,并去除脏位的标记。
注意脏位在系统构建时是默认标记为true
的,这即是说,动态维护的数据仅仅是对之前某次查询进行的计算结果的复用。这减小了动态维护的开销,尽可能地只在必要时进行更新时计算,节省了整体的时间开销。
3.3 图论查询的实现与维护
这一节将介绍queryBlockSum
、queryLeastConnection
、sendIndirectMessage
三条图论相关的查询的实现与维护。
3.3.1 连通分支
queryBlockSum
的返回值为图中连通分量的数量。后续查询中最小生成树与最短路的查找也需要在各自的连通分支中进行,因此连通分支的查找与维护至关重要。
由于本单元作业的图模型不存在删边的情况,因此本人采用了并查集进行连通分支的查找与维护。具体实现如下:
private final Map<Person, Integer> root = new HashMap<>();
private int sumOfBlocks = 0;
public void addPerson(Person person) throws EqualPersonIdException {
// ...
root.put(person, person.getId());
++sumOfBlocks;
}
public void addRelation(int id1, int id2, int value) throws
PersonIdNotFoundException, EqualRelationException {
// ...
int r1 = updateRoot(person1);
int r2 = updateRoot(person2);
if (r1 != r2) {
root.put(getPerson(r2), r1);
root.put(person2, r1);
--sumOfBlocks;
}
// ...
}
public boolean isCircle(int id1, int id2) throws PersonIdNotFoundException {
// ...
return updateRoot(getPerson(id1)) == updateRoot(getPerson(id2));
}
public int queryBlockSum() {
return sumOfBlocks;
}
private int updateRoot(Person person) {
Person p = person;
int r;
while (true) {
r = root.get(p);
if (r == p.getId()) {
root.put(person, r);
break;
}
p = getPerson(r);
}
return r;
}
不同Person
对象通过比较其root
的value值是否相同来判断是否处于同一个连通分支。由于两连通分支在新关系添加导致合并时,仅有添加新关系的两个Person
对象更新了其对应的root
条目,因此代码中需要获取root
内容进行比较的地方都应该调用updateRoot
方法。(该方法在root
条目已更新时不会产生太多额外开销,因此不必特意设置脏位。)
isCircle
方法的实现因此简化为比较两Person
对象的root
是否相同,queryBlockSum
方法另外采用了更新时计算的做法。
3.3.2 最小生成树
queryLeastConnection
本质上是查询特定Person
对象所在连通分量的最小生成树的权重。上面已经给出了Person
所在连通分量的查找方法,因此实现的重点落在了连通图的最小生成树的查找与维护。
由于一旦有新边加入,最小生成树就需要整个重新计算,因此在最小生成树的维护上本人采用了最基础的查询缓存机制。具体实现如下:
private final Map<Integer, Integer> least = new HashMap<>();
private final Map<Integer, Boolean> dirtyLeast = new HashMap<>();
ic void addRelation(int id1, int id2, int value) throws
PersonIdNotFoundException, EqualRelationException {
// ...
int r1 = updateRoot(person1);
int r2 = updateRoot(person2);
if (r1 != r2) {
root.put(getPerson(r2), r1);
root.put(person2, r1);
--sumOfBlocks;
}
dirtyLeast.put(r1, true);
// ...
}
public int queryLeastConnection(int id) throws PersonIdNotFoundException {
// ...
int r = updateRoot(getPerson(id));
boolean dirty = dirtyLeast.getOrDefault(r, true);
if (!dirty) { return least.get(r); }
// ...
// 采用普利姆算法计算最小生成树权重,鉴于篇幅不详细展开
// ...
least.put(r, rst);
this.dirtyLeast.put(r, false);
return rst;
}
3.3.3 最短路
sendIndirectMessage
指令要求返回两个Person
对象之间的最短路,因此也可视作查询指令。原则上来说每一对Person
对象之间都需要存储一个最短路径长度作为查询缓存,而新关系的添加将会影响许多最短路的结果,因此动态维护比较难以进行。本人选择了以Person
对象为单位设置局部脏位,以嵌套的Map
结构作为查询缓存。具体实现如下:
private final Map<Integer, Map<Integer, Integer>> shortest = new HashMap<>();
private final Map<Integer, Boolean> dirtyShortest = new HashMap<>();
public void addRelation(int id1, int id2, int value) throws
PersonIdNotFoundException, EqualRelationException {
// ...
int r1 = updateRoot(person1);
int r2 = updateRoot(person2);
if (r1 != r2) {
root.put(getPerson(r2), r1);
root.put(person2, r1);
--sumOfBlocks;
}
// ...
for (Person person : peopleMap.values()) {
if (updateRoot(person) == r1) {
dirtyShortest.put(person.getId(), true);
}
}
}
public int sendIndirectMessage(int id) throws MessageIdNotFoundException {
// ...
return findShortest(person1.getId(), person2.getId());
}
private int findShortest(int id1, int id2) {
int rst;
int clean = -1;
if (!dirtyShortest.getOrDefault(id1, true)) {
clean = id1;
} else if (!dirtyShortest.getOrDefault(id2, true)) {
clean = id2;
}
if (clean == id1) {
rst = shortest.get(id1).get(id2);
} else if (clean == id2) {
rst = shortest.get(id2).get(id1);
} else {
// ...
// 采用迪杰斯特拉算法计算id1对应Person到连通分支内各节点的最短距离,鉴于篇幅不详细展开
// ...
dirtyShortest.put(id1, false);
rst = distance.get(id2);
}
return rst;
}
迪杰斯特拉算法以特定的一个节点为源头,计算到达连通图中各节点的最短距离。与这种算法的特点相对应,使用一次该算法进行计算便缓存一个Person
对象到达其连通分支内各节点的最短距离。这样可以在一定程度上取得较好的性能。
四、系统的扩展
这一部分将按照课程组的要求对Network
进行一定程度的扩展。课程组要求如下:
假设出现了几种不同的Person
- Advertiser:持续向外发送产品广告
- Producer:产品生产商,通过Advertiser来销售产品
- Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
- Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等 请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
课程组没有为新功能的模拟给出太多限制与假设,因此本人自行设计了相关的架构。下面列举部分(不是全部):
- 新增的类:
Product
类抽象产品 - 新增的
Network
方法:addProduct
、getProduct
等,与已有的几个方法类似applyForAdvertisement
、advertise
、purchase
等,表示申请广告、广告、购买行为querySaledAmount
、querySalingPath
等,查询产品销售额和销售路径
下面给出三个核心业务功能的JML规格:
/*@ public normal_behavior
@ requires contains(id) && (getPerson(id) instanceof Advertiser);
@ assignable getPerson(id).acquaintance[*].products, getPerson(id).acquaintance[*].advertisers;
@ ensures (\forall int i; 0 <= i && i < getPerson(id).acquaintance.length &&
getPerson(id).acquaintance[i] instanceof Customer;
(\forall int j; 0 <= j && j < ((Advertiser) getPerson(id)).products.length;
((Customer) getPerson(id).acquaintance[i]).containsProduct(
((Advertiser) getPerson(id)).products[j])) &&
(\forall int j; 0 <= j &&
j < \old(((Customer) getPerson(id).acquaintance[i]).products.length);
((Customer) getPerson(id).acquaintance[i]).containsProduct(
((Customer) getPerson(id).acquaintance[i]).products[j])));
@ also
@ public exceptional_behavior
@ assignable \nothing;
@ requires !contains(id) || !(getPerson(id) instanceof Advertiser);
@ signals (PersonIdNotFoundException e) !contains(id);
@ signals (NotAdvertiserException e) contains(id) && !(getPerson(id) instanceof Advertiser);
@*/
public void advertise(int id) throws
PersonIdNotFoundException, NotAdvertiserException;
/*@ public normal_behavior
@ requires contains(id1) && contains(id2) && id1 != id2 &&
getPerson(id1) instanceof Customer && getPerson(id2) instanceof Advertiser;
@ assignable people[*].money, saledAmount;
@ ensures (\forall int i; 0 <= i && i < ((Customer) getPerson(id1)).products.length &&
((Advertiser) getPerson(id2)).containsProduct(((Customer) getPerson(id1)).products[i]);
getPerson(id2).money = \old(getPerson(id2).money) +
((Customer) getPerson(id1)).products[i].price &&
(\exist int j; 0 <= j && j < products.length; products[j] ==
((Customer) getPerson(id1)).products[i] && saledAmount[j] = \old(saledAmount[j]) + 1));
@ ensures getPerson(id1).money = \old(getPerson(id1).money) -
(\sum int i; 0 <= i && i < ((Customer) getPerson(id1)).products.length &&
((Advertiser) getPerson(id2)).containsProduct(((Customer) getPerson(id1)).products[i]);
((Customer) getPerson(id1)).products[i].price);
@ ensures (\forall int i; 0 <= i && i < people.length && people[i] != getPerson(id1) &&
people[i] != getPerson(id2); people[i].money = \old(people[i].money));
@ ensures (\forall int i; 0 <= i && i < ((Customer) getPerson(id1)).products.length &&
!((Advertiser) getPerson(id2)).containsProduct(((Customer) getPerson(id1)).products[i]);
saledAmount[i] = \old(saledAmount[i]));
@ also
@ public exceptional_behavior
@ assignable \nothing;
@ requires !contains(id1) || !contains(id2) || id1 == id2 ||
!(getPerson(id1) instanceof Customer) || !(getPerson(id2) instanceof Advertiser);
@ signals (PersonIdNotFoundException e) !contains(id1);
@ signals (PersonIdNotFoundException e) contains(id1) && !contains(id2);
@ signals (EqualPersonIdException e) contains(id1) && contains(id2) && id1 == id2;
@ signals (NotCustomerException e) contains(id1) && contains(id2) && id1 != id2 &&
!(getPerson(id1) instanceof Customer);
@ signals (NotAdvertiserException e) contains(id1) && contains(id2) && id1 != id2 &&
getPerson(id1) instanceof Customer &&
!(getPerson(id2) instanceof Advertiser);
@*/
public void purchase(int id1, int id2) throws
PersonIdNotFoundException, EqualPersonIdException, NotCustomerException, NotAdvertiserException;
/*@ public normal_behavior
@ requires (exists int i; 0 <= i && i < products.length; products[i].id == id);
@ assignable \nothing;
@ ensures (exists int i; 0 <= i && i < products.length; products[i].id == id &&
\result == saledAmount[i]);
@ also
@ public exceptional_behavior
@ assignable \nothing;
@ signals (ProductIdNoutFoundException e) (forall int i; 0 <= i && i < products.length;
products[i].id != id);
@*/
public int querySaledAmount(int id) throws
ProductIdNoutFoundException;
五、学习体会
本单元带领我们接触了规格描述语言,让我们亲身体会了利用规格进行代码架构的严谨性与便利性。在这一单元中,测试的重要性同样不言而喻,但自行搭建评测机在这一单元显得几乎无法实现,同学们主要采用的都是多人对拍的测试策略。很遗憾,本人在这一单元的测试过程中犯下了一些基本的错误,使得在强测和互测过程中没有取得满意的表现。
在代码的编写过程中,第二、三部分提到的内容真的给了本人许多收获与经验。这些内容在将来的代码架构编写的过程中势必也能派上不小的用场。OO课程为我们提供的实践机会让我们能够收获许多诸如此类的代码经验,这十分令人受用。
下个单元的OO作业中,本人将活用这次的经验,继续努力。