2021-07-01


哈工大软件构造阅读心得6-2: 规格说明


本文参考:MIT Reading6哈工大学长汉化

注:这个系列是本人看过阅读资料之后,对看过的内容进行的总结

测试与规格说明

在测试中,我们谈到了黑盒测试意味着仅仅通过规格说明构建测试,而白盒测试是通过代码实现来构建测试。但是要特别注意一点:即使是白盒测试也必须遵循规格说明。 你的实现也许很依赖前置条件的满足,否则方法就会有一个未定义的行为。而你的测试是不能依赖这种未定义的行为的。

例如,假设你正在测试find,它的规格说明如下:

在这里插入图片描述

这个规格说明已经很明显的要求了前置条件——val必须在arr中存在,而且它的后置条件很“弱”——没有规定返回哪一个索引,如果在arr中有多个val的话。甚至如果你的实现就是总是返回最后一个索引,你的测试用例也不能依赖这种行为。

在这里插入图片描述

类似的,即使你实现的find会在找不到val的时候抛出一个异常,你的测试用例也不能依赖这种行为,因为它不能在违背前置条件的情况下调用find()。

那么白盒测试意味着什么呢?如果它不能违背规格说明的话?它意味着你可以通过代码的实现去构建不同的测试用例,以此来测试不同的实现,但是依然要检查这些测试用例符合规格说明。

测试单元

回想在阅读03“测试” 中的web search例子:

在这里插入图片描述

一个好的单元测试应该仅仅关注于一个规格说明。我们的测试不应该依赖于另一个要测试的单元。例如上面例子中,当我们在对extractWords 测试时,就不应该使用getWebPage 的输出作为输入,因为如果getWebPage发生了错误, extractWords 的行为很可能是未定义的。

而对于一个好的综合测试(测试多个模块),它确保的是各个模块之间是兼容的:调用者和被调用者之间的数据输入输出应该是符合要求的。同时综合测试不能取代系统的单元测试,因为各个模块的输出集合很可能在输入空间中没有代表性。 例如我们只通过调用
makeIndex测试extractWords .而extractWords的输出又不能覆盖掉makeIndex的很多输入空间,这样我们以后在别处复用makeIndex的时候,就很可能产生意想不到的错误。

改变对象方法的规格说明

我们在之前的阅读材料中谈到了可改变的对象 vs.不可改变的对象。但是我们对于find的规格说明(后置条件)并没有告诉我们这个副作用——对象的内容被改变了。

以下是一个告诉了这种作用的规格说明,它来自Java中List接口:

static boolean addAll(List<T> list1, List<T> list2)
- requires:
  list1 != list2
- effects:
  modifies list1 by adding the elements of list2 to the end of it, and returns true if list1 changed as a result of call

首先看看后置条件,它给出了两个限制:list1会被更改;返回值是怎么确定的。

再来看看前置条件,我们可以发现,如果我们试着将一个列表加到它本身,其结果是未定义的(即规格说明未指出)。这也很好理解,这样的限制可以使得实现更容易,例如我们可以将第二个列表的元素逐个加入到第一个列表中。如果尝试将两个指向同一个对象的列表相加,就可能发生下图的情况,即将列表2的元素添加到列表1中后同时也改变了列表2,这样方法可能不会终止(或者最终内存不够而抛出异常):
在这里插入图片描述

另外,上文“Null 引用”提到过,这还有一个隐含的前置条件:list1和list2都不是null。

这里有另一个改变对象方法的例子:

在这里插入图片描述

和一个不改变对象方法的例子:

在这里插入图片描述

我们也隐式的规定改变对象(mutation)是不被允许的,除非显式的声明 。例如toLowerCase的规格说明中就没有谈到该方法会不会改变参数对象(不会改变),而sort中就显式的说明了。

练习

What’s in a spec?

下面哪一些选项是属于规格说明的?

[x] 返回类型

[x] 返回值的范围

[x] 参数个数

[x] 参数种类

[x] 对参数的限制

gcd 1

Alice 写了如下代码:

在这里插入图片描述

Bob 写了如下对应测试:

在这里插入图片描述

测试通过了!以下哪些说法是正确的?

Alice 应该在前置条件中加上 a > 0 -> True

Alice 应该在前置条件中加上 b > 0 -> True

Alice 应该在后置条件中加上 gcd(a, b) > 0 -> False

注:后置条件只需要说明gcd计算的逻辑即可,不需要说明结果范围

Alice 应该在后置条件中加上 a and b are integers -> False

注:应该在前置条件里面添加

gcd 2

如果Alice 在前置条件中加上 a > 0 , Bob 应该测试负数 a -> False

如果Alice 没有在前置条件中加上 a > 0 , Bob 应该测试负数 a -> True

异常

注:这里的异常不是计算机系统里面的异常,指的是代码意想不到的行为或者错误。

一个方法的标识(signature)包含它的名字、参数类型、返回类型,同时也包含该方法能触发的异常。

报告bug的异常

你可能已经在Java编程中遇到了一些异常,例如ArrayIndexOutOfBoundsException(数组访问越界)或者NullPointerException访问一个null引用的对象)。这些异常通常都是用来报告你代码里的bug,同时它们报告的信息也能帮助你修复bug。

ArrayIndexOutOfBounds- 和 NullPointerException大概是最常见的异常了,其他的例子有:

  1. ArithmeticException,当发生计算错误时抛出,例如除0。

  2. NumberFormatException,数字的类型不匹配的时候抛出,例如你向Integer.parseInt
    传入一个字符长而不是一个整数。

报告特殊结果的异常

异常不仅被用来报告bug,它们也被用来提升那些包含特殊结果的代码的结构。

不幸的是,一个常见的处理特殊结果的方法就是返回一个特殊的值。你在Java库中常常能发现这样的设计:当你期望一个正整数的时候,特殊结果会返回一个-1;当你期望一个对象的时候,特殊结果会返回一个null。这样的方法如果谨慎使用也还OK,但是它有两个问题。首先,它加重的检查返回值的负担。其次,程序员很可能会忘记检查返回值(我们待会会看到通过使用异常,编译器会帮助你处理这些问题)。

同时,找到一个“特殊值”返回并不是一件容易的事。现在假设我们有一个 BirthdayBook
类,其中有一个lookup方法:

在这里插入图片描述

(LocalDate是Java API的一个类.)

如果name在这个BirthdayBook中没有入口,这个方法该如何返回呢?或许我们可以找一个永远不会被人用到的日期。糟糕的程序员或许会选择一个9/9/99,毕竟他们觉得没有人会在这个世纪结束的时候使用这个程序。(事实上,它们错了

这里有一个更好的办法,就是抛出一个异常:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uichinIS-1625116079855)(media/e19054b5e4cd180808196dbbbeec412a.png)]

调用者使用catch捕获这个异常:

在这里插入图片描述

练习

1st birthday

假设我们在使用 BirthdayBook 中的 lookup 方法,它可能会抛出 NotFoundException.

如果“Elliot”不在birthdays里面(birthdays已经初始化了,并指向了一个对象),下面这些代码会发生什么?

在这里插入图片描述

运行时报错: NotFoundException

2nd birthday
在这里插入图片描述
静态错误: undeclared variable

3rd birthday在这里插入图片描述
运行时报错: DateTimeException

已检查异常和未检查异常

我们已经看到了两种不同目的的异常:报告特殊的结果或者报告bug。一个通用的规则是,我们用已检查的异常来报告特殊结果,用未检查的异常来报告bug。

“ 已检查 异常”这个名字是因为编译器会检查这种异常是否被正确处理:

  1. 如果一个方法抛出一个已检查异常,这种可能性必须添加到它的标识中。例如
    NotFoundException就是一个已检查异常,这也是为什么它的生命的结尾有一个 throws NotFoundException.

  2. 如果一个方法调用一个可能抛出已检查异常的方法,该方法要么处理它,要么在它的标识中说明该异常(交给它的调用者处理)。

所以如果你调用了 BirthdayBook中的 lookup 并忘记处理 NotFoundException,编译器就会拒绝你的代码。这非常有用,因为它确保了那些可能产生的特殊情况(异常)被处理。

相应的,未检查异常用来报告bug。 这些异常并不指望被代码处理(除了一些顶层的代码),同时这样的异常也不应该被显式抛出,例如边界溢出、null值、非法参数、断言失败等等。同样,编译器不会检查这些异常是否被try-catch 处理或者用 throws抛给上一层调用者。(Java允许你将未检查的异常作为方法的标识,不过这没有什么意义,我们也不建议这么做)

Throwable类异常

为了理解Java是如何定义一个异常是已检查还是未检查的,让我们看一看Java异常类的层次图:

在这里插入图片描述

Throwable是一个能够被抛出和捕获的对象对应的类。Throwable的实现记录了栈的结构(异常被抛出的时候),同时还有一个描述该异常的消息(可选)。任何被抛出或者捕获的异常对象都应该是Throwable的子类。

Error 是Throwable 的一个子类,它被保留用于Java运行系统的异常,例如StackOverflowErrorOutOfMemoryError.Errors应该被认为是不可恢复的,并且一般不会去捕获它。 这里有一个特例,AssertionError也是属于Error的,即使它反映的是用户代码错误)

下面描述了在Java中如何区别已检查异常和未检查异常:

  1. RuntimeException, Error,以及它们的子类都是未检查异常。编译器不会要求它们被throws修饰,也不会要求它们被捕获。

  2. 所有其他的throwables—— Throwable,Exception和其他子类都是已检查异常。编译器会要求它们被捕获或者用throws传给调用者处理。

当你定义你自己的异常时,你应该使它要么是 RuntimeException的子类(未检查异常,报告bug),要么是 Exception的子类(已检查异常,报告特殊结果)。程序员通常不会生成 Error 或者Throwable的子类,因为它们通常被Java保留使用。

练习

Get to the point

假设我们写了一个寻找两点之间路径的方法:

public static List<Point> findPath(Point initial, Point goal)

在前置条件中,我们要求findPath搜索的范围是有限的(有边界)。如果该方法没有找到一个路径,它就会抛出一个异常。

在设计方法时,以下哪一个异常是合理的?

[ ] 已检查异常 NoPathException

[ ] 未检查异常 NoPathException

[x] 已检查异常 PathNotFoundException

[ ] 未检查异常 PathNotFoundException

Don’t point that thing at me

当我们定义该异常时,应该使它是哪一个类的子类?

[ ] Throwable

[x] Exception

[ ] Error

[ ] RuntimeException

设计异常需要考虑的事

我们之前给了一个通用规则——对于特殊的结果(预测到的)使用已检查异常,对于bug使用未检查异常(意料之外)。

除了对性能有影响,Java中的异常会带来使用上的开销:如果你要设计一个异常,你必须创建一个新的类。如果你调用一个可能抛出已检查异常的方法,你必须使用try-catch处理它(即使你知道这个异常一定不会发生)。后一种情况导致了一个进退两难的局面。例如,你设计了一个抽象队列,你是应该期望使用者在循环pop的时候检查队列是否为空(作为前置条件),还是让使用者自由的pop,最后抛出一个异常呢?如果你选择抛出异常,那么即使使用者每次都检查队列不为空才pop,他还是要对这个异常进行处理。

所以我们提炼出另一个明确的规则:

  1. 对于意料之外的bug使用未检查的异常,或者对于使用者来说避免异常产生的情况非常容易(例如检查一个队列是否为空)。

  2. 其他的情况我们使用已检查异常。

这里举出一些例子:

  1. 当队列是空时,Queue.pop()会抛出一个未检查异常。因为检查队列是否为空对于用户来说是容易的。(例如Queue.size() or Queue.isEmpty().)

  2. 当无法连接互联网时,Url.getWebPage() 抛出一个已检查异常 IOException,因为客户可能无法确定调用的时候网络是否好使。

  3. 当x没有整数开方时,int integerSquareRoot(int x) 抛出一个已检查异常NotPerfectSquareException,因为对于调用者来说,判断一个整数是否为平方是困难的。

这些使用异常的“痛楚”也是很多Java API使用null引用或特殊值作为返回值的原因。

在规格说明当中如何声明异常

因为异常也可以归为方法的输出,所以我们应该在规格说明的后置条件中描述它。

Java中是以 @throws作为Javadoc中异常注释的。Java也可能要求函数声明时用throws标出可能抛出的异常 。

对于非检查的异常,由于它们描述的是意料之外的bug或者失败,不属于后置条件,所以不应该用@throws 或 throws修饰它们。例如,NullPointerException就不应该在规格说明中列出——我们的前置条件已经隐式(显式)的禁止了null值,这意味着如果使用者传入一个null,我们可以没有任何警告的扔出一个异常。例如下面这个规格说明,就没有提到
NullPointerException :
在这里插入图片描述
而对于报告特殊结果的异常,我们应该在Javadoc中用 @throws表示出来,并明确什么情况下会导致这个异常的抛出。另外,如果是一个已检查异常,Java会要求在函数声明的时候用throws 标识出来。例如,假设 NotPerfectSquareException 是一个已检查声明:
在这里插入图片描述
对于报告特殊结果的未检查异常,Java允许但是不要求使用 throws在声明中标识出。但是这种情况下通常不要使用 throws因为这会使得阅读者困惑(以为它是一个已检查异常)。例如,假设你将EmptyQueueException定义为未检查异常。那么你应该在Javadoc中使用@throws对其进行说明,但是不要在函数声明中将其标识出:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HD0ezno3-1625116079860)(media/6c27160c7b178b586f64d72ecff2936e.png)]

练习

Throw all the things!

阅读以下代码并分析 Thing 对象:

在这里插入图片描述

AnalysisException 是一个 已检查 异常.

analyzeEverything可能会抛出哪一些异常?

[x] ArrayIndexOutOfBoundsException

[ ] IOException

[x] NullPointerException

[ ] AnalysisException

[x] OutOfMemoryError

A terrible thing

如果 analyzeOneThing 自己会抛出一个 AnalysisException 异常,会发生什么?

[ ] 程序可能会崩溃

[x] 我们可能不能调用任何 analyzeOneThing

[ ] 我们可能会调用几次 analyzeOneThing

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值