BUAA-OO-第三单元作业总结(JML实现社交系统模拟)
前言
进入了第三单元之后,我们首先就是认识了何为JML,并且依据JML完成了所有的三次作业,如果将这一单元简单化处理,似乎就是根据注释写代码,看似简单,然而三次作业下来,笔者又有了些新的认识,发现并不是开始所想象的那么简单,虽有收获,但是做出来的结果却令人不甚满意,犯了许多不该犯的错误,下面笔者就来对这一单元的作业进行一些简单的总结。
一、JML语言的理论基础以及应用工具链的梳理
1.JML语言理论基础
JML(Java Modeling Language)是用于对Java程序进行规格化设计的一种表示语言。JML是一种行为接口规格语言(Behavior Interface Specification Language,BISL),基于Larch方法构建。BISL提供了对方法和类型的规格定义手段。一般而言,JML有两种主要的用法:其一是开展规格化设计。这样交给代码实现人员的将不是可能带有内在模糊性的自然语言描述,而是逻辑严格的规格。其二是针对已有的代码实现,书写其对应的规格,从而提高代码的可维护性。这在遗留代码的维护方面具有特别重要的意义。可以说,JML能够很好地实现契约式设计,只要能够满足JML的要求,就能够保证程序的正确性。下面从注释结构、JML表达式、方法规格、类型规格进行对于JML语言理论基础的梳理。
(1)注释结构
-
requires子句定义该方法的前置条件(precondition),也就是在对应情况下执行对应的代码;
- 副作用范围限定,assignable列出这个方法能够修改的类成员属性,\nothing是个关键词,表示这个方法不对任何成员属性进行修改,所以是一个pure方法。
- ensures子句定义了后置条件,也就是方法要改变什么,返回什么,是执行后的结果。
(2)JML表达式
- \result表达式:表示一个非void 类型的方法执行所获得的结果,即方法执行后的返回值。
- \old( expr )表达式:用来表示一个表达式expr 在相应方法执行前的取值。
- \not_assigned(x,y,...)表达式:用来表示括号中的变量是否在方法执行过程中被赋值。如果没有被赋值,返回为true ,否则返回false。
- \not_modified(x,y,...)表达式:与上面的\not_assigned表达式类似,该表达式限制括号中的变量在方法执行期间的取值未发生变化。
- \nonnullelements( container )表达式:表示container 对象中存储的对象不会有null。
- \type(type)表达式:返回类型type对应的类型(Class)。
- \typeof(expr)表达式:该表达式返回expr对应的准确类型。
- \forall表达式:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。
- \exists表达式:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。
- \sum表达式:返回给定范围内的表达式的和。
- \product表达式:返回给定范围内的表达式的连乘结果。
- \max表达式:返回给定范围内的表达式的最大值。
- \min表达式:返回给定范围内的表达式的最小值。
- \num_of表达式:返回指定变量中满足相应条件的取值个数。
- 集合构造表达式:可以在JML规格中构造一个局部的集合(容器),明确集合中可以包含的元素。
- 子类型关系操作符: E1<:E2 ,如果类型E1是类型E2的子类型(sub type),则该表达式的结果为真,否则为假。如果E1和E2是相同的类型,该表达式的结果也为真。
- 等价关系操作符: b_expr1<==>b_expr2 或者b_expr1<=!=>b_expr2 ,其中b_expr1和b_expr2都是布尔表达式,这两个表达式的意思是b_expr1==b_expr2 或者b_expr1!=b_expr2 。
- 推理操作符: b_expr1==>b_expr2 或者b_expr2<==b_expr1 。对于表达式b_expr1==>b_expr2而言,当b_expr1==false ,或者b_expr1==true 且b_expr2==true 时,整个表达式的值为true 。
- 变量引用操作符:除了可以直接引用Java代码或者JML规格中定义的变量外,JML还提供了几个概括性的关键词来引用相关的变量。\nothing指示一个空集;\everything指示一个全集,即包括当前作用域下能够访问到的所有变量。
(3)方法规格
- 前置条件(pre-condition):前置条件通过requires子句来表示: requires P; 。其中requires是JML关键词,表达的意思是“要求调用者确保P为真”。注意,方法规格中可以有多个requires子句,是并列关系,即调用者必须同时满足所有的并列子句要求。如果设计者想要表达或的逻辑,则应该使用一个requires子句,在其中的谓词P中使用逻辑或操作符来表示相应的约束场景: requires P1||P2; 。
- 后置条件(post-condition):后置条件通过ensures子句来表示: ensures P; 。其中ensures是JML关键词,表达的意思是“方法实现者确保方法执行返回结果一定满足谓词P的要求,即确保P为真”。同样,方法规格中可以有多个ensures子句,是并列关系,即方法实现者必须同时满足有所并列ensures子句的要求。如果设计者想要表达或的逻辑,这应该在在一个ensures子句中使用逻辑或( || )操作符来表示相应的约束场景: ensuresP1||P2; 。
- 副作用范围限定(side-effects):副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。从方法规格的角度,必须要明确给出副作用范围。JML提供了副作用约束子句,使用关键词assignable 或者modifiable 。从语法上来看,副作用约束子句共有两种形态,一种不指明具体的变量,而是用JML关键词来概括;另一种则是指明具体的变量列表。
- signals子句:signals子句的结构为signals (***Exception e) b_expr; ,意思是当b_expr 为true 时,方法会抛出括号中给出的相应异常e 。需要注意的是,所抛出的既可以是Java预先定义的异常类型,也可以是用户自定义的异常类型。此外,还有一个注意事项,如果一个方法在运行时会抛出异常,一定要在方法声明中明确指出(使用Java的throws 表达式),且必须确保signals子句中给出的异常类型一定等同于方法声明中给出的异常类型,或者是后者的子类型。还有一个简化的signals子句,即signals_only子句,后面跟着一个异常类型。signals子句强调在对象状态满足某个条件时会抛出符合相应类型的异常;而signals_only则不强调对象状态条件,强调满足前置条件时抛出相应的异常。有时候,为了更加明确的区分异常,会针对输入参数的取值范围抛出不同的异常,从而提醒调用者进行不同的处理。这时可以使用多个exceptional_behavior。
(4)类型规格
- 不变式invariant:不变式(invariant)是要求在所有可见状态下都必须满足的特性,语法上定义invariant P ,其中invariant 为关键词, P 为谓词。
- 状态变化约束constraint:对象的状态在变化时往往也许满足一些约束,这种约束本质上也是一种不变式。JML为了简化使用规则,规定invariant只针对可见状态(即当下可见状态)的取值进行约束,而是用constraint来对前序可见状态和当前可见状态的关系进行约束。
2.应用工具链
- Openjml:OpenJML是Java程序的程序验证工具,目标是为JML和当前Java实现一个完整的工具,供用户轻松使用以指定和验证Java程序。可以通过使用扩展的静态检查工具或使用运行时断言检查功能来执行。有多种方法检查JML的正确性和完整性。它不是定理证明器或模型检查器本身。OpenJml将JML规范转换为SMT-LIB格式,并将Java + JML程序隐含的证明问题传递给后端SMT求解器。
- JUnit:JUnit是一个Java语言的单元测试框架。JUnit有它自己的JUnit扩展生态圈。多数Java的开发环境都已经集成了JUnit作为单元测试的工具。用户可以在其中编写自己的单元测试代码,虽然很麻烦很累,但对于长期维护开发还是很有裨益的。
- JMLUnitNg:可以针对JML自动化生成测试用例,但似乎有很大的局限性,对于一些容器的识别不是很成功,用例也比较偏向于极端化,测试边缘数据尚可,对于大量随机测试似乎不是一个好的选择。
二、SMT Solver验证结果
经过重重磨难,上网络上找博客以及在同学的帮助下,总算是跑通了openjml,下面笔者来报告一下验证结果。
在将java文件拖入到openjml解压后的文件夹后,输入java -jar .\openjml.jar -exec C:\Users\PC\Desktop\openjml\Solvers-windows\z3-4.7.1.exe -esc -dir C:\Users\PC\Desktop\openjml\com\oocourse\spec3\main\xxx.java这一大堆超长指令后,便可以针对该java文件进行检测,检测的结果是Person类并没有发现异常和错误,而另外两个类则报了错,下面的是Group.java的报错结果:
由于Network类的报错结果更多,因此在这里就只分析一下Group的结果,可以看出,由于没有实现一个Person数组,当提到这个数组的时候openjml似乎就不知道是什么了,所以报出了找不到符号的错误,Network类的错误则更多,由于涉及到众多的exception,导致报错信息根本没法看,所以这里我选出了一些超级简单方法进行处理。
检查的结果就理想的多了,但是这也就意味着这个东西局限性太大,只能查一查简单的方法,稍微一复杂就疯狂报错,比如Network里面的众多有关图的操作,在jml描述的时候采用了数组作为中间容器的方式进行规约,人倒是好理解了,但是让openjml看懂就难了,再加上个人要使用现成的容器进行代码的实现,这openjml能发挥的作用也就仅限于一些查名字查id的方法了,各种复杂的图操作都难以确定是否是完备的,也可以看出这个东西不是非常成熟,感觉对于jml的撰写帮助不是很大。
三、JMLUnitNg生成测试用例以及对其简要分析
要部署JMLUnitNg,需要按照以下步骤进行操作:
- java -jar jmlunitng.jar test/test.java
- javac -cp jmlunitng.jar test/ *.java
- java -jar openjml.jar -cp -rac test/test.java
- java -cp jmlunitng.jar test/test_JML_Test
在这里笔者将待测试的MyGroup类截取出一些方法单独写了个test类,然后加入到openjml的文件夹去,大概的目录树如下:
生成的测试用例结果如下:
综合该结果以及其他同学的结果可以发现,这个虽然名为自动生成,但是基本数据都集中在比较极端的数据上,比如0,MAX_INT,MIN_INT等等,这样的结果可能照顾到极端的情况,但是覆盖程度极其低下,完全是不随机数据,此外对于Hashmap等已有容器支持也不到位,这也就是说虽然这个插件看似功能强大,但实际上还不如自己写个对拍器或者是干脆手动构造数据来的实在可靠,所以根本就不能够指望着靠这个来检测自己的程序有无问题,所以使用JUnit来自己写测试数据还是很有必要的。
综合来看,现有的JML工具链根本对程序设计根本没有多大助益,工具链的缺失和不好用也让笔者完成此次作业时感到不知所措,十分痛苦,整个过程糊里糊涂,查资料的时候还都是往年博客,帮助都不大,真心希望未来工具链可以更加完善。
四、架构设计和模型建构策略
1.第九次作业
本次作业的uml类图如下:
(1)架构设计
本次作业在算法上不是很难,唯一一个需要思考的就是isLinked方法,只需要使用bfs即可,因此基本按照jml来完成的本次作业,实现了一个MyNetwork和MyPerson类,实现各种方法,MyNetwork中原本利用Arraylist,后改用HashMap存储people,另外实现了一个SearchNet类来进行有关图操作的实现,没有什么思考难度,只要能够按照jml严格实现,就没什么问题。
(2)模型构建
这次作业并没有将整个社交系统抽象成一个数组表示的图,也没有实现一个专门的图类,单纯的利用了类似于邻接表的形式进行数据的存储以及遍历(利用各种容器实现),好处当然就是类似于jml的格式,检查起来不需要专门做一个复杂映射,比较容易,当然缺点就是各种方法实现起来有些麻烦,由于接口并不能够完全满足笔者的需要,因此加入了一些额外的方法,比如找人啊,返回keyset之类,这样就会产生大量的强制类型转换,代码读起来比较难看,然而对于笔者而言,抽象程度不高似乎更好理解一些,所以在以后的迭代中也没有对此作出什么变化与改进。
2.第十次作业
本次作业uml类图如下:
(1)架构设计
本次作业基本延续了上一次作业的设计,增加了一个MyGroup类,其他的类基本没变,由于这次的时间卡的比较严,所以对于时间复杂度为O(n^2)的方法,均在MyNetwork设立了HashMap的缓存,在MyGroup中也加入了相应的缓存,MyPerson加入了所在Group的HashMap,每当加入一个人或一个关系,就重新计算Agesum等数据,然后在询问平均值以及方差的时候直接读缓存计算,而不是重新计算,这样就省下了很多时间,结果表明也确实时间非常充裕,此外这次对于图的操作没有变化,因此算法上与上次相比反而难度降低了。
(2)模型构建
这次作业依旧延续了上一次邻接表的模型,没有专门对图进行构建,优缺点均已经体现,这里不加赘述。
3.第十一次作业
本次作业uml类图如下:
(1)架构设计
这一次的新增需求就需要比较多的算法知识了,由于并查集啥的笔者还没弄明白,于是就采用了bfs莽的策略,最短路径就是堆优化的Dijkstra算法,点双就按照标程的暴力枚举删点加遍历,也不会啥tarjan的,所有的有关方法都加到了SearchNet里面,同时为了使用Java内置的优先队列,实现了个Node类,内部有一个比较器,比较到起点的最短路径distance,依据这个来使用优先队列,其他的类原本方法都不变,加入了许多新方法,同时在MyNetwork里面加入了一个Hashmap来存储money,并且将所有图操作的visited记录访问过的点改变实现方法,由Arraylist换成HashSet,加快contains等方法的查询速度,避免了可能的bug。
(2)模型构建
这一次增加了Node类,作为查询最短路径中的结点来处理,内部有Person和距离起点的distance,每一次查询的时候都进行重新计算,利用优先队列的小顶堆来进行加速查询,同时继续扩充了SearchNet类,将其作为图操作专用类,内部包含所有需要的图操作方法,这样就不用实现专用的图类,基于已有的邻接表实现不变,不继续抽象成图,也可以实现所有的方法,但是缺点也依旧继承了下来,并在实现SearchNet中体现的非常多,由于涉及到巨多bfs操作,所以就需要涉及非常多的强制类型转换,并且代码实现起来感觉非常不便利,这是需要进一步改进的。
五、代码实现bug和修复情况
1.第九次作业
这一次作业强测真是爆炸了,只有5分(中测真就是不测啊),互测也没进去,当时看到这个结果,感觉很是失落,究其原因,就是jml理解上出了问题,主要就是addrelation缺少了一些情况的讨论,以及NameRank循环体的条件出了错误,许多人犯错的isCircle我还真没错,总结一下,都是自以为是,轻视了jml的作用,感觉自己认真理解了但其实没有,也没认真写测试,这也就给笔者重要的警醒,在之后的作业中,笔者就格外重视正确性的问题,也构造了大量的样例进行测试,正确性就没有出了问题。
2.第十次作业
这一次作业也出了问题,主要就是超时的问题,超时的原因就是算方差等复杂度有O(n^2)的方法时间爆了,原因在于每次询问都重新算了一遍,当有大量的类似请求时(问方差或者总量的),每次重算太费时间,导致ctle,所以增加了缓存的设置,每次加入新人和新关系的时候就重新计算,当询问时就直接读缓存,这就没有问题了。
3.第十一次作业
这一次作业又出了问题,还是超时的原因,原因是stronglink笔者使用了两次dfs进行查找,正确性是没有问题,但是复杂度超级高,果不其然时间爆了,又一次喜提两个ctle,后来更换了标程的做法,就是暴力枚举删点后判断是否连通,时间上就不会再有问题了。现在想想,感觉助教说的是对的,估计时间复杂度是个非常重要的能力,笔者就非常缺乏这种能力,不能够估计时间复杂度,也就难以采用真正合适的办法了,这是未来需要注意的。
六、规格撰写和理解上的心得体会
经过两次实验和三次作业的训练,对于jml也算是有了一点入门吧,个人觉得jml规格的撰写是一件非常困难的事情,由于工具链的不完善,想要完全正确地写出jml规格就要不断修改,实验当中根据代码写规格和根据规格写代码感觉非常难,一方面是不熟练,一方面短时间内也不知道代码要干什么,而作业中由于基本都是照着规格写代码,因此对于每个方法的作用也都有比较好的认识,写起来也不是非常困难,但是如果一味照着规格写也不能够很好地完成任务,规格只是给你一个框架,要告诉你干什么,真正实现的时候要做出适当的改变,否则还会出现性能的问题,或者是架构还可以继续优化,这单元算是给我上了这样的一课,既让我认识了什么是契约式编程,也让我复习了一些必要的图论知识,总的说来,感觉这单元任务完成的不令人满意,远没有达到自己所想要的效果,连最容易拿满分的一次作业也走了麦城,真的心情感到十分复杂,不论如何,也只能够把目光投向未来了,再接再厉吧。