1 分析本单元的测试过程
1.1 黑箱测试&白箱测试
1.1.1 黑箱测试、白箱测试
黑箱测试不需要了解程序具体的逻辑结构与实现细节,是基于JML规格说明,通过输入与输出来验证程序的正确性。这种测试方法只关注程序的运行结果是否符合规格设计,不需要探究其内部的具体实现。对于测试者而言,只需关注程序的功能,但是由于自行构造数据的局限性,无法发现代码实现的潜在问题。
白箱测试与黑箱测试不同,需要了解程序内部的逻辑结构与实现细节,是通过检查程序的源代码、控制流程、数据结构等来设计测试用例。这种测试方法可以通过源代码发现实现中的逻辑错误、边界条件问题等,深入了解代码的实现,并设计测试用例来覆盖各种情况,但是其测试过程相对较为复杂。
1.1.2 分析
这两种测试方式在这个单元的测试中缺一不可,黑箱测试体现在利用评测机对程序的压缩文件与输入数据放在一起进行运行,测试出程序整体的正确性;而白箱测试是对于算法等具体代码进行的细致化测试,用于修改程序的错误或漏洞,我认为本次的JUnit测试也可以算是一种白箱测试。
1.2 对单元测试、功能测试、集成测试、压力测试、回归测试的理解
单元测试是针对软件中最小的可测试单元(通常是函数、方法或模块)进行的测试。采用白箱测试,需要深入了解代码的内部逻辑和数据结构;功能测试是验证程序是否按照需求规格说明的要求正常工作的测试,可以通过黑箱测试,验证程序是否按照需求规格说明正常工作。集成测试是将不同的模块或组件集成到一起,并验证它们之间的接口和交互是否正常工作的测试,可以通过黑箱测试结合部分白箱测试;压力测试是在特定负载下,模拟大量数据负载,对程序的性能进行评估和验证的测试;回归测试是在对程序进行修改或更新后,重新运行既有的测试用例,以确保修改不会影响现有功能的正确性,可以使用黑箱测试来验证功能是否正常,同时也可以通过白箱测试来检查修改是否影响了原有代码的逻辑和结构。
在本单元的作业中,单元测试、功能测试体现在JUnit测试以及一些自行构造的数据,针对某个方法、指令做测试,保证每个方法的正确性和功能的正确性,进而保证整个程序的正确性。集成测试和压力测试则是体现在自行构造的一些数据,例如在sendMessage之前,我们需要加入Person,并使两个人之间Linked,还要加入信息、对信息进行一个处理,这一系列过程都可以看作一个模块进行集成测试。在一些时间复杂度较高的方法中,例如qsp,isCircle等,则需采用压力测试,构造大量数据,对程序的性能进行评估。回归测试则应该是Bug修复过程,对程序进行修改或更新后,重新运行既有的测试用例。
1.3 数据构造
在本次作业中容易出现Bug的情况是对官方给定的JML理解有误,以及设计的算法时间复杂度过高。因此本单元的自动测试基本是为了验证程序的功能正确性,而压力测试主要靠针对实现代码使用的算法手动构造数据,比如对并查集、最短路径的压力测试等。
在第一次作业中,主要是针对并查集的正确性检验和压力测试。在正确性方面,通过构造特殊数据、构造完全图、不断删边等操作来检验。压力测试则是通过同学的测评机,构造大量数据来进行
在第二次作业中,主要是针对查找最小路径来进行测试,也是进行正确性检验和压力测试,在正确性方面,可以通过特殊数据,比如,通过询问自己到自己或者自己到熟人来检验一些边界的正确性,构造一些连通性较强的图,来判断两点之间输出的是否为最短。
在第三次作业中,则主要是针对sendMessage进行测试。在本次作业中,我对官方给定的JML理解有误,在强测中WA了几个点。现在回想,在本次的测试中,对于这个方法应该根据JML规格的说明,进行白箱测试,根据规格全面构造一些数据,检查代码全面的正确性。
2 架构设计
2.1 架构
本单元的架构基本由官方包搭建好了,需要我们正确理解JML描述。
本单元的作业主要是维护一个社交网络Network
,以Person
为图的节点,以人与人之间的关系为边,构成一个无向图,接着针对这个无向图进行一些查询,例如是否连通、最短路径等。其中,Person
之间还可以发送Message
,在发送信息的过程中还要维护一些属性。
2.2 图模型构建
2.2.1 并查集
本次作业isCircle()
方法要求查询两个人之间是否连通,我采用的是并查集策略。新建一个并查集类DisJointSet
,利用HashMap<Integer,Integer>
存放父子节点,记录节点关系。用find()
、merge()
、add()
等方法封装并查集的相关操作。
public class DisJointSet { private HashMap<Integer, Integer> map; private HashMap<Integer, Integer> rank; //加入节点 public void add(int id) { //... } //找根结点 public int find(int id) { //... } //按秩合并 public int merge(int id1, int id2) { //... } }
对并查集作出以下优化:
-
路径压缩:
并查集中每个集合内部是连通的,所以我们可以从一个集合中取出一个“队长”,令其代表这个集合,这时整个集合的元素的父结点。当我们查找一个元素所在集合的代表元时,可以将查找路径上所有元素的直接上级设为代表元。在
find
函数里实现这个优化:public int find(int id) { int root = id; while (root != map.get(root)) { root = map.get(root); } //路径压缩 int now = id; while (now != root) { int father = map.get(now); map.put(now, root); now = father; } return root; }
-
按秩合并:
当两个不同集合要连通时,需要将大秩集合的代表元设为小秩集合的代表元的父亲节点,这样就可以实现两个集合的连通,而且尽可能使合并后的树高度降低有利于减短查找路径。
public int merge(int id1, int id2) { int root1 = find(id1); int root2 = find(id2); //同根 if (root1 == root2) { return -1; } int rank1 = rank.get(root1); int rank2 = rank.get(root2); if (rank1 > rank2) { map.put(root2, root1); rank.remove(root2); } else if (rank1 < rank2) { map.put(root1, root2); rank.remove(root1); } else { map.put(root2, root1); rank.put(root1, rank1 + 1); rank.remove(root2); } return 0; }
而且可以发现,当一个元素非代表元时,我会将其移出rank,保证在rank中的元素都是代表元,当queryblockSum时,就可以直接通过rank的大小来获得。
此外,在modifyRelation()还会出现删边的可能性,那么我会通过重建并查集来更新并查集:
this.disJointSet = new DisJointSet(); for (int i1 : persons.keySet()) { MyPerson p1 = (MyPerson) persons.get(i1); disJointSet.add(i1); for (int i2 : p1.getAcquaintance().keySet()) { disJointSet.add(i2); disJointSet.merge(i1, i2); } }
2.2.2 BFS查找最短路径
在第二次作业中,需要让我们查找两个Person之间的最短路径,我采用的是广度优先搜索。建立一个list队列由于存放待访问节点,每访问一层,就将其下一层加入待访问队列,直到找到路径的终结点。
int i = 0;//记录list长度 int j = 0;//记录当前访问的序号 //... while (j <= i) { MyPerson personJ = (MyPerson) persons.get(list[j]); if (personJ.isLinked(personEnd)) { //与id2相连 break; } //不与id2相连 for (int k : personJ.getAcquaintance().keySet()) { list[i++] = k; //... } j++; } //...
2.3 维护策略
2.3.1 BlockSum
连通块在并查集中动态维护,用rank的size表示。
2.3.2 TripleSum
在MyNetwork中动态维护,设置cntTri属性,初始化为0。当加入关系时,即在addRelation(int id1, int id2)
中,选择熟人较少的一个人,遍历他的acquaintance,找到她认识的人中有多少人又认识person2。
int size1 = person1.getAcquaintanceSize(); int size2 = person2.getAcquaintanceSize(); if (size1 < size2) { this.cntTri += person1.getTri(person2); } else { this.cntTri += person2.getTri(person1); }
在modifyRelation()中,可能会出现删除关系的情况,要在删边之前,处理cntTri。
int size1 = person1.getAcquaintanceSize(); int size2 = person2.getAcquaintanceSize(); if (size1 < size2) { this.cntTri -= person1.getTri(person2); } else { this.cntTri -= person2.getTri(person1); }
2.3.3 TagValueSum & TagAgeVar
没有进行动态维护。
2.3.4 BestAcquaintance & CoupleSum
没有进行动态维护,只有在查询的时候开始遍历。我认为,在过程中不断维护,若数据量大,有可能遇到A的关系网不断减去他的BestAcquaintance,而这时需要不断遍历重找A的BestAcquaintance,若A的社交网络很大,那么可能花销也很大。对于这种情况,在研讨课,有同组的同学提出部分维护,设置标志对我很有启发。在删除朋友且刚好删除掉最好朋友时较难,所以在这种情况下,可以让维护的最好朋友值失效。在执行查询最好朋友方法时,如果维护的值生效,可以得到,若无效则重新遍历。
2.3.5 ShortestPath
利用BFS(广度优先搜索)进行查询。
2.3.6 ReceivedMessages
在Person
类中将属性messages
设为LinkedList<Integer>
,在sendMessage()
中((LinkedList<Message>) (receiver.getMessages())).addFirst(message);
,当查询receivedMessages
时:
public List<Message> getReceivedMessages() { return messages.subList(0, Math.min(5, messages.size())); }
3 分析作业中出现的性能问题及其修复情况,谈谈自己对规格与实现分离的理解
3.1 性能问题
性能问题出现在第九次作业的强测中,其中由于并查集的重构,最开始时使用this.disJointSet = new DisJointSet();
,从头开始遍历构建并查集。后来提交的是版本是采用另一个并查集,其中定义两个容器:
private final HashMap<Integer, ArrayList<Integer>> block; private final HashMap<Integer, Integer> record;
block
以组号(代表人物的ID)分块,将所有persons
的id分组,record
用于记录每个ID对应的人所在的组号。在删除relation操作时,会进行dfs查找极大联通子图,在这里出现了CTLE。
所以最终,又改为最初的版本(见上)。
3.2 规格与实现分离
JML规格是我们针对具体实现方法的预期行为和约束条件,其包含前置条件、后置条件和不变量等,有利于在测试和验证阶段进行检查和验证,规格依赖具体实现,具体的实现方法依赖规格,并受规格约束。规格关注的是前置、结果,并不关注具体实现,对于一个规格,实现的方法却不是唯一的,例如以上并查集的不同实现,这体现了规格与实现的分离。
同时,具体实现在规格的基础上,需要更加灵活,来满足一些具体的需求。例如,在addRelation()时,还要维护cntTri,DisJointSet等,这些在规格中虽然没有体现,但是由于具体的需求,都要在方法中进行维护,这也体现了规格与实现的分离。
综上所述,JML实际只是提出了要求,对于实际的程序编写者而言,数据结构的设计、如何在规定的方法上思考算法,如和构建另外的方法和模型来完成方法要求,这些都是需要考虑的问题。
4 Junit测试方法
4.1 如何利用规格信息更好实现Junit测试
规格信息包括前置条件、后置条件、不变量等,在设计JUnit测试时,要对所有的前置、后置、不变量、结果等都进行一对一的测试。还有值得注意的是出现在第九次作业和第十次作业中的pure方法,需要检测前后一些相关属性、变量是否改变。
因为要对照前后的属性与变量等,在Junit中需要构造两个一样的network,一个作为参照类,另一个作为具体的实现类。对于结果,利用参照类按照规格信息得到result,再利用实现类实现方法得到结果,二者做比较,测试该方法结果是否正确。在第九次、第十次作业中要注意的是检验的两个方法都是pure方法,要在实现类方法调用后要检查前后数组长度、数组中的数据等属性是否改变,需要根据JML规格,仔细分析、做测试。
同时,要注意规格语义的分析,做更全面的Junit测试,例如在第十一次作业中,易遗漏的便是:
/* @ ensures (\forall int i; 0 <= i && i < \old(messages.length); @ (\old(messages[i]) instanceof EmojiMessage && @ containsEmojiId(\old(((EmojiMessage)messages[i]).getEmojiId())) ==> \not_assigned(\old(messages[i])) && @ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))))); @ ensures (\forall int i; 0 <= i && i < \old(messages.length); @ (!(\old(messages[i]) instanceof EmojiMessage) ==> \not_assigned(\old(messages[i])) && @ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))))); */
这一部分使用了==>
,不仅要验证JML中的条件,还要验证else的结果,例如以上只提到了!(\old(messages[i]) instanceof EmojiMessage)
,我们还需检验(\old(messages[i]) instanceof EmojiMessage)
的情况,还有其他种种相似的地方,就不赘述了。
4.2 Junit测试检验代码实现与规格的一致性的效果
正如上文提到的,规格包括前置条件、后置条件、不变量等,在检测代码实现的过程中,要全面地对规格中的前置条件、后置条件、不变量等作出相关的测试,检验被检测代码是否一一符合规格所规定的条件。
当JUnit测试检验代码实现与规格的一致性,即可验证代码功能的正确性,同时有利于检查边界条件、发现与修复问题。当需要对代码进行重构或修改时,JUnit测试可以作为安全网,确保修改后的代码仍然符合规格要求。通过运行测试用例,可以验证修改是否影响了代码的功能和行为,从而保证系统的稳定性和可靠性。
5 学习体会
在本单元的学习中,我第一次接触JML,第一次体验到“契约式编程”,通过形式化的规约,可以清晰地描述程序的行为和约束条件,从而提高代码的可读性、可维护性和可靠性,令我受益匪浅。初次接触JML,有时会被他冗长的叙述吓到,但是细究它的一些前置、后置条件等,可以体会到它的严谨性。我对于“契约式编程”只是初步接触,希望未来能够更深入地探索和运用这个概念。
同时,在本单元,需要考虑一些算法、性能层面的问题,不论是并查集的使用还是BFS查找等,都让我回顾、重新学习了图论还有数据结构的一些有点遗忘的知识点。还有就是通过学长学姐的博客以及与同学的讨论,还学习了许多优化的方法,进一步提高我对这些方面知识的掌握程度,让我进一步体会到算法的魅力。