面向对象编程 之 第三单元总结
针对第三单元的三次作业和课程内容,在独立思考的前提下在博客园完成技术博客的撰写
- (1)梳理JML语言的理论基础、应用工具链情况
- (2) (选做,能较为完善完成的酌情加分) 部署SMT Solver,至少选择3个主要方法来尝试进行验证,报告结果。有可能要补充JML规格
- (3)部署JMLUnitNG/JMLUnit,针对Group接口的实现自动生成测试用例,并结合规格对生成的测试用例和数据进行简要分析
- (4)按照作业梳理自己的架构设计,特别分析自己的模型构建策略
- (5)按照作业分析代码实现的bug和修复情况
- (6)阐述对规格撰写和理解上的心得体会
- 面向对象编程 之 第三单元总结
- 1.JML语言
- 1.1 理论基础
- 1.1.1 表达式
- 1.1.2 方法规格
- 1.1.3 行为规格
- 1.1.4 类型规格
- 1.2 应用工具链
- 1.1 理论基础
- 2.SMT Solver 验证
- 2.1 部署OpenJML
- 2.2 三种方式验证
- 2.2.1 语法检查
- 2.2.2 静态检查
- 2.2.3 运行时检查
- 3. JML 测试(JMLUnitNG)
- 3.1 JMLUnitNG自动测试
- 3.2 结果分析
- 4.架构分析
- 4.1 第一次作业
- 4.2 第二次作业
- 4.3 第三次作业
- 5.错误及修复
- 5.1 第一次作业
- 5.2 第二次作业
- 5.3 第三次作业
- 5.4 错误测试及反思
- 6.心得体会
- 1.JML语言
1.JML语言
1.1 理论基础
JML是一种建模语言。它通过将注释添加到 Java 代码中,说明性地描述所希望的类和方法的行为,可以辅助验证开发的正确性。引入JML的好处有很多,不局限于能更加精确地描述代码所完成的任务,有效地发现和纠正错误,能减少随着应用程序的进展而引入错误的机会等等。JML加入了描述型构造,包括前提条件、后置条件等等。
1.1.1 表达式
① 原子表达式
\result
:方法执行后的返回值。\old(expr)
:表示一个表达式expr
在相应方法执行前的取值。
② 量化表达式
\forall
:全称量词修饰的表达式。\exists
:存在量词修饰的表达式。\sum
:返回给定范围内的表达式的和。
③ 操作符
-
子类型操作符:如果类型E1是类型E2的子类型或相同类型,则该表达式的结果为真。
-
b_expr1<==>b_expr2
等价关系操作符:其中b_expr1和b_expr2都是布尔表达式。 -
b_expr1==>b_expr2
推理操作符:相当于->。 -
\nothing
或\everthing
变量引用操作符:表示当前作用域访问的所有变量。
1.1.2 方法规格
① 前置条件
requires P
:要求调用者确保P为真。
② 后置条件
ensures
P:方法实现者确保方法执行返回结果一定满足谓词P的要求。
③ 副作用
副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。JML使用关键词assignable
或者modifiable
表示。对于设计中不会产生副作用(无需提供参数,执行一定会正常结束,也就无需描述前置条件的方法)的情况无需描述前置条件的方法,使用*@ pure @ *
关键词标记。
1.1.3 行为规格
public normal_behavior
:方法的正常功能。public exception_behavior
:方法的异常行为。signals (Exception e) b_expr
:当 b_expr 为 true 时,方法会抛出括号中给出的相应异常e。
1.1.4 类型规格
类型规格指针对Java程序中定义的数据类型所设计的限制规则,强调在任意可见状态下都要满足不变式。
1.invariant P
:不变式是要求在所有可见状态下都必须满足的特性。
2.constraint P
:对对象的状态在变化进行约束的不变式。
1.2 应用工具链
可以使用测试工具或者验证工具来检查JML规范。有且不限于以下工具:
-
JUnit:JUnit是一个Java语言的单元测试框架,可以对单独的方法进行测试。
-
JMLdoc,javadoc:可在生成的HTML格式文档中包含JML规范。
3.openJML和SMT Solver:前者是一种JML编译器;根据程序的规格,它可以进行语法检查,静态和运行时检查。后者可以通过静态检查程序规格,通常可以与openJML一起使用。
- JMLUnitNG:可以自动生成测试用例进行JUnit测试。
2.SMT Solver 验证
2.1 部署OpenJML
SMT Solver在形式化方法、程序语言、软件工程、以及计算机安全、计算机系统等领域得到了广泛应用。由于OpenJML中已经包含了Microsoft Z3 Solver,所以在此直接借助openJML。
openJML的下载链接:https://github.com/OpenJML/OpenJML/releases。
openJML Documentation:http://www.openjml.org/documentation/。
2.2 三种方式验证
2.2.1 语法检查
此处我们对Person接口进行JML注释的语法检查,包括JML语法的完整性、变量可见性与可写性等,命令如下:
java -jar .\openjml.jar -exec $PATH$\z3-4.7.1.exe $PATH$\Person.java
结果如下图,可以看出,JML语法检查正常通过。
2.2.2 静态检查
接下来进行静态检查,命令如下:
java -jar .\openjml.jar -exec $PATH$\z3-4.7.1.exe -esc $PATH$\Person.java #选项-esc
结果如下(结果较多,过滤了重复的一部分)。主要有两种警告:
D:\OPEN_JML_TEST_FUCK\test\Person.java:10: 警告: The prover cannot establish an assertion (NullField) in method Person
/*@ public instance model non_null BigInteger character; @*/
^
D:\OPEN_JML_TEST_FUCK\test\Person.java:79: 警告: Associated declaration: D:\OPEN_JML_TEST_FUCK\test\Person.java:75: 注:
/*@ public normal_behavior
^
2.2.3 运行时检查
-esc选项是静态检查,而-rac选项则可以针对运行时检查。命令如下:
java -jar openjml.jar -exec $PATH$\z3-4.7.1.exe -rac $PATH$\Person.java
结果如下,报错各属性(如name,character)没有实现,但其实已经严格按照数组形式实现了,所以不清楚错误在哪儿。
test\Person.java:9: 警告: JML model field is not implemented: name
/*@ public instance model non_null String name; @*/
^
test\Person.java:11: 警告: JML model field is not implemented: character
/*@ public instance model non_null BigInteger character; @*/
^
2 个警告
3. JML 测试(JMLUnitNG)
3.1 JMLUnitNG自动测试
JMLUnitNG是针对有JML规格注释的Java代码文件的一种自动化测试工具,它使用JML断言作为测试预言机。JMLUnitNG生成的测试基于TestNG框架,相对于JMLUnit来说,JMLUnitNG的数据生成更为灵活,并且还是用Java反射机制来自动检测non-primitive的数据。
JMLUnitNG的下载链接(Release 1.4,jdk8环境):http://insttech.secretninjaformalmethods.org/software/jmlunitng/
使用如下语句在命令行窗口生成针对Group接口的测试用例:
java -jar jmlunitng.jar src/Group.java
java -cp jmlunitng.jar src/*.java
java -cp jmlunitng.jar src.Group_JML_Test
此外,配置中出现的错误及可能的解决方案:
$PATH$\Group.java:11: 错误: 已在类 Group 中定义了变量 name
//@ public instance model non_null String name;
/*解决方法:JML规格中的变量名不得与Java代码中的变量名重名。*/
$PATH$\Group.java:15: 错误: 需要数组, 但找到ArrayList
// @ ensures \result == people[index]
/*解决方法:严格按照JML规格,使用数组实现。*/
$PATH$\Group.java:35: 错误: An identifier with private visibility may not be used in a ensures clause with public visibility
/*解决方法:严格按照JML规格,使用public修饰变量。*/
成功编译之后,生成的文件树如下图所示,其中MyGroup_JML_Data
文件夹内包含两种测试,分别是类级测试与针对各个方法的测试。MyGroup_JML_Test.java
是测试主类,它包含所有生成的测试类。MyGroup_InstanceStrategy.java
用于生成类实例的策略类 MyGroup。PackageStrategy_*.java
- 类型的包级数据策略。
3.2 结果分析
运行结果如下:
[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)
Failed: <>.checkPerson(null)
Failed: <>.checkPerson(null)
Failed: <>.checkPerson(null)
Passed: <>.checkRelation(null, null, -2147483648)
Passed: <>.checkRelation(null, null, -2147483648)
Passed: <>.checkRelation(null, null, -2147483648)
Passed: <>.checkRelation(null, null, 0)
Passed: <>.checkRelation(null, null, 0)
Passed: <>.checkRelation(null, null, 0)
Passed: <>.checkRelation(null, null, 2147483647)
Passed: <>.checkRelation(null, null, 2147483647)
Passed: <>.checkRelation(null, null, 2147483647)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(null)
Passed: <>.equals(java.lang.Object@768debd)
Passed: <>.equals(java.lang.Object@7d4793a8)
Passed: <>.equals(java.lang.Object@5479e3f)
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: <>.getValueSum()
Passed: <>.getValueSum()
Passed: <>.getValueSum()
Failed: <>.hasPerson(null)
Failed: <>.hasPerson(null)
Failed: <>.hasPerson(null)
Passed: <>.size()
Passed: <>.size()
Passed: <>.size()
===============================================
Command line suite
Total tests run: 49, Failures: 10, Skips: 0
===============================================
结果是符合预期的。两个addPerson()
,checkPerson()
方法由于没有对应的JML规格注释,所以均Failed。如图所示,多次出现的int值为0或者±2147483647,可见JMLUnitNG自动生成的测试样例均为边界样例。
4.架构分析
4.1 第一次作业
第一次作业只需要实现MyNetwork/MyPerson接口,所以架构较为简单。
(1)MyPerson类
使用HashMap
接口的方法实现都较为朴素,直接根据JML规格实现即可。
(2)MyNetwork类
people:HashMap
:同样的,因为id是唯一的,所以可以作为map的key值存储此社交系统内的人。
unionSet:HashMap
:为降低isCircle
方法的时间复杂度,笔者采用并查集的方式判断连通性,将复杂度转移至addPerson()
中的$O(peopleNumber)$,查询复杂度为$O(1)$。
JML规格中实现相对复杂的方法如下:
1.queryNameRank
: 直接遍历查询即可,复杂度$O(peopleNumber)$。
2.isCircle
: 为降低isCircle
方法的时间复杂度,笔者采用并查集的方式判断连通性,将复杂度转移至addPerson()
中的$O(peopleNumber)$,查询复杂度为$O(1)$。
4.2 第二次作业
相对于第一次作业,此次作业增加了Group接口。基本架构仍然不变,只是实现了MyGroup,MyPerson和MyGroup接口。
(1)MyPerson类
与第一次毫无差别。
(2)MyNetwork类
在前一次作业的基础上,MyNetwork仅仅增加了MyGroup相关的增加/查询方法,前者实现较为朴素,后者均通过查询MyGroup类的实现方法,利用空间换取时间来控制复杂度。
(3)MyGroup类
people:HashMap
:同样的,因为id是唯一的,所以可以作为map的key值存储此Group内的人。
在Group类中,有两类查询方法,一类是点查询(如ageMean()
,ageVar()
等),另一类是线查询(如relationSum()
,valueSum()
)。这两类查询分别可以通过空间存储有关数值,时间复杂度分别分散到每次addPerson()
时的$O(1)$复杂度和查询的$O(1)$复杂度。对于线查询的第二类方法,通过每次增加社交关系时存储数值,可以降低到addRelation()
时的$O(peopleNumber)$复杂度(最坏情况)。
4.3 第三次作业
整体架构几乎不变,除了增加了三个类(MyTarjan,Edge和Point)。增加的三个类主要服务于Network的某几个方法,相对而言耦合度较低。
(1)MyPerson类
与第一次毫无差别。
(2)MyGroup类
在前一次作业的基础上,Group类仅仅增加了delPerson()方法,用于删除组内人员以及更改相关数值。
(3)MyNetwork类
新增了三个类,其中Point类和Edge类都比较简单,是辅助存储图的数据结构(点和边)。另外一个类MyTarjan,这是一个实现Tarjan算法的类。由于Tarjan算法较为复杂,笔者有意将其封装成类使用。但是这样的架构并不完善,因为其实有关图类的数据结构仍然保存在MyNetwork.java内。思考后,认为更好的解决方式是抽象出一个只有点/边的MyGraph类。由于group属性不影响多数图相关的查询方法,整个network实际上是一张图。
此外,关于此次的时间复杂度控制,query_min_path
使用堆优化的dijkstra算法,而query_strong_linked
方法使用tarjan算法求解两点是否在同一ecc即可。
5.错误及修复
5.1 第一次作业
/*@ public normal_behavior
@ assignable \nothing;
@ ensures \result == (\exists int i; 0 <= i && i < acquaintance.length;
@ acquaintance[i].getId() == person.getId()) || person.getId() == id;
@*/
public /*@pure@*/ boolean isLinked(Person person) {
if (person.getId() == id1) {
return true;
}
for (int i =0;i
因为没有查看更新后的JML规格,导致多种方法内的自身与自身(即id1==id2
时)默认为不连通,导致isLinked
及相关方法的结果出错。
5.2 第二次作业
relationSum
的规格实现出现了问题(即addPerson()
时没有乘2)。
5.3 第三次作业
public /*@pure@*/ int queryMinPath(int id1, int id2) throws PersonIdNotFoundException {
if (!contains(id1) || !contains(id2)) {
throw new PersonIdNotFoundException();
}
if (id1 == id2) {
return 0;
}
PriorityQueue queue = new PriorityQueue<>();
HashSet vis = new HashSet<>();
HashMap dis = new HashMap<>();
queue.add(new Point(id1, 0));
dis.put(id1,0);
while (!queue.isEmpty()) {
int here = queue.peek().id;
queue.poll();
if (here == id2) {
return dis.get(id2);
}
if (vis.contains(here)) {
continue;
}
vis.add(here);
for (int adj:linkedNodes.get(here)) {
int val = getPerson(here).queryValue(getPerson(adj));
int ori = dis.getOrDefault(here,Integer.MAX_VALUE);
if (!vis.contains(adj) && ori + val < dis.getOrDefault(adj,Integer.MAX_VALUE)) {
dis.put(adj,ori + val);
queue.add(new Point(adj,ori + val));
}
}
}
return dis.getOrDefault(id2,-1);
}
出现了CPU超时的情况,经检查,是queryMinPath()
内的dijstra算法没有及时停止。增加了判断node==id2的判断。
5.4 错误测试及反思
本次测试主要采用两种方法,分别是Junit单元测试和对拍测试。如果碰巧都看错了JML规格,那么后者极其不靠谱(划。
相较于其他,JUnit单元测试更适合测试Group和Person类(方法较为简单,正确性也显而易见)。比如针对Group接口的测试类的主要方法如下:
@Test
public void hasPerson() {
for (Person person:people) {
try {
assert (group.hasPerson(person));
} catch (AssertionError e) {
System.err.println("hasPerson failed");
continue;
}
}
System.out.println("---------hasPerson end------");
}
@Test
public void getRelationSum() {
int ret = group.getRelationSum();
int sum = 0;
for (Person person:people) {
for (Person person1:people) {
if (person.isLinked(person1)) {
sum++;
}
}
}
assert(ret == sum);
System.out.println("---------realtionSum end------");
}
@Test
public void getValueSum() {
int ret = group.getValueSum();
int sum = 0;
for (Person person:people) {
for (Person person1:people) {
if (person.isLinked(person1)) {
sum += person.queryValue(person1);
}
}
}
assert(ret == sum);
System.out.println("---------valueSum end------");
}
@Test
public void getConflictSum() {
BigInteger ret = group.getConflictSum();
BigInteger sum = BigInteger.ZERO;
for (Person person:people) {
sum = sum.xor(person.getCharacter());
}
assert(ret.equals(sum));
System.out.println("---------conflictSum end------");
}
@Test
public void getAgeMean() {
int ret = group.getAgeMean();
int sum = 0;
for (Person person:people) {
sum += person.getAge();
}
sum /= people.size;
assert(ret == sum);
System.out.println("---------getAgeMean end------");
}
@Test
public void getAgeVar() {
int ret = group.getAgeVar();
int sum = 0;
for (Person person:people) {
sum += (person.getAge() - getAgeMean()) *(person.getAge() - getAgeMean());
}
sum /= people.size;
assert(ret == sum);
System.out.println("---------getAgeVar end------");
}
此外,对于tarjan和dijkstra算法,使用对拍器构造数据并对拍测试。对拍的缺陷在于正确性难以保证,但是运行时间较为直观(然而因为对拍人数较少,dijkstra算法的TLE的情况并没有检测出)。
生成测试数据集的参数如下:
QUERY_EXISTED_PERSON_ID_POSIBILITY = 0.8 # 查询类指令从已有的person_id里选的概率
#QUERY_NOT_EXISTED_PERSON_ID_POSIBILITY = 0.1 # 查询类指令从person_id随机选的概率
QUERY_EXISTED_GROUP_ID_POSIBILITY = 0.8 # 查询类指令从已有的group_id里选的概率
#QUERY_NOT_EXISTED_GROUP_ID_POSIBILITY = 0.1 # 查询类指令group_id随机选的概率
ADD_EXISTED_PERSON_ID_POSIBILITY = 0.2 # 增加类指令从已有的person_id里选的概率
#ADD_NOT_EXISTED_PERSON_ID_POSIBILITY = 0.8 # 增加类指令从person_id随机选的概率
ADD_EXISTED_GROUP_ID_POSIBILITY = 0.2 # 增加类指令从已有的group_id里选的概率
#ADD_NOT_EXISTED_GROUP_ID_POSIBILITY = 0.8 # 增加类指令group_id随机选的概率
MAX_GROUP_INDEX = 1000 # 最大组编号
MAX_GROUP_NUM = 10 # 最大组数
INF = 100000
global group_num
group_num = 0````
6.心得体会
个人感觉,JML规格可以提高代码的正确性。它提供了规范代码的方法,通过形式化语言来撰写代码的规格,一方面利于前期的架构设计,更一方面也更容易测试分析。此外,JML规格提高了代码的可读性,更有利于多人合作完成代码。从这个角度出发,它也有利于个人发展一种良好的代码书写习惯吧。最重要的是,JML语言与离散数学的知识重叠度较高,也有利于通过形式化语言来验证正确性。但是当工程量相当大或者多个方法联系过于紧密时,一方面可能jml规格会更难以撰写,也容易显得过于庞杂和晦涩;另一方面可能也会给具体实现带来困难。