一、JML介绍
JML(Java Modelling Language)是一种用于描述Java程序方法的行为的语言。JML规格对方法的执行效果、执行条件和副作用等作出了明确的规定。
JML方法规格的场景有两种:正常行为和异常行为,分别用normal_behavior和exceptional_behavior表示。
在每个方法中,JML用requires子句表示行为的前置条件,即仅当满足该前置条件时,才会触发相应的行为。一般而言,前置条件的逻辑应该互不相容。
JML用ensures子句表示后置条件,也就是方法执行结果。当方法执行完毕后,一定要达到满足前置条件的某一行为的ensures所规定的条件。
JML通过assignable子句表示能够对哪些变量进行赋值,其语法是assignable set或assignable x,y,z。另外有modifiable子句拥有相同的语法,表示能够修改哪些变量。二者之间有微妙的差别。
JML的表达式具有以下几种和Java语言不同的表达式,另外,Java中除赋值操作的所有运算符都可以直接使用于JML。
表达式 | 含义 |
---|---|
\result | 非void 类型的方法执行所获得的结果 |
\old(expr ) |
表达式expr 在方法执行前的取值 |
\not_assigned(x,y,...) | 若括号中所有变量都未在执行过程中被赋值,则为true ,否则为false 。 |
\not_modified(x,y,...) | 若括号中所有变量的值都未在执行中改变,则为true ,否则为false 。 |
\nonnullelements(container ) |
表示container 中包含的对象不含null |
\type(t) | 返回t类型对应的标识值,例如\type(boolean )=Boolean.TYPE |
\typeof(expr) | 返回expr对应的类型,例如\typeof(false )=Boolean.TYPE |
(\forall Type i; condition; ensure) | 表示∀i(condition→ensure)的值,即任意i,如果满足若condition即ensure,则该式值为true ,否则为false |
(\exists Type i; condition; ensure) | 表示∃i(condition∧ensure)的值,即存在i,同时满足condition和ensure,则该式值为true ,否则为false |
(\sum Type i; condition; value) | 表示对于满足condition条件的所有i,求作为i的函数的value的值的加和 |
(\product Type i; condition; value) | 表示对于满足condition条件的所有i,求作为i的函数的value的值的乘积 |
(\max Type i; condition; value) | 表示对于满足condition条件的所有i,求作为i的函数的value的值在所有取值中的最大值 |
(\min Type i; condition; value) | 表示对于满足condition条件的所有i,求作为i的函数的value的值在所有取值中的最小值 |
(\num_of Type i; condition; ensure) | 等同于(\sum Type i; condition && ensure; 1) |
new Set {Type i | condition} | 表示构造一个集合,该集合中仅包含所有满足condition的元素i |
exp1<:exp2 | 如果exp1和exp2是类型,且exp1是exp2的子类型,则值为true ,否则为false |
exp1<==>exp2 | 取值和exp1==exp2相同,但优先级较低 |
exp1<=!=>exp2 | 取值和exp1!=exp2相同,但优先级较低 |
exp1==>exp2 | 取值和!exp1||exp2相同 |
exp1<==exp2 | 取值和!exp2||exp1相同 |
\nothing | 表示空集 |
\everything | 表示全集,具体的语义随场合不同而变化 |
除了用于方法的条件陈述,JML的表达式还用于类型规格的规定。JML类型规格包含两种约束,其中invariant表示不变式,即在方法执行前后都必须满足的条件,其语法是invariant exp;constraint表示执行方法之前和现在的状态之间有什么关系,拥有和invariant相同的语法。类型规格是所有的方法都必须满足的。
如果一个方法在任何时候都不对变量进行赋值或修改,则应该将其声明为pure。仅有pure方法的返回值可以用于JML的表达式。
JML的相关工具链包括OpenJML、JMLUnit、JMLUnitNG等,OpenJML可以对JML的正确性进行静态检验,以及对方法的正确性进行验证;JMLUnitNG可以生成简单的、针对边界条件的测试样例来对方法进行检测。
实际上,JML所描述的方法实现往往没有实用性。如果照搬JML规格所描述的方法实现函数,一定能够正确实现,但性能往往是很差的。实现JML所规定的功能,应对数据结构和算法都进行深入的分析。
二、程序架构
本次作业共需要实现三个类:Network、Person、Group。其中,Network代表一个社交网络,其本质是以Person为节点的无向图。Person也可能属于某些Group,一个Group内的Person可能相关或不相关,这一概念类似于“老龄人口”或者“残障人士”这种分类,抑或是群聊。
本次作业看上去好像很简单,容易实现,但实际上在算法的性能上,若不加考量,会出很大的问题。其中,比较容易产生问题的方法包括以下几个:
public int queryNameRank(int id)
public int queryAgeSum(int l, int r)
public boolean isCircle(int id1, int id2)
public int queryBlockSum()
public int queryGroupAgeVar(int id)
public boolean queryStrongLinked(int id1, int id2)
-
queryNameRank和queryAgeSum
这两个函数分别是求一个Person在整体中名字的字典序排名和在年龄在[l,r]之间的Person个数。这两个函数都不怎么容易优化,但好在本次作业中并没有卡这两个函数的打算,即使每次以O(n)的复杂度遍历也不会出问题。实际上,这两个函数都可以通过手动建树来优化,但需要的时间和精力实在不值得,还不如把这个时间花在OS作业上。
-
isCircle和queryBlockSum
这两个函数分别是询问两个Person之间是否连通,以及整个图中的连通分量的个数。
很显然,isCircle可以通过BFS或DFS的方法解决。第一次作业由于没有对时间作出较高的要求,使用这两种复杂度为O(n+e)的方法问题不算太大。
但是真正的噩梦从第二次作业开始。第二次作业要求在6.66s之内运行完成100000条指令,所以时间压力变得非常大。(实际上光是调用函数的过程就得耗费1秒)使用BFS和DFS都变得危险起来。因此,需要使用新的轮子算法:并查集(Disjoint Set)。通过并查集算法可以方便地查询两个点是否位于同一连通分量,而且如果在每次查询的同时对并查集结构进行展平,平均复杂度最好可以达到O(1)。同样地,连通分量的个数也可以使用并查集进行管理。 -
queryGroupAgeVar
这个函数实际上并不难处理,使用方差的定义式即可发现方差满足下面的式子:
然而,在第三次作业中年龄的上限是2000,人数的上限是800,这就意味着如果用int存储年龄的平方和,将会导致溢出。然而,由于方差的最大值是1000000,方差最大值乘以800为800000000,并没有溢出,实际上可以证明使用int并不会对最终的结果造成影响。这是因为<I, +, -, ×>构成了一个代数系统,而I中的元素和它的模4294967296同余类的映射构成一个到同余类的代数系统的同态映射。令φ表示该映射,φ^-1则表示同余类到其位于[-2147483648, 2147483647]的元素的映射,则根据同态映射的性质,满足下式:
因此,使用int并不会导致最后的结果出现问题
,long使用不慎反而会出问题,比如光记得用long存平方和了,结果式子后面没转换成long。 -
queryStrongLinked
这个函数是第三次作业中最难处理的函数。它在询问是否在两点之间存在两条途经点完全不同的路径。
如果使用两次BFS或两次DFS的方法那就上当了。很容易构造一个图G=<{a,b,c,d},{(a,b),(a,d),(b,c),(b,d),(c,d)}>以及一条路径(a,b,d,c),使得当去除该路径上的所有点(除端点)后a和c不再连通,但实际上a和c确实存在两条完全不同的路径。这个问题实际上是点双连通分量的问题,也就是在一个图中,去掉任意一个点,是否仍然能够保证任意两个点之间是连通的。这个问题的暴力做法是,寻找一条路径,再对该路径上的所有点依次进行屏蔽,依次寻找是否仍然有两点间的路径。这种方法较为简单粗暴,实际上也不会使性能差到无法接受,因为第三次作业的queryStrongLinked最多只有10条。
如果追求完美,可以采用Tarjan算法。Tarjan算法实际上不止有一个,包括求有向图强连通分量和点双连通分量、边双连通分量。我在查找资料的时候曾在这一问题上疑惑良久。
三、bug修复
在使用OpenJML之前,我特地将jdk版本换成了1.8.0.252。
我尝试使用如下指令使用SMT Solver:
java -jar "%~dp0/openjml.jar" -exec "%~dp0/Solvers/z3-4.7.1.exe" -dir "%cd%" -esc Group.java
结果报出了以下的错误:
错误: An internal JML error occurred, possibly recoverable. Please report the bug with as much information as you can.
Reason: Internal exception: class java.lang.NullPointerException
java.lang.NullPointerException
at org.jmlspecs.openjml.Utils.isJMLStatic(Utils.java:372)
at org.jmlspecs.openjml.esc.JmlAssertionAdder.addFinalStaticFields(JmlAssertionAdder.java:3804)
...
以及报了无数的“java.lang.IOException:管道正在被关闭”。
我简单对OpenJML这个工具的可用性测试了一下,特地更换成了Eclipse,使用OpenJML插件对工程进行ESC,发现OpenJML报如下错误:
OpenJML本身是一个古老的工具,其更新版本和对应的jdk版本刁钻程度是我没有想象到的。如果有人帮我配置成功,我可能会临表涕零不知所言。
我又将Group等类的包定义进行修改,尝试使用如下指令进行JMLUnitNG的测试。
java -jar jmlunitng.jar test/Group.java
javac -cp jmlunitng.jar test/*.java
java -cp jmlunitng.jar test.Group_JML_Test
最后得出了这样的测试结果:
[TestNG] Running:
Command line suite
Failed: racEnabled()
Passed: constructor Group(-2147483648)
Passed: constructor Group(0)
Passed: constructor Group(2147483647)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Failed: <>.addPerson(null)
Passed: <>.addRelation(-2147483648)
Passed: <>.addRelation(-2147483648)
Passed: <>.addRelation(-2147483648)
Passed: <>.addRelation(0)
Passed: <>.addRelation(0)
Passed: <>.addRelation(0)
Passed: <>.addRelation(2147483647)
Passed: <>.addRelation(2147483647)
Passed: <>.addRelation(2147483647)
Passed: <>.delPerson(null)
Passed: <>.delPerson(null)
Passed: <>.delPerson(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(java.lang.Object@617faa95)
Passed: <>.equals(java.lang.Object@1e127982)
Passed: <>.equals(java.lang.Object@60c6f5b)
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: <>.getRelationSum()
Passed: <>.getRelationSum()
Passed: <>.getRelationSum()
Passed: <>.getSerialNumber()
Passed: <>.getSerialNumber()
Passed: <>.getSerialNumber()
Passed: <>.getValueSum()
Passed: <>.getValueSum()
Passed: <>.getValueSum()
Passed: <>.hasPerson(null)
Passed: <>.hasPerson(null)
Passed: <>.hasPerson(null)
Passed: <>.hashCode()
Passed: <>.hashCode()
Passed: <>.hashCode()
Passed: <>.setSerialNumber(-2147483648)
Passed: <>.setSerialNumber(-2147483648)
Passed: <>.setSerialNumber(-2147483648)
Passed: <>.setSerialNumber(0)
Passed: <>.setSerialNumber(0)
Passed: <>.setSerialNumber(0)
Passed: <>.setSerialNumber(2147483647)
Passed: <>.setSerialNumber(2147483647)
Passed: <>.setSerialNumber(2147483647)
Passed: <>.size()
Passed: <>.size()
Passed: <>.size()
===============================================
Command line suite
Total tests run: 64, Failures: 4, Skips: 0
===============================================
可以看出,JMLUnitNG生成的测试样例,其覆盖面十分有限,甚至可以说几乎毫无用处。测试边界条件可不是简单无脑地把最大整数、最小整数和0往里一贴,把null往里一带就能解决的问题。顺便一提,addPerson的Fail原因,就是没有处理null的情况。
四、总结