272
社区成员




本单元重点学习了JML规格代码的书写、阅读,以及体验规格化设计实现。本单元学习的内容可谓是金刚钻,也许将来在工作的时候不一定会用到,但是掌握一项高专业化的能力不是一件坏事,况且一旦用到了那我们便能得心应手的应对,因此也是要好好对待JML的!
单元测试指的是,对软件中的函数、方法或类进行正确性验证。通过验证程序的函数是否正确,能够尽早发现代码逻辑错误,隔离缺陷,方便代码重构。单元测试过程自动化程度高,执行速度快,定位问题准确,成本最低。在我的程序中,我使用JUnit来进行单元测试,测试函数行为。
功能测试是指,基于软件需求规格说明书对软件功能进行验证。关注“软件做什么”,而不是“怎么做”。主要以黑盒测试为主,根据需求文档设计测试用例。在自动化测试的同时也包含大量手动测试,需要手搓一些极端样例来保证代码在极端情况下的健壮性。在我的程序中,我手搓了很多极端数据来保证函数功能的正确实现。
集成测试指的是,将通过单元测试的各个模块组合起来进行测试,重点关注模块之间的接口、交互和数据流,旨在发现模块之间由于接口不匹配、参数类型不一致、参数传递错误等引起的缺陷。验证组合后的模块协同工作是否正常。在我的程序中,我构建了一个验证场景,并调用了多个类的方法来测试。
压力测试是指,在超出系统正常运行负荷的极限条件下对软件进行测试,例如极高的并发用户数、大数据量等。其目的是为了检验程序在极端情况下的健壮性,模拟真实场景中的极端情况,保证程序的稳定,评估程序的抗压性能。主要测试方式就是短时间内大量输入。在我的程序中,我采用了大量输入测试的方式来评估程序抗压能力,同时手搓了卡TLE的极端数据。
回归测试指的是,在软件发生变更后,如修复了 Bug、新增了功能,需要重新运行原有测试用例,以确认这些变更没有引入新的bug或改变了原有已经正常工作的功能。在我的程序中,我沿用了之前的JUnit测试代码来进行回归测试。
我认为数据构造应该包含以下几个层级:1. 对代码正确性进行全面测试。 2. 对极端情况进行手搓构造。 3. 对代码进行压力测试。
对代码正确性进行全面测试。这一部分需要生成广泛的数据,来尽可能从测试最多的方法,触发每一种情况。每一种情况不要求深度测试,但是要求每一种情况都尽量测试到,求广度和不求深度。为了满足这种需求,这一部分数据生成可以采用随机数据生成,利用随机数据生成器来生成大量的数据,并进行大量的生成测试,通过增加数据量来提高方法测试覆盖率。
对极端情况进行手搓构造。这部分需要根据一些方法的易错点来手搓一些极端情况,来验证代码的正确性。我在这一部分首先观察所有方法在实现中较难、较复杂的地方,然后手动构造一些触发这些复杂情况的数据来验证代码的行为。
对代码进行压力测试。这部分我会对那些实现复杂度较高的方法进行压力测试,构造一个能够是其复杂度最高的场景,然后大量调用该方法,来测试其在极端情况下的健壮性。
在本单元,我尝试过使用大模型来书写JML规格、根据JML规格来生成代码、验证我实现的代码是否符合JML规格要求。在这过程中,我总结了一些经验。
使用大模型来书写JML规格。在这部分,我们首先需要将已有方法的JML规格投喂给大模型,这样大模型能够熟悉我们想要的JML的模板和样例,同时明确方法与方法之间的关系,这样生成的JML才能更加准确。其次,大模型很难一次性生成完美的JML,因此我们需要再把大模型之前的输出重复投喂给大模型,并且每次家伙是那个一些改动的prompt,让大模型不断微调。
使用大模型来根据JML书写代码。在这部分,同样需要先把已有的所有JML和Java代码投喂给大模型,让其先学习、适应我们的JML规格、JML与Java代码之间的关系,然后再告知大模型我们想要他生成的方法代码和对应的JML样例,让其仿照其余方法来生成。
使用大模型来验证Java代码是否符合JML规格。这部分也需要先把其他方法的JML和Java代码投喂给AI,这样AI才知道我们需要让他生成的方法中所设计的成员、方法都是什么含义,能够更精准的检验我们代码的准确度。
在本单元,我的代码架构如下图所示:
重点在于根据JML规格来完成接口方法的实现,并采取高内聚-低耦合的原则来明确划分代码职责,每个类完成各自的任务,并相互配合关联。
在存储图的时候,我采用的是类似链表邻接表的形式。其中图的节点就是Person,而边则代表Person之间的Relation,边的权重就是Value。在每个Person中,我采用了HashMap来存储与该Person有关系的Acquaintance,其中因为network中出现的任何person的id都是唯一的,因此可以用person的id作为键值,而person的引用作为映射,从而可以根据id来O(1)查找对应的acquaintance。
在维护图的时候,主要是根据一些能够改变person之间关系的方法来去维护。比如在addRelation的时候,需要在acquaintance中增加新person的映射关系,或者在modifyRelation的时候来修改value值或者取消映射关系。
几乎所有的维护工作都是在增加、减少、修改这三种操作的时候进行的,因此在考虑维护图的时候,也是先找出所有会增加、减少、修改关系的方法,然后在这些方法执行过程中去维护图的关系。
在本次作业中,有多个方法可能会出现因实现复杂度过高而导致的性能问题,必须加以优化,否则可能会超时。这里我将列举一些有必要的优化,来帮助大家体会思考优化的过程。
在第一次作业中,出现了一个看起来很简单,但实际上需要认真优化的方法:
该方法的JML规格写的非常简单,但是暗藏玄机。如果我们真的按照JML的引导,通过三层for循环来实现该方法,那么复杂度将会是O(n^3),在强测一万条指令的狂轰滥炸下,这肯定会超时的。因此我们需要巧妙地进行维护,来使得查询复杂度是O(1),维护复杂度是O(n),这样做可以把复杂度均摊到每一步方法中,而不是集中堆叠在某一个方法中。
具体来说,我们可以维护一个tripleSum,这样每次查询只需要返回该值即可。维护的过程也很简单:注意到,三元环的形成需要先有两条边相交,然后补齐第三条边,因此我们在每次补齐边的时候,可以遍历两个节点,看看这两个person有没有相同的acquaintance,如果有,那么就会多一个三元环。在删除关系的时候也同理,看看删除关系的两个person是否有相同的acquaintance,如果有那么就少一个三元环。
在第二次作业中,出现了qcs这个方法。该方法的JML规格如下:
如果无脑按照JML规格去实现,用两层for循环遍历,那么复杂度将会是O(n^2),由于该方法还要调用queryBestAcquaintance,因此在极端情况下也会变成O(n^3),因此需要对该方法进行优化。
注意到该方法主要是寻找两个人,这两个人的qba是彼此,因此我们不用遍历所有的两人组合,只需要对每个人遍历,看看这个人的qba的qba是否是他自己即可。这样就把复杂度优化到了O(n),然后对qba采取维护的方式,这样就可以用O(1)复杂度来查询qba,有效降低了时间复杂度。
在第二次作业中,还有一个大头优化,那就是qvs。该方法的JML规格如下图所示:
该方法最直观的方式就是两层for循环遍历,如果两个person之间isLinked是true,那么就加上value。也可以理解为,将所有彼此isLinked的person的value加两遍。不过目前该方法的复杂度是O(n^2),如果不优化那么很大概率会TLE。
一种简单的优化方案是遍历边复杂度,也就是第一层for训练遍历tag中的所有person,第二层for循环遍历每个person的acquaintance并检查其是否也在tag中。这种方式巧妙地把复杂度降低成了边复杂度,在person之间关系不多的时候可以很大幅度优化时间。不过这种方式在评测机压力过大的时候仍然会TLE。
所以最稳妥的方式还是采取维护的策略。首先,当tag中加入person或者删除person时,需要遍历tag中与该person有isLinked关系的person,并加上或减去两倍的value。其次,当有两个person之间的关系发生变动时,包括addRelation和modifyRelation,都需要找到同时含有这两个person的tag并进行value变动的维护。
在第二次作业中,每个person增加了一个属性,是receivedArticle,用来存储该person收到的article。这个容器需要支持以下操作:头部插入,删除某一个元素,获取头部前5个元素,获取全部元素。一般来讲,用LinkedList或ArrayList都能很好的完成以上内容,但是这两个容器的删除操作都是O(n)复杂度。
而且我们还要考虑硬件设备的因素,由于LinkedList是离散存储的,因此在遍历的时候,cache的块缺失率非常高,每次都需要重新填入块,因此实际运行时间要远远多于ArrayList。而ArrayList在内存中是连续存储的,因此cache命中率更高。
除此以外,我们还可以自己构造一个双向链表,并利用HashMap来实现O(1)查找结点,这样是理论的最优方法。
在最开始,我对JML有着一些误解,我误认为JML的语言是会在程序运行时也实际运行的,因此还曾困惑JML如何进行编译、执行?后来经过思考和询问,我才逐渐明白,JML只是一种规格描述语言,是写给编程人员看的,用来精准传递需求的媒介,而并非一种实际的程序。
JML提供的是规格,也就是让我们通过阅读JML来理解这个方法到底想要干什么,在什么情况下应该怎么执行等等。明确了这些规格后,我们就要开始实现这个方法,而实现方法的方式有很多,根据个人的选择可以有所不同。而这就是规格与实现想分离的意思。
本单元大规模进行了JUnit的测试,用来检验自己代码实现是否符合JML规格,以及是否能够检查出课程组提供代码的错误。
在编写JUnit代码的时候,我也总结出了很多经验心得。我认为最重要的是:
要严格根据JML规格来编写JUnit代码,并且测试一定要全面细致。如果该方法有exceptional_behavior,那么首先要根据JML的requires来构造相应的场景样例,然后调用相关方法,用assert检验或者expected来验证该方法是否抛出期待的异常。然后对normal_behavior进行检验,要对每个requires情况都进行构造,并根据JML的相关ensures来逐一验证方法是否满足后置要求。最后,如果该方法强调了assignable \nothing 或者 /* @ pure @ * / ,那么也要验证该方法调用前后是否发生了变化。
本单元的学习让我对JML规格有了更深入的体会,同时也让我学了一些算法的知识。尽管JML不一定会在将来工作中用到,但是一旦使用到,那么我们就具有绝对的优势。同时在撰写需求规格说明书的时候,也可以用到JML的思想和结构来书写,以保证需求的全面严谨细致。最后,经过本单元的学习,我们都是手握金刚钻的人才,希望我们能够好好发挥强大的本领,在以后的工作生活中有所贡献!