面向对象程序设计第三单元总结--规格化设计

何牧-21101036 2024-05-17 18:56:32

面向对象程序设计第三单元总结--规格化设计

关于测试

在软件开发中,如果要写出优秀、可靠的程序,那么对于程序的的测试一定必不可少。虽然说这些测试不能完全避免错误的出现,但是未经过测试的程序相比经过测试的程序在出错概率上一定更高。对于现代的软件,由于其复杂度高,同时对于软件中不同程序模块的要求可能并不相同,因此相对应的测试方法也颇多,下面我就对这些测试方法谈谈自己的理解:

  1. 黑箱测试和白箱测试

    1. 黑箱测试:对于有些程序,测试者不知道或者并不想关心其执行的过程。如果把程序看成是函数,那么在给定一些输入的前提下,测试者关心该程序能不能得到对应的正确的输出。黑箱测试就是基于这样的理念,该测试方法下,对于程序的观察/测试只发生在程序执行之前以及程序执行完毕之后;而程序执行过程当中不管发生了什么,并不关心,也不会进行测试。这就好像老板给员工分发工作,老板并不关心员工究竟用什么样的方法完成工作,老板只关心工作能不能在 DDL 之前被完成。

    2. 白箱测试:考虑另外一种情况:现在测试者本身也作为开发程序的程序员,需要编写某一个程序来完成某一种功能,程序员当然希望程序能够通过黑箱测试,顺利实现预期的功能,但是恐怕只有黑箱测试还不够。如果把程序视作是状态机,黑箱测试只关心状态迁移序列的头和末尾,期间所有的中间状态黑箱测试都并不考虑了;但是对于程序的开发者而言,状态迁移过程当中,每一个状态其实都需要保证是正确的。如果做不到这点,开发出的程序就真正意义上变成了一个黑匣子,没有人知道其内部究竟发生了什么。虽然该程序可能功能正常,但是变得不可维护,无法在后期进行更新或者修复;程序的可维护性也是软件开发过程中应该保证的一点。白箱测试便是基于上述的理念,它假定测试人员知晓程序的执行细节流程,并且在初始状态、中间状态、终点状态等多个点位对程序进行测试,相比于黑箱测试而言,其对于程序的测试更加充分,但同时也更难以编写。

  2. 单元测试和集成测试

    1. 单元测试

      单元测试一般是针对软件中最小的可测试单元进行的测试,通常是某个函数、方法或者是某个类。单元测试的目的是验证每个单元的功能是否按预期工作,以及在修改代码后是否依然正确。

    2. 集成测试

      当每个程序模块、单元都能够通过其对应的单元测试后,可以进行集成测试,用于验证不同模块或组件在集成在一起后是否协同工作正常。集成测试的目的是确保各个模块之间的接口和交互是否正确,以及整个系统是否按预期集成。集成测试应严格保持在单元测试之后进行;如果某个模块的单元测试未通过,那么总体的集成测试也应该推迟,直到所有模块都通过了单元测试。

  3. 功能测试、压力测试以及回归测试

    1. 功能测试:顾名思义,功能测试是用于测试软件功能的测试。功能测试一般用来验证软件的功能是否符合规格说明书、用户需求或设计文档中的要求。这种测试关注的是整个软件系统的功能,而不是单个单元或某几个单元的耦合。

    2. 压力测试:压力测试是评估软件在特定条件下的可靠性、稳定性以及性能的测试。通过在软件负载达到极限或超出极限时对其进行测试,以确定其性能是否受到影响或系统是否会崩溃。压力测试可以模拟大量用户访问、大数据量处理或持续高负载等情况。例如在OO的评测中,相对于中测和弱测而言,强测的数据规模、负载都更高,可以算得上是一种压力测试。

    3. 回归测试:回归测试是在对软件进行修改或更新后重新执行的测试,以确保修改没有引入新的错误或破坏现有功能。它通常在软件开发周期的后期执行,以确保软件的稳定性和可靠性。回归测试可以包括重新执行之前的所有测试,也可以仅针对已修改的部分执行部分测试

以上是我个人对于软件开发中的这些测试方法的理解,接下来我想分享一下自己构建测试程序的一些心得:

  1. 对JML规格信息进行翻译,构建黑箱测试代码

    本单元出现的 JML Level 0 规格化代码的特征非常符合黑箱测试所描述的在程序开头/结尾进行测试的原则:

    1. requires 子句要求子句的条件在函数执行之前需要被满足,对应于测试程序在被测程序执行之前先对该条件进行检查/测试。

    2. ensures 子句要求子句的条件在函数执行之后需要被满足,对应于测试程序在被测程序执行完毕之后对该条件进行检查/测试。

    总的来说,如果在程序就是状态机的视角上来看,一个函数/方法的执行可以看作是程序在其状态空间中的一次状态转移。如果该函数/方法是 pure 的,则执行函数/方法前后程序的状态不发生改变;而如果该函数/方法是一般性的其他方法,则程序的前后状态应满足一定的约束关系(由JML来描述)。将 JML 语句描述的约束翻译成一些判断真假的 assertion 语句就可以完成测试程序的编写。

    根据以上分析,对某个函数/方法进行单元测试的步骤大致如下:

    1. 生成一些随机的数据使得程序从初始状态转移到某个中间状态

    2. 检查并记录该状态(对应了 JML 规格化代码中的 requiresinvariant 等子句)

    3. 执行待检测的函数/方法

    4. 检查并记录执行待测函数/方法后程序的状态,并与执行之前的状态进行比较(对应了 JML 规格化代码中的 pureensures 等子句)

  2. 测试数据的构造

    上面提到,为了对某个函数/方法进行测试,需首先使程序从初始状态转移到一个中间态,然后再调用待测的函数/方法。对于第三单元的作业而言,这个状态转移过程的具体实现就是向一个空的社交网络里逐渐添加人/关系/标签/信息等对象。

    值得注意的是,程序的状态空间可以是非常庞大的,如果想要严格证明某个函数/方法的正确性,则需要对状态空间上的每一个状态都进行测试,检查其结果的正确性。由于测试时只有有限的计算资源和时间,所以这种级别的检查是无法实际做到的。但是可以退而求其次,为了尽可能地保证待测函数/方法的正确性,应该使测试前生成的中间态尽可能的去覆盖整个状态空间。

    这个原则反映到本次作业中,则意味着在待测方法运行之前测试程序所生成的的社交网络形态尽可能的丰富:全连接网络、稀疏网络、有无环路、每个人的标签数量、每个标签所包含的人的数量。

    为了实现高覆盖度的数据生成,我在数据生成的过程中运用了以下几个技巧/原则:

    1. 保证数据的随机性:在数据生成过程中,在没有约束的地方可以尽可能的引入随机性,例如:

       int personId = new Random().nextInt(100000)-50000; //生成随机的personId,可能负
       ​
       double coin = Math.random(); //以一定的比例生成两个人之间的关系边
       if (coin < genRatio) {
           try {
               int id1 = this.network.getPersons()[i].getId();
               int id2 = this.network.getPersons()[j].getId();
               int value = randomizer.nextInt(14)-7;  //关系的价值也是随机生成的
               this.network.addRelation(id1, id2, value);
               this.shadowNetwork.addRelation(id1, id2, value);
           } catch (Exception e) {
               e.printStackTrace();
           }
       }
    2. 参数化的数据生成设计:在数据生成时,有一些变量可以将其作为数据生成方法的参数暴露出来,这样在调用数据生成的相关方法时,调用者既可以选择继续使用随机的策略,也可以手动去控制这些参数的取值,以达到对某一些情况重点测试的目的。

      例如在本次作业中,对于一个新社交网络的生成,可以将网络的大小以及网络中连边的占比作为参数:

       public static MyNetwork genNewNet(double genRatio, int personCount) {
           ...
       }

      在真正生成测试数据时,如果我希望完全随机的进行测试,便可以这样调用该方法:

       MyNetwork newNetwork1 = genNewNet(
           Math.random(), New Random().nextInt(10,100)
       );

      但如果由于性能限制等原因,希望进行小网络的测试,可以这样进行调用:

       MyNetwork newNetwork1 = genNewNet(Math.random(), 10);

      如果希望测试全连接的网络,便可以这样进行调用:

       MyNetwork newNetwork1 = genNewNet(1.0, New Random().nextInt(10,100));

      这些暴露出来的参数为数据生成过程中的一些个性化需求提供了方便,使整个测试变得更加灵活。

架构设计

第一次作业

第一次作业主要注重于基础社交网络的构建,整个社交网络只有其中的 Person 作为节点,他们之间的关系作为连边,这些关系具有 value,所以整个社交网络可以被看为是有权无向图。

  1. 首先网络当中的每一个人都有一个独立的 id,因此可以根据这个特点建立一个 id 到人的 hashmap,方便后续的查找。

  2. 对于 ap, ar, mr,ln 这些指令,只需要找到对应要修改的对象进行修改即可,复杂度为时间复杂度都为 O(1)

  3. 本次作业复杂度最高的是查询指令:qci, qbsqts

    前面两个指令用于查询整个无向图的连接情况(连通片个数、任意两个节点的连接情况)。可以用并查集来进行优化,如果并查集正确实现了按秩合并和路径压缩的话,可以做到插入和查询的最坏复杂度 O(\log n) ,大多数情况下复杂度仅为 O(1)。值得注意的是,由于并查集的结构抛弃了原图中较多的信息,因此适用于增边的情况,而当删除边的时候会变得较为复杂。因此我在作业中采用了如果发生删边就重建并查集的做法,同时也可以将重建推迟至查询的时候进行,最大程度上优化性能。

    对于 qts 指令,其含义就是查询整个网络当中的三角形个数。对于该函数,也可以采取在程序执行过程中动态维护数据的做法。每当加入、删除边的时候,该加入/删除的边影响到的一定只有该边的两个节点和第三个节点组成的三角形,而其他的三角形都不受到影响,因此只需要遍历其他所有的点于这两个点的关系,并由此来动态增减值即可。采用动态维护时,假设增删边的指令占总指令数目的 m ,则最终的时间复杂度为 O(m\cdot n + (1-m)) 而如果不采用动态维护,则每次查询都需要 O(n^3) 进行遍历,最终的时间复杂度为 O((1-m)n^3) 。很显然动态维护的性能更优

第二次作业

第二册作业最主要的迭代点便是加入了标签,这样每个人都可以有若干个标签,而每个标签里又可以有若干个人。相当于对于每个人而言都有标签个数个子网。

  1. 对于某一个标签内部而言,有 qtvsqtavqba 这些指令

    对于这些指令,都可以采取动态维护的策略:

    1. 对于 qtav 指令,只要维护一个年龄的总值,然后每次向标签里面加人/减人的时候都相对应的把总值进行加减即可

    2. 对于 qtvs 指令,首先维护一个 value 的总值,然后每一次加人/减人/更改关系的时候都进行相应的加减操作即可,避免了每次维护时都 O(n^2)​ 的方式进行循环计算。

    3. 对于 qba 这个指令,相当于是在一个可以动态更新的集合里面寻找一个最大的元素。可考虑对于每一个人,都维护一个优先队列根据 value 的值来存储熟人的先后关系。这样每一次插入需要 O(\log n) ,但查询只用 O(1) ,相比之下如果不进行维护,每一次查询都是 O(n)

  2. 对于整个网络而言,有 qspqcs 这些指令

    由于 qsp 所要求的最短路径不考虑 Person 之间的 value 值,因此是一个无权无向图上的最短路径问题,采用 bfs(单向,双向均可,双向会好一些)便可以在 O(n)​ 的复杂度下求得结果。同时由于第一次作业已经采用并查集优化了两个节点之间的连通信息查询,因此在查询最短路时,可以先查询两个节点是否真正连接,如果没有连接,直接返回找不到路径,可以避免把整个网络都遍历一遍,节省了时间。

    qcs 指令则要求查询网络当中的 “伴侣对” 的数量,由于在 qba 中已经优化了每一个人的最佳熟人查询,因此该指令只需要查询到每个人的最佳熟人的最佳熟人是不是自己即可,复杂度最坏情况下为 O(n) 。

    我在上述两个指令的实现过程中都没有采取动态维护的策略,主要是考虑到

    1. 方法本身的复杂度不算高,经过一些优化后算是可以接受的程度

    2. 由于这些指令本身数据结构的性质,在进行动态更新时所花费的时间并没有显著小于,有些情况下甚至还大于不采用动态更新时查询所用到的时间。

第三次作业

第三次作业相比于第二次作业的迭代点在于给社交网络加入了收发 message 的功能。

不管是群发还是单发的消息,两次消息之间是独立的,没有关联,所以消息相关的方法就没有办法做到动态维护的优化。每一次消息来的时候,都需要维护发消息的人和接受消息的人相关的数据,因此:

  1. 私发消息的时间复杂度为 O(1)

  2. 群发消息的时间复杂度为 O(m)​ ,其中 m 为待群发的人的数量

而对于一些其他的功能,例如emoji,红包等等,其实其逻辑也都很简单直接,只需要按照 JML 来严格保证正确性即可,在性能上不用过多考虑。

BUG修复&心得体会

在这三次作业中,不同于前两个单元,强测、互测时均没有出现 WA 的情况。我想这主要应该是因为代码中的每一个函数都按照 JML 的规格进行了编写并且进行了 Junit 单元测试,保证了其在满足条件的输入下一定能够得到满足条件的输出。我想这也是契约式编程的优点所在,由于契约明确规定了组件的行为,违约会在早期阶段被检测到,从而减少了运行时错误。同时,通过明确接口和契约,组件之间的耦合度降低,增强了系统的模块化。通过明确这些契约,可以大大增强软件的健壮性和可维护性。

契约式编程只保证了程序的正确性,但是却没有规定程序的性能要求。长期的面向OJ编程让我有了一个坏习惯:只要看到了 AC 就认为自己的程序已经万无一失,然后就不管了。在没有好好优化代码的情况下进行提交,然后就 TLE 了。实际上,从上面的架构分析部分可以看出,同一个规格可以有非常多的实现,而不同的实现之间程序的性能千差万别。很多情况下,如果只保证了一个程序的正确性但是性能极差的话,那么该程序也几乎是不可用的。因此在后续的编程中,除了功能的正确以外,也应该尽可能地去优化程序。

...全文
24 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

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

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