这里写自定义目录标题
OO_Unit3 阶段总结
前言
本次作业聚焦于依据JML语言补充java代码,在这个过程中保证代码既能符合JML的规范要求,也能够在不超过一定的时间和空间限制的前提下正常运行。因此总体而言,本次作业难度并不高——并不需要着重思考架构设计,真正的难度在于正确理解JML语言含义(以及和第三单元的课程组斗智斗勇)。
测试
白箱测试和黑箱测试根本区别在于在测试中是否查看源代码
各种测试方式
黑箱测试
黑箱测试将被测程序看作一个打不开的黑箱,主要依据功能需求进行测试。使用评测机按照一定的规则“随机”生成数据进行评测就可以认为是一种黑箱测试——因为这种测试并不关心程序的具体实现过程,只需要最终功能符合预期即可。
黑箱测试的优点如下
- 与软件的具体实现无关,测试样例可以重复使用;
- 在一定程度上可以使用程序自动随机生成,提高测试效率,缩减测试时间。
事实上,个人认为,功能测试实际上就是黑箱测试的一种。功能测试从程序预期达到的功能出发,测试其各种功能能否正常运行,不关心程序的具体实现,符合黑箱测试的定义。
白箱测试
与黑箱测试相反,白箱测试要求清楚程序的具体实现以及运行逻辑,并针对此进行测试,以保证程序在所有分支,所有情况下能够正常工作。在第三单元的作业中,编写Junit并达到一定的覆盖率就可以看作是一种白箱测试。
白箱测试的优点如下
- 可以对黑箱测试难以覆盖到的分支进行测试;
- 难以发现一些数据相关的数据;
- 难以查出程序中设计原则相关的漏洞;
单元测试
单元测试是指“对程序中组成单元进行测试”,从定义上属于白箱测试。事实上,在每一次作业中要求的为特定方法编写Junit测试就可以看作是一种单元测试。
单元测试可以帮助我们快速定位bug产生所在的代码块,降低维护代码带来的成本。
集成测试
集成测试是指“在程序模块按照特定方式组装完毕后,对程序的接口以集成承后的功能进行测试”,主要目的是在进行单元测试之后检查软件单元之间的接口是否正确以及总体运行是否正确。
集成测试一般采用黑箱测试与白箱测试相结合的方式。
压力测试
压力测试指“对程序功能在较为极限,负载较大的情况下对程序的正确性以及健全性进行测试”,我本人的习惯是在已经进行了大量单元测试和集成测试的基础上,手动捏造数据进行压力测试,以保证自己的程序满足课程组要求的时间与空间限制。
压力测试一般也可以采取黑箱测试和白箱测试相结合的方式。
回归测试
回归测试指“针对对程序的更新或更改是否引入新的漏洞的测试”,用于保证对于程序的改进和修改不会破坏软件既定的性能和可靠性。对于需要多次迭代的OO作业而言,回归测试尤其重要。我们应当在每一次对程序进行修改后都进行回归测试。
回归测试也可以采取黑箱测试和白箱测试相结合的方式。
测试工具
说实话,我没有认真使用测试工具,就算是课程组推荐的Junit测试,我也只是应付,其目的只是通过特殊数据点。因为我个人觉得,就我们的OO作业而言,Junit并不好用(耗时费力不讨好,可复用性差)。我能理解在大型工程项目中,Junit可能有较为优秀的表现,但是就OO作业而言,Junit被市面上各种评测机完爆,甚至不如我自己手捏数据点(毕竟手捏数据点不需要写那么多代码),私以为,如果想让同学们真正领悟Junit的妙用,不如尝试加入小组作业,并结合Junit,这样可能效果会更好。
我和gpf同学共同开发了评测机,在开发过程中我负责接口的整合以及程序输出结果的正确性检验部分。正确性检验部分的实现依赖于python中用于进行图论计算的networkx
库,其代码已在GitHub上进行开源(https://github.com/solor-wind/BUAA_OO_TEST)。
评测机帮助我以及我的许多同学成功找出了Bug,大大缩短了我们Debug的时间(这不比Junit好用?)
数据构造的心得
我个人进行数据构造主要分三个阶段:
- 构造简单数据,确保对于每个单元程序都能正常运行,保证基本的程序正确性,此时每个测试数据大概在20行以内;
- 搭评测机,使用评测机“按照一定的规则”随机大量生成数据进行测试,并且修改评测机的参数进行多方位测试;
我可以通过调节graph_prop
、tag_prop
、message_prop
这三个参数的值来决定生成数据中图相关指令,标签相关指令以及信息相关指令的占比,以此进行高效测试
- 依据自身程序特点进行白箱测试,捏造评测机难以生成的边缘性数据进行测试。我在本次作业中使用了”并查集“这一数据结构,因此我就尝试了”大量
mr
指令“,”大量qts
指令“、”大量qtav
指令“,”Tag数目超过1111“等多种情况的数据。这种边缘性数据平常难以想到,只能多与同学进行交流,多关注讨论群中同学的发言。在第三次作业中,这也是产生bug最可能的原因。
架构设计
图模型构建
在MyNetwork
中,为了提高对涉及图论的计算的效率,我使用了”并查集“这一数据结构,具体表现为:将所有”可达“的Person
放置在同一个Set
中,并以该Set
中某个Person
的ID作为该Set的标识符。那么在判断两个Person
是否可达时,就只需要判断这两个Person
所在Set
的标识符是否相等即可。
这样做可以避免使用dfs
算法时可能导致的爆栈以及超时问题。(但是事实上,合理使用)dfs
或者bfs
并不会导致强测爆点,反而使用并查集的时候由于删边需要遍历整个Set,加之课程组奇妙的评测机时间测定,可能导致强测爆点
对于第二次作业新增的Tag需求,可以看作是在第一次作业的图的基础上产生的导出子图,在很大程度上可以参考第一次作业的实现。
图维护策略
在JML中有部分方法,如果完全按照JML的描述进行实现的话,这些方法将会达到O(n2)、O(n3)的复杂度,很容易导致在强测和互测中出现CTLE的问题。为了在使用并查集的基础上尽可能地提高性能,我在程序中进行了如下几个方面的设计:
对TripleSum进行动态维护
按照JML的实现方式,此方法的时间复杂度为O(n^3),显然不符合要求。我对其进行了动态维护,具体表现为:在加边时调用plusTripleSum
方法
private void plusTripleSum(int id1, int id2) {
MyPerson person1 = persons.get(id1);
MyPerson person2 = persons.get(id2);
if (person1.getAcquaintanceIds().size() > person2.getAcquaintanceIds().size()) {
MyPerson temp = person1;
person1 = person2;
person2 = temp;
}
for (int id : person1.getAcquaintanceIds()) {
MyPerson myPerson = persons.get(id);
if (myPerson.isLinked(person2)) {
this.tripleSum++;
}
}
}
在删边时调用minusTripleSum
方法
private void minusTripleAndValueSum(int id1, int id2) {
MyPerson person1 = persons.get(id1);
MyPerson person2 = persons.get(id2);
if (person1.getAcquaintanceIds().size() > person2.getAcquaintanceIds().size()) {
MyPerson temp = person1;
person1 = person2;
person2 = temp;
}
for (int id : person1.getAcquaintanceIds()) {
MyPerson myPerson = persons.get(id);
if (myPerson.isLinked(person2)) {
this.tripleSum--;
}
}
}
这样,在调用queryTripleSum
方法时只需要return this.tripleSum
即可,避免了重复计算。
使用染色法进行删边
由于并查集的特殊要求,在删边时可能需要将Set分裂成两个Set,在此处我采用了染色法
public void removeRelation(int id1, int id2) {
int fatherId = findFather(id1);
personId2FatherId.put(id2, null);
fatherId2PersonIds.remove(fatherId);
if (!fatherId2PersonIds.containsKey(id1)) {
fatherId2PersonIds.put(id1, new ArrayList<>());
}
bfs4RemoveRelation(id1, id1); // 使用bfs以将所有和id1对应的person“可达”的person的Set标识符设置为id1
if (personId2FatherId.get(id2) == null) {
if (!fatherId2PersonIds.containsKey(id2)) {
fatherId2PersonIds.put(id2, new ArrayList<>());
}
bfs4RemoveRelation(id2, id2); // 使用bfs以将所有和id2对应的person“可达”的person的Set标识符设置为id2
}
}
这样可能在数据中存在大量mr
指令时表现不佳(但是仍然能通过强测和互测)
对Tag的squareAgeSum, ageSum进行动态维护
为了便于计算Tag中所有Person的age的方差,避免冗余的遍历,我对squareAgeSum, ageSum进行了动态维护,动态维护的基本方法同上。
使用bfs求shortestPath
代码如下
public static int bfs(int id1, int id2, HashMap<Integer, MyPerson> persons) {
int depth = -1;
int end = id1;
HashSet<Integer> hasReached = new HashSet<>();
ArrayDeque<Integer> queue = new ArrayDeque<>();
queue.add(id1);
while (!queue.isEmpty()) {
int now = queue.poll();
if (now == id2) {
break;
}
MyPerson person = persons.get(now);
for (int acquaintanceId : person.getAcquaintanceIds()) {
if (!hasReached.contains(acquaintanceId)) {
hasReached.add(acquaintanceId);
queue.add(acquaintanceId);
}
}
if (now == end) {
depth++;
if (!queue.isEmpty()) {
end = queue.getLast();
}
}
}
return depth;
}
使用bfs而不是Dijkstra算法,可以减少对不必要点距离的冗余计算,提高程序的运行效率
使用按边遍历求tagValueSum
经过我严谨的计算分析,按边遍历并不会导致CTLE,于是我在计算tagValueSum
时大胆地采取了按边遍历的方法(其实当初也想到过动态规划的写法,但是懒了,摆了~~)
public int getValueSum() {
int sum = 0;
for (MyPerson person : persons.values()) {
for (int id : person.getAcquaintanceIds()) {
if (persons.containsKey(id)) {
sum += person.queryValue(persons.get(id));
}
}
}
return sum;
}
使用TreeSet求person的bestAcquaintance
我在每个person中都创建了一个TreeSet
以存储其acquaintance
, 比较器如下
public class AcquaintanceComparator implements Comparator<MyPerson> {
private MyPerson person;
public AcquaintanceComparator(MyPerson person) {
this.person = person;
}
@Override
public int compare(MyPerson o1, MyPerson o2) {
if (person.queryValue(o1) > person.queryValue(o2)) {
return -1;
} else if (person.queryValue(o1) < person.queryValue(o2)) {
return 1;
} else {
return Integer.compare(o1.getId(), o2.getId());
}
}
}
特别需要注意对于Integer.compare(o1.getId(), o2.getId());
,不能写成return o1.getId() - o2.getId()
,否则可能会因超过int范围而导致bug。
这样查找和插入的时间复杂度都为O(logn),避免了JML中的二重循环。
性能问题以及bug修复情况
说实话,第三单元中我并没有出现过性能问题,也没有在强测和互测中出现bug。如下是我自己进行评测的过程中产生的bug
重写比较函数时超出int范围
此处上文已经提到过,不再赘述
忽视了浮点数计算可能产生了微小偏差
在计算Tag中age的方差时,应当要这样写
public int getAgeVar() {
if (persons.isEmpty()) {
return 0;
} else {
int ageMean = getAgeMean();
return (squareAgeSum - 2 * ageMean * ageSum + getSize() * ageMean * ageMean) / getSize();
}
}
这里有两个坑点:
- JML要求使用
getAgeMean
获取age的平均数,这里的平均数已经有一定的误差损失,如果我们重新计算可能会忽略这一部分误差损失,从而导致最后结果出错。 - 不能简单使用我们概率统计课上学过的D(X) = E(X^2) - (E(X))^2来计算,因为计算产生的误差损失会不一样。
对规格和实现分离的理解
经过第三单元的学习,我认为:
规格事实上侧重于使用标准化的语言去描述“用户的要求”,并不需要将JML视作“实现的指导”,
而实现事实上侧重于实现用户的需要,在满足用户需要的前提下提高效率
正是因为二者侧重点的分离,才会导致在第三单元中许多方法“规格”和“实现”上的分离,但我认为,这种一定程度的分离是必须的,只有这样才能规格才能正确且完备地描述一个题目的需求。
Junit测试心得
为了通过Junit测试,我们需要仔细阅读JML规格,并且特别留心其中的诸如"pure", “assignable”, "ensure"等关键字。事实上,在我和其它同学进行大量交流过后,我发现大部分人无法通过Junit测试的原因在于没有留心"pure"关键字。
我们可以针对JML规格,逐行逐关键字地编写Junit测试样例(此乃下下策,仅当实在难以通过Junit测试特殊测试点时采用,毕竟对于时间的浪费实在巨大),对于标注有“pure”以及"assignable"的方法,还应该检测该类中其它成员变量是否发生了变化、变化是否符合预期。
体会
Warning: 以下含有情绪输出,谨慎观看
终于写到这部分了,我从三个星期前的第一次作业就开始期待这次博客作业,不为什么,只为好好拷打课程组!!! (m。_ _)/
指导书部分
我们来统计一下每次作业指导书更改了几次
第九次作业
第十次作业
第十一次作业
记录在gitlab上的更改就达14次之多,更不用说很多更改并不会记录在指导书中(甚至直到现在,hw11的指导书有关EmojiIdNotFoundException
传参的描述错误、"noticeMessage.string"范围等内容还没有修改)
这给我们同学们的作业带来了极大的干扰( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
我们也能理解,JML难写,容易产生Bug,但是这是不是也从另一方面说明了JML实用性差呢,毕竟助教们应该算是6系中面向对象课程比较优秀的团体了,连他们写出来的JML都有如此多的漏洞,将其推广到全体6系是否具有合理性呢?(ꐦ ´͈ ᗨ `͈ )
评测部分
如果说之前我还能理解课程组,助教团队的话,这次评测则是彻彻底底让我失望,以下我以流水账的形式记录评测部分引起同学们不满的原因。
-
在hw9的互测中出现了大规模重测的情况,妨碍了同学们的正常互测进度(可能是因为互测时间限制设置不当,个人猜测);
-
在hw9的强测公布后,出现了大规模的部分评测点同学们本地评测时间与课程组评测时间严重不符的情况(甚至相差十倍以上)。在当晚对部分有争议的点进行了重测;
-
在hw10的强测公布后,仍然出现了许多评测点同学们本地评测时间与课程组评测时间严重不符的情况,但这次课程组和助教组的处理极为缓慢,甚至在开放bug修复后才对部分有争议的点进行重测。但此时许多同学已经为自己本不应该进行的所谓bug修复付出了时间精力;
(૭ ఠ༬ఠ)૭ (૭ ◉༬◉)૭⁾⁾⁾⁾ ~‾͟͟͞(((ꎤ >ㅿ<)̂—̳͟͞͞o ヽ(#゚Д゚)ノ┌┛Σ(ノ´Д`)ノ
第三单元的评测成功地将运气纳入到了强测分数的组成部分,对学生的考察维度进一步增加,可喜可贺。我的评价是,难道课程组的服务器甚至不如我自己的电脑?建议明年招安同学的电脑,至少大部分同学的电脑都可以保证在一晚上的时间里对计院240多号人每个人测20个数据点,并且得出令人信服的结果。
后记
好了,输出完了,也算是把这三周相关的怨气发泄了一遍~~~~。
除去上述提到的问题,其实我第三单元的体验还是不错的(至少强测和互测没有WA过),而且JML也确确实实让我学习到了规格化设计的思想,学会了规格化设计的基本方法(虽然我至今仍然觉得不如教更加流行,实用性更强的javadoc)
我对上述情感抒发过程中可能波及到的课程组以及助教道歉,上述发泄更多的是释放自己的怨气,而非要指责课程制度抑或是指责助教团队,毕竟如果我来处理这件事,我也肯定不能比课程组和助教做的好,助教幸苦啦(>人<;)。
我也很期待下一次作业会给我带来什么挑战
当然,如果让我再来一次Unit_3,那我宁愿再做一次Unit_2