第三单元总结——JML契约式编程
一.JML语言的理论基础和应用工具链
jml是一种形式化规范语言,架构师完成设计,使用规格,将输入和输出,返回值,副作用的约束明确指定,然后由程序员根据规范实现代码。jml的整体书写风格像JavaDoc, 但是写法明显更加规范化。JML的语法层次很多,下面对我们课程要求的jml程度进行总结:
模型字段:
采用诸如
@public instance/static model 修饰符 类型 元素名称
的方式进行声明
-
instance表示实例成员,在使用时必须对对象进行实例化。
-
static表示静态成员,允许通过类型进行引用。
-
修饰符:non_null, 表示这个成员非空。
jml 的常见表达式有:
-
\result:结果
-
\old(expr):表达式在执行方法前的取值
-
\not_assigned()表示在方法的执行过程中,括号内的变量没有被赋值。如果赋值了,则应该返回false
除此之外,JML还有诸如typeof(), type(), not_modified()等原子表达式,但是在我们的课程中没有出现。
量化表达式有:
-
\forall: 全称量词
-
\exists:存在量词
-
\sum 求值之和
-
\max 求最大值
-
\min 求最小值
-
\nothing:空集
操作符
-
Java中定义的操作符
-
==>和离散数学的->符号相同的含义,表示“蕴含”
-
<==>等价符号
-
<=!=>不等价的符号
方法规格
前置条件约束requires: 如果使用了这个分类,程序员需要保证输入满足该前置条件。
对于每个方法,根据不同的输入条件,往往会指定不同的输出。从宏观层面,有normal_behavior和exceptional_behavior, 前者为正常输入应该进行的行为,后者是异常输入应该进行的行为。除此之外,每个大类别还可以继续细分。分别对应不同的requires(前置条件),assignable(副作用,也就是可以对哪些属性进行操作,也即可以修改的数据),ensures(后置条件,表明程序员实现方法需要遵守的约束)。对于需要抛出的异常,使用signals (Exception e)expr
的形式。
当涉及一个方法中多个功能规格的描述时,应该使用also
进行分割。
除此之外,还可以使用invariant和constraint定义数据需要满足的条件。这些条件一定要在程序执行时一定要满足,否则则视为错误。
jml工具链:
openjml: 底层使用SML Solver, 有cvc4和z3(目前均停止更新),进行jml规格语法的检查,通过形式化方式根据jml验证写的程序的正确性等。
JMLUnitNG/ JMLUnit:根据JML自动生成测试样例并进行测试。
使用OpenJML进行验证
看了看往届博客和讨论群,一年一度xx06人民群众喜闻乐见的鞭尸OpenJML环节又来了。
久闻openjml大名,直接下载。根据“先遣部队”的经验,怂怂地退回HW9, 直接运行:
java -jar .\openjml.jar -exec C:\Users\alang\IdeaProjects\HW9\openjml\Solvers-windows\z3-4.7.1.exe -esc C:\Users\alang\IdeaProjects\HW9\src\*.java -encoding utf-8 -cp ./
真的猛士,敢于直面满屏幕的errors和warning.
先把一些显而易见的语法错误修正一下,诸如少分号,多括号这种。
Invalid expression or missing semicolon here
@ signals (PersonIdNotFoundException e) !contains(id));
发现还有9个错误,全部在Network.java中,有一些是“找不到符号”,涉及OpenJml并不识别的表达方式,进行人工修正,人为替换了OpenJML识别的表达。修改完后,还剩5个,全部是
A \old token with no label may not be present in a requires clause
查了查资料,似乎是\old不能用在requires中,去掉\old, 终于将它跑通了。
一起感受OpenJML的神奇:
这是什么?用于检查的程序自己崩了?那看来还需要个检查检查程序的检查程序 (套娃行为)
除此之外,还有大量的“The prover cannot establish an assertion”警告,在全部工程代码中触发了大概100个,代表这个类的正确性无法检测。
提供一张最后的结果作为参考。吐槽归吐槽,对于实现各异的代码,想要做形式化验证和证明,难度可想而知。虽然JML现在暂时被搁置了,向曾经或正在做类似工作的前辈们致敬!
更改跑通后的包含JML的代码见github
三. 部署JMLUnitNG/JMLUnit,针对Group接口的实现自动生成测试用例
下载jmlunitng工具包,放置到MyGroup.java的出现路径下,重命名为jmluniting.jar,将Group.java中的规格复制到MyGroup.java中(经实测,该工具不支持读取到父类/接口的JML进行判断,而截止笔者发文,博客中很多同学根本没做这一步,得到的应该是虚假的JML测试),然后执行
java -jar jmluniting.jar MyGroup.java
依旧是大量的错误,诸如:
.\MyPerson.java:20: 错误: 类型变量数目错误; 需要2
acquaintance = new HashMap<>();
看了下,发现是工具不支持自己推断类型,将所有的容器类型都进行完整声明,并适当修改亿点点JML和源代码使其符合该工具的用法,例如将Arraylist换成数组(对,它竟然不支持Arraylist, 心想这支持一下也不难啊)。对于几个确实无法跑通的函数,只能忍痛割爱删掉。重新运行,成功生成测试文件。
编译文件
javac -cp ./jmluniting.jar -encoding utf-8 *.java
最后执行:
java -cp ./jmluniting.jar MyGroup_JML_Test
成功进行测试,测试结果如下:
Failed: racEnabled()
Passed: constructor MyGroup(-2147483648)
Passed: constructor MyGroup(0)
Passed: constructor MyGroup(2147483647)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Failed: <>.delPerson(null)
Failed: <>.delPerson(null)
Failed: <>.delPerson(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(java.lang.Object@29444d75)
Passed: <>.equals(java.lang.Object@1517365b)
Passed: <>.equals(java.lang.Object@44e81672)
Passed: <>.getAgeMean()
Passed: <>.getAgeMean()
Passed: <>.getAgeMean()
Passed: <>.getAgeVar()
Passed: <>.getAgeVar()
Passed: <>.getAgeVar()
Passed: <>.getConflictSum()
Passed: <>.getConflictSum()
Passed: <>.getConflictSum()
Passed: <>.getId()
Passed: <>.getId()
Passed: <>.getId()
Passed: <>.getPeopleSum()
Passed: <>.getPeopleSum()
Passed: <>.getPeopleSum()
Passed: <>.getRelationSum()
Passed: <>.getRelationSum()
Passed: <>.getRelationSum()
Passed: <>.getValueSum()
Passed: <>.getValueSum()
Passed: <>.getValueSum()
Failed: <>.hasPerson(null)
Failed: <>.hasPerson(null)
Failed: <>.hasPerson(null)
Failed: <>.updaterelation(null, null)
Failed: <>.updaterelation(null, null)
Failed: <>.updaterelation(null, null)
===============================================
Command line suite
Total tests run: 43, Failures: 13, Skips: 0
===============================================
定睛一看,发现主要是一些函数传入null时出现了错误结果,检查,发现这些函数是在调用的时候保证了它不为null。
可以看出,jmlunitng测试了边界条件,刚想例行吐槽这有什么大用吗,忽而想起了自己第三次作业爆了Int的惨痛经历(。
除此之外,和大多数同学在博客中提到的不同,我在换了几个诸如a+b, a-b的简单demo之后(代码太低幼了,就不贴出来了),发现该工具是可以根据JML判断代码对错的 ,并非单纯的使用极端数据判断是否出现异常。只不过它的测试行为是:对生成一定的数据。对于带参数的函数,同时用传入边界条件和类本身的数据进行正确性检验,对不带参数的函数,直接使用类本身的数据进行检验。可能“传入边界条件”的操作带给了一些同学错觉,以为它只会检验极端数据。
检查结果和预期相符。
梳理自己的架构设计
整体框架:
这三次作业中,我没有进行自己的独创架构设计,沿用了课程组的框架。但是,我并非课程组说的那种“完全按照已有框架,看一个写一个”的人,而是先通读了全部JML, 理清了整体思路,觉得课程组的JML的整体框架设计蛮不错的,就沿用了(。在互测中,我看到的大部分同学也是沿用了这种设计。由于一二次作业简单沿用了课程组的框架,没有任何改进,放出来UML图没有什么意义,也不是我原创, 在此贴出第三次作业的UML,其也只是在一些细节部分进行了改进:
第一次作业算法设计:
首先是人尽皆知的HashMap, HashSet, 来实现O(1)的根据ID查找人。其次是BFS实现的isCircle, 这个方法在第三次作业被换成了并查集。
第二次作业算法设计:
第二次作业增加了组,要计算组内成员的一些信息,诸如平均年龄,方差,联通边数的变式等。我采用了缓存+实时添加机制,当一个人被添加到组中时,先进入缓存队列,并更新记录好的年龄和和平方和,当查询平均年龄和方差时,直接简单计算就可以。当查询RelationSum和ValueSum时,进行更新,将缓存队列中的人加入到真正的队列中,并在已有的RelationSum和ValueSum基础上进行简单更新即可。
第三次作业算法设计
第三次作业的几个难点函数集中在:最短路径,联通块个数,求强连通。
最短路径,先用并查集实现的isCircle判断是否联通(真香警告),然后采用堆优化的djistra, 在发现自己写的小顶堆和Java自带的PriorityQueue性能差不多后,果断采用了PriorityQueue减少出错的可能性。
连通块个数,在并查集时可以直接顺手实现。每新增一个人,blockcount++, 每次添加关系时,判断id1和id2的父节点,如果他们的父节点不同,说明他们已经被合成了一个连通块,将blockcount--即可。
对于强连通,通过Tarjan弹出所有强联通分量,判断id1和id2是否在一个强连通分量中,即可知道他们是否强连通。由于要求了两个点的点双不在范围内,因此加一行特判即可。
对于Money,出于封装性要求和面向对象的思想,并非像JML要求一样放入Network, 而是放入了Person中,极度舒适。
bug和修复
第一次作业和第二次作业都没在自己的对拍,中测,强测中找出任何bug, 写出即是对的。第二次作业的互测中,一位同学采用了双重遍历的RelationSum, RelationValue的实现方式,被分别卡了两次。还有一位同学在计算方差时,没有注意到JML要求实现的int除法和自己的”更精确“的算法的误差,出现了错误。
在第三次作业中,自己由于在group中拿int缓存了年龄的平方和,被爆了Int。 在作业提交前和同学对拍时,已经有同样用Int缓存了平方和的同学警告过我这样可能溢出,但我们找了几个人的代码检验了一下,发现拿BigInteger和Int存,虽然用Int会溢出,但由于后面又减了回来,结果是一样的。唯独没有用我的代码测试,可万万没想到我的平方和具体算法和他们略有差异,导致自己的溢出产生了错误的结果 现在想想,自己不过是为了不想写BigInteger在长数学表达式的复杂,套来套去的臃肿用法,竟然冒了这个险,还没有把自己的答案确认一遍,确实是飘了(当然,归根揭底还是想吐槽一下Java不支持重载运算符,虽然我深知重载运算符是双刃剑)
在第三次作业的互测中,一位同学的isCircle判断竟然还是错的,而且一拍就很容易发现错误,大概是在第三次重写了isCircle?除此之外,大家的算法都优化的堪称完美,没有发现其他bug.
感想&&JML体会
-
今天听了课才知道吴老师在吐槽沿用JML框架往下写的行为,此前我还一直觉得课程组给的框架超棒(逃
-
学编程第一天,就听过入门老师讲的“波音787的电子系统每隔最长2^31秒就必须复位一次来避免整数溢出,否则将会导致飞控系统错误“,以及”算数溢出导致阿波罗5号坠毁”的案例,这次终于自己踩了一遍坑,不见棺材不落泪(大误)
-
要是未来架构师和程序员的分工是这样的,我绝对不去当架构师,就写代码摸摸鱼好了(逃, 这也太辛苦了,基本包揽了软件开发绝大部分的工作,况且这东西还不好写(从官方JML改了又改可见一二),还有一些原因见下.
-
除了上机时被迫营业写了点JML代码之外,这玩意儿真的没什么写他的欲望。理论课老师在介绍JML的好处时,大致提到了两点,其一是避免二义性,使得开发者能准确理解设计者的思路,其二是机械化证明和测试。从实践来看,第二点显然是停滞了,停留在了美好的设想和一些不成熟的应用上,对于第一点,我个人虽然认可它能避免二义性,但是为了避免二义性而引入了相对自然语言更复杂更机械化的叙述,不是又造成了规格解读上失误的可能性吗?(从第三单元不少同学强测爆0可见一二)。在真正的生产中,“自然语言叙述的二义性导致的错误”和“由于规格写出来太复杂而理解错了规格导致出错”二者究竟哪一个频率会更高,出现的问题更严重还真的不一定。事实上,我自己的写作业方式是先通读了所有JML后,把比较复杂的JML拿自然语言加了一遍批注,然后思考架构设计,最后很大程度是看着我的语言批注实现的,因为重新读一遍有点繁琐。
-
JML理解起来像是离散数学的谓词逻辑,对于优秀的1806er们绝对不是问题吧,除非粗心看错了。写起来也像离散数学谓词逻辑的书写,除了建议安装一个有效的括号颜色插件来盯复杂的括号匹配,可以极大提高写JML和读JML的幸福感。
-
自己的代码阅读能力还需要加强。同时,本次作业忽视了架构方面的问题,如果采用封装算法的方式,可能架构会更好。