软件构造课程MIT课程学习并文档翻译理解-------序号6(已对应MIT第6节)

西安工业大学计算机学院18060721班软件构造课程MIT课程学习并文档翻译理解-------序号6(已对应MIT第6节)

翻译内容

软件构造的规格

1.注释与断言   2.异常分析

Java辅导练习---通过完成Java导师的这些任务,继续在Java上取得进步,软件为6.031,

软件构造过程中保持规范化的意义:

1.提防安全漏洞:纠正当下在工程设计过程中犯过的每一个错误,要时刻保持着对未来系统中暴漏出安全隐患风险的考虑和前瞻性解决。

2.是构造过程容易理解:在设计过程中要清晰地考虑每一个细节问题,要为今后系统的正常运转和进一步发展做好充足的准备。

3.准备好去作出改变:设计用于适应变化而无需重写。

规范化设计目标:

1.要充分地理解方法规范中的前置条件和后前置条件,从而保证能够编写正确的规范。

2.能够根据规范编写测试来进一步印证系统。

3.了解Java中检查异常和未检查异常之间的区别。

4.了解如何对特殊结果使用异常。

介绍:

       规格是团队合作的关键。如果没有规范,就不可能委派实现方法的责任。规范充当契约:实现者负责满足契约,而使用该方法的客户端可以依赖契约。事实上,我们将看到,像真正的合法合同一样,规范对双方都有要求:当规范有先决条件时,客户也有责任。

       在本文中,我们将研究方法规范所扮演的角色。我们将讨论什么是前置条件和后置条件,以及它们对方法的实现者和客户机意味着什么。我们还将讨论如何使用异常,这是Java、Python和许多其他现代语言中的一个重要语言特性,它允许我们使方法的接口更安全,避免出现错误,更容易理解。

第一部分:规格化

在我们深入讨论规范的结构和意义前先来聊聊“什么是规格化?”   

        程序中许多最糟糕的错误是由于对两段代码之间接口的行为的误解而产生的。虽然每个程序员心里都有规范,但并不是所有的程序员都把它们写下来。因此,团队中不同的程序员有不同的规范。当程序失败时,很难确定错误在哪里。代码中的精确规范可以让您推卸责任(归咎于代码片段,而不是人),并且可以让您免去苦苦思索应该如何修复的麻烦。规范对于方法的客户端是有益的,因为它们省去了阅读代码的任务。如果您不相信阅读规范比阅读代码更容易,请查看一些标准Java规范,并将它们与实现它们的源代码进行比较。

API文档中的规范:----这里放值了java的API文档供跳转学习

public BigInteger add(BigInteger val)

这里的返回值类型为BigInteger,返回值为 (this + val),这里的参数:val代表将要添加到BigInteger的值。

方法体来自Java 8源代码

这里的BigInteger.add的规范对于客户端来说很容易理解,如果我们对一些特殊情况有疑问,BigInteger类提供了额外的可读文档,如果我们拥有哦的只是代码部分,那要理解这些内容就必须阅读这个构造函数,对输入的这种限制可能允许实现者跳过不再需要的昂贵检查,并使用更有效的实现。契约充当客户机和实现者之间的防火墙。它保护了客户端不受单元工作细节的影响——如果你有它的规范,你不需要阅读程序的源代码。而且它让实施者不知道设备使用的细节;他不需要询问每一个客户她打算如何使用这个单元。该防火墙导致解耦,允许单元代码和客户端代码独立更改,只要更改遵守规范——每个更改都遵守其义务。

行为代价:

考虑这两种方法它们是相同还是不同的?

static int findFirst(int[] arr, int val) {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == val) return i;
    }
    return arr.length;
}

static int findLast(int[] arr, int val) {
    for (int i = arr.length -1 ; i >= 0; i--) {
        if (arr[i] == val) return i;
    }
    return -1;
}

当然,代码是不同的,所以从这个意义上说,它们是不同的;我们给它们取了不同的名字,只是为了方便讨论。为了确定行为等价性,我们的问题是是否可以用一种实现替代另一种实现。

当val缺失时,findFirst返回arr的长度,findLast返回-1,当val出现两次时,findFirst返回较低的索引值,findLast返回较高的索引值。但是当val恰好出现在数组的一个下标处时,这两个方法的行为是相同的:它们都返回那个下标。可能是客户在其他情况下从不依赖行为。当它们调用该方法时,它们将传入一个包含一个元素val的arr。对于这样的客户端,这两个方法是相同的,我们可以毫无问题地从一个实现切换到另一个实现。

对等的概念是在旁观者的眼中——也就是客户的眼中。为了使用一种实现替代另一种实现成为可能,并知道这在什么时候是可以接受的,我们需要一个明确说明客户端依赖的规范。

在这种情况下,我们的规范可能是:

static int find(int[] arr, int val)
  requires: val occurs exactly once in arr
  effects:  returns index i such that arr[i] = val

规范的结构:

方法的说明包含以下几个子句:由关键字表示的前提条件需要由关键字效果表示的后置条件前提条件是客户端(即方法的调用者)的义务。它是调用方法的状态之上的一个条件后置条件是方法实现者的义务。如果为调用状态保留了前置条件,则该方法必须遵守后置条件,返回适当的值、抛出指定的异常、修改或不修改对象等等。

总体结构是一个逻辑含义:如果前置条件在方法被调用时保持不变,那么后置条件在方法完成时必须保持不变。

如果在调用方法时,前提条件不存在,则实现不受后置条件的约束。它可以自由地做任何事情,包括不终止、抛出异常、返回任意结果、进行任意修改等。

Java中的规范:一些语言(特别是Eiffel)将前置条件和后置条件作为语言的基本部分,作为运行时系统(甚至编译器)可以自动检查的表达式,以强制客户端和实现者之间的契约。Java还没有走到这一步,但是它的静态类型声明实际上是方法的前置条件和后置条件的一部分,这一部分由编译器自动检查和执行。契约的其余部分——我们不能写成类型的部分——必须在方法之前的注释中进行描述,通常需要人来检查和保证。Java有一个文档注释的约定,其中参数用@param子句描述,结果用@return和@throws子句描述。您应该尽可能将前置条件放入@param中,将后置条件放入@return和@throws中。

例如这样一个规范:

static int find(int[] arr, int val)
  requires: val occurs exactly once in arr
  effects:  returns index i such that arr[i] = val

可能在Java中呈现如下效果:

/**
 * Find a value in an array.
 * @param arr array to search, requires that val occurs exactly once
 *            in arr
 * @param val value to search for
 * @return index i such that arr[i] = val
 */
static int find(int[] arr, int val)

Java API文档是由Java标准库源代码中的Javadoc注释生成的。用Javadoc记录规范允许Eclipse向您(以及代码的客户端)显示有用的信息,并允许您以与Java API文档相同的格式生成HTML文档。

阅读:介绍,Java中的注释,Java的doc注释。

在编写您的规范时,您也可以参考Oracle的“如何编写Doc注释”。

空引用

在Java中,对对象和数组的引用也可以使用特殊值null,这意味着引用不指向对象。空值是Java类型系统中一个不幸的漏洞。

原语不能为空:

int size = null;     // illegal
double depth = null; // illegal

编译器会用静态错误拒绝这种尝试。另一方面,我们可以将null赋值给任何非基元变量:String name = null;    int[] points = null;

编译器在编译时愉快地接受此代码。但你会在运行时得到错误,因为你不能调用任何方法或使用这些引用中的任何字段:name.length()和points.length

特别要注意,null不同于空字符串“”或空数组。在空字符串或空数组上,可以调用方法并访问字段。空数组或空字符串的长度为0。指向null的字符串变量的长度不是任何东西:调用length()会抛出NullPointer-Exception。还要注意的是,非基元数组和集合(如List)可能是非null,但包含null值:

String[] names = new String[] { null };
List<Double> sizes = new ArrayList<>();
sizes.add(null);

一旦有人试图使用集合的内容,这些空值就可能导致错误。空值很麻烦而且不安全,因此建议您将其从设计词汇表中删除。在6.031中——实际上在大多数优秀的Java编程中——参数和返回值中隐式地不允许使用空值。所以每个方法在其对象和数组参数上都隐式地有一个前提条件,即它们是非空的。每个隐式返回对象或数组的方法都有一个后置条件,即其返回值是非空的。如果一个方法允许一个参数为空值,它应该显式地声明它,或者如果它可能返回一个空值作为结果,它应该显式地声明它。但这些都不是什么好主意。避免空。

Java有一些扩展,允许你在类型声明中直接禁止null,例如:

static boolean addAll(@NonNull List<T> list1, @NonNull List<T> list2)

可以在编译时或运行时自动检查。谷歌在公司的核心Java库中有关于null的讨论。项目说明:粗心地使用null会导致各种各样的错误。研究谷歌代码库,我们发现95%的集合中不应该有任何空值,让这些集合快速失败,而不是默默地接受空值,对开发人员是有帮助的此外,null具有令人不快的二义性。null返回值的含义并不明显——例如,map. get(key)可以返回null,因为映射中的值是空的,或者该值不在映射中。Null可以代表失败,可以代表成功,几乎可以代表任何事情。使用空值以外的东西会让你的意思更清楚。

一个规范可能谈论什么?

方法的说明可以讨论方法的参数和返回值,但绝不应该讨论方法的局部变量或方法类的私有字段。您应该考虑对规范的读者不可见的实现。在Java中,规范的读者通常无法获得该方法的源代码,因为Javadoc工具从代码中提取规范注释并将它们呈现为HTML。

测试和规范

在测试中,我们讨论的是只考虑规范而选择的黑盒测试,以及了解实际实现(测试)而选择的玻璃盒测试。但重要的是要注意,即使是玻璃盒测试也必须遵循规范。您的实现可能提供比规范要求的更强的保证,或者它可能在规范未定义的地方具有特定的行为。但是您的测试用例不应该指望这种行为。测试用例必须遵守契约,就像其他客户端一样。

例如,假设你正在测试这个find规范,与我们目前使用的略有不同:

static int find(int[] arr, int val)
  requires: val occurs in arr
  effects:  returns index i such that arr[i] = val

该规范有一个很强的前提条件,即val必须被发现;而且它有一个相当弱的后置条件,即如果val在数组中出现不止一次,该规范没有说明val返回的具体索引。即使你实现了find,它总是返回最低的索引,你的测试用例不能假设特定的行为:

int[] array = new int[] { 7, 7, 7 };
assertEquals(0, find(array, 7));  // bad test case: violates the spec
assertEquals(7, array[find(array, 7)]);  // correct

同样地,即使你实现了find,当val没有被找到时,它(明智地)抛出一个异常,而不是返回一些任意的误导性索引,你的测试用例不能假设这种行为,因为它不能以违反前提条件的方式调用find()。那么,如果黑盒测试不能超出规格,那它意味着什么?这意味着您正在尝试寻找执行实现的不同部分的新测试用例,但仍然以与实现无关的方式检查这些测试用例。

测试单元

回想一下使用以下方法测试中的web搜索示例:

/** @return the contents of the web page downloaded from url */
public static String getWebPage(URL url) { ... }

/** @return the words in string s, in the order they appear,
 *          where a word is a contiguous sequence of
 *          non-whitespace and non-punctuation characters */
public static List<String> extractWords(String s) { ... }

/** @return an index mapping a word to the set of URLs
 *          containing that word, for all webpages in the input set */
public static Map<String, Set<URL>> makeIndex(Set<URL> urls) { 
    ...
    calls getWebPage and extractWords
    ...
} 

        随后我们讨论了单元测试,即我们应该为程序的每个模块单独编写测试。一个好的单元测试只关注一个规范。我们的测试将几乎总是依赖于Java库方法的规范,但编写单元测试在一个方法我们不应该失败如果不同的方法不能满足其规范。在这个例子中,我们看到一个测试extractWords不该失败如果getWebPage不满足它的后置条件。

       良好的集成测试(使用模块组合的测试)将确保我们的不同方法具有兼容的规范:不同方法的调用者和实现者将按照另一个方法的期望传递和返回值。集成测试不能取代系统设计的单元测试。从这个例子来看,如果我们只通过调用makeIndex来测试extractWords,那么我们只会在它输入空间的一小部分上测试它:可能是getWebPage输出的输入。在这样做的时候,我们已经为bug留下了一个隐藏的地方,当我们在程序的其他地方使用extractWords用于不同的目的时,或者当getWebPage开始返回以新格式编写的网页时,就可以跳出来等等。

突变方法的规范

我们前面讨论了可变对象和不可变对象,但是我们的find规范没有给我们机会说明如何描述后置条件中的副作用——对可变数据的更改。下面是一个描述改变对象的方法的规范:

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

我们从Java List接口中获得了这个稍微简化的功能。首先,看看后置条件。它给出了两个约束条件:第一个告诉我们如何修改list1,第二个告诉我们如何确定返回值。第二,看看前提条件。它告诉我们,如果您试图将列表的元素添加到方法本身,该方法的行为是未定义的。您可以很容易地想象为什么该方法的实现者希望强加这种约束:它不太可能排除任何有用的方法应用程序,而且它使方法更容易实现。该规范允许一个简单的实现,即从list2获取一个元素并将其添加到list1,然后继续查看list2的下一个元素,直到最后。右边的快照图序列说明了这种行为。因此,如果list1和list2是同一个列表,这个简单的算法将不会终止——或者实际上,当列表对象增长到占用所有可用内存时,它将抛出内存错误。结果(无限循环或崩溃)都是规范所允许的,因为它有先决条件。

还请记住,我们隐式的前提条件是,list1和list2必须是有效对象,而不是null。我们通常会省略这一点,因为它实际上总是需要对象引用。下面是另一个突变方法的例子:

static void sort(List<String> lst)
  requires: nothing
  effects:  puts lst in sorted order, i.e. lst[i] <= lst[j]
              for all 0 <= i < j < lst.size()

下面是一个不改变其参数的方法的例子:

static List<String> toLowerCase(List<String> lst)
  requires: nothing
  effects:  returns a new list t where t[i] = lst[i].toLowerCase()

正如我们已经说过,除非另有说明,否则null是隐式禁止的,我们也将使用除非另有说明,否则不允许变异的约定。小写规范可以显式地声明“lst未修改”,但在没有描述突变的后置条件的情况下,我们不要求对输入进行突变。

第二部分:异常

        现在,我们正在编写规范并考虑客户端将如何使用我们的方法,让我们讨论如何以一种不存在bug且易于理解的方式处理异常情况。方法的签名——它的名称、参数类型、返回类型——是其规范的核心部分,而且签名还可能包括该方法可能触发的异常。

信号错误的异常:

        你可能已经看过一些例外在Java编程中,到目前为止,如ArrayIndex-OutOfBounds-Exception(索引数组时抛出foo[我]数组foo)的有效范围以外或空指针异常(试图调用一个方法时抛出一个null对象引用)。这些异常通常表明代码中的错误,Java在抛出异常时显示的信息可以帮助您找到和修复错误。

ArrayIndexOutOfBounds和NullPointerException是这类异常中最常见的。

特殊结果的异常情况

        异常不只是用于发送错误信号。它们可用于改进包含具有特殊结果的过程的代码结构。不幸的是,处理特殊结果的一种常见方法是返回特殊值。Java库中的查找操作通常是这样设计的:当期望得到一个正整数时,得到一个索引-1;当期望得到一个对象时,得到一个空引用。如果谨慎使用,这种方法是可以的,但是它有两个问题。首先,检查返回值非常繁琐。第二,很容易忘记去做。(在本文中,我们将看到通过使用异常,您可以从编译器获得帮助。)此外,找到一个“特殊值”并不总是容易的。假设我们有一个带有查找方法的BirthdayBook类。下面是一个可能的方法标签:

class BirthdayBook {
    LocalDate lookup(String name) { ... }
}

(LocalDate是Java API的一部分。)

        如果生日簿没有给定姓名的人的条目,该方法应该做什么?我们可以返回一些特殊的日期它不会被用作真正的日期。糟糕的程序员已经这样做了几十年了;例如,他们会返回9/9/99,因为很明显,1960年编写的程序到本世纪末不会仍然运行。(顺便说一句,他们错了,这里有一个更好的方法。该方法抛出异常:

LocalDate lookup(String name) throws NotFoundException {
    ...
    if ( ...not found... )
        throw new NotFoundException();
    ...

调用者用catch子句处理异常。例如:

BirthdayBook birthdays = ...
try {
    LocalDate birthdate = birthdays.lookup("Alyssa");
    // we know Alyssa's birthday
} catch (NotFoundException nfe) {
    // her birthday was not in the birthday book
}

这种情况下就不需要任何特殊值和与之相关联的检查。

检查和未检查的异常

我们已经看到了异常的两个不同用途:特殊结果和bug检测。一般情况下,您会希望使用受控异常来表示特殊结果,使用受控异常来表示bug。在后面的部分中,我们将对此进行一些改进。

以下是一些术语:因为它们被编译器检查:,所以被检查的异常被称为

1.如果方法可能抛出一个受控异常,则必须在其签名中声明这种可能性。NotFoundException将是一个被检查的异常,这就是签名end抛出NotFoundException的原因。

2.如果一个方法调用另一个可能抛出检查异常的方法,它必须要么处理该异常,要么本身声明该异常,因为如果它没有在本地被捕获,它将被传播到调用方。

因此,如果您调用BirthdayBook的查找方法而忘记处理NotFoundException,编译器将拒绝您的代码。这非常有用,因为它确保了预期发生的异常将得到处理相反,未经检查的异常则用于提示bug。这些异常不希望由代码处理,除非可能在顶层。我们不希望调用链上的每个方法都必须声明它(可能)抛出可能发生在较低调用级别的所有类型的错误异常:索引越界、空指针、非法参数、断言失败等。因此,对于未检查的异常,编译器不会检查try- catch或throws声明。Java仍然允许您为未检查异常编写抛出子句作为方法签名的一部分,但这没有任何效果,因此有点有趣,我们不建议这样做。所有异常都可能有与之相关的消息。如果在构造函数中没有提供,则对消息字符串的引用为空。

Throwable层次结构

 

要理解Java是如何决定是否检查异常的,让我们看看Java异常的类层次结构。Throwable是可以被抛出或捕获的对象类。Throwable的实现在抛出异常的地方记录堆栈跟踪,以及描述异常的可选字符串。在throw或catch语句中使用的任何对象,或在方法的throws子句中声明的任何对象,都必须是Throwable的子类。

Error是Throwable的一个子类,它为Java运行时系统产生的错误保留,比如StackOverflow-Error和OutOfMemory-Error。出于某种原因,断言错误还扩展了错误,尽管它表明是用户代码中的错误,而不是运行时中的错误。错误应该被认为是不可恢复的,并且通常不会被捕获。

以下是Java如何区分检查异常和未检查异常:

RuntimeException、Error及其子类是未检查的异常。编译器不要求在抛出它们的方法的throws子句中声明它们,也不要求此类方法的调用者捕获或声明它们所有其他的可抛出对象——Throwable、Exception以及它们的所有子类(除RuntimeException和Error类)——都是经过检查的异常。当可能抛出这些异常时,编译器要求捕获或声明这些异常。

当开发者定义自己的异常时,应该继承RuntimeException(使其成为未检查异常)或exception(使其成为检查异常)。程序员通常不会继承Error或Throwable的子类,因为它们是由Java本身保留的。

例外设计注意事项

我们给出的规则是对特殊结果(即预期的情况)使用受控异常,对bug(意外失败)使用受控异常,这些是有意义的,但这并不是故事的结束。问题是Java中的异常并不像它们可能的那样轻量级。

除了性能损失之外,Java中的异常还会带来另一个(更严重的)代价,在方法设计和方法使用中,使用异常很麻烦。如果你想让一个方法有它自己的(new)异常,你必须为异常创建一个新类。如果调用一个可以抛出检查异常的方法,则必须将其包装在try-catch语句中(即使您知道该异常永远不会被抛出)。后一种规定造成了一个困境。例如,假设您正在设计一个队列抽象。弹出队列是否应该在队列为空时抛出一个检查异常?假设您希望在客户端中支持一种编程风格,在这种风格中队列会弹出(在循环中),直到抛出异常。所以您选择了一个受控异常。现在,一些客户端想要在一个上下文中使用该方法,在弹出之前,客户端会测试队列是否为空,只有当队列不是空时才弹出。令人恼火的是,该客户机仍然需要将调用封装在try-catch语句中这就提出了一个更精炼的规则:你应该只使用未检查异常来表示一个意外的失败(即bug),或者如果你期望客户端通常会编写代码来确保异常不会发生,因为有一种方便和廉价的方法来避免异常;。否则,您应该使用受控异常。

以下是将此规则应用于假设方法的一些例子:

1.当队列为空时,queue .pop()抛出一个未检查的EmptyQueueException,因为调用者可以合理地通过调用queue .size()或queue . isempty()来避免这种情况。Url.getWebPage()在无法检索网页时抛出一个checked IOException,因为调用者很难避免这种情况。

2.当x没有整数的平方根时,int integersquareeroot (int x)抛出一个检查过的not - perfect - square - exception,因为测试x是否是一个完全的平方根与找到实际的平方根一样困难,所以期望调用者阻止它,这是不合理的。

异常的滥用

以下是Joshua Bloch的《Effective Java》(第二版第57项)中的一个例子。

try {
    int i = 0;
    while (true)
        a[i++].f();
} catch (ArrayIndexOutOfBoundsException e) { }

这段代码是做什么的?从检查来看,这一点都不明显,这就是不使用它的充分理由。当无限循环试图访问数组边界之外的第一个数组元素时,会抛出、捕获并忽略ArrayIndex-OutOfBounds-Exception,从而终止无限循环。

它应该等于:

for (int i = 0; i < a.length; i++) {
    a[i].f();
}

或者(用合适的T字段):

for (T x : a) {
    x.f();
}

基于例外的习语,Bloch写道:它是一个错误的尝试,以提高性能基于错误的推理,因为VM检查数组访问的边界,正常的循环终止测试(i &lt;长度)是多余的,应该避免。然而,由于Java中的异常只在异常情况下使用,所以很少(如果有的话)JVM实现尝试优化它们的性能。在典型的机器上,当循环从0到99时,基于异常的习惯用法的运行速度比标准习惯用法慢70倍。更糟糕的是,基于异常的习惯用法甚至不能保证工作!假设在循环体中对f()的计算包含一个bug,导致对一些不相关的数组的越界访问。会发生什么呢?

如果使用了合理的循环习惯用法,该bug将生成未捕获的异常,导致线程立即终止,并执行完整的堆栈跟踪。如果使用错误的基于异常的循环,则会捕获与bug相关的异常,并被错误地解释为正常的循环终止。

总结

规范充当过程的实现者和其客户机之间的关键防火墙。它使独立的开发成为可能:客户端可以自由地编写使用过程的代码,而不必看到它的源代码,实现者可以自由地编写实现过程的代码,而不必知道它将如何使用。

让我们回顾一下规范是如何帮助实现本课程的主要目标:

1:利用规范来要解决好bug,:一个好的规范清楚地记录了客户端和实现者所依赖的相互假设。错误通常来自于接口上的分歧,而规范的存在减少了这一点。在规范中使用机器检查的语言特性,比如静态类型和异常,而不仅仅是人们可读的注释,可以减少更多的bug。

2:使构造过程变得容易理解:一个简短、简单的规范比实现本身更容易理解,并使其他人不必阅读代码。

3:要准备好去改变:规范在代码的不同部分之间建立契约,允许这些部分在继续满足契约要求的情况下独立更改。

 

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值