一、概述
本单元主要学习的是规格化设计,按照所给规格逐步实现了一个基于图的地铁系统。按道理说应该是目前最简单的一个单元,但是我却被拿下了一血……
在本单元的学习中,我们需要掌握如下内容:
- 规格化的面向对象设计方法
- 规格的概念
- 方法的规格及其设计方法
- 类的规格及其设计方法
- 继承层次下类规格之间的关系
- 掌握基于规格的测试方法
二、作业总结
(一)JML语言的理论基础与应用工具链
ML(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等工具 以静态方式来检查代码实现对规格的满足情况。
1. 注释结构
JML以javadoc注释的方式来表示规格,每行都以@起头。有两种注释方式,行注释和块注释。
2. JML表达式
JML表达式包括:
- 原子表达式
- \result表达式:表示一个非 void 类型的方法执行所获得的结果,即方法执行后的返回值。
- \old(expr)表达式:用来表示一个表达式 expr 在相应方法执行前的取值。
- ……
- 量化表达式
- \forall表达式:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。
- \exists表达式:存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。
- \sum表达式:返回给定范围内的表达式的和。
- ……
- 集合表达式
- 操作符
- 子类型关系操作符
- 等价关系操作符
- 推理操作符
- 变量引用操作符
3. 方法规格
方法规格的核心内容包括 三个方面,前置条件、后置条件和副作用约定。
- 前置条件(pre-condition):前置条件通过requires子句来表示,是对方法输入参数的限制,如果不满足前置条件,方法执行结果不可预测,或者说不保证方法执行结果的正确性。
- 后置条件(post-condition):后置条件通过ensures子句来表示,是对方法执行结果的限制,如果执行结果满足后置条件,则表示方法执行正确,否则执行错误。
- 副作用范围限定(side-effects):副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。
4. 类型规格
类型规格指针对Java程序中定义的数据类型所设计的限制规则,一般而言,就是指针对类或接口所设计的约束规则。主要有不变式限制和约束限制两种。
- 不变式(invariant)是要求在所有可见状态下都必须满足的特性。
- 状态变化约束(constraint)是对前序可见状态和当前可见状态的关系约束,即对象的状态在变化时满足的一些约束,这种约束本质上也是一种不变式。
5. 应用工具链
-
openJML:可以根据JML对实现进行语法检查和静态检查。
-
JMLUnitNG:可以根据JML自动生成对应的测试样例,用于进行单元化测试。
(二)部署SMT Solver
测试程序如下:
1 public class MyPath { 2 private /*@ spec_public @*/ int[] nodes; 3 4 public MyPath(int... nodeList) { 5 nodes = new int[nodeList.length]; 6 for (int i = 0; i < nodeList.length; i++) { 7 nodes[i] = nodeList[i]; 8 } 9 } 10 11 //@ ensures \result == nodes.length; 12 public /*@ pure @*/ int size() { 13 return nodes.length; 14 } 15 16 /*@ requires index >= 0 && index < size(); 17 @ assignable \nothing; 18 @ ensures \result == nodes[index]; 19 @*/ 20 public /*@ pure @*/ int getNode(int index) { 21 return nodes[index]; 22 } 23 24 //@ ensures \result == (nodes.length >= 2); 25 public /*@ pure @*/ boolean isValid() { 26 return nodes.length >= 2; 27 } 28 29 /*@ requires index >= 0 && index < size(); 30 @ assignable \nothing; 31 @ ensures \result > 0 ==> nodes[index] > c; 32 @ ensures \result == 0 ==> nodes[index] == c; 33 @ ensures \result < 0 ==> nodes[index] < c; 34 @*/ 35 public int testOverflow(int index, int c) { 36 return nodes[index] - c; 37 } 38 }
1. 使用 -check 进行语法检查
java -jar openjml.jar -check <filename>
图1 语法检查结果
结果如图1所示,第1次语法检查报错 size() 函数式非pure的,更改之后通过语法检查。
2. 使用 -esc 进行静态检查
静态逻辑检查使用的是要求的 SMT Solvers。
java -jar openjml.jar -esc <filename>
图2 静态检查结果
静态检查结果如图2所示,在程序中特意写出了testOverflow函数用于测试溢出,因此最后4条警告包括2条underflow和overflow警告和2条index越界警告。
3. 使用 -rac 进行运行时检查
java -jar openjml.jar -esc <filename>
在删除了testOverflow函数并对构造时的index进行数组越界检查后,静态检查和运行时检查均通过,结果如图3所示。
图3 完善程序后静态检查和运行时检查结果
(三)部署JMLUnitNG
使用在(二)中编写的修改版MyPath作为测试程序,命令行运行:
java -jar jmlunitng.jar MyPath.java javac -cp jmlunitng.jar *.java java -jar openjml.jar -rac MyPath.java tree
生成测试文件,结果如图4所示
图4 自动生成的测试文件
命令行运行:
java -cp jmlunitng.jar:demo MyPath_JML_Test
测试MyPath类,测试结果如图5所示。
图5 MyPath测试结果
从测试结果中看出,testOverflow方法对于所有溢出都未通过测试(这是当然的,因为目的就是如此)。除此之外,getNode也没有通过边界测试。可见JMLUnitNG基本上上是在边界点和0点生成测试数据并测试的。
(四)架构设计
1. 第9次作业
图1 第9次作业类图
在第9次作业中,具体函数都是按照接口文档实现的,因此函数实现比较简单,主要分析类中的数据结构即可。
在MyPath中,Vector nodes 主要用于顺序存储数据;Set nodesSet主要用于存储不同结点,得到不同节点个数。
在MyPathContainer中,Map pathHashMap 主要负责从编号到指定Path的映射;Map pathTreeMap 是Path到编号的映射;Map nodesMap是节点到自己出现次数的映射。
函数的实现就是按照接口从上述数据结构中进行插入、删除和查询操作。
2. 第10次作业
图2 第10次作业类图
在第10次作业中,具体函数都是按照接口文档实现的,因此函数实现比较简单,主要分析类中的数据结构即可。MyPath继承了第9次作业。
在MyPathContainer中,Map identifier2PathMap (即第9次作业中的 pathHashMap) 主要负责从编号到指定Path的映射;Map path2IdentifierMap (即第9次作业中的 pathTreeMap) 是Path到编号的映射;Map nodesCounter (即第9次作业中的 nodesMap) 是节点到自己出现次数的映射。二维Map adjacList 中存储的是邻接链表,二维Map shortestPathCache中缓存着当前已计算的最短路。
函数的实现就是按照接口从上述数据结构中进行插入、删除、查询和计算最短路并存入缓存的操作。
3. 第11次作业
图3 第11次作业类图
在第11次作业中,具体函数都是按照接口文档实现的,此次对于工程有了很大的重构。
MyPath继承了第9次作业。
新建了一个ShortestPath类,此类支持加边、计算最短路和清空内容。
新建了一个并查集 UnionFindSet类,用于判断连通性。
新建了四个类,分别是BasicGraph、TransferCountGraph、TicketPriceGraph和UnpleasantValueGraph,分别用于计算原始图最短路、最小换乘次数、最小票价和最小不快乐值。这四个类中分别实例化了ShortestPath类,四个类之间的不同点仅是向ShortestPath类中添加边的方式不同。
新建了二维 Map edgesCounter, 用于统计边的引用数。
函数的实现就是按照接口从上述数据结构中进行插入、删除和查询操作,最短路的计算在ShortestPath类中自动实现。
(五)bug分析
此次被拿下一血主要错在一个很不起眼的地方,就是在第10次作业中,看着第9次通过的代码感觉不是那么漂亮,因此重写了Path类中的很多方法,在重写其中的compareTo方法时,将原本很冗余的代码在逻辑上改的很通顺,但是当同一位置出现不相等元素时,我是这样返回的:
return node - pathNode;
也就是说,当前节点node大于pathNode时,会返回一个正数。但是忘记考虑整数溢出的问题,因此可能返回的是一个复数,使得大部分比较两条路径大小的指令都出现了错误。
(六)心得体会
通过本单元的学习,我对于规格设计和JML有了更深入的理解。
规格的目的是在于能够用一种较严谨的方式对类、方法和数据进行描述。ML源自于契约式设计的需要,是一种基于信任机制和权利义务均衡机制的设计方法学,即
- 信任机制
- 类提供者信任使用者能够确保所有方法的前置条件都能被满足
- 类使用者信任设计者能够有效管理相应的数据和访问安全
- 权利义务均衡机制
- 任何一个类都要能够存储和管理规格所定义的数据(义务)
- 任何一个类都要保证对象始终保持有效(义务)
- 任何一个类都可以拒绝为不满足前置条件的输入提供服务(权利),或者通过异常来提醒使用者
- 任何一个类都可以选择自己的表示对象而不受外部约束(权利)
JML的设计方法可以按照WARRANTY方法,即
- Step1(Why):为什么需要这个方法?
- Step2(Acceptance criteria): 这个方法所提供结果正确的判定条件是什么?
- Step3(cleaR Requirement): 这个方法是否需要对调用者做出一些要求,从 而确保能够产生正确结果?
- Step4(ANticipated changes): 这个方法执行期间是否需要修改输入数据或 者所在对象数据?
- Step5(TrustY):无需代码即可确认其语义
但是说实话,在几次作业中,实际开发中仍旧是首先按照函数名产生对函数自己的理解,然后根据自己的理解再读JML验证自己理解的正确性,而不是直接阅读JML产生思路。因此我认为JML在实际开发中仍然存在复杂性过高的问题。