面向对象的分析和设计的原则之一是,应将程序思考尽可能地推迟。 我们大多数人仅在执行方法之前一直遵守该规则。 一旦确定了类及其接口,就该开始实现方法了,我们将切换到过程性思维。 毕竟,还有什么选择? 与大多数语言一样,编写Java代码时,我们需要提供逐步的过程来计算每种方法的结果。

就其本身而言,程序的符号只是说该怎么办而没有说的是什么 ,我们正在努力做一些事情。 在开始做之前了解我们要实现的目标将很有用,但是Java语言没有提供一种将信息显式合并到我们的代码中的方法。

Java建模语言(JML)在Java代码中添加了注释,这些注释使我们可以指定要执行的方法而不必说出它们如何执行。 使用JML,我们可以在不考虑实现的情况下描述方法的预期功能。 这样,JML将推迟过程思考的面向对象原理扩展到方法设计阶段。

JML引入了许多用于以声明方式描述行为的构造。 这些包括模型字段,量词,断言的可见性范围,前提条件,后置条件,不变性,合同继承以及正常行为与异常行为的规范。 这些构造使JML非常强大,但是不必了解或使用所有构造,也不必一次使用所有构造。 您可以很简单地开始逐步学习和使用JML。

在本文中,我们将逐步学习JML。 我们将首先概述使用JML的好处,并特别讨论它对开发和编译过程的影响。 接下来,我们将讨论一些关于前置条件,后置条件,模型字段,量化,副作用和异常行为的JML构造。 在此过程中,示例将为每种JML构造提供动力。 在本文的最后,您将对JML的工作原理有一个概念性的了解,并能够将其应用到您自己的程序中。

JML概述

使用JML声明性地描述类和方法的期望行为可以显着改善整个开发过程。 在Java代码中添加建模符号的好处包括:

对代码功能的更精确描述

高效发现和纠正错误

随着应用程序的发展,减少引入错误的机会

早期发现不正确的客户端使用类

始终与应用程序代码同步的精确文档

JML注释始终在Java注释中,因此它们对正常编译的代码没有影响。 开放源码JML编译器(见相关信息 )可以当我们想将类的实际行为比较其JML规范使用。 如果代码未按照规范的说明执行,则使用JML编译器编译的代码将在运行时引发JML异常。 这不仅可以捕获代码中的错误,还可以确保文档(以JML注释的形式)与代码保持同步。

在以下各节中,我将使用开源Jakarta Commons Collection Component(JCCC)中的PriorityQueue接口和BinaryHeap类来说明JML的功能。 您可以在“ 相关主题”部分中找到完整的带注释的PriorityQueue和BinaryHeap源代码。

要求和责任

本文的源代码(请参阅参考资料 )包括JCCC的PriorityQueue接口。 PriorityQueue接口包含方法签名,这些方法签名指定自变量和返回值的数据类型,但不说明方法的行为。 我们希望能够指定PriorityQueue的语义,以便实现它的所有类都以期望的方式运行。 (如果没有行为规范,我们最终可能会获得实现PriorityQueue接口的堆栈类,或类似的其他奇怪情况。)

考虑PriorityQueue的pop()方法。 pop()对优先级队列有什么要求? 有三个基本的。 首先,除非队列中至少有一个元素,否则不应调用pop() 。 其次,它应返回队列中具有最高优先级的元素。 最后,它应该从队列中删除它返回的元素。

清单1显示了表达第一个要求的JML注释:

清单1.满足pop()方法要求的JML注释
/*@
   @   public normal_behavior
   @     requires ! isEmpty();
   @*/
Object pop() throws NoSuchElementException;

如前所述,JML注释写在Java注释内。 包含JML的多行注释以/*@开头。 JML忽略任何@符号,它们是一行中的第一个非空白字符。 JML也可以写在以//@开头的单行注释中。

这里的public关键字在JML中的含义与在Java语言中的含义相同。 public表示该JML规范对应用程序中的所有其他类都是可见的。 公共规范只能引用公共方法和成员变量。 JML还允许规范具有private , protected或package级别的范围。 JML的作用域规则类似于Java语言中的相应规则。

normal_behavior关键字指示该规范描述了pop()正常返回而不会引发异常的情况。 稍后我们将讨论如何指定异常行为。

前提条件和后置条件

关键字JML requires用于前提条件。 前提条件是调用方法之前必须满足的条件。 清单1表示pop()的前提是isEmpty()返回false 。 也就是说,队列中至少包含一项。

方法的后置条件指定了方法的职责; 也就是说,当方法返回时,后置条件应为true。 在我们的示例中, pop()应该返回队列中具有最高优先级的元素。 我们希望指定此后置条件,以便JML在运行时可以对其进行检查。 为此,我们需要跟踪已添加到优先级队列中的所有值,以确定由pop()返回哪个值。 我们可能考虑将一个成员变量添加到PriorityQueue以将值存储在队列中,但是这样做有两个问题:

  • PriorityQueue是一个接口,因此它应该与不同的实现兼容,例如二进制堆,Fibonacci堆或日历队列。 PriorityQueue的JML注释不应说明任何实现。
  • 作为接口, PriorityQueue只能包含静态成员变量。

JML通过其模型字段的概念提供了摆脱这种困境的方法。

模型领域

模型字段就像成员变量,只能在行为规范中使用。 这是PriorityQueue使用的模型字段声明的示例:

//@ public model instance JMLObjectBag elementsInQueue;

该声明表明存在一个名为elementsInQueue的模型字段,其数据类型为JMLObjectBag (JML的bag数据类型)。 instance关键字指示,即使在接口中声明了该字段,该字段的单独非静态副本也将放置在实现PriorityQueue的类的每个实例中。 与所有JML注释一样, elementsInQueue的声明出现在Java注释中,因此无法在常规Java代码中使用elementsInQueue 。 在运行时,没有对象将包含elementsInQueue字段。

规格与实施

使用a来存储元素似乎效率低下,因为随后必须检查每个元素以找到具有最高优先级的元素。 但是,包是规范的一部分,而不是实现的一部分。 该规范的目的是描述PriorityQueue的行为接口; 也就是说,客户可以依靠的外部行为。 只要PriorityQueue的执行结果与规范相同,就可以使用更有效的方法。 例如,JCCC包含BinaryHeap类,该类通过使用存储在数组中的二进制堆来实现PriorityQueue 。

尽管不需要考虑效率就编写规范,但是JML运行时断言检查器的效率很重要,因为在启用断言检查的情况下运行确实会对性能产生影响。

elementsInQueue包含添加到优先级队列的值。 清单2显示了pop()的规范如何使用elementsInQueue :

清单2.在pop()的后置条件中使用的模型字段
/*@
   @ public normal_behavior
   @   requires ! isEmpty();
   @   ensures
   @     elementsInQueue.equals(((JMLObjectBag)
   @          \old(elementsInQueue))
   @                        .remove(\result)) &&
   @     \result.equals(\old(peek()));
   @*/
Object pop() throws NoSuchElementException;

ensures关键字指示以下是后继条件,当pop()返回时必须满足该条件。 \result是一个JML关键字,它等于pop()返回的值。 \old()是一个JML函数,它返回其参数在调用pop()之前具有的值。

ensures子句包含两个后置条件。 第一个说pop()返回的值已从elementsInQueue删除。 第二个说返回的值与peek()返回的值相同。

类级不变式

我们已经看到,JML允许我们为方法指定前提条件和后置条件。 它还允许我们指定类级不变式。 类级不变量是在类中每个方法的进入和退出时必须为真的条件。 例如, //@ public instance invariant elementsInQueue != null; 是PriorityQueue的不变式,表示在实现PriorityQueue的类的构造函数返回后, elementsInQueue不能在任何时候为null。

定量化

在早期的pop()规范中,我们说过它的返回值等于peek()返回的值,但是我们没有看peek()的规范。 清单3中显示了PriorityQueue peek()的规范:

清单3. PriorityQueue中的peek()规范
/*@
   @ public normal_behavior
   @   requires ! isEmpty();
   @   ensures elementsInQueue.has(\result);
   @*/
/*@ pure @*/ Object peek() throws NoSuchElementException;

这个JML注释说,仅当队列中至少有一个元素时才应调用peek() 。 它还说peek()返回的值必须在elementsInQueue ; 也就是说,它必须是先前插入队列的值。

/*@ pure @*/注释表示peek()是纯方法。 纯方法是没有副作用的方法。 JML仅允许断言使用纯方法。 我们声明peek()是纯净的,因此可以在pop()的后置条件中使用它。 如果JML在断言中允许使用非纯方法,那么我们可能会无意中写出具有副作用的规范。 这可能会导致在启用断言检查的情况下编译时可以工作的代码,但在禁用断言检查时不能工作。 稍后我们将更多地讨论副作用。

关于继承

JML规范由实现接口的子类和类继承(与J2SE 1.4中的assert语句不同)。 JML关键字指示规范与从祖先类和要实现的接口继承的规范组合在一起。 因此,对于本说明书peek()中PriorityQueue接口适用于peek()在BinaryHeap以及。 这意味着BinaryHeap.peek()返回的值必须在elementsInQueue ,即使BinaryHeap.peek()的规范中没有明确说明。

最小和最大堆

peek()规范中明显缺少一件事。 它永远不会说返回的值是具有最高优先级的值。 事实证明,JCCC中的PriorityQueue接口可用于最小堆和最大堆。 对于最小堆,最高优先级元素是最小的元素,而对于最大堆,最高优先级元素是最大的元素。 因为PriorityQueue不知道它是与最小堆还是最大堆一起使用,所以规范的指示返回哪个元素的部分必须放在实现PriorityQueue的类中。

在JCCC中, BinaryHeap类实现PriorityQueue 。 BinaryHeap允许客户端通过其构造函数中的参数指定其应作为最小堆还是最大堆。 我们使用布尔模型变量isMinimumHeap指示BinaryHeap是作为最小堆还是最大堆。 的说明书peek()在BinaryHeap使用isMinimumHeap ,如清单4所示:

清单4. BinaryHeap类中的peek()规范
/*@
   @ also
   @   public normal_behavior
   @     requires ! isEmpty();
   @     ensures
   @       (isMinimumHeap ==>
   @           (\forall Object obj;
   @                  elementsInQueue.has(obj);
   @                  compareObjects(\result, obj)
   @                             <= 0)) &&
   @       ((! isMinimumHeap) ==>
   @           (\forall Object obj;
   @                  elementsInQueue.has(obj);
   @                  compareObjects(\result, obj)
   @                             >= 0));
   @*/
public Object peek() throws NoSuchElementException

添加量词

清单4中的后置条件由两部分组成,一个用于最小堆,一个用于最大堆。 ==>符号表示“隐含”。 x ==> y当且仅当y为true或x为false时,y为true。 对于最小堆,以下条件适用:

(\forall Object obj;
          elementsInQueue.has(obj);
          compareObjects(\result, obj) <= 0)

\forall是JML量词。 如果对于所有Objects obj ,上面的\forall表达式为true,使得elementsInQueue.has(obj)为true, compareObjects(\result, obj)返回的值小于或等于零。 换句话说,当使用compareObjects()比较值时, peek()返回的值小于或等于elementsInQueue每个elementsInQueue 。 其他JML量词包括\exists , \sum和\min 。

添加比较器

BinaryHeap类允许以两种不同方式比较元素。 一种方法是使用Comparable接口依赖元素的自然顺序。 另一个是让客户端将Comparator对象传递给BinaryHeap构造函数。 然后,该Comparator将用于订购。 我们使用模型字段comparator来指示Comparator对象(如果有)。 peek()后置条件中的compareObjects()方法使用客户端选择的任何比较方法。 清单5定义了compareObjects() :

清单5. compareObjects()方法
/*@
   @ public normal_behavior
   @   ensures
   @     (comparator == null) ==>
   @         (\result == ((Comparable) a).compareTo(b)) &&
   @     (comparator != null) ==>
   @         (\result == comparator.compare(a, b));
   @
   @ public pure model int compareObjects(Object a, Object b)
   @ {
   @ if (m_comparator == null)
   @     return ((Comparable) a).compareTo(b);
   @ else
   @     return m_comparator.compare(a, b);
   @ }
   @*/

compareObjects声明中的关键字model表示它是模型方法。 模型方法是只能在规范中使用的JML方法。 它们在Java注释中声明,不能在常规Java实现代码中使用。

如果BinaryHeap的客户端请求使用特定的Comparator ,则m_comparator将引用该Comparator ; 否则,它将为null。 compareObjects()检查的值m_comparator ,并使用适当的比较操作。

模型字段如何获取值

我们在清单4中显示了peek()的后置条件。后置条件验证返回值的优先级大于或等于模型字段elementsInQueue中所有元素的优先级。 这就提出了一个问题:诸如elementsInQueue模型字段如何获取其值? 前提条件,后置条件和不变式必须没有副作用,因此它们不能设置模型字段的值。

JML使用一个represents子句,以具体落实领域联营模式领域。 例如,以下represents子句用于模型字段isMinimumHeap :

//@ private represents isMinimumHeap <- m_isMinHeap;

此子句说,模型字段isMinimumHeap的值等于m_isMinHeap的值。 m_isMinHeap是BinaryHeap类中的私有布尔成员字段。 每当需要isMinimumHeap的值时,JML就会替换m_isMinHeap的值。

represents子句不限于<-右侧的成员字段。 考虑一下elementsInQueue的以下represents子句:

清单6. elementsInQueue的represents子句
/*@ private represents elementsInQueue
   @         <- JMLObjectBag.convertFrom(
   @                   Arrays.asList(m_elements)
   @                     .subList(1, m_size + 1));
   @*/

此子句说,从索引1到m_size包括端点), elementsInQueue等于m_elements[]的值。 m_elements[]是一个私有成员变量BinaryHeap ,其用于存储在优先级队列中的元素。 m_size是m_elements[]中当前使用的元素数。 BinaryHeap不使用m_elements[0] ; 这简化了二进制堆的索引计算。 JMLObjectBag.convertFrom()将List转换为JMLObjectBag ,这是elementsInQueue所需的数据类型。

每当JML运行时断言检查器需要elementsInQueue的值时,它都会在represents子句中查找<-右侧的代码片段。

现在,我们准备讨论副作用和异常行为。

副作用

回顾清单2中pop()的后置条件:

ensures
elementsInQueue.equals(((JMLObjectBag)
             \old(elementsInQueue))
                           .remove(\result)) &&
\result.equals(\old(peek()));

这表示pop()具有从elementsInQueue中删除元素的副作用。 但是,它并没有说没有其他副作用。 例如, pop()的实现可以修改m_isMinHeap以将最小堆更改为最大堆。 只要它还返回了正确的值,这样的修改就不会导致运行时断言失败,但是会削弱我们的整体JML规范。

除了修改elementsInQueue ,我们可以加强后置条件以禁止任何副作用,如清单7所示:

清单7.副作用的后置条件
ensures
elementsInQueue.equals(((JMLObjectBag)
            \old(elementsInQueue))
                           .remove(\result)) &&
\result.equals(\old(peek())) &&
isMinimumHeap == \old(isMinimumHeap) &&
comparator == \old(comparator);

添加形式为x == \old(x)的后置条件将关闭漏洞。 不利的一面是,这种方法最终导致混乱的规范,因为每个方法在其后置条件中必须为未由该方法更改的每个字段都包含子句。 这也使维护变得更加困难,因为如果我们在类中添加了新字段,则必须修改所有方法的后置条件,以使其不能修改新字段。

JML通过assignable子句提供了一种更好的方法来指示方法的副作用。

可分配子句

assignable子句使我们可以编写pop()的规范,如清单8所示:

清单8.方法规范的可分配子句
/*@
   @ public normal_behavior
   @   requires ! isEmpty();
   @   assignable elementsInQueue;
   @   ensures
   @     elementsInQueue.equals(((JMLObjectBag)
   @          \old(elementsInQueue))
   @                        .remove(\result)) &&
   @     \result.equals(\old(peek()));
   @*/
Object pop() throws NoSuchElementException;

仅assignable子句中列出的字段可以通过方法修改(下面讨论了附加条件)。 pop()的assignable子句表示pop()可以修改elementsInQueue但不能修改其他字段,例如isMinimumHeap或comparator 。 如果pop()的实现修改了m_isMinHeap那将是一个错误。 (请注意,当前版本的JML编译器不会检查方法仅修改了它们的assignable子句中的字段。)

修改位置

说一种方法只能修改assignable子句中列出的字段,这有点简化。 如果满足以下任一条件,则实际规则允许方法修改位置( loc ):

  • loc在assignable子句中提到。
  • 可分配子句中提到的位置取决于loc 。 (例如,考虑“ assignable isMinimumHeap; ”的情况。模型字段isMinimumHeap取决于具体字段m_isMinHeap ,因此此assignable子句将允许方法修改m_isMinHeap以及isMinimumHeap 。)
  • 方法开始执行时未分配loc 。
  • loc是方法的局部变量,或者是方法的形式参数。

最后一种情况允许方法修改其自变量,即使自变量未出现在assignable子句中也是如此。 乍一看,这似乎允许方法修改其调用方中的变量,前提是该变量作为参数传递给该方法。 例如,假设我们有一个方法foo(Bar obj) ,它包含语句obj = anotherBar 。 虽然语句改变的价值obj ,它不会影响在调用者的任何变量foo()因为obj中foo()是在调用者的变量不同foo()即使它们指向同一个Bar实例。

如果foo(Bar obj)包含obj.x = 17怎么办? 这将导致调用函数可见的更改。 但是有一个陷阱。 assignable子句的规则允许方法将新值分配给自变量,而不必在assignable子句中提及该变量。 但是它们不允许它为参数的字段(例如obj.x分配新值。 如果foo()需要修改obj.x则它必须具有assignable assignable obj.x;形式的assignable obj.x; assignable子句assignable obj.x; 。

可将两个JML关键字与assignable子句一起使用: \nothing和\everything 。 通过编写assignable \nothing ,可以表明一种方法没有任何副作用。 类似地,我们可以指出,通过编写assignable \everything允许方法修改任何assignable \everything 。 前面在peek()方法的上下文中描述的pure关键字等效于assignable \nothing; 。

异常行为

前面给出的peek()和pop()规范要求在调用每个方法时,队列都不为空。 但是实际上,当队列为空时,可以调用peek()和pop() 。 在这种情况下,任何一个方法都将抛出NoSuchElementException 。 我们需要修改规范以允许这种可能性,为此,我们将使用JML的exceptional_behavior子句。

到目前为止,我们的规范始于public normal_behavior 。 normal_behavior关键字指示这些规范适用于方法不引发任何异常的情况。 可以使用public exceptional_behavior批注来描述引发异常时的行为。 清单9显示了PriorityQueue的peek()规范中的exceptional_behavior :

清单9.exception_behavior批注
/*@
   @ public normal_behavior
   @   requires ! isEmpty();
   @   ensures elementsInQueue.has(\result);
   @ also
   @ public exceptional_behavior
   @   requires isEmpty();
   @   signals (Exception e) e instanceof NoSuchElementException;
   @*/
/*@ pure @*/ Object peek() throws NoSuchElementException;

就像到目前为止我们看到的所有其他示例一样,本规范的第一部分以public normal_behavior开始。 从public exceptional_behavior开始的第二部分是描述异常行为的地方。 就像normal_behavior子句, exceptional_behavior条款有requires的条款。 requires子句指示什么条件必须为真才能引发signals子句中列出的异常。 在上面的示例中,如果isEmpty()为true,则peek()将抛出NoSuchElementException 。

信号条款

signals子句的一般形式是signals(E e) R; 其中E是Exception或从Exception派生的类, R是表达式。 JML对信号子句的解释如下:如果引发类型E的异常,则JML检查R是否为true。 如果是这样,则该方法符合其规范。 如果R为false,则JML抛出未经检查的异常,表明违反了exceptional_behavior规范。

上面的peek()声明中的signals子句要求,如果队列为空,则抛出NoSuchElementException 。 如果peek()抛出任何其他异常(例如, IllegalStateException ,这是未经检查的异常),则JML会将其捕获为错误,因为e instanceof NoSuchElementException将为false。 如果我们希望允许peek()返回NoSuchElementException或未经检查的异常,则可以将signals子句更改为signals (NoSuchElementException e) true; 。 这意味着,如果peek()抛出NoSuchElementException则true必须为true,情况总是如此。 因为我们没有说其他异常,所以peek()可能会抛出其签名所允许的任何异常。 签名指定throws NoSuchElementException ,这意味着可以抛出NoSuchElementException或未经检查的异常。

如果在队列中有内容时调用peek()并抛出NoSuchElementException (或任何其他异常),则JML运行时断言检查器将引发一个未经检查的异常,指示正常行为后置条件失败。

结论

在本文中,我介绍了JML,解释了它对面向对象的分析和设计过程的贡献,并通过示例向您展示了如何在Java程序中使用JML批注。 您可以下载完整的源代码,本文从相关信息 ,在这里你还可以找到参考资料,以帮助您了解更多关于JML。

您可以使用开源JML编译器生成类文件,这些类文件会在运行时自动检查您的JML规范。 如果程序没有按照其JML注释的规定进行操作,则JML将抛出未经检查的异常,表明违反了规范的哪一部分。 这对于捕获错误和帮助使文档(以JML注释的形式)与代码保持同步很有用。

JML运行时断言检查编译器是使用JML的越来越多的工具中的第一个。 其他包括jmldoc和jmlunit 。 jmldoc与javadoc相似,但是它在生成HTML文档中也包含JML规范。 jmlunit生成测试用例类的框架,该类使JML可以方便地与JUnit结合使用。 你会发现这些和在参考其他工具相关主题 。

感谢Gary Leavens和Yoonsik Cheon回答了我有关JML的问题以及对本文草案的有益评论。


翻译自: https://www.ibm.com/developerworks/java/library/j-jml/index.html