OO第三单元总结——JML系列
一、实现规格所采取的设计策略
- 严格实现接口与抽象类
- 由于第三单元的作业的基本架构已经由课程组给出,相应的接口与抽象类均已由课程组实现,因此我们在实现的过程中,需要严格实现课程组提供的接口与抽象类。
- 根据规格的要求在自己实现的类中适当增加方法或构建新的类,防止出现代码臃肿和性能低下的情况
- 课程组提供的方法规格虽然功能单一,符合面向对象的设计原则,但是真正实现起来的时候仍会出现各种各样的问题:例如规格中出现暴力二重循环时,作为实现者要对此做一些适当的优化,否则会出现致命的性能问题。而要将这种暴力循环改造成性能较好的实现方式,往往其他各种方法中维护相关的变量,不可避免地要增加一些额外的操作(例如第九次作业的并查集算法)。这些一系列的操作不可能都塞到一个方法里去实现——因此这种情况下,我们需要增加适当的方法甚至是构造新的类,来支撑我们的实现。
二、基于JML规格的设计测试方法和策略
-
JML规格的好处,笼统地说,便是为我们清晰地描述方法“究竟干了些什么”。前置条件、副作用和后置条件是作为实现者必须要关注和保证的,因此本单元作业中,我的涉及测试方法和策略也是基于前置条件、副作用和后置条件来实现的(表现为用python构造数据与朋友对拍,Junit运用不熟练因此没有把它作为主要的测试手段)
-
基于前置条件验证的测试策略
-
通过代码走查验证代码是否覆盖了所有分支
-
在写完代码后,都会对所完成的代码进行走查(当然要间隔一段时间才做这件事,否则会出现先入为主的情况而导致找不到bug),检查代码是否都实现了规格中要求的各个分支,或者检查是否已经覆盖了所有的分支(有时是也许是规格写漏了某种情况,遇到这种问题也能及时发现。)
-
这种情况下往往要忽略每个分支的具体实现,只关心requires部分,简化走查流程。
-
举个栗子,以第十一次作业的sendMessage方法为例:
-
规格是这么写的:
/*@ public normal_behavior @ requires containsMessage(id) && getMessage(id).getType() == 0 && getMessage(id).getPerson1().isLinked(getMessage(id).getPerson2()) && getMessage(id).getPerson1() != getMessage(id).getPerson2(); blablabla…………………… @ also @ public normal_behavior @ requires containsMessage(id) && getMessage(id).getType() == 1 && getMessage(id).getGroup().hasPerson(getMessage(id).getPerson1()); blablablabla……………… @ also @ public exceptional_behavior @ signals (MessageIdNotFoundException e) !containsMessage(id); @ signals (RelationNotFoundException e) containsMessage(id) && getMessage(id).getType() == 0 && !(getMessage(id).getPerson1().isLinked(getMessage(id).getPerson2())); @ signals (PersonIdNotFoundException e) containsMessage(id) && getMessage(id).getType() == 1 && !(getMessage(id).getGroup().hasPerson(getMessage(id).getPerson1())); @*/
-
分支情况如下图所示:
-
因此我代码的结构应该是这样的:
if (!this.containsMessage(id)) { } else { if (this.getMessage(id).getType() == 0) { if (!(person1.isLinked(person2))) { } else { } } else { if (!(group.hasPerson(person))) { } else { } } }
-
-
代码走查时只需关注分支的情况,可以简单地检查规格是否覆盖了可能出现的各种情况,防止遗漏导致出现错误。
-
-
构造测试数据验证代码是否能正确进入每个分支
- 在确保了每个分支都考虑到的情况下,开始构造测试数据来验证代码是否能正确地进入每个分支。具体来说,主要分为三种情况:区间内合法数据、边界数据和非法数据。下面我们以测试前置条件\requires i > 20 && i <= 50;为例,简要说明我的测试策略
- 区间内的合法数据
- 由于条件限制在20 < i <=50,因此构造30、35、40诸如此类的合法数据就能完成测试。
- 边界数据
- 边界数据的测试非常重要,尤其是对于这种区间性质的前置条件来说,判断区间的边界情况非常关键。在这个例子中,对于左边界来说,需要构造19、20、21三个数据进行验证;对于右边界来说,需要构造49、50、51来验证。把“边界”设置为一个边界点的一个小邻域,更有利于找到潜在的bug(你永远不知道程序员会在边界数据中出什么样的锅,这样测试更加科学也更加保险)。
- 非法数据
- 需要关注不在区间内的“非法数据”,关注需求中对非法数据的处理方式,是抛出异常还是自动屏蔽,是测试过程中需要特别注意的。
-
-
基于副作用验证的测试策略
- 副作用在规格的描述仅仅是表现为改变了哪些变量或者是序列,没有具体的作用效果,因此这部分不好测试,需要结合后置条件才能达到一定的测试效果。
-
基于后置条件验证的测试策略
-
对于result的验证:依赖其他query方法"复现"验证result的正确性。
-
对于容器中是否包含某个元素的验证:利用某些方法的前置条件,看是否抛出异常来判断容器中的contains关系,验证是否将元素加入或是否将元素删除。
-
三、容器的选择和使用的经验
-
本单元作业仅使用ArrayList和HashMap两种容器。
-
容器的选择
- 如果需要实现需要大量“索引”或者是有“一一对应关系”的序列,选择HashMap。例如Network中有大量需要根据ID索引的序列:Person、Group、Message,用HashMap来实现可以提高查询速度,提高性能。
- 如果仅需要存放一个简单的序列,只需ArrayList即可。
-
“边遍历边删除”的问题
-
在实现deleteColdEmoji方法中,需要在HashMap中删除heat小于某个limit的emojiId,这就需要遍历Hashmap找到符合条件的Entry<K,V>并将它从HashMap中删除。
-
最初的代码如下图所示:
ArrayList<Integer> deletedeid = new ArrayList<>(); for (int i : heatMap.keySet()) { if (heatMap.get(i) < limit) { deletedeid.add(i); heatMap.remove(i); } }
- 这种简单粗暴的遍历方式会报ConcurrentModifyException的异常,原因是在遍历和删除的过程中破坏了Hashmap原有的下标值,而这个过程中JAVA并没有维护好每个Entry对应的下标。
-
使用迭代器Iterator进行删除,可以避免这一异常,因为Iterator的remove方法已经维护好了下标值,不会出现上述错误。
ArrayList<Integer> deletedeid = new ArrayList<>(); Iterator heatIterator = heatMap.entrySet().iterator(); while (heatIterator.hasNext()) { Map.Entry entryh = (Map.Entry) heatIterator.next(); Integer key = (Integer) entryh.getKey(); if (heatMap.get(key) < limit) { deletedeid.add(key); heatIterator.remove(); } }
-
四、本单元容易出现的性能问题
-
第九次作业
- isCircle()
- 这个方法是为了查询网络中两个Person是否处于同一个连通块中,按照规格的写法需要BFS或DFS遍历整个社交网络,复杂度非常高,有出现性能问题的可能。
- 为了避免出现性能问题,这里采用“并查集”的算法:
- 对每个人维护一个Person类型的father,表示这个人的父节点,初始值设为自己。
- 每个人多有一个寻找“根节点”的方法,递归调用直至找到一个Person节点,它的父节点就是自己。为了减少以后的递归调用深度,每次调用这个方法都会将这个人及沿途搜索到的人的父节点直接设置为根节点。
- 在添加关系时,需要将person1的根节点设置为person2根节点的父节点(顺序交换也一样)。
- 当需要查询两人是否在同一个连通块里,只需要查询它们的根节点是不是同一个人即可。
- queryBlockSum()
- 这个方法是用来查询网络中连通块的块数,按照规格是一个二重循环,且每次都需要调用isCircle()方法,复杂度非常高,可能会出现性能问题。
- 为了避免出现性能问题,这里需要对连通块的个数进行维护,于是设置一个BlockSum的整型变量,初值为0。
- 在向网络中添加人时,需要将连通块的个数加一,因为一个孤立的人本身就是一个独立的连通块。
- 在添加关系时,首先判断两人是否本就在同一个连通块中(调用isCircle()方法),如果不是,则连通块个数减一;如果是,则连通块个数保持不变。
- 这样一来,当查询连通块个数时,就能避免暴力的二重循环,直接查询私有属性即可。
- isCircle()
-
第十次作业
-
queryAgeMean()
- 这个方法是用来查询group中人的年龄平均值,如果每次查询都遍历的话有可能会出现性能问题。
- 事实上只要将年龄总和作为group的一个属性并维护起来即可,在addPerson和deletePerson时对这一遍历进行合适的增减,查询时将年龄总和除以人数即得到年龄平均值(注意人数为0的情况)。
-
queryAgeVar()
-
这个方法是用来查询group中人的年龄的方差。如果每次查询都遍历的话有可能会出现性能问题。
-
这里需要对方差的计算公式做一些变换:
\[D(x) =\sum_{p}{(getAge(p)-getMean)^2}=\sum_{p}getAge^2(p)-2\times{getMean}\times\sum_{p}{getAge(p)}+size\times{getMean^2} \]- 由上述公式,需要额外维护组里人们年龄的平方和即可,维护方式与年龄和一样。
-
-
queryValueSum()
- 这个方法是用来查询group中Person之间相互认识的value值的总和,同样是设置一个valueSum并将其维护起来,具体维护方法:
- 当向group中添加person时,遍历组里已有的人,将valueSum加上他们与person的value值(如果有的话)的2倍。
- 当向group中删除person时,遍历组里其他人,将valueSum减去他们与person的value值(如果有的话)的2倍。
- 对每个person维护一个组号的Arraylist。当添加关系时,根据这个Arraylist求出两人的共同group,将这些group的valueSum值加上value的2倍。
- 这个方法是用来查询group中Person之间相互认识的value值的总和,同样是设置一个valueSum并将其维护起来,具体维护方法:
-
-
第十一次作业
-
sendIndirectMessage()
- 这个方法的主要难点时求两人之间的最短路径,运用Dijkstra算法。查询时将计算结果和算出的其他结果缓存起来,等下次查询时可以直接使用;同时注意维护数据的有效性(当添加关系时很可能就破坏了缓存结果的正确性)。
-
五、作业架构设计(特别是图模型构建与维护策略)
三次作业的架构设计均与官方包所实现的接口和抽象类一致,没有额外添加新的类,下面简要梳理作业架构。
-
作业架构
- Network类主要用于维护Person和Group的关系,对Person和Group的进行增删改查的操作。
- Person类主要用于存储每个节点(人)的相关信息,包括熟人、年龄等属性,定义Person内部的一些增删改查的方法
- Group类主要用于存储组内的相关属性,定义Group内部的一些增删改查的方法
- Message类主要用于管理消息的各种属性
- 各种异常类均继承官方包中的抽象类,用于在合适的情况下抛出异常
-
图模型的构建
- 图模型的构建主要体现在Network中。Network主要按节点(Person)存储网络,而节点之间的关系蕴含在节点本身(acquaintance)里面,这样使网络结构更加简洁,管理起来也更加方便。
-
维护策略
- 增加节点:向图中添加新的Person
- 增加边:对图中的相关节点的内部属性进行修改(修改有关Person内部的熟人arraylist)
- 维护图的连通子图个数:参考第九次作业对blockSum的维护方法