BUAA OO Unit 3

BUAA OO Unit 3


Unit 3 概览

第三单元的核心内容是 JML(java modeling language)规格的理解与运用,以 社交网络 的模拟为核心任务。

为什么要引入 JML 规格

我们知道,要理解一个函数的作用,最有效的方法就是直接 读源码 ,可以帮助我们直入函数的底层逻辑,了解它的来龙去脉;但是这样的效率十分低下,而且我相信对于程序员来说阅读别人的代码一定是一件令人头疼的事。于是,我们开始 写注释 ,来简明地说明这个函数的作用,帮助使用者快速地理解;但这样往往容易出现理解的偏差,因为函数的注释很可能不够全面,而且自然语言容易产生歧义。

特别是在大型项目的开发中,自己写的函数可能过了一会儿就忘了是干什么的,那就更遑论合作开发了……为了克服上述两种方式的弊端,也为了吸纳上述两种方式的优点,我们在 准确性高效性 之间做了 trade-off,就产生了 JML。

什么是 JML 规格

简单来说,JML 规格就是 标准化的注释。首先,它是一种注释,对于程序的正确运行不会产生任何影响;其次,它又是标准化的,没有二义性,任何人读了都只会产生一种理解。JML 规格以一种类似 Java 语言本身的语法,规定了调用者需要保证的东西、函数的效果和可能产生的副作用、返回值的特点等方面的内容,使得我们可以 更安全更高效 地进行程序设计和项目开发。

测试过程

由于本单元是基于 JML 规格进行开发,整个架构设计相对固定严谨,只要严格按照规格去设计,出错的可能性很小。因此,我在本单元所做的测试相对较少,主要是集中在部分方法上采用 JUnit 进行单元测试,通过对 JML 规格的直接“翻译”来检验程序设计的正确性。此外,由于本单元的输出固定,我也采用了黑箱测试的方法与同学对拍,以此进一步验证正确性。

黑箱测试与白箱测试

黑箱测试

黑箱测试即屏蔽了具体的实现细节,仅通过 输入输出 来检验程序的正确性。

优点是 实现简单,不需要关注程序内部的代码实现,只要简单地完成数据生成与输出检验即可;贴近用户,测试的过程实际上就是模拟用户体验软件功能的过程。

缺点是 覆盖率较低,生成的数据往往具有局限性,难以覆盖到各种各样的边界条件;难以定位错误,当产生错误后往往仅能从结果倒推问题,模拟程序运行去发现问题。

白箱测试

白箱测试即在明确程序具体实现的情况下,对 程序结构 本身的正确性进行测试。

优点是 直击问题,通过对程序本身的逻辑进行检查,能够直接定位问题点;覆盖率高,能够精准把握程序运行的各条路径,发现潜在的问题。

缺点是 实现困难,往往只有开发者自身可能洞悉程序的结构,且只有设计极其详尽的测试才能覆盖更多的分支;设计与功能脱节,白箱测试往往只能检验程序自身逻辑的正确性,而无法衡量其是否满足功能要求。

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

单元测试

单元测试即针对程序设计的某一 基本组成单元(类的方法、函数)进行测试。

特点是能够直接检验最小模块在业务逻辑上的正确性,快速定位问题。但实现起来比较复杂,需要对具体的模块有针对性的设计测试方案。

我们本单元要求针对某一方法进行的测试就是单元测试。

集成测试

集成测试即在单元测试的基础上,将 模块组装成系统 进行测试。

特点是能够检验不同模块之间的协调性,针对模块接口以及整个体系结构,发现程序在局部上反映不出来,但在全局上就会暴露出来的问题。但实现开销较大,需要在做足单元测试的基础上进行设计。

我们本单元进行单元测试时,保证了其他方法的正确实现,同时提供了 stricEqualsgetPersons 等方法来辅助我们进行测试,实际上也是集成测试的体现。

功能测试

功能测试即检验软件系统是否正确实现 功能要求 的测试。

特点是通常采用黑箱测试的方法,检验业务逻辑是否正确实现,能否满足客户需求。

我们本单元检验 qtvsqbs 等指令的输出是否正确,实际上就是功能测试

压力测试

压力测试即对系统施加 大量负载(线程、输入、用户、流量)的测试。

特点是检验系统的性能瓶颈和鲁棒性。

我们本单元第二次作业中,最后一个强测点中有大量的 qtvs 114514 1919810 ,就是通过压力测试来检验我们程序的性能是否合乎 10s CPU 时间的要求。

回归测试

回归测试即在软件改动后(升级、打补丁)重新测试 先前测试过的内容。

特点是保证不会引入新的错误,导致回归(退化),但在测试内容较多的情况下可能时间、人力成本较高。

我们在进行 bug 修复时,要求保证本来已经正确的内容不得出错、代码风格分不得降低才能提交,这实际上就是回归测试。

数据构造策略

从本单元的具体内容来看,构造数据时应该考虑三方面的内容。

一是考虑 数据的范围。比如 idint 范围内,我们就要考虑 int 范围的边界,构造对应的测试数据。

二是考虑 图的特征。本单元的功能实现很多都是图的内容,我们就需要考虑稀疏图、稠密图、连通图、非连通图等具有不同结构特征的图进行测试。

三是考虑 压力测试。本单元的许多指令如 qbsqcs 等直观看起来都有较高的复杂度,我们需要大量构造这类数据来检验程序是否在性能上做出良好的处理。

架构设计

JML 规格实际上为本单元设计了良好的架构。TagMessagePerson 作为底层类,实现最基本的功能。NetWork 作为顶层类,统一管理调度这些类,实现用户功能。

图模型构建

本单元的图模型是 以点为核心 进行构建。Person 类作为一个个节点,其内存放有与其相连的所有节点,进而构成了边的概念。NetWork 中存放了社交网络中的所有人,由此构成了图的概念。

图模型维护

对于找点的操作,由于采取了 HashMap 的容器,直接通过 id 映射到对应的值即可。

对于加边、删边的操作,只需在对应的两个 Person 对象中加上或删去对方的引用即可。

对于修改边的权重的操作,只需在对应的两个 Person 对象中修改其对应位置的 value 值即可。

对于遍历操作,只需深度或广度遍历每个Person 对象即可。

性能问题及其修复

图的深度优先遍历

在最初写的时候,我认为在图的规模十分庞大的情况,采取递归的写法可能存在爆栈的风险(事实证明是我多虑了),于是采用栈的结构模拟深度优先遍历的过程。但是我在使用 isVisited 容器记录访问过的节点时,错误地认为 只有完成指定操作的节点才算被访问,导致已经在栈中的节点被频繁加入栈中,进行了大量空循环的操作导致超时。而事实上,isVisited 中记录的应该是 已经进入(过)栈中的节点。一个比较合理的写法如下:

    public void dfs(int node, CallBack callBack) {
        Stack<Integer> stack = new Stack<>();
        HashSet<Integer> isVisited = new HashSet<>();
        stack.push(node);
        isVisited.add(node);
        while (!stack.isEmpty()) {
            int now = stack.pop();
            callBack.operateNode(now);
            HashSet<Integer> otherNodes = nodeToSet.get(now);
            for (Integer otherNode : otherNodes) {
                if (!isVisited.contains(otherNode)) {
                    stack.push(otherNode);
                    isVisited.add(otherNode);
                }
            }
        }
    }

qtvs 指令

这个指令用于查询一个 Tag 内所有 Person 之间的 value 总和。在最初写的时候,我经过简单的计算认为,在 10000 条指令内,采取朴素的双层循环遍历的方法 CPU 时间不会超过 10s。但实际上我的复杂度计算方式 忽略了幂函数前面的系数,这在数量级相当的情况下是极有可能超时的(而且评测环境并不足够理想),于是我就顺理成章的超时了。痛定思痛,我就抛弃了一切偷懒的幻想,采取 动态维护 的方式来维护 valueSum ,其核心就是当任意两个 Person 之间的 value 变化时(或大小变化,或从无到有,或从有到无),应当 对同时包含这两个 Person 的所有 Tag 进行维护 。因为一个 Person 对象的 Tag 里含有的 Person 一定时这个对象自身的熟人,所以我们在找 Tag 时只需遍历被修改关系的 Person 的熟人即可。

    public void updateTagValueSum(int id1, int id2, int value) {
        MyPerson p1 = acquaintances.get(id1);
        MyPerson p2 = acquaintances.get(id2);
        if (p1 != null && p2 != null) {
            for (MyTag tag : tags.values()) {
                if (tag.hasPerson(p1) && tag.hasPerson(p2)) {
                    tag.updateValueSum(value);
                }
            }
        }
    }

规格与实现

所谓 规格,就是逻辑的抽象,能够帮助我们 准确地把握需求,在 目的 层面指导我们的开发。

所谓 实现,就是代码的落地,能够帮助我们 高效地完成需求,在 手段 层面组织我们的开发。

二者的分离,是实际开发的需要。前者界定了设计的方向,后者保证了创造的空间。从维护的角度来看,规格所明确的需求和具体的代码实现分离,有利于一个项目长期稳定的开发;从合作的角度来看,规格提供了统一的需求共识,有利于不同开发者多元一体的协同。

Junit 测试

在本单元之前,我个人对 Junit 测试一直抱有嗤之以鼻的态度,认为它“食之无味,弃之可惜”。但在本单元它与规格结合起来后,我逐渐体会到它的价值。

我们认为 Junit 鸡肋,主要还是对测试什么不明确的原因所致,而规格恰巧弥补了这一点。在完成基本的数据生成之后,我们只需忠实地按照 JML 规格所规定的内容去检验即可。因为我们只需进行最简单的正确性测试,所以无需考虑性能,只要按照规格的逻辑去翻译即可。由于规格本身的严谨性,基于规格的 Junit 测试甚至可以实现半自动化。

从检验代码实现与规格的一致性的角度来说,Junit 这种白箱测试的手段能够直击程序的内部,帮助我们检验是否满足 assignable 的限制,灵活性高、效果显著。

学习体会

JML 规格是一种很好的开发辅助工具,能帮助我们实现 清晰的架构设计。最直接的体现就是,课程组的 JML 规格帮我们做好了架构设计,我们几乎可以没有任何负担地、不动脑子地完成代码实现,完全不必担心架构设计不合理、调用关系不清晰带来的负面效果,大大提高了开发效率。

我之前常常有对函数调用组织的困惑,比如一个函数传入的参数,是应该让调用者保证不为空,还是被调用者对为空的情况进行特别处理。我在之前的架构设计中并没有对这个问题做出很好的解答,结果往往是判断冗余或处理缺失,并没有很好地整体观念。而本次作业中,顶层的 NetWork 类就对整个架构做了良好的组织,按照函数的前置条件要求去调用,在不符合条件的情况下抛出异常,结构相当清晰。

本单元我们只是站在巨人的肩膀上进行开发,并没有体会从自然语言的需求到 JML 规格设计的这一流程,希望学习学妹们有机会体验动手写 JML 规格。

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值