守得云开见月明——OO第三单元总结

OO Unit3 总结 博客作业

18373806 冯天昱

历时五周(中途暂停了一周),OO第三单元——JML和规格的学习告一段落。

与前两个单元完全不同的是,这一单元的设计是基于非常完整的规格实现的,这导致了:

①更容易保证实现的正确性:可以使用JUnit单元测试对实现进行完整地测试,可以通过对照规格和实现快速检查和发现代码中的BUG;

②整体架构的设计能力被弱化:课程组给出的三个接口和规格十分详尽,相当于帮助我们确定了程序架构的整体框架,代码的编写只需要小体量的结构设计和保证每个指定功能的实现与预期一致就可以了。

下面对本单元的三次作业进行总结。

一、JML及其工具链

1. JML

JML (Java Modeling Language)是一种行为接口规格语言,定义了Java程序中的方法规格和类型规格。结合JML工具链,我们可以自动构造基于规格的测试用例、通过形式化验证检查规格的实现,以更加严格的手段确保程序实现符合预期。

JML以javadoc注释的形式嵌入Java代码中,以@开头。

JML的学习主要从以下两个方面入手:

  1. JML表达式

    JML中有许多表达式,其中有\result\old()\not_assigned等具有特定含义的原子表达式、\forall\exists\sum等对一组数据进行量化的量化表达式、与Java程序语言中类似并在其基础上有一定扩充的操作符等等,JML中还可以定义JML集合用来表示具有一类特征的数据的总和。

  2. JML规格

    1. 方法规格

      方法规格是针对Java类或Java接口中方法的规格定义。每个方法规格可以有多个行为规格,分为两类:正常行为和异常行为。

      对于每个行为,它的规格包括:前置条件、后置条件和副作用。其中:

      1. 前置条件的格式为requires P;,其中P是一个谓词,调用者必须保证P为真,方法的执行才能符合该行为。每个行为可以包含多个前置条件,调用者如果需要方法符合该行为,所有前置条件都必须被满足。
      2. 后置条件的格式为ensures P;,若调用者能够满足该行为的所有前置条件,则该方法的执行结果必须保证P为真。一个行为可以包含多个后置条件,它们都需要被保证。
      3. 副作用的格式为assignable varmodifiable var,其中var可以为规格中的变量,也可以为\nothing\everything等表达式。
    2. 类型规格

      类型规格是针对类/接口的规格,主要包括:规格变量、不变式和约束。

      1. 规格变量:规格变量是所有通过Java程序语言和JML规格注释声明的变量。
        对于某个变量,它可以在程序中为private,但在规格中为public,以便在编写使用该类的其他类的规格时使用。
        规格变量是用来完整地描述类型规格和方法规格的,在实现中并非一定要按照规格变量的定义来定义变量。
        规格变量也可以使用static修饰,因此规格变量也分为静态规格变量和实例规格变量。
      2. 不变式:不变式的格式是invariant P;,表示该类的不变式一旦建立,任何状态的变化都要保证:“若在变化前P为真,则变化后P依然为真”。
      3. 约束:约束的格式是constraint P;,表示每一次状态变化前和变化后的状态满足P为真。

在JML方法规格中,如果一个方法无需满足任何前置条件,也没有任何副作用,则该方法可以为pure的,在JML中使用pure关键字修饰。pure方法可以在表达式中使用,包括在其他方法规格和类型规格中。

2. JML工具链

  1. OpenJML:OpenJML是一个相对完整的JML工具,提供了JML语法检查、基于SMT Solver的静态检查(ESC)和基于自动生成测试用例的运行时检查(RAC)。
  2. JMLUnitNG/JMLUnit:可以基于JML规格自动生成测试用例,结合OpenJML的RAC对程序功能进行自动化测试。

JML的工具链大多比较老旧且缺乏维护,手册很少,使用十分困难,在下面的内容中会继续介绍。

二、使用JML工具链进行测试验证

1. 使用SMT Solver进行ESC(静态检查)

OpenJML提供了使用SMT Solver进行形式化验证的功能。

理论上,通过OpenJML提供的Eclipse集成开发环境插件,我们可以方便地进行形式化验证。

但在实际部署的过程中,出现了很多问题:如在本地环境运行时,OpenJML内部经常会抛出空指针等异常,导致很多类根本无法测试,唯一能够完整地完成测试而不出现错误的是我在本单元作业中对Person的实现——MyPerson类。

下图是在Eclipse中使用OpenJML插件对MyPerson类进行形式化验证的结果:

虽然能够执行检查,但检查结果出现了迷之错误,几乎所有的方法都是INVALID。我们查看其中几个检查的Trace:

其中,getId、getAge等获取结果的方法在检查中总是出现实际结果和预期相差1的问题,而compareTo,equals等方法则会在内部调用其他方法时抛出异常,但在程序的执行过程中并没有发现这些问题,原因至今不明。

2. 使用JMLUnitNG基于JML规格生成测试用例进行自动测试

JMLUnitNG的使用分为四个步骤:

  1. 使用java运行JMLUnitNG.jar,以待测试类的源代码为参数,生成测试代码。
    java -jar jmlunitng.jar package/path/here/ClassNameHere.java
  2. 使用javac,将JMLUnitNG.jar作为库(-cp),对生成测试代码和原来的代码进行编译。
    javac -cp jmlunitng.jar <所有.java代码文件>
  3. 使用java运行openjml.jar,以待测试类的源代码为参数,进行RAC程序的编译。
    java -jar openjml.jar -rac package/path/here/ClassNameHere.java
    (可能需要使用-cp参数来指定classpath)
  4. 使用java,将JMLUnitNG.jar作为库(-cp),运行待测试类的JML_Test类。
    java -cp jmlunitng.jar package.name.here.ClassNameHere_JML_Test

幸运的是,JMLUnitNG本身在使用的过程中并没有出现问题,然而在使用OpenJML生成编译RAC程序的时候出现了问题,抛出了内部异常,在尝试了许多方法后都没有解决,万般无奈之下只能跳过RAC部分的检查,仅使用JMLUnitNG生成的测试程序进行测试。

下面是使用JMLUnitNG对MyGroup类进行测试的结果(没有RAC):

❯ java -jar jmlunitng.jar test/MyGroup.java
❯ javac -cp jmlunitng.jar test/*.java
❯ java -cp jmlunitng.jar test.MyGroup_JML_Test
[TestNG] Running:
  Command line suite

Failed: racEnabled()
Passed: constructor MyGroup(-2147483648)
Passed: constructor MyGroup(0)
Passed: constructor MyGroup(2147483647)
Failed: <<test.MyGroup@8000001f>>.addPerson(null)
Failed: <<test.MyGroup@1f>>.addPerson(null)
Failed: <<test.MyGroup@8000001e>>.addPerson(null)
Passed: <<test.MyGroup@8000001f>>.addRelation(null, null)
Passed: <<test.MyGroup@1f>>.addRelation(null, null)
Passed: <<test.MyGroup@8000001e>>.addRelation(null, null)
Failed: <<test.MyGroup@8000001f>>.delPerson(null)
Failed: <<test.MyGroup@1f>>.delPerson(null)
Failed: <<test.MyGroup@8000001e>>.delPerson(null)
Passed: <<test.MyGroup@8000001f>>.equals(null)
Passed: <<test.MyGroup@1f>>.equals(null)
Passed: <<test.MyGroup@8000001e>>.equals(null)
Passed: <<test.MyGroup@8000001f>>.equals(java.lang.Object@1761e840)
Passed: <<test.MyGroup@1f>>.equals(java.lang.Object@6c629d6e)
Passed: <<test.MyGroup@8000001e>>.equals(java.lang.Object@3f102e87)
Passed: <<test.MyGroup@8000001f>>.getAgeMean()
Passed: <<test.MyGroup@1f>>.getAgeMean()
Passed: <<test.MyGroup@8000001e>>.getAgeMean()
Passed: <<test.MyGroup@8000001f>>.getAgeVar()
Passed: <<test.MyGroup@1f>>.getAgeVar()
Passed: <<test.MyGroup@8000001e>>.getAgeVar()
Passed: <<test.MyGroup@8000001f>>.getConflictSum()
Passed: <<test.MyGroup@1f>>.getConflictSum()
Passed: <<test.MyGroup@8000001e>>.getConflictSum()
Passed: <<test.MyGroup@8000001f>>.getId()
Passed: <<test.MyGroup@1f>>.getId()
Passed: <<test.MyGroup@8000001e>>.getId()
Passed: <<test.MyGroup@8000001f>>.getPeopleSum()
Passed: <<test.MyGroup@1f>>.getPeopleSum()
Passed: <<test.MyGroup@8000001e>>.getPeopleSum()
Passed: <<test.MyGroup@8000001f>>.getRelationSum()
Passed: <<test.MyGroup@1f>>.getRelationSum()
Passed: <<test.MyGroup@8000001e>>.getRelationSum()
Passed: <<test.MyGroup@8000001f>>.getValueSum()
Passed: <<test.MyGroup@1f>>.getValueSum()
Passed: <<test.MyGroup@8000001e>>.getValueSum()
Passed: <<test.MyGroup@8000001f>>.hasPerson(null)
Passed: <<test.MyGroup@1f>>.hasPerson(null)
Passed: <<test.MyGroup@8000001e>>.hasPerson(null)
Passed: <<test.MyGroup@8000001f>>.hashCode()
Passed: <<test.MyGroup@1f>>.hashCode()
Passed: <<test.MyGroup@8000001e>>.hashCode()

===============================================
Command line suite
Total tests run: 46, Failures: 7, Skips: 0
===============================================

由于没有编译RAC,因此第一个RAC的case没有通过,而addPerson和delPerson由于没有规格,因此测试没有意义。其他的测试都通过了。从构造器的参数可以看出JMLUnitNG生成的测试用例偏向于边界数据。

不知道RAC的测试强度会不会更大,但单从这些输出来看,JMLUnitNG的测试用例还是很弱的,况且使用的过程也十分不便,还没有达到能够在学习和生产中使用的程度。

三、作业架构分析

1. 第一次作业

第一次作业只写了两个类,也就是分别对两个接口进行了实现,所有的代码内容也几乎都是完全按照JML的规格来写的,为了能够在MyNetwork中访问MyPerson的人际关系,以完成isCircle中对连通性的查询,还定义了一个简单的方法直接返回了MyPerson的acquaintance对象(是一个ArrayList),这其实暴露了MyPerson内部的数据管理对象,在设计上有很大缺陷。

本次作业的UML类图没有什么意义就不放了。

2. 第二次作业

第二次作业引入了Group,并基于Group添加了许多查询的方法。

与第一次的实现不同,这次作业我充分考虑了程序性能,选用恰当的容器,并且对做了许多数据冗余和预处理来加快查询速度。当然这样做降低了修改的性能,但考虑到第二次作业的修改操作受到限制,而查询操作的规模可能很大,因此这样做是值得的。

  1. 为了提高isCircle对连通性的查询,考虑到本次作业没有人员的删除操作,我在程序中引入了并查集的数据结构,在添加关系时将两人所在的集合合并,并在查询时直接通过并查集查询是否在一个集合中获得结果,将查询和修改的复杂度都降低到对数级别。 并查集也利用了Java的泛型封装成了可复用的类。
  2. 使用了一些容器提高程序效率。
    1. 在MyNetwork中添加了从整数id到Person对象的HashMap,实现Person的快速查询。Group也是类似。
    2. 在MyGroup中使用HashSet来存储组内成员,加快了查询速度。
    3. 在MyPerson中使用Person对象到整数value的HashMap来保存每个有关系的人与这个人关系的value值。
  3. 为了提高Group中各种数据的查询,我在MyGroup中维护了许多变量来加快查询结果:
    1. 维护了人员年龄的和、平方和。在计算年龄平均值和年龄方差时直接通过公式就可以得到结果。
    2. 维护了valueSum和relationSum。在向组内添加Person时,检查他与其他组内成员的关系,并依此增加valueSum和relationSum;在向Network添加关系时,遍历每一个Group,检查两个人是否都在该Group里并依此增加valueSum和relationSum。在查询这两个值时直接返回维护的变量,查询效率非常高。
    3. 维护了conflictSum,初始化为0,每加入一个人就xor它的character,在查询时直接返回结果。

第二次作业的UML类图:

3. 第三次作业

这次作业依然没有将图抽离出来,而是将一些算法封装成类,这些类也直接依赖于Person,因此相当与用Person对象来维护整个社交网络的图结构。

在第二次作业的基础上,本次作业还增加了从Group中去除Person的操作。在删除时显然需要更新第二次作业中维护的那些变量。其中利用了一个异或的性质(一些地方称为“自反性”),即A ^ A = 0。因此在删除该Person时再次用conflistSum与它的character异或一下就可以了。

本次作业加入了年龄区间人数查询、最短路查询、点双连通性查询、连通块个数查询,还加入了借钱和余额查询。

  1. 对于年龄区间查询,我的程序中封装了一个二叉索引树(Binary Indexed Tree,又称“树状数组”),可以以O(logn)的时间复杂度进行一个数组的单点更新和区间和查询,正好与本功能的要求相符。但规格中并没有要求l <= r,因此在l > r时要单独判断,否则可能出现负数的错误结果。
  2. 对于最短路查询,我的程序封装了堆优化的Dijkstra算法,但是在一些变量的维护和访问上可能出了一些小问题,导致险些在强测中超时。
  3. 对于点双连通性查询,我的程序封装了求点双连通分量的Tarjan算法,通过判断两个点是否在同一个点个数不小于3的点双连通分量中来判断是否strong linked。为了防止递归爆栈,我在实现Tarjan算法时利用程序模拟了递归调用栈,利用堆空间展开算法。
  4. 对于连通块查询,我对第二次作业中的并查集进行了一些修改,维护了一个变量记录连通块的个数。在每次加入一个新人时,由于他与其他人没有关系,自身是一个连通块,因此变量+1;在每次增加关系时,如果两个人原来不在一个集合中,两个集合需要合并时,说明两个连通块合成了一个连通块,因此变量-1。在查询时直接输出该变量即可。
  5. 对于借钱和金额的查询部分,实现还是非常简单的,可喜的是我并没有将其分割成一个独立的模块,而是直接在Network中维护了相应的变量,这十分不利于之后的扩展。

本次作业增加了许多功能,对程序的性能提出了更高的要求,也要求我们必须要在性能上作出一些取舍。比如一些数据由于查询次数频繁而修改次数较少,必须通过增加空间复杂度、增加修改的时间开销来减小查询的时间开销,以保证程序的整体性能。

虽然本次作业在测试中表现尚可,但在架构设计上并没有多么出彩的地方,只是将一些算法封装成类、将并查集、二叉索引树等实用数据结构封装,方便编码时使用。

不难想象,如果还有第四次迭代,我的臃肿不堪的MyNetwork将不得不经历一次痛苦的重构了。

第三次作业的UML类图:

四、BUG分析

在本单元三次作业的所有测试中我的程序都没有被hack。

然而在第三次作业强测的第14、15、16个数据点(测试最短路)中,我的程序表现不是很好,最长的CPU时间达到了1.8s,距离时间上限很近,而询问许多同学的执行时间都在1s左右,说明我的程序实现中有影响效率的问题。然而在本地对相同的数据分别使用我的程序和强测结果1s左右的程序运行并记录时间后,发现执行时间是相近的。阅读了很多其他人的代码与自己的代码进行比对也没有发现异常耗时的地方,比较诡异。

不过使用JProfiler对程序运行过程进行Sampling的过程中,发现HashSet和HashMap的查询操作占用了相当大一部分时间,因此强侧中的效率低下可能与容器使用不当有关。

在互测的过程中,在房间内其他人的代码中发现了一些问题。其中,第二次作业中有一些人存在生搬硬套JML规格的现象,使用双重循环计算Group的ValueSum、RelationSum值,导致程序超时;此外,在第三次作业中一个同伴的代码出现了算法漏洞,在计算isStrongLinked时出现了误将false判成true的情况,但代码中使用的算法我并没有看懂。

五、心得体会

规格的引入从理论上保证了代码实现的正确性,大大增加了系统设计中各个模块设计的准确性和一致性,同时使得系统架构的设计可以和实现代码的编写以更大的程度分离:可以在代码实现之前先设计好架构,确定好每个类和方法规格,再根据规格进行实际代码的编写。

但规格的设计和撰写本身对使用者有较高的要求,不仅需要掌握形式化表述的能力,还需要具有较高的抽象能力、系统设计能力和对数据结构的掌握能力。这是因为虽然规格隐藏了具体实现,但类的层次和方法的划分还是不可能避免的。与其说规格隐藏了具体实现,不如说是规格要求设计者在代码实现之前就能够把握程序的整体运行流程和组织结构,对设计者的要求提高了,而降低了“码农”的需求——只需要严格按照规格实现,就能保证程序运行结果符合预期。

此外,对于大多数规格描述语言,其语法都与谓词逻辑类似,即使是强如JML这种原生支持多种表达式的语言,想要清楚地描述一些方法规格也是十分困难的。当然,如果一些方法规格描述十分困难甚至无法在合理的复杂程度内描述,那么很可能是方法或类的划分不合理。因此,规格的撰写也能帮助我们审视自己的设计。

同时,即使是面对已经设计好的规格,在具体实现时也需要考虑许多因素——怎么组织数据的存储、是否需要引入更加细化的层次结构来完成复杂功能、是否需要冗余存储某些数据、进行某些预处理以提高查询性能,等等,而不是按照规格表达的方式原封不动地翻译成程序语言。

随着第三单元的结束,OO课程也过去了一大半。虽然在学习和练习的过程中遇到了许多困难,但最终也都通过各种方式解决了。在此,我借用一句名言作为本次总结的结尾:

“我们遇到什么困难,也不要怕!微笑着面对它!消除恐惧的最好办法就是面对恐惧。坚持,才是胜利!加油,奥利给!”

posted @ 2020-05-23 00:57  fty1777  阅读(167)  评论(1编辑  收藏  举报