# OO Unit3 总结

OO Unit3 总结

一、 概述

本单元的三次作业主要是实现jml所要求的各个类以及相应方法,并进行某个方法的JUnit测试。总体来看,对于架构设计的要求大幅减弱(因为提供了相应的接口,可以认为最核心部分的架构已经设计好了),其中的难点是如果一味地按照JML写,只能保证正确性,却不能保证时间复杂度足够优秀。另外的难点就是在有限次数中测出全部JUnit测试点的问题。

三次作业中,主要是增量的开发,有的时候会对已有的方法进行一定的修改。

二、架构和图模型 ——兼谈性能问题

1. 架构

这里就简单的介绍一下架构的设计以及对于图模型构建和维护策略。

对于hw9,除异常外,只有Person类和Network类两个主类,而person包含于network中。

hw10增加了Tag类,并需要维护Tag相关的属性,较有挑战性的是维护valueSum、ageSum,其中Tag内聚于Person类。

hw11增加了Message类,并增加了对应的操作,此次作业综合运用了Tag、Message和Person类。

哈哈

2. 图相关设计&性能问题考量

我在本次作业中没有出现因实现方法不好、性能不佳导致的错误。

对于Network中的操作,主要就是增删改查。如果完全按照JML规格的描述进行实现,会出现时间复杂度过高的方法绝大多数都是查询方法——queryValueSum: O ( n 2 ) O(n^2) O(n2)、queryTripleSum: O ( n 3 ) O(n^3) O(n3)、queryAgeVar: O ( n 2 ) O(n^2) O(n2)等,因此必须使用一些方法控制时间复杂度。

下面是一些我所使用的方法。

(1) 实时维护值——分摊时间复杂度

根据木桶效应,程序的效率取决于时间复杂度最高的方法。对于类似valueSum、TripleSum、ageVar的查询,我们可以发现如果每次都要用极高的复杂度计算结果是一定不行的,而有趣的是对于相关的结果的改变似乎只发生在几个过程中,比如valueSum其实是tag内任意pair之间的value之和,那么只有在addPersonToTag、addRelation、modifyRelation以及delPerson的时候这个值会改变。

万幸的是,如果我们试图在每次修改这个值的时候进行维护,其时间复杂度是完全可以接受的——以addPersonToTag为例,假设最开始的实现是 O ( 1 ) O(1) O(1),我们在每次加人的时候就判断新人和tag里已有的人是否存在关系,存在的话就加在valueSum上,这样加人操作变为了 O ( n ) O(n) O(n),同理删人也变成了 O ( n ) O(n) O(n),但是这都是可以接受的,在修改relation值的时候,显然我们对应地更新valueSum的操作也是 O ( 1 ) O(1) O(1)的,并没有什么影响,最重要的是,这时候我们的queryValueSum就完全是 O ( 1 ) O(1) O(1)了!

回想我们上面的改进,实质上是把 O ( n 2 ) O(n^2) O(n2)的queryValueSum的时间复杂度摊给了增删改操作,从而保证了整体的理论极限复杂度控制在 O ( n ) O(n) O(n)

使用这个方法进行优化的方法有:

MyNetwork的queryValueSum、queryBlockSum、queryTripleSum

(2) dirty位

对于一些严格时间复杂度已经满足要求而又不是 O ( 1 ) O(1) O(1)的查询方法,实际上我们也可以通过设置dirty位的方式进行优化——如queryCoupleSum,我的设计中通过每次遍历person的bestAcquaintance的bestAcquaintance是不是自己, O ( n ) O(n) O(n)计算结果。但是显然如果没有任何relation发生了变化,那么这个结果是不变的,设置dirty位后,对于连续的查询,只需要计算第一次,后面可以做到 O ( 1 ) O(1) O(1)返回。

虽然对于严格时间复杂度没有影响,但实质上提升了程序运行的效率(提升效果与数据构造有关),使用这种方法的方法有:

MyNetwork中的queryCoupleSum

(3) 容器选择

jml的描述中,为了方便使用的都是朴素的数组,然而,很多方法的jml实现复杂度较高正是因为使用了数组这种数据结构。如果使用HashMap来存储Person、Message等,理论上可以平均 O ( 1 ) O(1) O(1)的时间找到对象,而非jml中遍历数组的 O ( n ) O(n) O(n)

另外,不同容器可能有类似的方法,比如remove(object)或remove(index),对于ArrayList,他们是 O ( n ) O(n) O(n)的,而对于HashMap,他们是 O ( 1 ) O(1) O(1)的,对LinkedList则分别是 O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)的。因此如果你在LinkedList中循环遍历元素、然后if判断后用remove(Object),很不幸,时间复杂度是 O ( n 2 ) O(n^2) O(n2)的;而如果你使用的是remove(index)或者使用迭代器删除(或者基于迭代器+判断的原生方法removeIf),那么就是 O ( n ) O(n) O(n)的。这告诉我们要谨慎地选择符合需求的容器,并一定要确认好所用方法的时间复杂度。

使用最多的容器是HashMap,因为需要根据id检索,所以MyNetwork中的persons,messages,emojiIds(id->heat)、MyPerson中的acquaintances、values、tags都选择了HashMap

(4) 优秀的算法

对于一些问题,可能本身就有成熟的解法,我们可以通过学习表现较好的算法,将其用在hw之中。

比如queryShortestPath,由于我们的“最短路”定义是走过关系的数量-1,所以实际上是一个无权图最短路——表现较好的方式是bfs,只需要 O ( n ) O(n) O(n)的时间就可以;研讨课有些同学说使用dijkstra算法,可能是没有注意到边权的特殊性。另外,也可以通过双向bfs进一步优化,但是同学说他写双向宽搜的时候发现一个赋值语句莫名其妙占了很长时间,导致最终效果甚微。

另外是BlockSum的问题,如果没有删除relation的操作,那么我们可以放心大胆且优雅地使用并查集,但是有删除的话,理论上就需要重构并查集,其重构过程为 O ( n ) O(n) O(n),实际上跟手动维护每个block的最大时间复杂度一样。但是研讨课上有同学提出,可以构建一颗生成树,如果删的关系是树上的,才需要重新构建并查集(因为不在树上的,一定不会使得blockSum增加,在树上的也不一定),类似dirty位,没有影响的关系就不进行修改。

3. 各方法时间复杂度

下表展示了时间复杂度,忽略了无需优化也是O(1)的方法(比如getId)、以及在Network中直接套壳的方法。

(1) MyPerson
方法名时间复杂度优化方法
addRelation,modifyValue,delRelationO(n)为维护valueSum,需维护person所在的所有Tag,O(1) -> O(n)
getReceivedMessagesO(1)使用LinkedList优化
addMessageO(1)使用LinkedList优化,头部插入O(1)
clearNoticeO(n)使用LinkedList的removeIf方法,保证O(n)
(2) MyTag
方法名时间复杂度优化方法
addPersonO(n)为维护valueSum,O(1) -> O(n)
delPersonO(n)同上
getAgeMean/VarO(1)维护ageSum、ageSquareSum,拆分等式
(3) MyNetwork
方法名时间复杂度优化方法-备注
addRelationO(n)为维护TripleSum,每次加边O(n)
modifyRelationO(n)同上
queryBlockSum/TripleSumO(1)通过动态维护,实现O(1)查询
deleteColdEmojiO(n)迭代器删除Emoji、再遍历message O(n)删除
sendMessageO(n)群发红包消息需要动态维护

三、测试方法

本单元我的测试除了最基础的基于结果的测试,或者叫“集成测试”外,也有基于规格要求的Junit黑箱测试、为特定方法构造的极限数据压力测试

在我的理解中,白箱测试是“你知道你在测试的是什么,并且需要针对性地测试里面过程是否正确被实现”,而黑箱测试则是“我可以不知道里面在做什么,我只需要测试方法执行前后状态的改变”。我认为本单元要求的JUnit测试就是黑箱测试

单元测试主要针对一个函数、方法进行测试;功能测试是针对需求中一项功能进行整体测试,对应到代码中可能是几个函数的整体配合,也有可能就是一个函数,这个“功能”更偏向需求侧的描述;集成测试则是整体性的测试;压力测试更多是对代码的极限测试;回归测试是对迭代前的旧代码再进行测试,看看旧代码有没有在新代码中引起新的问题。

1. 我的测试实践

本单元的测试我认为主要在正确性时间要求两个要求,对于正确性,JML是“圣旨”,可以通过给每个方法进行测试,但是这样又慢又耗时间,因为每个方法的测试还需要准备数据、调用其他方法。所以我的方法是通过大量随机数据+人为构造一些可能出错的数据进行对拍,这样做的好处是比较方便,但是坏处在于如果对题意理解不够精准、或数据构造策略选取不当,可能永远测不出我的bug。事实证明,jml更新以后我没有仔细地捕捉到变化,导致我没有测试到发RedEnvelopeMessage时Tag size为0的情况。

2. 极限测试

对于时间复杂度高(尤其是JML里面的实现超过O(n)的算法)的方法,在构造数据充分测试我的实现正确性后,要构造使我的实现达到最坏复杂度的数据,判断我的实现是否真的能达到效果(比如对于一些值的维护假如只是增加了dirty位,对于随机数据可能有概率能过,但是蓄意的修改/查询就会使得复杂度非常的坏)

3. 测试数据构造策略——面向JML规格的数据构造

JML中有对异常行为的规定,这一点在测试的时候很容易被忽略,对于构造测试数据的时候,也要测试异常处理是否正确。 这就要求在数据生成的时候对于各种情况都能生成到,在手动生成数据的时候肯定较为方便,但是麻烦;对于随机数据生成器,则可能需要控制一下概率(比如通过%2,%3手动规定各个情况概率相等)、并增大测试组数和数据条数以保证各种情况的排列组合都有概率遍历到。

四、JUnit

本单元的JUnit确实是一个难点,主要在于测试出未知的错误代码的问题,总结下来有以下几个要点:

1. 数据构造多样性

通过@Parameters修饰的方法,有策略性地构造多组数据,在我的实践中,构造了只有点的图、完全图、随机图、链以及拆开的链。由于规定了其他实现正确,因此只需要提供一个现有的Network,无需无意义的modifyRelation等操作。

对于hw11中新增的Message,需要提前增加各种Message,并保证一部分被send,增加Emoji的heat。

2. result要求

对于函数返回值的测试,一般是最简单的,大部分人不会忽略。根据jml的result计算方式,暴力地计算结果即可,注意如果没有使用两个network实现测试,要进行你的检验计算,再调用函数,否则内部可能已经改变。

3. 副作用测试

该变的应该按要求发生变化,并符合要求;而更重要的是,不变的就不能变化。为此,要对比方法调用前后对应数据结构的size、内部元素的对应关系,为避免浅拷贝的问题,还可以在构造数据的时候就构造一个形状完全一样但是是深拷贝的network,虽然好像有些人不考虑这个问题也通过了case。

4. 充分发挥JML对测试检验的决定性作用

JML作为本单元唯一规定功能正确性的要求,应当被严格遵守,因此每一条都应当被仔细检验。

毕竟测试代码无需考虑性能问题,因此,直接实现jml的方法进行计算结果检验和容器内容检验即可,简单又安心,外加注意好克隆问题和数据构造(有些同学甚至构造完数据没有message,那当然无法测出messages数组是否变化啊),在测试组数选取足够大的情况下即可轻松通过测试。

五、单元感想

这个单元的作业量不大,而且很多时间也花在了写一些思维量小的代码,真正要思考如何实现、优化的时间不多。而相比之下,测试和检查的时间则更多了一点,既有JUnit的测试点有时候令我煞费苦心,也有担心自己实现的错误而仔细对比代码和JML的惴惴不安。

本单元也收获了JML以及契约式编程的思想,为以后复杂软件/项目开发提供了一定的可靠性理论支持,从一定程度上杜绝了“屎山”的出现,当然,我们现在只是能够根据JML进行实现和测试,但是可能一个项目更重要的是设计出合理的架构以及方法,并为函数设计JML规定,最后才是根据规定进行实现和测试。

  • 11
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值