OO第三单元主要是JML规格的书写和实现,代码量不多,难度不大,但是很坑
JML理论基础与工具链
JML理论基础
JML(Java Modeling Language)是用于对Java程序进行规格化设计的一种表示语言。JML是一种行为接口规格语言(Behavior Interface Specification Language,BISL),基于Larch方法构建。BISL提供了对方法和类型的规格定义手段。所谓接口即一个方法或类型外部可见的内容。JML主要由Leavens教授在Larch上的工作,并融入了Betrand Meyer, John Guttag等人关于Design by Contract的研究成果。近年来,JML持续受到关注,为严格的程序设计提供了一套行之有效的方法。通过JML及其支持工具,不仅可以基于规格自动构造测试用例,并整合了SMT Solver等工具以静态方式来检查代码实现对规格的满足情况。
一般而言,JML有两种主要的用法:
(1)开展规格化设计。这样交给代码实现人员的将不是可能带有内在模糊性的自然语言描述,而是逻辑严格的规格。
(2)针对已有的代码实现,书写其对应的规格,从而提高代码的可维护性。这在遗留代码的维护方面具有特别重要的意义。
JML表达式
JML的表达式是对Java表达式的扩展,新增了一些操作符和原子表达式。同样JML表达式中的操作符也有优先级的概念。需要提醒的是,JML相对于Java新增的表达式成分仅用于JML中的断言(assertion)语句和其他相关的注释体。特别需要提醒,在JML断言中,不可以使用带有赋值语义的操作符,如++,--,+=
等操作符,因为这样的操作符会对被限制的相关变量的状态进行修改,产生副作用。
1.1 原子表达式
\result表达式:表示一个非void
类型的方法执行所获得的结果,即方法执行后的返回值。\result表达式的类型就是方法声明中定义的返回值类型。如针对方法:public boolean equals (Object o)
,\result的类型是boolean
,任意传递一个Object
类型的对象来调用该方法,可以使用\result来表示equals
的执行结果(true
表示this
和o
相等;false
表示不相等)。
\old(expr
)表达式:用来表示一个表达式expr
在相应方法执行前的取值。该表达式涉及到评估expr
中的对象是否发生变化,遵从Java的引用规则,即针对一个对象引用而言,只能判断引用本身是否发生变化,而不能判断引用所指向的对象实体内容是否发生变化。假设一个类有属性v
为HashMap
,假设在方法执行前v的取值为0x952ab340
,即指向了存储在该地址的具体HashMap
对象,则\old(v
)的值就是这个引用地址。如果方法执行过程中没有改变v
指向的对象,则v
和\old(v
)有相同的取值,即使方法在执行过程中对v
指向的HashMap
执行了插入或删除操作。因此v.size()
和\old(v
).size()
也有相同的结果。很多情况下,我们希望获得v
在方法执行前所管理的对象个数,这时应使用\old(v.size()
)。作为一般规则,任何情况下,都应该使用\old把关心的表达式取值整体括起来。
\not_assigned(x,y,...)表达式:用来表示括号中的变量是否在方法执行过程中被赋值。如果没有被赋值,返回为true
,否则返回false
。实际上,该表达式主要用于后置条件的约束表示上,即限制一个方法的实现不能对列表中的变量进行赋值。
\not_modified(x,y,...)表达式:与上面的\not_assigned表达式类似,该表达式限制括号中的变量在方法执行期间的取值未发生变化。
\nonnullelements(container
)表达式:表示container
对象中存储的对象不会有null
,等价于下面的断言,其中\forall是JML的关键词,表示针对所有i
。
\type(type)表达式:返回类型type对应的类型(Class),如type(boolean
)为Boolean.TYPE。TYPE是JML采用的缩略表示,等同于Java中的java.lang.Class
。
\typeof(expr)表达式:该表达式返回expr对应的准确类型。如\typeof(false
)为Boolean.TYPE。
1.2 量化表达式
\forall表达式:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。(\forall int i,j; 0 <= i && i < j && j < 10; a[i] < a[j])
,意思是针对任意0<=i
true
),则表明数组a实际是升序排列的数组。
\exists表达式:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。(\exists int i; 0 <= i && i < 10; a[i] < 0)
,表示针对0<=i<10,至少存在一个a[i]<0。
\sum表达式:返回给定范围内的表达式的和。(\sum int i; 0 <= i && i < 5; i)
,这个表达式的意思计算[0,5)范围内的整数i的和,即0+1+2+3+4==10。注意中间的0 <= i && i < 5
是对i范围的限制,求和表达式为最后面的那个i
。同理,我们构造表达式(\sum int i; 0 <= i && i < 5; i*i)
,则返回的结果为1+4+9+16。
\product表达式:返回给定范围内的表达式的连乘结果。(\product int i; 0 < i && i < 5; i)
,这个表达式的意思是针对(0,5)范围的整数的连乘结果,即1* 2* 3 * 4。
\max表达式:返回给定范围内的表达式的最大值。(\max int i; 0 <= i && i < 5; i)
,这个表达式返回[0,5)中的最大的整数,即4。
\min表达式:返回给定范围内的表达式的最小值。(\min int i; 0 <= i && i < 5; i)
,这个表达式返回[0,5)中的最小的整数,即0。
\num_of表达式:返回指定变量中满足相应条件的取值个数。(\num_of int x; 0
(\num_of T x; R(x);P(x))
,其中T为变量x的类型,R(x)为x的取值范围;P(x)定义了x需要满足的约束条件。从逻辑上来看,该表达式也等价于(\sum T x;R(x)&&P(x);1)
。
1.3 集合表达式
集合构造表达式:可以在JML规格中构造一个局部的集合(容器),明确集合中可以包含的元素。new JMLObjectSet {Integer i | s.contains(i) && 0 < i.intValue() }
表示构造一个JMLObjectSet对象,其中包含的元素类型为Integer,该集合中的所有元素都在容器集合s中出现(注:该容器集合指Java程序中构建的容器,比如ArrayList),且整数值大于0。集合构造表达式的一般形式为:new ST {T x|R(x)&&P(x)},其中的R(x)对应集合中x的范围,通常是来自于某个既有集合中的元素,如s.has(x),P(x)对应x取值的约束。
1.4 操作符
JML表达式中可以正常使用Java语言所定义的操作符,包括算术操作符、逻辑预算操作符等。此外,JML专门又定义了如下四类操作符。
(1) 子类型关系操作符:E1<:E2
,如果类型E1是类型E2的子类型(sub type),则该表达式的结果为真,否则为假。如果E1和E2是相同的类型,该表达式的结果也为真,如Integer.TYPE<:Integer.TYPE
为真;但Integer.TYPE<:ArrayList.TYPE
为假。需要指出的是,任意一个类X,都必然满足X.TYPE<:Object.TYPE
。
(2) 等价关系操作符:b_expr1<==>b_expr2
或者b_expr1<=!=>b_expr2
,其中b_expr1和b_expr2都是布尔表达式,这两个表达式的意思是b_expr1==b_expr2
或者b_expr1!=b_expr2
。可以看出,这两个操作符和Java中的==
和!=
具有相同的效果,按照JML语言定义,<==>
比==
的优先级要低,同样<=!=>
比!=
的优先级低。
(3) 推理操作符:b_expr1==>b_expr2
或者b_expr2<==b_expr1
。对于表达式b_expr1==>b_expr2
而言,当b_expr1==false
,或者b_expr1==true
且b_expr2==true
时,整个表达式的值为true
。
(4) 变量引用操作符:除了可以直接引用Java代码或者JML规格中定义的变量外,JML还提供了几个概括性的关键词来引用相关的变量。\nothing指示一个空集;\everything指示一个全集,即包括当前作用域下能够访问到的所有变量。变量引用操作符经常在assignable句子中使用,如assignable \nothing
表示当前作用域下每个变量都不可以在方法执行过程中被赋值。
方法规格
-
前置条件(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子句中使用逻辑或(||
)操作符来表示相应的约束场景:ensures P1||P2;
。
-
副作用范围限定(side-effects)
副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。从方法规格的角度,必须要明确给出副作用范围。JML提供了副作用约束子句,使用关键词assignable
或者modifiable
。从语法上来看,副作用约束子句共有两种形态,一种不指明具体的变量,而是用JML关键词来概括;另一种则是指明具体的变量列表。
注1:JML不允许在副作用约束子句中指定规格声明的变量数据,因为这样的声明只是为了描述规格,并不意味实现者一定要实现这样的数据。
注2:默认情况下,方法的规格对调用者可见,但是方法所在类的成员变量一般都声明为private,对调用者不可见。有时方法规格不得不使用类的成员变量来限制方法的行为,比如上面例子中的副作用范围限定,这就和类对相应成员变量的私有化保护产生了冲突。为了解决这个问题,JML提供了/*@spec_public@*/来注释一个类的私有成员变量,表示在规格中可以直接使用,从而调用者可见。
-
pure
设计中会出现某些纯粹访问性的方法,即不会对对象的状态进行任何改变,也不需要提供输入参数,这样的方法无需描述前置条件,也不会有任何副作用,且执行一定会正常结束。对于这类方法,可以使用简单的(轻量级)方式来描述其规格,即使用pure
关键词
-
\forall和\exists
有时候,前置条件或后置条件需要对不止一个变量进行约束,往往是需要对一个容器中的所有元素进行约束,这时就需要使用\forall或者\exists表达式。
-
also
有两种使用also的场景:(1)父类中对相应方法定义了规格,子类重写了该方法,需要补充规格,这时应该在补充的规格之前使用also;(2)一个方法规格中涉及多个功能规格描述,正常功能规格或者异常功能规格,需要使用also来分隔。
-
signals子句
signals子句的结构为signals (***Exception e) b_expr;
,意思是当b_expr
为true
时,方法会抛出括号中给出的相应异常e
。
还有一个简化的signals子句,即signals_only子句,后面跟着一个异常类型。signals子句强调在对象状态满足某个条件时会抛出符合相应类型的异常;而signals_only则不强调对象状态条件,强调满足前置条件时抛出相应的异常。
类型规格
类型规格指针对Java程序中定义的数据类型所设计的限制规则,一般而言,就是指针对类或接口所设计的约束规则。从面向对象角度来看,类或接口包含数据成员和方法成员的声明及或实现。不失一般性,一个类型的成员要么是静态成员(static member),要么是实例成员(instance member)。一个类的静态方法不可以访问这个类的非静态成员变量(即实例变量)。静态成员可以直接通过类型来引用,而实例成员只能通过类型的实例化对象来引用。因此,在设计和表示类型规格时需要加以区分。
JML针对类型规格定义了多种限制规则,从课程的角度,我们主要涉及两类,不变式限制(invariant)和约束限制(constraints)。无论哪一种,类型规格都是针对类型中定义的数据成员所定义的限制规则,一旦违反限制规则,就称相应的状态有错。
-
不变式invariant
不变式(invariant)是要求在所有可见状态下都必须满足的特性,语法上定义invariant P
,其中invariant
为关键词,P
为谓词。对于类型规格而言,可见状态(visible state)是一个特别重要的概念。下面所述的几种时刻下对象o的状态都是可见状态:
-
对象的有状态构造方法(用来初始化对象成员变量初值)的执行结束时刻
-
在调用一个对象回收方法(finalize方法)来释放相关资源开始的时刻
-
在调用对象o的非静态、有状态方法(non-helper)的开始和结束时刻
-
在调用对象o对应的类或父类的静态、有状态方法的开始和结束时刻
-
在未处于对象o的构造方法、回收方法、非静态方法被调用过程中的任意时刻
-
在未处于对象o对应类或者父类的静态方法被调用过程中的任意时刻
-
状态变化约束constraint
对象的状态在变化时往往也许满足一些约束,这种约束本质上也是一种不变式。JML为了简化使用规则,规定invariant只针对可见状态(即当下可见状态)的取值进行约束,而是用constraint来对前序可见状态和当前可见状态的关系进行约束。如下面的例子:
public class ServiceCounter{
private /*@spec_public@*/ long counter;
//@ invariant counter >= 0;
//@ constraint counter == \old(counter)+1;
}
类ServiceCounter拥有一个成员变量counter,包含一个不变式和一个状态变化约束。不变式指出counter>=0
,而constraint不同,约束每次修改counter只能加1。虽然这个约束可以在可能对counter进行修改的方法中通过后置条件来表示,但是每个可能修改counter的方法都需要加上这样的后置条件,远不如constraint这样的表示来的方便。不仅如此,invariant和constraint可以直接被子类继承获得。
和不变式一样,JML也根据类的静态成员变量区分了两类约束:static constraint和instance constraint。其中static constraint指涉及类的静态成员变量,而instance constraint则可以涉及类的静态成员变量和非静态成员变量。同样,也可以在规格中通过关键词来明确加以区分:static constraint P
和instance constraint P
。
-
方法与类型规格的关系
如果一个类是不可变类,其实就没必要定义其不变式,只需要在构造方法中明确其初始状态应该满足的后置条件即可。当然,也可以反过来,定义不变式,而不定义构造方法的后置条件。事实上,在大部分情况下,一个类有几种不同类别的方法:静态初始化(不是方法,但也是一种行为)、有状态静态方法、有状态构造方法、有状态非静态方法。下表给出了两类不变式与这些方法的关系:
静态成员初始化 | 有状态静态方法 | 有状态构造方法 | 有状态非静态方法 | |
---|---|---|---|---|
static invariant | 建立 | 保持 | 保持 | 保持 |
instance invariant | (无关) | (无关) | 建立 | 保持,除非是finalizer方法 |
注:“建立”的含义是静态成员建立了满足相应不变式的类或对象状态。“保持”的含义是如果方法执行前不变式满足,执行后还应该满足相应的不变式。
同理,JML也对constraint与方法之间的关系进行了约定:
静态成员初始化 | 有状态静态方法 | 有状态构造方法 | 有状态非静态方法 | |
---|---|---|---|---|
static constraint | (无关) | 遵从 | 遵从 | 遵从 |
instance constraint | (无关) | (无关) | (无关) | 遵从 |
注:“遵从”的含义是成员变量的当前取值和上一个取值之间的关系满足constraint的规定,即“遵从规定”。
JML工具链
使用openJML是JML工具链的核心,可以依据openJML进行代码的规格的静态检查和和动态的测试。
同时可以使用Junit来对代码进行单元测试。
JMLUnitNG/JMLUnit部署与测试
JMLUnitNG/JMLUnit 的测试在看到同学讨论和往届学长的测评之后,汲取前人的总结与经验,JMLUnitNG/JMLUnit的测试是很鸡肋的一个测试,只能覆盖到一些简简单单的边际,真正想要测试还是需要自己编写评测机与同学进行对拍测试。
作业架构设计
JMlL的三次作业,是根据课程组下发的接口实现具体的类。本质上来说就是实现一些有“注释”的方法,只是这些注释是使用JML语言进行表述。基本过程就是看懂JML的描述,然后去实现对应的需求即可。
第一次作业
第一次作业是实现两个类Person与NetWork类。根据接口中的各个方法的规格和变量的定义直接进行实现即可。由于第一次作业对于性能基本没有很大的限制,我也就“傻瓜式”进行实现各个方法。数据结构也大都使用的是ArrayList来进行实现。
第二次作业
这次作业新增加了一个类Group,将NetWork里面的人们进行分组然后进行各种操作。在这次作业里面,我在第一次作业的基础上做出的改动就是修改了NetWork里面people的存储结构,使用HashMap进行存储,以person的id作为key,以person作为value来存储,这样在进行大规模的访问与修改时,利用HashMap的优异特性,能过极大的提高程序运行效率。
新增方法里面Group里面的relationsum我采用缓冲的机制进行访问,来降低方法的复杂度。
第三次作业
第三次作业主要增加了在NetWork中查询的方法。新增的方法主要是对NetWork里面人们的关系进行查询。
对于qmp的实现我采用迪杰斯特拉算法(最后TLE几个点)来求两点之间的最小长度路径
对于qsl的实现我采用的思路是先找到两个点之间的一条路径,然后遍历删除这条路径中的每一个点,看两个点是否仍然联通,如果删除这条路径上的任意一个节点二者都联通那么他们两个就是强连通,返回true否则返回false。
总的来说架构设计这一块,个人感觉这一单元的体现并不那么的明显,很多设计其实接口都已经设计了,需要我们设计的反而是一些算法的实现和一些数据的存储方式。
BUG和BUG修复
由于前两次作业在某些常用方法的判断条件失误,导致强测均为0分,修改之后便为满分。
第三次作业出现TLE的现象,经查看可知是自己的qmp的迪杰斯特拉算法的复杂度过高导致TLE,同时在互测的环节发现自己的隐藏bug,qmp的最小值点的寻找发生错误,导致qmp的很多情况都会wa掉(好奇强测没有测出来)经过改正也都通过了
体会与心得
-
JML的这一单元,代码量不多,涉及到的JML的描述也都较为简单理解,实现上看起来不是很难,也很容易写对,但是一定要在课下针对规格进行充分的测试,所谓的中测和没测区别不大,有的方法在中测甚至没有出现。
-
对于JML描述的代码的理解,需要先理解数据的组织方式和内在的逻辑,比如NetWork本质就是关系网(图),人与人的关系就是图中的线,人的熟人就是与他直接相连的点等等,这些内在逻辑搞清楚之后,再进行代码的实现就有一种具体的方向和思路。毕竟JML的描述只是一直形式化语言,仅仅根据其描述而不去思考内在的含义是很难完全理解规格的。那么为什么我们还是需要使用JML呢?理解JML是需要理解其真正内涵才能理解其描述,为何不直接说出其内涵,这样不是更容易理解,更容易实现方法?
-
总而言之,JML的实现完全按照规格的描述来实现代码即可。