文章目录
一. 写在前面
lab3相对而言还是更偏向于利用我们所学的诸多策略去实现一个可维护可复用软件,而这个软件的质量更多来源于我们所使用的策略。lab4中我们需要针对软件最重要的两个性质,也就是健壮性和正确性,对于lab3的程序进行一定的修改。
除此之外,我觉得lab4中最棒的一部分是3.6Debugging部分。这一部分是让我们对于几乎没有任何注释的有问题的程序进行修改,真的让我切身的体会到了代码维护者和不爱写注释的代码编写者之间的不共戴天之仇,是一个非常有教育意义(确信)的实验。
二. 新的实验环境配置
本次实验引入了一个新工具,即SpotBugs。这是一种能够自动检测可能存在的bug的工具(当然肯定不能所有bug都检测出来,不然3.6还要咱们手动debug干嘛),还是很有用的。安装方式很简单,将spotbugs的jar包下载下来,塞进eclipse的plugins文件夹里,然后重启eclipse就ok了。
注意,虽然我们在lab0的配置里面就应该加上-ea激活assert,lab4开始之前老师终于讲到了这部分的原理,所以如果之前忘记加-ea,现在还为时不晚(笑)
三. 实验具体内容
3.1 Error & Exception
Exception 分为两类,checked exception 和 unchecked exception。前者继承了 java.lang.Exception 类,必须被 throws 出去或者被 catch 住,否则静态检查就会报错。后者继承了 java.lang.RuntimeException,和 Error 一样,没有上述必须被处理的要求。虽然也可以被 catch,但是一般而言没人这么干。
实验3.1的内容就是让我们针对给出的一些要求(可以再增加)编写 checked exception,从而对这些特殊情况进行处理。可以看到,基本上,实验手册中给出的要求都是针对输入文件的格式或者依赖关系错误的,而我又加了一个 BlockTooManyTimesException,可以防止用户对一个一共5站的火车进行4次及以上的阻塞。总而言之,我们这里利用 checked exception 检查的,主要是Client(主要是用户端)不合法的输入,从而保证程序在异常输入情况下不至于直接爆掉,可以优雅地退出,也就是健壮性。
3.2 Assertion & Defensive Programming
这里说到代码的防御式策略,基本就是分为pre-condition,post-condition以及RI。RI部分如果lab3好好地按照所学的标准进行编程了,就基本不用做出什么改动。
先说pre-condition。需要考虑pre-condition的情况,无非就是两种,用户输入,以及参数传递。由于用户输入的可能性很多,所以我采取的思路是让用户在while(true)循环中输入,然后用正则表达式判断用户输入的合法性。只要你输不对,我就不让你出循环。这样就可以保证我们程序获得的用户输入绝对是正确的。
而对于参数传递,一般而言,在ADT部分,错误的参数传递有时会导致不符合RI的状态出现,所以对于这种参数,用checkRep()监视即可。当然也有无法用checkRep()检测的,例如上面提到的block次数,就需要pre-condition了。而在ADT之外的类,比如说API或者客户端,对于存在的许多方法的参数传递,pre-condition要常见许多。
至于post-condition,一般是在需要一定的计算和逻辑推导的方法中比较常见(毕竟setters里面直接用RI就ok了),计算完了之后,对于结果进行一些基本的约束,不满足约束说明肯定有错误发生。
而RI,其意义在于监测ADT的状态是否合法,一旦ADT出现了不合理的状态,就可以用checkRep()来报错。我们在这里要注意它和post-condition的异同。虽然二者,都位于方法的最后,都用assert来报错,但二者的本质目的还是不同的。个人理解,RI的意义更多在于检测ADT状态是否合法,主要针对的是ADT,而post-condition更多针对我们编写的方法的正确性。RI无法检查的部分,就交给post-condition来处理。
3.3 Logging
这一部分类似于lab3的board部分,都是需要利用现成的API来进行编程。我利用的是 java.util.Logging 库,当然也可以使用类似有log4j之类的外部库。为了能保证在每个函数中都能运行,我设计的实例和配置方法都是静态的,如下:
public class StaticLog {
private final static DateTimeFormatter indtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
public static final Logger logger = Logger.getLogger("log for entries");
public static void initLog() {
try {
Locale.setDefault(new Locale("en", "EN"));
FileHandler fileHandler = new FileHandler("src/log/EntryLog.txt", true);
fileHandler.setFormatter(new SimpleFormatter());
logger.addHandler(fileHandler);
} catch (SecurityException e1) {
e1.printStackTrace();
} catch (IOException e1) {
e1.printStackTrace();
}
}
...
题干中要求日志可查询功能,并且实现用户输入过滤条件(或多种条件的组合)查询。日志查询本身的思路是,由于java.util.logging类没有缓存日志的功能,我们只能将日志写入一个文件中,然后查询的时候读文件即可。
本节最复杂的部分是过滤条件查询。我的思路是查询的时候,先将文件中的全体日志读入一个集合类中,然后过滤方法的思路就是遍历一遍集合类,把不符合条件的日志项扔掉。从而,不同的过滤方式用不同的方法实现,如果用户要求多种过滤条件的组合,那么只需要对集合类进行多次过滤方法的调用即可。
3.4 Test & Eclemma
在这一部分,我和其它一些同学的覆盖度并不高,只有不到60%的覆盖度。个人分析原因在于app中的main函数的大量代码无法被eclemma自动测试到,从而影响到了覆盖率。但是就像我在上一篇博客中提到的那样,覆盖率并不意味着一切,只要我们对于每一个类的各种情况考虑得足够周全,总体覆盖率偏低也并不是问题。我认为Eclemma的最大作用在于提示我们哪一句没有被测试覆盖到,从而提醒我们补充漏下的可能性。
3.5 SpotBugs的使用
SpotBugs是一个很有用的工具,一些常见(但不易被我们发现)的错误,比如equals写成了==,都可以用SpotBugs查出来。我这里发现的错误是我对于3个entry实现了clone()方法,但是并没有实现Cloneable接口。修改之后重新运行,就可以发现报出的错误消失了。
3.6 Debugging部分
3.6.1 EventManager
这一部分是我认为整个lab4中难度最大,也是最有价值的一部分。程序本身很简单,就是一个计划项记录的程序,用一个TreeMap来记录一年的每一小时的计划项数目。我们可以向程序中添加新计划项,也可以知道一年中最忙的一个小时中,我们需要处理多少个计划项。
题目中的代码的思路采用了差分作为TreeMap的value。就是说,某一时刻的计划项数目,是这一时刻以及之前的每一时刻的差分之和。这样,在加入新计划项的时候,只需要将开始时间点的差分加一,结束时间点的差分减一,就可以保证在两时间点之间的计划项数目加一而不影响其他时间。
可以看到,这个算法比较巧妙,而且相当不直观。就算是看到正确的代码,想要理解都颇要费一番功夫。但是给出的代码不仅有错误,而且是有严重的逻辑错误,导致我们能够看到的,就只是将TreeMap的全体value加和,和一些莫名其妙的错误操作。在没有注释的情况下,想要看懂这一算法的原本思路,几乎做不到。我看到很多同学,在见到这道题的时候完全不知道从何下手,修改的过程也很痛苦,想必写出这份代码的人如果身份公开,会遭到非常悲惨的对待吧。就像我前面说到的,这道题的最大意义并不在于让我们学会debug,更大程度上在于敦促我们以后写代码好好写注释。
3.6.2 LowestPrice
这个算法的思路是采用递归的思想,遍历所有可能的价格选取策略,然后找到其中最优的一种。相比于上一个,思路要清晰的多。这一代码的错误主要是出现在循环的跳出条件上,只要思考一下程序的控制流会如何变动,就可以发现错误。即使有漏网之鱼,通过足够多的测试用例也可以找到问题的位置。
当然,这个算法的主要问题在于其输入必须满足非常多的假设,所以pre-condition必须极为严格,否则算法就会出现问题。
3.6.3 FlightClient/Flight/Plane
这个算法是一个飞机&航班的分配算法,类似于一个lab3的简化版。我在里面找到了以下错误:
- ADT中的equals()方法中进行字符串比较时,==比较的是指针地址,而不是存储的字符串,应该改成equals。
- 调用判定飞机p和q是否相等时,p一定不是null,但q有可能是null。故将q.equals(p)改为p.equals(q),从而防止空指针异常的发生。
- 确定两时间段没有交叉时,如果采用“某一个时间段的起止时间点均不在另一个时间段中”这一思路,就会出现一个非常严重的问题:如果两个时间段完全重合,就会发现任意一个时间点都不在另一个时间段中,但显然这两个时间段是有交叉的。因此需要将这一特殊情况纳入考虑范畴中。
- 如果想要排序某种数据结构,至少要给这种数据结构实现Comparable接口以及CompareTo方法。
- 剩余的问题都是循环跳出问题,这里不再赘述
4. 总结
虽说已经说过两遍了,但我还是要强调,无论如何,写代码的时候一定要把你想干什么写清楚啊啊啊啊!编程不好好写注释,真的会被未来看代码的自己和同行骂到死。