BUAA_OO_2024 Unit 3 BLOG

邝晓文-22373445 学生 2024-05-16 22:51:18

BUAA_OO_2024 Unit 3 BLOG

任务简介:建立社交关系网络结构,需要完成的任务为实现简单社交关系的模拟和查询,学习目标为入门级 JML 规格理解与代码实现。

测试过程

黑箱测试 & 白箱测试

  • 黑箱测试(Black-box Testing)
    在黑箱测试中,我们不关心程序内部的逻辑,只关注程序输入和对应输出的正确性问题。即将程序视为一个“黑箱”,通过投喂数据,观察其外部表现来测试程序是否符合预期。

  • 白箱测试(White-box Testing)
    与黑箱测试正好相反,白箱测试关注程序的内部逻辑,这就要求测试者需要了解程序的内部结构和代码,并以此为基础构造测试,检测程序中特定路径、分支的正确性。

  • 黑箱测试 or 白箱测试
    两种测试方法各有优劣,黑箱测试易于理解、更接近实际使用场景但可能无法覆盖所有的内部路径,白箱测试能达到高覆盖率、有助于发现和优化性能问题但过度关注细节成本较高
    在实际中,我们应该结合使用两种测试方法,例如在进行单元测试时使用白箱测试,验证代码的内部逻辑;在集成测试时可以结合使用黑箱测试和白箱测试,确保模块间的接口和整体功能的正确性。

单元测试、功能测试、集成测试、压力测试、回归测试

  • 单元测试(Unit Testing)
    单元测试是针对代码中最小的可测试部分(一般为函数/方法)的测试。

  • 功能测试(Functional Testing)
    功能测试关注程序的功能需求是否被满足,验证程序的功能是否按照规定正常工作。

  • 集成测试(Integration Testing)
    集成测试一般在单元测试之后进行,其目的是检查不同模块或服务之间是如何协同工作,即确保各个部分的接口能够正确地交互

  • 压力测试(Stress Testing)
    用于检测程序在极端条件下的性能,比如说高负载、高并发等,这些在常规测试条件下难以出现,压力测试就可以帮助找到程序在极限情况下可能遇到的性能问题。

  • 回归测试(Regression Testing)
    回归测试往往在程序发生更改后进行,确保新增部分不会破坏现有功能,通常包括对之前通过的功能测试的重复。

  • 在测试时,对每个方法进行单元测试、每个功能需求单独验证当然是保证代码正确性的一种方法,但随着代码体量和复杂度的上升,此种方式成本高且难以实现,所以我们可以有针对性地进行测试,例如对具有重要功能或实现复杂易出错的方法进行单元测试,确保每个分支的实现正确性。同时可以应用测试金字塔原则,即大量的单元测试,适量的集成测试,少量的端到端测试,这也可以帮助我们达到较好的测试效果。

数据构造的策略

  • 边界值测试
    构造接近边界条件的数据,如最值、负值等。
  • 随机生成
    可以借助随机数生成器来构造大量不同的测试数据。
  • 针对新增模块
    由于我们是迭代开发,所以在我们可以在每次测试中多测试新增部分,检测其实现的正确性。

架构设计

总体而言按照JML所给出的框架和指导书要求实现,其社交网络、TagMessage等设定也符合我们生活认识,不再展开说明。为提高代码的复用性,新增Counter计数器类,在异常类中使用。此外,由于Network中涉及到很多与图相关的算法,新增Graph类用于处理相关问题。

容器选择

在本次构建中,应格外注意容器的选择,虽说ArrayListHashSet似乎都可以完成存、查、取、删等操作,但是实现形式却千差万别,ArrayList在进行很多操作时都需要进行遍历,有O(n)的复杂度,在进行大数量的数据操作时,很容易超时。所以,对于数据的存储,我们可以选择HashSet等容器提高效率,同时注意到本次作业经常用到id查询,可以利用HashMap建立idobject之间的映射,提高我们的查询效率。
同时,我们可能需要进行堆优化或者实现队列操作,这些都需要选择合适的数据结构,一般而言,这些都可以通过查阅网络资料得到答案,只是需要我们在实现时少一些“理所应当”,多一些考虑即可。

图模型

本单元的关系网涉及到很多图论相关的知识点,我们的Person就是一个个Nodepersons之间的关系可用一条条无向边来表示,通过建立Graph类,我们可以将Network中的相关问题分担到Graph中解决。此外,在本单元作业中,也运用了被多次提及的并查集
Graph的具体属性见下述代码:

public class Graph {
    private HashMap<Integer, Integer> parent; //存放根节点
    private HashMap<Integer, Integer> rank; //实现按秩合并
    private HashMap<Integer, HashSet<Integer>> relatives; //存储邻友关系
    private int blockSum;
    private int tripleSum;
}

Graph中,维护数据的方法主要有四个

add

public void add(int id) {
      if (!parent.containsKey(id)) {
          parent.put(id, id);
          rank.put(id, 0);
          this.blockSum++;
          relatives.put(id,new HashSet<>());
      }
}

在进行ap操作时,我们将person加入到网络中,并将其根节点设为自己、秩设为0

merge

  public int merge(MyPerson person1, MyPerson person2) {
        relatives.get(person1.getId()).add(person2.getId());
        relatives.get(person2.getId()).add(person1.getId());
        int id1 = person1.getId();
        int id2 = person2.getId();

        int fa1 = find(id1);
        int fa2 = find(id2);
        if (fa1 == fa2) {
            return -1;
        }

        int rank1 = rank.get(fa1);
        int rank2 = rank.get(fa2);
        if (rank1 < rank2) {
            parent.put(fa1, fa2);
        }
        else {
            if (rank1 == rank2) {
                rank.put(fa1, rank1 + 1);
            }
            parent.put(fa2, fa1);
        }
        return 0;
    }

在进行ar操作时,同步修改person1person2的亲友关系,同时根据二者的根节点判断是否需要进行并查集的合并操作,在进行合并操作时,我们将深度较小的部分合并到深度较大的部分,即按秩合并,保持整体的平衡性

find

public int find(int id) {   //查找根节点
        int temp = id;
        while (temp != parent.get(temp)) {
            temp = parent.get(temp);
        }

        int now = id;
        while (now != temp) {
            int fa = parent.get(now);
            parent.put(now, temp);
            now = fa;
        }
        return temp;
    }

在执行isCircle查询命令时,我们会用到find操作,通过查询两个节点的根节点是否相同,我们可以知道两个节点是否存在于同个并查集中,在查询过程中,我们实现了路径压缩的优化,其基本思想时将节点的父节点直接设为根节点,将整个查找路径压缩成一条路径,减小树的深度,提高后续操作的效率。

lift

public void lift(MyPerson myPerson1, MyPerson myPerson2) {
        //删除并查集中的关系
        int id1 = myPerson1.getId();
        int id2 = myPerson2.getId();
        relatives.get(myPerson1.getId()).remove(myPerson2.getId());
        relatives.get(myPerson2.getId()).remove(myPerson1.getId());
        HashSet<Integer> visited = new HashSet<>();
        dfs(myPerson1.getId(),visited);
        for (int node:visited) {
            parent.put(node,myPerson1.getId());
            rank.put(node,1);
        }
        if (!visited.contains(myPerson2.getId())) {
            HashSet<Integer> visited2 = new HashSet<>();
            dfs(myPerson2.getId(),visited2);
            for (int node:visited2) {
                parent.put(node,myPerson2.getId());
                rank.put(node,1);
            }
        }
    }

常规并查集一般只存在上面的三个操作,但由于在作业设定中,modifyRelation可能会导致关系的解除,所以我们要增加lift方法在删除关系时对Graph进行维护,由于删除关系后二者可能仍处于同个并查集中,所以删除时需要进行多重判断,相比于前几种操作更为复杂一些,最终实现方法如下,在删除关系时:

  • 首先在亲友网(relatives)中将二者的关系删除
  • 通过深度优先搜索,对与Person1同处一个并查集中的Person进行染色
  • 将与Person1同处于一个并查集中的点的根节点设置为Person1,并将秩设为1
  • 查询Persona2是否被染色,若未被染色,重复Person1的操作,将原并查集分裂为两个新的并查集。

通过上述四个操作,我们就可以对我们建立的图模型进行维护,同时,我们也可以在Graph中实现bfsdfs等方法,查询两个节点间的最小距离、维护tripleSumblockSum,具体实现可见下一节。

性能问题与修复

在最初接触到JML的时候,我把它当成Java语言与自然语言之间的一种临时转换形式,认为我们需要实现的只是将JML逐条翻译,甚至困惑于为什么要用一种比自然语言更不易理解的语言形式来表达需求。但是,这种理解不仅有违JML的初衷,并且如果只是单纯依照JML语句实现方法,在性能上会出现很多问题,所以应有意识地将规格与实现分离

规格与实现分离是软件工程中提出的一个概念,它强调将软件的规格说明与其实现代码分开。对于JML而言,JML提供了明确和形式化的规格,约束方法的行为、减少歧义,便于开发者、测试人员之间的沟通,其在没有实现代码的情况下也可以被审查和测试,甚至可以通过相关工具对JML规格进行静态检查,发现可能的错误,而无需运行程序。这说明规格在一定程度上是独立于实现代码的,实现只需要满足规格中限制,至于具体的实现逻辑与形式则可以自由发挥(即有约束的自由)。

在三次迭代作业中,我们实现了多种查询操作

query block sum

查询社交关系图中连通块的总个数,若在每次查询时都进行一次遍历查找,查询代价较大,故可以通过维护变量blockSum来实现该功能,具体维护操作为

  • 在往关系网中新增Person时执行blockSum++操作
  • 在新增关系时,若Person1和Person2原属于不同的连通块,则blockSum自减
  • 在解除关系时,需要检查Person1和Person2是否仍在一个连通块中,可以通过从Person1出发,对Person1可以“联系”上的人进行“染色”,之后检查Person2是否被染色,若未被染色,则说明由于关系的解除,原连通块分裂为两个连通块,需要将blockSum数量加1。

通过上述维护操作,我们在qbs时只需要将blockSum的值返回即可,提高了查询的性能,同理,通过维护变量,我们可以对qts、qtvs等操作进行优化。

query shortest path

实际上是不加权图中的最短路径问题,可以使用bfs实现,由于查询的起、终点已知,可以用双向bfs进行优化。
双向bfs与bfs类似,只是从两端同时出发,每次依次从起、终点向外扩展一层,并在节点扩展时查询是否被另一方访问过,即查询结束的条件是两方“相会”
tips:依次扩展一层并不是依次扩展一个节点,在我最初实现中按顺序依次从两方队列中取出一个Node进行扩展,得到了错误的最短路径长度,这是因为没有按层扩展,例如与起点距离为2的Node个数可能有多个,就需要将所有Node扩展后再从终点进行扩展,才能保证相会时得到正确的路径长度。

除查询操作外,我们也可以对其他类型的操作进行优化,如

clear notice

Person接收到的NoticeMessage进行接收,一般而言需要遍历判断,若为Notice则进行删除。我们可以在每个Person中置一个脏位receivedNotice,表示在上一次cn后或自初始时是否接收到NoticeMessage,若未接收到,则在cn时直接返回而不遍历,同时在每次cn后将receivedNotice置为false。同理,我们也可以利用脏位进行延迟重建等操作,这也是提升性能的一种方式。

Junit

利用规格信息设计Junit测试

Step1 理解JML规范 获取信息
通过阅读JML规格信息理解其定义的方法行为,注意方法的前置条件、后置条件和不变式。

Step2 生成测试用例
根据JML中的描述,可以根据前置条件等生成相应的用例,用例应包括正常情况和边界情况,若会抛出异常,也应覆盖到异常情况。

Step3 编写Junit测试方法
为测试用例编写Junit测试方法,确保测试覆盖了JML规范中的关键点。使用断言验证代码是否满足要求,例如使用assertEquals对后置条件进行检查,对于不变式也要相应验证对象状态在方法执行前后是否保持不变。

效果分析
Junit一般用于编写和运行可重复的单元测试,允许对代码的各个部分进行隔离测试,从这一点上来说,其用于检测代码实现与规格的一致性是合适的。
针对单个方法编写Junit测试,检测其是否实现了JML规格中定义的所有功能点,同时还可以利用断言等功能检查后置条件和不变式等。通过测试的覆盖率分析,还可以找出未被测试的规格部分,进行进一步检测。
从三次作业的实际运用效果来说,使用Junit测试检验代码实现与规格的一致性的效果也是不错的,按照上述方法构造测试,一般可以发现在代码实现中存在的问题。

学习体会

在本单元中,初步认识了JML语言,正如各处所讲,它的主要用途是为Java代码提供精确的规格描述,或许在本单元推送上第一次读到这句话时,我对它的理解只停留在字面意义,然而经过三次作业的不断疑惑、质疑和理解,我似乎对它有了更多一点的感触。或许在实际应用中,JML的使用场景并不那么广,但是在这个单元中学习到的逻辑思维、单元测试思想却可以有广泛而长久的帮助。面对有时比代码还要长的规格,也能够耐心阅读并理解,并在阅读过程中思考以何种形式实现更为合适。
或许经过OO前两个单元的洗礼,突然的难度断层会让有我们些许不习惯,同时因为JML的某些特质,甚至会存在对JML这一单元的必要性的质疑。但相信这些都在课程组的考量之中,JML的保留也必然有它存在的意义,“那些似乎颠覆了我们熟悉感觉的恰恰就是教育“,希望大家都能在U3中有所收获。

...全文
40 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复
【重要提示】本资源设置为0积分下载,若非0积分请勿轻易下载 亲爱的CSDN用户: 首先感谢你点进这个资源页面。我需要提前说明一个重要情况: 本资源原本已设置为“0积分下载”,即作者希望完全免费共享。但CSDN平台有时会根据文件的下载热度、文件大小、用户权限等因素,自动将部分资源的积分调整为非0数值(如1积分、2积分、5积分等)。这是平台系统的自动行为,而非作者本人的设定。 因此,如果你当前看到该资源的下载所需积分不是0(例如显示为1、2、3……),请谨慎决定是否下载。 如果你按照非0积分支付并下载后发现资源内容不符合预期、链接失效,或者实际上该资源本应是免费的,作者无法为此承担积分损失或退还操作。强烈建议:仅在页面显示为0积分时进行下载。 另外,本资源描述中并未直接提供具体的下载地址或外部链接,因为它本身是一个通过CSDN官方上传通道提交的文件/内容包。如果你看到描述中没有外部网盘地址,这是正常的——资源文件应通过CSDN内置的“下载”按钮获取。若因平台积分显示异常导致你支付了积分,请优先联系CSDN客服咨询积分退还政策,作者没有权限修改平台自动设定的积分值。 感谢你的理解与支持。技术分享本应开放,但受限于平台规则,特此提醒如上。祝学习进步!

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧