麻省理工18年春軟件構造課程閱讀06「規格說明」

<font size="3">html

本文內容來自MIT_6.031_sp18: Software Construction課程的Readings部分,採用CC BY-SA 4.0協議。java

因爲咱們學校(哈工大)大二軟件構造課程的大部分素材取自此,也是推薦的閱讀材料之一,因而打算作一些翻譯工做,本身學習的同時也能幫到一些懶得看英文的朋友。另外,該課程的閱讀資料中有許多練習題,可是沒有標準答案,所給出的答案均爲譯者所寫,有錯誤的地方還請指出。git

<br />程序員


<br />github

譯者:李秋豪web

審校:編程

V1.0 Tue Mar 13 22:17:35 CST 2018api

<br />數組

本次課程的目標

  • 理解方法規格說明中的前置條件和後置條件,並可以寫出正確的規格說明
  • B可以針對規格說明寫出測試
  • 理解Java中的檢查異常和非檢查異常(checked and unchecked exceptions)
  • 理解如何用異常處理特殊的結果

概要

規格說明是團隊合做中的關鍵點。若是沒有規格說明,就沒有辦法分工實現各類方法。規格說明就像一份合同:實現者的義務在於知足合同的要求,客戶能夠依賴這些要求工做。事實上,咱們會發現就像真的合同同樣,規格說明對雙方都有制約:當合同上有前置條件時,客戶有責任知足這些條件。安全

在這篇閱讀材料中咱們會研究方法中的規格說明,討論前置條件和後置條件分別是什麼,它們對方法的實現者和使用者來講意味着什麼。咱們也會討論如何使用異常——Java、Python、以及不少現代語言中的一個重要特性,它使得方法的接口更加安全也更加易懂。

<br />

爲何要使用規格說明

在編程中,不少讓人抓狂的bug是因爲兩個地方的代碼對於接口行爲的理解不同。雖然每個程序員在內心都有一份「規格說明」,可是不是全部程序員都會把他們寫下來。最終,一個團隊中的不一樣程序員對於同一個接口就有不一樣的「規格說明」了。當程序崩潰的時候,就很難發現問題在哪裏。簡潔準確的的規格說明使得咱們遠離bug,更能夠快速發現問題所在。

規格說明對使用者(客戶)來講也是頗有用的,它們使得使用者沒必要去閱讀源碼。若是你還不相信閱讀規格說明比閱讀源碼更簡單易懂的話,看看下面這個標準的Java規格說明和它對應的源碼,它是 BigInteger 中的一個方法:

API 文檔中的規格說明:

public BigInteger add(BigInteger val)

Returns a BigInteger whose value is (this + val).

Parameters: 
val - value to be added to this BigInteger.

Returns: 
this + val

Java 8 中對應的源碼:

if (val.signum == 0)
    return this;
if (signum == 0)
    return val;
if (val.signum == signum)
    return new BigInteger(add(mag, val.mag), signum);

int cmp = compareMagnitude(val);
if (cmp == 0)
    return ZERO;
int[] resultMag = (cmp > 0 ? subtract(mag, val.mag)
                   : subtract(val.mag, mag));
resultMag = trustedStripLeadingZeroInts(resultMag);

return new BigInteger(resultMag, cmp == signum ? 1 : -1);

能夠看到,經過閱讀 BigInteger.add 的規格說明,客戶能夠直接瞭解如何使用 BigInteger.add ,以及它的行爲屬性。若是咱們去閱讀源碼,咱們就不得不看 BigInteger 的構造體, compare­Magnitude, subtract以及trusted­StripLeadingZero­Ints 的實現——而這還僅僅只是開始。

另外,規格說明對於實現者也是頗有好處的,由於它們給了實現者更改實現策略而不告訴使用者的自由。同時,規格說明能夠限定一些特殊的輸入,這樣實現者就能夠省略一些麻煩的檢查和處理,代碼也能夠運行的更快。

如上圖所示,規格說明就好像一道防火牆同樣將客戶和實現者隔離開。它使得客戶沒必要知道這個單元是如何運行的(沒必要閱讀源碼),也使得實現者沒必要管這個單元會被怎麼使用(由於客戶要遵照前置條件)。這種隔離形成了「解耦」(decoupling),客戶本身的代碼和實現者的代碼能夠獨立發生改動,只要雙方都遵循規格說明對應的制約。

<br />

行爲等價

思考下面兩個方法的異同:

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找不到時,fingFirst返回arr的長度而findLast返回-1;
  • 當數組中有兩個val的時候,findFirst返回較小的那個索引,而findLast返回較大的那個。

可是當val在數組中僅有一個的時候,這兩個方法的行爲是同樣的。也只有在這種狀況下,咱們才能夠將方法的實如今二者中互換。

「行爲等價」是對於「旁觀者」來講的——就是客戶。爲了讓實現方法能夠發生改動,咱們就須要一個規格說明要求客戶遵照某一些制約/前置條件。

因此,咱們的規格說明多是這樣的:

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

閱讀小練習

Behave nicely

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

假設客戶只關心val是否在a中出現了一次。在這種狀況下,findFirstfindLast 的行爲等價嗎?

Yes

Best behavior

如今來改變一下規格說明,假設客戶對返回值要求:

  • 若是vala中,返回任何索引i ,使得a[i] == val
  • 不然,返回一個不在a索引範圍內的整數j

在這種狀況下,findFirstfindLast 的行爲等價嗎?

Yes

<br />

規格說明的結構

一個規格說明含有如下兩個「條款」:

  • 一個前置條件,關鍵詞是requires
  • 一個後置條件,關鍵詞是effects

其中前置條件是客戶的義務(誰調用的這個方法)。它確保了方法被調用時所處的狀態。

然後置條件是實現者的義務。若是前置條件獲得了知足,那麼該方法的行爲應該符合後置條件的要求,例如返回一個合適的值,拋出一個特定的異常,修改一個特定的對象等等。

若是前置條件不知足的話,實現也不須要知足後置條件——方法能夠作任何事情,例如不終止而是拋出一個異常、返回一個任意的值、作一個任意的修改等等。

閱讀小練習

Logical implication

思考下面這個規格說明

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

做爲find的實現者,下面哪些行爲是合法的?

  • [x] 若是arr爲空,返回0

  • [x] 若是arr爲空,拋出一個異常

  • [x] 若是valarr出現了兩次,拋出一個異常

  • [x] 若是valarr出現了兩次,將arr中的元素都設置爲0,而後拋出一個異常

  • [x] 若是arr不爲空可是val沒有出現,選取一個隨機的索引,將其對應的元素設置爲val ,而後返回這個索引

  • [x] 若是arr[0]val ,繼續檢查剩下的元素,返回索引最高的那個val對飲的索引(沒有再次找到val就返回0)

Logical implementation

做爲find的實現者,當arr爲空的時候,爲何要拋出一個異常?

  • [ ] DRY(譯者注:Don't repeat yourself)
  • [x] 快速失敗/報錯
  • [ ] 避免幻數
  • [ ] 一個變量只有一個目的
  • [ ] 避免全局變量
  • [ ] 返回結果

Java中的規格說明

有一些語言(例如 Eiffel ),將前置條件和後置條件做爲語言的基礎之一,以便程序運行的時候(或者編譯器)能夠自動檢查客戶和實現者是否都遵循了規格說明。

Java並無這麼嚴格,可是它的靜態檢查也是屬於一種前置條件和後置條件的檢查(編譯器)。至於剩下的部分——那些不屬於數據類型範疇的約束——必須經過註釋寫在方法的前面,經過人們來檢查和保證。

Java對於 文檔註釋有一些傳統,例如參數的說明以 @param做爲開頭,返回的說明以@return 做爲開頭。你應該將前置條件放在@param 的地方,後置條件放在 @return的地方。例如,一個規格說明多是這樣:

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標準庫源碼中的規格說明註釋生成的. 一樣的,Eclipse也能夠根據你的規格說明產生對應的文檔),或者產生和Java API一個格式的 HTML 文檔 ,這對你和你的客戶來講都是頗有用的信息。

參考閱讀:

Java: Javadoc Comments

Oracle: How to Write Doc Comments

閱讀小練習

Javadoc

思考如下規格說明:

static boolean isPalindrome(String word)
- requires:
  word contains only alphanumeric characters
- effects:
  returns true if and only if word is a palindrome

對應的Javadoc註釋:

/*
 * Check if a word is a palindrome.
 * A palindrome is a sequence of characters
 * that reads the same forwards and backwards.
 * @param String word
 * @requires word contains only alphanumeric characters
 * @effects returns true if and only if word is a palindrome
 * @return boolean
 */

請問Javadoc中哪一行是有問題的?

  • [x] /*
  • [ ] * Check if a word is a palindrome.
  • [ ] * A palindrome is a sequence of characters
  • [ ] * that reads the same forwards and backwards.
  • [x] * @param String word
  • [x] * @requires word contains only alphanumeric characters
  • [x] * @effects returns true if and only if word is a palindrome
  • [x] * @return boolean
  • [ ] */

Concise Javadoc specs

思考下面這個規格說明Javadoc,判斷每一句的做用(逆序):

/**
 * Calculate the potential energy of a mass in Earth's gravitational field.
 * @param altitude altitude in meters relative to sea level
 * @return potential energy in joules
 */
static double calculateGravitationalPotentialEnergy(double altitude);

static double calculateGravitationalPotentialEnergy(double altitude);

  • [ ] 前置條件

  • [ ] 後置條件

  • [x] 是前置條件也是後置條件

  • [ ] 都不是

@return potential energy in Joules

  • [ ] 前置條件

  • [x] 後置條件

  • [ ] 是前置條件也是後置條件

  • [ ] 都不是

@param altitude altitude in meters relative to sea level

  • [x] 前置條件

  • [ ] 後置條件

  • [ ] 是前置條件也是後置條件

  • [ ] 都不是

Calculate the potential energy of a mass in Earth's gravitational field.

  • [ ] 前置條件
  • [ ] 後置條件
  • [ ] 是前置條件也是後置條件
  • [x] 都不是

Null 引用

在Java中,對於對象和數組的引用能夠取一個特殊的值null ,它表示這個這個引用尚未指向任何對象。Null值在Java類型系統中是一個「不幸的黑洞」。

原始類型不能是null

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

咱們能夠給非原始類型的變量賦予null值:

String name = null;
int[] points = null;

在編譯期的時候,這是合法的。可是若是你嘗試調用這個null對象的方法或者訪問它裏面對應的數值,發產生一個運行時錯誤:

name.length()   // throws NullPointerException  
points.length   // throws NullPointerException

要注意是,null並不等於「空」,例如一個空的字符串""或者一個空的數組。對於一個空的字符串或者數組,你能夠調用它們的方法或者訪問其中的數據,只不過它們對應的元素長度是0罷了(調用 length() )。而對於一個指向null的String類型變量——它什麼都不是:調用 length() 會產生一個NullPointer­Exception.

另外要注意一點,非原始類型的聚合類型例如List可能不指向null可是它的元素可能指向null

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

若是有人嘗試使用這些爲null的元素,報錯依然會發生。

使用Null值很容易發生錯誤,同時它們也是不安全的,因此在設計程序的時候儘量避開它們。在這門課程中——事實上在大多數好的Java編程中——一個約定俗成規矩就是參數和返回值不是null。 因此每個方法都隱式的規定了前置條件中數組或者其餘對象不能是null,同時後置條件中的返回對象也不會是null值(除非規格說明顯式的說明了可能返回null,不過這一般不是一個好的設計)。總之,避免使用null!

在Java中你能夠在類型中顯式的禁用null , 這樣會在編譯期和運行時自動檢查null值

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

Google 也對null的使用進行了一些討論,其中說到:

不嚴謹的使用null能夠致使各類各樣的bug。經過統計Google的代碼庫,咱們發現有95%的聚合類型不該該有任何null值,若是利用這個性質快速失敗的話比默默接受這些null值更能幫助開發。

另外,null值是有歧義的。一般很難判斷一個null的返回值意味着什麼——例如, Map.get(key) 可能在key對應的value是null的時候返回null,也多是由於value不存在而返回null。null能夠意味着失敗,也能夠意味着成功,它能夠是任何東西。使用非null的值可以使得你的代碼更加清晰易懂。

譯者注:"這是我犯的一個巨大錯誤" - Sir C. A. R. Hoare, null引用的發明者

閱讀小練習

NullPointerException accessing exercise.name()

下面哪些變量能夠是null

  • [ ] int a;

  • [ ] char b;

  • [ ] double c;

  • [x] int[] d;

  • [x] String e;

  • [x] String[] f;

  • [ ] Double g;

  • [x] List<Integer> h;

  • [x] final MouseTrap i;

  • [x] static final String j;

There are null exercises remaining

public static String none() {
    return null;          // (1)
}

public static void main(String[] args) {
    String a = none();    // (2)
    String b = null;      // (3)
    if (a.length() > 0) { // (4)
        b = a;            // (5)
    }
    return b;             // (6)
}

哪一行有靜態錯誤? -> 6

若是們將上一個問題的行註釋掉,而後運行 main

哪一行會有運行時錯誤? -> 4

規格說明應該說些什麼

一個規格說明應該談到接口的參數和返回的值,可是它不該該談到局部變量或者私有的(private)內部方法或數據。這些內部的實現應該在規格說明中對讀者隱藏。

在Java中,規格說明的讀者一般不會接觸到實現的源碼,應爲Javadoc工具經過你的源碼自動生成對應的規格說明並渲染成HTML。

<br />

測試與規格說明

在測試中,咱們談到了黑盒測試意味着僅僅經過規格說明構建測試,而白盒測試是經過代碼實現來構建測試(譯者注:閱讀03「測試」)。可是要特別注意一點:即便是白盒測試也必須遵循規格說明。 你的實現也許很依賴前置條件的知足,不然方法就會有一個未定義的行爲。而你的測試是不能依賴這種未定義的行爲的。測試用例必須尊徐規格說明,就像每個客戶同樣。

例如,假設你正在測試find,它的規格說明以下:

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

這個規格說明已經很明顯的要求了前置條件——val必須在arr中存在,並且它的後置條件很「弱」——沒有規定返回哪個索引,若是在arr中有多個val的話。甚至若是你的實現就是老是返回最後一個索引,你的測試用例也不能依賴這種行爲。

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()

那麼白盒測試意味着什麼呢?若是它不能違背規格說明的話?它意味着你能夠經過代碼的實現去構建不一樣的測試用例,以此來測試不一樣的實現,可是依然要檢查這些測試用例符合規格說明。

測試單元

回想在閱讀03「測試」 中的web search例子:

/** @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
    ...
}

一個好的單元測試應該僅僅關注於一個規格說明。咱們的測試不該該依賴於另外一個要測試的單元。例如上面例子中,當咱們在對 extractWords 測試時,就不該該使用getWebPage 的輸出做爲輸入,由於若是getWebPage 發生了錯誤, extractWords 的行爲極可能是未定義的。

而對於一個好的綜合測試(測試多個模塊),它確保的是各個模塊之間是兼容的:調用者和被調用者之間的數據輸入輸出應該是符合要求的。**同時綜合測試不能取代系統的單元測試,由於各個模塊的輸出集合極可能在輸入空間中沒有表明性。**例如咱們只經過調用 makeIndex測試extractWords .而extractWords 的輸出又不能覆蓋掉 makeIndex的不少輸入空間,這樣咱們之後在別處複用 makeIndex的時候,就極可能產生意想不到的錯誤。

<br />

改變對象方法的規格說明

咱們在以前的閱讀材料中談到了可改變的對象 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 引用」提到過,這還有一個隱含的前置條件:list1list2都不是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是隱式的不被容許的,咱們也隱式的規定改變對象(mutation)是不被容許的,除非顯式的聲明 。例如 to­Lower­Case 的規格說明中就沒有談到該方法會不會改變參數對象(不會改變),而sort中就顯式的說明了。

READING EXERCISES閱讀小練習

What’s in a spec?

下面哪一些選項是屬於規格說明的?

  • [x] 返回類型

  • [x] 返回值的範圍

  • [x] 參數個數

  • [x] 參數種類

  • [x] 對參數的限制

gcd 1

Alice 寫了以下代碼:

public static int gcd(int a, int b) {
    if (a > b) {
        return gcd(a-b, b);
    } else if (b > a) {
        return gcd(a, b-a);
    }
    return a;
}

Bob 寫了以下對應測試:

@Test public void gcdTest() {
    assertEquals(6, gcd(24, 54));
}

測試經過了!如下哪些說法是正確的?

Alice 應該在前置條件中加上 a > 0 -> True

Alice 應該在前置條件中加上 b > 0 -> True

Alice 應該在後置條件中加上 gcd(a, b) > 0 -> False

Alice 應該在後置條件中加上 a and b are integers -> False

gcd 2

若是Alice 在前置條件中加上 a > 0 , Bob 應該測試負數 a -> False

若是Alice 沒有在前置條件中加上 a > 0 , Bob 應該測試負數 a -> True

<br />

異常

如今咱們來討論一下如何處理異常的狀況,而且這種處理既能遠離bug又能易於理解。

一個方法的標識(signature)包含它的名字、參數類型、返回類型,同時也包含該方法能觸發的異常。

參考閱讀: Exceptions in the Java Tutorials.

報告bug的異常

你可能已經在Java編程中遇到了一些異常,例如 ArrayIndex­OutOfBounds­Exception (數組訪問越界)或者 Null­Pointer­Exception (訪問一個null引用的對象)。這些異常一般都是用來報告你代碼裏的bug ,同時它們報告的信息也能幫助你修復bug。

ArrayIndex­OutOfBounds-Null­Pointer­Exception 大概是最多見的異常了,其餘的例子有:

  • ArithmeticException, 當發生計算錯誤時拋出,例如除0。
  • NumberFormatException, 數字的類型不匹配的時候拋出,例如你向Integer.parseInt 傳入一個字符長而不是一個整數。

報告特殊結果的異常

異常不只被用來報告bug,它們也被用來提高那些包含特殊結果的代碼的結構。

不幸的是,一個常見的處理特殊結果的方法就是返回一個特殊的值。你在Java庫中經常能發現這樣的設計:當你指望一個正整數的時候,特殊結果會返回一個-1;當你指望一個對象的時候,特殊結果會返回一個null 。這樣的方法若是謹慎使用也還OK,可是它有兩個問題。首先,它加劇的檢查返回值的負擔。其次,程序員極可能會忘記檢查返回值(咱們待會會看到經過使用異常,編譯器會幫助你處理這些問題)。

同時,找到一個「特殊值」返回並非一件容易的事。如今假設咱們有一個 BirthdayBook 類,其中有一個lookup方法:

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

(LocalDate 是Java API的一個類.)

若是name在這個BirthdayBook中沒有入口,這個方法該如何返回呢?或許咱們能夠找一個永遠不會被人用到的日期。糟糕的程序員或許會選擇一個9/9/99,畢竟他們以爲沒有人會在這個世紀結束的時候使用這個程序。((事實上,它們錯了

這裏有一個更好的辦法,就是拋出一個異常:

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
}

如今咱們就不須要使用「特殊」的返回值來通報特殊狀況了,調用者也不須要再檢查返回值。

閱讀小練習

1st birthday

假設咱們在使用 BirthdayBook 中的 lookup 方法,它可能會拋出 NotFoundException.

若是「Elliot」不在birthdays裏面(birthdays已經初始化了,並指向了一個對象),下面這些代碼會發生什麼?

try {
    LocalDate birthdate = birthdays.lookup("Elliot");
}

運行時報錯: NotFoundException

2nd birthday

try {
    LocalDate birthdate = birthdays.lookup("Elliot");
} catch (NotFoundException nfe) {
    birthdate = LocalDate.now();
}

靜態錯誤: undeclared variable

3rd birthday

try {
    LocalDate birthdate = birthdays.lookup("Elliot");
} catch (NotFoundException nfe) {
    throw new DateTimeException("Missing reference birthday", nfe);
}

(DateTimeException is provided by the Java API.)

運行時報錯: DateTimeException

<br />

已檢查(Checked)異常和未檢查(Unchecked)異常

咱們已經看到了兩種不一樣目的的異常:報告特殊的結果或者報告bug。一個通用的規則是,咱們用已檢查的異常來報告特殊結果,用未檢查的異常來報告bug。在後面一節中,咱們會詳細介紹一些。

已檢查 異常」這個名字是由於編譯器會檢查這種異常是否被正確處理:

  • 若是一個方法拋出一個已檢查異常,這種可能性必須添加到它的標識中。例如 Not­Found­Exception就是一個已檢查異常,這也是爲何它的生命的結尾有一個 throws Not­Found­Exception.
  • 若是一個方法調用一個可能拋出已檢查異常的方法,該方法要麼處理它,要麼在它的標識中說明該異常(交給它的調用者處理)。

因此若是你調用了 BirthdayBook中的 lookup 並忘記處理 Not­Found­Exception ,編譯器就會拒絕你的代碼。這很是有用,由於它確保了那些可能產生的特殊狀況(異常)被處理。

相應的,未檢查異經常使用來報告bug。這些異常並不期望被代碼處理(除了一些頂層的代碼),同時這樣的異常也不該該被顯式拋出,例如邊界溢出、null值、非法參數、斷言失敗等等。一樣,編譯器不會檢查這些異常是否被 try-catch 處理或者用 throws 拋給上一層調用者。(Java容許你將未檢查的異常做爲方法的標識,不過這沒有什麼意義,咱們也不建議這麼作)

異常中有可能有和異常相關的信息。(若是構建體沒有提供,引用這個信息(String)的值將會是null

Throwable 類層次

爲了理解Java是如何定義一個異常是已檢查仍是未檢查的,讓咱們看一看Java異常類的層次圖:

Throwable 是一個可以被拋出和捕獲的對象對應的類。Throwable的實現記錄了棧的結構(異常被拋出的時候),同時還有一個描述該異常的消息(可選)。任何被拋出或者捕獲的異常對象都應該是 Throwable的子類。

ErrorThrowable 的一個子類,它被保留用於Java運行系統的異常,例如 StackOverflow­ErrorOutOfMemory­Error.Errors應該被認爲是不可恢復的,而且通常不會去捕獲它。(這裏有一個特例, Assertion­Error 也是屬於Error 的,即便它反映的是用戶代碼錯誤)

下面描述了在Java中如何區別已檢查異常和未檢查異常:

  • RuntimeException, Error, 以及它們的子類都是未檢查異常。編譯器不會要求它們被throws修飾,也不會要求它們被捕獲。
  • 全部其餘的throwables—— Throwable, Exception和其餘子類都是已檢查異常。編譯器會要求它們被捕獲或者用throws傳給調用者處理。

當你定義你本身的異常時,你應該使它要麼是 RuntimeException 的子類(未檢查異常),要麼是 Exception 的子類(已檢查異常)。程序員一般不會生成 Error 或者 Throwable的子類,由於它們一般被Java保留使用。

閱讀小練習

Get to the point

假設咱們寫了一個尋找兩點之間路徑的方法:

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

In the postcondition, we say that findPath will search for paths only up to a bounded length (set elsewhere), and that it will throw an exception if it fails to find one.在前置條件中,咱們要求findPath 搜索的範圍是有限的(有邊界)。若是該方法沒有找到一個路徑,它就會拋出一個異常。

在設計方法時,如下哪個異常是合理的?

  • [ ] 已檢查異常 NoPathException
  • [ ] 未檢查異常 NoPathException
  • [x] 已檢查異常 PathNotFoundException
  • [ ] 未檢查異常 PathNotFoundException

Don’t point that thing at me

當咱們定義該異常時,應該使它是哪個類的子類?

  • [ ] Throwable
  • [x] Exception
  • [ ] Error
  • [ ] RuntimeException

<br />

設計異常時應該考慮的事情

咱們以前給了一個通用規則——對於特殊的結果(預測到的)使用已檢查異常,對於bug使用未檢查異常(意料以外)。這說得通,不過,在Java中異常並無這麼「輕量化」。

除了對性能有影響,Java中的異常會帶來使用上的開銷:若是你要設計一個異常,你必須建立一個新的類。若是你調用一個可能拋出已檢查異常的方法,你必須使用 try-catch 處理它(即便你知道這個異常必定不會發生)。後一種狀況致使了一個進退兩難的局面。例如,你設計了一個抽象隊列,你是應該指望使用者在循環pop的時候檢查隊列是否爲空(做爲前置條件),仍是讓使用者自由的pop,最後拋出一個異常呢?若是你選擇拋出異常,那麼即便使用者每次都檢查隊列不爲空才pop,他仍是要對這個異常進行處理。

因此咱們提煉出另外一個明確的規則:

  • 對於意料以外的bug使用未檢查的異常,或者對於使用者來講避免異常產生的狀況很是容易(例如檢查一個隊列是否爲空)。
  • 其餘的狀況咱們使用已檢查異常。

這裏舉出一些例子:

  • 當隊列是空時,Queue.pop() 會拋出一個未檢查異常。由於檢查隊列是否爲空對於用戶來講是容易的。(例如 Queue.size() or Queue.isEmpty().)
  • 當沒法鏈接互聯網時,Url.getWebPage() 拋出一個已檢查異常 IOException ,由於客戶可能沒法肯定調用的時候網絡是否好使。
  • x沒有整數開方時,int integerSquareRoot(int x) 拋出一個已檢查異常 Not­Perfect­Square­Exception ,由於對於調用者來講,判斷一個整數是否爲平方是困難的。

這些使用異常的「痛楚」也是不少Java API使用null引用或特殊值做爲返回值的緣由。額.....若是你嚴謹認真的使用這些返回值,這也不是什麼糟糕的事情。

在規格說明中應該如何聲明異常

由於異常也能夠歸爲方法的輸出,因此咱們應該在規格說明的後置條件中描述它。Java中是以 @throws 做爲Javadoc中異常註釋的。Java也可能要求函數聲明時用throws標出可能拋出的異常 。這一節會討論何時使用這兩種方法。

對於非檢查的異常,因爲它們描述的是意料以外的bug或者失敗,不屬於後置條件,因此不該該用 @throwsthrows修飾它們。例如, NullPointerException就不該該在規格說明中列出——咱們的前置條件已經隱式(顯式)的禁止了null值,這意味着若是使用者傳入一個null,咱們能夠沒有任何警告的扔出一個異常。例以下面這個規格說明,就沒有提到 NullPointerException

/**
 * @param lst list of strings to convert to lower case
 * @return new list lst' where lst'[i] is lst[i] converted to lowercase
 */
static List<String> toLowerCase(List<String> lst)

而對於報告特殊結果的異常,咱們應該在Javadoc中用 @throws 表示出來,並明確什麼狀況下會致使這個異常的拋出。另外,若是是一個已檢查異常,Java會要求在函數聲明的時候用 throws 標識出來。例如,假設 NotPerfectSquareException 是一個已檢查聲明:

/**
 * Compute the integer square root.
 * @param x value to take square root of
 * @return square root of x
 * @throws NotPerfectSquareException if x is not a perfect square
 */
int integerSquareRoot(int x) throws NotPerfectSquareException;

對於報告特殊結果的未檢查異常,Java容許可是不要求使用 throws 在聲明中標識出。可是這種狀況下一般不要使用 throws 由於這會使得閱讀者困惑(覺得它是一個已檢查異常)。例如,假設你將EmptyQueueException定義爲未檢查異常。那麼你應該在Javadoc中使用 @throws對其進行說明,可是不要在函數聲明中將其標識出:

/**
 * Pops a value from this queue.
 * @return next value in the queue, and removes the value from the queue
 * @throws EmptyQueueException if this queue is empty
 */
int pop();

閱讀小練習

Throw all the things!

閱讀如下代碼並分析 Thing 對象:

static Set<Thing> ALL_THE_THINGS;

static void analyzeEverything() {
    analyzeThingsInOrder();
}

static void analyzeThingsInOrder() {
    try {
        for (Thing t : ALL_THE_THINGS) {
            analyzeOneThing(t);
        }
    } catch (AnalysisException ae) {
        return;
    }
}

static void analyzeOneThing(Thing t) throws AnalysisException {
    // ...
    // ... maybe go off the end of an array
    // ...
}

AnalysisException 是一個 已檢查 異常.

analyzeEverything可能會拋出哪一些異常?

  • [x] ArrayIndexOutOfBoundsException

  • [ ] IOException

  • [x] NullPointerException

  • [ ] AnalysisException

  • [ ] OutOfMemoryError

A terrible thing

若是 analyzeOneThing 本身會拋出一個 AnalysisException 異常,會發生什麼?

  • [ ] 程序可能會崩潰

  • [x] 咱們可能不能調用任何 analyzeOneThing

  • [ ] 咱們可能會調用幾回 analyzeOneThing

<br />

總結

最後,再作一組練習看看你對今天學的內容理解的如何。

閱讀小練習

拼字遊戲 1

/* Requires: tiles has length 7 & contains only uppercase letters.
           crossings contains only uppercase letters, without duplicates.
 Effects: Returns a list of words where each word can be made by taking
          letters from tiles and at most 1 letter from crossings.*/
public static List<String> scrabble(String tiles, String crossings) {
    if (tiles.length() != 7) { throw new RuntimeException(); }
    return new ArrayList<>();
}

scrabble的後置條件有哪些?

  • [ ] tiles 中只有大寫字母
  • [ ] crossings 中字母沒有重複
  • [ ] scrabble 須要兩個參數
  • [x] scrabble 返回字符串列表

scrabble的前置條件有哪些?

  • [x] tiles 長度爲 7
  • [x] crossings 是一個大寫的字符串
  • [x] scrabble參數的類型是 StringString
  • [ ] scrabble 返回一個空的 ArrayList

拼字遊戲 2

規格說明中的哪一部分是會被靜態檢查的?

  • [ ] tiles 中只有大寫字母
  • [ ] crossings 中字母沒有重複
  • [ ] 當 tiles.length() != 7, scrabble 拋出 RuntimeException
  • [x] scrabble 接收兩個參數

scrabble 的實現知足了規格說明嗎?

  • [ ] 是
  • [ ] 否, 由於它會在沒法獲取tiles長度時拋出 RuntimeException
  • [x] 否,由於即便咱們傳入一個能夠組合成詞的tiles和crossings,它也會返回一個空列表。

一個規格說明就好像是實現者和使用者之間的防火牆。它使得分別開發成爲可能:使用者能夠在不理解源代碼的狀況下使用模塊,實現者能夠在不知道模塊如何被使用的狀況下實現模塊。

如今讓咱們想一想今天的內容和咱們三大目標之間的聯繫:

  • 遠離bug. 一個好的規格說明會清晰明確的要求實現者和使用者遵照相關的制約。而Bug常常是由於實現者和使用者對於接口的理解衝突致使的,規格說明會明顯的減少這種可能性。在模塊中使用一些可以交由機器檢查的特性,例如靜態檢查、異常等而不是註釋會進一步下降bug的可能性。
  • 易讀性. 一個簡潔準確的規格說明會比源代碼自己更易讀易懂。
  • 可改動性. 規格說明在實現者和使用者之間創建了一個「契約」——只要這兩方遵照這份「契約」,他們能夠對本身的代碼進行任何改變。

</font>

相關文章
相關標籤/搜索