北航计算机学院面向对象(2023 第三单元)

北航计算机学院面向对象(2023 第三单元)

简介。

本文将以笔者学习过程中的思考感悟为基础,对2023北航计算机学院面向对象课程第三单元(作业9~11)的架构搭建和程序设计思路做简明的描述;如有不同见解,欢迎学习交流。


一、作业架构

1. 架构总览

本单元的作业要求实现一个社交网络模拟器,由于规格和部分文件已经给出,故架构较为固定,笔者在设计过程中为了使功能相对关系不大的方法分开,在要求实现的类的基础上添加了一些优化代码可读性的设计。
这是本次作业的架构图:
在这里插入图片描述

可见这个系统本质上是一个以人为结点,人与人间关系为边,各种社交行为可以修改边和点的属性的无向图。

2. 图模型的建立和维护`

  • 在设计中采用 HashMap 存储各个节点下属的边;在总的 MyNetwork 类中采用 ArrayList 存储 person、message 等数据信息。经本地对比测试,在10000条数据内 ArrayList 的性能略优于 HashMap,但30000条数据以上,HashMap 存储的优势显现。
  • 对于关系的查找,并未采用并查集的策略,而是使用深度优先搜索进行遍历。
    其实并查集和深搜策略的性能取决于测试数据是读密集型(利好并查集)还是写密集型(利好深搜)。
  • 将性能纳入考虑,则需要采用缓存策略和一系列图论算法(见第三节优化部分)。

二、代码测试

本节中,将对各种测试方式和本单元测试策略做简要说明。

1. 黑箱与白箱测试

  • 白箱测试
    是测试人员要了解程序结构和处理过程,按照程序内部逻辑测试程序,检查程序中的每条通路是否按照预定要求正确工作.它主要的针对被测程序的源代码,测试者可以完全不考虑程序的功能.
  • 黑箱测试
    是根据功能需求来测试程序是否按照预期工作,是要从用户的角度分析,尽量发现代码所表现的外部行为的错误。黑盒测试应该是由测试团队来完成的,根据某个给定的输入,应该能够理解并详细说明程序的预期输出。

2. 各种测试策略

  • 单元测试
    完成最小的软件设计单元(模块)的验证工作,目标是确保模块被正确的编码,使用过程设计描述作为指南,对重要的控制路径进行测试以发现模块内的错误,通常情况下是白盒的,对代码风格和规则、程序设计和结构、业务逻辑等进行静态测试,及早的发现和解决不易显现的错误。
  • 功能测试
    功能测试也叫黑盒测试或数据驱动测试,只需考虑需要测试的各个功能,不需要考虑整个软件的内部结构及代码,一般从软件产品的界面、架构出发,按照需求编写出来的测试用例,输入数据在预期结果和实际结果之间进行评测,进而提出更加使产品达到用户使用的要求。
  • 集成测试
    通过测试发现与模块接口有关的问题。目标是把通过了单元测试的模块拿来,构造一个在设计中所描述的程序结构进行测试。
  • 压力测试
    压力测试指一段时间内持续超过系统规格的负载进行测试的一种可靠性测试方法。
  • 回归测试
    在对软件进行修正后进行的有选择的重新测试过程,一般要重复已用的测试用例,目的是检验软件在更改后所引起的错误,验证软件在修改后未引起不希望的有害效果。

3. 测试工具

笔者在本单元中对代码的测试主要采用黑盒测试的方式,主要进行功能测试和压力测试,使用的工具是 python 编写的数据生成器和对拍器。测试策略有:

  • 对指令集进行划分,既可以在全指令集上进行综合测试,也可以对部分指令进行集中的针对测试
  • 对指令数量做出要求,可一次产生10000条测试数据进行压力测试。
  • 支持多人对拍,对多个 jar 包进行功能测试,参与的人数越多,结果越准确。
  • 对于 okTest 方法的测试主要采用灰盒测试的策略,在对代码的各个分支进行分析后针对性构造测试数据,测试输出是否符合预期。

4. 数据构造

依靠数据生成器,可从以下几个角度构造具有一定强度的测试数据:

  • 大数据量 + 全指令集,生成综合测试数据。
  • 大数据量 + 针对指令集,生成针对性数据,特别是一些费时的查询指令,这种方法可以测试程序是否具有一定的性能。

依靠手动构造,可从以下几个角度构造具有一定强度的测试数据:

  • 边界数据(调用函数作用自身,存在异常等情况)
  • 异常触发数据,用于覆盖测试异常抛出机制。

三、规格和实现分离的优化策略

在第3单元作业的编写中,一些方法的规格十分简单易懂,但完全按照规格编写会导致某些方法的性能较差;具体的:

1. queryBlockSum 方法

该方法的目的是求出总人群中互不相连的人群数,规格中描述如下:

    /*@ ensures \result ==
      @         (\sum int i; 0 <= i && i < people.length &&
      @         (\forall int j; 0 <= j && j < i; !isCircle(people[i].getId(), people[j].getId()));
      @         1);
      @*/
    public /*@ pure @*/ int queryBlockSum();

可以看出,该方法在二重循环中调用了 isCircle 方法,算法复杂度达到 O(n3logn),必须考虑优化:

  • 优化方法1:
    为该方法另编写深搜函数,从某个人出发进行深搜,将可以链接到的人标记,在下一次深搜时不予访问;深搜次数即为所求;这种算法的复杂度约为 O(n2)
  • 优化方法2:
    对 person 对象构造并查集,在 addPerson、addRelation、modifyRelation 时进行更新,这样在查询时复杂度可以降低到 O(1)
    但这种方法需要在人或关系变更时对并查集进行维护,其性能优化效果取决于测试数据是读密集型还是写密集型

2. queryTripleSum 方法

改方法的目的是查询总人群中三角关系的总数,规格中描述如下:

    /*@ ensures \result ==
      @         (\sum int i; 0 <= i && i < people.length;
      @             (\sum int j; i < j && j < people.length;
      @                 (\sum int k; j < k && k < people.length
      @                     && getPerson(people[i].getId()).isLinked(getPerson(people[j].getId()))
      @                     && getPerson(people[j].getId()).isLinked(getPerson(people[k].getId()))
      @                     && getPerson(people[k].getId()).isLinked(getPerson(people[i].getId()));
      @                     1)));
      @*/
    public /*@ pure @*/ int queryTripleSum();

可以发现,该规格使用三重循环,算法复杂度为 O(n3),需要进行优化。
考虑到人与关系的集合本质是无向图,在边的遍历时,相同节点对间的边会被访问两次,故优化策略为将无向图转化为有向图

  • 若节点 A 与节点 B 间有边,则保留 小id 的指向 大id 的边,以此处理构造有向图。
  • 寻找三角关系,使用hashmap存储节点对
      hashmap<idB, idA>   //<key, value>    
      // idB > idA,这表示指向 B 的节点为 A
    
    A
    B
    C
    如图每次选取一个节点 A ,在 hashmap 中设置其指向的结点,对每个其指向的结点 B,查询 B 指向的结点 C 的 hashmap值,若 hashmap.get(idB) == hashmap.get(idC) 则确认一组三角关系。
    该算法的复杂度为 O(n1.5)

3. queryCoupleSum 方法

该方法寻找总人群中互相关系最好的对数,规格中描述如下:

    /*@ ensures \result ==
      @         (\sum int i, j; 0 <= i && i < j && j < people.length
      @                         && people[i].acquaintance.length > 0 && queryBestAcquaintance(people[i].getId()) == people[j].getId()
      @                         && people[j].acquaintance.length > 0 && queryBestAcquaintance(people[j].getId()) == people[i].getId();
      @                         1);
      @*/
    public /*@ pure @*/ int queryCoupleSum();

可以发现,该方法使用二重循环,每次调用 queryBestAcquaintance 方法,故算法复杂度为 O(n3),需要优化。

在该方法内部,调用了许多次 queryBestAcquaintance 方法,若每次进行检索,则做了许多重复计算,故在person类中设置 hisBestAcq 变量保存上一次的计算结果,并设置信号量 ifNeedRenew,在某些操作时发送更新信号,动态维护。
优化后复杂度为 O(n2)。

进一步,无需对每个可能存在的对都遍历一次,可以遍历所有人,对每个人求 bestAcq ,在确认 bestAcq 的 bestAcq 是否是此人,这样结合上一个策略,复杂度降到 O(n)。

4. queryLeastMoments 方法

该方法的目的是寻找以结点 id 为出发点的最短回路(回路上包括出发点至少有三个不同的结点),给出的规格只从结果约束的角度对方法进行了描述,未给出可能的实现方法,需要自行设计。

(1)删边法
最直观的,若有满足描述的最短回路,存在与出发点 A 邻接的点 B,使得 A 到 B 的路径不只有 AB 边这一条。
故我们每次取一条与 A 邻接的边 AB,删除该边,然后使用 dijstra 算法求出 A 到各个点的最短路径,若 A 到 B 存在最短路径,则存在一个满足题意的回路,长度为:

AB.length + Path(AB).length

如此遍历所有与 A,邻接的点后,长度最短的回路则为所求。
这种方法虽然简单易于实现,但在最坏情况下复杂度达到了 O(n3),需要进行优化。

(2)建树法
此方法的思想是“寻找最后一条边”,具体的:

  • 以 A 为出发点,使用 dijstra 算法
  • 记录 A 到各个点的最短路径上的边,以此构造最短路径树
  • 选取原图中不在最短路径树上的边 BC
  • 对每个这样的边的两个端点使用最近公共祖先(LCA)算法
  • 若最近公共祖先为 A ,则回路上“最后一条边”为 BC
  • 该回路长度为
      Path(AC).length + Path(AB).length + BC.length
    
  • 遍历每个这样的非路径树边记录最短回路长度即可

这样只进行了一次 dijstra 算法,且由于在使用 dijstra 算法的过程中记录每个点在最短路径树上的深度, 一次LCA 算法的开销为O(n),故总复杂度降到 O(n2)。

5. 缓存策略的推广

记录上一次运算结果的策略也可以推广到其他方法中:

方法历史保存变量更新信号量更新触发方法
queryBlockSumhisBlockSumifRenewForBlockaddRelation/addPerson/modifyRelation
queryTripleSumhisTriSumifRenewForTriaddRelation/modifyRelation
queryBestAcquaintancehisBestValue/hisBestIdifBestNeedRenewaddRelation/modifyRelation

这种策略可以适用于读密集型的数据,并规避恶意重复访问。


四、Bug 的发现和修复

在本单元作业中,笔者在强测出现了一些代码实现和设计上的 bug,并在强测中对他人的 bug 进行了hack。

1. 作业中出现的 bug

  • 在本单元第一次作业中,笔者的 okTest 中出现了访问越界的 bug,在代码设计的时候想当然的认为在该函数中 null 可以用判等符号与另一个非 null 值进行比较,结果课程组提供的 Runner 类中有对访问越界的异常进行捕获的机制,导致还未进行判等就直接抛出异常了。
    在撰写代码的时候还是应当注意与之关联的部分的实现方式。
  • 在第二次作业中,对 qcs 的强测出现了 CPU_Time_Limited_Existed 的 bug,这来源于 queryCoupleSum 方法的设计问题,当时仅对照 JML 描述进行了翻译,并未做降低复杂度的优化,导致超时。
  • 在第三次作业中,对 qlm 的强测中出现了 Memrory_Limited_Existed 的 bug,这来源于 queryLeastMoments 方法的设计问题,当时使用了过大的二维数组,适当减小空间或切换为邻接表表示图即可。

在撰写代码时还应该注意采用规格和实现分离的优化策略。

2. Hack 策略

由于笔者和室友在往届学长的对拍器上进行了扩展,形成了针对本单元作业的对拍器,故 hack 时非常简单直接,对拍器支持大于1的任意人数进行测试,数据在范围内随机生成,检测每个测试对象的每个输出,在不同的位置进行标记报错,定位 bug 十分快捷;主要发现的 bug 有

  • MyPerson 中一些函数的处理没有考虑到传入自身的特殊情况。
  • qlm 指令计算路径错误(常常表现为找不到路径)
  • 继承 Messgae 类的类的 equals 方法中直接沿用了Message类的 instanceof Message语句而未做修改。

五、关于 OK测试

1. OKTest 检验规格一致性

在三次作业中,我们对代码中的小部分方法进行了 OK测试,在根据规格编写测试程序时,可以发现:

  • OK测试代码严格依附于 JML 规格。
  • OK测试代码对方法调用前后数据变化的合理性和一致性都做了测试,这于规格相符。
  • OK测试对于方法调用可能作用于数据的各种行为进行了覆盖,较为严密。

2. 改进建议

在完成作业时,笔者有一些感受和建议:

  • 避免在写完该方法的代码实现后再编写 OKTest,否则易受到代码编写的影响导致 OKTest 失去原本的作用。
  • 可以尝试为已经提供的方法进行 OKTest,真正体现 OKTest 的作用。
  • 可以尝试在强测中引入 OKTest 互测机制,为同学的指定方法进行测试。

写在后面

本单元的核心是对 JML 规格的理解和撰写能力,在复杂方法中,JML 规格常常通过约束方法前后的变化和定义中间量来对方法的规格进行描述。
我们在阅读规格实现方法时,即要充分理解规格,保证完备性,又要不受规格中性能较低的语句约束,采取适当的优化,提升性能。
我们在撰写 JML 规格时,要明确该方法的作用是什么,有几种可能出现的情况,对变量修改的完备性如何等问题,并考虑 OKTest 的编写来验证方法是否符合规格的期待。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Vanthon

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值