OO第三单元总结
一、JML语言总结
JML语言是一种形式化的,面向JAVA语言行为的规格语言,可以描述JAVA程序的数据,方法,类的规格,对其行为进行抽象与限制,体现了契约式编程的思想。主要包含了对于数据规格抽象和方法规格抽象以及综合的类规格抽象。
1.方法规格
- 前置条件:
requires
- 后置条件:
ensures
- 副作用:需要被修改的对象属性及类静态变量,放在
assignable
之后 - pure方法:声明在方法之前,使用
/*@pure@*/
,表示该方法可以被其他方法的JML引用 - 副作用的限制:
\not_assigned(x,y,…)
,这些变量在方法执行时不能被改变 - 正常行为与异常行为:
normal_behavior, exceptional_behavior
,注意不同的行为一定是互斥的
2.数据规格
- 不变式:
invariant
:成员变量在处于可见状态下时必须满足的特性。 - 约束:
constraint
:某操作执行前与执行后该数据需要满足的关系
类中的数据在任意时刻都需要满足不变式与约束
3.类规格
在类中利用规格变量对类的数据结构进行抽象描述,与具体实现无关。同时回个支持类的层次化设计,子类可以对于父类的规格进行适当的扩充,但需要保证:
- 子类重写父类方法时不能与父类的方法时不能与父类的方法规格冲突
- 子类新增和拓展的数据规格不能与父类的数据规格相冲突
对于子类的重写方法,可以对父类的方法规格进行的改变有:
- 减弱父类方法规定的
requires
- 加强父类方法规定的
ensures
此外,对于数据规格
- 如果更新了父类的数据实现,需要检查是否导致父类的不变式无效
- 如果更新了子类的数据实现,需要检查是否导致子类的不变式无效
4. JML相关语法
\nothing, \everything
对于修改使用变量域的限制\result
方法的返回值\forall, \exists
等价于数理逻辑的全称量词和存在量词\old(x)
某一属性变量在方法执行前的值\sum, \max, \min
返回给定范围内表达式的和/最大值/最小值<==>, <==, ==>
推理表达式
5. JML工具链
- openJML: 编译含有JML的代码,内置SMT Solver,实现对于JML语言的静态检测
- JMLUnitNG / JMLUnit,根据JML生成测试文件并进行测试
二、工具链测试
1. JML静态检查
主要使用openJML语言对于代码进行静态检查。在真正使用之前,曾经对这样的代码检查抱有很大的期望,结果是真的很智能,配置环境相当简单,对于各种JML语法,各种JDK开发环境都能兼容呢。
由于Eclipse IDE上拥有openJDK相关插件,所以使用Eclipse进行测试。然而出现了以下问题:
听说jdk需要为jdk8 ,于是,不服气的我决定替换一台完全没有安装过java环境的电脑重新配置环境。
于是,还是这样的结果。
遂放弃
之后采用命令行,对于一个简单的java文件进行了检查
public class test {
/*@
@ ensures \result == a + b;
@*/
public static int add(int a, int b) {
return a + b;
}
/*@
@ ensures \result == a - b;
@*/
public static int sub(int a, int b) {
return a - b;
}
/*@
@ ensures \result == a * b;
@*/
public static int mul(int a, int b) {
return a * b;
}
/*@
@ ensures \result == a / b;
@*/
public static int dev(int a, int b) {
return a / b;
}
}
命令行输入:
java -jar openjml.jar "$@"
openjml -esc test.java
得到运行结果:
openJML 检查出了内容可能出现溢出,除0的情况。
2. JMLUnit自动测试
对简化版的MyGroup执行以下指令
java -jar jmlunitng.jar MyGroup.java
javac -cp jmlunitng.jar **.java
java -jar openjml.jar -rac MyGroup.java
java -cp jmlunitng.jar MyGroup_JML_Test
运行结果如下:
[TestNG] Running:
Command line suite
Passed: racEnabled()
Passed: constructor MyGroup(-2147483648)
Passed: constructor MyGroup(0)
Passed: constructor MyGroup(2147483647)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(java.lang.Object@367f5a65)
Passed: <>.equals(java.lang.Object@873d789)
Passed: <>.equals(java.lang.Object@6abd5366)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Failed: <>.delPerson(null)
Failed: <>.delPerson(null)
Failed: <>.delPerson(null)
===============================================
Command line suite
Total tests run: 16, Failures: 6, Skips: 0
===============================================
分析发现,这样的测试把重点全部放在了边界数据(null,int类型的边界等),大量测试这样的边界甚至无法完成对于程序基础功能的测试,更别说程序的鲁棒性了,测试价值几乎为null。自己编写Junit和对拍器不香吗?
三、作业分析
第三单元的作业相对于前两次作业,更大的需求在于细节架构与设计的掌握。在给定JML的情况下,需要寻找好的实现方式,包括:合适的数据结构实现对象,合适的算法实现方法,选择合适的容器与存储检索方法,合适的架构调整,在保证正确性的前提下实现更高的效率。自己的完成情况却不如前两个单元,主要出现的问题在于算法复杂度过高导致的超时问题。
1. 第一次作业
主要实现了MyPerson与MyNetwork两个类,建立了初级的人际关系网络。
MyPerson类的 acquaintance 属性采用了 HashMap
,分别对应熟人的id和Person对象, 同样的 value 属性采用了 HashMap
,对应id与其value数值,使用HashMap
可以在操作以查找为主的属性中,提高时间效率。
MyNetwork类的people集合同样采用的HashMap
的容器,利用了ID与Person一一对应的属性,方便需要查找的方法,特别是很多方法都需要判断Person是否在集合内,提高了查找的效率。建立HashMap
,保存一个人的熟人集合(即为图中结点的邻接点),方便搜索。
第一次作业中相对复杂的方法为public boolean isCircle(int id1, int id2)
,根据JML语言的描述,需要判断id1与id2两人是否有关系,从而抽象为两者是否在关系网络中联通。由于第一次作业对于时间复杂度的要求较为宽松,采用的是传统的广度优先搜索(DFS)方法。
第一次作业在强测与互测中未发现bug。
2. 第二次作业
在第一次作业的基础上,将Person构建成若干个组(Group),支持各种组内查询,同时对Network的功能进行拓展。通知,增加对算法实现复杂度的要求。
本次作业的JML描述都非常的直观,看起来都没什么坑,二重循环看起来挺和谐的,实则死板按照JML写就出了大问题。
MyGroup类中主要实现了对于组中Person的查询以及属性求和,平均值,方差的操作,部分操作如果不采用并查集,会导致时间复杂度过高甚至栈溢出。组内有许多需要对于集合进行遍历的方法,增加了便于遍历的动态数组存放Person。
首先是对于年龄平均值的查询public int getAgeMean()
以及对于年龄方差的查询public int getAgeVar()
,如果不将平均年龄作为属性在添加时更新,计算方差时将多次调用,增加的时间消耗不可小觑。
此外,则是我在实现代码时忽略的因素,在public int getValueSum()
与public int getRelationSum()
中,JML语言使用了双重循环:
/*@ ensures \result == (\sum int i; 0 <= i && i < people.length;
@(\sum int j; 0 <= j && j < people.length && people[i].isLinked(people[j]); 1));
@
*/
public /*@pure@*/ int getRelationSum();
于是我也使用了双重循环:
public int getRelationSum() {
int sum = 0;
for (int i = 0; i < peoplebianli.size(); i++) {
for (int j = 0; j < peoplebianli.size(); j++) {
if (peoplebianli.get(i).isLinked(peoplebianli.get(j))) {
sum++;
}
}
}
return sum;
于是强测送给我了一个TLE,互测送给我了8刀。
仔细分析,出现这样的问题,仍然在于“双重循环不会太慢吧”的侥幸心理,反应了我对于算法时间复杂度分析与时间消耗估计能力的不足。
替换方案:使用“修改即记录“的并查集的思想,设置属性 relationsum
与 valuesum
,在可能修改属性的方法:Group.addPerson()
(向组内添加人),Network.addRelation()
(网络中添加关系)中进行查询与修改。
3. 第三次作业
第三次作业在第二次作业的基础上,添加了组的删除功能以及网络的部分高阶查询功能,包括寻找最小路径,强连通,以及独立块数。
实现组删除功能时,顺带进行了对于平均年龄和的更新,却忽视了可能除0的bug。这样的bug在后续测试与对拍中都没有被重视,导致互测再中4刀。
实现最小路径,采用的经典迪杰斯特拉算法,在 addperson 时建立邻接矩阵,addrelation 时修改邻接矩阵,并且在 queryMinPath 时调用迪杰斯特拉算法进行计算。没有使用堆优化,但是强测并没有因为求最小路径而出现超时。
实现判断两点是否强连通,最初的想法为搜索+标记路径+搜索,但是自己画出了可以hack这一想法的图结构,只能另寻道路。最后使用的方法为寻找割点,即分别标记图中的每一个点,寻找是否有id1到id2的路径,若都有路径,则说明不存在割点,从id1到id2一定有回路。殊不知这样的算法虽然简单明了,但是时间复杂度在极端条件下会相当大。本以为20条指令不可能被hack到,结果还是在强测中因为强连通超时送出了两个点。
4.整体分析
UML类图如下:
第三单元的作业,我并没有在架构上有过多的思考,而是仅仅完成了题目要求的继承接口类的设计,在架构上就显得非常的平庸。此外,根据JML语言循规蹈矩写出来的代码的性能上是非常的差的,经过第二次以及第三次作业的经历,我发现契约式的编程也需要在算法以及容器集合的选择上下功夫,要不然很可能在时间上出大问题。此外,例如除0这样的细节却重要的错误,确实需要引起足够的重视。
在阅读他人代码的过程中,我也发现自己在算法选择与实现能力上有明显的不足,再加上时间消耗预估能力的缺乏,导致了这一单元频繁出现超时的情况。
在本单元的测试阶段,主要采用的是JUNIT基本功能测试加上自动生成大量数据与他人对拍的两种方式。编写JUNIT测试代码可以帮助我们更加深刻的理解规格规定的功能,并进行相应的基础功能的测试,但是程序鲁棒性测试仍然需要大量数据黑箱测试。
做的不好的是测试只重视了正确性,没有重视性能。
四、总结与感受
通过JML语言的学习,我深刻了解到了规格抽象对于软件开发质量的作用,也能够通过仔细研读规格,了解类,方法,属性的作用,之后再进行代码的编写。也通过惨痛的经历了解到,就算有JML语言的引导,实现代码也需要自己使用更加高效的容器与算法,在架构的层面上也不能直接照搬规格,需要有自己更好更清晰的架构才行。
对于JML工具链,我只想说,珍爱生命,远离JML工具链。不成熟的工具链不但不能简化开发测试的过程,还会浪费相当多无用的时间与努力。