301
社区成员
发帖
与我相关
我的任务
分享任务简介:建立社交关系网络结构,需要完成的任务为实现简单社交关系的模拟和查询,学习目标为入门级 JML 规格理解与代码实现。
黑箱测试(Black-box Testing)
在黑箱测试中,我们不关心程序内部的逻辑,只关注程序输入和对应输出的正确性问题。即将程序视为一个“黑箱”,通过投喂数据,观察其外部表现来测试程序是否符合预期。
白箱测试(White-box Testing)
与黑箱测试正好相反,白箱测试关注程序的内部逻辑,这就要求测试者需要了解程序的内部结构和代码,并以此为基础构造测试,检测程序中特定路径、分支的正确性。
黑箱测试 or 白箱测试
两种测试方法各有优劣,黑箱测试易于理解、更接近实际使用场景但可能无法覆盖所有的内部路径,白箱测试能达到高覆盖率、有助于发现和优化性能问题但过度关注细节成本较高。
在实际中,我们应该结合使用两种测试方法,例如在进行单元测试时使用白箱测试,验证代码的内部逻辑;在集成测试时可以结合使用黑箱测试和白箱测试,确保模块间的接口和整体功能的正确性。
单元测试(Unit Testing)
单元测试是针对代码中最小的可测试部分(一般为函数/方法)的测试。
功能测试(Functional Testing)
功能测试关注程序的功能需求是否被满足,验证程序的功能是否按照规定正常工作。
集成测试(Integration Testing)
集成测试一般在单元测试之后进行,其目的是检查不同模块或服务之间是如何协同工作,即确保各个部分的接口能够正确地交互。
压力测试(Stress Testing)
用于检测程序在极端条件下的性能,比如说高负载、高并发等,这些在常规测试条件下难以出现,压力测试就可以帮助找到程序在极限情况下可能遇到的性能问题。
回归测试(Regression Testing)
回归测试往往在程序发生更改后进行,确保新增部分不会破坏现有功能,通常包括对之前通过的功能测试的重复。
在测试时,对每个方法进行单元测试、每个功能需求单独验证当然是保证代码正确性的一种方法,但随着代码体量和复杂度的上升,此种方式成本高且难以实现,所以我们可以有针对性地进行测试,例如对具有重要功能或实现复杂易出错的方法进行单元测试,确保每个分支的实现正确性。同时可以应用测试金字塔原则,即大量的单元测试,适量的集成测试,少量的端到端测试,这也可以帮助我们达到较好的测试效果。
总体而言按照JML所给出的框架和指导书要求实现,其社交网络、Tag、Message等设定也符合我们生活认识,不再展开说明。为提高代码的复用性,新增Counter计数器类,在异常类中使用。此外,由于Network中涉及到很多与图相关的算法,新增Graph类用于处理相关问题。
在本次构建中,应格外注意容器的选择,虽说ArrayList和HashSet似乎都可以完成存、查、取、删等操作,但是实现形式却千差万别,ArrayList在进行很多操作时都需要进行遍历,有O(n)的复杂度,在进行大数量的数据操作时,很容易超时。所以,对于数据的存储,我们可以选择HashSet等容器提高效率,同时注意到本次作业经常用到id查询,可以利用HashMap建立id到object之间的映射,提高我们的查询效率。
同时,我们可能需要进行堆优化或者实现队列操作,这些都需要选择合适的数据结构,一般而言,这些都可以通过查阅网络资料得到答案,只是需要我们在实现时少一些“理所应当”,多一些考虑即可。
本单元的关系网涉及到很多图论相关的知识点,我们的Person就是一个个Node,persons之间的关系可用一条条无向边来表示,通过建立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操作时,同步修改person1和person2的亲友关系,同时根据二者的根节点判断是否需要进行并查集的合并操作,在进行合并操作时,我们将深度较小的部分合并到深度较大的部分,即按秩合并,保持整体的平衡性。
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进行维护,由于删除关系后二者可能仍处于同个并查集中,所以删除时需要进行多重判断,相比于前几种操作更为复杂一些,最终实现方法如下,在删除关系时:
Person1同处一个并查集中的Person进行染色Person1同处于一个并查集中的点的根节点设置为Person1,并将秩设为1Persona2是否被染色,若未被染色,重复Person1的操作,将原并查集分裂为两个新的并查集。通过上述四个操作,我们就可以对我们建立的图模型进行维护,同时,我们也可以在Graph中实现bfs、dfs等方法,查询两个节点间的最小距离、维护tripleSum和blockSum,具体实现可见下一节。
在最初接触到JML的时候,我把它当成Java语言与自然语言之间的一种临时转换形式,认为我们需要实现的只是将JML逐条翻译,甚至困惑于为什么要用一种比自然语言更不易理解的语言形式来表达需求。但是,这种理解不仅有违JML的初衷,并且如果只是单纯依照JML语句实现方法,在性能上会出现很多问题,所以应有意识地将规格与实现分离。
规格与实现分离是软件工程中提出的一个概念,它强调将软件的规格说明与其实现代码分开。对于JML而言,JML提供了明确和形式化的规格,约束方法的行为、减少歧义,便于开发者、测试人员之间的沟通,其在没有实现代码的情况下也可以被审查和测试,甚至可以通过相关工具对JML规格进行静态检查,发现可能的错误,而无需运行程序。这说明规格在一定程度上是独立于实现代码的,实现只需要满足规格中限制,至于具体的实现逻辑与形式则可以自由发挥(即有约束的自由)。
在三次迭代作业中,我们实现了多种查询操作
query block sum
查询社交关系图中连通块的总个数,若在每次查询时都进行一次遍历查找,查询代价较大,故可以通过维护变量blockSum来实现该功能,具体维护操作为
通过上述维护操作,我们在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。同理,我们也可以利用脏位进行延迟重建等操作,这也是提升性能的一种方式。
Step1 理解JML规范 获取信息
通过阅读JML规格信息理解其定义的方法行为,注意方法的前置条件、后置条件和不变式。
Step2 生成测试用例
根据JML中的描述,可以根据前置条件等生成相应的用例,用例应包括正常情况和边界情况,若会抛出异常,也应覆盖到异常情况。
Step3 编写Junit测试方法
为测试用例编写Junit测试方法,确保测试覆盖了JML规范中的关键点。使用断言验证代码是否满足要求,例如使用assertEquals对后置条件进行检查,对于不变式也要相应验证对象状态在方法执行前后是否保持不变。
效果分析
Junit一般用于编写和运行可重复的单元测试,允许对代码的各个部分进行隔离测试,从这一点上来说,其用于检测代码实现与规格的一致性是合适的。
针对单个方法编写Junit测试,检测其是否实现了JML规格中定义的所有功能点,同时还可以利用断言等功能检查后置条件和不变式等。通过测试的覆盖率分析,还可以找出未被测试的规格部分,进行进一步检测。
从三次作业的实际运用效果来说,使用Junit测试检验代码实现与规格的一致性的效果也是不错的,按照上述方法构造测试,一般可以发现在代码实现中存在的问题。
在本单元中,初步认识了JML语言,正如各处所讲,它的主要用途是为Java代码提供精确的规格描述,或许在本单元推送上第一次读到这句话时,我对它的理解只停留在字面意义,然而经过三次作业的不断疑惑、质疑和理解,我似乎对它有了更多一点的感触。或许在实际应用中,JML的使用场景并不那么广,但是在这个单元中学习到的逻辑思维、单元测试思想却可以有广泛而长久的帮助。面对有时比代码还要长的规格,也能够耐心阅读并理解,并在阅读过程中思考以何种形式实现更为合适。
或许经过OO前两个单元的洗礼,突然的难度断层会让有我们些许不习惯,同时因为JML的某些特质,甚至会存在对JML这一单元的必要性的质疑。但相信这些都在课程组的考量之中,JML的保留也必然有它存在的意义,“那些似乎颠覆了我们熟悉感觉的恰恰就是教育“,希望大家都能在U3中有所收获。