臆想實現重溫
回想一下 上次接口的 臆想實現是一個合法的實現,但不滿足接口規范的某些未經檢查的方面。我們考慮一下下面的堆棧接口,以及許多未被其單獨的類型簽名捕獲的不變量:
清單 1. 一個堆棧接
public interface Stack {
public Object pop();
public void push(Object top);
public boolean isEmpty();
}
例如,請考慮我們希望任意堆棧實現都遵守的下列規則:
如果一個對象 o 被壓進堆棧 s ,且在堆棧上執行的下一個操作是 pop ,則該操作的返回值將為 o 。
對於一個給定的堆棧 s ,如果 s.isEmpty() 的返回值為 true ,且在堆棧上執行的下一個操作是 pop ,那麼對 pop 的調用將拋出一個 RuntimeException 異常。
盡管 Java 語言在接口不變量的規范方面有限制,但指定象這樣的添加的接口不變量還是可能的。就象我們將看到的那樣,您可以用這種可以自動檢查以查看接口實現是否滿足它們的方法來指定這些不變量。
斷言
向程序中添加 斷言是一個很老但未被充分利用的好主意。這種思想是在程序執行的不同階段置入某些條件的布爾檢查。根據 design by contract思想,斷言應該被包含在接口實現與外部客戶達成的協議中。通常情況下,斷言使用下面 3 種變化形式之一:
前提條件檢查在進入代碼塊之前某些條件是否成立。
後置條件檢查在退出代碼塊時某些條件是否成立。
不變量檢查在代碼塊執行 期間是否具備某些條件。由於它們的代價問題,這類斷言極少以其最常規的形式受支持。相反,允許程序員檢查代碼塊執行的某個 點是否具備各種條件。
對於不給定實現代碼的接口規范,前兩種是最有用的。
引入了基於 Java 的預處理器,如 iContract 之後,就有可能將斷言放入源代碼中並使它們自動轉換為進行檢查以確保斷言永遠有效的 Java 代碼。由於經這些工具處理過的斷言在原始文件中被指定為 Javadoc 注釋,我們就可以很輕易地編譯該文件,而無須運行預處理器,為未檢查任何斷言的代碼制作一個“產品”副本。但用這種方法除去斷言太過頻繁。除對性能影響至關重要的部分之外,在程序的其它所有部分,斷言檢查的系統開銷都不會很大。將斷言留在程序中,可使得診斷來自最終用戶的錯誤報告更加容易(肯定 將會有錯誤報告)。
在我們的堆棧示例中,我們可以向 pop pop 添加一個斷言,以確保 pop 永遠不會在空堆棧上被調用:
清單 2. 測試堆棧接口的一個斷言
public interface Stack {
/**
*@pre ! this.isEmpty()
*/
public Object pop();
public void push(Object top);
public boolean isEmpty();
}
向接口代碼中添加類似這樣的斷言有助於確保當調用實現方法時,這種附加的不變量有效。因為它們可被編譯成代碼,所以它們是快速診斷臆想實現發生的高效方法。而且,它們還可作為接口的附加文檔。但是,由於它們是嚴格的函數布爾表達式,它們被限制在自己的表達中 ― 例如,我們將如何把堆棧的第一條規則編寫到斷言中?與類型聲明相同,斷言自身表達能力不夠,無法捕捉我們可能希望在接口上指定的全部規則。由於這個原因,最好是將它們與單元測試協力使用。
單元測試
程序員可為接口提供的另一個規范是一套單元測試。使用單元測試框架,如 JUnit(請參閱 參考資料),可以很容易地檢查這套單元測試對於接口的所有實現是否是支持的。不可過分強調能夠消除臆想實現發生的單元測試的范圍。實際上,單元測試是一種提供這些額外的不變量的限制規范的出色方法。一個帶有一套附隨單元測試的接口為實現人員提供了一種檢查是否滿足了接口的額外不變量的方法。筆者強烈推薦為將由外部客戶使用的所有接口提供這種測試,他們會為此感謝您的。即使是內部接口,有了這種附隨的測試套件實現起來也要容易得多。
當然,與類型聲明不同,有限的幾套測試在檢查實現時無法遍及所有可能的輸入。但單元測試的檢查已經足夠徹底,使我們能夠適度地期望它們捕捉到不變量的大多數不合法錯誤。當然,它們比類型簽名更富於表現力。
接口的一套單元測試還可以被視為該接口的一種文檔形式。在單元測試中描述不變量時要比在文字描述中精確得多。例如,考慮下面檢查堆棧的不變量的測試:
清單 3. 堆棧的單元測試
public void testPushAndPop() {
Stack s = new MyStack();
Object o = new Object();
s.push(o);
assertTrue(o == s.pop());
}
public void testPopOnEmpty() {
Stack s = new MyStack();
assertTrue(s.isEmpty());
try {
s.pop();
}
catch (RuntimeException e) {
return;
}
throw new RuntimeException("pop on empty stack does not fail");
}
將這些測試與我們在開頭 用英語定義的堆棧的不變量進行比較。與單元測試不同,這些英語描述使得許多東西的解釋都是開放的。例如,當第一條規則聲明“該操作的返回值將為 o ”時,是否意味著該返回值與壓入對象滿足 equals 測試,或實際上將滿足 == ?單元測試把這一點搞得很清楚。
關於這些測試要注意的其它幾點:
它們很小,又很直接了當。因為接口的單元測試還可作為文檔,主要是要盡可能容易閱讀。
因為它們可以是任意 Java 代碼,所以允許我們測試實現的復雜行為。例如,注意第二種方法實際上測試應該拋出異常時有沒有拋出,如果未拋出異常,則測試失敗!
單元測試如此富於表現力當然會有優勢。它使我們能夠捕獲我們希望指定的接口的任意規則的本質。這種表現力也有缺點:我們可以指定規則的示例,但我們也注意到無法使用單元測試檢查規則是否適合程序所有可能的輸入。
現在我們可以考慮用接口規范的三種語言(它們是,單元測試語言、斷言語言和類型系統)來形成表達的層次。層次每上一級都要以語言的易測性下降為代價。由於經常都是這種情況,在表達性和易測性之間就存在基本矛盾。通過為接口加入幾種這樣的規范語言,就有可能達到兩全其美的效果。
結論
如這些示例所示,斷言和單元測試為接口提供了可檢查規范,是避免臆想實現的高效方法。而且,它們檢查的不變量的種類是互補的。理想情況下,一個接口是兩者都包含的。
注意,這個規范的內涵並不只是捕捉完整的實現中的錯誤,它實際上還幫助自诩為實現人員的人確保 在他編程時正在正確地實現接口。這不僅可以促進生產率,還可以使程序員更加高興。發送代碼,使其通過一個自動檢查工具 ― 並看著它通過總是很不錯的。