OO随笔之追求完美的第三单元——初试JML

前言

这一章的JML比较简单,那么大家的关注点自然地移到了性能优化上。于是大家一股脑地去利用各种数据结构去做时间上的优化(当然很多人最后还是倒在了正确性上),故称追求完美的一单元。当然这也是得益于JML的,有了它的指导,每个方法的职能就非常清楚了,类之间的耦合自然也小了,同学们就可以针对一个方法精打细磨。

JML语言简介

JML(Java Modeling Language)是一种对于Java语言进行规格化设计的一种表示语言,它是一种行为接口规格语言(Behavior Interface Specification Language, BISL)。

一般而言,JML有两种主要用法:

  • 开展规格化设计。这样交给代码实现人员的将不是可能带有内在模糊性的自然语言描述,而是逻辑严格的规格。
  • 针对已有代码实现,书写其对应的规格,从而提高代码的可维护性。这在遗留代码的维护方面具有特别重要的意义。

方法规格的核心内容包括三个方面:前置条件、后置条件和副作用约定。

前置条件(pre-condition)

前置条件通过requires子句来表示:

requires P;

其中requires是JML关键词,表达的意思是“要求调用者确保P为真”。

后置条件(post-condition)

后置条件通过ensures子句来表示:

ensures P;

其中ensures是JML关键词,表达的意思是“方法实现者确保方法执行返回结果一定满足谓词P的要求,即确保P为真”。

副作用范围限定(side-effects)

副作用指方法在执行过程中会修改对象的属性数据或者类的静态成员数据,从而给后续方法的执行带来影响。从方法规格的角度,必须要明确给出副作用范围。JML提供了副作用约束子句,使用关键词assignable或者modifiable。从语法上来看,副作用约束子句共有两种形态,一种不指明具体的变量,而是用JML关键词来概括;另一种则是指明具体的变量列表。

signals子句

signals子句的结构为signals (***Exception e) b_expr,意思是当b_expr为true时,方法会抛出括号中给出的相应异常e。

JML工具链主要有OpenJML和JMLUnit,但是他们对于一般的学习者和开发者来说并不太友好,首先它对于代码的数据结构的要求比较高(Hash类好像就不支持),而且对于复杂场景复杂方法的处理存在问题。在实际的开发中,可能程序员们更加偏爱Javadoc和UML的方式。

部署JMLUnitNG/JMLUnit

本地只部署了OpenJML进行测试,也利用Junit手造数据对每个方法进行了测试。

实例代码如下:

package path;

public class path {
    static private /*@ spec_public @*/ int[] nodes;

    public static void main(String[] args) {
        nodes = new int[5];
        nodes[0] = 0;
        nodes[1] = 1;
        nodes[2] = 2;
        nodes[3] = 3;
        nodes[4] = 4;
        System.out.println(size() + "" + getNode(2) + isValid());
    }

    //@ ensures \result == nodes.length;
    public static  /*@ pure @*/int size() {
        return nodes.length;
    }

    /*@ requires index >= 0 && index < size();
      @ assignable \nothing;
      @ ensures \result == nodes[index];
      @*/
    public static /*@ pure @*/ int getNode(int index) {
        return nodes[index];
    }

    //@ ensures \result == (nodes.length >= 2);
    public static /*@ pure @*/ boolean isValid() {
        return size() >= 2;
    }
}

 

通过OpenJML的测试,他给了我如下反馈

根据结果发现,三个方法对于JML的实现在静态检查中均没有问题;但是在运行中,它发现我的nodes数组有为null值的可能,也算是个可能的bug吧。

关于JMLUnitNG的问题,我尝试了利用jmlunitng.jar包搭建自动生成数据的平台,但是似乎他自动生成的测试代码有bug,调了很久的源码后还是发现有这样一个问题:非子类访问了一个protected的变量,所以无法运行。想来想去应该还是自己可能有一些地方细节没有调好(甚至可能是JDK版本问题或者是shell的问题)。

所以就只能进行手动测试了:实际上对于每个类和方法,都做了基于Junit(或testNG)的测试,手动编写测试数据,利用断言判断代码逻辑的问题。

架构设计分析

第一次作业PathContainer

第一次作业比较简单,保证正确性不难,难点在于在“少量增删,大量查询”这样一个前提下的效率提高。在Path类和PathContainer类中都利用了数据冗余来提高查找效率:Path中采用了ArrayList来记录节点顺序和HashSet来记录节点种类;在PathContainer中用了两个HashMap来分别记录path及其id和所有出现过的节点及其次数。这使得增删时多了一倍的工作量,但是查询时的复杂度大大降低。

第二次作业Graph

Graph类直接继承了PathContainer类,只是新增了一个距离矩阵(利用映射来给节点和矩阵中元素建立一一对应),并且采用了floyd算法来计算两点间的最短路径:在“少量增删,大量查询”这样一个前提下,只每次图的增删时,重构距离矩阵,重新计算距离。

第三次作业RaiwaySystem

第三次作业RaiwaySystem类直接继承了Graph类,同时将floyd算法单独抽象出一个类,同时修改了祖传的Path类。

Path类中,对于每一个Path都有自己独立的“三矩阵”(换乘矩阵,价格矩阵和不满意度矩阵),当在RaiwaySystem中新增path时就初始化“三矩阵”(而不是新建path时,主要是考虑到程序中会隐含地出现“新建了但不加入RaiwaySystem”的临时path,这样做可以提高效率)。

RaiwaySystem类同样维护了“三矩阵”,在“少量增删,大量查询”这样一个前提下,每次地铁系统的增删时,重构距离矩阵,重新计算距离。

下面用数据分析下性能:

复杂度(仅列出v(G)较高的方法):

我们看下floyd()这个大头,作为一个多源最短路算法,他采用了for-for-for嵌套的方式计算最短路。思路很简单,但是表现在算法上的复杂度就比较复杂了。作为一个固定的算法,并没有很大的优化空间。

下一个是addPath(),它主要体现在逻辑复杂上。由于在Graph中维护了两个HashMap,分别记录出现过的所有节点及其次数、出现过的所有边及其次数,那么在新增一个path时,就必定要将新path中的所有点和边加入到这个hashmap中:对于已经存在的节点,我们可以简单地将它的出现次数加一;但对于图中不存在的节点,就必须要在我们的“三矩阵”中给他分配一个位置,便于我们计算这个点到其他点的距离。这个方法其实有一点点的优化空间,但是为了尽量满足单一责任原则(OCP)原则,想了想,算了,不改了。

下一个是path中的initMatrix(),这玩意干的事情就是对于每个路径path,初始化他的“三矩阵”,然后用floyd去给他算一遍。逻辑和代码复杂度都还行,但是为什么最后体现的v(G)足足有8点。

下一个是path中的祖传equals()方法,它首先利用hash判断两个path是否相同,如是,再遍历判断。本来想法是挺好的,但是在看了hash的源码之后,发现事情没有那么简单:既然ArrayList的hash是遍历还要做乘法加法,为什么不直接遍历逐项比较呢,对吧。所以讲道理这个可以有一定的优化。

其他复杂方法暂不分析。

bug情况

一共出现了两个bug:第一个是在第一次作业因为重写equals()方法时的大意,盲目相信了前任造的轮子,忽略了Hash值相同的情况;第二次是在第三次作业在最后优化时,在修改Path中某个新成员变量时误改动了旧的变量,导致代码逻辑出现了漏洞。

本来写了一个自动数据生成器,但是因为生成器的不够完善,生成的数据不够极端,导致上述第二个bug没有被检测出。

但是其实更大的原因应该是自己在修改时,擅自动了旧的代码,甚至轻微改变了旧的代码逻辑,这其实有违单一责任原则(OCP)原则。

心得体会

 这一次的作业难度其实并不是很大,除了学会了JML这一种代码描述语言的应用,还在搭建本地测评机时提高自己利用命令行来操作java程序的能力。但是同时也发现一个大问题:自己经常在一个大的项目中,犯下一些细节的错误,比如将一个数字写错,导致空指针;比如轻微改变了一下几行代码的顺序,却导致严重问题……自己在写大量的代码的时候,思路依然不够清楚;或者说是自己在设计的时候,往往只是设计了一个大的框架之后,就开始动笔,而没有将每一个细节都想好——当遇到一个自己从来没注意到的细节的地方的时候,可能会因为临时的仓促而忽略了代码的其他部分,而在逻辑上产生隐含的问题。所以下一单元,不论难度怎么样,我都应该更加严格地遵循SOLID原则,经过更加缜密的思考之后,再下笔。

posted @ 2019-05-22 15:51  lzhmark  阅读(154)  评论(0编辑  收藏  举报